Extract the operational Model Delivery PWA
This commit is contained in:
commit
958f8d5ff1
46 files changed
+6446
No files matched your search
@@ -0,0 +1,101 @@
|
||||
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>;
|
||||
}
|
||||
Reference in new issue
Block a user