diff --git a/docs/superpowers/plans/2026-10-14-payment-card-period-collapse.md b/docs/superpowers/plans/2026-10-14-payment-card-period-collapse.md new file mode 100644 index 00000000..1c370e20 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-payment-card-period-collapse.md @@ -0,0 +1,471 @@ +# Payment List Card Period Collapse 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:** 市场【缴费信息】列表卡片按期折叠展示,默认只显示「第几期 / 应缴 / 已缴」,点击可独立展开明细。 + +**Architecture:** 对齐运费 `sampleBlocks`:`paymentCard` 产出 `periodBlocks` + `footerFields`(累计实缴等放在期次区之后);`MarketListCard` 渲染每期独立折叠行;样式对齐 Demo。不改后端与催缴模块。 + +**Tech Stack:** Vue 3, Vant 4, CSS, Node.js `node:test` + +**Spec:** `docs/superpowers/specs/2026-10-14-payment-card-period-collapse-design.md` +**Demo:** `docs/demos/2026-10-14-payment-card-period-collapse-demo.html` + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| `mobile-oa-h5/src/market/list-presenter.js` | `isPaymentPeriodPresent` + `paymentPeriodBlock` + rewrite `paymentCard` | +| `mobile-oa-h5/src/market/list-presenter.test.js` | presenter 行为断言 | +| `mobile-oa-h5/src/market/components/MarketListCard.vue` | 期次折叠 UI + `footerFields` | +| `mobile-oa-h5/src/styles/market.css` | `.market-list-card__periods*` 样式 | +| `mobile-oa-h5/src/market/mobile-parity-ui.test.js` | 结构 / 默认折叠 / 44px 触控 | + +--- + +### Task 1: Presenter — failing tests for `periodBlocks` + +**Files:** +- Modify: `mobile-oa-h5/src/market/list-presenter.test.js` +- Test: `mobile-oa-h5/src/market/list-presenter.test.js` + +- [ ] **Step 1: Update existing payment card test and add period-block cases** + +Replace the assertions in `test('payment card formats summary totals and progress', …)` that expect flat period fields, and add a focused test. Keep the existing fixture; change expectations as follows: + +```js +test('payment card formats summary totals and progress', () => { + const card = buildMarketCard('payment', 'manager', { + protocolNo: 'P-1', motherName: '周雨桐', sampleCode: 'S-1', payMode: 'fq', + contractAmount: '10000.00', paidAmount: '3000.00', remainAmount: '7000.00', + signDate: '2026-10-01', storageYears: 20, sampleTypeName: '脐带血', + discountAmount: '1000.00', receivableAmount: '9000.00', remark: '首期已缴', + periods: [{ scheduleNo: 1, dueAmount: 3000, dueDate: '2026-10-10', displayPaidAmount: 3000, payDate: '2026-10-11', arrearsAmount: 0 }], + clearedScheduleCount: 1, activeScheduleCount: 4, paymentProgress: '部分已缴', + allowedActions: ['register-transfer'] + }) + assert.equal(card.status, '部分已缴') + assert.equal(card.subtitle, 'S-1') + assert.ok(card.fields.some((field) => field.label === '样本编码' && field.value === 'S-1')) + assert.ok(card.fields.some((field) => field.label === '协议号' && field.value === 'P-1')) + assert.ok(card.fields.some((field) => field.label === '签署日期' && field.value === '2026-10-01')) + assert.ok(card.fields.some((field) => field.label === '存储项目' && field.value === '脐带血')) + assert.ok(!card.fields.some((field) => String(field.label).includes('第1期'))) + assert.equal(card.periodBlocks.length, 1) + assert.equal(card.periodBlocks[0].scheduleNo, 1) + assert.equal(card.periodBlocks[0].dueAmount, '¥3,000.00') + assert.equal(card.periodBlocks[0].paidAmount, '¥3,000.00') + assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '应缴费时间' && f.value === '2026-10-10')) + assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '已缴费时间' && f.value === '2026-10-11')) + assert.ok(card.periodBlocks[0].fields.some((f) => f.label === '欠缴费金额' && f.value === '¥0.00')) + assert.ok(card.footerFields.some((field) => field.label === '待缴金额' && field.value === '¥7,000.00')) + assert.ok(card.footerFields.some((field) => field.label === '累计实缴' && field.value === '¥3,000.00')) + assert.ok(card.footerFields.some((field) => field.label === '备注' && field.value === '首期已缴')) + assert.deepEqual(card.actions, ['view-protocol', 'view-payment-detail']) +}) + +test('payment card periodBlocks skip empty periods and do not pad to six', () => { + const card = buildMarketCard('payment', 'specialist', { + motherName: '李婷', sampleCode: 'S-2', payMode: 'fq', + periods: [ + { scheduleNo: 1, dueAmount: 1000, dueDate: '2026-01-01', paidAmount: 1000, payDate: '2026-01-02', arrearsAmount: 0 }, + {}, + { scheduleNo: 3, dueAmount: 2000, dueDate: '2026-07-01', paidAmount: 0, arrearsAmount: 2000 }, + null, + undefined + ] + }) + assert.equal(card.periodBlocks.length, 2) + assert.equal(card.periodBlocks[0].key, 'period-1') + assert.equal(card.periodBlocks[1].scheduleNo, 3) + assert.equal(card.periodBlocks[1].dueAmount, '¥2,000.00') + assert.equal(card.periodBlocks[1].paidAmount, '¥0.00') + assert.ok(!card.fields.some((field) => /第\d+期/.test(field.label))) +}) +``` + +- [ ] **Step 2: Run tests and verify RED** + +```bash +cd mobile-oa-h5 && node --test --test-name-pattern="payment card" src/market/list-presenter.test.js +``` + +Expected: FAIL — `periodBlocks` / `footerFields` undefined, or flat `第1期·` fields still present. + +--- + +### Task 2: Presenter — implement `periodBlocks` + `footerFields` + +**Files:** +- Modify: `mobile-oa-h5/src/market/list-presenter.js` (`paymentCard` and helpers near it) + +- [ ] **Step 1: Add helpers and rewrite `paymentCard`** + +Insert before `paymentCard` (after `chaseCard`): + +```js +function hasPresentValue(value) { + if (value == null) return false + if (typeof value === 'number') return Number.isFinite(value) + return String(value).trim() !== '' +} + +function isPaymentPeriodPresent(period) { + if (!period || typeof period !== 'object') return false + return ( + hasPresentValue(period.scheduleNo) || + hasPresentValue(period.dueAmount) || + hasPresentValue(period.displayPaidAmount) || + hasPresentValue(period.paidAmount) || + hasPresentValue(period.arrearsAmount) || + hasPresentValue(period.dueDate) || + hasPresentValue(period.payDate) + ) +} + +function paymentPeriodBlock(period, index) { + const scheduleNo = Number(period.scheduleNo) || index + 1 + const paidRaw = period.displayPaidAmount ?? period.paidAmount + return { + key: String(period.id || `period-${scheduleNo}`), + scheduleNo, + dueAmount: money(period.dueAmount), + paidAmount: money(paidRaw), + fields: [ + field('应缴费时间', period.dueDate), + field('已缴费时间', period.payDate), + field('欠缴费金额', money(period.arrearsAmount), { money: true }) + ] + } +} + +function paymentCard(item) { + const progress = label({}, item.paymentProgress || item.progress || (Number(item.paidAmount || 0) > 0 ? '部分已缴' : '待缴清')) + const periods = (Array.isArray(item.periods) ? item.periods : []).filter(isPaymentPeriodPresent) + return { + title: text(item.motherName), + subtitle: text(item.sampleCode), + status: progress, + fields: [ + field('协议号', item.protocolNo, { wide: true }), + field('姓名', item.motherName), field('手机号', item.motherPhone), + field('样本编码', item.sampleCode, { wide: true }), field('签署日期', item.signDate), + field('存储年限', item.storageYears), field('存储项目', item.sampleTypeName, { wide: true }), + field('合同总额', money(item.contractAmount), { money: true }), + field('缴费方式', label(PAY_MODE_LABELS, item.payMode), { tag: true }), + field('优惠金额', money(item.discountAmount), { money: true }), + field('应收总额', money(item.receivableAmount), { money: true }) + ], + periodBlocks: periods.map(paymentPeriodBlock), + footerFields: [ + field('累计实缴', money(item.paidAmount), { money: true }), + field('待缴金额', money(item.dueRemainAmount ?? item.remainAmount), { money: true }), + field('备注', item.remark, { wide: true }) + ], + actions: ['view-protocol', 'view-payment-detail'] + } +} +``` + +Remove the old `Array.from({ length: 6 }, …)` + `periodFields` flatMap implementation entirely. + +- [ ] **Step 2: Run tests and verify GREEN** + +```bash +cd mobile-oa-h5 && node --test --test-name-pattern="payment card" src/market/list-presenter.test.js +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add mobile-oa-h5/src/market/list-presenter.js mobile-oa-h5/src/market/list-presenter.test.js +git commit -m "$(cat <<'EOF' +feat(mobile-oa-h5): present payment periods as collapsible periodBlocks + +Stop flattening six installment field groups into list cards; emit periodBlocks and footerFields instead. +EOF +)" +``` + +--- + +### Task 3: UI parity tests for period collapse (RED) + +**Files:** +- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js` + +- [ ] **Step 1: Add parity test** + +```js +test('payment list cards collapse period blocks by default and allow independent expand', () => { + const card = source('./components/MarketListCard.vue') + const presenter = source('./list-presenter.js') + const styles = source('../styles/market.css') + + assert.match(presenter, /periodBlocks:\s*periods\.map\(paymentPeriodBlock\)/) + assert.match(presenter, /footerFields:/) + assert.match(card, /card\.periodBlocks/) + assert.match(card, /card\.footerFields/) + assert.match(card, /periodOpenKeys/) + assert.match(card, /aria-expanded/) + assert.match(card, /应缴费金额/) + assert.match(card, /已缴费金额/) + assert.match(styles, /\.market-list-card__periods\s*\{/) + assert.match(styles, /\.market-list-card__period-toggle\s*\{[\s\S]*?min-height:\s*44px;/) +}) +``` + +- [ ] **Step 2: Run and verify RED** + +```bash +cd mobile-oa-h5 && node --test --test-name-pattern="payment list cards collapse period" src/market/mobile-parity-ui.test.js +``` + +Expected: FAIL — template/CSS not yet present. + +--- + +### Task 4: MarketListCard — render periods + footerFields + +**Files:** +- Modify: `mobile-oa-h5/src/market/components/MarketListCard.vue` + +- [ ] **Step 1: Insert period section and footer fields in template** + +After the existing `card.fields` block and **before** the `sampleBlocks` section, insert: + +```vue +
+
+ +
+
+
+ {{ field.label }} + {{ field.value }} +
+
+
+
+
+ +
+
+ {{ field.label }} + {{ field.value }} + {{ field.value }} +
+
+``` + +- [ ] **Step 2: Add script state** + +Alongside `samplesOpen`, add: + +```js +const periodOpenKeys = ref({}) + +function togglePeriod(key) { + periodOpenKeys.value = { + ...periodOpenKeys.value, + [key]: !periodOpenKeys.value[key] + } +} + +watch( + () => props.item?.id || props.index, + () => { + samplesOpen.value = false + periodOpenKeys.value = {} + } +) +``` + +Merge with the existing `samplesOpen` watch — keep a **single** watch that resets both `samplesOpen` and `periodOpenKeys`. Delete the old watch that only cleared `samplesOpen`. + +- [ ] **Step 3: Do not run full GREEN yet** — CSS still missing; proceed to Task 5. + +--- + +### Task 5: CSS for period rows + +**Files:** +- Modify: `mobile-oa-h5/src/styles/market.css` + +- [ ] **Step 1: Add styles after `.market-list-card__fields--compact` block** + +```css +.market-list-card__periods { + margin: 0 0 12px; + overflow: hidden; + border: 1px solid #eef4f1; + border-radius: 12px; + background: #fafcfb; +} + +.market-list-card__period + .market-list-card__period { + border-top: 1px solid #eef4f1; +} + +.market-list-card__period-toggle { + display: grid; + width: 100%; + min-height: 44px; + grid-template-columns: 64px 1fr 1fr 20px; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 0; + background: transparent; + color: var(--market-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.market-list-card__period-toggle:active { + background: #f0f7f3; +} + +.market-list-card__period-no { + font-size: 13px; + font-weight: 700; + color: var(--market-primary-dark); +} + +.market-list-card__period-col { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.market-list-card__period-col > span { + color: var(--market-muted); + font-size: 11px; +} + +.market-list-card__period-col > b { + overflow: hidden; + color: #c98312; + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-list-card__period-chevron { + color: var(--market-muted); + font-size: 14px; + transition: transform 0.2s ease; +} + +.market-list-card__period.is-open .market-list-card__period-chevron { + transform: rotate(180deg); +} + +.market-list-card__period-body { + padding: 0 12px 12px; + border-top: 1px dashed #eef4f1; +} +``` + +Align with Demo; use existing token vars where already defined (`--market-text`, `--market-muted`, `--market-primary-dark`). Money color `#c98312` matches `.market-list-card__field b.is-money`. + +- [ ] **Step 2: Run parity + presenter tests GREEN** + +```bash +cd mobile-oa-h5 && node --test --test-name-pattern="payment card|payment list cards collapse period" src/market/list-presenter.test.js src/market/mobile-parity-ui.test.js +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add mobile-oa-h5/src/market/components/MarketListCard.vue mobile-oa-h5/src/styles/market.css mobile-oa-h5/src/market/mobile-parity-ui.test.js +git commit -m "$(cat <<'EOF' +feat(mobile-oa-h5): collapse payment installment rows on list cards + +Render periodBlocks as independent expandable summary rows with footer totals after the period section. +EOF +)" +``` + +--- + +### Task 6: Full market suite + build verification + +**Files:** +- Verify only + +- [ ] **Step 1: Run all market tests** + +```bash +cd mobile-oa-h5 && node --test src/market/*.test.js +``` + +Expected: zero failures. + +- [ ] **Step 2: Production build** + +```bash +cd mobile-oa-h5 && pnpm build +``` + +Expected: Vite exit 0. + +- [ ] **Step 3: Manual smoke (optional but recommended)** + +Open市场专员 / 地区经理【缴费信息】列表:卡片默认矮;点第 N 期展开三明细;切换列表项后折叠状态重置;无数据期次不出现。 + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +|------------------|------| +| 折叠行:期数 / 应缴 / 已缴 | Task 4 | +| 展开:时间 + 欠缴 | Task 2 + 4 | +| 默认折叠、独立多开 | Task 4 (`periodOpenKeys`) | +| 不 pad 到 6、跳过空期 | Task 1–2 | +| `footerFields` 在期次后 | Task 2 + 4 | +| 仅 payment 列表 | Task 2(仅改 `paymentCard`) | +| 44px 触控 | Task 5 | +| 测试 | Task 1, 3, 6 |