// Screens: Rate List, Rate Detail/Edit, Add Rate, Import Wizard const RATE_STATUSES = ["Active", "Draft", "Inactive"]; // ─── All possible table columns (mirrors export CSV) ───────────────────────── const ALL_COLUMNS = [ { key: "id", label: "Rate ID", cls: "mono tiny" }, { key: "terminal", label: "Terminal" }, { key: "terminal_code", label: "Terminal Code", cls: "mono tiny muted" }, { key: "terminal_name", label: "Terminal Name", cls: "muted" }, { key: "cost_category", label: "Cost Category", cls: "muted" }, { key: "cost_type", label: "Cost Type" }, { key: "cost_type_name", label: "Cost Type Name", cls: "muted" }, { key: "sap_material_code", label: "SAP Material", cls: "mono tiny muted" }, { key: "material_name", label: "Material", cls: "tiny muted" }, { key: "charge_unit_code", label: "UOM", cls: "muted" }, { key: "containertype_code", label: "Ctr Type", cls: "muted" }, { key: "container_size", label: "Size", cls: "muted" }, { key: "container_fullness", label: "Fullness", cls: "muted" }, { key: "shipment_type_code", label: "Shipment", cls: "muted" }, { key: "rate_local", label: "Rate (Local)", cls: "num bold" }, { key: "iso_code", label: "Currency", cls: "muted" }, { key: "rate_usd", label: "≈ USD", cls: "num muted" }, { key: "effective_date", label: "Effective", cls: "mono tiny" }, { key: "expiry_date", label: "Expires", cls: "mono tiny" }, { key: "slab_unit_code", label: "Slab Unit", cls: "muted" }, { key: "slab_type_code", label: "Slab Type", cls: "muted" }, { key: "slab_min", label: "Slab Min", cls: "num muted" }, { key: "slab_max", label: "Slab Max", cls: "num muted" }, { key: "sap_vendor_code", label: "SAP Vendor", cls: "mono tiny muted" }, { key: "vendor_name", label: "Vendor Name", cls: "muted" }, { key: "contract_ref", label: "Contract", cls: "mono tiny muted" }, { key: "contract_owner", label: "Owner", cls: "muted" }, { key: "transport_type", label: "Transport", cls: "muted" }, { key: "route", label: "Route", cls: "muted" }, { key: "corridor_from", label: "Corridor From", cls: "muted" }, { key: "corridor_to", label: "Corridor To", cls: "muted" }, { key: "payment_terms", label: "Payment Terms", cls: "muted" }, { key: "brand_company_name", label: "Brand Company", cls: "muted" }, { key: "payable_by", label: "Payable By", cls: "muted" }, { key: "notes", label: "Notes", cls: "tiny muted" }, { key: "is_invoicerate", label: "Invoice Rate", cls: "center" }, { key: "stcy_flag", label: "STCY", cls: "center" }, { key: "calculator", label: "Calculator", cls: "center" }, { key: "source", label: "Source", cls: "tiny muted" }, { key: "status", label: "Status" }, { key: "created_by", label: "Created By", cls: "tiny muted" }, { key: "created_at", label: "Created At", cls: "mono tiny muted" }, { key: "modified_by", label: "Updated By", cls: "tiny muted" }, { key: "modified_at", label: "Updated At", cls: "mono tiny muted" }, ]; const DEFAULT_VIEW_COLUMNS = ["terminal","cost_type","charge_unit_code","rate_local","rate_usd","effective_date","contract_ref","status","modified_by"]; function renderCell(col, r, roeMap) { switch (col.key) { case "id": return r.id; case "terminal": return <>
{r.terminal_name}
{r.terminal_code}
; case "terminal_code": return r.terminal_code; case "terminal_name": return r.terminal_name; case "cost_category": return r.cost_category; case "cost_type": return <>
{r.cost_type_name}
{r.material_name &&
{r.material_name}
}; case "cost_type_name": return r.cost_type_name; case "sap_material_code": return r.sap_material_code; case "material_name": return r.material_name; case "charge_unit_code": return r.charge_unit_code; case "containertype_code":return r.containertype_code; case "container_size": return r.container_size; case "container_fullness":return r.container_fullness; case "shipment_type_code":return r.shipment_type_code; case "rate_local": return <>{fmt.num(r.rate_local, 2)} {r.iso_code}; case "iso_code": return r.iso_code; case "rate_usd": return fmt.usd(r.rate_local / (roeMap[r.iso_code] || 1)); case "effective_date": return r.effective_date?.slice(0, 10); case "expiry_date": return r.expiry_date?.slice(0, 10); case "slab_unit_code": return r.slab_unit_code; case "slab_type_code": return r.slab_type_code; case "slab_min": return r.slab_min; case "slab_max": return r.slab_max; case "sap_vendor_code": return r.sap_vendor_code; case "vendor_name": return r.vendor_name; case "contract_ref": return r.contract_ref; case "contract_owner": return r.contract_owner; case "transport_type": return r.transport_type; case "route": return r.route; case "route_rate": return r.route_rate; case "corridor_from": return r.corridor_from; case "corridor_to": return r.corridor_to; case "payment_terms": return r.payment_terms; case "brand_company_name": return r.brand_company_name || r.brand_company_code; case "payable_by": return r.payable_by; case "notes": return r.notes; case "is_invoicerate": return r.is_invoicerate ? "✓" : ""; case "stcy_flag": return r.stcy_flag ? "✓" : ""; case "calculator": return r.calculator ? "✓" : ""; case "source": return r.source; case "status": return ; case "created_by": return r.created_by; case "created_at": return r.created_at?.slice(0, 10); case "modified_by": return <>{r.modified_by}
{r.modified_at?.slice(5,10)}; case "modified_at": return r.modified_at?.slice(0, 10); default: return r[col.key] ?? ""; } } // ─── Manage Views Modal ─────────────────────────────────────────────────────── function ManageViewsModal({ views, currentFilters, onClose, onApply, onSaved, user, perms, initialViewId }) { const isAdmin = (user?.roles || []).map(r => r.toLowerCase()).includes("admin"); const [selected, setSelected] = React.useState( views.find(v => v.id === initialViewId) || views.find(v => v.is_default) || views[0] || null ); const [editName, setEditName] = React.useState(""); const [editCols, setEditCols] = React.useState([]); const [editFilters, setEditFilters] = React.useState({}); const [saving, setSaving] = React.useState(false); const [err, setErr] = React.useState(null); const [availSearch, setAvailSearch] = React.useState(""); const [availSort, setAvailSort] = React.useState("default"); // "default" | "az" const colListRef = React.useRef(); const dragFromKey = React.useRef(null); const [dragOverKey, setDragOverKey] = React.useState(null); React.useEffect(() => { if (selected) { setEditName(selected.name); setEditCols(Array.isArray(selected.columns) ? [...selected.columns] : [...DEFAULT_VIEW_COLUMNS]); setEditFilters(selected.filters || {}); } }, [selected?.id]); const canEdit = selected && (isAdmin || (!selected.is_default && (selected.owner_upn === user?.upn || !selected.owner_upn))); const availableCols = React.useMemo(() => { let cols = ALL_COLUMNS.filter(c => !editCols.includes(c.key)); if (availSearch.trim()) { const q = availSearch.toLowerCase(); cols = cols.filter(c => c.label.toLowerCase().includes(q) || c.key.toLowerCase().includes(q)); } if (availSort === "az") cols = [...cols].sort((a, b) => a.label.localeCompare(b.label)); return cols; }, [editCols, availSearch, availSort]); const selectedColDefs = editCols.map(k => ALL_COLUMNS.find(c => c.key === k)).filter(Boolean); const addCol = (key) => { setEditCols(prev => [...prev, key]); // scroll to bottom so newly added item is visible setTimeout(() => { if (colListRef.current) colListRef.current.scrollTop = colListRef.current.scrollHeight; }, 30); }; const removeCol = (key) => setEditCols(prev => prev.filter(k => k !== key)); const moveCol = (key, dir) => { setEditCols(prev => { const next = [...prev]; const idx = next.indexOf(key); if (idx === -1) return prev; const target = idx + dir; if (target < 0 || target >= next.length) return prev; [next[idx], next[target]] = [next[target], next[idx]]; return next; }); }; const onDragStart = (e, key) => { dragFromKey.current = key; e.dataTransfer.effectAllowed = "move"; }; const onDragEnter = (e, key) => { e.preventDefault(); if (dragFromKey.current && dragFromKey.current !== key) setDragOverKey(key); }; const onDragOver = (e) => { e.preventDefault(); // auto-scroll the list container const el = colListRef.current; if (!el) return; const { top, bottom } = el.getBoundingClientRect(); const zone = 36; if (e.clientY < top + zone) el.scrollTop -= 6; if (e.clientY > bottom - zone) el.scrollTop += 6; }; const onDrop = (e, toKey) => { e.preventDefault(); const fromKey = dragFromKey.current; if (!fromKey || fromKey === toKey) { dragFromKey.current = null; setDragOverKey(null); return; } setEditCols(prev => { const next = [...prev]; const fromIdx = next.indexOf(fromKey); const toIdx = next.indexOf(toKey); if (fromIdx === -1 || toIdx === -1) return prev; next.splice(fromIdx, 1); next.splice(toIdx, 0, fromKey); return next; }); dragFromKey.current = null; setDragOverKey(null); }; const onDragEnd = () => { dragFromKey.current = null; setDragOverKey(null); }; const handleSave = async () => { if (!editName.trim()) return; setSaving(true); setErr(null); try { const body = { name: editName.trim(), filters: editFilters, columns: editCols }; let res; if (selected.id < 0) { res = await apiFetch("/api/rate-views", { method: "POST", body: JSON.stringify(body) }); } else { res = await apiFetch(`/api/rate-views/${selected.id}`, { method: "PUT", body: JSON.stringify(body) }); } await onSaved(); } catch(e) { setErr(e.message || "Save failed"); } setSaving(false); }; const handleDelete = async () => { if (!selected || selected.is_default) return; if (!confirm(`Delete view "${selected.name}"?`)) return; setSaving(true); try { await apiFetch(`/api/rate-views/${selected.id}`, { method: "DELETE" }); await onSaved(); setSelected(null); } catch(e) { setErr(e.message || "Delete failed"); } setSaving(false); }; const handleNew = () => { const baseCols = selected && Array.isArray(selected.columns) && selected.columns.length > 0 ? [...selected.columns] : [...DEFAULT_VIEW_COLUMNS]; const newView = { id: -Date.now(), name: "New View", is_default: false, owner_upn: user?.upn, filters: currentFilters, columns: baseCols }; setSelected(newView); }; const handleApply = () => { if (!selected) return; onApply(selected); onClose(); }; return (
{ if (e.target === e.currentTarget) onClose(); }}>
Manage Views
{/* Left: view list */}
{views.map(v => (
setSelected(v)} style={{ padding: "8px 14px", cursor: "pointer", borderRadius: 0, background: selected?.id === v.id ? "var(--paper)" : "", borderLeft: selected?.id === v.id ? "3px solid var(--teal)" : "3px solid transparent", fontWeight: v.is_default ? 600 : 400, fontSize: 13 }}> {v.is_default && DEFAULT} {v.name} {!v.owner_upn && !v.is_default && (global)}
))}
{/* Right: editor */} {selected ? (
setEditName(e.target.value)} disabled={!canEdit} placeholder="View name" />
{/* Column picker */}
Columns
{/* Available */}
Available setAvailSearch(e.target.value)} placeholder="Search…" style={{ flex: 1, fontSize: 11, border: "1px solid var(--line)", borderRadius: 4, padding: "2px 6px", outline: "none", minWidth: 0 }} />
{availableCols.map(c => (
canEdit && addCol(c.key)} style={{ padding: "6px 10px", fontSize: 13, cursor: canEdit ? "pointer" : "default", display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid var(--line, #eee)" }} onMouseEnter={e => e.currentTarget.style.background = "var(--paper)"} onMouseLeave={e => e.currentTarget.style.background = ""}> {c.label} {canEdit && +}
))} {availableCols.length === 0 && (
{availSearch ? "No matching fields" : "All columns selected"}
)}
{/* Selected (arrow reorder) */}
Displayed ({selectedColDefs.length}) {selectedColDefs.length > 0 && canEdit && ( )}
{selectedColDefs.map((c, idx) => (
onDragStart(e, c.key)} onDragEnter={e => onDragEnter(e, c.key)} onDrop={e => onDrop(e, c.key)} onDragEnd={onDragEnd} style={{ padding: "4px 8px 4px 6px", fontSize: 13, display: "flex", alignItems: "center", gap: 6, borderBottom: "1px solid var(--line, #eee)", borderTop: dragOverKey === c.key ? "2px solid var(--teal, #009eb4)" : "2px solid transparent", background: dragOverKey === c.key ? "var(--teal-light, #e6f6f9)" : "", cursor: canEdit ? "grab" : "default", userSelect: "none", }}> {canEdit && } {c.label} {canEdit && (
)}
))} {selectedColDefs.length === 0 &&
No columns selected. Click + in Available to add.
}
{err &&
{err}
}
{selected.id > 0 && ( )} {canEdit && ( )} {canEdit && !selected.is_default && selected.id > 0 && ( )}
) : (
Select a view or create a new one
)}
); } function RateList({ user, perms, onOpenRate, onAdd, onAddPrefill, onImport, onOpenAI, refreshKey, onToast }) { const [filters, setFilters] = useState({ terminals: [], costTypes: [], statuses: ["Active"], containerTypes: [], containerSizes: [], containerFullnesses: [], invoiceRate: false, stcyFlag: false, calculator: false, q: "", }); const [page, setPage] = useState(1); const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState("asc"); const [colFilters, setColFilters] = useState({}); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [terminals, setTerminals] = useState([]); const [costTypes, setCostTypes] = useState([]); const [statusOptions, setStatusOptions] = useState([]); const [equipTypes, setEquipTypes] = useState([]); const [roeMap, setRoeMap] = useState({}); const [selectedIds, setSelectedIds] = useState(new Set()); const [selectAllPages, setSelectAllPages] = useState(false); const [showBatchEdit, setShowBatchEdit] = useState(false); const [savedViews, setSavedViews] = useState([]); const [activeView, setActiveView] = useState(null); const [manageViewsInitId, setManageViewsInitId] = useState(null); const openManageViews = () => { setManageViewsInitId(activeView?.id ?? null); }; const [viewMenuOpen, setViewMenuOpen] = useState(false); const viewMenuRef = useRef(); const [groupBySlab, setGroupBySlab] = useState(false); const [expandedGroups, setExpandedGroups] = useState(new Set()); const [filterMultiGroup, setFilterMultiGroup] = useState(false); const pageSize = 14; const activeColumns = useMemo(() => { const cols = activeView?.columns; return Array.isArray(cols) && cols.length > 0 ? cols : DEFAULT_VIEW_COLUMNS; }, [activeView]); const loadViews = () => apiFetch("/api/rate-views").then(data => { if (!Array.isArray(data)) return; setSavedViews(data); setActiveView(prev => { if (prev) return data.find(v => v.id === prev.id) || prev; return data.find(v => v.is_default) || data[0] || null; }); }).catch(() => {}); const load = () => { setLoading(true); const params = new URLSearchParams(); filters.terminals.forEach(id => params.append("terminal_id", id)); filters.costTypes.forEach(id => params.append("cost_type_id", id)); filters.statuses.forEach(s => params.append("status", s)); filters.containerTypes.forEach(id => params.append("containertype", id)); filters.containerSizes.forEach(s => params.append("container_size", s)); filters.containerFullnesses.forEach(s => params.append("container_fullness", s)); if (filters.invoiceRate) params.append("is_invoicerate", "true"); if (filters.stcyFlag) params.append("stcy_flag", "true"); if (filters.calculator) params.append("calculator", "true"); Promise.all([ apiFetch(`/api/rates?${params}&limit=500`), apiFetch("/api/metadata/terminals"), apiFetch("/api/metadata/cost-types"), apiFetch("/api/roe/latest"), apiFetch("/api/metadata/rate-statuses"), apiFetch("/api/metadata/equipment-types"), ]).then(([rates, terms, cts, roe, statuses, equips]) => { const roe_by_iso = {}; (Array.isArray(roe) ? roe : []).forEach(r => { roe_by_iso[r.iso_code] = r.rate; }); setRoeMap(roe_by_iso); setTerminals(Array.isArray(terms) ? terms : []); setCostTypes(Array.isArray(cts) ? cts : []); setStatusOptions(Array.isArray(statuses) ? statuses.filter(s => s.is_active) : []); setEquipTypes(Array.isArray(equips) ? equips.filter(e => e.is_active) : []); const rateArr = Array.isArray(rates) ? rates : []; setRows(rateArr); setLoading(false); }).catch(() => setLoading(false)); }; // Always keep a fresh ref so the refreshKey effect never closes over a stale load const loadRef = useRef(load); loadRef.current = load; useEffect(() => { loadViews(); }, []); useEffect(() => { load(); setPage(1); }, [ filters.terminals.join(","), filters.costTypes.join(","), filters.statuses.join(","), filters.containerTypes.join(","), filters.containerSizes.join(","), filters.containerFullnesses.join(","), filters.invoiceRate, filters.stcyFlag, filters.calculator, ]); useEffect(() => { if (refreshKey > 0) { loadRef.current(); setPage(1); } }, [refreshKey]); const handleSupersede = async (r) => { const isBatch = effectiveSelectedCount > 1; const msg = isBatch ? `Deactivate ${effectiveSelectedCount} selected rates?\n\nAll selected records will be marked as Inactive. This cannot be undone.` : `Deactivate rate #${r.id}?\n\nThis will mark it as Inactive and cannot be undone.`; if (!confirm(msg)) return; try { if (isBatch) { const res = await apiFetch(`${API_BASE}/api/rates/batch`, { method: "PATCH", body: JSON.stringify({ ids: effectiveSelectedIds, updates: { status: "Inactive" } }), }); clearSelection(); alert(`Deactivated ${res.updated} rate(s).`); } else { await apiFetch(`${API_BASE}/api/rates/${r.id}`, { method: "DELETE" }); } load(); } catch (e) { alert("Failed to deactivate: " + (e.message || e)); } }; const handleBulkDuplicate = async () => { const ids = effectiveSelectedIds; const count = ids.length; if (!confirm(`Duplicate ${count} record${count !== 1 ? "s" : ""} as Draft? They will be saved directly to the database.`)) return; const selectedRows = rows.filter(r => ids.includes(r.id)); let ok = 0, fail = 0; await Promise.all(selectedRows.map(async r => { const body = { terminal_id: r.terminal_id, cost_type_id: r.cost_type_id, charge_unit_id: r.charge_unit_id, containertype: r.containertype ?? r.cargo_type_id, shipment_type: r.shipment_type, primary_currency: r.primary_currency, rate_local: r.rate_local, route_rate: r.route_rate ?? null, slab_min: r.slab_min ?? null, slab_max: r.slab_max ?? null, slab_unit: r.slab_unit ?? null, slab_type: r.slab_type ?? null, effective_date: r.effective_date, expiry_date: r.expiry_date || null, material_name: r.material_name, contract_material_name: r.contract_material_name, sap_material_code: r.sap_material_code, sap_vendor_code: r.sap_vendor_code, contract_ref: r.contract_ref, contract_owner: r.contract_owner, payment_terms: r.payment_terms, notes: r.notes, transport_type: r.transport_type, secondary_currency: r.secondary_currency, route: r.route, corridor_from: r.corridor_from, corridor_to: r.corridor_to, container_size: r.container_size, container_fullness: r.container_fullness, cost_category: r.cost_category, is_invoicerate: r.is_invoicerate, stcy_flag: r.stcy_flag, calculator: r.calculator, status: "Draft", source: "Manual", }; try { await apiFetch("/api/rates", { method: "POST", body: JSON.stringify(body) }); ok++; } catch (_) { fail++; } })); clearSelection(); load(); if (onToast) onToast(`Duplicated ${ok} rate${ok !== 1 ? "s" : ""} as Draft${fail > 0 ? ` (${fail} failed)` : ""}.`); }; const handleDuplicate = (r) => { if (effectiveSelectedCount > 1 && (selectAllPages || selectedIds.has(r.id))) { handleBulkDuplicate(); return; } const prefill = { ...r, id: undefined, status: "Draft", created_at: undefined, modified_at: undefined }; if (onAddPrefill) onAddPrefill(prefill); }; const handleDelete = async (r) => { const isBatch = effectiveSelectedCount > 1; const msg = isBatch ? `Permanently delete ${effectiveSelectedCount} selected rates?\n\nOnly Draft records will be deleted. Non-Draft records will be skipped.\n\nThis action cannot be undone.` : `Permanently delete rate #${r.id}?\n\nThis action cannot be undone.`; if (!confirm(msg)) return; try { if (isBatch) { const res = await apiFetch(`${API_BASE}/api/rates/batch-delete`, { method: "POST", body: JSON.stringify({ ids: effectiveSelectedIds }), }); clearSelection(); alert(`Deleted ${res.deleted} rate(s)${res.skipped > 0 ? `, skipped ${res.skipped} non-Draft record(s)` : ""}.`); } else { await apiFetch(`${API_BASE}/api/rates/${r.id}/hard`, { method: "DELETE" }); } load(); } catch (e) { alert("Failed to delete: " + (e.message || e)); } }; useEffect(() => { const h = (e) => { if (viewMenuRef.current && !viewMenuRef.current.contains(e.target)) setViewMenuOpen(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); useEffect(() => { if (!loading) { const q = filters.q.toLowerCase(); // client-side q filter only (server already filtered by other dims) } }, [filters.q]); const NUMERIC_KEYS = new Set(["rate_local", "rate_usd", "route_rate", "slab_min", "slab_max"]); const BOOL_KEYS = new Set(["is_invoicerate", "stcy_flag", "calculator"]); function handleSort(key) { if (sortKey === key) { setSortDir(d => d === "asc" ? "desc" : "asc"); } else { setSortKey(key); setSortDir("asc"); } setPage(1); } const activeColFilters = useMemo(() => { return Object.fromEntries( Object.entries(colFilters).filter(([k, v]) => Array.isArray(v) && v.length > 0 && activeColumns.includes(k)) ); }, [colFilters, activeColumns]); const displayRows = useMemo(() => { const q = filters.q?.trim().toLowerCase(); const searchKeys = activeColumns.filter(k => !NUMERIC_KEYS.has(k) && !BOOL_KEYS.has(k)); let result = rows; if (q) { const terms = q.split(/\s+/).filter(Boolean); result = result.filter(r => { const haystack = searchKeys.map(k => { const v = r[k]; return v == null ? "" : String(v); }).join(" ").toLowerCase(); return terms.every(t => haystack.includes(t)); }); } const cfEntries = Object.entries(activeColFilters); if (cfEntries.length > 0) { result = result.filter(r => cfEntries.every(([k, allowed]) => { const rv = r[k]; const s = rv == null ? "" : String(rv); return allowed.includes(s); }) ); } if (sortKey) { const dir = sortDir === "asc" ? 1 : -1; const isNum = NUMERIC_KEYS.has(sortKey); result = [...result].sort((a, b) => { const av = a[sortKey] ?? (isNum ? -Infinity : ""); const bv = b[sortKey] ?? (isNum ? -Infinity : ""); if (isNum) return (av - bv) * dir; return String(av).localeCompare(String(bv)) * dir; }); } return result; }, [rows, filters.q, activeColumns, activeColFilters, sortKey, sortDir]); const GROUP_KEY_FIELDS = ["terminal_id","cost_type_id","containertype","container_size","container_fullness","shipment_type","corridor_from","corridor_to","transport_type","sap_vendor_code","sap_material_code","brand_company_id","payable_by","status","effective_date","expiry_date"]; const allGroups = useMemo(() => { if (!groupBySlab) return null; const map = new Map(); const ungrouped = []; for (const r of displayRows) { if (r.slab_unit == null || r.slab_unit === "") { ungrouped.push({ key: `__ungrouped__${r.id}`, rows: [r] }); continue; } const k = GROUP_KEY_FIELDS.map(f => r[f] ?? "").join("|"); if (!map.has(k)) map.set(k, { key: k, rows: [] }); map.get(k).rows.push(r); } return [...map.values(), ...ungrouped]; }, [displayRows, groupBySlab]); const groupedDisplay = useMemo(() => { if (!allGroups) return null; return filterMultiGroup ? allGroups.filter(g => g.rows.length > 1) : allGroups; }, [allGroups, filterMultiGroup]); const toggleGroup = (key) => setExpandedGroups(prev => { const next = new Set(prev); next.has(key) ? next.delete(key) : next.add(key); return next; }); const pagedItems = groupBySlab ? groupedDisplay : displayRows; const pageRows = (pagedItems || []).slice((page - 1) * pageSize, page * pageSize); const totalPages = Math.max(1, Math.ceil((pagedItems?.length || 0) / pageSize)); const pageIds = groupBySlab ? pageRows.flatMap(grp => grp.rows.map(r => r.id)) : pageRows.map(r => r.id); const allPageSelected = pageIds.length > 0 && pageIds.every(id => selectedIds.has(id)); const somePageSelected = pageIds.some(id => selectedIds.has(id)); function toggleAll() { setSelectAllPages(false); setSelectedIds(prev => { const next = new Set(prev); if (allPageSelected) { pageIds.forEach(id => next.delete(id)); } else { pageIds.forEach(id => next.add(id)); } return next; }); } function toggleOne(id) { setSelectAllPages(false); setSelectedIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); } function clearSelection() { setSelectedIds(new Set()); setSelectAllPages(false); } const handleExport = () => { if (!rows.length) return; const colDefs = [ { key: "id", header: "id" }, { key: "terminal_code", header: "terminal_code" }, { key: "terminal_name", header: "terminal_name" }, { key: "cost_category", header: "cost_category" }, { key: "cost_type_name", header: "cost_type_name" }, { key: "sap_material_code",header: "sap_material_code" }, { key: "material_name", header: "material_name" }, { key: "charge_unit_code", header: "charge_unit_code" }, { key: "containertype_code",header: "containertype_code" }, { key: "container_size", header: "container_size" }, { key: "container_fullness",header: "container_fullness" }, { key: "shipment_type_code",header: "shipment_type_code" }, { key: "rate_local", header: "rate_local" }, { key: "iso_code", header: "primary_currency" }, { key: "effective_date", header: "effective_date" }, { key: "expiry_date", header: "expiry_date" }, { key: "slab_unit_code", header: "slab_unit" }, { key: "slab_type_code", header: "slab_type" }, { key: "slab_min", header: "slab_min" }, { key: "slab_max", header: "slab_max" }, { key: "sap_vendor_code", header: "sap_vendor_code" }, { key: "vendor_name", header: "vendor_name" }, { key: "contract_ref", header: "contract_ref" }, { key: "contract_owner", header: "contract_owner" }, { key: "transport_type", header: "transport_type" }, { key: "route", header: "route" }, { key: "route_rate", header: "route_rate" }, { key: "corridor_from", header: "corridor_from" }, { key: "corridor_to", header: "corridor_to" }, { key: "payment_terms", header: "payment_terms" }, { key: "brand_company_code", header: "brand_company_code" }, { key: "payable_by", header: "payable_by" }, { key: "notes", header: "notes" }, { key: "is_invoicerate", header: "is_invoicerate" }, { key: "stcy_flag", header: "stcy_flag" }, { key: "calculator", header: "calculator" }, { key: "source", header: "source" }, { key: "status", header: "status" }, { key: "created_by", header: "created_by" }, { key: "created_at", header: "created_at" }, { key: "modified_by", header: "modified_by" }, { key: "modified_at", header: "modified_at" }, ]; const escape = v => { if (v == null) return ""; const s = String(v); return s.includes(",") || s.includes('"') || s.includes("\n") ? `"${s.replace(/"/g, '""')}"` : s; }; const csv = [ colDefs.map(c => c.header).join(","), ...rows.map(r => colDefs.map(c => escape(r[c.key])).join(",")), ].join("\r\n"); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `ocms_rates_${new Date().toISOString().slice(0,10)}.csv`; a.click(); URL.revokeObjectURL(url); }; const effectiveSelectedIds = selectAllPages ? displayRows.map(r => r.id) : [...selectedIds]; const effectiveSelectedCount = selectAllPages ? displayRows.length : selectedIds.size; const activeCount = rows.filter(r => r.status === "Active").length; const terminalCount = new Set(rows.map(r => r.terminal_id)).size; const expiringSoon = rows.filter(r => { if (!r.expiry_date) return false; const days = (new Date(r.expiry_date) - new Date()) / 86400000; return days >= 0 && days <= 30; }).length; return (
{perms.aiAssistant && ( )} {perms.export && ( )} {perms.importRates && ( )} {perms.editRates && effectiveSelectedCount > 1 && ( )} {perms.editRates && effectiveSelectedCount > 0 && ( )} {perms.editRates && ( )} } />
0 ? "up" : undefined} />
{/* View selector bar */}
View:
{viewMenuOpen && (
{savedViews.map(v => (
{ setActiveView(v); if (v.filters) setFilters({ ...filters, ...v.filters }); setViewMenuOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", display: "flex", alignItems: "center", gap: 8, background: activeView?.id === v.id ? "var(--paper)" : "" }} onMouseEnter={e => e.currentTarget.style.background = "var(--paper)"} onMouseLeave={e => e.currentTarget.style.background = activeView?.id === v.id ? "var(--paper)" : ""}> {v.is_default && DEF} {v.name} {activeView?.id === v.id && }
))}
)}
({ value: String(t.id), label: `${t.code} · ${t.name}` }))} onChange={(v) => setFilters({ ...filters, terminals: v })} /> ({ value: String(c.id), label: c.name }))} onChange={(v) => setFilters({ ...filters, costTypes: v })} /> 0 ? statusOptions.map(s => ({ value: s.name, label: s.name })) : RATE_STATUSES.map(s => ({ value: s, label: s })) } onChange={(v) => setFilters({ ...filters, statuses: v })} /> ({ value: String(e.id), label: e.name }))} onChange={(v) => setFilters({ ...filters, containerTypes: v })} /> ({ value: s, label: s }))} onChange={(v) => setFilters({ ...filters, containerSizes: v })} /> ({ value: s, label: s }))} onChange={(v) => setFilters({ ...filters, containerFullnesses: v })} />
setFilters({ ...filters, invoiceRate: v })} /> setFilters({ ...filters, stcyFlag: v })} /> setFilters({ ...filters, calculator: v })} /> {groupBySlab && <>
}
{ setFilters({ ...filters, q: e.target.value }); setPage(1); }} />
{activeColumns.map(key => { const colDef = ALL_COLUMNS.find(c => c.key === key); const isNum = NUMERIC_KEYS.has(key); const active = sortKey === key; return ( ); })} {activeColumns.map(key => { const selected = colFilters[key] || []; const isActive = selected.length > 0; const uniqueVals = (() => { const all = [...new Set(rows.map(r => { const v = r[key]; return v == null ? "" : String(v); }))]; const nonEmpty = all.filter(v => v !== "").sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); return all.includes("") ? [...nonEmpty, ""] : nonEmpty; })(); return ( ); })} {loading ? ( ) : pageRows.length === 0 ? ( ) : groupBySlab ? pageRows.map(grp => { const rep = grp.rows[0]; const multi = grp.rows.length > 1; const expanded = expandedGroups.has(grp.key); return ( {/* ── Group summary row ── */} multi ? toggleGroup(grp.key) : onOpenRate(rep)} style={{ cursor: "pointer", background: multi ? (expanded ? "#e0f2f4" : "#f0fafa") : "" }}> {activeColumns.map(key => { const colDef = ALL_COLUMNS.find(c => c.key === key); const blank = multi && ["rate_local","rate_usd","is_invoicerate","stcy_flag","calculator"].includes(key); return ; })} {/* ── Expanded slab sub-rows ── */} {multi && expanded && grp.rows.map((r, i) => { const currency = r.currency_code || r.iso_code || ""; const SlabCell = ({ label, value, mono, color, width = 72 }) => (
{label} {value != null && value !== "" && value !== false ? {value} : }
); return ( onOpenRate(r)} style={{ cursor: "pointer", background: i % 2 === 0 ? "#f0f9fa" : "#e8f5f7", borderLeft: "3px solid var(--teal,#0d7e96)" }}> ); })} ); }) : pageRows.map(r => ( onOpenRate(r)} style={{ cursor: "pointer" }}> {activeColumns.map(key => { const colDef = ALL_COLUMNS.find(c => c.key === key); return ; })} ))}
{ if (el) el.indeterminate = somePageSelected && !allPageSelected; }} onChange={toggleAll} /> {groupBySlab && (() => { const multiGroups = (groupedDisplay || []).filter(g => g.rows.length > 1); if (!multiGroups.length) return null; const allExpanded = multiGroups.every(g => expandedGroups.has(g.key)); return ( ); })()}
handleSort(key)} > {colDef?.label || key} {active && sortDir === "desc" ? "▼" : "▲"}
{ setColFilters(prev => ({ ...prev, [key]: vals })); setPage(1); }} /> {Object.keys(activeColFilters).length > 0 && ( )}
Loading rates…
No rates found. Try adjusting filters or import a rate sheet.
e.stopPropagation()} style={{ verticalAlign: "middle", padding: "0 4px" }}>
selectedIds.has(r.id))} onChange={() => grp.rows.forEach(r => toggleOne(r.id))} /> {multi && ( )}
{blank ? : renderCell(colDef || { key }, rep, roeMap)} e.stopPropagation()}> onOpenRate(rep)} onDuplicate={onAddPrefill ? () => handleDuplicate(rep) : null} onSupersede={perms.editRates && rep.status !== "Inactive" ? () => handleSupersede(rep) : null} onDelete={perms.editRates ? () => handleDelete(rep) : null} rateStatus={rep.status} />
e.stopPropagation()} style={{ verticalAlign: "middle", paddingLeft: 28 }}> toggleOne(r.id)} />
{/* Slab range */}
·
{/* Divider */}
{/* Rate & flags */} {/* Divider */}
{/* Flag badges — fixed width so they always take up the same space */}
Invoice Rate {r.is_invoicerate ? Yes : }
STCY {r.stcy_flag ? Yes : }
Calculator {r.calculator ? Yes : }
e.stopPropagation()}> onOpenRate(r)} onDuplicate={onAddPrefill ? () => handleDuplicate(r) : null} onSupersede={perms.editRates && r.status !== "Inactive" ? () => handleSupersede(r) : null} onDelete={perms.editRates ? () => handleDelete(r) : null} rateStatus={r.status} />
e.stopPropagation()}> toggleOne(r.id)} /> {renderCell(colDef || { key }, r, roeMap)} e.stopPropagation()}> onOpenRate(r)} onDuplicate={onAddPrefill ? () => handleDuplicate(r) : null} onSupersede={perms.editRates && r.status !== "Inactive" ? () => handleSupersede(r) : null} onDelete={perms.editRates ? () => handleDelete(r) : null} rateStatus={r.status} />
{effectiveSelectedCount > 0 && (
{selectAllPages ? `All ${displayRows.length} records are selected.` : `${selectedIds.size} record${selectedIds.size !== 1 ? "s" : ""} on this page selected.`} {!selectAllPages && allPageSelected && displayRows.length > pageIds.length && ( )}
)}
Showing {(page - 1) * pageSize + 1}{Math.min(page * pageSize, pagedItems?.length || 0)} of {(pagedItems?.length || 0).toLocaleString()}
Page {page} of {totalPages}
{showBatchEdit && ( setShowBatchEdit(false)} onDone={() => { setShowBatchEdit(false); clearSelection(); load(); }} /> )} {manageViewsInitId !== null && ( setManageViewsInitId(null)} onApply={(v) => { setActiveView(v); if (v.filters) setFilters(f => ({ ...f, ...v.filters })); }} onSaved={async () => { await loadViews(); }} /> )}
); } function BoolChip({ label, active, onChange }) { return ( ); } const BATCH_FIELDS = [ // ── Terminal / Corridor ────────────────────────────────────────── { type: "section", label: "Terminal / Corridor" }, { key: "transport_type", label: "Transport Type", type: "opts", opts: ["Free","Rail","Barge","Road","Sea"] }, { key: "route", label: "Route", type: "text" }, { key: "corridor_from", label: "Corridor From", type: "text" }, { key: "corridor_to", label: "Corridor To", type: "text" }, // ── Contract Info ──────────────────────────────────────────────── { type: "section", label: "Contract Info" }, { key: "brand_company_id", label: "Brand Company", type: "meta", meta: "brand-companies" }, { key: "contract_ref", label: "Contract Ref", type: "text" }, { key: "contract_owner", label: "Contract Owner", type: "text" }, { key: "sap_vendor_code", label: "SAP Vendor Code", type: "meta-str", meta: "vendors", valKey: "code", lblKey: "name", searchable: true }, { key: "payment_terms", label: "Payment Terms", type: "meta-str", meta: "payment-terms", valKey: "name", lblKey: "name", searchable: true }, // ── Cost Type & Container ──────────────────────────────────────── { type: "section", label: "Cost Type & Container" }, { key: "cost_category", label: "Cost Category", type: "opts", opts: ["CYB","CYM","PE"] }, { key: "sap_material_code", label: "SAP Material Code", type: "meta-str", meta: "materials", valKey: "code", lblKey: "name", searchable: true }, { key: "charge_unit_id", label: "Charge Unit", type: "meta", meta: "charge-units" }, { key: "containertype", label: "Container Type", type: "meta", meta: "equipment-types" }, { key: "container_size", label: "Container Size", type: "opts", opts: ["20","40","45","All"] }, { key: "container_fullness", label: "Container Fullness", type: "opts", opts: ["Empty","Full","All"] }, { key: "shipment_type", label: "Shipment Type", type: "meta", meta: "move-types" }, { key: "payable_by", label: "Payable By", type: "opts", opts: ["Maersk","Customer"] }, // ── Rate ───────────────────────────────────────────────────────── { type: "section", label: "Rate" }, { key: "primary_currency", label: "Currency", type: "meta", meta: "currencies", lblKey: "iso_code" }, { key: "effective_date", label: "Effective Date", type: "date" }, { key: "expiry_date", label: "Expiry Date", type: "date" }, { key: "status", label: "Status", type: "status" }, { key: "is_invoicerate", label: "Invoice Rate", type: "bool" }, { key: "stcy_flag", label: "STCY Flag", type: "bool" }, { key: "calculator", label: "Calculator", type: "bool" }, // ── Slab / Tier ────────────────────────────────────────────────── { type: "section", label: "Slab / Tier" }, { key: "slab_unit", label: "Slab Unit", type: "meta", meta: "slab-units" }, { key: "slab_type", label: "Slab Type", type: "meta", meta: "slab-types" }, { key: "slab_min", label: "Slab Min", type: "number" }, { key: "slab_max", label: "Slab Max", type: "number" }, // ── Others ─────────────────────────────────────────────────────── { type: "section", label: "Others" }, { key: "notes", label: "Notes", type: "textarea" }, ]; function BatchEditModal({ ids, onClose, onDone }) { const [enabled, setEnabled] = useState({}); const [values, setValues] = useState({}); const [meta, setMeta] = useState({}); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); useEffect(() => { const endpoints = ["charge-units","equipment-types","move-types","currencies","slab-units","slab-types","rate-statuses","vendors","payment-terms","materials","brand-companies"]; Promise.all(endpoints.map(e => apiFetch(`/api/metadata/${e}`).catch(() => []))).then(results => { const m = {}; endpoints.forEach((e, i) => { m[e] = Array.isArray(results[i]) ? results[i] : []; }); setMeta(m); }); }, []); const toggle = (key) => { setEnabled(prev => ({ ...prev, [key]: !prev[key] })); if (!enabled[key] && values[key] === undefined) { setValues(prev => ({ ...prev, [key]: "" })); } }; const setVal = (key, val) => setValues(prev => ({ ...prev, [key]: val })); const renderControl = (f) => { const dis = !enabled[f.key]; const val = values[f.key] ?? ""; const cls = "select" + (dis ? " disabled" : ""); const style = { width: "100%", opacity: dis ? 0.45 : 1 }; if (f.type === "status") { return ( ); } if (f.type === "meta") { const opts = meta[f.meta] || []; const lblKey = f.lblKey || "name"; return ( ); } if (f.type === "opts") { return ( ); } if (f.type === "meta-str") { const opts = (meta[f.meta] || []).filter(o => o.is_active !== false); const vKey = f.valKey || "name"; const lKey = f.lblKey || "name"; if (f.searchable) { return (
setVal(f.key, v || null)} placeholder="Search…" getLabel={o => lKey !== vKey ? `${o[lKey]} (${o[vKey]})` : o[lKey]} getValue={o => o[vKey]} />
); } return ( ); } if (f.type === "date") return setVal(f.key, e.target.value)} />; if (f.type === "number") return setVal(f.key, e.target.value)} />; if (f.type === "textarea") return