ForgeFlow-ERP/docs/superpowers/plans/2026-06-02-drawer-table-readability.md
2026-06-14 21:05:49 +08:00

22 KiB

Drawer Table Readability Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make all ERP drawers easier to read by eliminating ugly horizontal scrolling, vertical table headers, random wrapping, and overloaded drawer tables.

Architecture: First add global drawer/table readability guardrails so existing drawers immediately stop collapsing columns. Then introduce a reusable DrawerDataTable component for drawer tables with fixed layout, compact labels, ellipsis cells, optional expanded detail rows, and consistent column types. Finally migrate the highest-impact warehouse product overview and inventory ledger drawer tables, leaving the component ready for the rest of the ERP.

Tech Stack: Vue 3 single-file components, existing FormDrawer, existing TableControls, existing PaginationBar, global CSS in frontend/src/styles/main.css, Vite build.


File Structure

  • Modify: frontend/src/components/FormDrawer.vue

    • Add a size prop while keeping the old wide prop working.
    • Supported sizes: normal, wide, xl, fullscreen.
  • Create: frontend/src/components/DrawerDataTable.vue

    • Reusable drawer table renderer.
    • Accepts rows, columns, row key, empty text, and optional expandable row details.
    • Provides named cell slots like cell-lot_no and cell-status.
  • Modify: frontend/src/styles/main.css

    • Add drawer width classes.
    • Add drawer table readability CSS.
    • Prevent vertical Chinese headers.
    • Add cell helpers for ellipsis, numeric alignment, chip-like long codes, and detail grids.
  • Modify: frontend/src/views/InventoryLedgerView.vue

    • Use size="fullscreen" or size="xl" for complex inventory detail drawers.
    • Convert the product overview lot table, inventory batch detail table, and inventory transaction table to DrawerDataTable.
    • Reduce visible columns to the core fields and move secondary fields to expandable detail.
  • Test: frontend/scripts/test-drawer-data-table.mjs

    • Static test that confirms the new component exists and includes required slot/column/detail behavior.
  • Test: existing frontend/scripts/test-inventory-ledger-options.mjs

    • Must still pass.

Task 1: Add Drawer Size Levels

Files:

  • Modify: frontend/src/components/FormDrawer.vue

  • Modify: frontend/src/styles/main.css

  • Step 1: Update FormDrawer.vue props and class binding

Replace the current aside class binding:

<aside class="workflow-drawer form-drawer" :class="{ 'form-drawer-wide': wide }">

with:

<aside class="workflow-drawer form-drawer" :class="drawerClass">

Add this script code after const emit = defineEmits(["close"]);:

const drawerClass = computed(() => {
  const normalizedSize = ["normal", "wide", "xl", "fullscreen"].includes(props.size) ? props.size : "normal";
  return {
    "form-drawer-wide": props.wide || normalizedSize === "wide",
    "form-drawer-xl": normalizedSize === "xl",
    "form-drawer-fullscreen": normalizedSize === "fullscreen"
  };
});

Update the import:

import { computed, toRef } from "vue";

Add this prop:

size: {
  type: String,
  default: "normal"
}
  • Step 2: Add drawer width CSS

Append near the existing drawer/form-drawer CSS in frontend/src/styles/main.css:

.form-drawer-xl {
  width: min(1280px, calc(100vw - 40px));
  max-width: calc(100vw - 40px);
}

.form-drawer-fullscreen {
  width: min(1680px, calc(100vw - 28px));
  max-width: calc(100vw - 28px);
  height: min(940px, calc(100vh - 28px));
}

.form-drawer-fullscreen .form-drawer-body,
.form-drawer-xl .form-drawer-body {
  min-width: 0;
}
  • Step 3: Build check

Run:

cd /Volumes/ForgeFlow-ERP/frontend
npm run build

Expected: build succeeds.


Task 2: Add Global Drawer Table Guardrails

Files:

  • Modify: frontend/src/styles/main.css

  • Step 1: Add drawer table readability CSS

Append after the existing .data-table styles:

.workflow-drawer .table-wrap {
  max-width: 100%;
  overflow-x: hidden;
}

.workflow-drawer .data-table {
  min-width: 0;
  table-layout: fixed;
}

.workflow-drawer .data-table th {
  writing-mode: horizontal-tb;
  white-space: normal;
  word-break: keep-all;
  overflow-wrap: normal;
  line-height: 1.35;
  vertical-align: middle;
}

