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:
566
pages/dashboard/berth-list.vue
Normal file
566
pages/dashboard/berth-list.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user