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:
Matt 2025-06-17 15:59:39 +02:00
parent 0b881a2588
commit 0e85cb40bc
9 changed files with 2167 additions and 36 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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
};
};

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
});

View File

@ -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+)$/);

114
server/api/update-berth.ts Normal file
View File

@ -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;
}
});

View File

@ -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 {