From 8be7a6e29dd034e457fd08fdd6d43e86c40b24f3 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 1 Jun 2026 21:55:04 +0200 Subject: [PATCH] feat(berths): ship Waiting List + Maintenance Log tabs Both berth-detail surfaces were stubbed/hidden behind a comment in berth-tabs.tsx. Their backing schema already existed; this wires the UI and fills the service gaps. Maintenance Log (was ~60% built: schema/migration/add+get service/route): - new edit + delete: updateMaintenanceLog / deleteMaintenanceLog service (port-scoped tenant guard), PATCH/DELETE at maintenance/[logId], plus updateMaintenanceLogSchema. add schema now accepts null for cost / responsibleParty so the shared add+edit dialog sends one body shape. - BerthMaintenanceTab: list (newest first) + add/edit dialog + delete confirm, realtime invalidation. New berth:maintenanceUpdated/Removed socket events. Waiting List (un-hide the orphaned manager + next-in-line notify): - getWaitingList now left-joins the client so the queue renders names, not raw ids. - WaitingListManager rewritten: ClientPicker instead of free-text id, client names, manage_waiting_list gating on add/reorder/remove, and a "Next in line" marker on position 1. - notifyWaitlistNextInLine: when a berth transitions to available, surface the #1 client to staff who hold berths.manage_waiting_list (mirrors the interest-based notifyNextInLine; dedupeKey-suppressed). Hooked into updateBerthStatus on any -> available transition. Tests: maintenance add/get/update/delete + cross-port guard; waitlist notify recipient-resolution / payload / empty + no-permission no-ops. Verified end-to-end in the browser (create/render/delete for both). Also adds scripts/dev-reset-admin-pw.ts (reset a synthetic user's password via the better-auth hasher after a dev reseed). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/dev-reset-admin-pw.ts | 28 ++ .../berths/[id]/maintenance/[logId]/route.ts | 42 ++ .../berths/berth-maintenance-tab.tsx | 370 ++++++++++++++++++ src/components/berths/berth-tabs.tsx | 15 +- .../berths/waiting-list-manager.tsx | 235 ++++++----- src/lib/services/berths.service.ts | 134 ++++++- .../services/next-in-line-notify.service.ts | 69 +++- src/lib/socket/events.ts | 2 + src/lib/validators/berths.ts | 24 +- .../integration/berth-maintenance-log.test.ts | 118 ++++++ .../integration/berth-waitlist-notify.test.ts | 112 ++++++ 11 files changed, 1046 insertions(+), 103 deletions(-) create mode 100644 scripts/dev-reset-admin-pw.ts create mode 100644 src/app/api/v1/berths/[id]/maintenance/[logId]/route.ts create mode 100644 src/components/berths/berth-maintenance-tab.tsx create mode 100644 tests/integration/berth-maintenance-log.test.ts create mode 100644 tests/integration/berth-waitlist-notify.test.ts diff --git a/scripts/dev-reset-admin-pw.ts b/scripts/dev-reset-admin-pw.ts new file mode 100644 index 00000000..0fa78a88 --- /dev/null +++ b/scripts/dev-reset-admin-pw.ts @@ -0,0 +1,28 @@ +import 'dotenv/config'; +import { and, eq } from 'drizzle-orm'; + +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { user, account } from '@/lib/db/schema/users'; + +async function main() { + const email = process.argv[2] ?? 'admin@portnimara.test'; + const pw = process.argv[3] ?? 'SuperAdmin12345!'; + const [u] = await db.select().from(user).where(eq(user.email, email)).limit(1); + if (!u) throw new Error(`user not found: ${email}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ctx = await (auth as any).$context; + const hash = await ctx.password.hash(pw); + const res = await db + .update(account) + .set({ password: hash }) + .where(and(eq(account.userId, u.id), eq(account.providerId, 'credential'))) + .returning({ id: account.id }); + console.log(`updated ${res.length} credential row(s) for ${email}`); + process.exit(0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/api/v1/berths/[id]/maintenance/[logId]/route.ts b/src/app/api/v1/berths/[id]/maintenance/[logId]/route.ts new file mode 100644 index 00000000..111f821a --- /dev/null +++ b/src/app/api/v1/berths/[id]/maintenance/[logId]/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { updateMaintenanceLogSchema } from '@/lib/validators/berths'; +import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service'; +import { errorResponse } from '@/lib/errors'; + +// PATCH /api/v1/berths/[id]/maintenance/[logId] +export const PATCH = withAuth( + withPermission('berths', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, updateMaintenanceLogSchema); + const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: log }); + } catch (error) { + return errorResponse(error); + } + }), +); + +// DELETE /api/v1/berths/[id]/maintenance/[logId] +export const DELETE = withAuth( + withPermission('berths', 'edit', async (_req, ctx, params) => { + try { + await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/berths/berth-maintenance-tab.tsx b/src/components/berths/berth-maintenance-tab.tsx new file mode 100644 index 00000000..20dd0c33 --- /dev/null +++ b/src/components/berths/berth-maintenance-tab.tsx @@ -0,0 +1,370 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Loader2, Pencil, Plus, Trash2, Wrench } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { PermissionGate } from '@/components/shared/permission-gate'; +import { EmptyState } from '@/components/shared/empty-state'; +import { useConfirmation } from '@/hooks/use-confirmation'; +import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; +import { formatCurrency } from '@/lib/utils/currency'; +import { cn } from '@/lib/utils'; + +type MaintenanceCategory = 'routine' | 'repair' | 'inspection' | 'upgrade'; + +interface MaintenanceLog { + id: string; + berthId: string; + category: MaintenanceCategory; + description: string; + cost: string | null; + costCurrency: string | null; + responsibleParty: string | null; + /** 'YYYY-MM-DD' calendar date (postgres `date` column). */ + performedDate: string; + photoFileIds: string[] | null; + createdAt: string; + updatedAt: string; +} + +const CATEGORIES: MaintenanceCategory[] = ['routine', 'repair', 'inspection', 'upgrade']; + +const CATEGORY_LABELS: Record = { + routine: 'Routine', + repair: 'Repair', + inspection: 'Inspection', + upgrade: 'Upgrade', +}; + +const CATEGORY_TONES: Record = { + routine: 'bg-slate-100 text-slate-700', + repair: 'bg-rose-100 text-rose-700', + inspection: 'bg-blue-100 text-blue-700', + upgrade: 'bg-emerald-100 text-emerald-700', +}; + +/** Render a 'YYYY-MM-DD' calendar date without a timezone shift — treat it + * as a wall-clock date, not an instant (else dates west of UTC render a day + * early). */ +function formatDate(iso: string): string { + const [y, m, d] = iso.split('-').map(Number); + if (!y || !m || !d) return iso; + return new Date(y, m - 1, d).toLocaleDateString(); +} + +export function BerthMaintenanceTab({ berthId }: { berthId: string }) { + const qc = useQueryClient(); + const { confirm, dialog: confirmDialog } = useConfirmation(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + + const { data, isLoading } = useQuery<{ data: MaintenanceLog[] }>({ + queryKey: ['berths', berthId, 'maintenance'], + queryFn: () => apiFetch(`/api/v1/berths/${berthId}/maintenance`), + }); + + useRealtimeInvalidation({ + 'berth:maintenanceAdded': [['berths', berthId, 'maintenance']], + 'berth:maintenanceUpdated': [['berths', berthId, 'maintenance']], + 'berth:maintenanceRemoved': [['berths', berthId, 'maintenance']], + }); + + const logs = data?.data ?? []; + + const deleteMutation = useMutation({ + mutationFn: (logId: string) => + apiFetch(`/api/v1/berths/${berthId}/maintenance/${logId}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['berths', berthId, 'maintenance'] }); + toast.success('Maintenance entry deleted.'); + }, + onError: (err) => toastError(err), + }); + + async function handleDelete(log: MaintenanceLog) { + const ok = await confirm({ + title: 'Delete maintenance entry', + description: `Delete the ${CATEGORY_LABELS[log.category].toLowerCase()} entry from ${formatDate( + log.performedDate, + )}? This cannot be undone.`, + confirmLabel: 'Delete', + }); + if (!ok) return; + deleteMutation.mutate(log.id); + } + + return ( +
+
+

