A: Add AddressList.tsx component — list, create, edit, delete, set default
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user