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:
@@ -7,6 +7,7 @@ import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
||||
import { getClientTabs } from '@/components/clients/client-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { Address } from '@/components/shared/addresses-editor';
|
||||
|
||||
interface ClientData {
|
||||
id: string;
|
||||
@@ -64,6 +65,7 @@ interface ClientData {
|
||||
tenureType: string;
|
||||
status: string;
|
||||
}>;
|
||||
addresses: Address[];
|
||||
}
|
||||
|
||||
interface ClientDetailProps {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||
import { ContactsEditor } from '@/components/clients/contacts-editor';
|
||||
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type ClientPatchField =
|
||||
@@ -83,6 +84,7 @@ interface ClientTabsOptions {
|
||||
label?: string | null;
|
||||
isPrimary: boolean;
|
||||
}>;
|
||||
addresses?: Address[];
|
||||
yachts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -237,6 +239,18 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
<ClientReservationsTab clientId={clientId} activeReservations={client.activeReservations} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'addresses',
|
||||
label: 'Addresses',
|
||||
badge: client.addresses?.length ?? 0,
|
||||
content: (
|
||||
<AddressesEditor
|
||||
endpoint={`/api/v1/clients/${clientId}/addresses`}
|
||||
invalidateKey={['clients', clientId]}
|
||||
addresses={client.addresses ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CompanyDetailHeader } from '@/components/companies/company-detail-heade
|
||||
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { Address } from '@/components/shared/addresses-editor';
|
||||
|
||||
export interface CompanyData {
|
||||
id: string;
|
||||
@@ -25,6 +26,8 @@ export interface CompanyData {
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
addresses?: Address[];
|
||||
}
|
||||
|
||||
interface CompanyDetailProps {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
|
||||
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
|
||||
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
@@ -45,6 +46,7 @@ interface CompanyTabsCompany {
|
||||
billingEmail: string | null;
|
||||
notes: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
addresses?: Address[];
|
||||
}
|
||||
|
||||
interface CompanyTabsOptions {
|
||||
@@ -211,10 +213,12 @@ export function getCompanyTabs({
|
||||
{
|
||||
id: 'addresses',
|
||||
label: 'Addresses',
|
||||
badge: company.addresses?.length ?? 0,
|
||||
content: (
|
||||
<EmptyState
|
||||
title="Addresses"
|
||||
description="Company addresses coming soon — the addresses endpoint is pending wiring."
|
||||
<AddressesEditor
|
||||
endpoint={`/api/v1/companies/${companyId}/addresses`}
|
||||
invalidateKey={['companies', companyId]}
|
||||
addresses={company.addresses ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
411
src/components/shared/addresses-editor.tsx
Normal file
411
src/components/shared/addresses-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user