fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md

Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:50:07 +02:00
parent b2588ecdd8
commit 4233aa3ac3
94 changed files with 1674 additions and 895 deletions

View File

@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { CountryCode } from '@/lib/i18n/countries';
@@ -41,6 +42,7 @@ interface AddressesEditorProps {
export function AddressesEditor({ endpoint, invalidateKey, addresses }: AddressesEditorProps) {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
function invalidate() {
qc.invalidateQueries({ queryKey: invalidateKey });
@@ -74,7 +76,12 @@ export function AddressesEditor({ endpoint, invalidateKey, addresses }: Addresse
address={a}
onUpdate={(patch) => updateMutation.mutateAsync({ id: a.id, patch })}
onRemove={async () => {
if (!confirm('Remove this address?')) return;
const ok = await confirm({
title: 'Remove address',
description: 'Remove this address?',
confirmLabel: 'Remove',
});
if (!ok) return;
await removeMutation.mutateAsync(a.id);
}}
/>
@@ -102,6 +109,7 @@ export function AddressesEditor({ endpoint, invalidateKey, addresses }: Addresse
Add address
</Button>
)}
{confirmDialog}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
import { STAGE_LABELS, formatSource, type PipelineStage } from '@/lib/constants';
import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants';
interface AuditRow {
id: string;
@@ -51,11 +51,11 @@ function formatValueForField(field: string | null, value: unknown): string {
const f = field.replace(/_/g, '').toLowerCase();
if (typeof value === 'string') {
if (f === 'pipelinestage' || f === 'stage') {
return STAGE_LABELS[value as PipelineStage] ?? value.replace(/_/g, ' ');
return STAGE_LABELS[value as PipelineStage] ?? formatEnum(value);
}
if (f === 'source') return formatSource(value) ?? value;
if (f === 'leadcategory' || f === 'category' || f === 'outcome') {
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return formatEnum(value);
}
}
}

View File

@@ -66,6 +66,14 @@ export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaPr
* Enter/blur and cancels on Escape.
*/
export function InlineEditableField(props: InlineEditableFieldProps) {
// Key-based remount: keying the inner body on `value` re-runs the
// `useState(value)` initializer whenever the prop changes externally
// (optimistic-update settle, parent refetch). Replaces the prior
// useEffect(setDraft, [value]) Compiler-flagged set-state-in-effect.
return <InlineEditableFieldBody key={String(props.value ?? '')} {...props} />;
}
function InlineEditableFieldBody(props: InlineEditableFieldProps) {
const { value, displayValue, onSave, placeholder, emptyText = '-', className, disabled } = props;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
@@ -73,10 +81,6 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setDraft(value ?? '');
}, [value]);
useEffect(() => {
if (editing) {
if (inputRef.current) {

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -42,15 +42,16 @@ export function OwnerPicker({
disabled,
}: OwnerPickerProps) {
const [open, setOpen] = useState(false);
const [type, setType] = useState<'client' | 'company'>(value?.type ?? 'client');
// `type` is derived: when an owner is selected the prop wins; with no
// selection the user's local tab pick is the source of truth. Render-
// phase derivation replaces the prior useEffect(setType, [value?.type])
// that the Compiler flagged as set-state-in-effect.
const [localType, setLocalType] = useState<'client' | 'company'>(value?.type ?? 'client');
const type: 'client' | 'company' = value?.type ?? localType;
const setType = setLocalType;
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300);
// Keep local `type` in sync if value.type changes externally.
useEffect(() => {
if (value?.type) setType(value.type);
}, [value?.type]);
const endpoint =
type === 'client'
? `/api/v1/clients/options?search=${encodeURIComponent(debounced)}`

View File

@@ -36,18 +36,15 @@ export function ReminderDaysInput({
className,
}: ReminderDaysInputProps) {
const isPreset = typeof value === 'number' && (PRESETS as readonly number[]).includes(value);
const [customStr, setCustomStr] = React.useState<string>(() =>
!isPreset && typeof value === 'number' ? String(value) : '',
);
// Sync external value → custom input when it changes to a non-preset.
React.useEffect(() => {
if (typeof value === 'number' && !(PRESETS as readonly number[]).includes(value)) {
setCustomStr(String(value));
} else if (value == null) {
setCustomStr('');
}
}, [value]);
// Derived from the prop: a non-preset numeric value renders its string
// form; null/preset values show empty. Local edits flow through onChange
// and bounce back via `value` so render-phase derivation stays correct.
const customStr = !isPreset && typeof value === 'number' ? String(value) : '';
const [draftStr, setDraftStr] = React.useState<string>(customStr);
// When the user is mid-typing (`draftStr` set), keep their text; otherwise
// show the derived value. Resets when value changes externally because
// the derived `customStr` overrides on commit.
const shownStr = draftStr !== '' ? draftStr : customStr;
return (
<div className={cn('space-y-2', className)}>
@@ -79,10 +76,10 @@ export function ReminderDaysInput({
step={1}
placeholder={placeholder}
disabled={disabled}
value={customStr}
value={shownStr}
onChange={(e) => {
const raw = e.target.value;
setCustomStr(raw);
setDraftStr(raw);
if (raw === '') {
onChange(null);
return;
@@ -90,6 +87,7 @@ export function ReminderDaysInput({
const n = Number.parseInt(raw, 10);
if (Number.isFinite(n) && n > 0) onChange(n);
}}
onBlur={() => setDraftStr('')}
/>
</div>
);

View File

@@ -15,7 +15,7 @@
* - Body length capped at 50KB; char count visible.
*/
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
@@ -59,7 +59,20 @@ interface PreviewResponse {
data: { html: string; markdown: string; unresolved: string[] };
}
export function SendDocumentDialog({
export function SendDocumentDialog(props: SendDocumentDialogProps) {
// Key-based remount: the body is keyed on `open + recipient.email` so
// its useState initializers re-run each time the dialog opens with a
// new recipient. Replaces the prior useEffect-driven reset that the
// Compiler flagged as set-state-in-effect.
return (
<SendDocumentDialogInner
key={props.open ? `open:${props.recipient.email ?? ''}` : 'closed'}
{...props}
/>
);
}
function SendDocumentDialogInner({
open,
onOpenChange,
documentKind,
@@ -73,14 +86,6 @@ export function SendDocumentDialog({
const [emailOverride, setEmailOverride] = useState(recipient.email ?? '');
const [customBody, setCustomBody] = useState('');
useEffect(() => {
if (open) {
setStep('compose');
setEmailOverride(recipient.email ?? '');
setCustomBody('');
}
}, [open, recipient.email]);
const recipientForApi = useMemo(
() => ({
clientId: recipient.clientId,