feat(addresses): full CRUD UI for client + company multi-address

Client and company detail pages each gain an Addresses tab with click-to-edit
fields wired to the existing CountryCombobox/SubdivisionCombobox primitives.
Adds a primary toggle that demotes the previous primary inside one transaction
so the partial unique index never trips.

- New service helpers: list/add/update/remove ClientAddress + CompanyAddress
- New routes: /api/v1/clients/[id]/addresses[/addressId], same under companies/
- New shared component: <AddressesEditor> reused by both detail surfaces
- Integration tests cover happy path, primary demotion, and tenant scoping

Tests: 747/747 vitest (was 741, +6 address tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 19:38:43 +02:00
parent 27cdbcc695
commit 46937bbcb9
12 changed files with 1117 additions and 5 deletions

View File

@@ -0,0 +1,411 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
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 { apiFetch } from '@/lib/api/client';
import type { CountryCode } from '@/lib/i18n/countries';
import { getCountryName } from '@/lib/i18n/countries';
import { getSubdivisionName } from '@/lib/i18n/subdivisions';
import { cn } from '@/lib/utils';
export interface Address {
id: string;
label: string;
streetAddress: string | null;
city: string | null;
subdivisionIso: string | null;
postalCode: string | null;
countryIso: string | null;
isPrimary: boolean;
}
type AddressPatch = Partial<Omit<Address, 'id'>>;
interface AddressesEditorProps {
/** Base API endpoint, e.g. `/api/v1/clients/abc/addresses` */
endpoint: string;
/** React-Query invalidation key for the parent entity. */
invalidateKey: readonly unknown[];
addresses: Address[];
}
export function AddressesEditor({ endpoint, invalidateKey, addresses }: AddressesEditorProps) {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
function invalidate() {
qc.invalidateQueries({ queryKey: invalidateKey });
}
const updateMutation = useMutation({
mutationFn: async ({ id, patch }: { id: string; patch: AddressPatch }) =>
apiFetch(`${endpoint}/${id}`, { method: 'PATCH', body: patch }),
onSuccess: invalidate,
});
const addMutation = useMutation({
mutationFn: async (data: AddressPatch) => apiFetch(endpoint, { method: 'POST', body: data }),
onSuccess: invalidate,
});
const removeMutation = useMutation({
mutationFn: async (id: string) => apiFetch(`${endpoint}/${id}`, { method: 'DELETE' }),
onSuccess: invalidate,
});
return (
<div className="space-y-2">
{addresses.length === 0 && !adding && (
<p className="text-sm text-muted-foreground">No addresses yet</p>
)}
{addresses.map((a) => (
<AddressCard
key={a.id}
address={a}
onUpdate={(patch) => updateMutation.mutateAsync({ id: a.id, patch })}
onRemove={async () => {
if (!confirm('Remove this address?')) return;
await removeMutation.mutateAsync(a.id);
}}
/>
))}
{adding ? (
<NewAddressForm
isFirst={addresses.length === 0}
onCancel={() => setAdding(false)}
onSave={async (data) => {
await addMutation.mutateAsync(data);
setAdding(false);
}}
/>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdding(true)}
className="w-full justify-center"
data-testid="add-address-button"
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add address
</Button>
)}
</div>
);
}
function AddressCard({
address,
onUpdate,
onRemove,
}: {
address: Address;
onUpdate: (patch: AddressPatch) => Promise<unknown>;
onRemove: () => void;
}) {
async function togglePrimary() {
if (address.isPrimary) return; // already primary; demoting via toggle would orphan all
try {
await onUpdate({ isPrimary: true });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
}
}
return (
<div className="group rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<InlineEditableField
value={address.label}
placeholder="Label (e.g. Home, Office)"
onSave={async (v) => {
if (!v) {
toast.error('Label is required');
return;
}
await onUpdate({ label: v });
}}
/>
</div>
<button
type="button"
onClick={togglePrimary}
title={address.isPrimary ? 'Primary address' : 'Make primary'}
className={cn(
'p-1 rounded hover:bg-background/60 transition-colors',
address.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)}
data-testid="address-primary-toggle"
>
<Star className={cn('h-3.5 w-3.5', address.isPrimary && 'fill-current')} />
</button>
<button
type="button"
onClick={onRemove}
title="Remove"
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
data-testid="address-remove-button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 pl-5">
<Field label="Street">
<InlineEditableField
value={address.streetAddress}
placeholder="123 Main St"
onSave={async (v) => {
await onUpdate({ streetAddress: v });
}}
/>
</Field>
<Field label="City">
<InlineEditableField
value={address.city}
placeholder="City"
onSave={async (v) => {
await onUpdate({ city: v });
}}
/>
</Field>
<Field label="Country">
<CountryFieldInline
value={address.countryIso}
onSave={async (iso) => {
// Clear subdivision if country changes — codes are scoped per country.
const patch: AddressPatch = { countryIso: iso };
if (iso !== address.countryIso) patch.subdivisionIso = null;
await onUpdate(patch);
}}
/>
</Field>
<Field label="Region">
<SubdivisionFieldInline
value={address.subdivisionIso}
country={(address.countryIso as CountryCode | null) ?? null}
onSave={async (code) => {
await onUpdate({ subdivisionIso: code });
}}
/>
</Field>
<Field label="Postal Code">
<InlineEditableField
value={address.postalCode}
placeholder="ZIP / Postal"
onSave={async (v) => {
await onUpdate({ postalCode: v });
}}
/>
</Field>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-baseline gap-2">
<span className="text-xs text-muted-foreground w-20 shrink-0">{label}</span>
<span className="flex-1 min-w-0">{children}</span>
</div>
);
}
function CountryFieldInline({
value,
onSave,
}: {
value: string | null;
onSave: (iso: string | null) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<CountryCombobox
value={value ?? null}
onChange={async (iso) => {
setEditing(false);
await onSave(iso ?? null);
}}
clearable
className="w-full"
/>
);
}
const display = value ? getCountryName(value, 'en') : null;
return (
<button
type="button"
onClick={() => setEditing(true)}
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
>
{display ?? <span className="text-muted-foreground italic">Not set</span>}
</button>
);
}
function SubdivisionFieldInline({
value,
country,
onSave,
}: {
value: string | null;
country: CountryCode | null;
onSave: (code: string | null) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<SubdivisionCombobox
value={value ?? null}
country={country}
onChange={async (code) => {
setEditing(false);
await onSave(code ?? null);
}}
clearable
className="w-full"
/>
);
}
if (!country) {
return <span className="text-muted-foreground italic text-xs">Pick country first</span>;
}
const display = value ? getSubdivisionName(value) : null;
return (
<button
type="button"
onClick={() => setEditing(true)}
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
>
{display ?? <span className="text-muted-foreground italic">Not set</span>}
</button>
);
}
function NewAddressForm({
onSave,
onCancel,
isFirst,
}: {
onSave: (data: AddressPatch) => Promise<void>;
onCancel: () => void;
isFirst: boolean;
}) {
const [label, setLabel] = useState('Primary');
const [streetAddress, setStreet] = useState('');
const [city, setCity] = useState('');
const [countryIso, setCountryIso] = useState<string | null>(null);
const [subdivisionIso, setSubdivisionIso] = useState<string | null>(null);
const [postalCode, setPostal] = useState('');
const [makePrimary, setMakePrimary] = useState(isFirst);
const [saving, setSaving] = useState(false);
async function submit() {
if (!label.trim()) {
toast.error('Label is required');
return;
}
setSaving(true);
try {
await onSave({
label: label.trim(),
streetAddress: streetAddress.trim() || null,
city: city.trim() || null,
countryIso,
subdivisionIso,
postalCode: postalCode.trim() || null,
isPrimary: makePrimary,
});
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add address');
} finally {
setSaving(false);
}
}
return (
<div className="rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Label (Home, Office)"
className="h-8"
autoFocus
disabled={saving}
/>
<Input
value={streetAddress}
onChange={(e) => setStreet(e.target.value)}
placeholder="Street address"
className="h-8"
disabled={saving}
/>
<Input
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="City"
className="h-8"
disabled={saving}
/>
<CountryCombobox
value={countryIso}
onChange={(iso) => {
setCountryIso(iso ?? null);
setSubdivisionIso(null);
}}
clearable
placeholder="Country"
/>
<SubdivisionCombobox
value={subdivisionIso}
country={(countryIso as CountryCode | null) ?? null}
onChange={(code) => setSubdivisionIso(code ?? null)}
clearable
placeholder="Region (optional)"
/>
<Input
value={postalCode}
onChange={(e) => setPostal(e.target.value)}
placeholder="Postal code"
className="h-8"
disabled={saving}
/>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={makePrimary}
onChange={(e) => setMakePrimary(e.target.checked)}
disabled={saving}
/>
Set as primary address
</label>
<div className="flex gap-2">
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
Cancel
</Button>
<Button type="button" size="sm" onClick={submit} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
</div>
</div>
</div>
);
}