{message}
+| 通知 | 狀態 | |
|---|---|---|
| 開繳通知 | +{sentLabel(data?.billing_opened?.sent_at)} | ++ + | +
| 逾期催繳 | +{sentLabel(data?.overdue?.sent_at)} | ++ {" "} + + | +
+ 要把 {period} 改回「未開繳」(成員暫時無法繳費)?請到「繳費審核」使用收回本期開繳 —— + 它會一併刪掉未繳/已退回的帳單,不會只留下對不起來的半套狀態。 +
+ + {open === "resend" &&無法重發:{NOTIFY_REASON_TEXT[preview.outcome] ?? preview.outcome}。
+ )} + {preview && !result && ready && ( + <> ++ 重發只會在繳費頻道再貼一次同樣的開繳通知。不會新增或修改任何帳單、不會改動方案定價, + 期別也全程維持在「已開繳」。成員會再被 @ 一次。 +
+ + > + )} +無法催繳:{NOTIFY_REASON_TEXT[preview.outcome] ?? preview.outcome}。
+ )} + {preview && !result && ready && ( + <> ++ 這會在繳費頻道公開 @ {period} 所有未繳成員(含已退回的),不受「逾期天數({preview.overdue_days} 天)」限制; + 每日自動催繳只會 @ 已超過逾期天數的人,所以這份名單通常比較長。送出後本期的催繳發送紀錄會更新為現在。 +
+ + > + )} ++ 修改金額即為該方案的新定價(下期沿用);已繳/已驗證的紀錄不受影響。下一步會先列出這次會改動的帳單與定價,確認後才真的送出。 +
++ {preview.will_notify + ? `送出後會在繳費頻道公開發出 ${preview.period} 的開繳通知並 @ 各方案身分組。` + : `送出後${preview.notify_reason === "already_sent" ? "不會再發通知" : "不會發出通知"}:${NOTIFY_REASON_TEXT[preview.notify_reason] ?? preview.notify_reason}。`} + {preview.frozen_count > 0 && ` 已繳/已驗證的 ${preview.frozen_count} 筆一律原樣保留。`} +
++ 收回後本期回到「未開繳」:刪掉的帳單不會被「重新同步本期」補回來,日後可以再次發起繳費(屆時會重新發送開繳通知)。 + 此期先前用「產生上傳連結」發出去的一次性連結會立即失效,對方點開只會看到連結無效。 + {preview.frozen_count > 0 && `已繳/已驗證的 ${preview.frozen_count} 筆一律原樣保留,重開本期也不會重複開帳單。`} + 已經發出的 Discord 開繳通知不會撤回,必要時請自行到頻道說明。 +
+``` + +同一個 modal 的「將刪除的帳單」標題已寫「(未繳/已退回)」,維持不動。 + +- [ ] **Step 5: typecheck + build** + +```bash +pnpm --filter @chippot/admin typecheck && pnpm --filter @chippot/admin build +``` +Expected: 兩者都通過、零錯誤。 + +- [ ] **Step 6: Commit(含 Task 10、11 的檔案)** + +```bash +git add packages/admin/src +git commit -m "feat(admin): 推播狀態與發起繳費改兩段式預覽、誠實回報筆數;收回文案補齊;工具列顯示持久狀態" +``` + +--- + +### Task 13: 文件 + +**Files:** +- Modify: `README.md:12, 218-226` +- Modify: `README.zh-TW.md:206-213` + +- [ ] **Step 1: 更新 `README.md`** + +`:221-222` 的 `發起繳費` 那段改成: + +```markdown +- **發起繳費** — confirm the selected period's per-plan amounts (any change becomes the plan's new + price), preview exactly which bills would be created/repriced and whether a notice goes out, then + apply. Defaults to the period being collected; pre-opening next month is an explicit opt-in. + Triggerable from the admin Settings or Discord's `/發起繳費` (which takes an optional 期別 and + refuses when a workspace has more than 5 active plans — use the admin UI there). +``` + +`:225-226` 的 `Push status` 那段改成: + +```markdown +- **Push status** — the dashboard shows whether the billing-opened / overdue notices went out, with + **重發開繳通知** (re-posts the notice only — never creates bills, never clears the open marker), + **催繳未繳成員** (@s every unpaid member regardless of 逾期天數, unlike the cron) and + **重置催繳發送紀錄**. All three preview first and report the real counts. Reopening/closing a + period lives in 收回本期開繳 on the payments page, not here. +``` + +- [ ] **Step 2: 更新 `README.zh-TW.md`** + +`:209-210` 改成: + +```markdown +- **發起繳費** — 確認所選期別各方案金額(任何更動就是該方案的新定價),先看「會建立/改價哪些帳單、 + 定價 before→after、是否會發通知」的預覽,確認後才送出。預設期別是**目前收款中的那一期**, + 「預開下期」是要自己勾的次要選項。可從後台「設定」或 Discord 的 `/發起繳費` 觸發 + (後者可帶 `期別`,方案超過 5 個時會直接請你改用後台)。 +``` + +`:213` 改成: + +```markdown +- **推播狀態** — 看板顯示開繳/逾期通知是否已發,並提供 **重發開繳通知**(只重貼公告,不建帳單、 + 不會讓期別短暫變回未開繳)、**催繳未繳成員**(@ 全部未繳者,與只 @ 逾期者的每日 cron 不同) + 與 **重置催繳發送紀錄**。三者都先預覽再確認,並回報真實筆數。要把期別改回未開繳請用 + 「繳費審核 → 收回本期開繳」。 +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md README.zh-TW.md +git commit -m "docs(readme): 更新推播狀態與發起繳費的行為說明(issue #43)" +``` + +--- + +### Task 14: 驗收 —— 全套測試、typecheck、build、CDP 人工證據 + +**Files:** 無(只跑驗證) + +- [ ] **Step 1: 後端全套** + +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot/packages/worker && npx vitest run +``` +Expected: 全綠,且 **tests 總數 > 300**(基準線)。把實際數字記下來 —— 下一步要寫進 badge。 + +- [ ] **Step 2: 更新測試數 badge** + +把 `README.md:12` 的 `tests-300%20passing` 改成 Step 1 實測的數字。**不要猜**,用 vitest 印出的那個數。 + +```bash +git add README.md && git commit -m "docs(readme): 更新測試數 badge" +``` + +- [ ] **Step 3: 全 repo typecheck 與 build** + +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +pnpm -r typecheck && pnpm -r build +``` +Expected: 兩者皆零錯誤。 + +- [ ] **Step 4: 確認沒有殘留的舊模式** + +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +grep -rn 'window.confirm' packages/admin/src packages/web/src # 必須 0 筆 +grep -rn 'var(--danger' packages/admin/src --include='*.tsx' # 必須 0 筆 +grep -rn 'force: true' packages/worker/src/core/billing.ts # 必須 0 筆 +grep -rn 'nextBillingPeriod' packages/worker/src/adapters # 必須 0 筆 +``` + +- [ ] **Step 5: CDP 人工驗收(admin 沒有測試框架,這是它的證據)** + +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot/packages/admin && pnpm dev +``` + +用 Chrome DevTools MCP 對 dev server 逐項確認並**留下截圖**: + +1. **看板 → 推播狀態**:`開繳通知` 那列**只有一顆**「重發開繳通知…」,沒有任何「重置」;`逾期催繳` 那列有「催繳未繳成員…」與「重置催繳發送紀錄…」。表格下方有指向「收回本期開繳」的說明。 +2. 點「重發開繳通知…」在一個**未開繳**的期別 → modal 顯示「無法重發:此期尚未開繳」,且**沒有**確認按鈕。 +3. 點「催繳未繳成員…」→ 先看到名單與「不受逾期天數限制」的說明,確認鈕是紅的且字樣含人數。 +4. **設定 → 工具 → 發起繳費…** → 期別預設值等於看板的期別(不是下個月);勾「預開下期」才會跳到下個月;按「預覽影響…」後看到四張統計卡與 DiffList,確認鈕文字依 `will_notify` 變化。 +5. **繳費審核**:hover「收回本期開繳」看到新 tooltip(含「已退回」與「回到未開繳」);開啟 modal 看到「一次性連結會立即失效」那句。 +6. **成員/訂閱/方案/支付渠道**四張表的「刪除」是紅字;點下去出現 modal(不是瀏覽器原生 confirm),確認鈕是 `btn--danger` 的紅字(**不是** `#c0392b` 那個橘紅)。 +7. **設定 → 工具**:三列都有「目前:已張貼/尚未張貼/已註冊…」的狀態行;「匯入…」按鈕**不再是紅色**。 +8. **零橫向溢出回歸**:在 375px 與 768px 下,對每個新/改動過的 modal 執行 + ```js + document.documentElement.scrollWidth === document.documentElement.clientWidth + ``` + Expected: 全部 `true`。 + +- [ ] **Step 6: 建 PR** + +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +git push -u origin ux/43-danger-actions +``` + +PR body(zh-TW)必須包含: +- `Closes #43` +- 一段 **Breaking changes** 明列三件事:`initiateBillingOpened` 移除 `opts.force`;`sendOverdueForPeriod` 改回傳 `OverdueResult`;`POST /admin/billing/initiate` 與 `POST /admin/notifications/resend` 的 `dry_run` **預設為 true**(既有腳本若直接打這兩支 API,必須改帶 `dry_run: false` 才會實際執行)。 +- 一段說明 `POST /admin/notifications/reset` 對 `billing_opened` 改回 409 是刻意的行為移除。 +- Step 5 的截圖。 + +--- + +## UI 終態契約(批次 B/C/D 依此建構) + +> 這一節是本批次收工後 UI 的**權威描述**。之後的批次若要改動這裡列出的字串或流程,必須明確說明是在取代哪一條。 + +### 1. 按鈕標籤(精確字面) + +| 位置 | 標籤 | class | 確認方式 | +|---|---|---|---| +| 看板 → 推播狀態 → 開繳通知 | `重發開繳通知…` | `btn btn--danger` | 兩段式 modal `重發開繳通知 · {period}` | +| 看板 → 推播狀態 → 逾期催繳 | `催繳未繳成員…` | `btn btn--danger` | 兩段式 modal `催繳未繳成員 · {period}` | +| 看板 → 推播狀態 → 逾期催繳 | `重置催繳發送紀錄…` | `btn btn--danger` | `ConfirmDanger`,確認鈕 `確認重置` | +| 繳費審核 工具列 | `重新同步本期` | `btn` | 既有 `SyncModal`(不動) | +| 繳費審核 工具列 | `收回本期開繳` | `btn btn--danger` | 既有 `RetractModal`(文案已補) | +| 設定 → 工具 | `發起繳費…` | `btn btn--sm btn--danger` | 兩段式 `InitiateModal` | +| 設定 → 工具 | `匯入…` | `btn btn--sm`(**去紅**) | 既有 `ImportModal`(不動) | +| 設定 → 工具 | `重建` / `張貼/更新` / `註冊` | `btn btn--sm` | 無(各列上方有「目前:…」狀態行) | +| 成員/訂閱/方案/渠道 列內 | `刪除` | `btn btn--danger` | `ConfirmDanger`,確認鈕 `確認刪除` | +| 編輯成員 modal | `解除綁定` | `btn btn--danger` | `ConfirmDanger`,確認鈕 `確認解除綁定` | +| 繳費詳情 modal | `刪除此筆` | `btn btn--danger` | `ConfirmDanger`,確認鈕 `確認刪除` | + +### 2. 移除/搬移的項目 + +| 原本 | 現在 | +|---|---| +| 看板 → 推播狀態 → 開繳通知列的 `重置` 按鈕 | **刪除**。後端 `POST /admin/notifications/reset` 收到 `billing_opened` 回 **409**。 | +| 看板 → 推播狀態 → 開繳通知列的 `立即重發` | 改名 `重發開繳通知…`,並改走只重貼公告的路徑(不再建帳單、不再 delete-then-claim marker)。 | +| 看板 → 推播狀態 → 逾期催繳列的 `立即重發` | 改名 `催繳未繳成員…`(名實相符:@ 全部未繳者)。 | +| 看板 → 推播狀態的 `✓ 完成` | **刪除**。改為各 modal 內回報真實筆數(`✓ 已在頻道催繳 N 位成員。` 等)。 | +| `Dashboard.tsx` 內的 `PushStatus` | 搬到 `packages/admin/src/views/PushStatus.tsx`。 | +| `Manage.tsx` 內的 `ConfirmDelete` | 搬到 `packages/admin/src/ui.tsx`,更名 `ConfirmDanger`,可自訂確認字樣。 | +| `var(--danger, #c0392b)` | **刪除**,全站唯一的紅是 `--red`。 | +| `window.confirm`(2 處) | **刪除**,改 `ConfirmDanger`。 | + +### 3. Modal 流程 + +**`重發開繳通知 · {period}`**(兩段式) +1. 開啟即 `dry_run: true`。未開繳 → 只顯示「無法重發:此期尚未開繳。」,**無確認鈕**。 +2. 可重發 → 統計卡 `📣 公告方案`+DiffList「通知會列出的方案」+一段「重發只會再貼一次公告,不會新增或修改帳單、不會改定價、期別全程維持已開繳」+紅鈕 `確認重發通知`。 +3. 結果:`✓ 已在頻道重發 {period} 開繳通知(列出 N 個方案)。` 或 `未發送:{中文原因}`。 + +**`催繳未繳成員 · {period}`**(兩段式) +1. `dry_run: true` 取名單。 +2. 統計卡 `🔔 會 @ 的成員` / `💰 未繳總額`+DiffList(未綁定者標「(未綁定,@ 不到)」)+一段講清楚「不受逾期天數(N 天)限制,與每日自動催繳名單不同」+紅鈕 `確認催繳這 N 位`。 +3. 結果:`✓ 已在頻道催繳 N 位成員。` 或 `未發送:{中文原因}`。 + +**`重置催繳發送紀錄 · {period}`**(ConfirmDanger) +- 內文明講:只影響催繳去重紀錄、不動帳單、不影響開繳狀態、下次符合逾期條件會再送一次。 +- 結果:`✓ 已重置催繳發送紀錄(刪除 N 筆)。` + +**`發起繳費 · {period}`**(兩段式,三個畫面) +1. 表單:期別(預設 `periodForBillingDay`)+「預開下期({next})」checkbox(僅當 next ≠ current 才出現)+各方案金額+主要鈕 `預覽影響…`。 +2. 預覽:四張統計卡(`➕ 新增帳單` / `🔄 改價` / `🏷️ 方案改價` / `🔒 保留(已繳)`)+三個 DiffList(方案定價 before→after、將建立的帳單、將改價的待繳帳單)+一段「是否會發通知」+`回上一步`(`btn`)與紅鈕 `確認發起並通知` / `確認發起(不會發通知)`。 +3. 結果:`✓ 已發起 {period}:新增 N 筆帳單、改價 N 筆、更新 N 個方案定價。…` + `前往繳費審核` 按鈕(`btn btn--primary`,設 `location.hash = "payments"`)。 + +**`收回本期開繳 · {period}`**(既有,只改文案) +- tooltip:`刪除本期未繳/已退回帳單,期別回到未開繳` +- 內文新增一句:此期先前發出的一次性上傳連結會**立即失效**。 + +### 4. Discord 側 + +- `/發起繳費` 指令描述:`(管理員)確認指定期別各方案金額並發出開繳通知` +- 新增 optional option `期別`,description `YYYY-MM(留空=目前收款中的期別)` +- 預設期別:`periodForBillingDay(billing_day)`(與後台一致) +- 啟用方案 > 5 → ephemeral 拒絕:`目前有 N 個啟用中的方案,超過 Discord 表單的 5 欄上限,無法在這裡確認全部金額。請改用後台「設定 → 工具 → 發起繳費」。` +- 期別格式錯誤 → ephemeral:`` 期別格式需為 `YYYY-MM`,例如 `2026-07`。 `` +- 送出成功文案帶期別與建立筆數(不再說「本期」)。 + +### 5. API 契約 + +| Endpoint | 變更 | +|---|---| +| `POST /admin/notifications/resend` | 新增 `dry_run`(**預設 true**)。`billing_opened` 未開繳 → **409**。回傳 `{ ok, dry_run, outcome, sent, lines }`(billing_opened)或 `{ ok, dry_run, outcome, count, overdue_days, people }`(overdue)。 | +| `POST /admin/notifications/reset` | `type: "billing_opened"` → **409**。`overdue` 行為不變。 | +| `POST /admin/billing/initiate` | 新增 `dry_run`(**預設 true**)。dry run 回 `InitiatePreview`;apply 回 `{ ok, sent, updated_plans, created_payments, updated_payments }`。 | +| `POST /admin/upload-link` | period 格式錯誤 → **400**。 | +| `POST /upload/:token` | 期別未開繳 → **409** `{ error: "本期繳費尚未開放…", code: "payment" }`。 | +| `POST /admin/discord/register-commands` | 回傳新增 `registered_at`;寫入 `settings.discord_commands_registered_at`。 | + +### 6. 這批次**沒有**改的東西(B/C/D 可以放心接手) + +- `重新同步本期` 的按鈕字面、`SyncModal` 的流程與文案(含 `移除(已退訂)` 這個 P0-7 的錯字 —— 屬批次 D)。 +- 全站「本期 vs 此期」的用詞不一致(D3)、`核准/驗證` 四種說法(D1)、`已繳 vs 已繳待驗`(D2)—— 全屬批次 D。 +- `routes/admin.ts` 既有的英文錯誤訊息(D14)—— 本批次只把**新增**的訊息寫成中文。 +- 行動版斷點、`.tbl-cards` 範圍、focus 樣式、對比度 —— 全屬批次 B。 +- 成員回饋迴路(receipt 通知、成員 web 頁的英文錯誤)—— 全屬批次 C。 + +--- + +## Self-Review + +**1. Spec coverage** —— issue #43 逐項對照: + +| 項目 | Task | +|---|---| +| P0-1 重置行為分離 | Task 4(後端 409)+ Task 11(UI 移除 billing_opened 的重置、overdue 改名加 confirm、指向收回) | +| P0-2(a) resend 409 | Task 1(只重貼的新函式)+ Task 4(409 閘門) | +| P0-2(b) 發起繳費兩段式預覽+預設期別+預開下期 | Task 5(後端 dry-run)+ Task 12(前端) | +| P0-3 Discord >5 方案 | Task 7 | +| P0-4 收回 tooltip + modal 文案 | Task 12 Step 4 | +| A1 誠實回報 | Task 1/3(後端 outcome)+ Task 10(中文對照)+ Task 11/12(UI 用真數字) | +| A2 立即重發語意 | Task 3(dry-run + overdue_days 帶回)+ Task 11(改名+預覽講清與 cron 的差異) | +| A3 web token 閘門+period 驗證 | Task 6 | +| A4 一種紅、一種確認 | Task 9 | +| A5 「本期」名不符實 | Task 7(Discord)+ Task 12 Step 2(後台) | +| A6 立即執行的持久狀態 | Task 8(後端時間戳)+ Task 12 Step 3(三列狀態行) | +| A7 Discord 期別選項 | Task 7(**未 descope**) | + +全部有歸屬,無缺口。 + +**2. Placeholder scan** —— 全文無 TBD/TODO/「類似 Task N」/「加上適當的錯誤處理」。每個 code step 都有可直接貼上的完整程式碼;每個測試 step 都有完整測試檔或完整的 diff 片段。 + +**3. Type consistency** —— 交叉核對過: +- `ResendBillingResult { outcome, sent, lines }`:Task 1 定義 → Task 4 route 展開 → Task 10 `ResendBillingPreview` → Task 11 消費。名稱一致。 +- `OverdueResult { notified, outcome, overdue_days, people }`:Task 3 定義 → Task 3 cron 用 `.notified` → Task 4 route 映射成 `count` → Task 10 `OverduePreview.count` → Task 11 用 `r.count` 與 `preview.people`。route 那層的 `notified → count` 改名是刻意的(維持既有 API 欄位名),已在 Task 4 的 Interfaces 明講。 +- `InitiateResult { sent, updatedPlans, createdPayments, updatedPayments }`:Task 2 定義 → Task 5 route 轉 snake_case → Task 10 `InitiateApplied` → Task 12 消費。 +- `InitiatePreview`:Task 5(worker)與 Task 10(admin)**同名不同檔**,欄位逐一相同(`period, opened, will_notify, notify_reason, plan_changes, create, reprice, frozen_count`)。 +- `ConfirmDanger` 的 props(`title/message/confirmLabel/busyLabel/onClose/onConfirm`)在 Task 9 定義,Task 9 與 Task 11 的所有呼叫端都只用這六個。 +- `previewBillingInitiate` / `resendBillingOpenedNotice` / `sendOverdueForPeriod` 三個函式名在計畫全文拼寫一致。 diff --git a/packages/admin/src/api.ts b/packages/admin/src/api.ts index 37ffe1c..843f4c3 100644 --- a/packages/admin/src/api.ts +++ b/packages/admin/src/api.ts @@ -1,5 +1,18 @@ const BASE = "/api/admin"; +/** + * What every failed call throws. `status` is what lets a caller tell an EXPECTED refusal apart from + * a real failure: 重發開繳通知 has to render 「此期尚未開繳」 as a state of its screen, and the worker + * reports that as a 409 rather than as an outcome in the body (routes/admin.ts notificationsResend). + * Callers that only read `.message` are unaffected. + */ +export class ApiError extends Error { + constructor(message: string, readonly status: number) { + super(message); + this.name = "ApiError"; + } +} + async function req{message}
+