925 lines
42 KiB
HTML
925 lines
42 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>合同行绑定 · 计划目标量 / 归档实际量 · Fresh Mint Demo</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<style>
|
||
:root {
|
||
--primary: #10b981;
|
||
--primary-dark: #059669;
|
||
--primary-bg: #ecfdf5;
|
||
--text: #1e293b;
|
||
--text-muted: #64748b;
|
||
--border: #d9e8e2;
|
||
--surface: #ffffff;
|
||
--bg-page: #f1f5f9;
|
||
--table-head: #f8fafc;
|
||
--row-alt: #fafafa;
|
||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.14);
|
||
--radius: 10px;
|
||
--warn: #d97706;
|
||
--warn-bg: #fffbeb;
|
||
--danger: #dc2626;
|
||
--danger-bg: #fef2f2;
|
||
--info: #0284c7;
|
||
--info-bg: #f0f9ff;
|
||
--info-border: #bae6fd;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: var(--text);
|
||
background: var(--bg-page);
|
||
}
|
||
.page { max-width: 1320px; margin: 0 auto; padding: 24px 20px 80px; }
|
||
.hero {
|
||
background: linear-gradient(135deg, #ecfdf5 0%, #fff 50%, #f0f9ff 100%);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 20px 22px;
|
||
margin-bottom: 14px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 700; }
|
||
.hero h1 em { font-style: normal; color: var(--primary-dark); }
|
||
.hero p { margin: 0; color: var(--text-muted); max-width: 1040px; }
|
||
.pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||
.pill {
|
||
padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500;
|
||
background: var(--primary-bg); color: var(--primary-dark); border: 1px solid #a7f3d0;
|
||
}
|
||
.pill.warn { background: var(--warn-bg); color: var(--warn); border-color: #fde68a; }
|
||
.pill.info { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
|
||
.pill.muted { background: #f1f5f9; color: var(--text-muted); border-color: #e2e8f0; }
|
||
|
||
.steps { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
||
.step-btn {
|
||
border: 1px solid var(--border); background: #fff; border-radius: 8px;
|
||
padding: 8px 14px; font: inherit; font-weight: 600; font-size: 13px;
|
||
color: var(--text-muted); cursor: pointer;
|
||
}
|
||
.step-btn:hover { border-color: #a7f3d0; color: var(--primary-dark); }
|
||
.step-btn.active {
|
||
background: var(--primary-bg); border-color: var(--primary); color: var(--primary-dark);
|
||
}
|
||
.step-btn .n {
|
||
display: inline-flex; width: 18px; height: 18px; align-items: center; justify-content: center;
|
||
border-radius: 999px; background: #e2e8f0; color: #475569; font-size: 11px; margin-right: 6px;
|
||
}
|
||
.step-btn.active .n { background: var(--primary); color: #fff; }
|
||
|
||
.panel { display: none; }
|
||
.panel.active { display: block; }
|
||
|
||
.callout {
|
||
margin-bottom: 12px; padding: 10px 12px; border-radius: 8px; font-size: 13px;
|
||
border: 1px solid #a7f3d0; background: var(--primary-bg); color: #047857;
|
||
}
|
||
.callout.warn { border-color: #fde68a; background: var(--warn-bg); color: #92400e; }
|
||
.callout.info { border-color: var(--info-border); background: var(--info-bg); color: #0369a1; }
|
||
.callout.danger { border-color: #fecaca; background: var(--danger-bg); color: #991b1b; }
|
||
|
||
.dialog-sim {
|
||
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
|
||
box-shadow: var(--shadow-lg); overflow: hidden; max-height: calc(100vh - 120px);
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.dialog-sim__hd {
|
||
padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 700;
|
||
background: linear-gradient(180deg, #ecfdf5, #fff);
|
||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||
}
|
||
.dialog-sim__hd .sub { font-weight: 500; font-size: 12px; color: var(--text-muted); }
|
||
.dialog-sim__bd { padding: 16px 18px; overflow: auto; }
|
||
.dialog-sim__ft {
|
||
padding: 12px 18px; border-top: 1px solid var(--border); background: var(--table-head);
|
||
display: flex; justify-content: flex-end; gap: 8px;
|
||
}
|
||
|
||
.sec-title {
|
||
margin: 0 0 10px; font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.sec-title::before {
|
||
content: ""; width: 3px; height: 14px; background: var(--primary); border-radius: 2px;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 12px; margin-bottom: 14px;
|
||
}
|
||
@media (max-width: 900px) { .form-grid { grid-template-columns: 1fr 1fr; } }
|
||
.field label {
|
||
display: block; font-size: 11px; color: var(--text-muted); font-weight: 600; margin-bottom: 4px;
|
||
}
|
||
.field .ctl, .field input, .field select {
|
||
width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 8px;
|
||
padding: 0 10px; font: inherit; background: #fff;
|
||
}
|
||
.field .ctl.ro { background: #f8fafc; display: flex; align-items: center; color: var(--text-muted); }
|
||
.field input:focus, .field select:focus {
|
||
outline: none; border-color: var(--primary);
|
||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
|
||
}
|
||
|
||
table.mini { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
table.mini th, table.mini td {
|
||
border-bottom: 1px solid #eef2f0; padding: 8px 10px; text-align: left; vertical-align: middle;
|
||
}
|
||
table.mini th {
|
||
background: var(--table-head); color: var(--text-muted); font-weight: 600; font-size: 12px;
|
||
}
|
||
table.mini tr:nth-child(even) td { background: var(--row-alt); }
|
||
table.mini tr.dup td { background: #ecfdf5 !important; box-shadow: inset 3px 0 0 var(--primary); }
|
||
table.mini tr.over td { background: #fef2f2 !important; box-shadow: inset 3px 0 0 var(--danger); }
|
||
table.mini input[type="number"] {
|
||
width: 88px; height: 32px; border: 1px solid var(--border); border-radius: 8px;
|
||
padding: 0 8px; font: inherit;
|
||
}
|
||
table.mini tr.over input { border-color: #fca5a5; background: #fff; }
|
||
|
||
.tag {
|
||
display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 4px;
|
||
font-size: 11px; font-weight: 600; background: #059669; color: #fff;
|
||
}
|
||
.tag.warn { background: #d97706; }
|
||
.tag.danger { background: #dc2626; }
|
||
.tag.info { background: transparent; color: #1e293b; border: 1px solid #cbd5e1; }
|
||
.mono { font-variant-numeric: tabular-nums; }
|
||
.muted { color: var(--text-muted); }
|
||
.price { color: var(--warn); font-weight: 700; }
|
||
|
||
.btn {
|
||
display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px;
|
||
border-radius: 8px; border: 1px solid var(--border); background: #fff; color: var(--text);
|
||
font: inherit; font-weight: 600; font-size: 13px; cursor: pointer;
|
||
}
|
||
.btn:hover { border-color: #a7f3d0; }
|
||
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||
.btn.primary:hover { background: var(--primary-dark); }
|
||
.btn.ghost { background: var(--primary-bg); border-color: #a7f3d0; color: var(--primary-dark); }
|
||
.btn.danger { color: #fff; background: var(--danger); border-color: var(--danger); }
|
||
.btn.link { height: auto; padding: 0; border: none; background: none; color: var(--primary-dark); }
|
||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
|
||
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0 10px; }
|
||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.chip {
|
||
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px;
|
||
background: var(--primary-bg); border: 1px solid #a7f3d0; font-size: 12px; font-weight: 600;
|
||
color: var(--primary-dark);
|
||
}
|
||
.chip .x {
|
||
width: 16px; height: 16px; border: none; border-radius: 4px; background: #fff;
|
||
color: var(--text-muted); cursor: pointer; line-height: 1;
|
||
}
|
||
|
||
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
@media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
|
||
.box { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||
.box-h {
|
||
padding: 10px 12px; background: #f8fafc; border-bottom: 1px solid var(--border);
|
||
font-weight: 700; font-size: 13px; display: flex; justify-content: space-between; gap: 8px;
|
||
}
|
||
.box-h.load { border-left: 3px solid var(--primary); }
|
||
.box-h.delivery { border-left: 3px solid var(--info); }
|
||
.box-b { padding: 10px 12px; }
|
||
|
||
.mask {
|
||
display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35);
|
||
z-index: 40; align-items: center; justify-content: center; padding: 20px;
|
||
backdrop-filter: none;
|
||
}
|
||
.mask.show { display: flex; }
|
||
.modal {
|
||
width: min(640px, 100%); background: #fff; border-radius: var(--radius);
|
||
box-shadow: var(--shadow-lg); max-height: calc(100vh - 40px);
|
||
display: flex; flex-direction: column; overflow: hidden;
|
||
}
|
||
.modal-h { padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 700; }
|
||
.modal-b { padding: 16px 18px; overflow: auto; }
|
||
.modal-f {
|
||
padding: 12px 18px; border-top: 1px solid var(--border); background: var(--table-head);
|
||
display: flex; justify-content: flex-end; gap: 8px;
|
||
}
|
||
|
||
.driver {
|
||
max-width: 390px; margin: 0 auto; background: #fff; border-radius: 18px;
|
||
border: 1px solid var(--border); overflow: hidden; box-shadow: var(--shadow);
|
||
}
|
||
.driver-hd {
|
||
background: linear-gradient(180deg, #0a9f6d, #087f57); color: #fff; padding: 18px 16px 22px;
|
||
border-radius: 0 0 20px 20px;
|
||
}
|
||
.driver-hd h3 { margin: 0; font-size: 16px; }
|
||
.driver-hd p { margin: 4px 0 0; font-size: 12px; opacity: .9; }
|
||
.driver-bd { padding: 14px; }
|
||
.driver-card {
|
||
border: 1px solid #d1fae5; background: #e9f9f2; border-radius: 12px; padding: 12px; margin-bottom: 10px;
|
||
}
|
||
.driver-card .k { font-size: 12px; color: #087f57; }
|
||
.driver-card .v { font-size: 18px; font-weight: 700; }
|
||
|
||
.toast {
|
||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(16px);
|
||
background: #0f172a; color: #fff; padding: 10px 16px; border-radius: 8px;
|
||
font-size: 13px; opacity: 0; pointer-events: none; transition: .2s; z-index: 70; max-width: 90vw;
|
||
}
|
||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||
|
||
.remain.neg { color: var(--danger); font-weight: 700; }
|
||
.sum-bar {
|
||
margin-top: 8px; font-size: 12px; color: var(--text-muted);
|
||
display: flex; flex-wrap: wrap; gap: 12px;
|
||
}
|
||
.sum-bar.bad { color: var(--danger); font-weight: 600; }
|
||
.sum-bar.ok { color: var(--primary-dark); font-weight: 600; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<header class="hero">
|
||
<h1>合同明细多单价行 × 运输计划绑合同行 × <em>归档确认实际量</em></h1>
|
||
<p>
|
||
按 2026-10-05 已定口径:计划员保存时按「合同行 + 目标量」拆行;归档填实际量;超剩余红字高亮并二次确认;
|
||
司机/现场只看产品与数量。本页是界面示意,可点操作,不是现网页面。
|
||
</p>
|
||
<div class="pills">
|
||
<span class="pill warn">同品同包装可多行</span>
|
||
<span class="pill warn">装/送货多选合同</span>
|
||
<span class="pill">选合同行,不先选产品再拆</span>
|
||
<span class="pill info">计划可超剩余 · 安排才硬拦</span>
|
||
<span class="pill info">归档超交 · 红字 + 二次确认</span>
|
||
<span class="pill muted">司机现场不展示合同</span>
|
||
</div>
|
||
</header>
|
||
|
||
<nav class="steps">
|
||
<button class="step-btn active" data-step="1"><span class="n">1</span>合同 · 新增/编辑弹窗</button>
|
||
<button class="step-btn" data-step="2"><span class="n">2</span>运输计划 · 新增/编辑</button>
|
||
<button class="step-btn" data-step="3"><span class="n">3</span>安排运输 · 硬拦</button>
|
||
<button class="step-btn" data-step="4"><span class="n">4</span>归档 · 实际量与超交</button>
|
||
<button class="step-btn" data-step="5"><span class="n">5</span>司机 / 现场</button>
|
||
<button class="step-btn" data-step="6"><span class="n">6</span>合同详情挂量</button>
|
||
</nav>
|
||
|
||
<!-- 1 Contract form -->
|
||
<section class="panel active" id="panel-1">
|
||
<div class="callout warn">
|
||
<strong>相对现状:</strong>现网会提示「同一产品的包装规格不能重复」。下面弹窗允许两行都是「次氯酸钠 / 槽罐车」,单价不同。
|
||
</div>
|
||
<article class="dialog-sim">
|
||
<div class="dialog-sim__hd">
|
||
<div>编辑合同 <span class="sub">HT-A · 客户:安徽某某水务</span></div>
|
||
</div>
|
||
<div class="dialog-sim__bd">
|
||
<div class="form-grid">
|
||
<div class="field"><label>合同编号</label><div class="ctl ro">HT-A-2026-088</div></div>
|
||
<div class="field"><label>客户</label><div class="ctl ro">安徽某某水务</div></div>
|
||
<div class="field"><label>合同类型</label><div class="ctl ro">销售合同</div></div>
|
||
<div class="field"><label>状态</label><div class="ctl ro"><span class="tag">执行中</span></div></div>
|
||
</div>
|
||
<h3 class="sec-title">产品明细</h3>
|
||
<div class="toolbar">
|
||
<button class="btn primary" type="button" id="btn-add-line">+ 添加产品行</button>
|
||
<span class="muted" style="font-size:12px">同品同包装允许重复;请用单价 / 数量区分行。</span>
|
||
</div>
|
||
<table class="mini" id="tbl-contract">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>产品</th>
|
||
<th>包装规格</th>
|
||
<th>合同数量</th>
|
||
<th>含税单价</th>
|
||
<th>说明</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="dialog-sim__ft">
|
||
<button class="btn" type="button">取消</button>
|
||
<button class="btn primary" type="button" id="btn-save-contract">确定保存</button>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<!-- 2 Plan form -->
|
||
<section class="panel" id="panel-2">
|
||
<div class="callout">
|
||
<strong>计划员视角:</strong>知道本车对应哪些合同行、各多少目标吨。装货/送货可多选合同;添加产品时直接选「合同行」,不要只选产品名。
|
||
</div>
|
||
<article class="dialog-sim">
|
||
<div class="dialog-sim__hd">
|
||
<div>新增运输计划 <span class="sub">PO20261005001 · 直销单</span></div>
|
||
</div>
|
||
<div class="dialog-sim__bd">
|
||
<div class="form-grid">
|
||
<div class="field"><label>计划日期</label><div class="ctl ro">2026-10-05</div></div>
|
||
<div class="field"><label>单据类型</label><div class="ctl ro">直销单</div></div>
|
||
<div class="field"><label>车牌</label><div class="ctl ro">皖 H12xxx</div></div>
|
||
<div class="field"><label>本车目标合计</label><div class="ctl ro mono" id="plan-total">0 吨</div></div>
|
||
</div>
|
||
<h3 class="sec-title">装送货信息</h3>
|
||
<div class="cols">
|
||
<div class="box">
|
||
<div class="box-h load"><span>装货信息</span><span class="muted" style="font-weight:500">客户:安徽某某水务</span></div>
|
||
<div class="box-b">
|
||
<label class="muted" style="font-size:11px;font-weight:600">合同(可多选)</label>
|
||
<div class="chips" id="load-chips" style="margin:6px 0 10px"></div>
|
||
<div class="toolbar" style="margin-top:0">
|
||
<button class="btn ghost" type="button" id="btn-add-load-row">+ 添加装货产品(选合同行)</button>
|
||
</div>
|
||
<table class="mini" id="tbl-load"><thead>
|
||
<tr><th>合同行</th><th>产品 / 包装</th><th>单价</th><th>剩余</th><th>目标量</th><th></th></tr>
|
||
</thead><tbody></tbody></table>
|
||
</div>
|
||
</div>
|
||
<div class="box">
|
||
<div class="box-h delivery"><span>送货信息 #1</span><span class="muted" style="font-weight:500">同一客户</span></div>
|
||
<div class="box-b">
|
||
<label class="muted" style="font-size:11px;font-weight:600">合同(可多选)</label>
|
||
<div class="chips" id="delivery-chips" style="margin:6px 0 10px"></div>
|
||
<div class="toolbar" style="margin-top:0">
|
||
<button class="btn ghost" type="button" id="btn-add-delivery-row">+ 添加送货产品(选合同行)</button>
|
||
<button class="btn" type="button" id="btn-sync">从装货同步目标量</button>
|
||
</div>
|
||
<table class="mini" id="tbl-delivery"><thead>
|
||
<tr><th>合同行</th><th>产品 / 包装</th><th>单价</th><th>剩余</th><th>目标量</th><th></th></tr>
|
||
</thead><tbody></tbody></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="muted" style="margin:10px 0 0;font-size:12px">草稿保存不拦超剩余。下方「保存草稿」可超;「安排运输」会硬拦(见步骤 3)。</p>
|
||
</div>
|
||
<div class="dialog-sim__ft">
|
||
<button class="btn" type="button">取消</button>
|
||
<button class="btn" type="button" id="btn-save-draft">保存草稿</button>
|
||
<button class="btn primary" type="button" id="btn-go-arrange">安排运输…</button>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<!-- 3 Arrange -->
|
||
<section class="panel" id="panel-3">
|
||
<div class="callout warn">
|
||
<strong>安排运输硬拦:</strong>任一合同行目标量 > 该行剩余时,禁止安排。请先改目标量或换行。不会记到别的同品行。
|
||
</div>
|
||
<article class="dialog-sim" style="max-width:720px;margin:0 auto">
|
||
<div class="dialog-sim__hd">安排运输</div>
|
||
<div class="dialog-sim__bd">
|
||
<p class="muted" style="margin:0 0 10px">将按当前送货目标量检查合同行剩余。</p>
|
||
<table class="mini" id="tbl-arrange-check">
|
||
<thead><tr><th>合同行</th><th>目标量</th><th>剩余</th><th>结果</th></tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="dialog-sim__ft">
|
||
<button class="btn" type="button" data-step="2">返回改目标量</button>
|
||
<button class="btn primary" type="button" id="btn-confirm-arrange">确认安排</button>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<!-- 4 Archive -->
|
||
<section class="panel" id="panel-4">
|
||
<div class="callout info">
|
||
<strong>归档:</strong>行结构与计划相同。实际量默认=目标量,可改。地磅实发须等于各行实际合计。某行超过该行剩余时红字,必须二次确认才归档。
|
||
</div>
|
||
<article class="dialog-sim">
|
||
<div class="dialog-sim__hd">
|
||
<div>运输计划归档 <span class="sub">PO20261005001 · 已完成运输</span></div>
|
||
</div>
|
||
<div class="dialog-sim__bd">
|
||
<h3 class="sec-title">装卸货归档 · 送货段</h3>
|
||
<div class="form-grid" style="grid-template-columns:repeat(3,1fr)">
|
||
<div class="field"><label>地磅 / 本车实发</label><input id="weigh-total" type="number" step="0.001" value="30" /></div>
|
||
<div class="field"><label>各行实际合计</label><div class="ctl ro mono" id="actual-sum">—</div></div>
|
||
<div class="field"><label>对齐结果</label><div class="ctl ro" id="align-result">—</div></div>
|
||
</div>
|
||
<table class="mini" id="tbl-archive">
|
||
<thead>
|
||
<tr>
|
||
<th>合同行</th>
|
||
<th>产品 / 包装</th>
|
||
<th>单价</th>
|
||
<th>目标量</th>
|
||
<th>该行剩余(归档前)</th>
|
||
<th>实际量</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
<div class="sum-bar" id="archive-hint"></div>
|
||
<div class="toolbar">
|
||
<button class="btn" type="button" id="btn-demo-over">演示超交:行1 实际量改 55,地磅改 75</button>
|
||
<span class="muted" style="font-size:12px">用于弹出红字二次确认。行2、行3 仍为 10,合计 75。</span>
|
||
</div>
|
||
</div>
|
||
<div class="dialog-sim__ft">
|
||
<button class="btn" type="button">取消</button>
|
||
<button class="btn primary" type="button" id="btn-archive">确认归档</button>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<!-- 5 Driver -->
|
||
<section class="panel" id="panel-5">
|
||
<div class="callout">
|
||
<strong>司机 / 现场:</strong>不展示合同号、单价、合同行。只看到产品和大约数量。内部仍按合同行记账。
|
||
</div>
|
||
<div class="driver">
|
||
<div class="driver-hd">
|
||
<h3>送货任务</h3>
|
||
<p>皖 H12xxx · 安徽某某水务</p>
|
||
</div>
|
||
<div class="driver-bd">
|
||
<div class="driver-card">
|
||
<div class="k">产品</div>
|
||
<div class="v">次氯酸钠 · 槽罐车</div>
|
||
</div>
|
||
<div class="driver-card">
|
||
<div class="k">本车数量(仅供现场)</div>
|
||
<div class="v" id="driver-qty">30 吨</div>
|
||
</div>
|
||
<p class="muted" style="font-size:12px;margin:0">不会出现 HT-A / HT-B、800 元 / 850 元等合同信息。</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 6 Contract detail -->
|
||
<section class="panel" id="panel-6">
|
||
<div class="callout warn">
|
||
<strong>合同详情:</strong>同品同包装并排两行,各自已归档 / 剩余。超交显示负数或「超交」,绝不合并成一行。
|
||
</div>
|
||
<article class="dialog-sim">
|
||
<div class="dialog-sim__hd">查看合同 HT-A-2026-088</div>
|
||
<div class="dialog-sim__bd">
|
||
<h3 class="sec-title">产品明细 · 履约数量</h3>
|
||
<table class="mini" id="tbl-detail">
|
||
<thead>
|
||
<tr>
|
||
<th>产品</th><th>包装</th><th>单价</th><th>合同量</th>
|
||
<th>已归档</th><th>未归档已安排</th><th>剩余</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- pick contract line -->
|
||
<div class="mask" id="mask-pick">
|
||
<div class="modal">
|
||
<div class="modal-h" id="pick-title">选择合同产品行</div>
|
||
<div class="modal-b">
|
||
<p class="muted" style="margin:0 0 10px;font-size:13px">仅列出本段已选合同中的明细。同品同包装会因单价不同出现多行,请点目标那一行。</p>
|
||
<table class="mini" id="tbl-pick">
|
||
<thead><tr><th>合同</th><th>产品 / 包装</th><th>单价</th><th>剩余</th><th></th></tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="modal-f">
|
||
<button class="btn" type="button" data-close="mask-pick">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- overage confirm -->
|
||
<div class="mask" id="mask-over">
|
||
<div class="modal" style="width:min(560px,100%)">
|
||
<div class="modal-h" style="color:#991b1b">实际量超过合同行剩余</div>
|
||
<div class="modal-b">
|
||
<div class="callout danger" style="margin-bottom:12px">
|
||
不会记到其它同品同包装行。确认后超交部分记在下列指定行,剩余将为负(超交)。
|
||
</div>
|
||
<table class="mini" id="tbl-over">
|
||
<thead><tr><th>合同行</th><th>剩余</th><th>实际量</th><th>超出</th></tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="modal-f">
|
||
<button class="btn" type="button" data-close="mask-over">返回修改</button>
|
||
<button class="btn danger" type="button" id="btn-over-ok">我已确认,仍然归档</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const PRODUCT = { id: 'p-nao', name: '次氯酸钠', pkg: '槽罐车' }
|
||
|
||
let contractLines = [
|
||
{ id: '行1', contractId: 'HT-A', contractNo: 'HT-A-2026-088', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 50, price: 800, arrangedUnarchived: 0, archived: 0, note: '低价行' },
|
||
{ id: '行2', contractId: 'HT-A', contractNo: 'HT-A-2026-088', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 40, price: 850, arrangedUnarchived: 0, archived: 0, note: '高价行' },
|
||
{ id: '行3', contractId: 'HT-B', contractNo: 'HT-B-2026-012', productId: PRODUCT.id, name: PRODUCT.name, pkg: PRODUCT.pkg, qty: 80, price: 820, arrangedUnarchived: 0, archived: 0, note: '另一合同' },
|
||
{ id: '行4', contractId: 'HT-A', contractNo: 'HT-A-2026-088', productId: 'p-hcl', name: '工业盐酸', pkg: '槽罐车', qty: 100, price: 180, arrangedUnarchived: 0, archived: 0, note: '其它产品' }
|
||
]
|
||
|
||
const loadContracts = ['HT-A', 'HT-B']
|
||
const deliveryContracts = ['HT-A', 'HT-B']
|
||
let loadRows = []
|
||
let deliveryRows = []
|
||
let pickSide = 'load'
|
||
let arranged = false
|
||
let archivedOk = false
|
||
|
||
function remaining(line) {
|
||
return Math.round((line.qty - line.archived - line.arrangedUnarchived) * 1000) / 1000
|
||
}
|
||
function toast(msg) {
|
||
const el = document.getElementById('toast')
|
||
el.textContent = msg
|
||
el.classList.add('show')
|
||
clearTimeout(toast._t)
|
||
toast._t = setTimeout(() => el.classList.remove('show'), 2800)
|
||
}
|
||
function openMask(id) { document.getElementById(id).classList.add('show') }
|
||
function closeMask(id) { document.getElementById(id).classList.remove('show') }
|
||
function lineById(id) { return contractLines.find((l) => l.id === id) }
|
||
|
||
function renderContractForm() {
|
||
const tb = document.querySelector('#tbl-contract tbody')
|
||
tb.innerHTML = ''
|
||
contractLines.filter((l) => l.contractId === 'HT-A').forEach((l, i) => {
|
||
const dup = l.productId === PRODUCT.id
|
||
const tr = document.createElement('tr')
|
||
if (dup) tr.className = 'dup'
|
||
tr.innerHTML = `
|
||
<td>${i + 1}</td>
|
||
<td>${l.name}</td>
|
||
<td>${l.pkg}</td>
|
||
<td class="mono">${l.qty}</td>
|
||
<td class="mono ${dup ? 'price' : ''}">¥ ${l.price}</td>
|
||
<td>${dup ? '<span class="tag warn">同品同包装</span> ' : ''}<span class="muted">${l.note}</span></td>
|
||
<td>${dup ? '' : ''}</td>`
|
||
tb.appendChild(tr)
|
||
})
|
||
}
|
||
|
||
function renderChips() {
|
||
const html = (ids) => ids.map((id) => {
|
||
const no = id === 'HT-A' ? 'HT-A-2026-088' : 'HT-B-2026-012'
|
||
return `<span class="chip">${no}<span class="muted" style="font-weight:500">${id === 'HT-A' ? '安徽某某水务' : '安徽某某水务(框架)'}</span></span>`
|
||
}).join('')
|
||
document.getElementById('load-chips').innerHTML = html(loadContracts)
|
||
document.getElementById('delivery-chips').innerHTML = html(deliveryContracts)
|
||
}
|
||
|
||
function renderStopTable(sel, rows) {
|
||
const tb = document.querySelector(sel)
|
||
tb.innerHTML = ''
|
||
if (!rows.length) {
|
||
tb.innerHTML = `<tr><td colspan="6" class="muted">尚未添加。点「选合同行」添加目标量。</td></tr>`
|
||
return
|
||
}
|
||
rows.forEach((r, idx) => {
|
||
const line = lineById(r.cpId)
|
||
const tr = document.createElement('tr')
|
||
tr.className = 'dup'
|
||
const rem = remaining(line)
|
||
const over = r.planQty > rem + 1e-9
|
||
tr.innerHTML = `
|
||
<td class="mono">${line.contractNo}<div class="muted" style="font-size:11px">${line.id} · ${line.note}</div></td>
|
||
<td>${line.name} / ${line.pkg}</td>
|
||
<td class="mono price">¥ ${line.price}</td>
|
||
<td class="mono">${rem}</td>
|
||
<td><input type="number" min="0" step="0.001" value="${r.planQty}" data-side="${sel.includes('load') ? 'load' : 'delivery'}" data-idx="${idx}" /></td>
|
||
<td><button class="btn link" type="button" data-del="${sel.includes('load') ? 'load' : 'delivery'}" data-idx="${idx}">删除</button></td>`
|
||
if (over) tr.classList.add('over')
|
||
tb.appendChild(tr)
|
||
})
|
||
tb.querySelectorAll('input').forEach((inp) => {
|
||
inp.addEventListener('input', () => {
|
||
const side = inp.dataset.side
|
||
const idx = Number(inp.dataset.idx)
|
||
const v = Number(inp.value) || 0
|
||
if (side === 'load') loadRows[idx].planQty = v
|
||
else deliveryRows[idx].planQty = v
|
||
const line = lineById((side === 'load' ? loadRows : deliveryRows)[idx].cpId)
|
||
inp.closest('tr').classList.toggle('over', v > remaining(line) + 1e-9)
|
||
updatePlanTotal()
|
||
})
|
||
})
|
||
tb.querySelectorAll('[data-del]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const idx = Number(btn.dataset.idx)
|
||
if (btn.dataset.del === 'load') loadRows.splice(idx, 1)
|
||
else deliveryRows.splice(idx, 1)
|
||
renderPlan()
|
||
})
|
||
})
|
||
}
|
||
|
||
function updatePlanTotal() {
|
||
const sum = deliveryRows.reduce((s, r) => s + (Number(r.planQty) || 0), 0)
|
||
document.getElementById('plan-total').textContent = (Math.round(sum * 1000) / 1000) + ' 吨'
|
||
document.getElementById('driver-qty').textContent = (Math.round(sum * 1000) / 1000) + ' 吨'
|
||
}
|
||
|
||
function renderPlan() {
|
||
renderChips()
|
||
renderStopTable('#tbl-load tbody', loadRows)
|
||
renderStopTable('#tbl-delivery tbody', deliveryRows)
|
||
updatePlanTotal()
|
||
}
|
||
|
||
function openPick(side) {
|
||
pickSide = side
|
||
document.getElementById('pick-title').textContent = side === 'load' ? '装货 · 选择合同产品行' : '送货 · 选择合同产品行'
|
||
const ids = side === 'load' ? loadContracts : deliveryContracts
|
||
const used = new Set((side === 'load' ? loadRows : deliveryRows).map((r) => r.cpId))
|
||
const tb = document.querySelector('#tbl-pick tbody')
|
||
tb.innerHTML = ''
|
||
contractLines.filter((l) => ids.includes(l.contractId)).forEach((l) => {
|
||
const tr = document.createElement('tr')
|
||
if (l.productId === PRODUCT.id) tr.className = 'dup'
|
||
const disabled = used.has(l.id)
|
||
tr.innerHTML = `
|
||
<td>${l.contractNo}</td>
|
||
<td>${l.name} / ${l.pkg}</td>
|
||
<td class="mono price">¥ ${l.price}</td>
|
||
<td class="mono">${remaining(l)}</td>
|
||
<td>${disabled ? '<span class="muted">已添加</span>' : `<button class="btn ghost" type="button" data-pick="${l.id}">选用此行</button>`}</td>`
|
||
tb.appendChild(tr)
|
||
})
|
||
tb.querySelectorAll('[data-pick]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const line = lineById(btn.dataset.pick)
|
||
const row = { cpId: line.id, planQty: 10, actualQty: null }
|
||
if (pickSide === 'load') loadRows.push(row)
|
||
else deliveryRows.push(row)
|
||
closeMask('mask-pick')
|
||
renderPlan()
|
||
toast('已绑定合同行 ' + line.id + ',请改目标量')
|
||
})
|
||
})
|
||
openMask('mask-pick')
|
||
}
|
||
|
||
function seedDemoRows() {
|
||
if (loadRows.length) return
|
||
loadRows = [
|
||
{ cpId: '行1', planQty: 10, actualQty: null },
|
||
{ cpId: '行2', planQty: 10, actualQty: null },
|
||
{ cpId: '行3', planQty: 10, actualQty: null }
|
||
]
|
||
deliveryRows = loadRows.map((r) => ({ ...r }))
|
||
}
|
||
|
||
function renderArrangeCheck() {
|
||
const tb = document.querySelector('#tbl-arrange-check tbody')
|
||
tb.innerHTML = ''
|
||
if (!deliveryRows.length) {
|
||
tb.innerHTML = `<tr><td colspan="4" class="muted">请先在步骤 2 添加合同行。</td></tr>`
|
||
return
|
||
}
|
||
deliveryRows.forEach((r) => {
|
||
const line = lineById(r.cpId)
|
||
const rem = remaining(line)
|
||
const ok = r.planQty <= rem + 1e-9
|
||
const tr = document.createElement('tr')
|
||
if (!ok) tr.className = 'over'
|
||
tr.innerHTML = `
|
||
<td>${line.contractNo} · ${line.id} · ¥${line.price}</td>
|
||
<td class="mono">${r.planQty}</td>
|
||
<td class="mono">${rem}</td>
|
||
<td>${ok ? '<span class="tag">通过</span>' : '<span class="tag danger">超过剩余,禁止安排</span>'}</td>`
|
||
tb.appendChild(tr)
|
||
})
|
||
}
|
||
|
||
function arrangeOk() {
|
||
return deliveryRows.every((r) => r.planQty <= remaining(lineById(r.cpId)) + 1e-9)
|
||
}
|
||
|
||
function occupyUnarchived() {
|
||
contractLines.forEach((l) => { l.arrangedUnarchived = 0 })
|
||
deliveryRows.forEach((r) => {
|
||
const line = lineById(r.cpId)
|
||
line.arrangedUnarchived += r.planQty
|
||
})
|
||
}
|
||
|
||
function renderArchive() {
|
||
const tb = document.querySelector('#tbl-archive tbody')
|
||
tb.innerHTML = ''
|
||
if (!deliveryRows.length) {
|
||
tb.innerHTML = `<tr><td colspan="6" class="muted">请先完成步骤 2 的拆行。</td></tr>`
|
||
return
|
||
}
|
||
deliveryRows.forEach((r, idx) => {
|
||
const line = lineById(r.cpId)
|
||
if (r.actualQty == null) r.actualQty = r.planQty
|
||
const rem = remaining(line) + (arranged ? r.planQty : 0)
|
||
const over = r.actualQty > rem + 1e-9
|
||
const tr = document.createElement('tr')
|
||
if (over) tr.className = 'over'
|
||
tr.innerHTML = `
|
||
<td class="mono">${line.contractNo}<div class="muted" style="font-size:11px">${line.id} · ${line.note}</div></td>
|
||
<td>${line.name} / ${line.pkg}</td>
|
||
<td class="mono price">¥ ${line.price}</td>
|
||
<td class="mono">${r.planQty}</td>
|
||
<td class="mono">${Math.round(rem * 1000) / 1000}</td>
|
||
<td><input type="number" min="0" step="0.001" value="${r.actualQty}" data-idx="${idx}" /></td>`
|
||
tb.appendChild(tr)
|
||
})
|
||
tb.querySelectorAll('input').forEach((inp) => {
|
||
inp.addEventListener('input', () => {
|
||
const idx = Number(inp.dataset.idx)
|
||
deliveryRows[idx].actualQty = Number(inp.value) || 0
|
||
const line = lineById(deliveryRows[idx].cpId)
|
||
const rem = remaining(line) + (arranged ? deliveryRows[idx].planQty : 0)
|
||
inp.closest('tr').classList.toggle('over', deliveryRows[idx].actualQty > rem + 1e-9)
|
||
updateArchiveSum()
|
||
})
|
||
})
|
||
updateArchiveSum()
|
||
}
|
||
|
||
function updateArchiveSum() {
|
||
const sum = deliveryRows.reduce((s, r) => s + (Number(r.actualQty ?? r.planQty) || 0), 0)
|
||
const weigh = Number(document.getElementById('weigh-total').value) || 0
|
||
const aligned = Math.abs(sum - weigh) < 0.0005
|
||
document.getElementById('actual-sum').textContent = (Math.round(sum * 1000) / 1000) + ' 吨'
|
||
const res = document.getElementById('align-result')
|
||
res.innerHTML = aligned ? '<span class="tag">已对齐</span>' : '<span class="tag danger">与地磅不一致</span>'
|
||
const hint = document.getElementById('archive-hint')
|
||
const overs = overageRows()
|
||
hint.className = 'sum-bar' + (aligned && !overs.length ? ' ok' : ' bad')
|
||
hint.textContent = aligned
|
||
? (overs.length ? `地磅已对齐,但有 ${overs.length} 行超过合同剩余。` : '地磅已对齐,各行未超剩余。')
|
||
: `各行实际合计 ${sum} ≠ 地磅 ${weigh},不能归档。`
|
||
}
|
||
|
||
function overageRows() {
|
||
return deliveryRows.filter((r) => {
|
||
const line = lineById(r.cpId)
|
||
const rem = remaining(line) + (arranged ? r.planQty : 0)
|
||
return (r.actualQty ?? 0) > rem + 1e-9
|
||
}).map((r) => {
|
||
const line = lineById(r.cpId)
|
||
const rem = remaining(line) + (arranged ? r.planQty : 0)
|
||
const actual = r.actualQty ?? 0
|
||
return { line, rem, actual, over: Math.round((actual - rem) * 1000) / 1000 }
|
||
})
|
||
}
|
||
|
||
function tryArchive() {
|
||
const weigh = Number(document.getElementById('weigh-total').value) || 0
|
||
const sum = deliveryRows.reduce((s, r) => s + (Number(r.actualQty ?? r.planQty) || 0), 0)
|
||
if (Math.abs(sum - weigh) >= 0.0005) {
|
||
toast('各行实际合计必须等于地磅实发')
|
||
return
|
||
}
|
||
const overs = overageRows()
|
||
if (overs.length) {
|
||
const tb = document.querySelector('#tbl-over tbody')
|
||
tb.innerHTML = overs.map((o) => `
|
||
<tr class="over">
|
||
<td>${o.line.contractNo} · ${o.line.id} · ¥${o.line.price}</td>
|
||
<td class="mono">${o.rem}</td>
|
||
<td class="mono">${o.actual}</td>
|
||
<td class="mono remain neg">+${o.over}</td>
|
||
</tr>`).join('')
|
||
openMask('mask-over')
|
||
return
|
||
}
|
||
commitArchive()
|
||
}
|
||
|
||
function commitArchive() {
|
||
closeMask('mask-over')
|
||
contractLines.forEach((l) => { l.arrangedUnarchived = 0 })
|
||
deliveryRows.forEach((r) => {
|
||
const line = lineById(r.cpId)
|
||
line.archived += Number(r.actualQty) || 0
|
||
})
|
||
archivedOk = true
|
||
arranged = false
|
||
renderDetail()
|
||
toast('已归档:实际量记在指定合同行')
|
||
gotoStep(6)
|
||
}
|
||
|
||
function renderDetail() {
|
||
const tb = document.querySelector('#tbl-detail tbody')
|
||
tb.innerHTML = ''
|
||
contractLines.filter((l) => l.contractId === 'HT-A').forEach((l) => {
|
||
const rem = remaining(l)
|
||
const tr = document.createElement('tr')
|
||
if (l.productId === PRODUCT.id) tr.className = rem < 0 ? 'over' : 'dup'
|
||
tr.innerHTML = `
|
||
<td>${l.name}</td>
|
||
<td>${l.pkg}</td>
|
||
<td class="mono price">¥ ${l.price}</td>
|
||
<td class="mono">${l.qty}</td>
|
||
<td class="mono">${Math.round(l.archived * 1000) / 1000}</td>
|
||
<td class="mono">${Math.round(l.arrangedUnarchived * 1000) / 1000}</td>
|
||
<td class="mono ${rem < 0 ? 'remain neg' : ''}">${rem < 0 ? '超交 ' + Math.abs(rem) : rem}</td>`
|
||
tb.appendChild(tr)
|
||
})
|
||
}
|
||
|
||
function gotoStep(n) {
|
||
document.querySelectorAll('.step-btn').forEach((b) => b.classList.toggle('active', b.dataset.step === String(n)))
|
||
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('active', p.id === 'panel-' + n))
|
||
if (n === '2' || n === 2) {
|
||
seedDemoRows()
|
||
renderPlan()
|
||
}
|
||
if (n === '3' || n === 3) {
|
||
seedDemoRows()
|
||
renderPlan()
|
||
renderArrangeCheck()
|
||
}
|
||
if (n === '4' || n === 4) {
|
||
seedDemoRows()
|
||
if (!arranged) {
|
||
/* 归档前视为已安排,占用按目标量 */
|
||
occupyUnarchived()
|
||
arranged = true
|
||
}
|
||
renderArchive()
|
||
}
|
||
if (n === '6' || n === 6) renderDetail()
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
}
|
||
|
||
document.querySelectorAll('.step-btn, [data-step]').forEach((b) => {
|
||
if (b._stepBound) return
|
||
b._stepBound = true
|
||
b.addEventListener('click', () => gotoStep(b.dataset.step))
|
||
})
|
||
document.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => closeMask(b.dataset.close)))
|
||
document.getElementById('btn-add-line').addEventListener('click', () => {
|
||
contractLines.push({
|
||
id: '行' + (contractLines.length + 1),
|
||
contractId: 'HT-A',
|
||
contractNo: 'HT-A-2026-088',
|
||
productId: PRODUCT.id,
|
||
name: PRODUCT.name,
|
||
pkg: PRODUCT.pkg,
|
||
qty: 15,
|
||
price: 860,
|
||
arrangedUnarchived: 0,
|
||
archived: 0,
|
||
note: '演示再加一行同品同包装'
|
||
})
|
||
renderContractForm()
|
||
toast('已追加同品同包装行(现网会被拦住)')
|
||
})
|
||
document.getElementById('btn-save-contract').addEventListener('click', () => {
|
||
toast('合同已保存:同品同包装多行有效')
|
||
gotoStep(2)
|
||
})
|
||
document.getElementById('btn-add-load-row').addEventListener('click', () => openPick('load'))
|
||
document.getElementById('btn-add-delivery-row').addEventListener('click', () => openPick('delivery'))
|
||
document.getElementById('btn-sync').addEventListener('click', () => {
|
||
deliveryRows = loadRows.map((r) => ({ ...r, actualQty: null }))
|
||
renderPlan()
|
||
toast('已从装货同步目标量到送货')
|
||
})
|
||
document.getElementById('btn-save-draft').addEventListener('click', () => {
|
||
toast('草稿已保存(允许目标量暂时超过剩余)')
|
||
})
|
||
document.getElementById('btn-go-arrange').addEventListener('click', () => gotoStep(3))
|
||
document.getElementById('btn-confirm-arrange').addEventListener('click', () => {
|
||
if (!deliveryRows.length) { toast('请先添加送货合同行'); return }
|
||
if (!arrangeOk()) { toast('存在超过剩余的合同行,不能安排'); renderArrangeCheck(); return }
|
||
occupyUnarchived()
|
||
arranged = true
|
||
toast('安排成功:各行按目标量占用额度')
|
||
gotoStep(4)
|
||
})
|
||
document.getElementById('weigh-total').addEventListener('input', updateArchiveSum)
|
||
document.getElementById('btn-demo-over').addEventListener('click', () => {
|
||
const row = deliveryRows.find((r) => r.cpId === '行1')
|
||
if (row) row.actualQty = 55
|
||
document.getElementById('weigh-total').value = '75'
|
||
renderArchive()
|
||
toast('行1 已标红。点「确认归档」会弹出二次确认')
|
||
})
|
||
document.getElementById('btn-archive').addEventListener('click', tryArchive)
|
||
document.getElementById('btn-over-ok').addEventListener('click', commitArchive)
|
||
document.getElementById('mask-pick').addEventListener('click', (e) => { if (e.target.id === 'mask-pick') closeMask('mask-pick') })
|
||
document.getElementById('mask-over').addEventListener('click', (e) => { if (e.target.id === 'mask-over') closeMask('mask-over') })
|
||
|
||
renderContractForm()
|
||
renderDetail()
|
||
</script>
|
||
</body>
|
||
</html>
|