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

214 lines
6.6 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;
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}
/>
</>
);
}