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 { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
|
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
|
||||||
import { formatCurrency } from '@/lib/utils/currency';
|
import { formatCurrency } from '@/lib/utils/currency';
|
||||||
import type { BerthRow } from './berth-columns';
|
import type { BerthRow } from './berth-columns';
|
||||||
import { mooringLetterDot } from './mooring-letter-tone';
|
import { mooringLetterDot } from './mooring-letter-tone';
|
||||||
@@ -167,7 +168,9 @@ export function BerthCard({ berth }: BerthCardProps) {
|
|||||||
|
|
||||||
{/* Status pill + tags */}
|
{/* Status pill + tags */}
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
<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) => (
|
{tags.slice(0, 2).map((tag) => (
|
||||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
|
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
|
||||||
import { formatCurrency } from '@/lib/utils/currency';
|
import { formatCurrency } from '@/lib/utils/currency';
|
||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
@@ -342,7 +343,9 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
const isManualUnreconciled = isManual && !r.latestInterestStage;
|
const isManualUnreconciled = isManual && !r.latestInterestStage;
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1.5">
|
<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}
|
{isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null}
|
||||||
</div>
|
</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