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
sizeprop while keeping the oldwideprop working. - Supported sizes:
normal,wide,xl,fullscreen.
- Add a
-
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_noandcell-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"orsize="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.
- Use
-
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.vueprops 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
DrawerDataTableand 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.vuefrontend/src/views/DeliveryManagementView.vuefrontend/src/views/WorkOrderManagementView.vuefrontend/src/views/CompletionReceiptView.vuefrontend/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:
- Import
DrawerDataTable. - Add
expandedRowsref. - Add
columnscomputed. - Replace raw drawer table with
DrawerDataTable. - Keep existing
TableControlsandPaginationBar. - 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:
DrawerDataTableprops are consistently namedrows,columns,rowKey,expandedRows, andemptyText; slot names usecell-${column.key}anddetail. - Risk note: The current project directory is mounted at
/Volumes/ForgeFlow-ERPand is not detected as a git repository, so commit steps are intentionally omitted.