// CreateWO.jsx — Admin-only booking creation. Strict shift options (8am-12pm / 1pm-5pm / Custom). // Reusable searchable select with an inline "+ Add new" footer. // `options` is an array of strings; `onAddNew` (optional) opens an add UI. function SearchableSelect({ value, onChange, options, placeholder, onAddNew, addLabel, error, emptyText, disabled }) { const [open, setOpen] = React.useState(false); const [q, setQ] = React.useState(''); const ref = React.useRef(null); const inputRef = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onDoc); setTimeout(() => inputRef.current && inputRef.current.focus(), 30); return () => document.removeEventListener('mousedown', onDoc); }, [open]); const filtered = (options || []).filter(o => o.toLowerCase().includes(q.toLowerCase())); return (
{open ? (
setQ(e.target.value)} placeholder="Search..." className="input" style={{width: '100%', height: 34, paddingLeft: 32, fontSize: 13}}/>
{filtered.length === 0 ? (
{emptyText || 'No matches.'}
) : filtered.map(o => ( ))}
{onAddNew ? ( ) : null}
) : null}
); } // Machine picker — shows "PLATE (TONNAGE)" + a live status colour pill per // option (and on the selected value). Searchable by plate or fleet id. function MachineSelect({ value, onChange, machines, error, disabled }) { const [open, setOpen] = React.useState(false); const [q, setQ] = React.useState(''); const ref = React.useRef(null); const inputRef = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onDoc); setTimeout(() => inputRef.current && inputRef.current.focus(), 30); return () => document.removeEventListener('mousedown', onDoc); }, [open]); const statusMeta = (s) => s === 'available' ? { c: 'green', label: 'Available' } : s === 'occupied' ? { c: 'red', label: 'Occupied' } : { c: 'gray', label: 'Not available' }; const selected = machines.find(m => m.id === value); const filtered = machines.filter(m => (machineLabel(m) + ' ' + m.id).toLowerCase().includes(q.toLowerCase())); return (
{open ? (
setQ(e.target.value)} placeholder="Search plate or fleet id..." className="input" style={{width: '100%', height: 34, paddingLeft: 32, fontSize: 13}}/>
{filtered.length === 0 ? (
No machines match.
) : filtered.map(m => { const sm = statusMeta(m.status); return ( ); })}
) : null}
); } // Small reusable cert-dot row. Expired certs are hidden entirely; certs // inside the renewal window get an amber outline + "renew soon" tooltip. // `compact` hides the text codes (dots only). function CertDots({ certs, compact }) { const active = activeCerts(certs); if (active.length === 0) { return No valid certs; } return ( {active.map(c => { const expiring = certState(c.exp) === 'expiring'; return ( {compact ? null : c.code} {!compact && expiring ? : null} ); })} ); } // Operator picker — like MachineSelect, but each row shows the operator's // certifications as coloured short-code chips so dispatch can see who is // qualified while assigning. A legend sits at the top of the dropdown. function OperatorSelect({ value, onChange, error }) { const [open, setOpen] = React.useState(false); const [q, setQ] = React.useState(''); const ref = React.useRef(null); const inputRef = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onDoc); setTimeout(() => inputRef.current && inputRef.current.focus(), 30); return () => document.removeEventListener('mousedown', onDoc); }, [open]); const selected = OPERATORS.find(o => o.name === value); const filtered = OPERATORS.filter(o => (o.name + ' ' + activeCerts(o.certs).map(c => c.code).join(' ')).toLowerCase().includes(q.toLowerCase())); return (
{open ? (
{/* Legend */}
Certification legend
{CERT_LEGEND.map(c => ( {c.code} {c.label} ))}
setQ(e.target.value)} placeholder="Search name or cert (e.g. OGSP)..." className="input" style={{width: '100%', height: 34, paddingLeft: 32, fontSize: 13}}/>
{filtered.length === 0 ? (
No operators match.
) : filtered.map(o => ( ))}
) : null}
); } // Greyed-out style for management-locked inputs in Modify Booking. const lockedInputStyle = { background: 'var(--color-grey-100)', color: 'var(--color-grey-500)', cursor: 'not-allowed', }; // Inline "only Management can change this" note shown under a locked field. function MgmtLockHint() { return (
Locked — only Management can change this.
); } // Mint the next sequential work-order / booking number, e.g. BK-2026-00424. // Scans existing BOOKINGS for the highest numeric suffix, keeps the same // prefix, and increments — so a duplicated job gets its own fresh WO number. function genWorkOrderId() { let max = 0, prefix = 'BK-2026-'; if (typeof BOOKINGS !== 'undefined') { BOOKINGS.forEach(b => { const m = /^(.*?)(\d+)$/.exec(b.id || ''); if (m) { const n = parseInt(m[2], 10); if (n >= max) { max = n; prefix = m[1]; } } }); } return prefix + String(max + 1).padStart(5, '0'); } function CreateWO({ onSubmit, onBack, role, drawer }) { // Pickup any pre-fill payload set by another page (e.g. clicking an Available // machine row in Current Bookings). Read once, then clear in useEffect so // we don't mutate state during render. const prefill = React.useRef(window.__createPrefill || null).current; React.useEffect(() => { if (typeof window !== 'undefined') window.__createPrefill = null; }, []); // Edit mode — when an existing booking is passed in, this whole form acts as // the "Modify Booking" page: every field pre-fills from the booking, the // heading/button text switch, and Submit writes the changes back in place. const editBooking = (prefill && prefill.editBooking) || null; const wasEdit = !!editBooking; // "Duplicate" — when an existing booking is open in Modify mode, the user can // copy every detail into a brand-new booking (same job, different date). We // keep all current field values in state and just flip the form out of edit // mode so Submit creates a fresh booking instead of overwriting this one. const [duplicated, setDuplicated] = React.useState(false); const isEdit = wasEdit && !duplicated; // The work-order number this duplicate will receive when submitted (preview). const pendingWO = duplicated ? genWorkOrderId() : null; // Company, Price, Diesel Surcharge and Overtime are commercial fields locked // once a booking exists — only Management may change them. Everyone else sees // them read-only in Modify Booking. (Creating a new booking is unaffected.) const mgmtLocked = isEdit && role !== 'Management'; // Strip a leading "RM " (and keep digits/commas/dots) so price fields edit cleanly. const stripRM = (v) => (v == null ? '' : String(v).replace(/^\s*RM\s*/i, '').trim()); // Resolve the booking's machine id from its "PLC-005 Tadano …" machine string. const editMachineId = isEdit ? ((editBooking.machine || '').split(' ')[0]) : ''; // Default state: empty for everything except today's date. // Prefill (set from another page) can override per-field. const initialMachineId = editMachineId || (prefill && prefill.machineId) || ''; const blankCustomer = isEdit ? false : !(prefill && prefill.customerId); // Infer a shift id from the booking's stored shift / times. const editShift = isEdit ? (editBooking.shift || 'custom') : null; const [shift, setShift] = React.useState(isEdit ? (editShift === 'morning' || editShift === 'afternoon' || editShift === 'custom' ? editShift : 'fullday') : 'fullday'); const [start, setStart] = React.useState(isEdit ? (editBooking.start || '08:00') : '08:00'); const [end, setEnd] = React.useState(isEdit ? (editBooking.end || '17:00') : '17:00'); const prefillDate = isEdit ? (editBooking.date || TODAY) : ((prefill && prefill.date) || TODAY); const [dateFrom, setDateFrom] = React.useState(prefillDate); const [dateTo, setDateTo] = React.useState(prefillDate); // Day preset — quick-pick row at top of the Schedule section. const addDays = (iso, n) => { const d = new Date(iso + 'T00:00:00'); d.setDate(d.getDate() + n); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; }; const tomorrow = addDays(TODAY, 1); const dayAfter = addDays(TODAY, 2); // Infer initial Day preset from the current dateFrom/dateTo. Defaults to Today. const inferDayPreset = (from, to) => { if (from !== to) return 'custom'; if (from === TODAY) return 'today'; if (from === tomorrow) return 'tomorrow'; if (from === dayAfter) return 'dayafter'; return 'custom'; }; const [dayPreset, setDayPreset] = React.useState(inferDayPreset(dateFrom, dateTo)); const applyDayPreset = (id) => { setDayPreset(id); if (id === 'today') { setDateFrom(TODAY); setDateTo(TODAY); } if (id === 'tomorrow') { setDateFrom(tomorrow); setDateTo(tomorrow); } if (id === 'dayafter') { setDateFrom(dayAfter); setDateTo(dayAfter); } // 'custom' — leave dates as-is so user can edit }; const [photo, setPhoto] = React.useState(null); const [toolsOpen, setToolsOpen] = React.useState(false); const [selectedTools, setSelectedTools] = React.useState([]); const toolsRef = React.useRef(null); // Close tools dropdown when clicking outside React.useEffect(() => { if (!toolsOpen) return; const onClick = (e) => { if (toolsRef.current && !toolsRef.current.contains(e.target)) setToolsOpen(false); }; document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, [toolsOpen]); const toggleTool = (id) => { setSelectedTools(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); }; const toolsByCat = {}; TOOLS.forEach(t => { (toolsByCat[t.category] = toolsByCat[t.category] || []).push(t); }); const [machineId, setMachineId] = React.useState(initialMachineId); const [category, setCategory] = React.useState(() => { const m = MACHINES.find(x => x.id === initialMachineId); return m ? m.category : ''; }); const [machineStatus, setMachineStatus] = React.useState(() => { const m = MACHINES.find(x => x.id === initialMachineId); return m ? m.status : 'occupied'; }); // In edit mode pre-fill company / contact / phone / location straight from // the booking (matching the company record by name where possible). const editCustomerId = isEdit ? ((CUSTOMERS.find(c => c.name === editBooking.customer) || {}).id || '') : ''; const [customerId, setCustomerId] = React.useState(isEdit ? editCustomerId : (blankCustomer ? '' : 'C-001')); const customerObj = CUSTOMERS.find(c => c.id === customerId) || null; const [contactName, setContactName] = React.useState(isEdit ? (editBooking.contact || '') : (blankCustomer ? '' : (customerObj ? customerObj.contacts[0].name : ''))); const [phone, setPhone] = React.useState(isEdit ? (editBooking.phone || '') : (blankCustomer ? '' : (customerObj ? customerObj.contacts[0].phone : ''))); const [location, setLocation] = React.useState(isEdit ? (editBooking.location || '') : (blankCustomer ? '' : (customerObj && customerObj.locations[0] ? customerObj.locations[0] : ''))); const [operator, setOperator] = React.useState(() => { if (isEdit) return editBooking.operator || ''; const m = MACHINES.find(x => x.id === initialMachineId); return (m && m.operator) || ''; }); // When user picks a different machine, auto-fill its default operator // (only if that machine has one assigned). Skip the very first run in edit // mode so we don't clobber the booking's saved operator on mount. const skipOperatorAutofill = React.useRef(isEdit); React.useEffect(() => { if (skipOperatorAutofill.current) { skipOperatorAutofill.current = false; return; } const m = MACHINES.find(x => x.id === machineId); if (m && m.operator) setOperator(m.operator); }, [machineId]); const [showErrors, setShowErrors] = React.useState(false); // Entry_Type — replaces the standalone Machine Status section. // When 'occupied' the full booking form shows; 'available' / 'unavailable' // means we're just flipping the machine status without capturing a booking. const [entryType, setEntryType] = React.useState(machineStatus === 'available' ? 'occupied' : machineStatus); const [entryOpen, setEntryOpen] = React.useState(false); const entryRef = React.useRef(null); React.useEffect(() => { if (!entryOpen) return; const onDoc = (e) => { if (entryRef.current && !entryRef.current.contains(e.target)) setEntryOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [entryOpen]); // Additional contact phone numbers — a contact can have several (office, mobile, whatsapp). // Seeded from the selected contact's main phone. const [extraContacts, setExtraContacts] = React.useState([]); React.useEffect(() => { setExtraContacts([]); }, [customerId, contactName]); const addExtraContact = () => setExtraContacts([...extraContacts, '']); const updateExtraContact = (i, val) => setExtraContacts(extraContacts.map((p, idx) => idx === i ? val : p)); const removeExtraContact = (i) => setExtraContacts(extraContacts.filter((_, idx) => idx !== i)); // Address URL (google maps link) + free-text address const [addressUrl, setAddressUrl] = React.useState(isEdit ? (editBooking.addressUrl || '') : ''); const [address, setAddress] = React.useState(isEdit ? (editBooking.address || '') : ''); // Price (RM) — keep diesel surcharge, add new overtime field const [price, setPrice] = React.useState(isEdit ? stripRM(editBooking.price) : ''); const [diesel, setDiesel] = React.useState(isEdit ? stripRM(editBooking.diesel) : ''); const [overtime, setOvertime] = React.useState(isEdit ? stripRM(editBooking.overtime) : ''); const [remarks, setRemarks] = React.useState(isEdit ? (editBooking.remarks || '') : ''); // Once Entry_Type isn't 'occupied' (Booked), most booking-detail fields hide. const isBookingMode = entryType === 'occupied'; // Inline-add modal state const [addModal, setAddModal] = React.useState(null); // 'customer' | 'contact' | 'location' | null const [, forceRender] = React.useReducer(x => x + 1, 0); // When customer changes, reset contact / phone / location to first of that customer const pickCustomer = (name) => { const c = CUSTOMERS.find(x => x.name === name); setCustomerId(c ? c.id : ''); if (c && c.contacts[0]) { setContactName(c.contacts[0].name); setPhone(c.contacts[0].phone); } else { setContactName(''); setPhone(''); } if (c && c.locations[0]) setLocation(c.locations[0]); else setLocation(''); }; // When contact changes (within the same customer), auto-fill phone const pickContact = (name) => { setContactName(name); const c = CUSTOMERS.find(x => x.id === customerId); const ct = c && c.contacts.find(x => x.name === name); if (ct) setPhone(ct.phone); }; // Add handlers — these mutate the master data arrays and re-render const addCustomer = (data) => { const id = 'C-' + String(CUSTOMERS.length + 1).padStart(3, '0'); const next = { id, name: data.name, jobs: 0, contacts: [{ name: data.contactName, phone: data.phone }], locations: data.location ? [data.location] : [], }; CUSTOMERS.push(next); if (data.location && !LOCATIONS.includes(data.location)) LOCATIONS.push(data.location); setCustomerId(id); setContactName(data.contactName); setPhone(data.phone); setLocation(data.location || ''); forceRender(); }; const addContact = (data) => { const c = CUSTOMERS.find(x => x.id === customerId); if (!c) return; c.contacts.push({ name: data.name, phone: data.phone }); setContactName(data.name); setPhone(data.phone); forceRender(); }; const addLocation = (addr) => { if (!LOCATIONS.includes(addr)) LOCATIONS.push(addr); const c = CUSTOMERS.find(x => x.id === customerId); if (c && !c.locations.includes(addr)) c.locations.push(addr); setLocation(addr); forceRender(); }; // Bind the currently-typed phone to the selected contact in master data const savePhoneToContact = (newPhone) => { const c = CUSTOMERS.find(x => x.id === customerId); if (!c) return; const ct = c.contacts.find(x => x.name === contactName); if (ct) ct.phone = newPhone; setPhone(newPhone); forceRender(); }; const setShiftOption = (id) => { setShift(id); const opt = SHIFT_OPTIONS.find(s => s.id === id); if (opt && opt.start) { setStart(opt.start); setEnd(opt.end); } }; // Multi-day bookings can only use Full-day (8am–5pm) or Custom shift. // If user expands the range while a half-day shift is selected, auto-switch to fullday. React.useEffect(() => { if (rangeDays > 1 && (shift === 'morning' || shift === 'afternoon')) { setShiftOption('fullday'); } }, [rangeDays]); const machine = MACHINES.find(m => m.id === machineId) || MACHINES[0]; // Rigger: only required for Mobile Crane >= 30t. Everything else defaults to empty. const tonNum = parseInt(machine.tonnage, 10); const riggerRequired = machine.category === 'Mobile Crane' && !isNaN(tonNum) && tonNum >= 30; const [rigger, setRigger] = React.useState(isEdit ? (editBooking.rigger || '—') : '—'); const skipRiggerReset = React.useRef(isEdit); React.useEffect(() => { if (skipRiggerReset.current) { skipRiggerReset.current = false; return; } setRigger('—'); }, [machineId, riggerRequired]); // Operator is OPTIONAL for these categories (machines often self-operated / // driven by the customer, or no dedicated operator on file). const OPERATOR_OPTIONAL_CATEGORIES = ['Scissor Lift', 'Lorry Crane', 'Lorry', 'Boom Lift', 'Forklift', 'Cross Rent', 'Utility', 'Car']; const operatorRequired = !OPERATOR_OPTIONAL_CATEGORIES.includes(category); // Required-field validation const errors = { customer: !customerObj, contact: !contactName.trim(), phone: !phone.trim(), location: !location.trim(), operator: operatorRequired && (!operator.trim() || operator === '—'), machine: !machineId, date: isBookingMode && !dateFrom, }; const hasErrors = Object.values(errors).some(Boolean); // Build array of dates in the inclusive range [dateFrom, dateTo] const buildDateRange = (from, to) => { if (!from || !to || from > to) return from ? [from] : []; const out = []; const cur = new Date(from + 'T00:00:00'); const end = new Date(to + 'T00:00:00'); while (cur <= end) { const y = cur.getFullYear(); const m = String(cur.getMonth() + 1).padStart(2, '0'); const d = String(cur.getDate()).padStart(2, '0'); out.push(`${y}-${m}-${d}`); cur.setDate(cur.getDate() + 1); } return out; }; const rangeDates = buildDateRange(dateFrom, dateTo); const rangeDays = rangeDates.length; // Check overlap across every day in the range; report the first conflict found. // We check 3 kinds of overlap: machine (plate), operator, rigger. let conflict = null; let conflictDate = null; let conflictKind = null; // 'machine' | 'operator' | 'rigger' for (const d of rangeDates) { const excludeId = isEdit ? editBooking.id : undefined; // Machine let c = findBookingOverlap({ plate: machine.plate, date: d, start, end, excludeId }); if (c) { conflict = c; conflictDate = d; conflictKind = 'machine'; break; } // Operator c = findOperatorOverlap({ operator, date: d, start, end, excludeId }); if (c) { conflict = c; conflictDate = d; conflictKind = 'operator'; break; } // Rigger (only if assigned) if (riggerRequired && rigger && rigger !== '—') { c = findRiggerOverlap({ rigger, date: d, start, end, excludeId }); if (c) { conflict = c; conflictDate = d; conflictKind = 'rigger'; break; } } } const handleSubmit = () => { if (hasErrors) { setShowErrors(true); return; } if (conflict) return; const fmtRM = (v) => { const s = String(v == null ? '' : v).trim(); return s === '' ? '' : (/^rm/i.test(s) ? s : 'RM ' + s); }; // Edit mode — write the edited fields back onto the booking record in place // (BOOKINGS holds the same object reference) before handing control back. if (isEdit) { Object.assign(editBooking, { customer: customerObj ? customerObj.name : editBooking.customer, contact: contactName, phone, location, machine: machine.id + ' ' + machineLabel(machine), plate: machine.plate, category: machine.category, tonnage: machine.tonnage, date: dateFrom, shift, start, end, operator, rigger, price: fmtRM(price), diesel: fmtRM(diesel), overtime: fmtRM(overtime), addressUrl, address, remarks, }); if (onSubmit) onSubmit(true); return; } // New booking (including a duplicated job moved to another date) — mint a // fresh work-order number and add it to the live BOOKINGS list so it shows // up as its own separate record. let newId = null; if (isBookingMode) { newId = genWorkOrderId(); const newBooking = { id: newId, customer: customerObj ? customerObj.name : '', contact: contactName, phone, location, machine: machine.id + ' ' + machineLabel(machine), plate: machine.plate, category: machine.category, tonnage: machine.tonnage, date: dateFrom, shift, start, end, operator, rigger, desc: (duplicated && editBooking ? editBooking.desc : '') || '', price: fmtRM(price), diesel: fmtRM(diesel), overtime: fmtRM(overtime), addressUrl, address, remarks, status: 'Assigned', urgency: 'normal', }; if (typeof BOOKINGS !== 'undefined') BOOKINGS.unshift(newBooking); } if (onSubmit) onSubmit(false, newId); }; // Duplicate this booking into a new one. Keep the job details, but blank out // the schedule (user must pick a fresh date) and reset the operator to whoever // is assigned to this machine plate — then flip out of edit mode so Submit // creates a brand-new record with its own work-order number. const handleDuplicate = () => { setDuplicated(true); setDateFrom(''); setDateTo(''); setDayPreset('custom'); const m = MACHINES.find(x => x.id === machineId); setOperator((m && m.operator) || ''); setShowErrors(false); }; const errStyle = (key) => (showErrors && errors[key]) ? { borderColor: '#dc2626', background: '#fef2f2' } : {}; const errMsg = (key, msg) => (showErrors && errors[key]) ? (
{msg}
) : null; const drawerHead = drawer ? (
{isEdit ? 'Modify Booking' : 'New Booking'}
) : null; return (
{drawerHead} {!drawer && onBack ? ( ) : null}
{!drawer ?
{isEdit ? 'Modify Booking' : 'New Booking'}
: null}
{isEdit ? <>Editing {editBooking.id} · changes notify the operator via push : duplicated ? <>Duplicated from {editBooking.id} · new work order {pendingWO} assigned on submit · pick a new date : <>Booking creation is restricted to Admin · Customer code maps to AutoCount Debtor}
{isEdit ? ( ) : null}
{isEdit ? 'Editing booking' : duplicated ? 'Duplicate · new date' : 'Draft · auto-saved'}
{/* Entry_Type dropdown — picks the machine status. When set to anything other than "Booked" the rest of the booking form is hidden. */}
{entryOpen ? (
{[ { id: 'occupied', label: 'Booking', pill: 'red' }, { id: 'available', label: 'Available', pill: 'green' }, { id: 'unavailable', label: 'Not available', pill: 'gray' }, ].map(opt => ( ))}
) : null}
{/* Machinery — always visible. Status applies to the picked machine. */}
Machinery
m.category === category) : MACHINES} disabled={!category} error={showErrors && errors.machine}/>
{category ? <>Status pill shows live availability — Available · Occupied · Not available (workshop/leave). : 'Pick a category first, then choose a machine from that list.'}
{errMsg('operator', 'Operator must be assigned')}
{riggerRequired ? `Rigger required for Mobile Crane ${machine.tonnage}.` : `Rigger not assigned — ${machine.category} ${machine.tonnage} is operated solo.`}
{isBookingMode ? ( <>
Schedule
{/* Day quick-pick row */}
{[ { id: 'today', label: 'Today' }, { id: 'tomorrow', label: 'Tomorrow' }, { id: 'dayafter', label: 'Day After' }, { id: 'custom', label: 'Custom' }, ].map(opt => ( ))}
{/* Shift quick-pick row */}
{[ { id: 'fullday', label: 'All Day' }, { id: 'morning', label: '8 AM - 12 PM' }, { id: 'afternoon', label: '1 PM - 5 PM' }, { id: 'custom', label: 'Custom' }, ].map(opt => { const isHalf = opt.id === 'morning' || opt.id === 'afternoon'; const locked = rangeDays > 1 && isHalf; return ( ); })}
{rangeDays > 1 ? (
Multi-day booking — only All Day or Custom allowed.
) : null}
{/* Shift_Start datetime */}
{ const [d, t] = e.target.value.split('T'); if (d) { setDateFrom(d); if (dateTo < d) setDateTo(d); setDayPreset('custom'); } if (t) { setStart(t); setShift('custom'); } }} style={{width: '100%'}}/>
{/* Shift_End datetime */}
{ const [d, t] = e.target.value.split('T'); if (d) { setDateTo(d); setDayPreset('custom'); } if (t) { setEnd(t); setShift('custom'); } }} style={{width: '100%'}}/>
{!dateFrom ? 'Pick a date for this booking.' : rangeDays > 1 ? `${rangeDays}-day range · ${dateFrom} ${start} → ${dateTo} ${end}` : `Single day · ${start} – ${end}`}
{errMsg('date', 'A booking date is required')}
Company & Location
c.name)} placeholder="— Select company —" emptyText="No company matches. Add new?" onAddNew={mgmtLocked ? undefined : () => setAddModal('customer')} addLabel="Add new company" error={showErrors && errors.customer} disabled={mgmtLocked} /> {mgmtLocked ? : null} {errMsg('customer', 'Company name is required')}
!customerObj.locations.includes(l))] : LOCATIONS } placeholder="— Select location —" emptyText="No location matches. Add new?" onAddNew={() => setAddModal('location')} addLabel="Add new location" error={showErrors && errors.location} /> {errMsg('location', 'Job location is required')}
c.name) : []} placeholder={customerObj ? '— Select contact —' : 'Pick company first'} emptyText="No contacts. Add new?" onAddNew={() => setAddModal('contact')} addLabel="Add new contact for this company" error={showErrors && errors.contact} disabled={!customerObj} /> {errMsg('contact', 'Contact person is required')}
{/* Primary phone (bound to contact) */}
setPhone(e.target.value)} style={{...errStyle('phone'), flex: 1}} placeholder="+60 ..."/>
{/* Additional phone numbers — added inline */} {extraContacts.map((p, i) => (
updateExtraContact(i, e.target.value)} placeholder="+60 ..." style={{flex: 1}}/>
))}
{errMsg('phone', 'At least one contact number is required')}
setAddressUrl(e.target.value)} placeholder="http://" type="url"/>
Google Maps share link — pasted here so operator can tap → open directions.