codex-work-receipt 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
 
4
+ import { buildCode128B } from "../core/barcode.mjs";
4
5
  import { formatDate, formatDuration, formatNumber, formatTime } from "../lib/time.mjs";
5
6
  import { buildCompensation, DEFAULT_LOCALE, getReceiptCopy } from "../core/presentation.mjs";
6
7
 
@@ -24,6 +25,16 @@ function receiptRow(label, value, emphasize = false) {
24
25
  return `<div class="receipt-row${emphasize ? " receipt-row--emphasize" : ""}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
25
26
  }
26
27
 
28
+ function receiptBarcode(value) {
29
+ const barcode = buildCode128B(value);
30
+ const segments = barcode.segments.map((segment) => {
31
+ const kind = segment.isBar ? "bar" : "space";
32
+ return `<span class="barcode-segment barcode-segment--${kind}" style="flex-grow:${segment.width}"></span>`;
33
+ }).join("");
34
+
35
+ return `<div class="barcode" data-barcode-value="${escapeHtml(barcode.value)}" aria-hidden="true">${segments}</div>`;
36
+ }
37
+
27
38
  function formatCount(value, units, locale) {
28
39
  const amount = Math.max(0, Math.round(value || 0));
29
40
  const unit = units[amount === 1 ? 0 : 1];
@@ -36,7 +47,14 @@ function formatDateKey(value, locale) {
36
47
  return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
37
48
  }
38
49
 
39
- export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = null }) {
50
+ function formatCopy(template, values) {
51
+ return Object.entries(values).reduce(
52
+ (result, [key, value]) => result.replaceAll(`{${key}}`, String(value)),
53
+ String(template),
54
+ );
55
+ }
56
+
57
+ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
40
58
  const locale = record.locale || DEFAULT_LOCALE;
41
59
  const copy = getReceiptCopy(locale);
42
60
  const startAt = new Date(record.period.start_at);
@@ -56,7 +74,8 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
56
74
  : `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
57
75
  const businessPeriodLabel = isCalendarScope ? copy.meta.period : copy.meta.hours;
58
76
  const modelLabel = record.stats.models.length ? record.stats.models.join(" / ") : copy.modelMissing;
59
- const receiptNumber = `${record.id.slice(4, 12).toUpperCase()}-${String(record.stats.completed_turns).padStart(3, "0")}`;
77
+ const shortReceiptId = record.id.replace(/^cwr2?_/, "").slice(0, 8).toUpperCase() || "UNKNOWN";
78
+ const receiptNumber = `${shortReceiptId}-${String(record.stats.completed_turns).padStart(3, "0")}`;
60
79
  const compensation = record.presentation.compensation || buildCompensation(record.source.scope, 0, locale);
61
80
  const responseSeconds = new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
62
81
  minimumFractionDigits: 1,
@@ -85,6 +104,62 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
85
104
  const miniProgramVisual = miniProgramCodeDataUrl
86
105
  ? `<img src="${miniProgramCodeDataUrl}" alt="${escapeHtml(copy.miniProgramAlt)}">`
87
106
  : `<div class="mini-placeholder" role="img" aria-label="${escapeHtml(copy.placeholderAria)}"><span>${escapeHtml(copy.placeholderLabel)}</span><strong>${escapeHtml(copy.placeholderValue)}</strong></div>`;
107
+ const qrUrls = Array.isArray(dataQrDataUrls) && dataQrDataUrls.length
108
+ ? dataQrDataUrls
109
+ : dataQrDataUrl
110
+ ? [dataQrDataUrl]
111
+ : [];
112
+ const dataQrItems = qrUrls.map((url, index) => `
113
+ <div class="qr-item" data-data-qr-index="${index}">
114
+ <div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
115
+ <strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
116
+ <span>${escapeHtml(copy.importDataHint)}</span>
117
+ </div>`).join("");
118
+ const isMultipart = qrUrls.length > 1;
119
+ const multipartSetupSeconds = 10;
120
+ const multipartConfig = JSON.stringify({
121
+ enabled: isMultipart,
122
+ setupSeconds: multipartSetupSeconds,
123
+ frameMs: 4000,
124
+ blankMs: 240,
125
+ setupHint: copy.multipartOpenHint,
126
+ partLabel: copy.multipartPartLabel,
127
+ }).replaceAll("<", "\\u003c");
128
+ const transferVisual = isMultipart
129
+ ? `
130
+ <div class="multipart-live" id="multipart-live">
131
+ <div class="multipart-panel multipart-setup" id="multipart-setup">
132
+ <div class="qr-item multipart-setup__item">
133
+ <div class="qr-frame qr-frame--large">${miniProgramVisual}</div>
134
+ <strong>${escapeHtml(copy.multipartOpenTitle)}</strong>
135
+ <span id="multipart-setup-hint">${escapeHtml(formatCopy(copy.multipartOpenHint, { seconds: multipartSetupSeconds }))}</span>
136
+ </div>
137
+ </div>
138
+ <div class="multipart-panel multipart-stage" id="multipart-stage" hidden>
139
+ <div class="qr-frame qr-frame--large"><img id="multipart-active-qr" alt="${escapeHtml(copy.dataQrAlt)}"></div>
140
+ <strong class="multipart-stage__title">${escapeHtml(copy.multipartTransferTitle)}</strong>
141
+ <span class="multipart-stage__part" id="multipart-part-label">${escapeHtml(formatCopy(copy.multipartPartLabel, { current: 1, total: qrUrls.length }))}</span>
142
+ <span class="multipart-stage__hint">${escapeHtml(copy.multipartTransferHint)}</span>
143
+ <button class="multipart-secondary" id="multipart-show-mini" type="button">${escapeHtml(copy.multipartRestart)}</button>
144
+ </div>
145
+ </div>
146
+ <div class="qr-grid qr-grid--export-only" hidden>
147
+ <div class="qr-item">
148
+ <div class="qr-frame">${miniProgramVisual}</div>
149
+ <strong>${escapeHtml(copy.openMiniProgram)}</strong>
150
+ <span>${escapeHtml(copy.openMiniProgramHint)}</span>
151
+ </div>
152
+ ${dataQrItems}
153
+ </div>`
154
+ : `
155
+ <div class="qr-grid qr-grid--single">
156
+ <div class="qr-item">
157
+ <div class="qr-frame">${miniProgramVisual}</div>
158
+ <strong>${escapeHtml(copy.openMiniProgram)}</strong>
159
+ <span>${escapeHtml(copy.openMiniProgramHint)}</span>
160
+ </div>
161
+ ${dataQrItems}
162
+ </div>`;
88
163
 
89
164
  return `<!doctype html>
90
165
  <html lang="${escapeHtml(copy.htmlLang)}" data-theme="${escapeHtml(record.presentation.default_theme)}">
@@ -302,11 +377,28 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
302
377
  color: var(--muted);
303
378
  }
304
379
  .barcode {
380
+ display: flex;
381
+ align-items: stretch;
382
+ box-sizing: border-box;
383
+ width: 100%;
305
384
  height: 48px;
306
385
  margin: 18px auto 8px;
307
- max-width: 280px;
308
- background: repeating-linear-gradient(90deg, var(--ink) 0 2px, transparent 2px 4px, var(--ink) 4px 5px, transparent 5px 8px);
309
- opacity: .84;
386
+ max-width: 320px;
387
+ padding: 0 20px;
388
+ overflow: hidden;
389
+ }
390
+ .barcode-segment {
391
+ display: block;
392
+ flex-basis: 0;
393
+ flex-shrink: 0;
394
+ min-width: 0;
395
+ height: 100%;
396
+ }
397
+ .barcode-segment--bar {
398
+ background: var(--ink);
399
+ }
400
+ .barcode-segment--space {
401
+ background: transparent;
310
402
  }
311
403
  .footer {
312
404
  text-align: center;
@@ -341,6 +433,39 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
341
433
  .qr-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
342
434
  .qr-item strong { display: block; font-size: 12px; }
343
435
  .qr-item span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.45; }
436
+ .qr-grid--export-only { display: none; }
437
+ .multipart-live { margin-top: 20px; }
438
+ .multipart-panel {
439
+ display: grid;
440
+ place-items: center;
441
+ min-height: 310px;
442
+ padding: 18px;
443
+ border: 1px solid var(--line);
444
+ background: color-mix(in srgb, var(--paper) 94%, var(--ink));
445
+ text-align: center;
446
+ }
447
+ .multipart-panel[hidden] { display: none; }
448
+ .multipart-setup__item { width: 100%; }
449
+ .qr-frame--large { width: min(100%, 250px); }
450
+ .multipart-stage__title,
451
+ .multipart-stage__part,
452
+ .multipart-stage__hint { display: block; }
453
+ .multipart-stage__title { margin-top: 4px; font-size: 14px; }
454
+ .multipart-stage__part { margin-top: 8px; font-size: 12px; font-weight: 800; letter-spacing: .06em; }
455
+ .multipart-stage__hint { max-width: 360px; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
456
+ #multipart-active-qr[aria-busy="true"] { visibility: hidden; }
457
+ .multipart-secondary {
458
+ margin-top: 14px;
459
+ padding: 7px 12px;
460
+ border: 1px solid var(--line);
461
+ border-radius: 999px;
462
+ color: var(--ink);
463
+ background: transparent;
464
+ font: inherit;
465
+ font-size: 10px;
466
+ cursor: pointer;
467
+ }
468
+ .multipart-secondary:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
344
469
  .mini-placeholder {
345
470
  display: grid;
346
471
  place-content: center;
@@ -425,7 +550,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
425
550
  <strong>${escapeHtml(formatNumber(compensation.amount, locale))}<small>${escapeHtml(compensation.unit)}</small></strong>
426
551
  </div>
427
552
  </section>
428
- <div class="barcode" aria-hidden="true"></div>
553
+ ${receiptBarcode(receiptNumber)}
429
554
  <footer class="footer">
430
555
  <div>MODEL · ${escapeHtml(modelLabel)}</div>
431
556
  <div>${escapeHtml(copy.footerThanks)}</div>
@@ -437,18 +562,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
437
562
  <h2>${escapeHtml(copy.transferTitle)}</h2>
438
563
  <p>${escapeHtml(copy.transferDescription)}</p>
439
564
  </header>
440
- <div class="qr-grid">
441
- <div class="qr-item">
442
- <div class="qr-frame">${miniProgramVisual}</div>
443
- <strong>${escapeHtml(copy.openMiniProgram)}</strong>
444
- <span>${escapeHtml(copy.openMiniProgramHint)}</span>
445
- </div>
446
- <div class="qr-item">
447
- <div class="qr-frame"><img src="${dataQrDataUrl}" alt="${escapeHtml(copy.dataQrAlt)}"></div>
448
- <strong>${escapeHtml(copy.importData)}</strong>
449
- <span>${escapeHtml(copy.importDataHint)}</span>
450
- </div>
451
- </div>
565
+ ${transferVisual}
452
566
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
453
567
  </section>
454
568
  </div>
@@ -458,6 +572,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
458
572
  <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
459
573
  <script>
460
574
  const exportConfig = ${exportConfig};
575
+ const multipartConfig = ${multipartConfig};
461
576
  const themes = new Set(["classic", "diner", "payroll"]);
462
577
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
463
578
  function applyTheme(theme) {
@@ -471,6 +586,88 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
471
586
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
472
587
  applyTheme(savedTheme || document.documentElement.dataset.theme);
473
588
 
589
+ const multipartLive = document.getElementById("multipart-live");
590
+ if (multipartConfig.enabled && multipartLive) {
591
+ const setup = document.getElementById("multipart-setup");
592
+ const setupHint = document.getElementById("multipart-setup-hint");
593
+ const stage = document.getElementById("multipart-stage");
594
+ const activeQr = document.getElementById("multipart-active-qr");
595
+ const partLabel = document.getElementById("multipart-part-label");
596
+ const showMiniButton = document.getElementById("multipart-show-mini");
597
+ const urls = [...document.querySelectorAll(".qr-grid--export-only [data-data-qr-index] img")]
598
+ .map((image) => image.src)
599
+ .filter(Boolean);
600
+ let setupTimer = null;
601
+ let rotationTimer = null;
602
+ let switchTimer = null;
603
+ let activeIndex = 0;
604
+
605
+ function stopMultipartTimers() {
606
+ if (setupTimer !== null) clearInterval(setupTimer);
607
+ if (rotationTimer !== null) clearInterval(rotationTimer);
608
+ if (switchTimer !== null) clearTimeout(switchTimer);
609
+ setupTimer = null;
610
+ rotationTimer = null;
611
+ switchTimer = null;
612
+ }
613
+
614
+ function setupHintText(seconds) {
615
+ return String(multipartConfig.setupHint).replaceAll("{seconds}", String(seconds));
616
+ }
617
+
618
+ function partLabelText(index) {
619
+ return String(multipartConfig.partLabel)
620
+ .replaceAll("{current}", String(index + 1))
621
+ .replaceAll("{total}", String(urls.length));
622
+ }
623
+
624
+ function showPart(index, immediate = false) {
625
+ if (!urls.length) return;
626
+ activeIndex = (index + urls.length) % urls.length;
627
+ const applyPart = () => {
628
+ activeQr.src = urls[activeIndex];
629
+ activeQr.alt = partLabelText(activeIndex);
630
+ activeQr.setAttribute("aria-busy", "false");
631
+ partLabel.textContent = partLabelText(activeIndex);
632
+ switchTimer = null;
633
+ };
634
+ if (immediate) {
635
+ applyPart();
636
+ return;
637
+ }
638
+ activeQr.setAttribute("aria-busy", "true");
639
+ switchTimer = setTimeout(applyPart, multipartConfig.blankMs);
640
+ }
641
+
642
+ function startMultipartTransfer() {
643
+ stopMultipartTimers();
644
+ setup.hidden = true;
645
+ stage.hidden = false;
646
+ showPart(0, true);
647
+ rotationTimer = setInterval(() => showPart(activeIndex + 1), multipartConfig.frameMs);
648
+ }
649
+
650
+ function showMultipartSetup() {
651
+ stopMultipartTimers();
652
+ stage.hidden = true;
653
+ setup.hidden = false;
654
+ let remaining = multipartConfig.setupSeconds;
655
+ setupHint.textContent = setupHintText(remaining);
656
+ setupTimer = setInterval(() => {
657
+ remaining -= 1;
658
+ if (remaining <= 0) {
659
+ startMultipartTransfer();
660
+ return;
661
+ }
662
+ setupHint.textContent = setupHintText(remaining);
663
+ }, 1000);
664
+ }
665
+
666
+ showMiniButton.addEventListener("click", showMultipartSetup);
667
+ window.addEventListener("beforeunload", stopMultipartTimers);
668
+ showMultipartSetup();
669
+ }
670
+
474
671
  const exportButton = document.getElementById("save-receipt-image");
475
672
  const exportStatus = document.getElementById("export-status");
476
673
  const exportNode = document.getElementById("receipt-export");
@@ -481,7 +678,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
481
678
  }
482
679
 
483
680
  function waitForImages(node) {
484
- return Promise.all([...node.querySelectorAll("img")].map((image) => {
681
+ return Promise.all([...node.querySelectorAll("img")].filter((image) => image.getAttribute("src")).map((image) => {
485
682
  if (image.complete && image.naturalWidth > 0) return Promise.resolve();
486
683
  return new Promise((resolve, reject) => {
487
684
  image.addEventListener("load", resolve, { once: true });
@@ -524,6 +721,13 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
524
721
  style: { background: paperColor },
525
722
  onclone(clone) {
526
723
  clone.style.background = paperColor;
724
+ const multipartLiveClone = clone.querySelector(".multipart-live");
725
+ if (multipartLiveClone) multipartLiveClone.remove();
726
+ const exportGridClone = clone.querySelector(".qr-grid--export-only");
727
+ if (exportGridClone) {
728
+ exportGridClone.removeAttribute("hidden");
729
+ exportGridClone.style.display = "grid";
730
+ }
527
731
  clone.querySelectorAll(".paper").forEach((paper) => {
528
732
  paper.style.boxShadow = "none";
529
733
  });