// Screens: Access Requests, My Requests, Admin Approval Queue, Admin Users / Metadata / Audit / ROE / Routing const DRow = ({ k, v }) => ( {k} {v} ); function AccessRequests({ user, perms, onApprovalAction }) { const [isApprover, setIsApprover] = useState(false); const [isManager, setIsManager] = useState(false); const [isRoutingMember, setIsRoutingMember] = useState(false); const [tab, setTab] = useState(null); const [showForm, setShowForm] = useState(false); const [showBehalf, setShowBehalf] = useState(false); const [openReq, setOpenReq] = useState(null); const [queueReload, setQueueReload] = useState(0); const [myReload, setMyReload] = useState(0); React.useEffect(() => { apiFetch("/api/access-requests/is-approver") .then(res => { const approver = !!res.is_approver; setIsApprover(approver); setIsManager(!!res.is_manager); setIsRoutingMember(!!res.is_routing_member); setTab(approver ? "queue" : "my"); }) .catch(() => setTab("my")); }, []); const tabs = []; if (isApprover) tabs.push({ id: "queue", label: "Approval Queue" }); tabs.push({ id: "my", label: "My Requests" }); if (isManager || perms.admin) tabs.push({ id: "routing", label: "Routing Table" }); if (tab === null) return
Loading…
; return (
{isRoutingMember && ( )}
} /> {tab === "queue" && } {tab === "my" && } {tab === "routing" && } {showForm && setShowForm(false)} onSubmitted={() => { setTab("my"); setMyReload(n => n + 1); }} />} {showBehalf && setShowBehalf(false)} onSubmitted={() => { setTab("queue"); setQueueReload(n => n + 1); }} />} {openReq && setOpenReq(null)} onAction={() => { setOpenReq(null); setQueueReload(n => n + 1); onApprovalAction?.(); }} />} ); } function _slaDisplay(sla_deadline, status) { if (status !== "Pending") return ; if (!sla_deadline) return null; const days = Math.ceil((new Date(sla_deadline) - new Date()) / 86400000); if (days < 0) return ⚠ Overdue; if (days <= 1) return ⚠ {days}d; return {days}d; } function ApprovalQueue({ onOpen, reload, onApprovalAction }) { const [status, setStatus] = useState("Pending"); const [rows, setRows] = useState([]); const [counts, setCounts] = useState({}); const [loading, setLoading] = useState(true); const [acting, setActing] = useState(null); // req_id being actioned inline const load = () => { setLoading(true); Promise.all([ apiFetch("/api/admin/access-requests"), apiFetch("/api/admin/access-requests?status=Pending"), ]).then(([all, pending]) => { const c = {}; ["Pending","Approved","Rejected"].forEach(s => { c[s] = (Array.isArray(all) ? all : []).filter(r => r.status === s).length; }); setCounts(c); setRows(Array.isArray(all) ? all.filter(r => r.status === status) : []); setLoading(false); }).catch(() => setLoading(false)); }; React.useEffect(() => { load(); }, [reload]); React.useEffect(() => { apiFetch(`/api/admin/access-requests${status ? `?status=${status}` : ""}`) .then(r => setRows(Array.isArray(r) ? r : [])) .catch(() => {}); }, [status]); const quickAction = async (e, req_id, action) => { e.stopPropagation(); setActing(req_id); try { await apiFetch(`/api/admin/access-requests/${req_id}/${action}`, { method: "PUT", body: JSON.stringify({ comment: null }) }); load(); onApprovalAction?.(); } catch (err) { alert(err.message); } setActing(null); }; const parseRegions = str => { try { return JSON.parse(str).join(", "); } catch { return str || "—"; } }; return (
{["Pending","Approved","Rejected"].map(s => ( ))}
SLA breach
Within SLA
{loading ?
Loading…
: ( {rows.map(r => ( onOpen(r)} style={{ cursor: "pointer" }}> ))} {rows.length === 0 && ( )}
Request # Requester Requested Role Region(s) Submitted SLA Routed to
AR-{String(r.id).padStart(4, "0")}
{r.requester_name}
{r.requester_email}
{r.requested_role} {parseRegions(r.requested_regions)} {r.submitted_at?.slice(5, 16)} {_slaDisplay(r.sla_deadline, r.status)} {r.routed_to_email || "—"} e.stopPropagation()}> {r.status === "Pending" && (
)}
No {status.toLowerCase()} requests
)}
); } function MyRequests({ user, reload }) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); React.useEffect(() => { setLoading(true); apiFetch("/api/access-requests/my") .then(r => { setRows(Array.isArray(r) ? r : []); setLoading(false); }) .catch(() => setLoading(false)); }, [reload]); const parseRegions = str => { try { return JSON.parse(str).join(", "); } catch { return str || "—"; } }; return (
{loading ?
Loading…
: ( {rows.map(r => ( ))} {rows.length === 0 && ( )}
Request # Role Region(s) Submitted Routed to Decided Status
AR-{String(r.id).padStart(4, "0")} {r.requested_role} {parseRegions(r.requested_regions)} {r.submitted_at?.slice(0, 16)} {r.routed_to_email || "—"} {r.reviewed_at?.slice(0, 16) || "—"}
No requests submitted yet
)}
); } function RoutingModal({ row, regions, onClose, onSaved }) { const isEdit = !!(row && row.id); const empty = { region_id: "", manager_email: "", delegate_email: "", delegate_valid_until: "" }; const [form, setForm] = React.useState(isEdit ? { region_id: row.region_id ?? "", manager_email: row.manager_email ?? "", delegate_email: row.delegate_email ?? "", delegate_valid_until: row.delegate_valid_until ? row.delegate_valid_until.slice(0, 10) : "", } : empty); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const inputStyle = { padding: "6px 8px", borderRadius: 6, border: "1px solid var(--border)", fontSize: 13, width: "100%", boxSizing: "border-box" }; const submit = async () => { if (!form.manager_email) { setError("Manager email is required."); return; } setSaving(true); setError(null); const body = { region_id: form.region_id ? parseInt(form.region_id) : null, manager_email: form.manager_email, delegate_email: form.delegate_email || null, delegate_valid_until: form.delegate_valid_until || null, }; try { if (isEdit) await apiFetch(`/api/admin/routing/${row.id}`, { method: "PUT", body: JSON.stringify(body) }); else await apiFetch("/api/admin/routing", { method: "POST", body: JSON.stringify(body) }); onSaved(); } catch (e) { setError(e.message); setSaving(false); } }; return (
{ if (e.target === e.currentTarget) onClose(); }}>
{isEdit ? "Edit" : "Add"} Routing Rule
{error &&
{error}
}
); } function RoutingTable() { const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [regions, setRegions] = React.useState([]); const [modal, setModal] = React.useState(null); // null | "new" | row object const load = () => { setLoading(true); apiFetchMeta("regions") .then(r => setRegions(Array.isArray(r) ? r : [])) .catch(() => {}); apiFetch("/api/admin/routing") .then(r => { setRows(Array.isArray(r) ? r : []); setLoading(false); }) .catch(() => setLoading(false)); }; React.useEffect(() => { load(); }, []); const handleDelete = async (id, email) => { if (!confirm(`Delete routing rule for "${email}"?`)) return; try { await apiFetch(`/api/admin/routing/${id}`, { method: "DELETE" }); load(); } catch (e) { alert(e.message); } }; const today = new Date().toISOString().slice(0, 10); const regionName = id => regions.find(r => r.id === Number(id))?.name || "—"; const delegateStatus = row => { if (!row.delegate_email) return null; if (!row.delegate_valid_until) return Permanent; const expired = row.delegate_valid_until.slice(0, 10) < today; return {expired ? "Expired" : `Until ${row.delegate_valid_until.slice(0, 10)}`}; }; const onSaved = () => { setModal(null); load(); }; return (
setModal("new")}> Add rule} > {loading ? (
Loading…
) : (
{rows.length === 0 && ( )} {rows.map(r => ( ))}
Region Manager Email Delegate Email Delegate Status
No routing rules configured.
{regionName(r.region_id)} {r.manager_email} {r.delegate_email || "—"} {delegateStatus(r)}
)}
{modal && ( setModal(null)} onSaved={onSaved} /> )}
); } // ── Multi-Select dropdown with search + checkboxes ── function MultiSearchSelect({ options, values, onChange, placeholder, getLabel, getValue }) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); const ref = React.useRef(null); const gl = getLabel || (o => String(o)); const gv = getValue || (o => String(o)); const norm = s => s.toLowerCase().replace(/[^a-z0-9一-鿿]+/g, " ").trim(); const filtered = options.filter(o => !search || norm(search).split(" ").filter(Boolean).every(t => norm(gl(o)).includes(t))); const isSelected = v => values.includes(String(v)); const toggle = v => { const sv = String(v); onChange(isSelected(sv) ? values.filter(x => x !== sv) : [...values, sv]); }; const selAll = () => onChange(options.map(o => String(gv(o)))); const clear = () => onChange([]); React.useEffect(() => { const h = e => { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); setSearch(""); } }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); const selLabels = values.map(v => { const o = options.find(op => String(gv(op)) === v); return o ? gl(o) : v; }); return (
setOpen(o => !o)} style={{ minHeight: 34, padding: "4px 8px", borderRadius: 6, border: "1px solid var(--border)", background: "var(--paper)", cursor: "pointer", display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center", fontSize: 13 }}> {values.length === 0 ? {placeholder || "All — click to select"} : selLabels.map((lbl, i) => ( {lbl} )) }
{open && (
setSearch(e.target.value)} placeholder="Search…" style={{ flex: 1, padding: "4px 8px", borderRadius: 4, border: "1px solid var(--border)", fontSize: 12, background: "var(--paper)", color: "var(--ink)" }} />
{filtered.length === 0 ?
No match
: filtered.map((o, i) => { const v = String(gv(o)); const sel = isSelected(v); return (
{ e.preventDefault(); toggle(v); }} style={{ padding: "6px 12px", cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center", gap: 8, background: sel ? "rgba(0,158,135,.08)" : "transparent" }}> {gl(o)}
); }) }
)}
); } function CoverageAddModal({ terminals, costTypes, equipTypes, moveTypes, brandCompanies, onClose, onSaved }) { const EMPTY = { terminal_ids: [], cost_type_ids: [], equipment_type_ids: [], container_sizes: [], container_fullnesses: [], shipment_type_ids: [], brand_company_ids: [] }; const [form, setForm] = React.useState(EMPTY); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const set = (k, v) => { setForm(f => ({ ...f, [k]: v })); setError(null); }; const SIZE_OPTS = [{ id: "20", name: "20" }, { id: "40", name: "40" }, { id: "45", name: "45" }]; const FULL_OPTS = [{ id: "Empty", name: "Empty" }, { id: "Full", name: "Full" }]; const cartesian = (...arrs) => arrs.reduce((acc, arr) => acc.flatMap(prev => arr.map(item => [...prev, item])), [[]]); const toNullArr = (arr, asInt = false) => arr.length === 0 ? [null] : arr.map(v => v === "" ? null : (asInt ? Number(v) : v)); const comboCount = React.useMemo(() => { if (!form.cost_type_ids.length) return 0; return cartesian( toNullArr(form.terminal_ids, true), toNullArr(form.cost_type_ids, true), toNullArr(form.equipment_type_ids, true), toNullArr(form.container_sizes), toNullArr(form.container_fullnesses), toNullArr(form.shipment_type_ids, true), toNullArr(form.brand_company_ids, true), ).length; }, [form]); const hasPreview = form.cost_type_ids.length > 0; const fld = (label, children, hint) => ( ); const submit = async () => { if (!form.cost_type_ids.length) { setError("Cost Type is required."); return; } setSaving(true); setError(null); const combinations = cartesian( toNullArr(form.terminal_ids, true), toNullArr(form.cost_type_ids, true), toNullArr(form.equipment_type_ids, true), toNullArr(form.container_sizes), toNullArr(form.container_fullnesses), toNullArr(form.shipment_type_ids, true), toNullArr(form.brand_company_ids, true), ); const entries = combinations.map(([tid, ctid, etid, cs, cf, stype, bcid]) => ({ terminal_id: tid, cost_type_id: ctid, equipment_type_id: etid, container_size: cs, container_fullness: cf, shipment_type: stype, brand_company_id: bcid, })); try { const res = await apiFetch("/api/health/coverage/batch", { method: "POST", body: JSON.stringify({ entries }), }); setSaving(false); if (res.skipped > 0 && res.inserted === 0) { setError(`All ${res.skipped} entries already exist.`); } else if (res.skipped > 0) { setError(`${res.inserted} inserted, ${res.skipped} already existed (skipped).`); } onSaved(); } catch (e) { setError(e.message || "Save failed"); setSaving(false); } }; return (
{ if (e.target === e.currentTarget && !saving) onClose(); }}>
{/* ── header ── */}
Add Coverage Requirement
Multi-select any field — each combination generates one rule. Empty selection = All values.
{/* ── Core section ── */}
Core — Required
{fld("Terminal", set("terminal_ids", v)} placeholder="All terminals — empty = apply to all" getLabel={o => `${o.code} · ${o.name}`} getValue={o => o.id} />, "Leave empty to apply this rule to all terminals." )}
{fld(<>Cost Type *, set("cost_type_ids", v)} placeholder="— Select one or more cost types —" getLabel={o => o.name} getValue={o => o.id} /> )}
{/* ── Scope section ── */}
Scope — Optional (empty = All)
{fld("Equipment Type", set("equipment_type_ids", v)} placeholder="All" getLabel={o => o.name} getValue={o => o.id} /> )} {fld("Container Size", set("container_sizes", v)} placeholder="All" getLabel={o => o.name} getValue={o => o.id} /> )} {fld("Container Fullness", set("container_fullnesses", v)} placeholder="All" getLabel={o => o.name} getValue={o => o.id} /> )} {fld("Shipment Type", set("shipment_type_ids", v)} placeholder="All" getLabel={o => o.name} getValue={o => o.id} /> )}
{fld("Brand Company", set("brand_company_ids", v)} placeholder="All" getLabel={o => o.name} getValue={o => o.id} /> )}
{/* ── preview / combo count ── */}
Preview
{hasPreview ? (
{[ ["Terminal", form.terminal_ids.length ? `${form.terminal_ids.length} selected` : "All"], ["Cost Type", form.cost_type_ids.length ? `${form.cost_type_ids.length} selected` : "—"], ["Equipment", form.equipment_type_ids.length ? `${form.equipment_type_ids.length} selected` : "All"], ["Size", form.container_sizes.length ? form.container_sizes.join(", ") : "All"], ["Fullness", form.container_fullnesses.length ? form.container_fullnesses.join(", ") : "All"], ["Move", form.shipment_type_ids.length ? `${form.shipment_type_ids.length} selected` : "All"], ["Brand", form.brand_company_ids.length ? `${form.brand_company_ids.length} selected` : "All"], ].map(([lbl, val], i) => ( {lbl}: {val} {i < 6 && ·} ))}
{comboCount} rule{comboCount !== 1 ? "s" : ""}
) : (
Select at least one Cost Type to preview.
)}
{error && (
{error}
)}
{/* ── footer ── */}
); } // ───────────────────────────────────────────── // Coverage Matrix Manager // ───────────────────────────────────────────── function CoverageMatrix() { const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [populating, setPopulating] = React.useState(false); const [modal, setModal] = React.useState(false); const [selectedIds, setSelectedIds] = React.useState(new Set()); const [deleting, setDeleting] = React.useState(false); const [importProgress, setImportProgress] = React.useState(null); const [search, setSearch] = React.useState(""); const [colFilters, setColFilters] = React.useState({}); const [openDropdown, setOpenDropdown] = React.useState(null); const [dropdownSearch, setDropdownSearch] = React.useState(""); const fileRef = React.useRef(null); // dropdown options for the Add modal const [terminals, setTerminals] = React.useState([]); const [costTypes, setCostTypes] = React.useState([]); const [equipTypes, setEquipTypes] = React.useState([]); const [moveTypes, setMoveTypes] = React.useState([]); const [brandCompanies, setBrandCompanies] = React.useState([]); const load = () => { setLoading(true); apiFetch("/api/health/coverage") .then(r => { setRows(Array.isArray(r) ? r : []); setSelectedIds(new Set()); }) .catch(() => setRows([])) .finally(() => setLoading(false)); }; React.useEffect(() => { load(); Promise.all([ apiFetchMeta("terminals"), apiFetchMeta("cost-types"), apiFetchMeta("equipment-types"), apiFetchMeta("move-types"), apiFetchMeta("brand-companies"), ]).then(([t, ct, et, mt, bc]) => { setTerminals(Array.isArray(t) ? t : []); setCostTypes(Array.isArray(ct) ? ct : []); setEquipTypes(Array.isArray(et) ? et : []); setMoveTypes(Array.isArray(mt) ? mt : []); setBrandCompanies(Array.isArray(bc) ? bc : []); }).catch(() => {}); }, []); // Close col-filter dropdown on outside click React.useEffect(() => { const h = () => setOpenDropdown(null); document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); // ── filter ── const FILTER_KEYS = ["terminal", "cost_type_name", "equipment", "container_size", "container_fullness", "shipment_type", "brand_company", "created_by"]; const q = search.trim().toLowerCase(); const filtered = rows.filter(r => { if (q && ![r.terminal, r.terminal_name, r.cost_type, r.cost_type_name, r.equipment, r.created_by] .some(v => v && String(v).toLowerCase().includes(q))) return false; for (const k of FILTER_KEYS) { const sel = colFilters[k] || []; if (sel.length && !sel.includes(String(r[k] ?? ""))) return false; } // is_required filter stored as "true"/"false" string const reqSel = colFilters["is_required"] || []; if (reqSel.length && !reqSel.includes(String(r.is_required))) return false; return true; }); // ── column dropdown filter header ── const fDrop = (key, labelFn) => { if (!key) return ; const options = [...new Set(rows.map(r => String(r[key] ?? "")).filter(Boolean))].sort(); const sel = colFilters[key] || []; const active = sel.length > 0; const label = v => (labelFn ? labelFn(v) : v); return (
e.stopPropagation()}> {openDropdown === key && (
setDropdownSearch(e.target.value)} placeholder="Search…" style={{ width: "100%", padding: "3px 7px", fontSize: 12, border: "1px solid var(--border)", borderRadius: 3, outline: "none", boxSizing: "border-box", background: "var(--paper)" }} />
{(() => { const vis = options.filter(v => !dropdownSearch || label(v).toLowerCase().includes(dropdownSearch.toLowerCase())); return ( <>
{vis.map(v => ( ))}
); })()}
)}
); }; // ── checkbox helpers ── const toggleSelect = id => setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const toggleAll = () => { const ids = filtered.map(r => r.id); const allChk = ids.length > 0 && ids.every(id => selectedIds.has(id)); setSelectedIds(allChk ? new Set() : new Set(ids)); }; const chkTh = () => { const ids = filtered.map(r => r.id); const all = ids.length > 0 && ids.every(id => selectedIds.has(id)); const some = ids.some(id => selectedIds.has(id)); return ( { if (el) el.indeterminate = some && !all; }} onChange={toggleAll} /> ); }; const chkTd = id => ( e.stopPropagation()}> toggleSelect(id)} /> ); // ── bulk delete ── const handleBulkDelete = async () => { if (!confirm(`Delete ${selectedIds.size} selected entries?`)) return; setDeleting(true); try { await apiFetch("/api/health/coverage", { method: "DELETE", body: JSON.stringify({ ids: [...selectedIds] }) }); load(); } catch (e) { alert("Delete failed: " + e.message); } setDeleting(false); }; // ── auto-populate ── const handleAutoPopulate = async () => { if (!confirm("This will scan all Active rates and add any missing terminal × cost-type combinations. Continue?")) return; setPopulating(true); try { const res = await apiFetch("/api/health/coverage/auto-populate", { method: "POST" }); load(); alert(`Done: ${res.inserted} new entries added (${res.total_combos} combinations scanned).`); } catch (e) { alert("Auto-populate failed: " + e.message); } setPopulating(false); }; // ── single delete ── const handleDelete = async (id) => { if (!confirm("Remove this coverage requirement?")) return; try { await apiFetch(`/api/health/coverage/${id}`, { method: "DELETE" }); load(); } catch (e) { alert("Delete failed: " + e.message); } }; // ── toggle is_required ── const handleToggle = async (id) => { try { const res = await apiFetch(`/api/health/coverage/${id}`, { method: "PATCH" }); setRows(prev => prev.map(r => r.id === id ? { ...r, is_required: res.is_required } : r)); } catch (e) { alert("Toggle failed: " + e.message); } }; // ── export ── const handleExport = () => { const src = filtered.length < rows.length ? filtered : rows; exportCSV(src.map(r => ({ terminal: r.terminal, terminal_name: r.terminal_name, cost_type: r.cost_type, cost_type_name: r.cost_type_name, equipment: r.equipment, container_size: r.container_size, container_fullness: r.container_fullness, shipment_type: r.shipment_type, brand_company: r.brand_company, is_required: r.is_required ? "TRUE" : "FALSE", created_by: r.created_by, created_at: r.created_at, })), `ocms_coverage_matrix_${new Date().toISOString().slice(0, 10)}.csv`); }; const downloadTemplate = () => { const csv = "terminal,cost_type,equipment,container_size,container_fullness,shipment_type,brand_company\r\nCNSHA,THC,20DRY,20,Full,IMPORT,MCK\r\nCNSHA,THC,All,,,,"; const a = Object.assign(document.createElement("a"), { href: URL.createObjectURL(new Blob([csv], { type: "text/csv" })), download: "coverage_matrix_template.csv", }); a.click(); }; // ── import ── const handleImport = async (e) => { const file = e.target.files[0]; if (!file) return; e.target.value = ""; const reader = new FileReader(); reader.onload = async ev => { const rawRows = parseCSV(ev.target.result); if (!rawRows.length) { alert("No data rows found in CSV."); return; } setImportProgress({ total: rawRows.length, done: 0 }); let ok = 0, fail = 0; for (let i = 0; i < rawRows.length; i++) { const raw = rawRows[i]; const t = terminals.find(x => x.code === (raw.terminal || "").trim()); const ct = costTypes.find(x => x.code === (raw.cost_type || "").trim()); const etCode = (raw.equipment || "").trim(); const et = (etCode && etCode !== "All") ? equipTypes.find(x => x.code === etCode) : null; const mtCode = (raw.shipment_type || "").trim(); const mt = (mtCode && mtCode !== "All") ? moveTypes.find(x => x.code === mtCode) : null; const bcCode = (raw.brand_company || "").trim(); const bc = (bcCode && bcCode !== "All") ? brandCompanies.find(x => x.code === bcCode) : null; const sz = (raw.container_size || "").trim() || null; const fl = (raw.container_fullness || "").trim() || null; if (!t || !ct) { fail++; setImportProgress({ total: rawRows.length, done: i + 1 }); continue; } try { await apiFetch("/api/health/coverage", { method: "POST", body: JSON.stringify({ terminal_id: t.id, cost_type_id: ct.id, equipment_type_id: et ? et.id : null, container_size: sz, container_fullness: fl, shipment_type: mt ? mt.id : null, brand_company_id: bc ? bc.id : null, }), }); ok++; } catch { fail++; } setImportProgress({ total: rawRows.length, done: i + 1 }); } setImportProgress(null); load(); alert(`Import complete: ${ok} added, ${fail} failed/skipped (duplicate or unknown code).`); }; reader.readAsText(file); }; const activeFilterCount = Object.values(colFilters).filter(v => v && v.length).length; return (
} > {/* toolbar */}
setSearch(e.target.value)} />
{selectedIds.size > 0 && ( )} {activeFilterCount > 0 && ( )} {importProgress && ( Importing {importProgress.done}/{importProgress.total}… )} {filtered.length} / {rows.length} entries
{loading ? (
Loading…
) : (
{chkTh()} {filtered.length === 0 && ( )} {filtered.map(r => ( {chkTd(r.id)} ))}
Terminal Cost Type Equipment Size Fullness Move Type Brand Required Added by Date
{fDrop("terminal")} {fDrop("cost_type_name")} {fDrop("equipment")} {fDrop("container_size")} {fDrop("container_fullness")} {fDrop("shipment_type")} {fDrop("brand_company")} {fDrop("is_required", v => v === "true" ? "Yes" : "No")} {fDrop("created_by")}
{rows.length === 0 ? 'No entries. Click "Auto-populate from rates" to seed the matrix from existing Active rates.' : "No rows match current filters."}
{r.terminal} {r.terminal_name} {r.cost_type_name} {r.equipment} {r.container_size} {r.container_fullness} {r.shipment_type} {r.brand_company} {r.created_by} {r.created_at}
)} {modal && setModal(false)} onSaved={() => { setModal(false); load(); }} />}
); } function AccessRequestForm({ user, onClose, onSubmitted, onBehalf = false }) { const ROLE_DESCS = { rate_calculator: "Cost estimation tool only", read: "View rates, run calculator", edit: "Create / update rates", fbp: "Analytical, AI Q&A, export", procurement: "Contract upload, AI Q&A, export", }; const [form, setForm] = useState({ role: "read", regions: [], areas: [], justification: "" }); const [behalfEmail, setBehalfEmail] = useState(""); const [regionObjs, setRegionObjs] = useState([]); // { id, name } const [allAreas, setAllAreas] = useState([]); // { id, name, region_id } const [approvers, setApprovers] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [areaOpen, setAreaOpen] = useState(false); const [areaSearch, setAreaSearch] = useState(""); const areaRef = React.useRef(null); React.useEffect(() => { Promise.all([ apiFetchMeta("regions"), apiFetchMeta("areas"), ]).then(([r, a]) => { setRegionObjs(Array.isArray(r) ? r : []); setAllAreas(Array.isArray(a) ? a : []); }).catch(() => {}); }, []); React.useEffect(() => { const h = e => { if (areaRef.current && !areaRef.current.contains(e.target)) setAreaOpen(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); React.useEffect(() => { if (form.regions.length === 0) { setApprovers([]); return; } apiFetch(`/api/access-requests/routing-preview?regions=${encodeURIComponent(form.regions.join(","))}`) .then(r => setApprovers(Array.isArray(r.approvers) ? r.approvers : [])) .catch(() => {}); }, [form.regions]); // Areas available for current selected regions const selectedRegionIds = regionObjs.filter(r => form.regions.includes(r.name)).map(r => r.id); const availableAreas = selectedRegionIds.length > 0 ? allAreas.filter(a => selectedRegionIds.includes(a.region_id)) : allAreas; const filteredAreas = areaSearch.trim() ? availableAreas.filter(a => a.name.toLowerCase().includes(areaSearch.trim().toLowerCase())) : availableAreas; const toggleRegion = r => { const has = form.regions.includes(r); const newRegions = has ? form.regions.filter(x => x !== r) : [...form.regions, r]; // When deselecting a region, remove areas that no longer belong to any selected region if (has) { const remaining = regionObjs.filter(ro => newRegions.includes(ro.name)).map(ro => ro.id); const validAreaNames = allAreas.filter(a => remaining.includes(a.region_id)).map(a => a.name); setForm(f => ({ ...f, regions: newRegions, areas: f.areas.filter(a => validAreaNames.includes(a)) })); } else { setForm(f => ({ ...f, regions: newRegions })); } }; const toggleArea = name => { setForm(f => ({ ...f, areas: f.areas.includes(name) ? f.areas.filter(x => x !== name) : [...f.areas, name], })); }; const submit = async () => { if (onBehalf && !behalfEmail.trim()) { setError("Target user email is required."); return; } if (onBehalf && !behalfEmail.includes("@")) { setError("Please enter a valid email address."); return; } if (!form.justification.trim()) { setError("Business justification is required."); return; } if (form.regions.length === 0) { setError("Please select at least one region."); return; } setSubmitting(true); setError(null); try { await apiFetch("/api/access-requests", { method: "POST", body: JSON.stringify({ requested_role: form.role, requested_regions: JSON.stringify(form.regions), requested_areas: JSON.stringify(form.areas), justification: form.justification, ...(onBehalf ? { on_behalf_of: behalfEmail.trim().toLowerCase() } : {}), }), }); onSubmitted?.(); onClose(); } catch (e) { setError(e.message); setSubmitting(false); } }; return ( } >
{onBehalf ? ( setBehalfEmail(e.target.value)} placeholder="target.user@maersk.com" autoFocus /> ) : ( )}
{Object.keys(ROLE_DESCS).map(r => ( ))}
{regionObjs.map(r => ( ))}
{/* Trigger button */} {/* Selected tags */} {form.areas.length > 0 && (
{form.areas.map(a => ( {a} ))}
)} {/* Dropdown panel */} {areaOpen && (
setAreaSearch(e.target.value)} placeholder="Search areas…" style={{ width: "100%", padding: "5px 8px", fontSize: 12, border: "1px solid var(--border)", borderRadius: 4, outline: "none", boxSizing: "border-box" }} />
{availableAreas.length === 0 && (
{form.regions.length === 0 ? "Select a region first" : "No areas for selected region(s)"}
)} {filteredAreas.length === 0 && availableAreas.length > 0 && (
No matches
)} {filteredAreas.map(a => ( ))}
)}