fix(ux): T4 polish wave — empty-contact filter, redirect-on-create, friendly stage errors
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=<id> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -176,9 +176,30 @@ export function ClientForm({
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: CreateClientInput) => {
|
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) {
|
if (isEdit) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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 });
|
await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest });
|
||||||
if (tIds) {
|
if (tIds) {
|
||||||
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
|
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
|
||||||
@@ -187,7 +208,7 @@ export function ClientForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await apiFetch('/api/v1/clients', { method: 'POST', body: data });
|
await apiFetch('/api/v1/clients', { method: 'POST', body: payload });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
|
||||||
|
|
||||||
@@ -96,11 +97,30 @@ function findInTree(nodes: FolderNode[], id: string): FolderNode | null {
|
|||||||
return 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=<uuid>` → 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) {
|
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
||||||
// undefined = "All documents" (no folder selected / hub root)
|
// undefined = "All documents" (no folder selected / hub root)
|
||||||
// null = root folder only
|
// null = root folder only
|
||||||
// string = specific folder id
|
// string = specific folder id
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null | undefined>(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();
|
const { data: tree = [] } = useDocumentFolders();
|
||||||
|
|
||||||
@@ -144,9 +164,20 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
selectedFolder.entityId != null &&
|
selectedFolder.entityId != null &&
|
||||||
isEntityType(folderEntityType);
|
isEntityType(folderEntityType);
|
||||||
|
|
||||||
const handleFolderSelect = (id: string | null | undefined) => {
|
const handleFolderSelect = useCallback(
|
||||||
setSelectedFolderId(id);
|
(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 = (
|
const sidebarFooter = (
|
||||||
<PermissionGate resource="documents" action="manage_folders">
|
<PermissionGate resource="documents" action="manage_folders">
|
||||||
|
|||||||
@@ -333,12 +333,12 @@ export function InlineStagePicker({
|
|||||||
) : isCurrent ? (
|
) : isCurrent ? (
|
||||||
<Check className="size-3.5 text-muted-foreground" aria-hidden />
|
<Check className="size-3.5 text-muted-foreground" aria-hidden />
|
||||||
) : isOverride && canOverride ? (
|
) : isOverride && canOverride ? (
|
||||||
<span
|
// F22: was ⚑ unicode glyph — replaced with a Lucide
|
||||||
className="text-[10px] uppercase tracking-wide text-amber-600"
|
// icon to match the rest of the visual system.
|
||||||
title="Override required"
|
<AlertTriangle
|
||||||
>
|
className="size-3.5 text-amber-600"
|
||||||
⚑
|
aria-label="Override required"
|
||||||
</span>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Archive,
|
Archive,
|
||||||
@@ -144,6 +145,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
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.');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
|
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -85,6 +86,9 @@ interface InterestFormProps {
|
|||||||
|
|
||||||
export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) {
|
export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
const isEdit = !!interest;
|
const isEdit = !!interest;
|
||||||
|
|
||||||
const [clientOpen, setClientOpen] = useState(false);
|
const [clientOpen, setClientOpen] = useState(false);
|
||||||
@@ -220,13 +224,23 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
body: { tagIds: tIds },
|
body: { tagIds: tIds },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
return { id: interest!.id, created: false };
|
||||||
await apiFetch('/api/v1/interests', { method: 'POST', body: enriched });
|
|
||||||
}
|
}
|
||||||
|
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'] });
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
onOpenChange(false);
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -832,13 +832,22 @@ export async function changeInterestStage(
|
|||||||
throw new NotFoundError('Interest');
|
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
|
// Plan: yachtId required to leave the initial enquiry stage
|
||||||
if (
|
if (
|
||||||
existing.pipelineStage === 'enquiry' &&
|
existing.pipelineStage === 'enquiry' &&
|
||||||
data.pipelineStage !== 'enquiry' &&
|
data.pipelineStage !== 'enquiry' &&
|
||||||
!existing.yachtId
|
!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
|
// 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
|
// gates this on the `interests.override_stage` permission and requires
|
||||||
// a reason, recorded in the audit log below.
|
// a reason, recorded in the audit log below.
|
||||||
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
||||||
|
// F21: use the human-readable stage labels in error copy.
|
||||||
throw new ValidationError(
|
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)) {
|
if (data.override && (!data.reason || data.reason.trim().length < 5)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user