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" > - -