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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user