oa-related-approval-picker-card-demo.html 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1" />
  6. <title>关联审批单 · 选单卡片模式 Demo</title>
  7. <style>
  8. :root {
  9. --primary: #10b981;
  10. --primary-dark: #059669;
  11. --primary-bg: #ecfdf5;
  12. --primary-border: #a7f3d0;
  13. --text: #1e293b;
  14. --text-muted: #64748b;
  15. --text-faint: #94a3b8;
  16. --border: #e2e8f0;
  17. --border-soft: #f1f5f9;
  18. --bg: #f1f5f9;
  19. --surface: #ffffff;
  20. --warn: #d97706;
  21. --danger: #ef4444;
  22. --success: #059669;
  23. --info: #64748b;
  24. --shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
  25. --radius: 10px;
  26. }
  27. * { box-sizing: border-box; }
  28. body {
  29. margin: 0;
  30. min-height: 100vh;
  31. font-family: Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
  32. font-size: 14px;
  33. line-height: 1.5;
  34. color: var(--text);
  35. background:
  36. radial-gradient(1200px 400px at 10% -10%, #d1fae5 0%, transparent 55%),
  37. radial-gradient(900px 360px at 100% 0%, #e0f2fe 0%, transparent 50%),
  38. var(--bg);
  39. }
  40. .page {
  41. max-width: 960px;
  42. margin: 0 auto;
  43. padding: 28px 20px 80px;
  44. }
  45. h1 {
  46. margin: 0 0 6px;
  47. font-size: 22px;
  48. font-weight: 700;
  49. color: var(--text);
  50. }
  51. .lead {
  52. margin: 0 0 20px;
  53. color: var(--text-muted);
  54. max-width: 720px;
  55. }
  56. .notes {
  57. margin-bottom: 20px;
  58. padding: 12px 14px;
  59. border: 1px dashed var(--primary-border);
  60. border-radius: var(--radius);
  61. background: #fff;
  62. color: var(--text);
  63. font-size: 13px;
  64. }
  65. .notes strong { color: var(--primary-dark); }
  66. .notes ul { margin: 8px 0 0; padding-left: 18px; }
  67. .notes li { margin: 2px 0; }
  68. /* Dialog shell */
  69. .dialog {
  70. background: var(--surface);
  71. border-radius: 12px;
  72. box-shadow: var(--shadow);
  73. border: 1px solid var(--border);
  74. overflow: hidden;
  75. display: flex;
  76. flex-direction: column;
  77. max-height: calc(100vh - 120px);
  78. }
  79. .dialog__header {
  80. display: flex;
  81. align-items: center;
  82. justify-content: space-between;
  83. padding: 14px 18px;
  84. border-bottom: 1px solid var(--border);
  85. }
  86. .dialog__title {
  87. margin: 0;
  88. font-size: 16px;
  89. font-weight: 600;
  90. }
  91. .dialog__close {
  92. width: 28px;
  93. height: 28px;
  94. border: 0;
  95. border-radius: 6px;
  96. background: transparent;
  97. color: var(--text-muted);
  98. font-size: 18px;
  99. cursor: pointer;
  100. }
  101. .dialog__close:hover { background: var(--border-soft); }
  102. .dialog__body {
  103. padding: 0;
  104. overflow: auto;
  105. flex: 1;
  106. }
  107. .dialog__footer {
  108. display: flex;
  109. align-items: center;
  110. justify-content: space-between;
  111. gap: 12px;
  112. padding: 12px 18px;
  113. border-top: 1px solid var(--border);
  114. background: #f8fafc;
  115. }
  116. .dialog__footer-left {
  117. color: var(--text-muted);
  118. font-size: 13px;
  119. }
  120. .dialog__footer-left em {
  121. font-style: normal;
  122. color: var(--primary-dark);
  123. font-weight: 600;
  124. }
  125. .dialog__footer-right { display: flex; gap: 8px; }
  126. .btn {
  127. display: inline-flex;
  128. align-items: center;
  129. gap: 6px;
  130. height: 36px;
  131. padding: 0 14px;
  132. border-radius: 8px;
  133. border: 1px solid var(--border);
  134. background: #fff;
  135. color: var(--text);
  136. font-size: 14px;
  137. cursor: pointer;
  138. }
  139. .btn:hover { background: #f8fafc; }
  140. .btn--primary {
  141. border-color: var(--primary);
  142. background: var(--primary);
  143. color: #fff;
  144. }
  145. .btn--primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
  146. .btn--primary:disabled {
  147. opacity: 0.45;
  148. cursor: not-allowed;
  149. }
  150. /* Tabs */
  151. .tabs {
  152. display: flex;
  153. gap: 0;
  154. padding: 0 18px;
  155. border-bottom: 1px solid var(--border);
  156. background: #fff;
  157. }
  158. .tab {
  159. position: relative;
  160. height: 44px;
  161. padding: 0 16px;
  162. border: 0;
  163. background: transparent;
  164. color: var(--text-muted);
  165. font-size: 14px;
  166. cursor: pointer;
  167. }
  168. .tab:hover { color: var(--text); }
  169. .tab.is-active {
  170. color: var(--primary-dark);
  171. font-weight: 600;
  172. }
  173. .tab.is-active::after {
  174. content: "";
  175. position: absolute;
  176. left: 12px;
  177. right: 12px;
  178. bottom: 0;
  179. height: 2px;
  180. border-radius: 1px;
  181. background: var(--primary);
  182. }
  183. .tab .count {
  184. margin-left: 4px;
  185. color: var(--text-faint);
  186. font-weight: 400;
  187. font-size: 12px;
  188. }
  189. /* Filters */
  190. .filters {
  191. display: flex;
  192. flex-wrap: wrap;
  193. gap: 10px;
  194. align-items: center;
  195. padding: 12px 18px;
  196. border-bottom: 1px solid var(--border-soft);
  197. background: #fff;
  198. }
  199. .field {
  200. display: flex;
  201. align-items: center;
  202. gap: 6px;
  203. }
  204. .field label {
  205. font-size: 12px;
  206. color: var(--text-muted);
  207. white-space: nowrap;
  208. }
  209. .input, .select {
  210. height: 34px;
  211. padding: 0 10px;
  212. border: 1px solid var(--border);
  213. border-radius: 8px;
  214. background: #fff;
  215. color: var(--text);
  216. font-size: 13px;
  217. outline: none;
  218. }
  219. .input { min-width: 200px; }
  220. .input:focus, .select:focus {
  221. border-color: var(--primary);
  222. box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
  223. }
  224. .select { min-width: 120px; }
  225. /* Card list */
  226. .card-list {
  227. background: #fff;
  228. }
  229. .card {
  230. display: flex;
  231. gap: 12px;
  232. align-items: flex-start;
  233. padding: 14px 18px;
  234. border-bottom: 1px solid var(--border-soft);
  235. cursor: pointer;
  236. transition: background 0.12s;
  237. }
  238. .card:hover { background: #f8fafc; }
  239. .card.is-selected { background: var(--primary-bg); }
  240. .card.is-disabled {
  241. opacity: 0.55;
  242. cursor: not-allowed;
  243. }
  244. .card__check {
  245. flex-shrink: 0;
  246. margin-top: 10px;
  247. }
  248. .card__check input {
  249. width: 16px;
  250. height: 16px;
  251. accent-color: var(--primary);
  252. cursor: pointer;
  253. }
  254. .card__avatar {
  255. flex-shrink: 0;
  256. width: 40px;
  257. height: 40px;
  258. border-radius: 8px;
  259. display: flex;
  260. align-items: center;
  261. justify-content: center;
  262. color: #fff;
  263. font-size: 13px;
  264. font-weight: 600;
  265. letter-spacing: 0.5px;
  266. margin-top: 2px;
  267. }
  268. .card__main {
  269. flex: 1;
  270. min-width: 0;
  271. }
  272. .card__title {
  273. margin: 0 0 4px;
  274. font-size: 14px;
  275. font-weight: 600;
  276. color: var(--text);
  277. line-height: 1.4;
  278. }
  279. .card__meta {
  280. margin: 0;
  281. padding: 0;
  282. list-style: none;
  283. color: var(--text-muted);
  284. font-size: 12.5px;
  285. }
  286. .card__meta li {
  287. margin: 1px 0;
  288. overflow: hidden;
  289. text-overflow: ellipsis;
  290. white-space: nowrap;
  291. }
  292. .card__time {
  293. margin-top: 6px;
  294. color: var(--text-faint);
  295. font-size: 12px;
  296. }
  297. .card__right {
  298. flex-shrink: 0;
  299. text-align: right;
  300. min-width: 100px;
  301. padding-top: 2px;
  302. }
  303. .card__actor {
  304. font-size: 12.5px;
  305. color: var(--warn);
  306. line-height: 1.4;
  307. }
  308. .card__status {
  309. margin-top: 4px;
  310. font-size: 12.5px;
  311. font-weight: 500;
  312. }
  313. .st-running { color: var(--warn); }
  314. .st-completed { color: var(--success); }
  315. .st-rejected { color: var(--danger); }
  316. .st-returned { color: var(--warn); }
  317. .st-cancelled { color: var(--info); }
  318. .empty {
  319. padding: 48px 18px;
  320. text-align: center;
  321. color: var(--text-faint);
  322. font-size: 13px;
  323. }
  324. .pager {
  325. display: flex;
  326. justify-content: flex-end;
  327. align-items: center;
  328. gap: 8px;
  329. padding: 10px 18px 14px;
  330. color: var(--text-muted);
  331. font-size: 12px;
  332. background: #fff;
  333. }
  334. .pager button {
  335. height: 28px;
  336. padding: 0 10px;
  337. border: 1px solid var(--border);
  338. border-radius: 6px;
  339. background: #fff;
  340. cursor: pointer;
  341. font-size: 12px;
  342. }
  343. .pager button:disabled { opacity: 0.4; cursor: not-allowed; }
  344. .legend {
  345. margin-top: 18px;
  346. font-size: 12px;
  347. color: var(--text-muted);
  348. }
  349. </style>
  350. </head>
  351. <body>
  352. <div class="page">
  353. <h1>关联审批单 · 选单卡片模式</h1>
  354. <p class="lead">
  355. 需求草案 Demo:去掉「仅已通过」限制;三 Tab(我发起的 / 我审批的 / 抄送我的);
  356. 钉钉式卡片;展示部门与表单摘要;支持筛选。交互可点选,便于对齐产品预期。
  357. </p>
  358. <div class="notes">
  359. <strong>本 Demo 约定(待评审)</strong>
  360. <ul>
  361. <li>Tab 默认落在「我发起的」;切换 Tab 清空当前页勾选草稿(已确认关联保留在表单外)。</li>
  362. <li>状态不限制:处理中 / 已通过 / 已拒绝 / 已打回 / 已撤销均可出现;右侧展示当前处理人 + 状态文案。</li>
  363. <li>卡片主信息:标题、部门、前 2 个业务字段摘要、申请时间;左勾选 + 发起人头像字。</li>
  364. <li>筛选:仅关键词(流程名 / 标题)。</li>
  365. </ul>
  366. </div>
  367. <div class="dialog" role="dialog" aria-label="选择关联审批单">
  368. <div class="dialog__header">
  369. <h2 class="dialog__title">选择关联审批单</h2>
  370. <button class="dialog__close" type="button" aria-label="关闭">×</button>
  371. </div>
  372. <div class="tabs" role="tablist">
  373. <button class="tab is-active" data-tab="mine" type="button">我发起的<span class="count" id="count-mine"></span></button>
  374. <button class="tab" data-tab="done" type="button">我审批的<span class="count" id="count-done"></span></button>
  375. <button class="tab" data-tab="cc" type="button">抄送我的<span class="count" id="count-cc"></span></button>
  376. </div>
  377. <div class="filters">
  378. <div class="field">
  379. <label>关键词</label>
  380. <input class="input" id="kw" type="search" placeholder="搜索流程名 / 标题" />
  381. </div>
  382. <button class="btn" type="button" id="btnSearch">查询</button>
  383. <button class="btn" type="button" id="btnReset">重置</button>
  384. </div>
  385. <div class="dialog__body">
  386. <div class="card-list" id="list"></div>
  387. <div class="pager">
  388. <span id="pageInfo">第 1 页</span>
  389. <button type="button" id="prevPage">上一页</button>
  390. <button type="button" id="nextPage">下一页</button>
  391. </div>
  392. </div>
  393. <div class="dialog__footer">
  394. <div class="dialog__footer-left">已选 <em id="selCount">0</em> 项</div>
  395. <div class="dialog__footer-right">
  396. <button class="btn" type="button">取消</button>
  397. <button class="btn btn--primary" type="button" id="btnOk" disabled>确定</button>
  398. </div>
  399. </div>
  400. </div>
  401. <p class="legend">
  402. 静态 Mock · Fresh Mint 配色 · 打开本文件即可预览:
  403. <code>docs/mockups/oa-related-approval-picker-card-demo.html</code>
  404. </p>
  405. </div>
  406. <script>
  407. const AVATAR_COLORS = ["#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#06b6d4", "#ef4444"];
  408. const STATUS_TEXT = {
  409. running: "处理中",
  410. completed: "已通过",
  411. rejected: "已拒绝",
  412. returned: "已打回",
  413. cancelled: "已撤销",
  414. };
  415. /** 已关联(模拟表单已选,不可再选) */
  416. const alreadyLinked = new Set(["m2"]);
  417. const DATA = {
  418. mine: [
  419. {
  420. id: "m1",
  421. appName: "人事审批",
  422. title: "吴自龙提交的人事审批",
  423. initiatorName: "吴自龙",
  424. deptName: "研发中心 / 平台组",
  425. fields: [
  426. { label: "数字输入框", value: "123" },
  427. { label: "单行输入框", value: "123" },
  428. ],
  429. applyTime: "2026-07-23 13:50",
  430. status: "running",
  431. actor: "吴震 (审批人)",
  432. },
  433. {
  434. id: "m2",
  435. appName: "请假",
  436. title: "吴自龙提交的请假",
  437. initiatorName: "吴自龙",
  438. deptName: "研发中心 / 平台组",
  439. fields: [
  440. { label: "请假类型", value: "年假" },
  441. { label: "时长", value: "1.5 天" },
  442. ],
  443. applyTime: "2026-07-20 09:12",
  444. status: "completed",
  445. actor: "",
  446. },
  447. {
  448. id: "m3",
  449. appName: "报销",
  450. title: "吴自龙提交的报销",
  451. initiatorName: "吴自龙",
  452. deptName: "研发中心 / 平台组",
  453. fields: [
  454. { label: "报销金额", value: "¥1,280.00" },
  455. { label: "事由", value: "客户拜访差旅" },
  456. ],
  457. applyTime: "2026-07-18 16:40",
  458. status: "rejected",
  459. actor: "",
  460. },
  461. {
  462. id: "m4",
  463. appName: "采购",
  464. title: "吴自龙提交的采购",
  465. initiatorName: "吴自龙",
  466. deptName: "研发中心 / 平台组",
  467. fields: [
  468. { label: "采购品类", value: "办公设备" },
  469. { label: "预算", value: "¥8,000" },
  470. ],
  471. applyTime: "2026-07-10 11:05",
  472. status: "returned",
  473. actor: "",
  474. },
  475. ],
  476. done: [
  477. {
  478. id: "d1",
  479. appName: "请假",
  480. title: "张三提交的请假",
  481. initiatorName: "张三",
  482. deptName: "市场部 / 华东区",
  483. fields: [
  484. { label: "请假类型", value: "事假" },
  485. { label: "时长", value: "0.5 天" },
  486. ],
  487. applyTime: "2026-07-22 10:20",
  488. status: "running",
  489. actor: "吴自龙 (审批人)",
  490. },
  491. {
  492. id: "d2",
  493. appName: "人事审批",
  494. title: "李四提交的人事审批",
  495. initiatorName: "李四",
  496. deptName: "人事行政部",
  497. fields: [
  498. { label: "调整类型", value: "转正" },
  499. { label: "生效日期", value: "2026-08-01" },
  500. ],
  501. applyTime: "2026-07-15 14:33",
  502. status: "completed",
  503. actor: "",
  504. },
  505. {
  506. id: "d3",
  507. appName: "报销",
  508. title: "王五提交的报销",
  509. initiatorName: "王五",
  510. deptName: "财务部",
  511. fields: [
  512. { label: "报销金额", value: "¥560.00" },
  513. { label: "科目", value: "招待费" },
  514. ],
  515. applyTime: "2026-07-12 08:50",
  516. status: "cancelled",
  517. actor: "",
  518. },
  519. ],
  520. cc: [
  521. {
  522. id: "c1",
  523. appName: "采购",
  524. title: "赵六提交的采购",
  525. initiatorName: "赵六",
  526. deptName: "供应链 / 采购组",
  527. fields: [
  528. { label: "供应商", value: "某某科技" },
  529. { label: "金额", value: "¥12,000" },
  530. ],
  531. applyTime: "2026-07-21 17:08",
  532. status: "running",
  533. actor: "陈七 (审批人)",
  534. },
  535. {
  536. id: "c2",
  537. appName: "人事审批",
  538. title: "钱八提交的人事审批",
  539. initiatorName: "钱八",
  540. deptName: "研发中心 / 测试组",
  541. fields: [
  542. { label: "岗位", value: "高级测试工程师" },
  543. { label: "职级", value: "P6" },
  544. ],
  545. applyTime: "2026-07-08 09:40",
  546. status: "completed",
  547. actor: "",
  548. },
  549. ],
  550. };
  551. const state = {
  552. tab: "mine",
  553. kw: "",
  554. page: 1,
  555. pageSize: 5,
  556. selected: new Set(),
  557. };
  558. function shortName(name) {
  559. const s = String(name || "").trim();
  560. return s.length <= 2 ? s : s.slice(-2);
  561. }
  562. function avatarColor(name) {
  563. let h = 0;
  564. for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * 17) % AVATAR_COLORS.length;
  565. return AVATAR_COLORS[h];
  566. }
  567. function statusClass(st) {
  568. return "st-" + (st || "running");
  569. }
  570. function filtered() {
  571. return (DATA[state.tab] || []).filter((row) => {
  572. if (state.kw) {
  573. const blob = [row.appName, row.title].join(" ").toLowerCase();
  574. if (!blob.includes(state.kw.toLowerCase())) return false;
  575. }
  576. return true;
  577. });
  578. }
  579. function renderCounts() {
  580. for (const k of ["mine", "done", "cc"]) {
  581. const el = document.getElementById("count-" + k);
  582. if (el) el.textContent = "(" + DATA[k].length + ")";
  583. }
  584. }
  585. function renderList() {
  586. const all = filtered();
  587. const totalPages = Math.max(1, Math.ceil(all.length / state.pageSize));
  588. if (state.page > totalPages) state.page = totalPages;
  589. const start = (state.page - 1) * state.pageSize;
  590. const pageRows = all.slice(start, start + state.pageSize);
  591. const list = document.getElementById("list");
  592. if (!pageRows.length) {
  593. list.innerHTML = '<div class="empty">暂无符合条件的审批单</div>';
  594. } else {
  595. list.innerHTML = pageRows
  596. .map((row) => {
  597. const disabled = alreadyLinked.has(row.id);
  598. const checked = state.selected.has(row.id);
  599. const meta = [
  600. row.deptName ? `<li>部门:${escapeHtml(row.deptName)}</li>` : "",
  601. ...(row.fields || [])
  602. .slice(0, 2)
  603. .map((f) => `<li>${escapeHtml(f.label)}:${escapeHtml(f.value)}</li>`),
  604. ].join("");
  605. const actor =
  606. row.status === "running" && row.actor
  607. ? `<div class="card__actor">${escapeHtml(row.actor)}</div>`
  608. : "";
  609. return `
  610. <div class="card ${checked ? "is-selected" : ""} ${disabled ? "is-disabled" : ""}"
  611. data-id="${row.id}" data-disabled="${disabled ? "1" : "0"}">
  612. <div class="card__check">
  613. <input type="checkbox" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
  614. </div>
  615. <div class="card__avatar" style="background:${avatarColor(row.initiatorName)}">
  616. ${escapeHtml(shortName(row.initiatorName))}
  617. </div>
  618. <div class="card__main">
  619. <h3 class="card__title">${escapeHtml(row.title)}</h3>
  620. <ul class="card__meta">${meta}</ul>
  621. <div class="card__time">${escapeHtml(row.applyTime)}</div>
  622. </div>
  623. <div class="card__right">
  624. ${actor}
  625. <div class="card__status ${statusClass(row.status)}">${STATUS_TEXT[row.status] || row.status}</div>
  626. </div>
  627. </div>`;
  628. })
  629. .join("");
  630. }
  631. document.getElementById("pageInfo").textContent =
  632. `共 ${all.length} 条 · 第 ${state.page}/${totalPages} 页`;
  633. document.getElementById("prevPage").disabled = state.page <= 1;
  634. document.getElementById("nextPage").disabled = state.page >= totalPages;
  635. document.getElementById("selCount").textContent = String(state.selected.size);
  636. document.getElementById("btnOk").disabled = state.selected.size === 0;
  637. }
  638. function escapeHtml(s) {
  639. return String(s || "")
  640. .replace(/&/g, "&amp;")
  641. .replace(/</g, "&lt;")
  642. .replace(/>/g, "&gt;")
  643. .replace(/"/g, "&quot;");
  644. }
  645. document.querySelectorAll(".tab").forEach((btn) => {
  646. btn.addEventListener("click", () => {
  647. document.querySelectorAll(".tab").forEach((b) => b.classList.remove("is-active"));
  648. btn.classList.add("is-active");
  649. state.tab = btn.dataset.tab;
  650. state.page = 1;
  651. state.selected.clear();
  652. renderList();
  653. });
  654. });
  655. document.getElementById("btnSearch").addEventListener("click", () => {
  656. state.kw = document.getElementById("kw").value.trim();
  657. state.page = 1;
  658. renderList();
  659. });
  660. document.getElementById("btnReset").addEventListener("click", () => {
  661. document.getElementById("kw").value = "";
  662. state.kw = "";
  663. state.page = 1;
  664. renderList();
  665. });
  666. document.getElementById("kw").addEventListener("keydown", (e) => {
  667. if (e.key === "Enter") document.getElementById("btnSearch").click();
  668. });
  669. document.getElementById("prevPage").addEventListener("click", () => {
  670. if (state.page > 1) {
  671. state.page -= 1;
  672. renderList();
  673. }
  674. });
  675. document.getElementById("nextPage").addEventListener("click", () => {
  676. state.page += 1;
  677. renderList();
  678. });
  679. document.getElementById("list").addEventListener("click", (e) => {
  680. const card = e.target.closest(".card");
  681. if (!card || card.dataset.disabled === "1") return;
  682. const id = card.dataset.id;
  683. if (state.selected.has(id)) state.selected.delete(id);
  684. else state.selected.add(id);
  685. renderList();
  686. });
  687. renderCounts();
  688. renderList();
  689. </script>
  690. </body>
  691. </html>