Replace external berth dashboard with native Vue interface
- Replace iframe embed with full-featured berth status dashboard - Add BerthDetailsModal and BerthStatusBadge components - Implement search, filtering, and multiple view modes - Add berth management API endpoints (get-by-id, update) - Include measurement conversion utilities and type definitions - Provide status summaries and visual berth overview
This commit is contained in:
parent
0b881a2588
commit
0e85cb40bc
|
|
@ -0,0 +1,735 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-model="isVisible"
|
||||
:fullscreen="mobile"
|
||||
:max-width="mobile ? undefined : 1000"
|
||||
scrollable
|
||||
persistent
|
||||
:transition="mobile ? 'dialog-bottom-transition' : 'dialog-transition'"
|
||||
>
|
||||
<v-card v-if="berth">
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<h3 class="text-h5">{{ berth['Mooring Number'] || 'Unknown Berth' }}</h3>
|
||||
<p class="text-body-2 text-grey-darken-1 mb-0">
|
||||
Area {{ berth.Area }} - {{ berth.Status }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<v-btn
|
||||
v-if="!editMode"
|
||||
@click="editMode = true"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
prepend-icon="mdi-pencil"
|
||||
>
|
||||
Edit
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="editMode"
|
||||
@click="saveChanges"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save"
|
||||
:loading="saving"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="editMode"
|
||||
@click="cancelEdit"
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
prepend-icon="mdi-close"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="closeModal"
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-container fluid class="pa-0">
|
||||
<!-- Basic Information Section -->
|
||||
<div class="section mb-6">
|
||||
<h4 class="text-h6 mb-3 text-primary">Basic Information</h4>
|
||||
<v-row>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Mooring Number']"
|
||||
label="Mooring Number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Mooring Number:</span>
|
||||
<span class="field-value">{{ berth['Mooring Number'] || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth.Area"
|
||||
:items="areaOptions"
|
||||
label="Area"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Area:</span>
|
||||
<span class="field-value">{{ berth.Area || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth.Status"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Status:</span>
|
||||
<BerthStatusBadge :status="berth.Status" />
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Specifications Section -->
|
||||
<div class="section mb-6">
|
||||
<h4 class="text-h6 mb-3 text-primary">Specifications</h4>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Nominal Boat Size']"
|
||||
label="Nominal Boat Size (ft/m)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="Enter in feet or meters (e.g., '50ft' or '15.24m')"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Nominal Boat Size:</span>
|
||||
<span class="field-value">
|
||||
{{ displayMeasurement(berth['Nominal Boat Size']) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Water Depth']"
|
||||
label="Water Depth (ft/m)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="Enter in feet or meters"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Water Depth:</span>
|
||||
<span class="field-value">
|
||||
{{ displayMeasurement(berth['Water Depth']) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model="editedBerth.Length"
|
||||
label="Length (ft/m)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Length:</span>
|
||||
<span class="field-value">
|
||||
{{ displayMeasurement(berth.Length) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model="editedBerth.Width"
|
||||
label="Width (ft/m)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Width:</span>
|
||||
<span class="field-value">
|
||||
{{ displayMeasurement(berth.Width) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model="editedBerth.Depth"
|
||||
label="Depth (ft/m)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Depth:</span>
|
||||
<span class="field-value">
|
||||
{{ displayMeasurement(berth.Depth) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Infrastructure Section -->
|
||||
<div class="section mb-6">
|
||||
<h4 class="text-h6 mb-3 text-primary">Infrastructure</h4>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Side Pontoon']"
|
||||
:items="sidePontoonOptions"
|
||||
label="Side Pontoon"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Side Pontoon:</span>
|
||||
<span class="field-value">{{ berth['Side Pontoon'] || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model.number="editedBerth['Power Capacity']"
|
||||
label="Power Capacity (A)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
type="number"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Power Capacity:</span>
|
||||
<span class="field-value">{{ berth['Power Capacity'] }}A</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model.number="editedBerth.Voltage"
|
||||
label="Voltage (V)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
type="number"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Voltage:</span>
|
||||
<span class="field-value">{{ berth.Voltage }}V</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Mooring Type']"
|
||||
:items="mooringTypeOptions"
|
||||
label="Mooring Type"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Mooring Type:</span>
|
||||
<span class="field-value">{{ berth['Mooring Type'] || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth.Access"
|
||||
:items="accessOptions"
|
||||
label="Access"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Access:</span>
|
||||
<span class="field-value">{{ berth.Access || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Section -->
|
||||
<div class="section mb-6">
|
||||
<h4 class="text-h6 mb-3 text-primary">Hardware</h4>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Cleat Type']"
|
||||
:items="cleatTypeOptions"
|
||||
label="Cleat Type"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Cleat Type:</span>
|
||||
<span class="field-value">{{ berth['Cleat Type'] || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Cleat Capacity']"
|
||||
:items="cleatCapacityOptions"
|
||||
label="Cleat Capacity"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Cleat Capacity:</span>
|
||||
<span class="field-value">{{ berth['Cleat Capacity'] || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Bollard Type']"
|
||||
:items="bollardTypeOptions"
|
||||
label="Bollard Type"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Bollard Type:</span>
|
||||
<span class="field-value">{{ berth['Bollard Type'] || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Bollard Capacity']"
|
||||
:items="bollardCapacityOptions"
|
||||
label="Bollard Capacity"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Bollard Capacity:</span>
|
||||
<span class="field-value">{{ berth['Bollard Capacity'] || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Pricing & Orientation -->
|
||||
<div class="section mb-6">
|
||||
<h4 class="text-h6 mb-3 text-primary">Details</h4>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model.number="editedBerth.Price"
|
||||
label="Price"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
type="number"
|
||||
prefix="$"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Price:</span>
|
||||
<span class="field-value">${{ formatPrice(berth.Price) }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-if="editMode"
|
||||
v-model="editedBerth['Bow Facing']"
|
||||
label="Bow Facing"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else class="field-display">
|
||||
<span class="field-label">Bow Facing:</span>
|
||||
<span class="field-value">{{ berth['Bow Facing'] || '—' }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Interested Parties Section -->
|
||||
<div class="section">
|
||||
<h4 class="text-h6 mb-3 text-primary">
|
||||
Interested Parties
|
||||
<v-chip
|
||||
v-if="interestedParties.length > 0"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ interestedParties.length }}
|
||||
</v-chip>
|
||||
</h4>
|
||||
|
||||
<div v-if="interestedParties.length === 0" class="text-center py-4 text-grey-darken-1">
|
||||
<v-icon size="48" class="mb-2">mdi-account-search-outline</v-icon>
|
||||
<p>No interested parties</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<v-card variant="outlined" class="overflow-hidden">
|
||||
<v-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Sales Status</th>
|
||||
<th>EOI Status</th>
|
||||
<th>Contract Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="party in interestedParties"
|
||||
:key="party.Id"
|
||||
class="clickable-row"
|
||||
@click="viewInterest(party)"
|
||||
>
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar size="24" color="primary" class="mr-2">
|
||||
<span class="text-caption">{{ getInitials(party['Full Name']) }}</span>
|
||||
</v-avatar>
|
||||
<span class="font-weight-medium">{{ party['Full Name'] }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<InterestSalesBadge
|
||||
v-if="party['Sales Process Level']"
|
||||
:salesProcessLevel="party['Sales Process Level']"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else class="text-grey-darken-1">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<EOIStatusBadge
|
||||
v-if="party['EOI Status']"
|
||||
:eoiStatus="party['EOI Status']"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else class="text-grey-darken-1">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<ContractStatusBadge
|
||||
v-if="party['Contract Status']"
|
||||
:contractStatus="party['Contract Status']"
|
||||
size="small"
|
||||
/>
|
||||
<span v-else class="text-grey-darken-1">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<v-btn
|
||||
@click.stop="viewInterest(party)"
|
||||
icon="mdi-open-in-new"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Interest Details Modal -->
|
||||
<InterestDetailsModal
|
||||
v-model="showInterestModal"
|
||||
:selected-interest="selectedInterest"
|
||||
@save="handleInterestSave"
|
||||
/>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import type { Berth, Interest, InterestedParty } from '@/utils/types';
|
||||
import {
|
||||
BerthStatus,
|
||||
BerthArea,
|
||||
SidePontoon,
|
||||
MooringType,
|
||||
CleatType,
|
||||
CleatCapacity,
|
||||
BollardType,
|
||||
BollardCapacity,
|
||||
Access
|
||||
} from '@/utils/types';
|
||||
import BerthStatusBadge from './BerthStatusBadge.vue';
|
||||
import InterestSalesBadge from './InterestSalesBadge.vue';
|
||||
import EOIStatusBadge from './EOIStatusBadge.vue';
|
||||
import ContractStatusBadge from './ContractStatusBadge.vue';
|
||||
import InterestDetailsModal from './InterestDetailsModal.vue';
|
||||
|
||||
// Simple debounce implementation
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const debounced = (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
berth: Berth | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'save', berth: Berth): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { mobile } = useDisplay();
|
||||
const toast = useToast();
|
||||
|
||||
const isVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const editMode = ref(false);
|
||||
const saving = ref(false);
|
||||
const editedBerth = ref<any>({});
|
||||
const showInterestModal = ref(false);
|
||||
const selectedInterest = ref<Interest | null>(null);
|
||||
|
||||
// Auto-save related
|
||||
const hasUnsavedChanges = ref(false);
|
||||
const autoSaveTimer = ref<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Store the debounced functions
|
||||
let autoSave: any = null;
|
||||
let debouncedSaveChanges: any = null;
|
||||
|
||||
// Initialize debounced functions
|
||||
const initializeDebouncedFunctions = () => {
|
||||
// Auto-save function (debounced)
|
||||
autoSave = debounce(async () => {
|
||||
if (!hasUnsavedChanges.value || !editedBerth.value || saving.value || !editMode.value) return;
|
||||
|
||||
console.log('Auto-saving berth...');
|
||||
await saveChanges(true); // Pass true to indicate auto-save
|
||||
}, 2000); // 2 second delay
|
||||
|
||||
// Debounced manual save
|
||||
debouncedSaveChanges = debounce(async () => {
|
||||
await saveChanges();
|
||||
}, 300); // 300ms delay to prevent multiple clicks
|
||||
};
|
||||
|
||||
// Initialize on component creation
|
||||
initializeDebouncedFunctions();
|
||||
|
||||
// Dropdown options
|
||||
const areaOptions = Object.values(BerthArea);
|
||||
const statusOptions = Object.values(BerthStatus);
|
||||
const sidePontoonOptions = Object.values(SidePontoon);
|
||||
const mooringTypeOptions = Object.values(MooringType);
|
||||
const cleatTypeOptions = Object.values(CleatType);
|
||||
const cleatCapacityOptions = Object.values(CleatCapacity);
|
||||
const bollardTypeOptions = Object.values(BollardType);
|
||||
const bollardCapacityOptions = Object.values(BollardCapacity);
|
||||
const accessOptions = Object.values(Access);
|
||||
|
||||
const interestedParties = computed(() => {
|
||||
return props.berth?.['Interested Parties'] || [];
|
||||
});
|
||||
|
||||
// Display measurement with both metric and imperial
|
||||
const displayMeasurement = (imperial: number): string => {
|
||||
if (!imperial) return '—';
|
||||
const metric = imperial * 0.3048;
|
||||
return `${metric.toFixed(2)}m / ${imperial}ft`;
|
||||
};
|
||||
|
||||
// Format price with thousands separator
|
||||
const formatPrice = (price: number): string => {
|
||||
if (!price) return '0';
|
||||
return new Intl.NumberFormat('en-US').format(price);
|
||||
};
|
||||
|
||||
// Get initials for avatar
|
||||
const getInitials = (name: string): string => {
|
||||
if (!name) return '?';
|
||||
const parts = name.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
// Watch for changes to trigger auto-save
|
||||
watch(
|
||||
() => editedBerth.value,
|
||||
(newValue, oldValue) => {
|
||||
if (newValue && oldValue && JSON.stringify(newValue) !== JSON.stringify(oldValue) && editMode.value) {
|
||||
hasUnsavedChanges.value = true;
|
||||
// Cancel any pending saves
|
||||
if (debouncedSaveChanges) debouncedSaveChanges.cancel();
|
||||
if (autoSave) autoSave();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Reset edit mode when berth changes
|
||||
watch(() => props.berth, (newBerth) => {
|
||||
if (newBerth) {
|
||||
editedBerth.value = { ...newBerth };
|
||||
hasUnsavedChanges.value = false;
|
||||
}
|
||||
editMode.value = false;
|
||||
}, { immediate: true });
|
||||
|
||||
const cancelEdit = () => {
|
||||
editMode.value = false;
|
||||
if (props.berth) {
|
||||
editedBerth.value = { ...props.berth };
|
||||
}
|
||||
};
|
||||
|
||||
const saveChanges = async (isAutoSave = false) => {
|
||||
if (!props.berth || !editedBerth.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await $fetch('/api/update-berth', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
berthId: props.berth.Id,
|
||||
updates: editedBerth.value
|
||||
}
|
||||
});
|
||||
|
||||
hasUnsavedChanges.value = false;
|
||||
|
||||
if (!isAutoSave) {
|
||||
toast.success("Berth saved successfully!");
|
||||
emit('save', { ...props.berth, ...editedBerth.value });
|
||||
editMode.value = false;
|
||||
} else {
|
||||
// For auto-save, just emit save to refresh parent
|
||||
emit('save', { ...props.berth, ...editedBerth.value });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save berth:', error);
|
||||
if (!isAutoSave) {
|
||||
toast.error("Failed to save berth. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isVisible.value = false;
|
||||
editMode.value = false;
|
||||
};
|
||||
|
||||
const viewInterest = (interest: InterestedParty) => {
|
||||
selectedInterest.value = interest;
|
||||
showInterestModal.value = true;
|
||||
};
|
||||
|
||||
const handleInterestSave = (interest: Interest) => {
|
||||
// Refresh berth data to get updated interest information
|
||||
// This could trigger a refresh in the parent component
|
||||
showInterestModal.value = false;
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
if (autoSaveTimer.value) {
|
||||
clearTimeout(autoSaveTimer.value);
|
||||
}
|
||||
// Cancel any pending auto-save
|
||||
if (autoSave) autoSave.cancel();
|
||||
if (debouncedSaveChanges) debouncedSaveChanges.cancel();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.field-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.clickable-row:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
:deep(.v-table) th {
|
||||
font-weight: 600;
|
||||
color: #424242;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<v-chip
|
||||
:color="statusColor"
|
||||
:size="size"
|
||||
variant="flat"
|
||||
class="font-weight-medium"
|
||||
>
|
||||
{{ status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { BerthStatus } from '@/utils/types';
|
||||
|
||||
interface Props {
|
||||
status: string;
|
||||
size?: 'x-small' | 'small' | 'default' | 'large' | 'x-large';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'small'
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case BerthStatus.Available:
|
||||
return 'success'; // Green
|
||||
case BerthStatus.Waitlist:
|
||||
return 'primary'; // Blue
|
||||
case BerthStatus.Reserved:
|
||||
return 'warning'; // Orange
|
||||
case BerthStatus.Sold:
|
||||
return 'error'; // Red
|
||||
default:
|
||||
return 'grey';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional styling if needed */
|
||||
</style>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
export const useMeasurementConversion = () => {
|
||||
// Convert feet to meters (NocoDB uses this formula: imperial * 0.3048)
|
||||
const feetToMeters = (feet: number): number => {
|
||||
return feet * 0.3048;
|
||||
};
|
||||
|
||||
// Convert meters to feet (reverse conversion for editing)
|
||||
const metersToFeet = (meters: number): number => {
|
||||
return meters / 0.3048;
|
||||
};
|
||||
|
||||
// Format measurement for display (show both units)
|
||||
const formatMeasurement = (imperial: number, unit: 'ft' | 'in' = 'ft') => {
|
||||
const metric = feetToMeters(imperial);
|
||||
return {
|
||||
imperial: `${imperial}${unit}`,
|
||||
metric: `${metric.toFixed(2)}m`,
|
||||
display: `${metric.toFixed(2)}m / ${imperial}${unit}`,
|
||||
imperialValue: imperial,
|
||||
metricValue: parseFloat(metric.toFixed(2))
|
||||
};
|
||||
};
|
||||
|
||||
// Format just metric value with precision
|
||||
const formatMetric = (meters: number): string => {
|
||||
return `${meters.toFixed(2)}m`;
|
||||
};
|
||||
|
||||
// Format just imperial value
|
||||
const formatImperial = (feet: number, unit: 'ft' | 'in' = 'ft'): string => {
|
||||
return `${feet}${unit}`;
|
||||
};
|
||||
|
||||
// Parse user input and convert to imperial for database storage
|
||||
const parseUserInput = (input: string): { imperial: number; metric: number } | null => {
|
||||
// Remove any non-numeric characters except decimal points
|
||||
const cleanInput = input.replace(/[^\d.]/g, '');
|
||||
const numericValue = parseFloat(cleanInput);
|
||||
|
||||
if (isNaN(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if input is likely metric (contains 'm') or imperial (contains 'ft' or just a number)
|
||||
const isMetric = input.toLowerCase().includes('m') && !input.toLowerCase().includes('ft');
|
||||
|
||||
if (isMetric) {
|
||||
// Convert metric to imperial for storage
|
||||
const imperial = metersToFeet(numericValue);
|
||||
return {
|
||||
imperial: parseFloat(imperial.toFixed(2)),
|
||||
metric: numericValue
|
||||
};
|
||||
} else {
|
||||
// Assume imperial, convert to metric for display
|
||||
const metric = feetToMeters(numericValue);
|
||||
return {
|
||||
imperial: numericValue,
|
||||
metric: parseFloat(metric.toFixed(2))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
feetToMeters,
|
||||
metersToFeet,
|
||||
formatMeasurement,
|
||||
formatMetric,
|
||||
formatImperial,
|
||||
parseUserInput
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-container fluid>
|
||||
<!-- Header Section -->
|
||||
<v-row class="mb-4 mb-md-6">
|
||||
<v-col cols="12" md="8">
|
||||
<h1 class="text-h5 text-md-h4 font-weight-bold mb-1 mb-md-2">
|
||||
Port Nimara Berths
|
||||
</h1>
|
||||
<p class="text-body-2 text-md-subtitle-1 text-grey-darken-1">
|
||||
Manage and view all berth information
|
||||
</p>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" class="d-flex justify-start justify-md-end align-center mt-2 mt-md-0">
|
||||
<v-chip
|
||||
v-if="berths?.list?.length"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:size="mobile ? 'default' : 'large'"
|
||||
>
|
||||
{{ berths.list.length }} Total Berths
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Search and Filters Section -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" :md="mobile ? 12 : 6" class="mb-2 mb-md-0">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
label="Search berths..."
|
||||
placeholder="Search by mooring number, area, status..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
:density="mobile ? 'compact' : 'comfortable'"
|
||||
clearable
|
||||
hide-details
|
||||
class="search-field"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-fade-transition>
|
||||
<v-chip
|
||||
v-if="filteredBerths.length !== berths?.list?.length"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ filteredBerths.length }}
|
||||
</v-chip>
|
||||
</v-fade-transition>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" :md="mobile ? 12 : 6">
|
||||
<div class="d-flex flex-wrap align-center" :class="mobile ? 'justify-start' : 'justify-end'">
|
||||
<v-btn
|
||||
v-if="hasActiveFilters && mobile"
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="x-small"
|
||||
@click="clearAllFilters"
|
||||
icon="mdi-filter-off"
|
||||
class="mr-2"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="hasActiveFilters && !mobile"
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="small"
|
||||
@click="clearAllFilters"
|
||||
prepend-icon="mdi-filter-off"
|
||||
class="mr-2"
|
||||
>
|
||||
Clear Filters
|
||||
</v-btn>
|
||||
<v-chip-group
|
||||
v-model="selectedArea"
|
||||
selected-class="text-primary"
|
||||
:column="false"
|
||||
mandatory
|
||||
>
|
||||
<v-chip
|
||||
filter
|
||||
variant="outlined"
|
||||
:size="mobile ? 'x-small' : 'small'"
|
||||
value="all"
|
||||
>
|
||||
All Areas
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="area in areaOptions"
|
||||
:key="area"
|
||||
filter
|
||||
variant="outlined"
|
||||
:size="mobile ? 'x-small' : 'small'"
|
||||
:value="area"
|
||||
>
|
||||
Area {{ area }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Status Filter Chips -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<div class="d-flex flex-wrap align-center gap-2">
|
||||
<span class="text-body-2 text-grey-darken-1 mr-2">Status:</span>
|
||||
<v-chip-group
|
||||
v-model="selectedStatus"
|
||||
selected-class="text-primary"
|
||||
:column="false"
|
||||
mandatory
|
||||
>
|
||||
<v-chip
|
||||
filter
|
||||
variant="outlined"
|
||||
:size="mobile ? 'x-small' : 'small'"
|
||||
value="all"
|
||||
>
|
||||
All
|
||||
</v-chip>
|
||||
<v-chip
|
||||
filter
|
||||
variant="outlined"
|
||||
:size="mobile ? 'x-small' : 'small'"
|
||||
value="Available"
|
||||
color="success"
|
||||
>
|
||||
Available
|
||||
</v-chip>
|
||||
<v-chip
|
||||
filter
|
||||
variant="outlined"
|
||||
:size="mobile ? 'x-small' : 'small'"
|
||||
value="Waitlist"
|
||||
color="primary"
|
||||
>
|
||||
Waitlist
|
||||
</v-chip>
|
||||
<v-chip
|
||||
filter
|
||||
variant="outlined"
|
||||
:size="mobile ? 'x-small' : 'small'"
|
||||
value="Reserved"
|
||||
color="warning"
|
||||
>
|
||||
Reserved
|
||||
</v-chip>
|
||||
<v-chip
|
||||
filter
|
||||
variant="outlined"
|
||||
:size="mobile ? 'x-small' : 'small'"
|
||||
value="Sold"
|
||||
color="error"
|
||||
>
|
||||
Sold
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Mobile: Card Layout -->
|
||||
<div v-if="mobile" class="mobile-card-container">
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
|
||||
|
||||
<div v-if="!loading && filteredBerths.length === 0" class="text-center py-8">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-4">mdi-pier-crane</v-icon>
|
||||
<p class="text-h6 text-grey-darken-1">No berths found</p>
|
||||
<p class="text-body-2 text-grey">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
|
||||
<!-- Grouped by Area for Mobile -->
|
||||
<div v-for="area in areasWithBerths" :key="area" class="area-section mb-6">
|
||||
<h3 class="text-h6 mb-3 text-primary">Area {{ area }}</h3>
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<v-card
|
||||
v-for="berth in getBerthsByArea(area)"
|
||||
:key="berth.Id"
|
||||
@click="handleBerthClick(berth)"
|
||||
class="berth-card"
|
||||
elevation="1"
|
||||
hover
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Berth Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar size="40" color="primary" class="mr-3">
|
||||
<span class="text-white text-body-2 font-weight-bold">
|
||||
{{ berth['Mooring Number'] }}
|
||||
</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<h4 class="text-subtitle-1 font-weight-medium">{{ berth['Mooring Number'] }}</h4>
|
||||
<p class="text-caption text-grey-darken-1 mb-0">Area {{ berth.Area }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<BerthStatusBadge :status="berth.Status" size="small" />
|
||||
</div>
|
||||
|
||||
<!-- Berth Details Grid -->
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Boat Size</span>
|
||||
<span class="detail-value">{{ displayMeasurement(berth['Nominal Boat Size']) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Water Depth</span>
|
||||
<span class="detail-value">{{ displayMeasurement(berth['Water Depth']) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Price</span>
|
||||
<span class="detail-value">${{ formatPrice(berth.Price) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Interested</span>
|
||||
<span class="detail-value">
|
||||
<v-chip v-if="getInterestedCount(berth)" size="x-small" color="primary" variant="tonal">
|
||||
{{ getInterestedCount(berth) }}
|
||||
</v-chip>
|
||||
<span v-else class="text-grey-darken-1">0</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: List View -->
|
||||
<div v-if="!mobile">
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
|
||||
|
||||
<div v-if="!loading && filteredBerths.length === 0" class="text-center py-8">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-4">mdi-pier-crane</v-icon>
|
||||
<p class="text-h6 text-grey-darken-1">No berths found</p>
|
||||
<p class="text-body-2 text-grey">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
|
||||
<!-- Grouped by Area for Desktop -->
|
||||
<div v-for="area in areasWithBerths" :key="area" class="area-section mb-8">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-h5 font-weight-bold text-primary mr-4">Area {{ area }}</h3>
|
||||
<v-chip color="primary" variant="tonal" size="small">
|
||||
{{ getBerthsByArea(area).length }} berths
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-card elevation="0" class="rounded-lg">
|
||||
<v-table class="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Berth</th>
|
||||
<th>Status</th>
|
||||
<th>Boat Size</th>
|
||||
<th>Water Depth</th>
|
||||
<th>Dimensions</th>
|
||||
<th>Price</th>
|
||||
<th>Interested</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="berth in getBerthsByArea(area)"
|
||||
:key="berth.Id"
|
||||
@click="handleBerthClick(berth)"
|
||||
class="table-row"
|
||||
>
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar size="32" color="primary" class="mr-3">
|
||||
<span class="text-white text-caption font-weight-bold">
|
||||
{{ berth['Mooring Number'] }}
|
||||
</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ berth['Mooring Number'] }}</div>
|
||||
<div class="text-caption text-grey-darken-1">{{ berth['Mooring Type'] || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><BerthStatusBadge :status="berth.Status" /></td>
|
||||
<td>{{ displayMeasurement(berth['Nominal Boat Size']) }}</td>
|
||||
<td>{{ displayMeasurement(berth['Water Depth']) }}</td>
|
||||
<td>
|
||||
<div class="text-caption">
|
||||
<div>L: {{ displayMeasurement(berth.Length) }}</div>
|
||||
<div>W: {{ displayMeasurement(berth.Width) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-weight-medium">${{ formatPrice(berth.Price) }}</td>
|
||||
<td>
|
||||
<v-chip
|
||||
v-if="getInterestedCount(berth)"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ getInterestedCount(berth) }}
|
||||
</v-chip>
|
||||
<span v-else class="text-grey-darken-1">0</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
|
||||
<!-- Berth Details Modal -->
|
||||
<BerthDetailsModal
|
||||
v-model="showModal"
|
||||
:berth="selectedBerth"
|
||||
@save="handleBerthSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useFetch } from '#app';
|
||||
import type { Berth, BerthsResponse } from '@/utils/types';
|
||||
import { BerthArea } from '@/utils/types';
|
||||
import BerthStatusBadge from '@/components/BerthStatusBadge.vue';
|
||||
import BerthDetailsModal from '@/components/BerthDetailsModal.vue';
|
||||
|
||||
useHead({
|
||||
title: "Berth List",
|
||||
});
|
||||
|
||||
const { mobile } = useDisplay();
|
||||
const loading = ref(true);
|
||||
const showModal = ref(false);
|
||||
const selectedBerth = ref<Berth | null>(null);
|
||||
const search = ref("");
|
||||
const selectedArea = ref('all');
|
||||
const selectedStatus = ref('all');
|
||||
|
||||
const { data: berths, refresh } = useFetch<BerthsResponse>("/api/get-berths", {
|
||||
onResponse() {
|
||||
loading.value = false;
|
||||
},
|
||||
onResponseError() {
|
||||
loading.value = false;
|
||||
},
|
||||
});
|
||||
|
||||
const areaOptions = Object.values(BerthArea);
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = computed(() => {
|
||||
return search.value !== '' || selectedArea.value !== 'all' || selectedStatus.value !== 'all';
|
||||
});
|
||||
|
||||
// Clear all filters
|
||||
const clearAllFilters = () => {
|
||||
search.value = '';
|
||||
selectedArea.value = 'all';
|
||||
selectedStatus.value = 'all';
|
||||
};
|
||||
|
||||
// Filter berths based on search and filters
|
||||
const filteredBerths = computed(() => {
|
||||
if (!berths.value?.list) return [];
|
||||
|
||||
let filtered = berths.value.list;
|
||||
|
||||
// Apply area filter
|
||||
if (selectedArea.value !== 'all') {
|
||||
filtered = filtered.filter(berth => berth.Area === selectedArea.value);
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (selectedStatus.value !== 'all') {
|
||||
filtered = filtered.filter(berth => berth.Status === selectedStatus.value);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (search.value) {
|
||||
const searchLower = search.value.toLowerCase();
|
||||
filtered = filtered.filter(berth => {
|
||||
return Object.values(berth).some(value =>
|
||||
String(value).toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Get unique areas that have berths (for grouping)
|
||||
const areasWithBerths = computed(() => {
|
||||
const areas = new Set(filteredBerths.value.map(berth => berth.Area));
|
||||
return Array.from(areas).sort();
|
||||
});
|
||||
|
||||
// Get berths by area
|
||||
const getBerthsByArea = (area: string) => {
|
||||
return filteredBerths.value
|
||||
.filter(berth => berth.Area === area)
|
||||
.sort((a, b) => {
|
||||
// Sort by mooring number within area
|
||||
const aNum = parseInt(a['Mooring Number']?.replace(/[A-Za-z]/g, '') || '0');
|
||||
const bNum = parseInt(b['Mooring Number']?.replace(/[A-Za-z]/g, '') || '0');
|
||||
return aNum - bNum;
|
||||
});
|
||||
};
|
||||
|
||||
// Display measurement with both metric and imperial
|
||||
const displayMeasurement = (imperial: number): string => {
|
||||
if (!imperial) return '—';
|
||||
const metric = imperial * 0.3048;
|
||||
return `${metric.toFixed(1)}m / ${imperial}ft`;
|
||||
};
|
||||
|
||||
// Format price with thousands separator
|
||||
const formatPrice = (price: number): string => {
|
||||
if (!price) return '0';
|
||||
return new Intl.NumberFormat('en-US').format(price);
|
||||
};
|
||||
|
||||
// Get interested parties count
|
||||
const getInterestedCount = (berth: Berth): number => {
|
||||
return berth['Interested Parties']?.length || 0;
|
||||
};
|
||||
|
||||
// Handle berth click
|
||||
const handleBerthClick = (berth: Berth) => {
|
||||
selectedBerth.value = berth;
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
// Handle berth save
|
||||
const handleBerthSave = async (berth: Berth) => {
|
||||
// Refresh the berths data to reflect the updates
|
||||
loading.value = true;
|
||||
await refresh();
|
||||
loading.value = false;
|
||||
showModal.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-field :deep(.v-field) {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-field:focus-within :deep(.v-field) {
|
||||
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2);
|
||||
}
|
||||
|
||||
/* Mobile Card Layout */
|
||||
.mobile-card-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.area-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.berth-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.berth-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12) !important;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Desktop Table Styles */
|
||||
.modern-table :deep(.v-table__wrapper) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modern-table :deep(.v-data-table-header__content) {
|
||||
font-weight: 600;
|
||||
color: #424242;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.modern-table :deep(tbody tr) {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modern-table :deep(tbody tr:hover) {
|
||||
background-color: #f8f9fa;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-row td {
|
||||
padding: 16px !important;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.table-row:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.v-container {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,30 +1,502 @@
|
|||
<template>
|
||||
<div class="embed">
|
||||
<iframe
|
||||
src="https://database.portnimara.com/dashboard/#/nc/kanban/b695ec72-2af5-4d13-a73f-9c72cd223680"
|
||||
<div>
|
||||
<v-container fluid>
|
||||
<!-- Header Section -->
|
||||
<v-row class="mb-4 mb-md-6">
|
||||
<v-col cols="12" md="8">
|
||||
<h1 class="text-h5 text-md-h4 font-weight-bold mb-1 mb-md-2">
|
||||
Berth Status Overview
|
||||
</h1>
|
||||
<p class="text-body-2 text-md-subtitle-1 text-grey-darken-1">
|
||||
Visual overview of all berth statuses and occupancy
|
||||
</p>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" class="d-flex justify-start justify-md-end align-center mt-2 mt-md-0">
|
||||
<v-chip
|
||||
v-if="berths?.list?.length"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:size="mobile ? 'default' : 'large'"
|
||||
>
|
||||
{{ berths.list.length }} Total Berths
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<v-row class="mb-6">
|
||||
<v-col v-for="statusSummary in statusSummaries" :key="statusSummary.status" cols="6" md="3">
|
||||
<v-card :color="statusSummary.color" variant="tonal" class="text-center pa-4">
|
||||
<div class="text-h4 font-weight-bold mb-1">{{ statusSummary.count }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ statusSummary.status }}</div>
|
||||
<v-progress-linear
|
||||
:model-value="statusSummary.percentage"
|
||||
:color="statusSummary.color"
|
||||
height="4"
|
||||
class="mt-2"
|
||||
/>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Search and View Toggle -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" :md="mobile ? 12 : 8" class="mb-2 mb-md-0">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
label="Search berths..."
|
||||
placeholder="Search by mooring number, area..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
:density="mobile ? 'compact' : 'comfortable'"
|
||||
clearable
|
||||
hide-details
|
||||
class="search-field"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-fade-transition>
|
||||
<v-chip
|
||||
v-if="filteredBerths.length !== berths?.list?.length"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ filteredBerths.length }}
|
||||
</v-chip>
|
||||
</v-fade-transition>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" :md="mobile ? 12 : 4">
|
||||
<div class="d-flex align-center" :class="mobile ? 'justify-start' : 'justify-end'">
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
:density="mobile ? 'compact' : 'comfortable'"
|
||||
>
|
||||
<v-btn value="kanban" :size="mobile ? 'small' : 'default'">
|
||||
<v-icon>mdi-view-column</v-icon>
|
||||
<span v-if="!mobile" class="ml-2">Kanban</span>
|
||||
</v-btn>
|
||||
<v-btn value="grid" :size="mobile ? 'small' : 'default'">
|
||||
<v-icon>mdi-view-grid</v-icon>
|
||||
<span v-if="!mobile" class="ml-2">Grid</span>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
|
||||
|
||||
<!-- Kanban View -->
|
||||
<div v-if="viewMode === 'kanban' && !loading" class="kanban-container">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
cols="12"
|
||||
:sm="mobile ? 12 : 6"
|
||||
:md="mobile ? 12 : 3"
|
||||
>
|
||||
<v-card class="status-column" elevation="1">
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :color="status.color" class="mr-2">{{ status.icon }}</v-icon>
|
||||
<span class="font-weight-bold">{{ status.value }}</span>
|
||||
</div>
|
||||
<v-chip
|
||||
:color="status.color"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ getBerthsByStatus(status.value).length }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-2" style="max-height: 600px; overflow-y: auto;">
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<v-card
|
||||
v-for="berth in getBerthsByStatus(status.value)"
|
||||
:key="berth.Id"
|
||||
@click="handleBerthClick(berth)"
|
||||
class="berth-kanban-card"
|
||||
:color="status.color"
|
||||
variant="tonal"
|
||||
elevation="0"
|
||||
hover
|
||||
>
|
||||
<v-card-text class="pa-3">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="font-weight-bold text-subtitle-2">{{ berth['Mooring Number'] }}</span>
|
||||
<span class="text-caption text-grey-darken-1">Area {{ berth.Area }}</span>
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-1 mb-2">
|
||||
{{ displayMeasurement(berth['Nominal Boat Size']) }} boat
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
|
||||
<v-chip
|
||||
v-if="getInterestedCount(berth)"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
>
|
||||
{{ getInterestedCount(berth) }} interested
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Empty state for status column -->
|
||||
<div v-if="getBerthsByStatus(status.value).length === 0" class="text-center py-4">
|
||||
<v-icon size="32" color="grey-lighten-2" class="mb-2">{{ status.icon }}</v-icon>
|
||||
<p class="text-caption text-grey-darken-1">No {{ status.value.toLowerCase() }} berths</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-if="viewMode === 'grid' && !loading">
|
||||
<div v-if="filteredBerths.length === 0" class="text-center py-8">
|
||||
<v-icon size="64" color="grey-lighten-2" class="mb-4">mdi-pier-crane</v-icon>
|
||||
<p class="text-h6 text-grey-darken-1">No berths found</p>
|
||||
<p class="text-body-2 text-grey">Try adjusting your search</p>
|
||||
</div>
|
||||
|
||||
<!-- Grouped by Area for Grid View -->
|
||||
<div v-for="area in areasWithBerths" :key="area" class="area-section mb-8">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-h5 font-weight-bold text-primary mr-4">Area {{ area }}</h3>
|
||||
<v-chip color="primary" variant="tonal" size="small">
|
||||
{{ getBerthsByArea(area).length }} berths
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="berth in getBerthsByArea(area)"
|
||||
:key="berth.Id"
|
||||
cols="12"
|
||||
:sm="mobile ? 12 : 6"
|
||||
:md="mobile ? 12 : 4"
|
||||
:lg="mobile ? 12 : 3"
|
||||
>
|
||||
<v-card
|
||||
@click="handleBerthClick(berth)"
|
||||
class="berth-grid-card"
|
||||
elevation="2"
|
||||
hover
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Berth Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar size="36" color="primary" class="mr-3">
|
||||
<span class="text-white text-body-2 font-weight-bold">
|
||||
{{ berth['Mooring Number'] }}
|
||||
</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<h4 class="text-subtitle-1 font-weight-medium">{{ berth['Mooring Number'] }}</h4>
|
||||
<p class="text-caption text-grey-darken-1 mb-0">Area {{ berth.Area }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<BerthStatusBadge :status="berth.Status" size="small" />
|
||||
</div>
|
||||
|
||||
<!-- Berth Details -->
|
||||
<div class="berth-details mb-3">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Boat Size:</span>
|
||||
<span class="detail-value">{{ displayMeasurement(berth['Nominal Boat Size']) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Water Depth:</span>
|
||||
<span class="detail-value">{{ displayMeasurement(berth['Water Depth']) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Price:</span>
|
||||
<span class="detail-value">${{ formatPrice(berth.Price) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-caption text-grey-darken-1">{{ berth['Mooring Type'] || 'Standard' }}</span>
|
||||
<v-chip
|
||||
v-if="getInterestedCount(berth)"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ getInterestedCount(berth) }} interested
|
||||
</v-chip>
|
||||
<span v-else class="text-caption text-grey-darken-1">No interest</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
|
||||
<!-- Berth Details Modal -->
|
||||
<BerthDetailsModal
|
||||
v-model="showModal"
|
||||
:berth="selectedBerth"
|
||||
@save="handleBerthSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useFetch } from '#app';
|
||||
import type { Berth, BerthsResponse } from '@/utils/types';
|
||||
import { BerthArea, BerthStatus } from '@/utils/types';
|
||||
import BerthStatusBadge from '@/components/BerthStatusBadge.vue';
|
||||
import BerthDetailsModal from '@/components/BerthDetailsModal.vue';
|
||||
|
||||
useHead({
|
||||
title: "Berth Status",
|
||||
});
|
||||
|
||||
const { mobile } = useDisplay();
|
||||
const loading = ref(true);
|
||||
const showModal = ref(false);
|
||||
const selectedBerth = ref<Berth | null>(null);
|
||||
const search = ref("");
|
||||
const viewMode = ref('kanban');
|
||||
|
||||
const { data: berths, refresh } = useFetch<BerthsResponse>("/api/get-berths", {
|
||||
onResponse() {
|
||||
loading.value = false;
|
||||
},
|
||||
onResponseError() {
|
||||
loading.value = false;
|
||||
},
|
||||
});
|
||||
|
||||
// Status options with colors and icons
|
||||
const statusOptions = [
|
||||
{ value: BerthStatus.Available, color: 'success', icon: 'mdi-check-circle' },
|
||||
{ value: BerthStatus.Waitlist, color: 'primary', icon: 'mdi-clock-outline' },
|
||||
{ value: BerthStatus.Reserved, color: 'warning', icon: 'mdi-bookmark' },
|
||||
{ value: BerthStatus.Sold, color: 'error', icon: 'mdi-check-all' }
|
||||
];
|
||||
|
||||
// Status summaries for overview cards
|
||||
const statusSummaries = computed(() => {
|
||||
const total = berths.value?.list?.length || 0;
|
||||
return statusOptions.map(status => {
|
||||
const count = getBerthsByStatus(status.value).length;
|
||||
const percentage = total > 0 ? (count / total) * 100 : 0;
|
||||
return {
|
||||
...status,
|
||||
count,
|
||||
percentage,
|
||||
status: status.value
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Filter berths based on search
|
||||
const filteredBerths = computed(() => {
|
||||
if (!berths.value?.list) return [];
|
||||
|
||||
let filtered = berths.value.list;
|
||||
|
||||
// Apply search filter
|
||||
if (search.value) {
|
||||
const searchLower = search.value.toLowerCase();
|
||||
filtered = filtered.filter(berth => {
|
||||
return (
|
||||
berth['Mooring Number']?.toLowerCase().includes(searchLower) ||
|
||||
berth.Area?.toLowerCase().includes(searchLower) ||
|
||||
berth.Status?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Get unique areas that have berths (for grid view grouping)
|
||||
const areasWithBerths = computed(() => {
|
||||
const areas = new Set(filteredBerths.value.map(berth => berth.Area));
|
||||
return Array.from(areas).sort();
|
||||
});
|
||||
|
||||
// Get berths by status
|
||||
const getBerthsByStatus = (status: string) => {
|
||||
return filteredBerths.value
|
||||
.filter(berth => berth.Status === status)
|
||||
.sort((a, b) => {
|
||||
// Sort by area, then by mooring number
|
||||
if (a.Area !== b.Area) {
|
||||
return a.Area.localeCompare(b.Area);
|
||||
}
|
||||
const aNum = parseInt(a['Mooring Number']?.replace(/[A-Za-z]/g, '') || '0');
|
||||
const bNum = parseInt(b['Mooring Number']?.replace(/[A-Za-z]/g, '') || '0');
|
||||
return aNum - bNum;
|
||||
});
|
||||
};
|
||||
|
||||
// Get berths by area (for grid view)
|
||||
const getBerthsByArea = (area: string) => {
|
||||
return filteredBerths.value
|
||||
.filter(berth => berth.Area === area)
|
||||
.sort((a, b) => {
|
||||
// Sort by mooring number within area
|
||||
const aNum = parseInt(a['Mooring Number']?.replace(/[A-Za-z]/g, '') || '0');
|
||||
const bNum = parseInt(b['Mooring Number']?.replace(/[A-Za-z]/g, '') || '0');
|
||||
return aNum - bNum;
|
||||
});
|
||||
};
|
||||
|
||||
// Display measurement with both metric and imperial
|
||||
const displayMeasurement = (imperial: number): string => {
|
||||
if (!imperial) return '—';
|
||||
const metric = imperial * 0.3048;
|
||||
return `${metric.toFixed(1)}m / ${imperial}ft`;
|
||||
};
|
||||
|
||||
// Format price with thousands separator
|
||||
const formatPrice = (price: number): string => {
|
||||
if (!price) return '0';
|
||||
return new Intl.NumberFormat('en-US').format(price);
|
||||
};
|
||||
|
||||
// Get interested parties count
|
||||
const getInterestedCount = (berth: Berth): number => {
|
||||
return berth['Interested Parties']?.length || 0;
|
||||
};
|
||||
|
||||
// Handle berth click
|
||||
const handleBerthClick = (berth: Berth) => {
|
||||
selectedBerth.value = berth;
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
// Handle berth save
|
||||
const handleBerthSave = async (berth: Berth) => {
|
||||
// Refresh the berths data to reflect the updates
|
||||
loading.value = true;
|
||||
await refresh();
|
||||
loading.value = false;
|
||||
showModal.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embed {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
.search-field :deep(.v-field) {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.embed iframe {
|
||||
.search-field:focus-within :deep(.v-field) {
|
||||
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2);
|
||||
}
|
||||
|
||||
/* Kanban Layout */
|
||||
.kanban-container {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-column {
|
||||
min-height: 400px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.berth-kanban-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.berth-kanban-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Grid Layout */
|
||||
.area-section {
|
||||
width: 100%;
|
||||
height: calc(100% + 46px);
|
||||
border: 0;
|
||||
position: absolute;
|
||||
top: -46px;
|
||||
}
|
||||
|
||||
.berth-grid-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 12px !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.berth-grid-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12) !important;
|
||||
}
|
||||
|
||||
.berth-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.v-container {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.kanban-container .v-col {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.status-column {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for kanban columns */
|
||||
.status-column .v-card-text::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.status-column .v-card-text::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status-column .v-card-text::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.status-column .v-card-text::-webkit-scrollbar-thumb:hover {
|
||||
background: #999;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { getNocoDbConfiguration } from "../utils/nocodb";
|
||||
import { requireAuth } from "../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[get-berth-by-id] Request received');
|
||||
|
||||
// Check authentication (x-tag header OR Keycloak session)
|
||||
await requireAuth(event);
|
||||
|
||||
try {
|
||||
const query = getQuery(event);
|
||||
const berthId = query.id;
|
||||
|
||||
if (!berthId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Berth ID is required"
|
||||
});
|
||||
}
|
||||
|
||||
const config = getNocoDbConfiguration();
|
||||
const berthsTableId = "mczgos9hr3oa9qc";
|
||||
|
||||
console.log('[get-berth-by-id] Fetching berth with ID:', berthId);
|
||||
|
||||
// Fetch berth with linked interested parties
|
||||
const berth = await $fetch(`${config.url}/api/v2/tables/${berthsTableId}/records/${berthId}`, {
|
||||
headers: {
|
||||
"xc-token": config.token,
|
||||
},
|
||||
params: {
|
||||
// Expand the "Interested Parties" linked field to get full interest records
|
||||
fields: '*,Interested Parties.*'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[get-berth-by-id] Successfully fetched berth:', berthId);
|
||||
|
||||
return berth;
|
||||
} catch (error) {
|
||||
console.error('[get-berth-by-id] Error occurred:', error);
|
||||
console.error('[get-berth-by-id] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
|
@ -18,16 +18,18 @@ export default defineEventHandler(async (event) => {
|
|||
},
|
||||
params: {
|
||||
limit: 1000,
|
||||
// Include interested parties count (expand the linked field)
|
||||
fields: '*,Interested Parties.Id,Interested Parties.Full Name,Interested Parties.Sales Process Level,Interested Parties.EOI Status,Interested Parties.Contract Status'
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[get-berths] Successfully fetched berths, count:', berths.list?.length || 0);
|
||||
|
||||
// Sort berths by letter zone and then by number
|
||||
// Sort berths by letter zone and then by number using Mooring Number
|
||||
if (berths.list && Array.isArray(berths.list)) {
|
||||
berths.list.sort((a, b) => {
|
||||
const berthA = a['Berth Number'] || '';
|
||||
const berthB = b['Berth Number'] || '';
|
||||
const berthA = a['Mooring Number'] || '';
|
||||
const berthB = b['Mooring Number'] || '';
|
||||
|
||||
// Extract letter and number parts
|
||||
const matchA = berthA.match(/^([A-Za-z]+)(\d+)$/);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import { withBerthQueue } from '~/server/utils/operation-lock';
|
||||
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
||||
import { requireAuth } from '~/server/utils/auth';
|
||||
import {
|
||||
BerthStatus,
|
||||
BerthArea,
|
||||
SidePontoon,
|
||||
MooringType,
|
||||
CleatType,
|
||||
CleatCapacity,
|
||||
BollardType,
|
||||
BollardCapacity,
|
||||
Access
|
||||
} from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[update-berth] Request received');
|
||||
|
||||
// Check authentication (x-tag header OR Keycloak session)
|
||||
await requireAuth(event);
|
||||
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const { berthId, updates } = body;
|
||||
console.log('[update-berth] Request body:', { berthId, updates });
|
||||
|
||||
if (!berthId || !updates) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "berthId and updates object are required"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate enum fields
|
||||
const validEnumFields: Record<string, string[]> = {
|
||||
'Status': Object.values(BerthStatus),
|
||||
'Area': Object.values(BerthArea),
|
||||
'Side Pontoon': Object.values(SidePontoon),
|
||||
'Mooring Type': Object.values(MooringType),
|
||||
'Cleat Type': Object.values(CleatType),
|
||||
'Cleat Capacity': Object.values(CleatCapacity),
|
||||
'Bollard Type': Object.values(BollardType),
|
||||
'Bollard Capacity': Object.values(BollardCapacity),
|
||||
'Access': Object.values(Access)
|
||||
};
|
||||
|
||||
// Validate enum values
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
if (validEnumFields[field] && value !== null && value !== undefined) {
|
||||
if (!validEnumFields[field].includes(value as string)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Invalid value for ${field}: ${value}. Must be one of: ${validEnumFields[field].join(', ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle measurement conversions
|
||||
// If metric values are being updated, convert them to imperial for storage
|
||||
const measurementFields = ['Nominal Boat Size', 'Water Depth', 'Length', 'Width', 'Depth'];
|
||||
const processedUpdates = { ...updates };
|
||||
|
||||
for (const field of measurementFields) {
|
||||
if (processedUpdates[field] !== undefined) {
|
||||
const value = processedUpdates[field];
|
||||
if (typeof value === 'string') {
|
||||
// Parse user input and convert metric to imperial if needed
|
||||
const cleanInput = value.replace(/[^\d.]/g, '');
|
||||
const numericValue = parseFloat(cleanInput);
|
||||
|
||||
if (!isNaN(numericValue)) {
|
||||
const isMetric = value.toLowerCase().includes('m') && !value.toLowerCase().includes('ft');
|
||||
if (isMetric) {
|
||||
// Convert metric to imperial for NocoDB storage
|
||||
const imperial = numericValue / 0.3048;
|
||||
processedUpdates[field] = parseFloat(imperial.toFixed(2));
|
||||
console.log(`[update-berth] Converted ${field} from ${numericValue}m to ${processedUpdates[field]}ft`);
|
||||
} else {
|
||||
// Assume imperial, store as is
|
||||
processedUpdates[field] = numericValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use queuing system to handle concurrent updates
|
||||
return await withBerthQueue(berthId, async () => {
|
||||
const config = getNocoDbConfiguration();
|
||||
const berthsTableId = "mczgos9hr3oa9qc";
|
||||
|
||||
const url = `${config.url}/api/v2/tables/${berthsTableId}/records/${berthId}`;
|
||||
console.log('[update-berth] URL:', url);
|
||||
console.log('[update-berth] Processed updates:', processedUpdates);
|
||||
|
||||
const result = await $fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
"xc-token": config.token,
|
||||
},
|
||||
body: processedUpdates,
|
||||
});
|
||||
|
||||
console.log('[update-berth] Successfully updated berth:', berthId);
|
||||
return result;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[update-berth] Error occurred:', error);
|
||||
console.error('[update-berth] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
122
utils/types.ts
122
utils/types.ts
|
|
@ -1,28 +1,110 @@
|
|||
// Berth Status Enum
|
||||
export enum BerthStatus {
|
||||
Available = 'Available',
|
||||
Waitlist = 'Waitlist',
|
||||
Reserved = 'Reserved',
|
||||
Sold = 'Sold'
|
||||
}
|
||||
|
||||
// Berth Area Enum
|
||||
export enum BerthArea {
|
||||
A = 'A',
|
||||
B = 'B',
|
||||
C = 'C',
|
||||
D = 'D',
|
||||
E = 'E'
|
||||
}
|
||||
|
||||
// Side Pontoon Options
|
||||
export enum SidePontoon {
|
||||
No = 'No',
|
||||
QuaySB = 'Quay SB',
|
||||
QuayPT = 'Quay PT',
|
||||
QuaySBYesPT = 'Quay SB, Yes PT',
|
||||
QuayPTYesSB = 'Quay PT, Yes SB',
|
||||
YesSB = 'Yes SB',
|
||||
YesPT = 'Yes PT'
|
||||
}
|
||||
|
||||
// Mooring Type Options
|
||||
export enum MooringType {
|
||||
SidePierMed = 'Side Pier / Med Mooring',
|
||||
DoubleMed = '2x Med Mooring',
|
||||
SidePierFinger = 'Side Pier / Finger',
|
||||
FingerMed = 'Finger / Med Mooring',
|
||||
DoubleFinger = '2x Finger'
|
||||
}
|
||||
|
||||
// Cleat Type Options
|
||||
export enum CleatType {
|
||||
A3 = 'A3',
|
||||
A5 = 'A5'
|
||||
}
|
||||
|
||||
// Cleat Capacity Options
|
||||
export enum CleatCapacity {
|
||||
TwentyToTwentyFour = '20-24 ton break load',
|
||||
TenToFourteen = '10-14 ton break load'
|
||||
}
|
||||
|
||||
// Bollard Type Options
|
||||
export enum BollardType {
|
||||
TypeA = 'Bull bollard type A',
|
||||
TypeB = 'Bull bollard type B'
|
||||
}
|
||||
|
||||
// Bollard Capacity Options
|
||||
export enum BollardCapacity {
|
||||
Twenty = '20 ton break load',
|
||||
Forty = '40 ton break load'
|
||||
}
|
||||
|
||||
// Access Options
|
||||
export enum Access {
|
||||
CarThreeTonToVessel = 'Car (3t) to Vessel',
|
||||
CarToVessel = 'Car to Vessel',
|
||||
CarToQuaiCartToVessel = 'Car to Quai, Cart to Vessel',
|
||||
CartToVessel = 'Cart to Vessel',
|
||||
CarThreeAndHalfTonToVessel = 'Car (3.5t) to Vessel'
|
||||
}
|
||||
|
||||
// Interested Party with status info
|
||||
export interface InterestedParty extends Interest {
|
||||
linkId?: number; // ID of the link record if needed
|
||||
}
|
||||
|
||||
export interface Berth {
|
||||
Id: number;
|
||||
"Mooring Number": string;
|
||||
Length: string;
|
||||
Draft: string;
|
||||
"Side Pontoon": string;
|
||||
"Mooring Number": string; // e.g., 'A5'
|
||||
Area: string; // Area enum values: A, B, C, D, E
|
||||
Status: string; // BerthStatus enum values
|
||||
"Nominal Boat Size": number; // in feet (imperial)
|
||||
"Water Depth": number; // in feet
|
||||
Length: number; // in feet
|
||||
Width: number; // in feet
|
||||
Depth: number; // in feet
|
||||
"Side Pontoon": string; // SidePontoon enum values
|
||||
"Power Capacity": number;
|
||||
Voltage: number;
|
||||
Status: string;
|
||||
Width: string;
|
||||
Area: string;
|
||||
"Map Data": {};
|
||||
"Nominal Boat Size": number;
|
||||
"Water Depth": string;
|
||||
"Water Depth Is Minimum": boolean;
|
||||
"Width Is Minimum": boolean;
|
||||
"Mooring Type": string;
|
||||
"Cleat Type": string;
|
||||
"Cleat Capacity": string;
|
||||
"Bollard Type": string;
|
||||
"Bollard Capacity": string;
|
||||
Access: string;
|
||||
Price: string;
|
||||
"Mooring Type": string; // MooringType enum values
|
||||
"Cleat Type": string; // CleatType enum values
|
||||
"Cleat Capacity": string; // CleatCapacity enum values
|
||||
"Bollard Type": string; // BollardType enum values
|
||||
"Bollard Capacity": string; // BollardCapacity enum values
|
||||
"Bow Facing": string;
|
||||
"Berth Approved": boolean;
|
||||
Access: string; // Access enum values
|
||||
Price: number;
|
||||
"Interested Parties"?: InterestedParty[];
|
||||
"Map Data"?: {};
|
||||
"Water Depth Is Minimum"?: boolean;
|
||||
"Width Is Minimum"?: boolean;
|
||||
"Berth Approved"?: boolean;
|
||||
"Created At"?: string;
|
||||
"Updated At"?: string;
|
||||
}
|
||||
|
||||
export interface BerthsResponse {
|
||||
list: Berth[];
|
||||
}
|
||||
|
||||
export interface EOIDocument {
|
||||
|
|
|
|||
Loading…
Reference in New Issue