Files
delivery-app-web/src/CustomersView.tsx
T

102 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import type { Customer, CustomerAddress } from '@model/shared';
import { changeCustomer, resolveCustomerConflict, type LocalAddress, type LocalCustomer, type LocalDelivery, type OutboxEntry } from './db';
type CustomerData = Omit<Customer, 'id' | 'version' | 'createdAt' | 'deletedAt' | 'updatedAt'>;
const windowLabels = { morning: 'בוקר', afternoon: 'צהריים', lateNight: 'לילה' } as const;
const windowDefaults = { morning: ['08:00', '12:00'], afternoon: ['12:00', '17:00'], lateNight: ['20:00', '23:00'] } as const;
function dataFromForm(form: HTMLFormElement): CustomerData {
const fields = new FormData(form);
const deliveryWindows = Object.fromEntries(Object.entries(windowDefaults).map(([key, defaults]) => [key, fields.get(`${key}Enabled`) === 'on' ? { start: String(fields.get(`${key}Start`) || defaults[0]), end: String(fields.get(`${key}End`) || defaults[1]) } : null])) as CustomerData['deliveryWindows'];
return { name: String(fields.get('name') ?? ''), contactName: String(fields.get('contactName') ?? ''), phone: String(fields.get('phone') ?? ''), comments: String(fields.get('comments') ?? ''), deliveryWindows };
}
function CustomerForm({ customer, busy, submit, cancel }: { customer?: LocalCustomer; busy: boolean; submit: (data: CustomerData) => Promise<void>; cancel?: () => void }) {
return <form className="customer-form" onSubmit={event => { event.preventDefault(); void submit(dataFromForm(event.currentTarget)); }}>
<div className="field-pair"><label>שם הלקוח<input name="name" required maxLength={120} defaultValue={customer?.name} placeholder="שם המרפאה או הלקוח" /></label><label>איש קשר<input name="contactName" maxLength={100} defaultValue={customer?.contactName} autoComplete="name" placeholder="שם מלא" /></label></div>
<label>מספר טלפון<input name="phone" dir="ltr" inputMode="tel" maxLength={30} pattern="[0-9+().\-\s]*" defaultValue={customer?.phone} autoComplete="tel" placeholder="050-0000000" /></label>
<fieldset className="delivery-windows"><legend>חלונות מסירה מועדפים</legend>{(Object.keys(windowLabels) as Array<keyof typeof windowLabels>).map(key => {
const value = customer?.deliveryWindows[key]; const defaults = windowDefaults[key];
return <div className="window-row" key={key}><label className="check-field"><input name={`${key}Enabled`} type="checkbox" defaultChecked={Boolean(value)} />{windowLabels[key]}</label><label><span className="sr-only">תחילת {windowLabels[key]}</span><input aria-label={`תחילת ${windowLabels[key]}`} name={`${key}Start`} type="time" defaultValue={value?.start ?? defaults[0]} /></label><span>עד</span><label><span className="sr-only">סיום {windowLabels[key]}</span><input aria-label={`סיום ${windowLabels[key]}`} name={`${key}End`} type="time" defaultValue={value?.end ?? defaults[1]} /></label></div>;
})}</fieldset>
<label>הערות ללקוח <span className="optional">לא חובה</span><textarea name="comments" rows={3} maxLength={1000} defaultValue={customer?.comments} placeholder="הנחיות קבועות, חניה, קומה או פרטים חשובים" /></label>
<div className="form-actions"><button className="primary" disabled={busy}>{busy ? 'שומר…' : customer ? 'שמירת שינויים' : 'יצירת לקוח'}</button>{cancel && <button className="secondary" type="button" onClick={cancel}>ביטול</button>}</div>
</form>;
}
function AddressText({ address }: { address: CustomerAddress | LocalAddress }) {
return <>{address.street ? `${address.street} ${address.buildingNumber}, ${address.city}` : address.address}</>;
}
export function CustomersView({ customers, addresses, deliveries, outbox, busy, setBusy, save, onChanged, createRequest = 0 }: {
customers: LocalCustomer[]; addresses: LocalAddress[]; deliveries: LocalDelivery[]; outbox: OutboxEntry[]; busy: boolean;
setBusy: (value: boolean) => void; save: (kind: 'customer.create' | 'address.create', data: unknown) => Promise<string>; onChanged: (message: string) => void; createRequest?: number;
}) {
const [selectedId, setSelectedId] = useState<string>();
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [error, setError] = useState('');
const visible = useMemo(() => customers.filter(customer => showArchived || !customer.deletedAt), [customers, showArchived]);
const selectedCandidate = customers.find(customer => customer.id === selectedId);
const selected = selectedId ? (selectedCandidate && (showArchived || !selectedCandidate.deletedAt) ? selectedCandidate : undefined) : visible[0];
const relatedAddresses = addresses.filter(address => address.customerId === selected?.id && !address.deletedAt);
const relatedDeliveries = deliveries.filter(delivery => delivery.customerId === selected?.id && !delivery.deletedAt);
const conflict = outbox.find(entry => entry.operation.entityId === selected?.id && entry.serverConflict?.entityType === 'customer');
// A Dexie live query may publish the new row just after save() resolves. Do not
// replace an explicitly selected new ID with the old first row during that gap.
useEffect(() => { if (!selectedId && selected) setSelectedId(selected.id); }, [selected, selectedId]);
useEffect(() => { if (createRequest > 0) { setCreating(true); setEditing(false); setConfirmRemove(false); setError(''); } }, [createRequest]);
async function create(data: CustomerData) {
setBusy(true); setError('');
try { const id = await save('customer.create', data); setSelectedId(id); setCreating(false); onChanged('הלקוח נשמר במכשיר'); }
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לשמור את הלקוח.'); }
finally { setBusy(false); }
}
async function update(data: CustomerData) {
if (!selected) return; setBusy(true); setError('');
try { await changeCustomer(selected.id, selected.version, 'customer.update', data); setEditing(false); onChanged('פרטי הלקוח נשמרו במכשיר'); }
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לעדכן את הלקוח.'); }
finally { setBusy(false); }
}
async function archive() {
if (!selected) return; setBusy(true); setError('');
try { await changeCustomer(selected.id, selected.version, 'customer.remove', {}); setConfirmRemove(false); setSelectedId(undefined); onChanged('הלקוח הועבר לארכיון. הרשומות המשויכות נשמרו.'); }
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן להעביר את הלקוח לארכיון.'); }
finally { setBusy(false); }
}
async function addAddress(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); if (!selected) return;
const form = event.currentTarget, fields = new FormData(form); const city = String(fields.get('city') ?? ''), street = String(fields.get('street') ?? ''), buildingNumber = String(fields.get('buildingNumber') ?? '');
setBusy(true); setError('');
try { await save('address.create', { customerId: selected.id, label: fields.get('label'), city, street, buildingNumber, address: `${street} ${buildingNumber}, ${city}`.trim() }); form.reset(); onChanged('הכתובת נשמרה במכשיר'); }
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לשמור את הכתובת.'); }
finally { setBusy(false); }
}
async function resolve(choice: 'server' | 'local') {
if (!conflict?.serverConflict) return; setBusy(true); setError('');
try { await resolveCustomerConflict(conflict.operationId, conflict.serverConflict.record.version, choice); onChanged(choice === 'server' ? 'גרסת השרת נשמרה' : 'השינוי שלך הוכן לסנכרון מחדש'); }
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לפתור את ההתנגשות.'); }
finally { setBusy(false); }
}
return <div className="clients-layout">
<section className="glass-panel clients-list"><div className="panel-heading"><div><p className="eyebrow">לקוחות</p><h2>ספר לקוחות</h2></div><button className="icon-button" aria-label="לקוח חדש" onClick={() => setCreating(true)}>+</button></div>
<div className="client-list-controls"><label className="check-field"><input type="checkbox" checked={showArchived} onChange={event => setShowArchived(event.target.checked)} />הצגת ארכיון</label><span className="count">{visible.length}</span></div>
<div className="client-list-scroll">{visible.map(customer => <button key={customer.id} className={`client-row ${selected?.id === customer.id ? 'selected' : ''}`} onClick={() => { setSelectedId(customer.id); setCreating(false); setEditing(false); }}><span className="client-avatar">{customer.name.slice(0, 1)}</span><span><strong>{customer.name}</strong><small>{customer.deletedAt ? 'בארכיון' : customer.contactName || customer.phone || 'ללא איש קשר'}</small></span><span className={`sync-dot ${customer.syncState}`} aria-label={customer.syncState} /></button>)}</div>
</section>
<section className="glass-panel client-detail">
{creating ? <><div className="panel-heading"><div><p className="eyebrow">כרטיס חדש</p><h2>הוספת לקוח</h2></div></div>{error && <p className="alert error" role="alert">{error}</p>}<CustomerForm busy={busy} submit={create} cancel={() => setCreating(false)} /></> : !selected ? <div className="empty"><span className="empty-mark">◎</span><h3>בחרו לקוח</h3><p>או צרו לקוח חדש כדי להתחיל.</p><button className="primary compact" onClick={() => setCreating(true)}>לקוח חדש</button></div> : <>
<div className="client-hero"><span className="client-avatar large">{selected.name.slice(0, 1)}</span><div><p className="eyebrow">כרטיס לקוח</p><h2>{selected.name}</h2><p>{selected.contactName || 'טרם הוגדר איש קשר'}</p></div><div className="client-actions">{!selected.deletedAt && <><button className="secondary" disabled={busy || selected.syncState !== 'synced'} onClick={() => setEditing(!editing)}>עריכה</button><button className="danger-button" disabled={busy || selected.syncState !== 'synced'} onClick={() => setConfirmRemove(true)}>הסרה</button></>}</div></div>
{error && <p className="alert error" role="alert">{error}</p>}
{conflict?.serverConflict?.entityType === 'customer' && <div className="conflict-panel" role="region" aria-label="פתרון התנגשות לקוח"><h3>הלקוח השתנה במכשיר אחר</h3><p>בחרו איזו גרסה לשמור לאחר בדיקת הפרטים.</p><div className="action-buttons"><button className="secondary" disabled={busy} onClick={() => void resolve('server')}>שמירת גרסת השרת</button><button className="primary compact" disabled={busy} onClick={() => void resolve('local')}>החלת השינוי שלי</button></div></div>}
{editing ? <CustomerForm key={`${selected.id}-${selected.version}`} customer={selected} busy={busy} submit={update} cancel={() => setEditing(false)} /> : <div className="client-summary">
<div><span>איש קשר</span><strong>{selected.contactName || 'לא הוגדר'}</strong></div><div><span>טלפון</span>{selected.phone ? <a dir="ltr" href={`tel:${selected.phone}`}>{selected.phone}</a> : <strong>לא הוגדר</strong>}</div>
<div className="wide"><span>חלונות מסירה</span><div className="slot-chips">{(Object.keys(windowLabels) as Array<keyof typeof windowLabels>).map(key => selected.deliveryWindows[key] && <span key={key}>{windowLabels[key]} · {selected.deliveryWindows[key]!.start}–{selected.deliveryWindows[key]!.end}</span>)}{!Object.values(selected.deliveryWindows).some(Boolean) && <strong>לא הוגדרו</strong>}</div></div>
<div className="wide"><span>הערות</span><p>{selected.comments || 'ללא הערות קבועות'}</p></div>
</div>}
{confirmRemove && <div className="archive-confirm" role="alert"><strong>להעביר את {selected.name} לארכיון?</strong><p>{relatedDeliveries.length} משלוחים ו־{relatedAddresses.length} כתובות יישמרו ויישארו משויכים.</p><div className="action-buttons"><button className="danger-button" disabled={busy} onClick={() => void archive()}>העברה לארכיון</button><button className="secondary" onClick={() => setConfirmRemove(false)}>ביטול</button></div></div>}
<section className="associated-section"><div className="section-title"><h3>כתובות</h3><span>{relatedAddresses.length}</span></div>{!selected.deletedAt && <form className="inline-address-form" onSubmit={event => void addAddress(event)}><label>שם הכתובת<input name="label" required maxLength={80} placeholder="ראשית" /></label><label>עיר<input name="city" required maxLength={100} autoComplete="address-level2" /></label><label>רחוב<input name="street" required maxLength={120} autoComplete="address-line1" /></label><label>מספר<input name="buildingNumber" required maxLength={20} inputMode="text" /></label><button className="primary compact" disabled={busy}>הוספת כתובת</button></form>}{relatedAddresses.map(address => <article className="associated-row" key={address.id}><div><strong>{address.label}</strong><p><AddressText address={address} /></p></div><span className={`sync-dot ${address.syncState}`} /></article>)}</section>
<section className="associated-section"><div className="section-title"><h3>רשומות משויכות</h3><span>{relatedDeliveries.length}</span></div>{!relatedDeliveries.length ? <p className="helper">אין משלוחים משויכים ללקוח.</p> : relatedDeliveries.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(delivery => <article className="associated-row" key={delivery.id}><div><strong>{delivery.deliveryNumber ? `משלוח #${delivery.deliveryNumber}` : 'משלוח ממתין לסנכרון'}</strong><p>{new Date(delivery.createdAt).toLocaleDateString('he-IL')} · {delivery.status === 'cancelled' ? 'מבוטל' : 'פעיל'}</p></div><span className={`sync-dot ${delivery.syncState}`} /></article>)}</section>
</>}
</section>
</div>;
}