A: Add AddressList.tsx component — list, create, edit, delete, set default

This commit is contained in:
2026-07-04 01:37:01 +00:00
parent 77c2e2265c
commit 26a01f706f
+288
View File
@@ -0,0 +1,288 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface Address {
id: string;
entity_type: string;
entity_id: string;
label: string;
address_type: string;
street: string | null;
street_number: string | null;
city: string | null;
zip: string | null;
state: string | null;
country: string | null;
is_default: boolean;
created_at: string | null;
updated_at: string | null;
}
interface AddressListProps {
entityType: 'company' | 'contact';
entityId: string;
}
const ADDRESS_TYPE_COLORS: Record<string, string> = {
billing: 'bg-blue-100 text-blue-800',
shipping: 'bg-purple-100 text-purple-800',
headquarters: 'bg-green-100 text-green-800',
branch: 'bg-yellow-100 text-yellow-800',
private: 'bg-pink-100 text-pink-800',
other: 'bg-gray-100 text-gray-800',
};
export function AddressList({ entityType, entityId }: AddressListProps) {
const { t } = useTranslation();
const [addresses, setAddresses] = useState<Address[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
const fetchAddresses = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await apiGet<{ items: Address[]; total: number }>(
`/addresses?entity_type=${entityType}&entity_id=${entityId}`
);
setAddresses(data.items);
} catch (err: any) {
setError(err.message || t('common.error'));
} finally {
setLoading(false);
}
}, [entityType, entityId, t]);
React.useEffect(() => {
fetchAddresses();
}, [fetchAddresses]);
const handleDelete = async (id: string) => {
if (!window.confirm(t('address.confirmDelete'))) return;
try {
await apiDelete(`/addresses/${id}`);
await fetchAddresses();
} catch (err: any) {
setError(err.message || t('common.error'));
}
};
const handleSetDefault = async (id: string) => {
try {
await apiPatch(`/addresses/${id}`, { is_default: true });
await fetchAddresses();
} catch (err: any) {
setError(err.message || t('common.error'));
}
};
const handleSave = async (data: Partial<Address>) => {
try {
if (editingAddress) {
await apiPatch(`/addresses/${editingAddress.id}`, data);
} else {
await apiPost('/addresses', { ...data, entity_type: entityType, entity_id: entityId });
}
setShowForm(false);
setEditingAddress(null);
await fetchAddresses();
} catch (err: any) {
setError(err.message || t('common.error'));
}
};
if (loading) {
return <div className="text-secondary-500">{t('common.loading')}</div>;
}
return (
<div className="space-y-4" data-testid="address-list">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-secondary-900">{t('address.title')}</h3>
<button
onClick={() => { setEditingAddress(null); setShowForm(true); }}
className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
data-testid="address-add-btn"
>
{t('address.addAddress')}
</button>
</div>
{error && (
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{error}</div>
)}
{showForm && (
<AddressForm
address={editingAddress}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditingAddress(null); }}
/>
)}
{addresses.length === 0 && !showForm ? (
<p className="text-sm text-secondary-500">{t('address.noAddresses')}</p>
) : (
<div className="space-y-3">
{addresses.map((addr) => (
<div
key={addr.id}
className="p-4 border border-secondary-200 rounded-lg flex items-start justify-between"
data-testid={`address-item-${addr.id}`}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-secondary-900">{addr.label}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${ADDRESS_TYPE_COLORS[addr.address_type] || ADDRESS_TYPE_COLORS.other}`}>
{t(`addressType.${addr.address_type}`)}
</span>
{addr.is_default && (
<span className="px-2 py-0.5 text-xs rounded-full bg-primary-100 text-primary-700">
{t('address.defaultAddress')}
</span>
)}
</div>
<div className="text-sm text-secondary-600">
{addr.street && <span>{addr.street}</span>}
{addr.street_number && <span> {addr.street_number}</span>}
{(addr.street || addr.street_number) && <br />}
{addr.zip && <span>{addr.zip} </span>}
{addr.city && <span>{addr.city}</span>}
{(addr.zip || addr.city) && <br />}
{addr.state && <span>{addr.state}, </span>}
{addr.country && <span>{addr.country}</span>}
</div>
</div>
<div className="flex items-center gap-2">
{!addr.is_default && (
<button
onClick={() => handleSetDefault(addr.id)}
className="text-xs text-primary-600 hover:underline"
>
{t('address.setDefault')}
</button>
)}
<button
onClick={() => { setEditingAddress(addr); setShowForm(true); }}
className="text-xs text-secondary-600 hover:underline"
>
{t('common.edit')}
</button>
<button
onClick={() => handleDelete(addr.id)}
className="text-xs text-red-600 hover:underline"
>
{t('common.delete')}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
function AddressForm({
address,
onSave,
onCancel,
}: {
address: Address | null;
onSave: (data: Partial<Address>) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [label, setLabel] = useState(address?.label || '');
const [addressType, setAddressType] = useState(address?.address_type || 'headquarters');
const [street, setStreet] = useState(address?.street || '');
const [streetNumber, setStreetNumber] = useState(address?.street_number || '');
const [city, setCity] = useState(address?.city || '');
const [zip, setZip] = useState(address?.zip || '');
const [state, setState] = useState(address?.state || '');
const [country, setCountry] = useState(address?.country || '');
const [isDefault, setIsDefault] = useState(address?.is_default || false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({ label, address_type: addressType, street, street_number: streetNumber, city, zip, state, country, is_default: isDefault });
};
const addressTypes = ['billing', 'shipping', 'headquarters', 'branch', 'private', 'other'];
return (
<form onSubmit={handleSubmit} className="p-4 border border-secondary-200 rounded-lg space-y-3" data-testid="address-form">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.label')}</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.type')}</label>
<select
value={addressType}
onChange={(e) => setAddressType(e.target.value)}
className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm"
>
{addressTypes.map((type) => (
<option key={type} value={type}>{t(`addressType.${type}`)}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-sm font-medium text-secondary-700">{t('address.street')}</label>
<input type="text" value={street} onChange={(e) => setStreet(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.streetNumber')}</label>
<input type="text" value={streetNumber} onChange={(e) => setStreetNumber(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.zip')}</label>
<input type="text" value={zip} onChange={(e) => setZip(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.city')}</label>
<input type="text" value={city} onChange={(e) => setCity(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.state')}</label>
<input type="text" value={state} onChange={(e) => setState(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.country')}</label>
<input type="text" value={country} onChange={(e) => setCountry(e.target.value)} maxLength={2} placeholder="DE" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm font-medium text-secondary-700">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" />
{t('address.defaultAddress')}
</label>
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">
{t('common.cancel')}
</button>
<button type="submit" className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">
{t('common.save')}
</button>
</div>
</form>
);
}