feat(berths): click-to-change status from the list (chip → reason modal)
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:
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
242
src/components/berths/berth-status-quick-edit.tsx
Normal file
242
src/components/berths/berth-status-quick-edit.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user