Files
pn-new-crm/src/components/berths/berth-detail-header.tsx

225 lines
7.0 KiB
TypeScript
Raw Normal View History

'use client';
import { useState } from 'react';
import { Pencil, RefreshCw } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { BerthForm } from './berth-form';
import { apiFetch } from '@/lib/api/client';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES } from '@/lib/constants';
type BerthDetailData = {
id: string;
mooringNumber: string;
area: string | null;
status: string;
portId: string;
lengthFt: string | null;
lengthM: string | null;
widthFt: string | null;
widthM: string | null;
draftFt: string | null;
draftM: string | null;
widthIsMinimum: boolean | null;
feat(berths): full NocoDB field parity, numeric types, sales edit access Aligns the berths schema with the 117 production rows in NocoDB and exposes every field for editing via the BerthForm sheet. Schema (migration 0020): - power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric (NocoDB stores plain numbers; text was wrong shape and broke filter/sort) - ADD status_override_mode text (1/117 legacy rows have a value; carried forward for parity but not yet wired into the UI) - USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty strings convert cleanly Validator + service: - updateBerthSchema / createBerthSchema use z.coerce.number() for the four numeric fields - berths.service stringifies numeric values for Drizzle's numeric type Form (src/components/berths/berth-form.tsx): - adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag, side pontoon, cleat type/capacity, bollard type/capacity, bow facing - converts to typed selects (with NocoDB option lists in src/lib/constants): area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity, access - power capacity / voltage become numeric inputs (with kW / V hints) Permissions (seed.ts + dev DB): - sales_manager and sales_agent: berths.edit false -> true ("sales will sometimes have to update these and I cannot be the only one") - super_admin / director already had it; viewer stays read-only - dev DB updated in-place via UPDATE roles ... jsonb_set Verification: - pnpm exec vitest run: 858/858 passing - pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing on feat/mobile-foundation, none introduced) - lint clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
bowFacing: string | null;
price: string | null;
priceCurrency: string;
tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
access: string | null;
berthApproved: boolean | null;
tags: Array<{ id: string; name: string; color: string }>;
};
interface BerthDetailHeaderProps {
berth: BerthDetailData;
}
const STATUS_COLORS: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-300',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-300',
sold: 'bg-red-100 text-red-800 border-red-300',
};
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
function StatusChangeDialog({
berthId,
currentStatus,
open,
onOpenChange,
}: {
berthId: string;
currentStatus: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { isSubmitting },
} = useForm<UpdateBerthStatusInput>({
resolver: zodResolver(updateBerthStatusSchema),
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
});
const status = watch('status');
async function onSubmit(data: UpdateBerthStatusInput) {
try {
await apiFetch(`/api/v1/berths/${berthId}/status`, {
method: 'PATCH',
body: data,
});
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
toast.success('Status updated');
reset();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update status';
toast.error(message);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Status</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label>New Status</Label>
<Select
value={status}
onValueChange={(v) => setValue('status', v as (typeof BERTH_STATUSES)[number])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BERTH_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
<Textarea {...register('reason')} placeholder="Reason for status change..." rows={3} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Update Status'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
const [editOpen, setEditOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
return (
<>
<DetailHeaderStrip>
fix(mobile): clipping, dropdown-tabs and stale phone metadata Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass: 1. SpecRow on berth detail clipped at right edge on phone widths. "Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack: label on top, value full-width below; flex row only from sm up. 2. ResponsiveTabs collapsed to a Select on phone widths, which read like a generic dropdown and obscured the existence of peer tabs. Replaced with a horizontally-scrollable strip that auto-scrolls the active trigger into view (so the user sees neighbors and gets a discovery cue that more exists beyond the edge). Removes the phone-only Select and unifies the tab UI across viewport sizes. 3. Documents page tab strip ("All / EOI queue / Awaiting them / ...") overflowed the 390px viewport because the wrapper was a fixed flex row. Same horizontal-scroll fix as (2); inherits because Documents uses ResponsiveTabs. 4. Berth detail header: "Change Status" + "Edit" buttons crowded the area subtitle on mobile, causing "North Pier" to wrap to two lines ("North" / "Pier"). Stacked vertically on phone widths; from sm up the buttons sit beside the title block as before. 5. Empty contact rows on client detail rendered a stale "Add tag · star" metadata strip even when the contact value was unset, which cluttered the row and offered no useful action. The metadata block now only shows when contact.value is non-empty; the trash icon stays visible so users can clean up the empty placeholder. Verification: - pnpm exec vitest run: 858/858 passing - pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing on feat/mobile-foundation, none introduced) - lint clean Defers: - Mobile More sheet last-row alignment / "Email" label specificity - Admin index grouping (Access / System / Configuration / Content) - Interest detail header icon labels (trophy/X discoverability) - Pipeline funnel x-axis label abbreviations - Reminders rail width allocation on dashboard Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:03:56 +02:00
{/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="hidden sm:block text-2xl font-bold text-foreground">
Berth {berth.mooringNumber}
</h1>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
>
{STATUS_LABELS[berth.status] ?? berth.status}
</span>
</div>
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
</div>
fix(mobile): clipping, dropdown-tabs and stale phone metadata Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass: 1. SpecRow on berth detail clipped at right edge on phone widths. "Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack: label on top, value full-width below; flex row only from sm up. 2. ResponsiveTabs collapsed to a Select on phone widths, which read like a generic dropdown and obscured the existence of peer tabs. Replaced with a horizontally-scrollable strip that auto-scrolls the active trigger into view (so the user sees neighbors and gets a discovery cue that more exists beyond the edge). Removes the phone-only Select and unifies the tab UI across viewport sizes. 3. Documents page tab strip ("All / EOI queue / Awaiting them / ...") overflowed the 390px viewport because the wrapper was a fixed flex row. Same horizontal-scroll fix as (2); inherits because Documents uses ResponsiveTabs. 4. Berth detail header: "Change Status" + "Edit" buttons crowded the area subtitle on mobile, causing "North Pier" to wrap to two lines ("North" / "Pier"). Stacked vertically on phone widths; from sm up the buttons sit beside the title block as before. 5. Empty contact rows on client detail rendered a stale "Add tag · star" metadata strip even when the contact value was unset, which cluttered the row and offered no useful action. The metadata block now only shows when contact.value is non-empty; the trash icon stays visible so users can clean up the empty placeholder. Verification: - pnpm exec vitest run: 858/858 passing - pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing on feat/mobile-foundation, none introduced) - lint clean Defers: - Mobile More sheet last-row alignment / "Email" label specificity - Admin index grouping (Access / System / Configuration / Content) - Interest detail header icon labels (trophy/X discoverability) - Pipeline funnel x-axis label abbreviations - Reminders rail width allocation on dashboard Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:03:56 +02:00
<div className="flex flex-wrap items-center gap-2 sm:shrink-0">
<PermissionGate resource="berths" action="edit">
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" />
Change Status
</Button>
<Button size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-4 w-4" />
Edit
</Button>
</PermissionGate>
</div>
</div>
</DetailHeaderStrip>
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
<StatusChangeDialog
berthId={berth.id}
currentStatus={berth.status}
open={statusOpen}
onOpenChange={setStatusOpen}
/>
</>
);
}