Maintenance log

+ + + +
+ + {isLoading ? ( +

Loading…

+ ) : logs.length === 0 ? ( + + ) : ( +
    + {logs.map((log) => ( +
  • +
    +
    +
    + + {CATEGORY_LABELS[log.category]} + + {formatDate(log.performedDate)} + {log.cost ? ( + + {formatCurrency(log.cost, log.costCurrency)} + + ) : null} +
    +

    + {log.description} +

    + {log.responsibleParty ? ( +

    By {log.responsibleParty}

    + ) : null} +
    + +
    + + +
    +
    +
    +
  • + ))} +
+ )} + + {/* Keyed conditional mount: a fresh form state is seeded from `editing` + each time the dialog opens (add => key 'new', edit => key ). */} + {dialogOpen && ( + setDialogOpen(false)} + /> + )} + {confirmDialog} +
+ ); +} + +function MaintenanceEntryDialog({ + berthId, + editing, + onClose, +}: { + berthId: string; + editing: MaintenanceLog | null; + onClose: () => void; +}) { + const qc = useQueryClient(); + const isEdit = editing !== null; + + const [category, setCategory] = useState(editing?.category ?? 'routine'); + const [performedDate, setPerformedDate] = useState( + editing?.performedDate ?? new Date().toISOString().slice(0, 10), + ); + const [description, setDescription] = useState(editing?.description ?? ''); + const [cost, setCost] = useState(editing?.cost ?? ''); + const [costCurrency, setCostCurrency] = useState(editing?.costCurrency ?? 'USD'); + const [responsibleParty, setResponsibleParty] = useState(editing?.responsibleParty ?? ''); + + const mutation = useMutation({ + mutationFn: async () => { + const body = { + category, + performedDate, + description: description.trim(), + // Empty cost clears the column (null), not 0. + cost: cost.trim() === '' ? null : Number(cost), + costCurrency: costCurrency.trim() || 'USD', + responsibleParty: responsibleParty.trim() === '' ? null : responsibleParty.trim(), + }; + if (isEdit) { + return apiFetch(`/api/v1/berths/${berthId}/maintenance/${editing.id}`, { + method: 'PATCH', + body, + }); + } + return apiFetch(`/api/v1/berths/${berthId}/maintenance`, { method: 'POST', body }); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['berths', berthId, 'maintenance'] }); + toast.success(isEdit ? 'Maintenance entry updated.' : 'Maintenance entry added.'); + onClose(); + }, + onError: (err) => toastError(err), + }); + + const canSave = description.trim().length > 0 && performedDate.length > 0; + + return ( + !next && onClose()}> + + + {isEdit ? 'Edit maintenance entry' : 'Add maintenance entry'} + + Record upkeep performed on this berth: category, date, what was done, and optional cost. + + + +
+
+
+ + +
+
+ +
+ +
+
+
+ +
+ +