diff --git a/src/components/berths/berth-card.tsx b/src/components/berths/berth-card.tsx index e1f919df..53e370f9 100644 --- a/src/components/berths/berth-card.tsx +++ b/src/components/berths/berth-card.tsx @@ -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 */}
- {statusLabel} + + {statusLabel} + {tags.slice(0, 2).map((tag) => ( ))} diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 3826ce82..2d43008e 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -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[] = [ const isManualUnreconciled = isManual && !r.latestInterestStage; return (
- + + + {isManual ? : null}
); diff --git a/src/components/berths/berth-status-quick-edit.tsx b/src/components/berths/berth-status-quick-edit.tsx new file mode 100644 index 00000000..f7c96976 --- /dev/null +++ b/src/components/berths/berth-status-quick-edit.tsx @@ -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 = { + 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 ( + {children}}> + + {open && ( + + )} + + ); +} + +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({ + 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 ( + + + + Change status + +
+ +
+ + +
+
+ + {reasonChips.length > 0 && ( +
+ {reasonChips.map((chip) => ( + + ))} +
+ )} +