feat(berths): click-to-change status from the list (chip → reason modal)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m11s
Build & Push Docker Images / build-and-push (push) Successful in 13m12s

Adds BerthStatusQuickEdit — wraps the status chip on the berths list (card +
table) in a click target that opens a compact change-status dialog: status
dropdown + required reason (quick-pick chips) + optional interest link when
moving to under_offer/sold. Reuses the existing PATCH /api/v1/berths/[id]/status
endpoint + validator + audit (same capability the detail page already had).
Gated by berths.edit (non-editors see a plain chip); stops click propagation
so it doesn't also navigate into the berth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:01:40 +02:00
parent d1f6d6a427
commit 39c19b2340
3 changed files with 250 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import {
import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
import { formatCurrency } from '@/lib/utils/currency';
import type { BerthRow } from './berth-columns';
import { mooringLetterDot } from './mooring-letter-tone';
@@ -167,7 +168,9 @@ export function BerthCard({ berth }: BerthCardProps) {
{/* Status pill + tags */}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
<BerthStatusQuickEdit berthId={berth.id} currentStatus={berth.status}>
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
</BerthStatusQuickEdit>
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}

View File

@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
import { formatCurrency } from '@/lib/utils/currency';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { apiFetch } from '@/lib/api/client';
@@ -342,7 +343,9 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
const isManualUnreconciled = isManual && !r.latestInterestStage;
return (
<div className="inline-flex items-center gap-1.5">
<StatusBadge status={r.status} />
<BerthStatusQuickEdit berthId={r.id} currentStatus={r.status}>
<StatusBadge status={r.status} />
</BerthStatusQuickEdit>
{isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null}
</div>
);

View File

@@ -0,0 +1,242 @@
'use client';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils';
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
interface InterestOption {
id: string;
clientName: string;
pipelineStage: string;
}
/**
* Click-to-change berth status from the berths LIST. Wraps the status chip
* (passed as children) in a button that opens a compact change-status dialog
* — status dropdown + required reason (with quick-pick chips) + an optional
* interest link when moving to under_offer/sold. Same PATCH endpoint +
* validator + audit as the berth detail page. Reps without `berths.edit` see
* a plain, non-interactive chip via the PermissionGate fallback.
*/
export function BerthStatusQuickEdit({
berthId,
currentStatus,
children,
className,
}: {
berthId: string;
currentStatus: string;
children: ReactNode;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<PermissionGate resource="berths" action="edit" fallback={<>{children}</>}>
<button
type="button"
onClick={(e) => {
// The chip usually sits inside a clickable list card/row — stop the
// click from also navigating to the berth detail page.
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
title="Change status"
aria-label="Change berth status"
className={cn(
'cursor-pointer rounded-full outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
className,
)}
>
{children}
</button>
{open && (
<BerthStatusQuickEditDialog
berthId={berthId}
currentStatus={currentStatus}
open={open}
onOpenChange={setOpen}
/>
)}
</PermissionGate>
);
}
function BerthStatusQuickEditDialog({
berthId,
currentStatus,
open,
onOpenChange,
}: {
berthId: string;
currentStatus: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const reasonChips = useVocabulary('berth_status_change_reasons');
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<UpdateBerthStatusInput>({
resolver: zodResolver(updateBerthStatusSchema),
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
const status = watch('status');
const interestId = watch('interestId');
const showInterestPicker = status === 'under_offer' || status === 'sold';
// Active interests for the picker — only fetched once the picker is shown.
const interestsQuery = useQuery<{ data: InterestOption[] }>({
queryKey: ['interests', 'status-link-picker'],
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
enabled: open && showInterestPicker,
staleTime: 60_000,
});
const interestOptions = interestsQuery.data?.data ?? [];
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] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
toast.success('Status updated');
reset();
onOpenChange(false);
} catch (err) {
toastError(err);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change status</DialogTitle>
</DialogHeader>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
<FormErrorSummary
errors={errors}
labels={{ status: 'Status', reason: 'Reason', interestId: 'Linked interest' }}
/>
<div className="space-y-2">
<Label>New status</Label>
<Select
value={status}
onValueChange={(v) => {
setValue('status', v as (typeof BERTH_STATUSES)[number]);
// Clear any stale interest pick when returning to available.
if (v === 'available') setValue('interestId', undefined);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BERTH_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
{reasonChips.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{reasonChips.map((chip) => (
<button
type="button"
key={chip}
onClick={() => setValue('reason', chip, { shouldDirty: true })}
className="rounded-full border border-muted-foreground/20 bg-muted px-2.5 py-0.5 text-xs hover:bg-accent"
>
{chip}
</button>
))}
</div>
)}
<Textarea {...register('reason')} placeholder="Reason for status change…" rows={3} />
</div>
{showInterestPicker && (
<div className="space-y-2">
<Label>Linked interest (optional)</Label>
<Select
value={interestId ?? '__none__'}
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="No interest" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No interest</SelectItem>
{interestOptions.map((o) => (
<SelectItem key={o.id} value={o.id}>
{`${o.clientName || '(unnamed)'} · ${stageLabel(o.pipelineStage)}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Links this change to the interest it relates to it shows on that interest&apos;s
timeline and the berth attaches to it automatically.
</p>
</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>
);
}