fc88d7ab4b
Features added: - Multi-piece support: add unlimited pieces each with L x W x H x Weight, combined class from total density - NMFC commodity lookup: 55 bundled items, live search by name or code - PDF export: branded PDF with piece table, Cu Ft, PCF, freight class via jsPDF - Default piece preset: save a standard pallet size in Settings > Defaults - Export/import history as CSV - Density gauge: color gradient bar with live marker in results panel - Unit toggle: in/cm and lbs/kg with live conversion across all pieces - Copy to clipboard, timestamps in history, Enter key shortcut, input validation - Charcoal dark mode Bug fixes: - PDF and Copy now blocked before a calculation is performed - History restore no longer misconverts values when units differ from stored unit - History display shows correct unit labels (cm/kg) instead of hardcoded in/lbs - PDF logo format auto-detected from data URL (fixes silent failure for JPG uploads) - Removed dead parsedPieces variable; forEach with early return prevents NaN accumulation - Removed no-op placeholder update in applyUnitToggleUI - CSV export URL revocation deferred to avoid race condition Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1055 lines
58 KiB
HTML
1055 lines
58 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Freight Calculator Pro</title>
|
||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--primary: #2563eb; --primary-hover: #1d4ed8; --bg-color: #f1f5f9;
|
||
--card-bg: #ffffff; --text-main: #1e293b; --text-muted: #64748b;
|
||
--border-radius: 12px; --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||
--input-bg: #ffffff; --input-border: #e2e8f0;
|
||
--input-focus-ring: rgba(37, 99, 235, 0.1); --border-color: #e2e8f0;
|
||
--table-header-bg: #f8fafc; --table-hover: #f1f5f9;
|
||
--highlight-bg: #dbeafe; --highlight-text: #1e3a8a;
|
||
--modal-bg: rgba(0,0,0,0.5); --error-color: #ef4444;
|
||
--error-bg: rgba(239,68,68,0.08);
|
||
}
|
||
body.dark-mode {
|
||
--primary: #60a5fa; --primary-hover: #3b82f6; --bg-color: #1a1a1a;
|
||
--card-bg: #2b2b2b; --text-main: #f0f0f0; --text-muted: #a0a0a0;
|
||
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.5); --input-bg: #1a1a1a;
|
||
--input-border: #3d3d3d; --input-focus-ring: rgba(96,165,250,0.2);
|
||
--border-color: #3d3d3d; --table-header-bg: #1a1a1a; --table-hover: #363636;
|
||
--highlight-bg: #1e3a8a; --highlight-text: #ffffff;
|
||
--error-bg: rgba(239,68,68,0.15);
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Segoe UI', sans-serif; transition: background-color 0.3s, color 0.3s; }
|
||
body { background-color: var(--bg-color); color: var(--text-main); display: flex; justify-content: center; min-height: 100vh; padding: 20px; }
|
||
.container { width: 100%; max-width: 960px; display: flex; flex-direction: column; }
|
||
|
||
/* Layout */
|
||
.branding-header { display: flex; align-items: center; gap: 15px; margin-bottom: 10px; }
|
||
#business-logo { max-height: 60px; max-width: 200px; object-fit: contain; display: none; }
|
||
#business-name-display { font-size: 1.5rem; font-weight: 700; color: var(--primary); display: none; }
|
||
.main-title { text-align: center; margin-bottom: 24px; }
|
||
h1 { font-size: 1.8rem; margin-bottom: 4px; }
|
||
.content-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px; }
|
||
|
||
/* Top controls */
|
||
.top-controls { position: absolute; top: 20px; right: 20px; display: flex; gap: 12px; }
|
||
.icon-btn { background: var(--card-bg); color: var(--text-main); border: 1px solid var(--input-border); width: 40px; height: 40px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow); }
|
||
.icon-btn svg { width: 18px; height: 18px; stroke: var(--text-main); }
|
||
|
||
/* Cards */
|
||
.card { background: var(--card-bg); border-radius: var(--border-radius); box-shadow: var(--shadow); padding: 24px; display: flex; flex-direction: column; gap: 16px; }
|
||
|
||
/* Inputs */
|
||
input[type="number"], input[type="text"] { width: 100%; padding: 10px 12px; border: 2px solid var(--input-border); border-radius: 8px; font-size: 0.95rem; background: var(--input-bg); color: var(--text-main); }
|
||
input:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 3px var(--input-focus-ring); }
|
||
input.input-error { border-color: var(--error-color) !important; background: var(--error-bg); }
|
||
.error-msg { font-size: 0.78rem; color: var(--error-color); font-weight: 500; display: none; margin-top: 2px; }
|
||
.error-msg.visible { display: block; }
|
||
|
||
/* Unit toggle */
|
||
.unit-toggle { display: flex; border: 1.5px solid var(--input-border); border-radius: 6px; overflow: hidden; }
|
||
.unit-toggle button { background: none; border: none; padding: 3px 9px; font-size: 0.72rem; font-weight: 700; color: var(--text-muted); cursor: pointer; }
|
||
.unit-toggle button.active { background: var(--primary); color: #fff; }
|
||
|
||
/* Unit row above pieces */
|
||
.unit-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||
.unit-row-group { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; font-weight: 600; color: var(--text-muted); }
|
||
|
||
/* Pieces table */
|
||
.pieces-col-headers { display: grid; grid-template-columns: 22px 1fr 1fr 1fr 1fr 30px; gap: 6px; padding: 0 2px; }
|
||
.pieces-col-headers span { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
|
||
.piece-row { display: grid; grid-template-columns: 22px 1fr 1fr 1fr 1fr 30px; gap: 6px; align-items: center; }
|
||
.piece-num { font-size: 0.82rem; font-weight: 700; color: var(--text-muted); text-align: center; }
|
||
.piece-row input[type="number"] { padding: 8px 10px; font-size: 0.9rem; }
|
||
.piece-delete { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: 4px; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
|
||
.piece-delete:hover { color: var(--error-color); }
|
||
.piece-delete svg { width: 15px; height: 15px; stroke: currentColor; pointer-events: none; }
|
||
.pieces-container { display: flex; flex-direction: column; gap: 8px; max-height: 260px; overflow-y: auto; padding-right: 2px; }
|
||
|
||
/* Add piece */
|
||
.add-piece-btn { display: inline-flex; align-items: center; gap: 6px; background: none; border: 1.5px dashed var(--input-border); color: var(--text-muted); padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; font-weight: 600; align-self: flex-start; }
|
||
.add-piece-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||
.add-piece-btn svg { width: 14px; height: 14px; stroke: currentColor; }
|
||
|
||
/* Buttons */
|
||
.btn-row { display: flex; gap: 10px; margin-top: 4px; }
|
||
button.calculate-btn { flex: 1; background: var(--primary); color: white; border: none; padding: 14px; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 0.95rem; }
|
||
button.calculate-btn:hover { background: var(--primary-hover); }
|
||
button.clear-btn { background: transparent; color: var(--text-muted); border: 2px solid var(--input-border); padding: 14px 16px; border-radius: 8px; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 0.88rem; white-space: nowrap; }
|
||
button.clear-btn:hover { border-color: var(--error-color); color: var(--error-color); }
|
||
button.clear-btn svg { width: 15px; height: 15px; stroke: currentColor; }
|
||
|
||
/* Results */
|
||
.results-container { display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; opacity: 0.45; transition: opacity 0.3s; }
|
||
.results-container.active { opacity: 1; }
|
||
.result-box { background: var(--bg-color); border: 2px dashed var(--border-color); border-radius: var(--border-radius); padding: 28px 24px 20px; width: 100%; position: relative; }
|
||
.result-value { font-size: 3.8rem; font-weight: 800; color: var(--primary); margin: 10px 0 4px; }
|
||
.result-label { font-size: 1rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.density-info { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 14px; }
|
||
|
||
/* Density gauge */
|
||
.density-gauge { width: 100%; margin-top: 8px; }
|
||
.gauge-bar { height: 8px; border-radius: 4px; background: linear-gradient(to right, #22c55e, #f59e0b, #ef4444); position: relative; }
|
||
.gauge-marker { position: absolute; top: -5px; width: 3px; height: 18px; background: var(--text-main); border-radius: 2px; transform: translateX(-50%); transition: left 0.4s ease; left: 0%; }
|
||
.gauge-labels { display: flex; justify-content: space-between; font-size: 0.68rem; color: var(--text-muted); margin-top: 4px; }
|
||
|
||
/* Result action buttons */
|
||
.result-actions { display: flex; gap: 8px; justify-content: center; margin-top: 14px; }
|
||
.result-action-btn { display: inline-flex; align-items: center; gap: 5px; background: var(--card-bg); border: 1px solid var(--input-border); border-radius: 8px; padding: 7px 14px; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||
.result-action-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||
.result-action-btn svg { width: 13px; height: 13px; stroke: currentColor; }
|
||
.result-action-btn.copied { color: #22c55e; border-color: #22c55e; }
|
||
|
||
/* Table */
|
||
.reference-section { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { padding: 11px 12px; border-bottom: 1px solid var(--border-color); text-align: left; font-size: 0.9rem; }
|
||
th { background: var(--table-header-bg); color: var(--text-muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||
.highlight-row { background: var(--highlight-bg) !important; font-weight: bold; color: var(--highlight-text); }
|
||
|
||
/* NMFC section */
|
||
.nmfc-section { display: flex; flex-direction: column; gap: 14px; margin-bottom: 24px; }
|
||
.nmfc-section h3 { font-size: 1rem; font-weight: 700; }
|
||
.nmfc-search-wrapper { position: relative; }
|
||
.nmfc-search-wrapper input { padding-left: 38px; }
|
||
.nmfc-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; }
|
||
.nmfc-search-icon svg { width: 16px; height: 16px; stroke: var(--text-muted); }
|
||
.nmfc-results { border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; max-height: 240px; overflow-y: auto; }
|
||
.nmfc-empty { padding: 16px; text-align: center; color: var(--text-muted); font-size: 0.88rem; }
|
||
.nmfc-results table { margin: 0; }
|
||
.nmfc-results tbody tr { cursor: pointer; }
|
||
.nmfc-results tbody tr:hover { background: var(--table-hover); }
|
||
.nmfc-class-badge { display: inline-block; background: var(--primary); color: #fff; border-radius: 5px; padding: 2px 8px; font-weight: 700; font-size: 0.82rem; }
|
||
|
||
/* Modal */
|
||
.modal-overlay { position: fixed; inset: 0; background: var(--modal-bg); z-index: 2000; display: none; justify-content: center; align-items: center; }
|
||
.modal-overlay.open { display: flex; }
|
||
.modal-content { background: var(--card-bg); width: 90%; max-width: 500px; border-radius: var(--border-radius); box-shadow: 0 20px 40px rgba(0,0,0,0.25); display: flex; flex-direction: column; max-height: 88vh; }
|
||
.modal-header { padding: 20px 24px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
|
||
.modal-header h2 { font-size: 1.1rem; font-weight: 700; }
|
||
.modal-header .icon-btn { width: 32px; height: 32px; box-shadow: none; }
|
||
.modal-header .icon-btn svg { width: 16px; height: 16px; }
|
||
.modal-tabs { display: flex; border-bottom: 1px solid var(--border-color); padding: 0 24px; flex-shrink: 0; overflow-x: auto; }
|
||
.tab-btn { background: none; border: none; padding: 12px 14px; cursor: pointer; color: var(--text-muted); border-bottom: 3px solid transparent; font-size: 0.88rem; font-weight: 500; margin-bottom: -1px; white-space: nowrap; }
|
||
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
|
||
.modal-body { padding: 24px; overflow-y: auto; flex: 1; }
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
/* Settings groups */
|
||
.settings-group { margin-bottom: 22px; }
|
||
.settings-group:last-child { margin-bottom: 0; }
|
||
.settings-group > label { display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 8px; }
|
||
.settings-group input[type="text"] { padding: 10px 14px; font-size: 0.95rem; border-width: 1.5px; }
|
||
.color-row { display: flex; align-items: center; gap: 12px; }
|
||
input[type="color"] { width: 44px; height: 44px; padding: 3px; border: 1.5px solid var(--input-border); border-radius: 8px; background: var(--input-bg); cursor: pointer; }
|
||
#color-hex-label { font-family: 'Courier New', monospace; font-size: 0.9rem; color: var(--text-muted); }
|
||
.logo-upload-area { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||
#logo-preview { max-height: 56px; max-width: 180px; object-fit: contain; display: none; border-radius: 6px; border: 1px solid var(--border-color); padding: 4px; }
|
||
.file-upload-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border: 1.5px solid var(--input-border); border-radius: 7px; cursor: pointer; font-size: 0.85rem; font-weight: 500; color: var(--text-muted); background: var(--input-bg); }
|
||
.file-upload-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||
.file-upload-btn svg { width: 14px; height: 14px; stroke: currentColor; }
|
||
#setting-logo { display: none; }
|
||
.danger-zone { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border-color); }
|
||
|
||
/* Defaults tab */
|
||
.defaults-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-bottom: 10px; }
|
||
.defaults-grid .form-group { display: flex; flex-direction: column; gap: 5px; }
|
||
.defaults-grid label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); }
|
||
.defaults-grid input { padding: 8px 10px; font-size: 0.9rem; }
|
||
|
||
/* History tab */
|
||
.history-controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; flex-wrap: wrap; gap: 8px; }
|
||
.history-actions { display: flex; gap: 8px; }
|
||
.history-list { list-style: none; overflow-y: auto; border: 1px solid var(--border-color); border-radius: 8px; max-height: 260px; }
|
||
.history-item { padding: 11px 14px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: flex-start; cursor: pointer; font-size: 0.88rem; gap: 8px; }
|
||
.history-item:last-child { border-bottom: none; }
|
||
.history-item:hover { background: var(--table-hover); }
|
||
.history-item-left { display: flex; flex-direction: column; gap: 2px; }
|
||
.history-item-timestamp { font-size: 0.7rem; color: var(--text-muted); }
|
||
.history-class { font-weight: 700; color: var(--primary); white-space: nowrap; }
|
||
|
||
/* Buttons misc */
|
||
.btn-primary { background: var(--primary); color: white; border: none; padding: 10px 18px; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 0.88rem; display: inline-flex; align-items: center; gap: 7px; }
|
||
.btn-primary:hover { background: var(--primary-hover); }
|
||
.btn-primary svg { width: 14px; height: 14px; stroke: currentColor; }
|
||
.btn-secondary { background: transparent; border: 1px solid var(--border-color); color: var(--text-muted); padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 0.88rem; display: inline-flex; align-items: center; gap: 6px; }
|
||
.btn-secondary:hover { background: var(--table-hover); }
|
||
.btn-secondary svg { width: 14px; height: 14px; stroke: currentColor; }
|
||
.btn-danger { color: #ef4444; border-color: #fca5a5; }
|
||
.btn-danger:hover { background: rgba(239,68,68,0.1); }
|
||
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="top-controls">
|
||
<button id="theme-toggle" class="icon-btn"><i data-lucide="moon"></i></button>
|
||
<button id="settings-btn" class="icon-btn"><i data-lucide="settings"></i></button>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<div class="branding-header">
|
||
<img id="business-logo" src="" alt="Logo">
|
||
<div id="business-name-display"></div>
|
||
</div>
|
||
|
||
<div class="main-title">
|
||
<h1>Freight Class Calculator</h1>
|
||
</div>
|
||
|
||
<div class="content-grid">
|
||
<!-- Left: Pieces input -->
|
||
<div class="card">
|
||
<!-- Unit toggles -->
|
||
<div class="unit-row">
|
||
<div class="unit-row-group">
|
||
Dimensions
|
||
<div class="unit-toggle" id="dim-unit-toggle">
|
||
<button class="active" data-unit="in" onclick="setDimUnit('in')">in</button>
|
||
<button data-unit="cm" onclick="setDimUnit('cm')">cm</button>
|
||
</div>
|
||
</div>
|
||
<div class="unit-row-group">
|
||
Weight
|
||
<div class="unit-toggle" id="wt-unit-toggle">
|
||
<button class="active" data-unit="lbs" onclick="setWtUnit('lbs')">lbs</button>
|
||
<button data-unit="kg" onclick="setWtUnit('kg')">kg</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Column headers -->
|
||
<div class="pieces-col-headers">
|
||
<span style="text-align:center">#</span>
|
||
<span id="hdr-l">L (in)</span>
|
||
<span id="hdr-w">W (in)</span>
|
||
<span id="hdr-h">H (in)</span>
|
||
<span id="hdr-wt">Wt (lbs)</span>
|
||
<span></span>
|
||
</div>
|
||
|
||
<!-- Piece rows rendered by JS -->
|
||
<div class="pieces-container" id="pieces-container"></div>
|
||
|
||
<span class="error-msg" id="err-pieces">All pieces need valid dimensions and weight.</span>
|
||
|
||
<button class="add-piece-btn" onclick="addPiece()">
|
||
<i data-lucide="plus"></i> Add Piece
|
||
</button>
|
||
|
||
<div class="btn-row">
|
||
<button class="calculate-btn" onclick="calculateFreightClass()">Calculate Class</button>
|
||
<button class="clear-btn" onclick="clearForm()"><i data-lucide="x-circle"></i> Clear</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Results -->
|
||
<div class="card results-container" id="results-area">
|
||
<div class="result-box">
|
||
<div class="result-label">Freight Class</div>
|
||
<div class="result-value" id="class-result">--</div>
|
||
<div class="density-info">Density: <span id="density-result">0.00</span> lb/ft³</div>
|
||
<div class="density-gauge">
|
||
<div class="gauge-bar"><div class="gauge-marker" id="gauge-marker"></div></div>
|
||
<div class="gauge-labels"><span>Class 500<br><small>Low density</small></span><span style="text-align:right">Class 50<br><small>High density</small></span></div>
|
||
</div>
|
||
</div>
|
||
<div class="result-actions" id="result-actions">
|
||
<button class="result-action-btn" id="copy-btn" onclick="copyResult()"><i data-lucide="copy"></i> Copy</button>
|
||
<button class="result-action-btn" id="pdf-btn" onclick="exportPDF()"><i data-lucide="file-text"></i> PDF</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NMFC Lookup -->
|
||
<div class="card nmfc-section" style="margin-bottom:24px">
|
||
<h3>NMFC Commodity Lookup</h3>
|
||
<div class="nmfc-search-wrapper">
|
||
<span class="nmfc-search-icon"><i data-lucide="search"></i></span>
|
||
<input type="text" id="nmfc-search" placeholder="Search by commodity name or NMFC code…" oninput="nmfcSearch(this.value)">
|
||
</div>
|
||
<div class="nmfc-results" id="nmfc-results">
|
||
<div class="nmfc-empty">Type to search commodities</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Density Reference -->
|
||
<div class="card reference-section">
|
||
<h3>Density Reference</h3>
|
||
<table><thead><tr><th>Class</th><th>Min Density (lb/ft³)</th><th>Example Items</th></tr></thead>
|
||
<tbody id="ref-table-body"></tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings Modal -->
|
||
<div id="settings-modal" class="modal-overlay">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>Settings</h2>
|
||
<button class="icon-btn" id="modal-close-btn"><i data-lucide="x"></i></button>
|
||
</div>
|
||
<div class="modal-tabs">
|
||
<button class="tab-btn active" data-tab="branding" onclick="switchTab('branding',event)">Branding</button>
|
||
<button class="tab-btn" data-tab="defaults" onclick="switchTab('defaults',event)">Defaults</button>
|
||
<button class="tab-btn" data-tab="history" onclick="switchTab('history',event)">History</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
|
||
<!-- Branding tab -->
|
||
<div id="tab-branding" class="tab-content active">
|
||
<div class="settings-group">
|
||
<label for="setting-name">Business Name</label>
|
||
<input type="text" id="setting-name" placeholder="Your company name">
|
||
</div>
|
||
<div class="settings-group">
|
||
<label>Brand Color</label>
|
||
<div class="color-row">
|
||
<input type="color" id="setting-color">
|
||
<span id="color-hex-label">#2563eb</span>
|
||
</div>
|
||
</div>
|
||
<div class="settings-group">
|
||
<label>Logo</label>
|
||
<div class="logo-upload-area">
|
||
<img id="logo-preview" src="" alt="Logo preview">
|
||
<label for="setting-logo" class="file-upload-btn">
|
||
<i data-lucide="upload"></i> Choose File
|
||
</label>
|
||
<input type="file" id="setting-logo" accept="image/*">
|
||
</div>
|
||
</div>
|
||
<div class="danger-zone">
|
||
<button class="btn-secondary btn-danger" id="reset-branding-btn">
|
||
<i data-lucide="rotate-ccw"></i> Reset Branding
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Defaults tab -->
|
||
<div id="tab-defaults" class="tab-content">
|
||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:16px;">
|
||
Save a default piece to pre-fill when you open the calculator or click "Use Default".
|
||
</p>
|
||
<div class="defaults-grid">
|
||
<div class="form-group"><label>Length (in)</label><input type="number" id="def-l" placeholder="e.g. 48" min="0" step="0.1"></div>
|
||
<div class="form-group"><label>Width (in)</label><input type="number" id="def-w" placeholder="e.g. 40" min="0" step="0.1"></div>
|
||
<div class="form-group"><label>Height (in)</label><input type="number" id="def-h" placeholder="e.g. 48" min="0" step="0.1"></div>
|
||
</div>
|
||
<div class="defaults-grid" style="grid-template-columns:1fr 1fr;">
|
||
<div class="form-group"><label>Weight (lbs)</label><input type="number" id="def-wt" placeholder="e.g. 500" min="0" step="0.1"></div>
|
||
</div>
|
||
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">
|
||
<button class="btn-primary" id="save-defaults-btn"><i data-lucide="save"></i> Save Default</button>
|
||
<button class="btn-secondary" id="apply-defaults-btn"><i data-lucide="copy-check"></i> Use Default Now</button>
|
||
<button class="btn-secondary btn-danger" id="clear-defaults-btn"><i data-lucide="x"></i> Clear Default</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- History tab -->
|
||
<div id="tab-history" class="tab-content">
|
||
<div class="history-controls">
|
||
<div class="history-actions">
|
||
<button class="btn-secondary" id="export-history-btn"><i data-lucide="download"></i> Export CSV</button>
|
||
<label class="btn-secondary" style="cursor:pointer;"><i data-lucide="upload"></i> Import CSV<input type="file" id="import-history-file" accept=".csv" style="display:none"></label>
|
||
</div>
|
||
<button class="btn-secondary btn-danger" id="clear-history-btn"><i data-lucide="trash-2"></i> Clear All</button>
|
||
</div>
|
||
<div id="history-list" class="history-list"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer" id="modal-footer">
|
||
<button class="btn-secondary" id="modal-cancel-btn">Cancel</button>
|
||
<button class="btn-primary" id="modal-save-btn"><i data-lucide="save"></i> Save Changes</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ─── Data ──────────────────────────────────────────────────────────────────────
|
||
const densityRanges = [
|
||
{ min:50, class:50, desc:'Heavy machinery' },
|
||
{ min:35, class:55, desc:'Bricks, cement' },
|
||
{ min:30, class:60, desc:'Car parts' },
|
||
{ min:22.5, class:65, desc:'Car parts, books' },
|
||
{ min:15, class:70, desc:'Food items' },
|
||
{ min:13.5, class:77.5, desc:'Tires' },
|
||
{ min:12, class:85, desc:'Crated machinery' },
|
||
{ min:10.5, class:92.5, desc:'Computers' },
|
||
{ min:9, class:100, desc:'Boat motors' },
|
||
{ min:8, class:110, desc:'TVs' },
|
||
{ min:7, class:125, desc:'Furniture' },
|
||
{ min:6, class:150, desc:'Sheet metal' },
|
||
{ min:5, class:175, desc:'Clothing' },
|
||
{ min:4, class:200, desc:'Aircraft parts' },
|
||
{ min:3, class:250, desc:'Mattresses' },
|
||
{ min:2, class:300, desc:'Cabinets' },
|
||
{ min:1, class:400, desc:'Deer antlers' },
|
||
{ min:-1, class:500, desc:'Gold dust' }
|
||
];
|
||
|
||
const nmfcData = [
|
||
{ code:'16030', desc:'Automobiles, NOI', cls:85 },
|
||
{ code:'22500', desc:'Batteries, Electric Storage', cls:70 },
|
||
{ code:'23340', desc:'Beverages, Alcoholic, NOI', cls:70 },
|
||
{ code:'28220', desc:'Books, Bound, NOI', cls:70 },
|
||
{ code:'37720', desc:'Cabinets, Wood', cls:150 },
|
||
{ code:'38420', desc:'Cameras / Photography Equipment', cls:100 },
|
||
{ code:'40560', desc:'Carpet / Rugs', cls:100 },
|
||
{ code:'46030', desc:'Clothing / Wearing Apparel, NOI', cls:175 },
|
||
{ code:'49700', desc:'Computers / Peripherals, NOI', cls:100 },
|
||
{ code:'53460', desc:'Desks, Office, NOI', cls:125 },
|
||
{ code:'55650', desc:'Electronics, Consumer, NOI', cls:125 },
|
||
{ code:'60390', desc:'Engines, Internal Combustion, NOI', cls:85 },
|
||
{ code:'61300', desc:'Equipment, Agricultural, NOI', cls:85 },
|
||
{ code:'64500', desc:'Exercise / Fitness Equipment, NOI', cls:92.5 },
|
||
{ code:'65700', desc:'Fixtures, Lighting, NOI', cls:100 },
|
||
{ code:'68100', desc:'Food, Canned, NOI', cls:55 },
|
||
{ code:'68240', desc:'Food, Frozen, NOI', cls:65 },
|
||
{ code:'69480', desc:'Foodstuffs, Dry, NOI', cls:65 },
|
||
{ code:'72800', desc:'Garden / Lawn Equipment, NOI', cls:85 },
|
||
{ code:'82950', desc:'Furniture, Household, NOI', cls:125 },
|
||
{ code:'83520', desc:'Furniture, Office, NOI', cls:125 },
|
||
{ code:'84470', desc:'Glass, Window / Plate', cls:200 },
|
||
{ code:'85820', desc:'Grains, NOI', cls:55 },
|
||
{ code:'86800', desc:'Hardware, NOI', cls:70 },
|
||
{ code:'88685', desc:'HVAC Equipment, NOI', cls:85 },
|
||
{ code:'92420', desc:'Iron or Steel Articles, NOI', cls:70 },
|
||
{ code:'93900', desc:'Janitorial / Cleaning Supplies, NOI', cls:85 },
|
||
{ code:'97460', desc:'Lumber / Wood Boards, NOI', cls:60 },
|
||
{ code:'100440', desc:'Medical Devices / Equipment, NOI', cls:100 },
|
||
{ code:'107100', desc:'Machinery, NOI', cls:85 },
|
||
{ code:'108400', desc:'Mattresses / Box Springs', cls:250 },
|
||
{ code:'109740', desc:'Metal Articles, NOI', cls:70 },
|
||
{ code:'113980', desc:'Motors, Electric, NOI', cls:85 },
|
||
{ code:'115440', desc:'Musical Instruments, NOI', cls:150 },
|
||
{ code:'119300', desc:'Paper, NOI', cls:70 },
|
||
{ code:'122400', desc:'Paints / Lacquers, NOI', cls:70 },
|
||
{ code:'123240', desc:'Pallets, Wood, Empty', cls:150 },
|
||
{ code:'125100', desc:'Pharmaceuticals, NOI', cls:85 },
|
||
{ code:'126780', desc:'Pipes, Metal, NOI', cls:70 },
|
||
{ code:'132800', desc:'Plastics Articles, NOI', cls:85 },
|
||
{ code:'137900', desc:'Printed Matter / Publications, NOI', cls:70 },
|
||
{ code:'145600', desc:'Rubber Articles, NOI', cls:85 },
|
||
{ code:'148240', desc:'Safety Equipment, NOI', cls:100 },
|
||
{ code:'150150', desc:'Sheet Metal, NOI', cls:150 },
|
||
{ code:'155900', desc:'Steel, Structural, NOI', cls:50 },
|
||
{ code:'157100', desc:'Stones, Granite / Marble, Slabs', cls:55 },
|
||
{ code:'160400', desc:'Tires, Pneumatic, NOI', cls:77.5 },
|
||
{ code:'161320', desc:'Tools, Hand, NOI', cls:70 },
|
||
{ code:'161440', desc:'Tools, Power, NOI', cls:85 },
|
||
{ code:'162630', desc:'Toys / Games, NOI', cls:85 },
|
||
{ code:'168540', desc:'Televisions / Monitors', cls:110 },
|
||
{ code:'172000', desc:'Textiles, NOI', cls:125 },
|
||
{ code:'174900', desc:'Trailers, NOI', cls:85 },
|
||
{ code:'178260', desc:'Vehicle Parts / Accessories, NOI', cls:85 },
|
||
{ code:'182600', desc:'Wire, Electrical, NOI', cls:70 },
|
||
{ code:'183600', desc:'Wood Articles, NOI', cls:100 },
|
||
];
|
||
|
||
// ─── State ────────────────────────────────────────────────────────────────────
|
||
let appState = JSON.parse(localStorage.getItem('freightCalcState')) || {};
|
||
appState = Object.assign({
|
||
theme: 'light',
|
||
branding: { name:'', color:'#2563eb', logo:'' },
|
||
history: [],
|
||
units: { dim:'in', wt:'lbs' },
|
||
defaults: { L:'', W:'', H:'', Weight:'' }
|
||
}, appState);
|
||
if (!appState.units) appState.units = { dim:'in', wt:'lbs' };
|
||
if (!appState.defaults) appState.defaults = { L:'', W:'', H:'', Weight:'' };
|
||
|
||
let pendingBranding = null;
|
||
|
||
// Working pieces array (ephemeral, not persisted)
|
||
let pieces = [{ L:'', W:'', H:'', Weight:'' }];
|
||
|
||
function saveState() { localStorage.setItem('freightCalcState', JSON.stringify(appState)); }
|
||
|
||
// ─── Init ──────────────────────────────────────────────────────────────────────
|
||
window.onload = () => {
|
||
document.body.className = appState.theme === 'dark' ? 'dark-mode' : '';
|
||
document.getElementById('theme-toggle').innerHTML = appState.theme === 'dark'
|
||
? '<i data-lucide="sun"></i>' : '<i data-lucide="moon"></i>';
|
||
document.documentElement.style.setProperty('--primary', appState.branding.color);
|
||
applyBrandingToPage();
|
||
renderHistory();
|
||
renderPieces();
|
||
applyUnitToggleUI();
|
||
|
||
// Pre-fill defaults if set
|
||
if (appState.defaults.L) {
|
||
pieces = [{ L: appState.defaults.L, W: appState.defaults.W, H: appState.defaults.H, Weight: appState.defaults.Weight }];
|
||
renderPieces();
|
||
}
|
||
|
||
// Density reference table
|
||
densityRanges.forEach(r => {
|
||
const row = document.createElement('tr'); row.id = `row-${r.class}`;
|
||
row.innerHTML = `<td>Class ${r.class}</td><td>${r.min}+ lbs/ft³</td><td>${r.desc}</td>`;
|
||
document.getElementById('ref-table-body').appendChild(row);
|
||
});
|
||
|
||
lucide.createIcons();
|
||
};
|
||
|
||
// ─── Theme ────────────────────────────────────────────────────────────────────
|
||
document.getElementById('theme-toggle').onclick = () => {
|
||
document.body.classList.toggle('dark-mode');
|
||
appState.theme = document.body.classList.contains('dark-mode') ? 'dark' : 'light';
|
||
document.getElementById('theme-toggle').innerHTML = appState.theme === 'dark'
|
||
? '<i data-lucide="sun"></i>' : '<i data-lucide="moon"></i>';
|
||
lucide.createIcons();
|
||
saveState();
|
||
};
|
||
|
||
// ─── Branding ─────────────────────────────────────────────────────────────────
|
||
function applyBrandingToPage() {
|
||
const b = appState.branding;
|
||
const header = document.querySelector('.branding-header');
|
||
const logo = document.getElementById('business-logo');
|
||
const nameEl = document.getElementById('business-name-display');
|
||
logo.src = b.logo || '';
|
||
logo.style.display = b.logo ? 'block' : 'none';
|
||
nameEl.textContent = b.name;
|
||
nameEl.style.display = b.name ? 'block' : 'none';
|
||
header.style.display = (b.logo || b.name) ? 'flex' : 'none';
|
||
}
|
||
function populateSettingsForm() {
|
||
const b = appState.branding;
|
||
document.getElementById('setting-name').value = b.name;
|
||
document.getElementById('setting-color').value = b.color;
|
||
document.getElementById('color-hex-label').textContent = b.color;
|
||
const preview = document.getElementById('logo-preview');
|
||
if (b.logo) { preview.src = b.logo; preview.style.display = 'block'; }
|
||
else { preview.src = ''; preview.style.display = 'none'; }
|
||
}
|
||
function populateDefaultsForm() {
|
||
const d = appState.defaults;
|
||
document.getElementById('def-l').value = d.L || '';
|
||
document.getElementById('def-w').value = d.W || '';
|
||
document.getElementById('def-h').value = d.H || '';
|
||
document.getElementById('def-wt').value = d.Weight || '';
|
||
}
|
||
document.getElementById('setting-name').oninput = e => {
|
||
appState.branding.name = e.target.value; applyBrandingToPage();
|
||
};
|
||
document.getElementById('setting-color').oninput = e => {
|
||
appState.branding.color = e.target.value;
|
||
document.getElementById('color-hex-label').textContent = e.target.value;
|
||
document.documentElement.style.setProperty('--primary', e.target.value);
|
||
};
|
||
document.getElementById('setting-logo').onchange = e => {
|
||
if (!e.target.files[0]) return;
|
||
const reader = new FileReader();
|
||
reader.onload = evt => {
|
||
appState.branding.logo = evt.target.result;
|
||
const preview = document.getElementById('logo-preview');
|
||
preview.src = evt.target.result; preview.style.display = 'block';
|
||
applyBrandingToPage();
|
||
};
|
||
reader.readAsDataURL(e.target.files[0]);
|
||
};
|
||
document.getElementById('reset-branding-btn').onclick = () => {
|
||
appState.branding = { name:'', color:'#2563eb', logo:'' };
|
||
document.documentElement.style.setProperty('--primary', '#2563eb');
|
||
applyBrandingToPage(); populateSettingsForm();
|
||
};
|
||
|
||
// ─── Defaults tab ─────────────────────────────────────────────────────────────
|
||
document.getElementById('save-defaults-btn').onclick = () => {
|
||
appState.defaults = {
|
||
L: document.getElementById('def-l').value,
|
||
W: document.getElementById('def-w').value,
|
||
H: document.getElementById('def-h').value,
|
||
Weight: document.getElementById('def-wt').value
|
||
};
|
||
saveState();
|
||
const btn = document.getElementById('save-defaults-btn');
|
||
btn.innerHTML = '<i data-lucide="check"></i> Saved!'; lucide.createIcons();
|
||
setTimeout(() => { btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons(); }, 1800);
|
||
};
|
||
document.getElementById('apply-defaults-btn').onclick = () => {
|
||
const d = appState.defaults;
|
||
if (!d.L) return;
|
||
pieces = [{ L: d.L, W: d.W, H: d.H, Weight: d.Weight }];
|
||
renderPieces();
|
||
revertAndClose();
|
||
};
|
||
document.getElementById('clear-defaults-btn').onclick = () => {
|
||
appState.defaults = { L:'', W:'', H:'', Weight:'' };
|
||
saveState(); populateDefaultsForm();
|
||
};
|
||
|
||
// ─── Modal ────────────────────────────────────────────────────────────────────
|
||
document.getElementById('settings-btn').onclick = () => {
|
||
pendingBranding = JSON.parse(JSON.stringify(appState.branding));
|
||
populateSettingsForm();
|
||
populateDefaultsForm();
|
||
switchTab('branding');
|
||
document.getElementById('settings-modal').classList.add('open');
|
||
lucide.createIcons();
|
||
};
|
||
function revertAndClose() {
|
||
if (pendingBranding) {
|
||
appState.branding = pendingBranding;
|
||
document.documentElement.style.setProperty('--primary', pendingBranding.color);
|
||
applyBrandingToPage();
|
||
pendingBranding = null;
|
||
}
|
||
document.getElementById('settings-modal').classList.remove('open');
|
||
}
|
||
document.getElementById('modal-close-btn').onclick = revertAndClose;
|
||
document.getElementById('modal-cancel-btn').onclick = revertAndClose;
|
||
document.getElementById('modal-save-btn').onclick = () => {
|
||
saveState(); pendingBranding = null;
|
||
document.getElementById('settings-modal').classList.remove('open');
|
||
};
|
||
|
||
// ─── Tabs ─────────────────────────────────────────────────────────────────────
|
||
function switchTab(t, e) {
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||
document.getElementById('tab-' + t).classList.add('active');
|
||
const btn = e ? e.target : document.querySelector(`.tab-btn[data-tab="${t}"]`);
|
||
if (btn) btn.classList.add('active');
|
||
document.getElementById('modal-footer').style.display = t === 'branding' ? 'flex' : 'none';
|
||
}
|
||
|
||
// ─── History ──────────────────────────────────────────────────────────────────
|
||
document.getElementById('clear-history-btn').onclick = () => {
|
||
appState.history = []; saveState(); renderHistory();
|
||
};
|
||
document.getElementById('export-history-btn').onclick = exportHistoryCSV;
|
||
document.getElementById('import-history-file').onchange = importHistoryCSV;
|
||
|
||
function formatTimestamp(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
return d.toLocaleDateString(undefined, { month:'short', day:'numeric' }) + ' ' +
|
||
d.toLocaleTimeString(undefined, { hour:'2-digit', minute:'2-digit' });
|
||
}
|
||
function renderHistory() {
|
||
const list = document.getElementById('history-list');
|
||
list.innerHTML = '';
|
||
if (!appState.history.length) {
|
||
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-muted);font-size:0.88rem">No calculations yet</div>';
|
||
return;
|
||
}
|
||
appState.history.forEach(item => {
|
||
const div = document.createElement('div'); div.className = 'history-item';
|
||
const pArr = item.pieces || [{ L:item.L, W:item.W, H:item.H, Weight:item.Weight }];
|
||
const storedDim = item.units?.dim || 'in';
|
||
const storedWt = item.units?.wt || 'lbs';
|
||
const dimSym = storedDim === 'in' ? '"' : ' cm';
|
||
const summary = pArr.length === 1
|
||
? `${pArr[0].L}${dimSym}×${pArr[0].W}${dimSym}×${pArr[0].H}${dimSym} · ${pArr[0].Weight} ${storedWt}`
|
||
: `${pArr.length} pieces · ${pArr.reduce((s,p)=>s+parseFloat(p.Weight||0),0).toFixed(1)} ${storedWt} total`;
|
||
div.innerHTML = `
|
||
<div class="history-item-left">
|
||
<span>${summary}</span>
|
||
<span class="history-item-timestamp">${formatTimestamp(item.timestamp)}</span>
|
||
</div>
|
||
<span class="history-class">Class ${item.Class}</span>`;
|
||
div.onclick = () => {
|
||
pieces = pArr.map(p => ({ L:p.L, W:p.W, H:p.H, Weight:p.Weight }));
|
||
// Restore to the exact units the values were saved in — no conversion needed
|
||
appState.units.dim = storedDim;
|
||
appState.units.wt = storedWt;
|
||
saveState();
|
||
applyUnitToggleUI();
|
||
renderPieces();
|
||
document.getElementById('density-result').textContent = item.Density;
|
||
document.getElementById('class-result').textContent = item.Class;
|
||
document.getElementById('results-area').classList.add('active');
|
||
document.querySelectorAll('tbody tr').forEach(r => r.classList.remove('highlight-row'));
|
||
const row = document.getElementById(`row-${item.Class}`);
|
||
if (row) row.classList.add('highlight-row');
|
||
updateGauge(parseFloat(item.Density));
|
||
clearErrors();
|
||
revertAndClose();
|
||
};
|
||
list.appendChild(div);
|
||
});
|
||
}
|
||
|
||
// Export history as CSV
|
||
function exportHistoryCSV() {
|
||
if (!appState.history.length) return;
|
||
const rows = [['Timestamp','Pieces','Total Weight (lbs)','Density (lb/ft³)','Freight Class']];
|
||
appState.history.forEach(item => {
|
||
const pArr = item.pieces || [{ L:item.L, W:item.W, H:item.H, Weight:item.Weight }];
|
||
const totalWt = pArr.reduce((s,p)=>s+parseFloat(p.Weight||0),0).toFixed(1);
|
||
const piecesStr = pArr.map((p,i)=>`P${i+1}:${p.L}x${p.W}x${p.H}/${p.Weight}lbs`).join('; ');
|
||
rows.push([item.timestamp||'', piecesStr, totalWt, item.Density, item.Class]);
|
||
});
|
||
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
|
||
const blob = new Blob([csv], { type:'text/csv' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `freight-history-${new Date().toISOString().slice(0,10)}.csv`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 100);
|
||
}
|
||
|
||
// Import history from CSV
|
||
function importHistoryCSV(e) {
|
||
const file = e.target.files[0]; if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = evt => {
|
||
const lines = evt.target.result.trim().split('\n').slice(1); // skip header
|
||
const imported = [];
|
||
lines.forEach(line => {
|
||
const cols = line.match(/(".*?"|[^,]+)/g) || [];
|
||
const clean = cols.map(c => c.replace(/^"|"$/g,'').replace(/""/g,'"'));
|
||
if (clean.length < 5) return;
|
||
const [timestamp, piecesStr, , density, cls] = clean;
|
||
const pArr = piecesStr.split(';').map(ps => {
|
||
const m = ps.trim().match(/P\d+:([\d.]+)x([\d.]+)x([\d.]+)\/([\d.]+)lbs/);
|
||
return m ? { L:m[1], W:m[2], H:m[3], Weight:m[4] } : null;
|
||
}).filter(Boolean);
|
||
if (!pArr.length) return;
|
||
imported.push({ pieces:pArr, L:pArr[0].L, W:pArr[0].W, H:pArr[0].H, Weight:pArr[0].Weight,
|
||
Density:density, Class:parseFloat(cls), timestamp });
|
||
});
|
||
if (imported.length) {
|
||
appState.history = [...imported, ...appState.history].slice(0, 50);
|
||
saveState(); renderHistory();
|
||
}
|
||
e.target.value = '';
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
// ─── Unit toggles ─────────────────────────────────────────────────────────────
|
||
function applyUnitToggleUI() {
|
||
const du = appState.units.dim;
|
||
const wu = appState.units.wt;
|
||
document.querySelectorAll('#dim-unit-toggle button').forEach(b => b.classList.toggle('active', b.dataset.unit === du));
|
||
document.querySelectorAll('#wt-unit-toggle button').forEach(b => b.classList.toggle('active', b.dataset.unit === wu));
|
||
document.getElementById('hdr-l').textContent = `L (${du})`;
|
||
document.getElementById('hdr-w').textContent = `W (${du})`;
|
||
document.getElementById('hdr-h').textContent = `H (${du})`;
|
||
document.getElementById('hdr-wt').textContent = `Wt (${wu})`;
|
||
lucide.createIcons();
|
||
}
|
||
function setDimUnit(newUnit) {
|
||
const oldUnit = appState.units.dim; if (oldUnit === newUnit) return;
|
||
const factor = newUnit === 'cm' ? 2.54 : (1/2.54);
|
||
pieces.forEach(p => {
|
||
if (p.L) p.L = parseFloat((parseFloat(p.L)*factor).toFixed(2));
|
||
if (p.W) p.W = parseFloat((parseFloat(p.W)*factor).toFixed(2));
|
||
if (p.H) p.H = parseFloat((parseFloat(p.H)*factor).toFixed(2));
|
||
});
|
||
appState.units.dim = newUnit; saveState();
|
||
renderPieces(); applyUnitToggleUI();
|
||
}
|
||
function setWtUnit(newUnit) {
|
||
const oldUnit = appState.units.wt; if (oldUnit === newUnit) return;
|
||
const factor = newUnit === 'kg' ? (1/2.20462) : 2.20462;
|
||
pieces.forEach(p => {
|
||
if (p.Weight) p.Weight = parseFloat((parseFloat(p.Weight)*factor).toFixed(2));
|
||
});
|
||
appState.units.wt = newUnit; saveState();
|
||
renderPieces(); applyUnitToggleUI();
|
||
}
|
||
|
||
// ─── Pieces ───────────────────────────────────────────────────────────────────
|
||
function renderPieces() {
|
||
const container = document.getElementById('pieces-container');
|
||
container.innerHTML = '';
|
||
pieces.forEach((p, i) => {
|
||
const row = document.createElement('div'); row.className = 'piece-row';
|
||
const canDelete = pieces.length > 1;
|
||
row.innerHTML = `
|
||
<span class="piece-num">${i+1}</span>
|
||
<input type="number" class="dim-input" value="${p.L}" placeholder="0" min="0" step="0.1" data-idx="${i}" data-field="L">
|
||
<input type="number" class="dim-input" value="${p.W}" placeholder="0" min="0" step="0.1" data-idx="${i}" data-field="W">
|
||
<input type="number" class="dim-input" value="${p.H}" placeholder="0" min="0" step="0.1" data-idx="${i}" data-field="H">
|
||
<input type="number" value="${p.Weight}" placeholder="0" min="0" step="0.1" data-idx="${i}" data-field="Weight">
|
||
<button class="piece-delete" ${canDelete ? '' : 'style="visibility:hidden"'} data-idx="${i}">
|
||
<i data-lucide="x"></i>
|
||
</button>`;
|
||
container.appendChild(row);
|
||
});
|
||
// Bind input events
|
||
container.querySelectorAll('input[type="number"]').forEach(el => {
|
||
el.addEventListener('input', e => { pieces[e.target.dataset.idx][e.target.dataset.field] = e.target.value; });
|
||
el.addEventListener('keydown', e => { if (e.key === 'Enter') calculateFreightClass(); });
|
||
});
|
||
// Bind delete buttons
|
||
container.querySelectorAll('.piece-delete').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
const idx = parseInt(e.currentTarget.dataset.idx);
|
||
pieces.splice(idx, 1);
|
||
renderPieces();
|
||
});
|
||
});
|
||
lucide.createIcons();
|
||
}
|
||
function addPiece() {
|
||
pieces.push({ L:'', W:'', H:'', Weight:'' });
|
||
renderPieces();
|
||
// Focus first input of new row
|
||
const rows = document.querySelectorAll('.piece-row');
|
||
const lastRow = rows[rows.length-1];
|
||
if (lastRow) lastRow.querySelector('input') && lastRow.querySelector('input').focus();
|
||
}
|
||
|
||
// ─── Validation & Clear ───────────────────────────────────────────────────────
|
||
function clearErrors() {
|
||
document.querySelectorAll('.input-error').forEach(el => el.classList.remove('input-error'));
|
||
document.getElementById('err-pieces').classList.remove('visible');
|
||
}
|
||
function clearForm() {
|
||
pieces = [{ L:'', W:'', H:'', Weight:'' }];
|
||
renderPieces();
|
||
document.getElementById('class-result').textContent = '--';
|
||
document.getElementById('density-result').textContent = '0.00';
|
||
document.getElementById('results-area').classList.remove('active');
|
||
document.querySelectorAll('tbody tr').forEach(r => r.classList.remove('highlight-row'));
|
||
document.getElementById('gauge-marker').style.left = '0%';
|
||
clearErrors();
|
||
}
|
||
|
||
// ─── Density gauge ────────────────────────────────────────────────────────────
|
||
function updateGauge(density) {
|
||
// Gauge goes left=0 (low density / high class) to right=50+ (high density / low class)
|
||
const pct = Math.min(Math.max(density / 50, 0), 1) * 100;
|
||
document.getElementById('gauge-marker').style.left = pct + '%';
|
||
}
|
||
|
||
// ─── Calculate ────────────────────────────────────────────────────────────────
|
||
function calculateFreightClass() {
|
||
clearErrors();
|
||
const dimFactor = appState.units.dim === 'cm' ? (1/2.54) : 1;
|
||
const wtFactor = appState.units.wt === 'kg' ? 2.20462 : 1;
|
||
|
||
let valid = true;
|
||
let totalCuFt = 0, totalWt = 0;
|
||
pieces.forEach((p, i) => {
|
||
const L = parseFloat(p.L);
|
||
const W = parseFloat(p.W);
|
||
const H = parseFloat(p.H);
|
||
const WT = parseFloat(p.Weight);
|
||
if (!L || !W || !H || !WT || L<=0 || W<=0 || H<=0 || WT<=0) {
|
||
valid = false;
|
||
const rows = document.querySelectorAll('.piece-row');
|
||
if (rows[i]) rows[i].querySelectorAll('input').forEach(el => {
|
||
if (!parseFloat(el.value) || parseFloat(el.value)<=0) el.classList.add('input-error');
|
||
});
|
||
return; // skip accumulation — prevents NaN from propagating into totals
|
||
}
|
||
totalCuFt += (L * dimFactor * W * dimFactor * H * dimFactor) / 1728;
|
||
totalWt += WT * wtFactor;
|
||
});
|
||
|
||
if (!valid) {
|
||
document.getElementById('err-pieces').classList.add('visible');
|
||
return;
|
||
}
|
||
|
||
const density = totalWt / totalCuFt;
|
||
let cls = 500;
|
||
for (let r of densityRanges) if (density >= r.min) { cls = r.class; break; }
|
||
|
||
// Save to history — store units so restore and display are always correct
|
||
const histItem = {
|
||
pieces: pieces.map(p => ({ L:p.L, W:p.W, H:p.H, Weight:p.Weight })),
|
||
L: pieces[0].L, W: pieces[0].W, H: pieces[0].H, Weight: pieces[0].Weight,
|
||
Density: density.toFixed(2),
|
||
Class: cls,
|
||
timestamp: new Date().toISOString(),
|
||
units: { dim: appState.units.dim, wt: appState.units.wt }
|
||
};
|
||
appState.history.unshift(histItem);
|
||
if (appState.history.length > 50) appState.history.pop();
|
||
saveState(); renderHistory();
|
||
|
||
document.getElementById('density-result').textContent = density.toFixed(2);
|
||
document.getElementById('class-result').textContent = cls;
|
||
document.getElementById('results-area').classList.add('active');
|
||
document.querySelectorAll('tbody tr').forEach(r => r.classList.remove('highlight-row'));
|
||
const refRow = document.getElementById(`row-${cls}`);
|
||
if (refRow) refRow.classList.add('highlight-row');
|
||
updateGauge(density);
|
||
lucide.createIcons();
|
||
}
|
||
|
||
// ─── Copy to clipboard ────────────────────────────────────────────────────────
|
||
function copyResult() {
|
||
if (document.getElementById('class-result').textContent === '--') return;
|
||
const cls = document.getElementById('class-result').textContent;
|
||
const density = document.getElementById('density-result').textContent;
|
||
const du = appState.units.dim, wu = appState.units.wt;
|
||
const pLines = pieces.map((p,i)=>` Piece ${i+1}: ${p.L}×${p.W}×${p.H} ${du}, ${p.Weight} ${wu}`).join('\n');
|
||
const text = `Freight Class: ${cls}\nDensity: ${density} lb/ft³\n${pLines}`;
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
const btn = document.getElementById('copy-btn');
|
||
btn.classList.add('copied');
|
||
btn.innerHTML = '<i data-lucide="check"></i> Copied!'; lucide.createIcons();
|
||
setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons(); }, 2000);
|
||
});
|
||
}
|
||
|
||
// ─── PDF Export ───────────────────────────────────────────────────────────────
|
||
function exportPDF() {
|
||
if (document.getElementById('class-result').textContent === '--') {
|
||
alert('Please calculate a freight class before exporting a PDF.');
|
||
return;
|
||
}
|
||
if (typeof window.jspdf === 'undefined') { alert('PDF library not loaded. Please check your internet connection.'); return; }
|
||
const { jsPDF } = window.jspdf;
|
||
const doc = new jsPDF({ unit:'mm', format:'letter' });
|
||
const W = doc.internal.pageSize.getWidth();
|
||
const brandColor = hexToRgb(appState.branding.color || '#2563eb');
|
||
const cls = document.getElementById('class-result').textContent;
|
||
const density = document.getElementById('density-result').textContent;
|
||
const du = appState.units.dim, wu = appState.units.wt;
|
||
const now = new Date().toLocaleString();
|
||
let y = 18;
|
||
|
||
// Header bar
|
||
doc.setFillColor(brandColor.r, brandColor.g, brandColor.b);
|
||
doc.rect(0, 0, W, 28, 'F');
|
||
|
||
// Logo — detect format from data URL so JPG/PNG/WEBP all work
|
||
if (appState.branding.logo) {
|
||
try { doc.addImage(appState.branding.logo, getImageFormat(appState.branding.logo), 10, 4, 0, 20); } catch(e) {}
|
||
}
|
||
|
||
// Company name
|
||
doc.setTextColor(255, 255, 255);
|
||
doc.setFontSize(16); doc.setFont('helvetica','bold');
|
||
const companyName = appState.branding.name || 'Freight Calculator Pro';
|
||
doc.text(companyName, W/2, 13, { align:'center' });
|
||
doc.setFontSize(9); doc.setFont('helvetica','normal');
|
||
doc.text('Freight Quote', W/2, 21, { align:'center' });
|
||
|
||
y = 40;
|
||
// Date
|
||
doc.setTextColor(100, 116, 139); doc.setFontSize(8);
|
||
doc.text(`Generated: ${now}`, W - 14, y - 6, { align:'right' });
|
||
|
||
// Class result box
|
||
doc.setDrawColor(brandColor.r, brandColor.g, brandColor.b);
|
||
doc.setFillColor(240, 245, 255);
|
||
doc.roundedRect(14, y, W - 28, 28, 3, 3, 'FD');
|
||
doc.setTextColor(brandColor.r, brandColor.g, brandColor.b);
|
||
doc.setFontSize(32); doc.setFont('helvetica','bold');
|
||
doc.text(`Class ${cls}`, W/2, y + 18, { align:'center' });
|
||
doc.setFontSize(10); doc.setFont('helvetica','normal');
|
||
doc.setTextColor(100, 116, 139);
|
||
doc.text(`Density: ${density} lb/ft³`, W/2, y + 25, { align:'center' });
|
||
|
||
y += 38;
|
||
// Pieces table
|
||
doc.setFontSize(11); doc.setFont('helvetica','bold'); doc.setTextColor(30, 41, 59);
|
||
doc.text('Shipment Pieces', 14, y); y += 5;
|
||
|
||
const dimFactor = du === 'cm' ? (1/2.54) : 1;
|
||
const wtFactor = wu === 'kg' ? 2.20462 : 1;
|
||
const tableBody = pieces.map((p, i) => {
|
||
const Lin = parseFloat(p.L)*dimFactor, Win = parseFloat(p.W)*dimFactor, Hin = parseFloat(p.H)*dimFactor;
|
||
const WTlb = parseFloat(p.Weight)*wtFactor;
|
||
const cuFt = (Lin*Win*Hin)/1728;
|
||
const pcf = WTlb / cuFt;
|
||
return [
|
||
i+1,
|
||
`${p.L} ${du}`,
|
||
`${p.W} ${du}`,
|
||
`${p.H} ${du}`,
|
||
`${p.Weight} ${wu}`,
|
||
cuFt.toFixed(3),
|
||
pcf.toFixed(2)
|
||
];
|
||
});
|
||
const totalWtLb = pieces.reduce((s,p)=>s+parseFloat(p.Weight||0)*(wu==='kg'?2.20462:1),0);
|
||
const totalCuFt = pieces.reduce((p,pc)=>{
|
||
const L=parseFloat(pc.L)*dimFactor, Ww=parseFloat(pc.W)*dimFactor, H=parseFloat(pc.H)*dimFactor;
|
||
return p+(L*Ww*H)/1728;
|
||
},0);
|
||
|
||
doc.autoTable({
|
||
startY: y,
|
||
head: [['#','Length','Width','Height','Weight','Cu Ft','PCF']],
|
||
body: tableBody,
|
||
foot: [['','','','',`Total: ${totalWtLb.toFixed(1)} lbs`, `${totalCuFt.toFixed(3)}`, `${(totalWtLb/totalCuFt).toFixed(2)}`]],
|
||
theme: 'grid',
|
||
headStyles: { fillColor:[brandColor.r, brandColor.g, brandColor.b], textColor:255, fontStyle:'bold', fontSize:9 },
|
||
footStyles: { fillColor:[240,245,255], textColor:[30,41,59], fontStyle:'bold', fontSize:9 },
|
||
bodyStyles: { fontSize:9, textColor:[30,41,59] },
|
||
alternateRowStyles: { fillColor:[248,250,252] },
|
||
margin: { left:14, right:14 },
|
||
columnStyles: { 0:{cellWidth:10, halign:'center'} }
|
||
});
|
||
|
||
// Footer
|
||
const pageH = doc.internal.pageSize.getHeight();
|
||
doc.setFontSize(7); doc.setTextColor(148,163,184);
|
||
doc.text('Generated by Freight Calculator Pro', W/2, pageH-8, { align:'center' });
|
||
|
||
doc.save(`freight-class-${cls}-${new Date().toISOString().slice(0,10)}.pdf`);
|
||
}
|
||
|
||
function getImageFormat(dataUrl) {
|
||
if (/^data:image\/jpe?g/i.test(dataUrl)) return 'JPEG';
|
||
if (/^data:image\/png/i.test(dataUrl)) return 'PNG';
|
||
if (/^data:image\/gif/i.test(dataUrl)) return 'GIF';
|
||
if (/^data:image\/webp/i.test(dataUrl)) return 'WEBP';
|
||
return 'PNG';
|
||
}
|
||
|
||
function hexToRgb(hex) {
|
||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||
return result ? { r:parseInt(result[1],16), g:parseInt(result[2],16), b:parseInt(result[3],16) } : { r:37, g:99, b:235 };
|
||
}
|
||
|
||
// ─── NMFC Search ──────────────────────────────────────────────────────────────
|
||
function nmfcSearch(query) {
|
||
const container = document.getElementById('nmfc-results');
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) {
|
||
container.innerHTML = '<div class="nmfc-empty">Type to search commodities</div>';
|
||
return;
|
||
}
|
||
const results = nmfcData.filter(n =>
|
||
n.desc.toLowerCase().includes(q) || n.code.includes(q)
|
||
).slice(0, 20);
|
||
|
||
if (!results.length) {
|
||
container.innerHTML = '<div class="nmfc-empty">No matching commodities found</div>';
|
||
return;
|
||
}
|
||
let html = '<table><thead><tr><th>NMFC Code</th><th>Description</th><th>Std Class</th></tr></thead><tbody>';
|
||
results.forEach(n => {
|
||
html += `<tr onclick="nmfcSelect(${n.cls})">
|
||
<td style="font-family:monospace;font-size:0.85rem">${n.code}</td>
|
||
<td>${n.desc}</td>
|
||
<td><span class="nmfc-class-badge">${n.cls}</span></td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
container.innerHTML = html;
|
||
}
|
||
function nmfcSelect(cls) {
|
||
// Highlight the matching row in the density reference table
|
||
document.querySelectorAll('tbody tr').forEach(r => r.classList.remove('highlight-row'));
|
||
const row = document.getElementById(`row-${cls}`);
|
||
if (row) {
|
||
row.classList.add('highlight-row');
|
||
row.scrollIntoView({ behavior:'smooth', block:'center' });
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|