port-nimara-client-portal/components/BerthDetailsModal.vue

836 lines
26 KiB
Vue

<template>
<v-dialog
v-model="isVisible"
:fullscreen="mobile"
:max-width="mobile ? undefined : 1200"
scrollable
:transition="mobile ? 'dialog-bottom-transition' : 'dialog-transition'"
@click:outside="closeModal"
>
<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-8 bg-grey-lighten-5">
<v-container fluid class="pa-0">
<!-- Basic Information Section -->
<div class="info-section mb-8">
<div class="section-header mb-6">
<v-icon color="primary" size="28" class="mr-3">mdi-information-outline</v-icon>
<h4 class="text-h5 text-primary font-weight-bold">Basic Information</h4>
</div>
<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="info-section mb-8">
<div class="section-header mb-6">
<v-icon color="primary" size="28" class="mr-3">mdi-ruler-square</v-icon>
<h4 class="text-h5 text-primary font-weight-bold">Specifications</h4>
</div>
<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.Draft"
label="Draft (ft/m)"
variant="outlined"
density="compact"
/>
<div v-else class="field-display">
<span class="field-label">Draft:</span>
<span class="field-value">
{{ displayMeasurement(berth.Draft) }}
</span>
</div>
</v-col>
</v-row>
</div>
<!-- Infrastructure Section -->
<div class="info-section mb-8">
<div class="section-header mb-6">
<v-icon color="primary" size="28" class="mr-3">mdi-office-building-outline</v-icon>
<h4 class="text-h5 text-primary font-weight-bold">Infrastructure</h4>
</div>
<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="info-section mb-8">
<div class="section-header mb-6">
<v-icon color="primary" size="28" class="mr-3">mdi-wrench-outline</v-icon>
<h4 class="text-h5 text-primary font-weight-bold">Hardware</h4>
</div>
<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="info-section mb-8">
<div class="section-header mb-6">
<v-icon color="primary" size="28" class="mr-3">mdi-currency-usd</v-icon>
<h4 class="text-h5 text-primary font-weight-bold">Pricing & Details</h4>
</div>
<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="info-section">
<div class="section-header mb-6">
<v-icon color="primary" size="28" class="mr-3">mdi-account-group-outline</v-icon>
<h4 class="text-h5 text-primary font-weight-bold">
Interested Parties
<v-chip
v-if="interestedParties.length > 0"
size="small"
color="primary"
variant="tonal"
class="ml-3"
>
{{ interestedParties.length }}
</v-chip>
</h4>
</div>
<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>
/* Modern card style with subtle shadow */
:deep(.v-dialog .v-card) {
box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.18) !important;
}
/* Header styling */
:deep(.v-card-title) {
background-color: #fafafa;
border-bottom: 1px solid #e0e0e0;
padding: 24px !important;
}
/* Modern section styling with subtle shadows */
.info-section {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.08), 0 1px 3px 0 rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.info-section:hover {
box-shadow: 0 8px 16px -4px rgba(0, 0, 0, 0.12), 0 4px 8px -2px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
/* Section headers with icons */
.section-header {
display: flex;
align-items: center;
border-bottom: 2px solid #f1f5f9;
padding-bottom: 16px;
margin-bottom: 24px;
}
.section-header h4 {
margin-bottom: 0 !important;
color: #1e293b;
font-size: 1.375rem;
font-weight: 700;
}
.section-header .v-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white !important;
border-radius: 50%;
padding: 8px;
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.25);
}
/* Field display improvements */
.field-display {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
transition: all 0.2s ease;
}
.field-display:hover {
transform: translateX(4px);
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field-value {
font-size: 1rem;
font-weight: 500;
color: #1e293b;
}
/* Interested parties table styling */
:deep(.v-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.v-table) th {
font-weight: 600;
color: #475569;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background-color: #f8fafc !important;
border-bottom: 2px solid #e2e8f0 !important;
}
.clickable-row {
cursor: pointer;
transition: all 0.2s ease;
}
.clickable-row:hover {
background-color: #f8fafc;
transform: scale(1.01);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
/* Button styling */
:deep(.v-btn) {
font-weight: 500;
letter-spacing: 0.025em;
}
/* Smooth scrolling */
:deep(.v-card-text) {
scroll-behavior: smooth;
}
/* Responsive padding adjustments */
@media (max-width: 960px) {
.section {
padding: 16px;
}
:deep(.v-card-title) {
padding: 16px !important;
}
}
</style>