From 84468386d947829a989a7a9dd1e1cc7113f572fc Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 23:42:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(ux):=20T4=20polish=20wave=20=E2=80=94=20emp?= =?UTF-8?q?ty-contact=20filter,=20redirect-on-create,=20friendly=20stage?= =?UTF-8?q?=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F19: client form drops empty-value contacts on submit; auto-promotes first remaining row to primary if none flagged. F20: new-interest dialog redirects to the detail page on create instead of bouncing back to the list. F21: stage-transition validation errors render with STAGE_LABELS — "Yacht is required before leaving the Enquiry stage." (was "yachtId is required before leaving stage=enquiry"). F22: blocked-stage marker swapped from the ⚑ unicode glyph to a Lucide AlertTriangle with aria-label. F25: documents-hub folder selection moves to ?folder= querystring so deep-link / browser-back / refresh round-trip the current folder. F26: reopen-outcome action now toasts "Outcome cleared — interest is open again." F27: stage PATCH where target === current short-circuits to a no-op return; downstream callers don't see a phantom stage_change audit row. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/clients/client-form.tsx | 25 +++++++++++- src/components/documents/documents-hub.tsx | 39 +++++++++++++++++-- .../interests/inline-stage-picker.tsx | 12 +++--- .../interests/interest-detail-header.tsx | 4 ++ src/components/interests/interest-form.tsx | 20 ++++++++-- src/lib/services/interests.service.ts | 14 ++++++- 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 9fd75b36..8e85768d 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -176,9 +176,30 @@ export function ClientForm({ const mutation = useMutation({ mutationFn: async (data: CreateClientInput) => { + // F19: drop contact rows whose value is empty/whitespace before + // submitting. The form pre-adds an "empty primary" contact row + // for convenience; reps who only want to record a name shouldn't + // be forced to either fill it or delete it. + const cleanedContacts = (data.contacts ?? []).filter( + (c) => typeof c.value === 'string' && c.value.trim().length > 0, + ); + if (cleanedContacts.length === 0) { + // The API still requires ≥1 contact. The form-level required + // marker on the email input also fires HTML5 validation; this + // is the fall-back if the rep wiped the value after focus. + throw Object.assign(new Error('At least one contact is required.'), { status: 400 }); + } + // If none of the remaining contacts is flagged primary, promote + // the first one — guards against a rep removing the originally- + // primary row and leaving an orphan set. + if (!cleanedContacts.some((c) => c.isPrimary)) { + cleanedContacts[0]!.isPrimary = true; + } + const payload: CreateClientInput = { ...data, contacts: cleanedContacts }; + if (isEdit) { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { contacts, tagIds: tIds, ...rest } = data; + const { contacts, tagIds: tIds, ...rest } = payload; await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest }); if (tIds) { await apiFetch(`/api/v1/clients/${client!.id}/tags`, { @@ -187,7 +208,7 @@ export function ClientForm({ }); } } else { - await apiFetch('/api/v1/clients', { method: 'POST', body: data }); + await apiFetch('/api/v1/clients', { method: 'POST', body: payload }); } }, onSuccess: () => { diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index daffd38a..58982378 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react'; @@ -96,11 +97,30 @@ function findInTree(nodes: FolderNode[], id: string): FolderNode | null { return null; } +// URL encoding for the folder selection tri-state: +// no `folder` param → undefined (hub root / "All documents") +// `folder=root` → null (root folder only) +// `folder=` → string (specific folder) +function decodeFolderParam(raw: string | null): string | null | undefined { + if (raw == null) return undefined; + if (raw === 'root') return null; + return raw; +} +function encodeFolderParam(value: string | null | undefined): string | null { + if (value === undefined) return null; + if (value === null) return 'root'; + return value; +} + export function DocumentsHub({ portSlug }: DocumentsHubProps) { // undefined = "All documents" (no folder selected / hub root) // null = root folder only // string = specific folder id - const [selectedFolderId, setSelectedFolderId] = useState(undefined); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const folderParam = searchParams.get('folder'); + const selectedFolderId = useMemo(() => decodeFolderParam(folderParam), [folderParam]); const { data: tree = [] } = useDocumentFolders(); @@ -144,9 +164,20 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) { selectedFolder.entityId != null && isEntityType(folderEntityType); - const handleFolderSelect = (id: string | null | undefined) => { - setSelectedFolderId(id); - }; + const handleFolderSelect = useCallback( + (id: string | null | undefined) => { + const next = new URLSearchParams(searchParams.toString()); + const encoded = encodeFolderParam(id); + if (encoded == null) { + next.delete('folder'); + } else { + next.set('folder', encoded); + } + const qs = next.toString(); + router.replace((qs ? `${pathname}?${qs}` : pathname) as never, { scroll: false }); + }, + [router, pathname, searchParams], + ); const sidebarFooter = ( diff --git a/src/components/interests/inline-stage-picker.tsx b/src/components/interests/inline-stage-picker.tsx index 3536c4c4..869a7c98 100644 --- a/src/components/interests/inline-stage-picker.tsx +++ b/src/components/interests/inline-stage-picker.tsx @@ -333,12 +333,12 @@ export function InlineStagePicker({ ) : isCurrent ? ( ) : isOverride && canOverride ? ( - - ⚑ - + // F22: was ⚑ unicode glyph — replaced with a Lucide + // icon to match the rest of the visual system. + ) : null} diff --git a/src/components/interests/interest-detail-header.tsx b/src/components/interests/interest-detail-header.tsx index fdfd4059..372dcc5c 100644 --- a/src/components/interests/interest-detail-header.tsx +++ b/src/components/interests/interest-detail-header.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { Pencil, Archive, @@ -144,6 +145,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interests', interest.id] }); queryClient.invalidateQueries({ queryKey: ['interests'] }); + // F26: confirm to the user that the action ran — pre-fix the + // button gave no feedback and reps weren't sure if it took. + toast.success('Outcome cleared — interest is open again.'); }, }); diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index a822a5bd..dfa25a4f 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react'; +import { useParams, useRouter } from 'next/navigation'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; @@ -85,6 +86,9 @@ interface InterestFormProps { export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) { const queryClient = useQueryClient(); + const router = useRouter(); + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; const isEdit = !!interest; const [clientOpen, setClientOpen] = useState(false); @@ -220,13 +224,23 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: body: { tagIds: tIds }, }); } - } else { - await apiFetch('/api/v1/interests', { method: 'POST', body: enriched }); + return { id: interest!.id, created: false }; } + const res = await apiFetch<{ data: { id: string } }>('/api/v1/interests', { + method: 'POST', + body: enriched, + }); + return { id: res.data.id, created: true }; }, - onSuccess: () => { + onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['interests'] }); onOpenChange(false); + // F20: navigate to the new interest's detail page so the rep can + // start the workflow immediately. Edits stay in place — no point + // re-loading the same row's detail page they just came from. + if (result.created && portSlug) { + router.push(`/${portSlug}/interests/${result.id}` as never); + } }, }); diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index c2210280..79761d06 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -832,13 +832,22 @@ export async function changeInterestStage( throw new NotFoundError('Interest'); } + // F27: same-stage write is a no-op. Return the existing row without + // bumping updatedAt or emitting an audit log entry — pre-fix every + // re-submit (e.g. accidental double-click) wrote a "Same → Same" + // audit entry and triggered downstream invalidations. + if (existing.pipelineStage === data.pipelineStage) { + return existing; + } + // Plan: yachtId required to leave the initial enquiry stage if ( existing.pipelineStage === 'enquiry' && data.pipelineStage !== 'enquiry' && !existing.yachtId ) { - throw new ValidationError('yachtId is required before leaving stage=enquiry'); + // F21: user-readable; was "yachtId is required before leaving stage=enquiry" + throw new ValidationError('A yacht must be linked before leaving the Enquiry stage.'); } // Block egregious skips. The transition table allows reasonable forward @@ -848,8 +857,9 @@ export async function changeInterestStage( // gates this on the `interests.override_stage` permission and requires // a reason, recorded in the audit log below. if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) { + // F21: use the human-readable stage labels in error copy. throw new ValidationError( - `Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}". Use the override option if you need to skip stages — requires a reason.`, + `Cannot move interest from "${STAGE_LABELS[existing.pipelineStage as PipelineStage] ?? existing.pipelineStage}" directly to "${STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage}". Use the override option if you need to skip stages — requires a reason.`, ); } if (data.override && (!data.reason || data.reason.trim().length < 5)) {