.workflow-drawer .data-table td {
  min-width: 0;
  vertical-align: middle;
  word-break: normal;
  overflow-wrap: anywhere;
}

.drawer-cell-ellipsis {
  display: block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.drawer-cell-code {
  display: inline-flex;
  max-width: 100%;
  padding: 4px 8px;
  border-radius: 999px;
  background: #eef6ff;
  color: #1f5fa8;
  font-weight: 650;
  line-height: 1.25;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.drawer-cell-number {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
  • Step 2: Verify screenshots manually

Run the frontend, open an existing drawer, and confirm:

cd /Volumes/ForgeFlow-ERP/frontend
npm run dev

Expected:

  • Headers no longer display one Chinese character per line.
  • Tables inside drawers no longer force immediate horizontal scroll for normal-width content.
  • Long batch numbers are allowed to truncate rather than deform the whole table.

Task 3: Create DrawerDataTable

Files:

  • Create: frontend/src/components/DrawerDataTable.vue

  • Create: frontend/scripts/test-drawer-data-table.mjs

  • Step 1: Write the static component test

Create frontend/scripts/test-drawer-data-table.mjs:

import { readFileSync } from "node:fs";
import { resolve } from "node:path";

const componentPath = resolve(process.cwd(), "src/components/DrawerDataTable.vue");
const source = readFileSync(componentPath, "utf8");

const requiredSnippets = [
  "defineProps",
  "columns",
  "rows",
  "rowKey",
  "expandedRows",
  "cellClass",
  "slot :name=\"`cell-${column.key}`\"",
  "drawer-data-table",
  "drawer-row-detail"
];

for (const snippet of requiredSnippets) {
  if (!source.includes(snippet)) {
    throw new Error(`DrawerDataTable missing required snippet: ${snippet}`);
  }
}

console.log("drawer data table static tests passed");
  • Step 2: Run the test to verify it fails

Run:

cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-drawer-data-table.mjs

Expected: FAIL because src/components/DrawerDataTable.vue does not exist.

  • Step 3: Create the component

Create frontend/src/components/DrawerDataTable.vue:

<template>
  <div class="drawer-data-table-wrap">
    <table class="data-table compact-table drawer-data-table">
      <colgroup>
        <col v-for="column in visibleColumns" :key="column.key" :style="{ width: column.width || 'auto' }" />
        <col v-if="expandable" style="width: 72px" />
      </colgroup>
      <thead>
        <tr>
          <th v-for="column in visibleColumns" :key="column.key" :title="column.title || column.label">
            <span class="drawer-table-head-label">{{ column.compactLabel || column.label }}</span>
          </th>
          <th v-if="expandable">详情</th>
        </tr>
      </thead>
      <tbody>
        <template v-for="row in rows" :key="getRowKey(row)">
          <tr>
            <td v-for="column in visibleColumns" :key="column.key" :class="cellClass(column)">
              <slot :name="`cell-${column.key}`" :row="row" :column="column" :value="row[column.key]">
                <span :class="fallbackClass(column)" :title="formatFallback(row, column)">
                  {{ formatFallback(row, column) }}
                </span>
              </slot>
            </td>
            <td v-if="expandable" class="drawer-cell-actions">
              <button class="mini-icon-button" type="button" @click="toggleRow(row)">
                {{ isExpanded(row) ? "收起" : "展开" }}
              </button>
            </td>
          </tr>
          <tr v-if="expandable && isExpanded(row)" class="drawer-row-detail">
            <td :colspan="visibleColumns.length + 1">
              <slot name="detail" :row="row">
                <div class="drawer-row-detail-grid">
                  <article v-for="column in detailColumns" :key="column.key">
                    <span>{{ column.label }}</span>
                    <strong>{{ formatFallback(row, column) }}</strong>
                  </article>
                </div>
              </slot>
            </td>
          </tr>
        </template>
        <tr v-if="!rows.length">
          <td :colspan="visibleColumns.length + (expandable ? 1 : 0)" class="empty-row">{{ emptyText }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script setup>
import { computed } from "vue";

const props = defineProps({
  rows: {
    type: Array,
    default: () => []
  },
  columns: {
    type: Array,
    default: () => []
  },
  rowKey: {
    type: [String, Function],
    default: "id"
  },
  expandedRows: {
    type: Array,
    default: () => []
  },
  emptyText: {
    type: String,
    default: "暂无数据"
  }
});

const emit = defineEmits(["update:expandedRows"]);

const visibleColumns = computed(() => props.columns.filter((column) => !column.hidden && !column.detailOnly));
const detailColumns = computed(() => props.columns.filter((column) => column.detailOnly));
const expandable = computed(() => detailColumns.value.length > 0);

function getRowKey(row) {
  return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}

function isExpanded(row) {
  return props.expandedRows.includes(getRowKey(row));
}

function toggleRow(row) {
  const key = getRowKey(row);
  const nextRows = isExpanded(row) ? props.expandedRows.filter((item) => item !== key) : [...props.expandedRows, key];
  emit("update:expandedRows", nextRows);
}

function cellClass(column) {
  return {
    "drawer-cell-number": column.type === "number" || column.align === "right",
    "drawer-cell-status": column.type === "status",
    "drawer-cell-long": column.type === "code" || column.type === "longText"
  };
}

function fallbackClass(column) {
  return {
    "drawer-cell-code": column.type === "code",
    "drawer-cell-ellipsis": column.type === "longText" || column.type === "code"
  };
}

function formatFallback(row, column) {
  if (typeof column.format === "function") {
    return column.format(row[column.key], row);
  }
  const value = row?.[column.key];
  return value === null || value === undefined || value === "" ? "-" : String(value);
}
</script>
  • Step 4: Add component CSS

Append to frontend/src/styles/main.css:

.drawer-data-table-wrap {
  width: 100%;
  max-width: 100%;
  overflow: hidden;
}

.drawer-data-table {
  width: 100%;
  min-width: 0;
  table-layout: fixed;
}

.drawer-data-table th,
.drawer-data-table td {
  min-width: 0;
}

.drawer-table-head-label {
  display: inline-block;
  max-width: 100%;
  line-height: 1.35;
  white-space: normal;
  word-break: keep-all;
}

.drawer-cell-actions {
  text-align: center;
}

.mini-icon-button {
  border: 1px solid #d9e6f5;
  border-radius: 999px;
  background: #f5f9ff;
  color: #2f74c8;
  font-size: 12px;
  padding: 4px 9px;
  cursor: pointer;
}

.drawer-row-detail td {
  background: #f8fbff;
}

.drawer-row-detail-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 10px;
}

.drawer-row-detail-grid article {
  display: grid;
  gap: 4px;
  padding: 10px 12px;
  border: 1px solid #e4edf7;
  border-radius: 12px;
  background: #ffffff;
}

.drawer-row-detail-grid span {
  color: #7a8699;
  font-size: 12px;
}

.drawer-row-detail-grid strong {
  color: #1f2d3d;
  font-size: 13px;
  overflow-wrap: anywhere;
}
  • Step 5: Run static component test

Run:

cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-drawer-data-table.mjs

Expected: PASS with drawer data table static tests passed.


Task 4: Migrate Warehouse Drawer Tables

Files:

  • Modify: frontend/src/views/InventoryLedgerView.vue

  • Step 1: Import DrawerDataTable and add expansion state

Add import:

import DrawerDataTable from "../components/DrawerDataTable.vue";

Add refs near the other table page refs:

const expandedLotRows = ref([]);
const expandedTxnRows = ref([]);
  • Step 2: Add column definitions

Add computed columns near the table-related computed values:

const lotDrawerColumns = computed(() => [
  { key: "lot_no", label: "库存批次号", compactLabel: "批次号", type: "code", width: "220px" },
  ...(isSelectedFinishedWarehouse.value ? [{ key: "inbound_qty", label: "入库数量", compactLabel: "入库数", type: "number", width: "96px", format: (_value, row) => formatQty(row.inbound_qty) }] : []),
  ...(selectedWeightOnly.value ? [] : [{ key: "remaining_qty", label: isSelectedFinishedWarehouse.value ? "剩余数量" : "变动数量", compactLabel: isSelectedFinishedWarehouse.value ? "剩余数" : "变动数", type: "number", width: "96px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.remaining_qty, "PIECE") : formatQty(row.remaining_qty) }]),
  { key: "inbound_weight_kg", label: "入库重量(kg)", compactLabel: "入库重量", type: "number", width: "110px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.inbound_weight_kg, "WEIGHT") : formatWeight(row.inbound_weight_kg) },
  { key: "remaining_weight_kg", label: "剩余重量(kg)", compactLabel: "剩余重量", type: "number", width: "110px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.remaining_weight_kg, "WEIGHT") : formatWeight(row.remaining_weight_kg) },
  { key: "unit_cost", label: selectedUnitCostColumnLabel.value, compactLabel: isSelectedFinishedWarehouse.value ? "材料成本" : "单价", type: "number", width: "116px", format: (_value, row) => formatSelectedUnitCost(row.unit_cost) },
  { key: "status", label: "状态", type: "status", width: "90px" },
  { key: "warehouse_name", label: "仓库", detailOnly: true },
  { key: "source_purchase_order_no", label: "采购单号", detailOnly: true },
  { key: "source_receipt_no", label: "入库单号", detailOnly: true },
  { key: "source_material_summary", label: "来源库存批次", detailOnly: true },
  { key: "logistics_waybill_no", label: "运单号", detailOnly: true },
  { key: "logistics_freight_amount", label: "运费", detailOnly: true, format: (_value, row) => formatFreight(row.logistics_freight_amount) },
  { key: "quality_release", label: "质检放行人", detailOnly: true, format: (_value, row) => row.item_type === "RAW_MATERIAL" ? formatLotQualityRelease(row) : "-" }
]);

const txnDrawerColumns = computed(() => [
  { key: "biz_time", label: "时间", type: "longText", width: "128px", format: (_value, row) => formatDateTime(row.biz_time) },
  { key: "txn_type", label: "类型", width: "94px", format: (_value, row) => formatInventoryTxnTypeLabel(row.txn_type) },
  { key: "lot_no", label: "库存批次号", compactLabel: "批次号", type: "code", width: "220px" },
  ...(selectedWeightOnly.value ? [] : [{ key: "qty_change", label: "变动数量", compactLabel: "变动数", type: "number", width: "96px", format: (_value, row) => formatQty(row.qty_change) }]),
  { key: "weight_change_kg", label: "变动重量(kg)", compactLabel: "变动重量", type: "number", width: "110px", format: (_value, row) => formatWeight(row.weight_change_kg) },
  { key: "unit_cost", label: isSelectedFinishedWarehouse.value ? "材料成本" : "单价", type: "number", width: "116px", format: (_value, row) => formatSelectedUnitCost(row.unit_cost) },
  { key: "amount", label: "金额", type: "number", width: "104px", format: (_value, row) => ${formatAmount(row.amount)}` },
  { key: "source", label: "来源", detailOnly: true, format: (_value, row) => `${formatSourceDocTypeLabel(row.source_doc_type)} #${row.source_doc_id || "-"}` },
  { key: "remark", label: "备注", detailOnly: true }
]);
  • Step 3: Replace the lot detail table

Replace the current 库存批次明细 table markup with:

<DrawerDataTable
  v-model:expanded-rows="expandedLotRows"
  :rows="paginatedLots"
  :columns="lotDrawerColumns"
  row-key="lot_id"
  :empty-text="`暂无该${selectedDetailEntityLabel}库存批次`"
>
  <template #cell-status="{ row }">
    <span class="status-pill" :class="lotStatusClass(row)">{{ formatStatusLabel(row.status) }}</span>
  </template>
  <template #cell-lot_no="{ row }">
    <span class="drawer-cell-code" :title="row.lot_no">{{ row.lot_no || "-" }}</span>
  </template>
  <template #detail="{ row }">
    <div class="drawer-row-detail-grid">
      <article><span>仓库</span><strong>{{ row.warehouse_name || "-" }}</strong></article>
      <article><span>采购单号</span><strong>{{ row.source_purchase_order_no || "-" }}</strong></article>
      <article><span>入库单号</span><strong>{{ row.source_receipt_no || "-" }}</strong></article>
      <article><span>来源库存批次</span><strong>{{ row.source_material_summary || formatLotSourceLots(row) }}</strong></article>
      <article><span>运单号</span><strong>{{ row.logistics_waybill_no || "-" }}</strong></article>
      <article><span>运费</span><strong>{{ formatFreight(row.logistics_freight_amount) }}</strong></article>
      <article><span>质检放行人</span><strong>{{ row.item_type === "RAW_MATERIAL" ? formatLotQualityRelease(row) : "-" }}</strong></article>
    </div>
  </template>
</DrawerDataTable>
  • Step 4: Replace the transaction table

Replace the current 库存流水 table markup with:

<DrawerDataTable
  v-model:expanded-rows="expandedTxnRows"
  :rows="paginatedTransactions"
  :columns="txnDrawerColumns"
  row-key="inventory_txn_id"
  :empty-text="`暂无该${selectedDetailEntityLabel}库存流水`"
>
  <template #cell-lot_no="{ row }">
    <span class="drawer-cell-code" :title="row.lot_no || ''">{{ row.lot_no || "-" }}</span>
  </template>
  <template #detail="{ row }">
    <div class="drawer-row-detail-grid">
      <article><span>来源</span><strong>{{ formatSourceDocTypeLabel(row.source_doc_type) }} #{{ row.source_doc_id || "-" }}</strong></article>
      <article><span>备注</span><strong>{{ row.remark || "-" }}</strong></article>
    </div>
  </template>
</DrawerDataTable>
  • Step 5: Use a larger drawer size for complex inventory detail

Find the warehouse detail drawer container in InventoryLedgerView.vue. If it uses a custom dual drawer mask, add a class that enables fullscreen width:

<div v-if="detailDrawerOpen" class="workflow-drawer-mask dual-drawer-mask inventory-ledger-detail-mask" @click.self="closeDetailDrawer">

Add CSS:

.inventory-ledger-detail-mask .dual-drawer-layout {
  width: min(1760px, calc(100vw - 28px));
  max-width: calc(100vw - 28px);
}

.inventory-ledger-detail-mask .workflow-drawer {
  min-width: 0;
}
  • Step 6: Run focused frontend tests

Run:

cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-drawer-data-table.mjs
node scripts/test-inventory-ledger-options.mjs
npm run build

Expected:

  • Static drawer table test passes.
  • Existing inventory option test passes.
  • Vite build passes.

Task 5: Apply the Same Pattern to Other Drawers

Files:

  • Modify high-impact drawer pages after warehouse migration:

    • frontend/src/views/PurchaseOrderView.vue
    • frontend/src/views/DeliveryManagementView.vue
    • frontend/src/views/WorkOrderManagementView.vue
    • frontend/src/views/CompletionReceiptView.vue
    • frontend/src/components/ProductWorkflowDrawer.vue
  • Step 1: Identify drawer tables that exceed 7 columns

Run:

cd /Volumes/ForgeFlow-ERP
rg -n "<table class=\"data-table|<table class=\"data-table compact-table" frontend/src/views frontend/src/components

Expected: list all drawer tables. Prioritize tables inside FormDrawer, .workflow-drawer, or custom drawer masks.

  • Step 2: For each drawer table, classify columns

Use this rule:

Main table: 5-7 business-critical columns.
Detail row: long text, source documents, remarks, logistics, photos, secondary IDs.
Status: always visible as colored pill.
Money/quantity/weight: visible only when it is part of the user's immediate decision.
  • Step 3: Migrate one page at a time

For each target page:

  1. Import DrawerDataTable.
  2. Add expandedRows ref.
  3. Add columns computed.
  4. Replace raw drawer table with DrawerDataTable.
  5. Keep existing TableControls and PaginationBar.
  6. Run npm run build.

Example import and state:

import DrawerDataTable from "../components/DrawerDataTable.vue";

const expandedRows = ref([]);
  • Step 4: Stop when all common drawers follow the new pattern

Acceptance criteria:

  • No drawer table header displays vertically.
  • No drawer table requires horizontal scrolling for the default visible columns.
  • Long codes are shown as chips or ellipsis with title.
  • Secondary details are visible by expanding the row.

Task 6: Verification

Files:

  • Test: frontend/scripts/test-drawer-data-table.mjs

  • Test: frontend/scripts/test-inventory-ledger-options.mjs

  • Step 1: Run static and build checks

Run:

cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-drawer-data-table.mjs
node scripts/test-inventory-ledger-options.mjs
npm run build

Expected: all pass.

  • Step 2: Manual browser verification

Open the ERP in the browser and check:

1. 百华仓库 -> 成品库 -> 产品明细抽屉
2. 库存批次明细表
3. 库存流水表
4. 采购订单详情抽屉
5. 发货/销售出库详情抽屉

Expected:

  • No vertical table headers.
  • No ugly horizontal scrollbar for normal data.
  • Long batch numbers do not deform the table.
  • Row expand details reveal hidden fields clearly.
  • Pagination bar remains visually consistent.

Self-Review

  • Spec coverage: The plan covers drawer width, table header readability, horizontal-scroll reduction, long-code handling, detail expansion, and migration of warehouse plus other high-impact drawers.
  • Placeholder scan: No TBD, TODO, or unspecified “handle later” steps remain.
  • Type consistency: DrawerDataTable props are consistently named rows, columns, rowKey, expandedRows, and emptyText; slot names use cell-${column.key} and detail.
  • Risk note: The current project directory is mounted at /Volumes/ForgeFlow-ERP and is not detected as a git repository, so commit steps are intentionally omitted.