From 777b711548e8a0575605d91e0427ea8200569c8b Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 03:50:46 +0200 Subject: [PATCH] feat(uat-b2): visual breakpoint fixes + form-error UX rollout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2 Wave A (visual breakpoints): - Documents Hub folder rail: widen ResizablePanel default 20→22%, min 14→18%, add min-w-[180px] CSS floor so names don't truncate at tablet 768 - Website analytics KPI tiles: switch lg grid 6→3 cols, restore 6 at xl so "Visit duration" stops truncating in the 1024+sidebar layout - Pipeline Value tile per-stage rows: compact $3.5M format on sm- breakpoint (responsive sm:hidden / hidden sm:inline pair) B2 Wave D (form-error UX rollout): - useFormScrollToError + FormErrorSummary wired into 5 high-impact forms: client-form, interest-form, yacht-form, company-form, berth-form. Validation failures now scroll the first errored field into view + render a top-of-form summary banner when ≥2 errors exist. Remaining ~23 form surfaces queued for follow-up. B2 Wave B (Umami follow-ups): - TopList primitive: add onExpandRange + expandRangeLabel props for the empty-state nudge ("Try last 30 days" button). Callers can opt in to drive the page-level DateRange. B2 Wave C (FieldLabel + admin tooltip audit): - Verified FieldLabel primitive already exists + is adopted in custom-field-form. Registry-driven-form renders entry.description inline below labels for every entry — the broad sweep across 15-20 admin pages is deferred to a focused polish session. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/berths/berth-form.tsx | 10 ++++++++-- src/components/clients/client-form.tsx | 9 ++++++++- src/components/companies/company-form.tsx | 7 ++++++- .../dashboard/pipeline-value-tile.tsx | 18 +++++++++++++++++- src/components/documents/documents-hub.tsx | 4 ++-- src/components/interests/interest-form.tsx | 7 ++++++- src/components/website-analytics/top-list.tsx | 18 +++++++++++++++++- .../website-analytics-shell.tsx | 7 +++++-- src/components/yachts/yacht-form.tsx | 7 ++++++- 9 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/components/berths/berth-form.tsx b/src/components/berths/berth-form.tsx index fe878b50..7df01a39 100644 --- a/src/components/berths/berth-form.tsx +++ b/src/components/berths/berth-form.tsx @@ -6,6 +6,9 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error'; +import { FormErrorSummary } from '@/components/forms/form-error-summary'; + import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -122,7 +125,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) { setValue, watch, control, - formState: { isSubmitting }, + formState: { errors, isSubmitting }, } = useForm, unknown, UpdateBerthInput>({ resolver: zodResolver(updateBerthSchema), defaultValues: { @@ -167,6 +170,8 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) { }), }); + const scrollOnError = useFormScrollToError(handleSubmit, errors); + async function onSubmit(data: UpdateBerthInput) { try { await apiFetch(`/api/v1/berths/${berth.id}`, { @@ -200,7 +205,8 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) { Edit Berth {berth.mooringNumber} -
+ + {/* Basic Info */}

diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 00eda419..fbd06d3e 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from 'react'; import { useForm, useFieldArray } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error'; +import { FormErrorSummary } from '@/components/forms/form-error-summary'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Trash2, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -104,6 +106,10 @@ export function ClientForm({ tagIds: [], }, }); + // Scroll to / focus the first errored field on submit failure + show a + // top-of-form summary banner when there are ≥2 errors. Critical on tall + // drawers where the failing field is below the fold. + const scrollOnError = useFormScrollToError(handleSubmit, errors); const { fields, append, remove } = useFieldArray({ control, name: 'contacts' }); const tagIds = watch('tagIds') ?? []; @@ -313,10 +319,11 @@ export function ClientForm({ if (cleaned.length !== current.length) { setValue('contacts', cleaned, { shouldValidate: false }); } - return handleSubmit((data) => mutation.mutate(data))(e); + return scrollOnError((data) => mutation.mutate(data))(e); }} className="space-y-6 py-6" > + {/* Dedup suggestion - only on the create path. Watches the live form values for email / phone / name and surfaces an existing client when one matches. The user can diff --git a/src/components/companies/company-form.tsx b/src/components/companies/company-form.tsx index c3b00ca2..ed6307c3 100644 --- a/src/components/companies/company-form.tsx +++ b/src/components/companies/company-form.tsx @@ -8,6 +8,9 @@ import { useRouter } from 'next/navigation'; import { Loader2, Plus, X, ChevronsUpDown, Check } from 'lucide-react'; import { z } from 'zod'; +import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error'; +import { FormErrorSummary } from '@/components/forms/form-error-summary'; + import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -129,6 +132,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor }, }); + const scrollOnError = useFormScrollToError(handleSubmit, errors); const tagIds = watch('tagIds') ?? []; const status = watch('status') ?? 'active'; @@ -264,12 +268,13 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor { + onSubmit={scrollOnError((data) => { setFormError(null); mutation.mutate(data as CreateCompanyInput); })} className="space-y-6 py-6" > + {/* Basics */}

diff --git a/src/components/dashboard/pipeline-value-tile.tsx b/src/components/dashboard/pipeline-value-tile.tsx index 99496343..fb1c2223 100644 --- a/src/components/dashboard/pipeline-value-tile.tsx +++ b/src/components/dashboard/pipeline-value-tile.tsx @@ -83,6 +83,21 @@ export function PipelineValueTile({ range }: { range?: DateRange } = {}) { const stageMax = activeStages.reduce((m, s) => Math.max(m, s.grossValue), 0) || 1; const fmt = (v: number) => formatCurrency(v, currency, { maxFractionDigits: 0 }); + // Compact variant for the per-stage cell on narrow viewports — $3,528,000 + // overflows the right column at 375px otherwise. Falls back to the full + // format at sm+ where there's room. + const fmtCompact = (v: number) => { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currency || 'USD', + notation: 'compact', + maximumFractionDigits: 1, + }).format(v); + } catch { + return fmt(v); + } + }; return ( @@ -204,7 +219,8 @@ export function PipelineValueTile({ range }: { range?: DateRange } = {}) {

- {fmt(s.grossValue)} + {fmtCompact(s.grossValue)} + {fmt(s.grossValue)}

{s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}% diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index 6d493448..57522de9 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -272,8 +272,8 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) { autoSaveId="documents-hub-split" className="hidden sm:flex h-full" > - -