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