瀏覽代碼

docs: add transport plan contract completion design and UI demo

Capture the agreed independent page (inline expand, per-side save, menu badge) for completing missing supplier/demander contracts after they became optional on create/edit.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 月之前
父節點
當前提交
f9f32a8b68

+ 803 - 0
docs/fresh-mint-pl-plan-contract-complete-demo.html

@@ -0,0 +1,803 @@
+<!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;
+      --table-head: #f8fafc;
+      --bg-page: #f8fafc;
+      --row-alt: #fafafa;
+      --row-hover: #f1f5f9;
+      --shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+      --shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.12);
+      --radius: 10px;
+      --warn: #d97706;
+      --warn-bg: #fffbeb;
+      --danger: #dc2626;
+      --danger-bg: #fef2f2;
+    }
+
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      min-height: 100vh;
+      font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
+      font-size: 14px;
+      line-height: 1.5;
+      color: var(--text);
+      background: var(--bg-page);
+    }
+
+    .demo-page { max-width: 1280px; margin: 0 auto; padding: 28px 24px 100px; }
+    .demo-head { margin-bottom: 24px; }
+    .demo-head h1 { margin: 0 0 8px; font-size: 24px; font-weight: 700; letter-spacing: -0.02em; }
+    .demo-head h1 span { color: var(--primary); }
+    .demo-head p { margin: 0 0 8px; color: var(--text-muted); max-width: 880px; }
+    .meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
+    .pill {
+      display: inline-flex; align-items: center; gap: 6px;
+      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.muted { background: #f1f5f9; color: var(--text-muted); border-color: #e2e8f0; }
+    .pill.warn { background: var(--warn-bg); color: var(--warn); border-color: rgba(217,119,6,.25); }
+
+    .brief {
+      display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 22px;
+    }
+    @media (max-width: 900px) { .brief { grid-template-columns: 1fr; } }
+    .card {
+      background: var(--surface); border: 1px solid var(--border);
+      border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow);
+    }
+    .card h2 {
+      margin: 0 0 10px; font-size: 15px; font-weight: 600;
+      padding-left: 10px; border-left: 3px solid var(--primary);
+      color: var(--primary-dark); line-height: 1.2;
+    }
+    .card ul { margin: 0; padding-left: 18px; color: var(--text-muted); }
+    .card li { margin: 5px 0; }
+    .card li strong { color: var(--text); }
+
+    .scheme-tabs {
+      display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap;
+    }
+    .scheme-tab {
+      appearance: none; border: 1px solid var(--border); background: #fff;
+      padding: 8px 14px; border-radius: 8px; font-size: 13px; font-weight: 500;
+      color: var(--text-muted); cursor: pointer;
+    }
+    .scheme-tab.active {
+      color: var(--primary-dark); border-color: #6ee7b7; background: var(--primary-bg);
+    }
+    .scheme-pane { display: none; }
+    .scheme-pane.active { display: block; }
+
+    .approach-note {
+      margin: 0 0 14px; padding: 10px 14px; border-radius: 8px;
+      background: #f8fafc; border: 1px dashed #cbd5e1; color: var(--text-muted); font-size: 13px;
+    }
+    .approach-note strong { color: var(--text); }
+    .approach-note.rec {
+      background: var(--primary-bg); border-color: #6ee7b7; border-style: solid;
+      color: var(--primary-dark);
+    }
+
+    /* ── 列表壳(对齐 web-admin panel) ── */
+    .panel {
+      background: var(--surface); border: 1px solid var(--border);
+      border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden;
+    }
+    .panel-top {
+      display: flex; align-items: center; justify-content: space-between;
+      gap: 12px; padding: 16px 20px 12px; border-bottom: 1px solid #eef2f6;
+    }
+    .panel-top h2 {
+      margin: 0; font-size: 16px; font-weight: 600;
+      display: flex; align-items: center; gap: 0;
+    }
+    .panel-top h2::before {
+      content: ""; display: inline-block; width: 3px; height: 16px;
+      background: var(--primary); border-radius: 2px; margin-right: 10px;
+    }
+    .stat-chip {
+      display: inline-flex; align-items: center; gap: 6px;
+      padding: 4px 10px; border-radius: 8px; font-size: 12px; font-weight: 600;
+      background: var(--warn-bg); color: var(--warn); border: 1px solid rgba(217,119,6,.2);
+    }
+
+    .filter-block { padding: 14px 20px 6px; }
+    .filter-grid {
+      display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px 16px;
+    }
+    @media (max-width: 1100px) { .filter-grid { grid-template-columns: repeat(2, 1fr); } }
+    .field label {
+      display: block; font-size: 11px; color: var(--text-muted); margin-bottom: 4px;
+    }
+    .field input, .field select {
+      width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 8px;
+      padding: 0 10px; font-size: 14px; color: var(--text); background: #fff;
+    }
+    .filter-actions {
+      display: flex; justify-content: flex-end; gap: 8px; padding: 10px 0 4px;
+    }
+
+    .list-toolbar {
+      display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
+      padding: 10px 20px; border-top: 1px solid #eef2f6;
+    }
+    .list-toolbar .hint { color: var(--text-muted); font-size: 13px; margin-right: auto; }
+
+    .btn {
+      appearance: none; display: inline-flex; align-items: center; gap: 6px;
+      height: 36px; padding: 0 14px; border-radius: 8px; font-size: 13px; font-weight: 500;
+      cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--text);
+    }
+    .btn:hover { background: #f8fafc; }
+    .btn-main {
+      background: var(--primary); border-color: var(--primary); color: #fff;
+    }
+    .btn-main:hover { background: var(--primary-dark); }
+    .btn-ghost { background: #fff; }
+    .btn-link {
+      height: auto; padding: 0; border: none; background: transparent;
+      color: var(--primary-dark); font-weight: 500;
+    }
+    .btn-link:hover { text-decoration: underline; background: transparent; }
+    .btn-sm { height: 30px; padding: 0 10px; font-size: 12px; }
+    .btn.warn-outline {
+      border-color: rgba(217,119,6,.35); color: var(--warn); background: var(--warn-bg);
+    }
+
+    .list-body { border-top: 1px solid #eef2f6; }
+    table.list-table {
+      width: 100%; border-collapse: collapse; table-layout: fixed;
+    }
+    .list-table th {
+      background: var(--table-head); text-align: left; font-weight: 600; font-size: 12px;
+      color: var(--text-muted); padding: 10px 12px; border-bottom: 1px solid #eef2f6;
+      white-space: nowrap;
+    }
+    .list-table td {
+      padding: 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top;
+      font-size: 13px;
+    }
+    .list-table tbody tr:nth-child(even) { background: var(--row-alt); }
+    .list-table tbody tr:hover { background: var(--row-hover); }
+    .list-table .mono { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--primary-dark); }
+    .list-table .muted { color: var(--text-muted); }
+    .list-table .party { display: flex; flex-direction: column; gap: 2px; }
+    .list-table .party .name { font-weight: 500; }
+    .list-table .party .sub { font-size: 12px; color: var(--text-muted); }
+
+    .tag {
+      display: inline-flex; align-items: center; padding: 2px 8px;
+      border-radius: 6px; font-size: 12px; font-weight: 500; white-space: nowrap;
+    }
+    .tag.success { background: #059669; color: #fff; }
+    .tag.warn { background: #d97706; color: #fff; }
+    .tag.danger { background: #dc2626; color: #fff; }
+    .tag.info { background: transparent; color: #1e293b; border: 1px solid #e2e8f0; }
+    .tag.muted { background: #f1f5f9; color: var(--text-muted); }
+
+    .gap-tags { display: flex; flex-wrap: wrap; gap: 6px; }
+    .ops { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
+
+    .pager {
+      display: flex; align-items: center; justify-content: flex-end; gap: 12px;
+      padding: 12px 20px; border-top: 1px solid #eef2f6; color: var(--text-muted); font-size: 13px;
+    }
+
+    /* 方案 B:行内展开补合同 */
+    .expand-row td { background: #f8fffb !important; padding: 0 !important; }
+    .expand-panel { padding: 14px 16px 16px; border-top: 1px dashed #a7f3d0; }
+    .expand-grid {
+      display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
+    }
+    @media (max-width: 900px) { .expand-grid { grid-template-columns: 1fr; } }
+    .side-box {
+      background: #fff; border: 1px solid var(--border); border-radius: 8px; padding: 12px;
+    }
+    .side-box.need { border-color: rgba(217,119,6,.35); background: #fffdf8; }
+    .side-box.ok { border-color: #a7f3d0; background: #f6fffb; }
+    .side-box.exempt { border-color: #e2e8f0; background: #f8fafc; }
+    .side-box h4 {
+      margin: 0 0 8px; font-size: 13px; display: flex; align-items: center;
+      justify-content: space-between; gap: 8px;
+    }
+    .side-box .customer { font-size: 13px; margin-bottom: 10px; }
+    .contract-chip {
+      display: flex; align-items: center; justify-content: space-between; gap: 8px;
+      padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 8px;
+      background: #fff; margin-bottom: 8px; font-size: 12px;
+    }
+    .contract-chip .no { font-weight: 600; color: var(--primary-dark); }
+    .empty-slot {
+      padding: 14px; border: 1px dashed #cbd5e1; border-radius: 8px;
+      text-align: center; color: var(--text-muted); font-size: 12px; margin-bottom: 8px;
+    }
+    .side-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+
+    /* 弹窗 */
+    .modal-mask {
+      display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45);
+      z-index: 1000; align-items: center; justify-content: center; padding: 24px;
+      backdrop-filter: none;
+    }
+    .modal-mask.open { display: flex; }
+    .dialog {
+      width: min(920px, 100%); max-height: calc(100vh - 100px);
+      background: #fff; border-radius: 12px; box-shadow: var(--shadow-lg);
+      display: flex; flex-direction: column; overflow: hidden;
+    }
+    .dialog-head {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 14px 20px; border-bottom: 1px solid #eef2f6; flex-shrink: 0;
+    }
+    .dialog-head h3 { margin: 0; font-size: 16px; font-weight: 600; }
+    .dialog-body { padding: 18px 20px; overflow: auto; flex: 1; }
+    .dialog-foot {
+      display: flex; justify-content: flex-end; gap: 8px;
+      padding: 12px 20px; border-top: 1px solid #eef2f6; flex-shrink: 0;
+    }
+
+    .plan-summary {
+      display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
+      margin-bottom: 16px; padding: 12px; background: #f8fafc; border-radius: 8px;
+      border: 1px solid #eef2f6;
+    }
+    @media (max-width: 800px) { .plan-summary { grid-template-columns: 1fr 1fr; } }
+    .kv .k { font-size: 11px; color: var(--text-muted); }
+    .kv .v { font-size: 13px; font-weight: 500; margin-top: 2px; }
+
+    .party-sections { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+    @media (max-width: 800px) { .party-sections { grid-template-columns: 1fr; } }
+
+    .selector-list { max-height: 220px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; }
+    .selector-item {
+      display: flex; align-items: flex-start; gap: 10px;
+      padding: 10px 12px; border-bottom: 1px solid #f1f5f9; cursor: pointer;
+    }
+    .selector-item:last-child { border-bottom: none; }
+    .selector-item:hover { background: var(--primary-bg); }
+    .selector-item.selected { background: var(--primary-bg); }
+    .selector-item .meta-line { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
+    .selector-item input { margin-top: 3px; }
+
+    .legend {
+      margin-top: 22px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
+    }
+    @media (max-width: 900px) { .legend { grid-template-columns: 1fr; } }
+    .legend .card h3 { margin: 0 0 8px; font-size: 14px; }
+    .legend code {
+      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      font-size: 12px; background: #f1f5f9; padding: 1px 6px; border-radius: 4px;
+    }
+
+    .toast {
+      position: fixed; top: 20px; left: 50%; transform: translateX(-50%) translateY(-120%);
+      background: #059669; color: #fff; padding: 10px 18px; border-radius: 8px;
+      font-size: 13px; font-weight: 500; box-shadow: var(--shadow-lg);
+      transition: transform .25s ease; z-index: 2000;
+    }
+    .toast.show { transform: translateX(-50%) translateY(0); }
+  </style>
+</head>
+<body>
+  <div class="demo-page">
+    <header class="demo-head">
+      <h1>运输计划 · <span>合同完善</span> 界面 Demo</h1>
+      <p>
+        仅展示「缺合同」的运输计划。判定规则:供需双方若非内置公司
+        (安庆市山峡化工有限公司 / 安徽云栋化工有限公司),该侧至少需关联 1 份合同;
+        内置公司该侧合同可免。本页只做<strong>关联已有合同</strong>,不支持新增/编辑合同。
+      </p>
+      <div class="meta">
+        <span class="pill">Fresh Mint · web-admin</span>
+        <span class="pill muted">参考模式:P02 复杂列表 + 关联弹窗</span>
+        <span class="pill warn">可点击交互 Demo</span>
+        <span class="pill muted">菜单建议:运输计划模块下</span>
+      </div>
+    </header>
+
+    <section class="brief">
+      <div class="card">
+        <h2>业务目标</h2>
+        <ul>
+          <li>创建计划时合同改为非必填,事后用本页<strong>快速补齐</strong></li>
+          <li>列表默认只显示缺口:缺供方 / 缺需方 / 两侧都缺</li>
+          <li>行内操作「补充合同」→ 打开选择器关联 CRM 已有合同</li>
+          <li>补齐后自动从本列表消失(或刷新后消失)</li>
+        </ul>
+      </div>
+      <div class="card">
+        <h2>本 Demo 两种交互</h2>
+        <ul>
+          <li><strong>方案 A(推荐)</strong>:列表 +「补充合同」弹窗,两侧分区关联</li>
+          <li><strong>方案 B</strong>:列表行展开,直接在行内补两侧合同</li>
+          <li>下方可切换方案,并点击按钮体验弹窗</li>
+        </ul>
+      </div>
+    </section>
+
+    <div class="scheme-tabs" role="tablist">
+      <button type="button" class="scheme-tab active" data-scheme="a">方案 A · 列表 + 弹窗(推荐)</button>
+      <button type="button" class="scheme-tab" data-scheme="b">方案 B · 行内展开</button>
+    </div>
+
+    <!-- ═══ 方案 A ═══ -->
+    <div class="scheme-pane active" id="scheme-a">
+      <p class="approach-note rec">
+        <strong>推荐:</strong>与现有「危化品证上传」「运输计划」列表一致;弹窗内可专注选合同,
+        不打断表格滚动;便于后续加菜单角标(待完善数量)。
+      </p>
+
+      <section class="panel">
+        <div class="panel-top">
+          <h2>运输计划合同完善</h2>
+          <span class="stat-chip">待完善 3 条</span>
+        </div>
+
+        <div class="filter-block">
+          <div class="filter-grid">
+            <div class="field">
+              <label>计划编号</label>
+              <input placeholder="搜索计划编号" />
+            </div>
+            <div class="field">
+              <label>缺口类型</label>
+              <select>
+                <option>全部缺口</option>
+                <option>仅缺供方合同</option>
+                <option>仅缺需方合同</option>
+                <option>两侧均缺</option>
+              </select>
+            </div>
+            <div class="field">
+              <label>供方 / 需方</label>
+              <input placeholder="客户名称" />
+            </div>
+            <div class="field">
+              <label>计划日期</label>
+              <input type="text" placeholder="开始日期 - 结束日期" />
+            </div>
+          </div>
+          <div class="filter-actions">
+            <button type="button" class="btn btn-ghost">重置</button>
+            <button type="button" class="btn btn-main">搜索</button>
+          </div>
+        </div>
+
+        <div class="list-toolbar">
+          <span class="hint">仅展示仍缺合同的运输计划 · 内置公司侧已自动豁免</span>
+          <button type="button" class="btn btn-ghost">刷新</button>
+        </div>
+
+        <div class="list-body">
+          <table class="list-table">
+            <colgroup>
+              <col style="width: 12%" />
+              <col style="width: 9%" />
+              <col style="width: 18%" />
+              <col style="width: 18%" />
+              <col style="width: 14%" />
+              <col style="width: 12%" />
+              <col style="width: 17%" />
+            </colgroup>
+            <thead>
+              <tr>
+                <th>计划编号</th>
+                <th>计划日期</th>
+                <th>供方</th>
+                <th>需方</th>
+                <th>合同缺口</th>
+                <th>跟进状态</th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td><a class="mono btn-link" href="javascript:void(0)">YP20260820001</a></td>
+                <td>2026-08-20</td>
+                <td>
+                  <div class="party">
+                    <span class="name">合肥某某商贸有限公司</span>
+                    <span class="sub">供方合同:未关联</span>
+                  </div>
+                </td>
+                <td>
+                  <div class="party">
+                    <span class="name">安庆市山峡化工有限公司</span>
+                    <span class="sub">内置 · 合同可免</span>
+                  </div>
+                </td>
+                <td>
+                  <div class="gap-tags">
+                    <span class="tag warn">缺供方合同</span>
+                  </div>
+                </td>
+                <td><span class="tag success">已安排</span></td>
+                <td>
+                  <div class="ops">
+                    <button type="button" class="btn btn-sm warn-outline" data-open-dialog="row1">补充合同</button>
+                    <button type="button" class="btn-link btn-sm">查看计划</button>
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td><a class="mono btn-link" href="javascript:void(0)">YP20260819088</a></td>
+                <td>2026-08-19</td>
+                <td>
+                  <div class="party">
+                    <span class="name">安庆市山峡化工有限公司</span>
+                    <span class="sub">内置 · 合同可免</span>
+                  </div>
+                </td>
+                <td>
+                  <div class="party">
+                    <span class="name">芜湖某某材料有限公司</span>
+                    <span class="sub">需方合同:未关联</span>
+                  </div>
+                </td>
+                <td>
+                  <div class="gap-tags">
+                    <span class="tag warn">缺需方合同</span>
+                  </div>
+                </td>
+                <td><span class="tag info">待安排</span></td>
+                <td>
+                  <div class="ops">
+                    <button type="button" class="btn btn-sm warn-outline" data-open-dialog="row2">补充合同</button>
+                    <button type="button" class="btn-link btn-sm">查看计划</button>
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td><a class="mono btn-link" href="javascript:void(0)">YP20260818042</a></td>
+                <td>2026-08-18</td>
+                <td>
+                  <div class="party">
+                    <span class="name">马鞍山某某化工有限公司</span>
+                    <span class="sub">供方合同:未关联</span>
+                  </div>
+                </td>
+                <td>
+                  <div class="party">
+                    <span class="name">铜陵某某实业有限公司</span>
+                    <span class="sub">需方合同:未关联</span>
+                  </div>
+                </td>
+                <td>
+                  <div class="gap-tags">
+                    <span class="tag danger">两侧均缺</span>
+                  </div>
+                </td>
+                <td><span class="tag success">运输中</span></td>
+                <td>
+                  <div class="ops">
+                    <button type="button" class="btn btn-sm warn-outline" data-open-dialog="row3">补充合同</button>
+                    <button type="button" class="btn-link btn-sm">查看计划</button>
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+        <div class="pager">共 3 条 · 每页 20 条 · 1 / 1</div>
+      </section>
+    </div>
+
+    <!-- ═══ 方案 B ═══ -->
+    <div class="scheme-pane" id="scheme-b">
+      <p class="approach-note">
+        <strong>备选:</strong>适合一次补几条、不想频繁开弹窗的场景;表格变高,窄屏体验略差。
+      </p>
+
+      <section class="panel">
+        <div class="panel-top">
+          <h2>运输计划合同完善</h2>
+          <span class="stat-chip">待完善 2 条(示例)</span>
+        </div>
+        <div class="list-toolbar">
+          <span class="hint">点击「展开补充」在行内关联合同</span>
+        </div>
+        <div class="list-body">
+          <table class="list-table">
+            <thead>
+              <tr>
+                <th style="width:12%">计划编号</th>
+                <th style="width:10%">计划日期</th>
+                <th style="width:22%">供方</th>
+                <th style="width:22%">需方</th>
+                <th style="width:14%">合同缺口</th>
+                <th style="width:20%">操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="mono">YP20260820001</td>
+                <td>2026-08-20</td>
+                <td>合肥某某商贸有限公司</td>
+                <td>安庆市山峡化工有限公司</td>
+                <td><span class="tag warn">缺供方合同</span></td>
+                <td><button type="button" class="btn btn-sm warn-outline" id="toggle-expand">收起补充</button></td>
+              </tr>
+              <tr class="expand-row" id="expand-demo">
+                <td colspan="6">
+                  <div class="expand-panel">
+                    <div class="expand-grid">
+                      <div class="side-box need">
+                        <h4>
+                          供方合同
+                          <span class="tag warn">待补充</span>
+                        </h4>
+                        <div class="customer">合肥某某商贸有限公司</div>
+                        <div class="empty-slot">暂未关联合同</div>
+                        <div class="side-actions">
+                          <button type="button" class="btn btn-sm btn-main" data-open-dialog="row1">选择合同</button>
+                        </div>
+                      </div>
+                      <div class="side-box exempt">
+                        <h4>
+                          需方合同
+                          <span class="tag muted">可免</span>
+                        </h4>
+                        <div class="customer">安庆市山峡化工有限公司(内置)</div>
+                        <div class="empty-slot" style="border-style:solid;background:#f8fafc;">
+                          内置公司,无需关联合同
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td class="mono">YP20260818042</td>
+                <td>2026-08-18</td>
+                <td>马鞍山某某化工有限公司</td>
+                <td>铜陵某某实业有限公司</td>
+                <td><span class="tag danger">两侧均缺</span></td>
+                <td><button type="button" class="btn btn-sm warn-outline" data-open-dialog="row3">展开补充</button></td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </section>
+    </div>
+
+    <section class="legend">
+      <div class="card">
+        <h3>缺口判定(与内置名单一致)</h3>
+        <ul>
+          <li>名称 ∈ {山峡, 云栋} → 该侧<strong>不要求</strong>合同</li>
+          <li>否则 → 该侧 <code>contracts.length ≥ 1</code></li>
+          <li>任一侧不满足 → 进入本列表</li>
+        </ul>
+      </div>
+      <div class="card">
+        <h3>本页能力边界</h3>
+        <ul>
+          <li>✅ 筛选、列表、关联已有合同、保存</li>
+          <li>✅ 跳转查看运输计划(只读)</li>
+          <li>❌ 不在此页新增 / 编辑 / 审批合同</li>
+        </ul>
+      </div>
+      <div class="card">
+        <h3>待你确认(下一步提问)</h3>
+        <ul>
+          <li>是否含已作废计划?归档计划?</li>
+          <li>菜单角标是否显示待完善数量?</li>
+          <li>关联时是否允许多份合同?</li>
+        </ul>
+      </div>
+    </section>
+  </div>
+
+  <!-- 补充合同弹窗 -->
+  <div class="modal-mask" id="dialog-mask" role="dialog" aria-modal="true">
+    <div class="dialog">
+      <div class="dialog-head">
+        <h3>补充合同 · <span id="dlg-plan-no">YP20260820001</span></h3>
+        <button type="button" class="btn-link" id="dlg-close" aria-label="关闭">✕</button>
+      </div>
+      <div class="dialog-body">
+        <div class="plan-summary">
+          <div class="kv"><div class="k">计划日期</div><div class="v" id="dlg-date">2026-08-20</div></div>
+          <div class="kv"><div class="k">跟进状态</div><div class="v"><span class="tag success">已安排</span></div></div>
+          <div class="kv"><div class="k">供方</div><div class="v" id="dlg-supplier">合肥某某商贸有限公司</div></div>
+          <div class="kv"><div class="k">需方</div><div class="v" id="dlg-demander">安庆市山峡化工有限公司</div></div>
+        </div>
+
+        <div class="party-sections">
+          <div class="side-box need" id="dlg-supplier-box">
+            <h4>供方合同 <span class="tag warn" id="dlg-supplier-tag">待补充</span></h4>
+            <div class="customer" id="dlg-supplier-name">合肥某某商贸有限公司</div>
+            <div id="dlg-supplier-selected" class="empty-slot">暂未选择 · 下方勾选后确认</div>
+            <div class="selector-list" id="dlg-supplier-list">
+              <label class="selector-item">
+                <input type="checkbox" name="sc" value="c1" />
+                <div>
+                  <div class="no" style="font-weight:600;color:var(--primary-dark)">HT-2026-0312</div>
+                  <div class="meta-line">销售合同 · 执行中 · 液碱 32%</div>
+                </div>
+              </label>
+              <label class="selector-item">
+                <input type="checkbox" name="sc" value="c2" />
+                <div>
+                  <div class="no" style="font-weight:600;color:var(--primary-dark)">HT-2026-0288</div>
+                  <div class="meta-line">框架合同 · 已审批 · 盐酸</div>
+                </div>
+              </label>
+            </div>
+          </div>
+
+          <div class="side-box exempt" id="dlg-demander-box">
+            <h4>需方合同 <span class="tag muted" id="dlg-demander-tag">可免</span></h4>
+            <div class="customer" id="dlg-demander-name">安庆市山峡化工有限公司(内置)</div>
+            <div class="empty-slot" style="border-style:solid;">内置公司,无需选择合同</div>
+            <div class="selector-list" id="dlg-demander-list" style="display:none;"></div>
+          </div>
+        </div>
+      </div>
+      <div class="dialog-foot">
+        <button type="button" class="btn btn-ghost" id="dlg-cancel">取消</button>
+        <button type="button" class="btn btn-main" id="dlg-save">保存关联</button>
+      </div>
+    </div>
+  </div>
+
+  <div class="toast" id="toast">已保存合同关联(Demo)</div>
+
+  <script>
+    const scenarios = {
+      row1: {
+        planNo: 'YP20260820001',
+        date: '2026-08-20',
+        supplier: '合肥某某商贸有限公司',
+        demander: '安庆市山峡化工有限公司',
+        supplierNeed: true,
+        demanderNeed: false,
+        supplierExempt: false,
+        demanderExempt: true
+      },
+      row2: {
+        planNo: 'YP20260819088',
+        date: '2026-08-19',
+        supplier: '安庆市山峡化工有限公司',
+        demander: '芜湖某某材料有限公司',
+        supplierNeed: false,
+        demanderNeed: true,
+        supplierExempt: true,
+        demanderExempt: false
+      },
+      row3: {
+        planNo: 'YP20260818042',
+        date: '2026-08-18',
+        supplier: '马鞍山某某化工有限公司',
+        demander: '铜陵某某实业有限公司',
+        supplierNeed: true,
+        demanderNeed: true,
+        supplierExempt: false,
+        demanderExempt: false
+      }
+    }
+
+    const mask = document.getElementById('dialog-mask')
+
+    function paintSide(prefix, need, exempt, name) {
+      const box = document.getElementById('dlg-' + prefix + '-box')
+      const tag = document.getElementById('dlg-' + prefix + '-tag')
+      const list = document.getElementById('dlg-' + prefix + '-list')
+      const selected = document.getElementById('dlg-' + prefix + '-selected')
+      document.getElementById('dlg-' + prefix + '-name').textContent =
+        exempt ? name + '(内置)' : name
+
+      box.classList.remove('need', 'ok', 'exempt')
+      if (exempt) {
+        box.classList.add('exempt')
+        tag.className = 'tag muted'
+        tag.textContent = '可免'
+        if (selected) {
+          selected.className = 'empty-slot'
+          selected.style.borderStyle = 'solid'
+          selected.textContent = '内置公司,无需选择合同'
+        }
+        if (list) list.style.display = 'none'
+      } else if (need) {
+        box.classList.add('need')
+        tag.className = 'tag warn'
+        tag.textContent = '待补充'
+        if (selected) {
+          selected.className = 'empty-slot'
+          selected.style.borderStyle = ''
+          selected.textContent = '暂未选择 · 下方勾选后确认'
+        }
+        if (list) {
+          list.style.display = ''
+          list.innerHTML = `
+            <label class="selector-item">
+              <input type="checkbox" name="${prefix}" value="a" />
+              <div>
+                <div style="font-weight:600;color:var(--primary-dark)">HT-2026-0312</div>
+                <div class="meta-line">销售合同 · 执行中 · 示例产品 A</div>
+              </div>
+            </label>
+            <label class="selector-item">
+              <input type="checkbox" name="${prefix}" value="b" />
+              <div>
+                <div style="font-weight:600;color:var(--primary-dark)">HT-2026-0288</div>
+                <div class="meta-line">框架合同 · 已审批 · 示例产品 B</div>
+              </div>
+            </label>`
+        }
+      } else {
+        box.classList.add('ok')
+        tag.className = 'tag success'
+        tag.textContent = '已齐'
+        if (list) list.style.display = 'none'
+      }
+    }
+
+    function openDialog(key) {
+      const s = scenarios[key] || scenarios.row1
+      document.getElementById('dlg-plan-no').textContent = s.planNo
+      document.getElementById('dlg-date').textContent = s.date
+      document.getElementById('dlg-supplier').textContent = s.supplier
+      document.getElementById('dlg-demander').textContent = s.demander
+      paintSide('supplier', s.supplierNeed, s.supplierExempt, s.supplier)
+      paintSide('demander', s.demanderNeed, s.demanderExempt, s.demander)
+      mask.classList.add('open')
+    }
+
+    function closeDialog() {
+      mask.classList.remove('open')
+    }
+
+    document.querySelectorAll('.scheme-tab').forEach(tab => {
+      tab.addEventListener('click', () => {
+        document.querySelectorAll('.scheme-tab').forEach(t => t.classList.remove('active'))
+        document.querySelectorAll('.scheme-pane').forEach(p => p.classList.remove('active'))
+        tab.classList.add('active')
+        document.getElementById('scheme-' + tab.dataset.scheme).classList.add('active')
+      })
+    })
+
+    document.querySelectorAll('[data-open-dialog]').forEach(btn => {
+      btn.addEventListener('click', () => openDialog(btn.dataset.openDialog))
+    })
+
+    document.getElementById('dlg-close').addEventListener('click', closeDialog)
+    document.getElementById('dlg-cancel').addEventListener('click', closeDialog)
+    mask.addEventListener('click', e => { if (e.target === mask) closeDialog() })
+
+    document.getElementById('dlg-save').addEventListener('click', () => {
+      closeDialog()
+      const toast = document.getElementById('toast')
+      toast.classList.add('show')
+      setTimeout(() => toast.classList.remove('show'), 1800)
+    })
+
+    const expandBtn = document.getElementById('toggle-expand')
+    const expandRow = document.getElementById('expand-demo')
+    if (expandBtn && expandRow) {
+      expandBtn.addEventListener('click', () => {
+        const hidden = expandRow.style.display === 'none'
+        expandRow.style.display = hidden ? '' : 'none'
+        expandBtn.textContent = hidden ? '收起补充' : '展开补充'
+      })
+    }
+  </script>
+</body>
+</html>

+ 218 - 0
docs/superpowers/specs/2026-08-20-pl-plan-contract-complete-design.md

@@ -0,0 +1,218 @@
+# 运输计划合同完善 · 设计说明
+
+> Demo:`docs/fresh-mint-pl-plan-contract-complete-demo.html`(方案 B 为采纳交互)  
+> 日期:2026-08-20  
+> 状态:待实现
+
+## 1. 背景与目标
+
+运输计划新增/编辑时,供需双方合同已改为**非必填**,便于先建计划再补合同。业务上:非内置公司一侧仍应关联合同;内置公司该侧合同可免。
+
+**内置公司(合同可免):**
+
+- 安庆市山峡化工有限公司
+- 安徽云栋化工有限公司
+
+判定逻辑复用现有工具:
+
+- 前端:`web-admin/src/utils/pl-internal-customer.ts` → `isPlInternalCustomerName`
+- 后端:`backend/app/services/pl_internal_customer.py` → `is_internal_customer_name`
+
+**目标:** 提供独立的「运输计划合同完善」界面,只列出仍缺合同的未作废计划,并支持在列表行内直接关联已有 CRM 合同(不支持在本页新增/编辑合同)。
+
+## 2. 已确认决策
+
+| 项 | 结论 |
+|---|---|
+| 实现路径 | 独立菜单页 + 专用列表/保存 API(路径 1) |
+| 交互 | 方案 B:行内展开补充合同 |
+| 计划范围 | 仅未作废(`follow_status != cancelled`),含待安排/已安排/运输中等;**已归档但未作废的计划仍入列**(未采用「排除归档」) |
+| 合同数量 | 每侧可多选(与计划新增/编辑一致) |
+| 保存方式 | 两侧各自「保存」;补完一侧即可落库 |
+| 菜单角标 | 要;显示当前缺合同条数(对齐危化品证) |
+| 非目标 | 本页不新增/编辑/审批合同;不改计划其它字段;不做小程序页 |
+
+## 3. 信息架构与入口
+
+### 3.1 菜单
+
+挂在 **运输管理 → 运输计划** 分组下,与「运输计划」「运输计划归档」并列:
+
+| 项 | 值 |
+|---|---|
+| 菜单名 | 运输计划合同完善 |
+| Hash 路由 | `/#/pl/plan-module/plan-contract` |
+| 组件 | `web-admin/src/views/pl/plan-contract/index.vue` |
+| menuCode | `pl:plan-contract:list` |
+| 更新权限 | `pl:plan-contract:update` |
+
+路由注册:`web-admin/src/router/modules/pl.ts`(`plan-module` children)。  
+菜单/权限 SQL:写入 `docs/sql`(与现有 pl 菜单脚本风格一致)。
+
+### 3.2 UI 模式
+
+**P02 复杂业务列表**,参考:
+
+- `web-admin/src/views/pl/plan-cert/index.vue`(待办型衍生列表 + 角标)
+- `web-admin/src/views/pl/plan/index.vue`(列表骨架、筛选、日期快捷)
+
+视觉遵循 `docs/web-admin-ui-spec-fresh-mint.md`。
+
+页面骨架:
+
+```
+panel
+  ├─ panel-top:标题 + 待完善数量
+  ├─ filter-block
+  ├─ list-toolbar(日期快捷 + 快速搜索 + 刷新;无「新增」)
+  ├─ list-table(可展开行)
+  └─ pagination
+```
+
+## 4. 缺口判定规则
+
+对每条未删除、未作废的运输计划:
+
+1. **供方侧**  
+   - 若 `is_internal_customer_name(supplier_name)` → 该侧**不要求**合同(可免)  
+   - 否则 → 至少 1 条有效供方侧 `pl_plan_contract`(`side=supplier`, `del_flag=0`)
+2. **需方侧**  
+   - 对 `customer_name` 同理(`side=demander`)
+3. **入列表条件**:供方侧缺口 **或** 需方侧缺口(任一侧不满足要求)
+
+列表/角标口径必须与上述规则一致(前后端同一工具函数)。
+
+## 5. 列表与行内展开
+
+### 5.1 列表列
+
+| 列 | 说明 |
+|---|---|
+| 计划编号 | 主色链接 → 打开现有计划只读查看 |
+| 计划日期 | |
+| 供方 | 名称;副文案:已关联 N 份 / 未关联 / 内置可免 |
+| 需方 | 同上 |
+| 合同缺口 | Tag:`缺供方合同` / `缺需方合同` / `两侧均缺` |
+| 跟进状态 | 现有跟进状态 Tag |
+| 操作 | 「展开补充」/「收起」;「查看计划」 |
+
+### 5.2 筛选与排序
+
+- 筛选:计划编号、缺口类型(全部 / 仅缺供方 / 仅缺需方 / 两侧均缺)、供方或需方关键词、计划日期区间
+- 工具栏:日期快捷 + 快速搜索(计划编号/供方/需方)
+- 默认排序:`plan_date DESC`,其次 `create_time DESC`
+- 数据权限:与运输计划列表相同(`apply_pl_scope`)
+
+### 5.3 行内展开交互
+
+- 同时仅展开一行;展开另一行时收起当前行
+- 展开区左右两栏:供方合同 | 需方合同
+
+| 侧状态 | UI |
+|---|---|
+| 待补充 | 已选合同卡片(可移除)+「选择合同」+ 该侧「保存」 |
+| 已齐 | 展示已关联合同;仍允许改选后保存 |
+| 可免(内置) | 灰态说明;无选择器、无保存按钮 |
+
+- 「选择合同」复用 `ContractSelector`(多选;可选状态与计划页一致:已审批 / 执行中);按该侧 `customerId` 过滤
+- **不提供**新建/编辑合同入口
+- 保存只提交**该侧**合同列表;成功后刷新该行;若两侧均已满足缺口规则,该行从列表移除并刷新角标
+- 有未保存变更时收起:二次确认
+
+组件复用:优先复用 `PlPlanContractMultiSelect` / `ContractSelector` 的选择与卡片展示能力。
+
+## 6. API 设计
+
+建议挂在运输计划模块下(路径示例,实现时可微调前缀,须注册到 `app/api` + `main`):
+
+### 6.1 缺合同分页列表
+
+`GET /api/pl/plans/contract-gaps`
+
+Query:分页 + `planNo`、`gapType`(`supplier` | `demander` | `both`)、客户关键词、`planDate` 起止、快速搜索等。
+
+固定过滤:`del_flag=0`、`follow_status != cancelled`、存在合同缺口(**不**按 `archive_status` 排除)。
+
+响应行至少包含:
+
+- 计划基础字段(编号、日期、供需方 id/name、跟进状态等)
+- `gapSides: ("supplier" | "demander")[]`
+- `supplierContractExempt` / `demanderContractExempt`
+- `supplierContracts` / `demanderContracts`(`PlanContractLink[]`,展开回填)
+
+### 6.2 单侧保存合同
+
+`PUT /api/pl/plans/{planId}/contracts/{side}`
+
+- `side` ∈ `supplier` | `demander`
+- Body:`{ "contracts": PlanContractLink[] }`(可多份;空数组表示清空该侧)
+
+服务端:
+
+1. 校验计划存在、未作废、租户与数据权限  
+2. 校验合同可选(归属该侧客户、状态允许等),复用 `assert_contract_selectable_for_plan` / `validate_plan_party_contracts` 中与合同相关的能力;**本接口不因「非内置且空列表」直接 422**(允许先清空;列表是否仍展示由缺口规则决定)  
+3. 加载对侧现有合同 + 本侧请求列表 → 调用现有 `replace_plan_contracts` 整体替换关联表  
+4. 返回:更新后两侧合同 + 最新 `gapSides`(及豁免标记)
+
+**不走**完整 `PlanUpdate`;不修改产品、运输、客户等字段。
+
+### 6.3 与现有「合同非必填」的关系
+
+计划创建/全量更新已不再强制非内置侧必须有合同。本页是事后补齐通道;主计划编辑页仍可改合同,行为保持一致。
+
+## 7. 菜单角标
+
+- `menuCode`:`pl:plan-contract:list`
+- 在 `backend/app/services/pl_menu_badge_service.py` 增加计数函数,口径与列表默认条件一致
+- 加入 `SUPPORTED_MENU_CODES`;前端侧栏展示数字(对齐 `pl:plan-cert:list`)
+
+## 8. 权限
+
+| 权限码 | 用途 |
+|---|---|
+| `pl:plan-contract:list` | 进入页面、拉列表、角标 |
+| `pl:plan-contract:update` | 单侧保存合同 |
+
+「查看计划」复用现有计划查看能力(与 `plan-cert` 跳转查看一致,通常依赖 `pl:plan:list` 或已有详情权限)。
+
+## 9. 前端文件(预期)
+
+| 文件 | 职责 |
+|---|---|
+| `web-admin/src/views/pl/plan-contract/index.vue` | 页面 |
+| `web-admin/src/router/modules/pl.ts` | 路由 |
+| `web-admin/src/api/pl/*` | list gaps / save side |
+| (可选)小组件 | 行内展开双侧合同区,避免单文件过大 |
+
+## 10. 后端文件(预期)
+
+| 文件 | 职责 |
+|---|---|
+| `app/api/pl/plan.py` 或独立 router | 两个端点 |
+| `app/services/pl_plan_service.py`(或小服务模块) | 缺口查询、单侧保存编排 |
+| `app/schemas/pl/plan.py` | 请求/响应模型 |
+| `app/services/pl_menu_badge_service.py` | 角标 |
+| `docs/sql/*` | 菜单与权限 |
+
+## 11. 错误与边界
+
+- 已作废计划:列表不出现;若 ID 直调保存 → 400/422,提示不可修改  
+- 合同不属于该侧客户或状态不可选 → 422,明确文案  
+- 并发:单侧保存以「读对侧最新 + 写本侧」再 `replace_plan_contracts`;后写覆盖该次替换结果(可接受;不做乐观锁,除非后续有冲突投诉)  
+- 内置侧误传合同:允许关联(不强制拒绝),UI 默认不展示选择器  
+
+## 12. 测试要点
+
+- 缺口判定:双非内置无合同 → 入列;一侧内置一侧外部无合同 → 仅缺外部侧;双侧内置 → 不入列  
+- 单侧保存后对侧合同不变  
+- 补齐后行消失、角标减一  
+- 作废计划不出现在列表与角标  
+- 数据权限用户看不到范围外计划  
+- 前端:展开互斥、未保存收起确认、无新建合同入口  
+
+## 13. 非目标(明确不做)
+
+- 合同 CRUD / 审批  
+- 修改运输计划主数据、产品、运输安排  
+- 小程序「合同完善」页  
+- 主计划列表增加「缺合同」Tab(本设计采用独立页)