port-nimara-client-portal/pages/dashboard/berth-list.vue

576 lines
17 KiB
Vue
Raw Normal View History

<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="getBerthColorFromMooringNumber(berth['Mooring Number'])"
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="getBerthColorFromMooringNumber(berth['Mooring Number'])"
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 { getBerthColorFromMooringNumber } from '@/utils/berthColors';
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>