Files
rmutr/docs/superpowers/plans/2026-05-28-construction-installment-calculator.md
T
2026-05-28 12:48:48 +07:00

25 KiB

Construction Installment Calculator 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: Build a single-file HTML tool that calculates construction payment installments (งวดงาน), supports editable rows, live recalculation, and Excel export.

Architecture: Single index.html with vanilla JS and inline CSS. State lives in a JS array installments[]. Every input change triggers recalcAll() which redraws summary cards and all calculated table cells. SheetJS (CDN) handles Excel export.

Tech Stack: HTML5, Vanilla JS (ES6+), SheetJS xlsx CDN, no build step


File Map

File Purpose
construction-installment-calculator/index.html Only file — contains all HTML, CSS, and JS

Task 1: HTML skeleton, CSS, and static layout

Files:

  • Create: construction-installment-calculator/index.html

  • Step 1: Create the file with full CSS and static HTML structure

Create construction-installment-calculator/index.html with this complete content:

<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>คำนวณงวดงานสิ่งก่อสร้าง</title>
<script src="https://unpkg.com/xlsx/dist/xlsx.full.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
  font-family: 'Sarabun', 'Segoe UI', sans-serif;
  background: #f0f2f5;
  padding: 20px;
  font-size: 14px;
  color: #222;
}
.page {
  max-width: 1200px;
  margin: auto;
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.1);
  overflow: hidden;
}
.page-header {
  background: #1a56a0;
  color: white;
  padding: 18px 28px;
}
.page-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.page-header p { font-size: 13px; opacity: 0.85; }

/* Form */
.form-section {
  background: #f7f9fc;
  border-bottom: 1px solid #e0e6ef;
  padding: 18px 28px;
}
.form-row { display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group label {
  font-size: 11px; color: #555; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.5px;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"] {
  border: 1.5px solid #c5d3e8;
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 14px;
  background: white;
  color: #333;
  font-family: inherit;
}
.form-group input[type="number"] { width: 90px; }
.form-group input[type="date"] { width: 160px; }
.form-group input#contractAmount { width: 180px; }
.ratio-group { display: flex; align-items: center; gap: 6px; }
.ratio-group span { font-size: 12px; color: #666; }

/* Buttons */
.btn {
  border: none; border-radius: 6px; padding: 9px 20px;
  font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit;
}
.btn-calc { background: #1a56a0; color: white; }
.btn-export { background: #217346; color: white; }
.btn-export:disabled { background: #aaa; cursor: not-allowed; }
.btn-add {
  background: #e8f0fb; color: #1a56a0;
  border: 1.5px solid #c5d3e8; border-radius: 5px;
  padding: 5px 14px; font-size: 13px; cursor: pointer;
  font-family: inherit; font-weight: 600;
}
.btn-del {
  background: none; border: none; color: #cc3333;
  font-size: 16px; cursor: pointer; padding: 0 6px; line-height: 1;
}

/* Summary cards */
.table-section { padding: 20px 28px 28px; }
.summary-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.summary-card {
  background: #f0f6ff; border: 1px solid #c5d3e8;
  border-radius: 8px; padding: 10px 16px; min-width: 150px;
}
.summary-card .lbl { font-size: 11px; color: #666; font-weight: 600; }
.summary-card .val { font-size: 15px; font-weight: 700; color: #1a56a0; margin-top: 3px; }
.summary-card.warn .val { color: #c0392b; }
.summary-card.ok .val { color: #217346; }

/* Table toolbar */
.table-toolbar {
  display: flex; justify-content: space-between;
  align-items: center; margin-bottom: 12px;
}
.legend { display: flex; gap: 14px; font-size: 12px; color: #666; align-items: center; }
.legend-box {
  width: 13px; height: 13px; border-radius: 3px;
  display: inline-block; vertical-align: middle; margin-right: 4px;
}

/* Table */
.table-wrapper { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
  background: #1a56a0; color: white;
  padding: 8px 8px; text-align: center;
  white-space: nowrap; font-size: 12px;
}
th.sub { background: #2a6abf; }
th.editable-col { background: #c0832a; }
td {
  padding: 6px 8px;
  border-bottom: 1px solid #e8edf5;
  text-align: right; white-space: nowrap;
}
td.center { text-align: center; }
td.editable-cell { background: #fffbf0; text-align: center; padding: 3px 5px; }
td.editable-cell input {
  border: 1.5px solid #f0c040; border-radius: 4px;
  padding: 4px 5px; width: 62px; text-align: center;
  font-size: 13px; font-family: inherit;
}
td.editable-cell input:focus { outline: none; border-color: #e67e22; }
td.acc { background: #f0f6ff; font-weight: 600; color: #1a56a0; }
td.calc { color: #333; }
tr:nth-child(even) td { background: #fafcff; }
tr:nth-child(even) td.editable-cell { background: #fffdf0; }
tr:nth-child(even) td.acc { background: #e8f0fb; }
tr:hover td { background: #f0f6ff !important; }
tr.total-row td {
  background: #dce8f8 !important;
  font-weight: 700; color: #1a56a0;
  border-top: 2px solid #1a56a0;
}
.pct-status { font-size: 11px; font-weight: 700; margin-left: 6px; }
.pct-warn { color: #c0392b; }
.pct-ok { color: #217346; }
</style>
</head>
<body>
<div class="page">
  <div class="page-header">
    <h1>การคำนวณงวดงานสิ่งก่อสร้าง</h1>
    <p>กรอกข้อมูล กำหนดจำนวนงวด แล้วแก้ไขวันและร้อยละในตาราง ระบบจะคำนวณให้อัตโนมัติ</p>
  </div>

  <div class="form-section">
    <div class="form-row">
      <div class="form-group">
        <label>วงเงินสัญญา (บาท)</label>
        <input type="text" id="contractAmount" placeholder="เช่น 120000000" value="">
      </div>
      <div class="form-group">
        <label>วันเริ่มก่อสร้าง</label>
        <input type="date" id="startDate" value="">
      </div>
      <div class="form-group">
        <label>สัดส่วน งปม. / เงินนอก (%)</label>
        <div class="ratio-group">
          <input type="number" id="ngpmPct" value="95" min="0" max="100" style="width:70px">
          <span>งปม. /</span>
          <input type="number" id="nookPct" value="5" min="0" max="100" style="width:70px" readonly>
          <span>นอก</span>
        </div>
      </div>
      <div class="form-group">
        <label>จำนวนงวด</label>
        <input type="number" id="installmentCount" value="27" min="1" max="100" style="width:75px">
      </div>
      <div class="form-group" style="flex-direction:row;gap:10px;align-items:flex-end">
        <button class="btn btn-export" id="btnExport" onclick="exportExcel()" disabled>⬇ Export Excel</button>
      </div>
    </div>
  </div>

  <div class="table-section">
    <div class="summary-row" id="summaryRow">
      <div class="summary-card">
        <div class="lbl">วงเงินสัญญา</div>
        <div class="val" id="s_contract"></div>
      </div>
      <div class="summary-card">
        <div class="lbl">เงิน งปม.</div>
        <div class="val" id="s_ngpm"></div>
      </div>
      <div class="summary-card">
        <div class="lbl">เงินนอก</div>
        <div class="val" id="s_nook"></div>
      </div>
      <div class="summary-card" id="s_pct_card">
        <div class="lbl">รวมร้อยละทุกงวด</div>
        <div class="val" id="s_pct">0.00%</div>
      </div>
      <div class="summary-card">
        <div class="lbl">วันสิ้นสุดสัญญา</div>
        <div class="val" id="s_endDate"></div>
      </div>
    </div>

    <div class="table-toolbar">
      <div class="legend">
        <span>
          <span class="legend-box" style="background:#fffbf0;border:1.5px solid #f0c040"></span>
          แก้ไขได้
        </span>
        <span>
          <span class="legend-box" style="background:#f0f6ff;border:1px solid #c5d3e8"></span>
          คำนวณอัตโนมัติ
        </span>
      </div>
      <button class="btn-add" onclick="addRow()">+ เพิ่มงวด</button>
    </div>

    <div class="table-wrapper">
      <table id="mainTable">
        <thead>
          <tr>
            <th rowspan="2">งวดที่</th>
            <th class="editable-col" rowspan="2">จำนวนวัน<br><small>(แก้ไขได้)</small></th>
            <th class="editable-col" rowspan="2">ร้อยละ (%)<br><small>(แก้ไขได้)</small></th>
            <th rowspan="2">วันส่งมอบงาน</th>
            <th colspan="3">จำนวนเงินงวดนั้น (บาท)</th>
            <th colspan="3">จำนวนเงินสะสม (บาท)</th>
            <th rowspan="2"></th>
          </tr>
          <tr>
            <th class="sub">รวม</th>
            <th class="sub">เงิน งปม.</th>
            <th class="sub">เงินนอก</th>
            <th class="sub">รวม</th>
            <th class="sub">เงิน งปม.</th>
            <th class="sub">เงินนอก</th>
          </tr>
        </thead>
        <tbody id="tableBody"></tbody>
      </table>
    </div>
  </div>
</div>

<script>
// ── State ──────────────────────────────────────────────────────────────────
let installments = []; // [{days, percent}]

// ── Template data (27 งวด from Excel) ─────────────────────────────────────
const TEMPLATE_27 = [
  {days:20, percent:3.80},
  {days:20, percent:3.70},{days:20, percent:3.70},{days:20, percent:3.70},{days:20, percent:3.70},
  {days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},
  {days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},
  {days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},
  {days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},
  {days:30, percent:3.70},{days:30, percent:3.70},{days:30, percent:3.70},
  {days:40, percent:3.70},{days:40, percent:3.70},{days:40, percent:3.70}
];

// ── Helpers ────────────────────────────────────────────────────────────────
function parseAmount(str) {
  return parseFloat(String(str).replace(/,/g, '')) || 0;
}

function formatNumber(n) {
  if (!n && n !== 0) return '—';
  return Math.round(n).toLocaleString('th-TH');
}

function addDays(dateStr, days) {
  const d = new Date(dateStr);
  d.setDate(d.getDate() + days);
  return d;
}

function toThaiDate(date) {
  if (!(date instanceof Date) || isNaN(date)) return '—';
  const y = date.getFullYear() + 543;
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  return `${d}/${m}/${y}`;
}

function getInputs() {
  return {
    contract: parseAmount(document.getElementById('contractAmount').value),
    startDate: document.getElementById('startDate').value,
    ngpmPct: parseFloat(document.getElementById('ngpmPct').value) || 0,
    nookPct: parseFloat(document.getElementById('nookPct').value) || 0,
  };
}

// ── Recalculate all derived values and update DOM ──────────────────────────
function recalcAll() {
  const {contract, startDate, ngpmPct, nookPct} = getInputs();
  const hasStart = startDate && contract > 0;

  let cumDays = 0, cumTotal = 0, cumNgpm = 0, cumNook = 0;
  let sumPct = 0;

  installments.forEach((inst, i) => {
    const days = parseFloat(inst.days) || 0;
    const pct  = parseFloat(inst.percent) || 0;
    cumDays += days;
    sumPct  += pct;

    const total = contract > 0 ? Math.round(contract * pct / 100) : 0;
    const ngpm  = Math.round(total * ngpmPct / 100);
    const nook  = total - ngpm;
    cumTotal += total; cumNgpm += ngpm; cumNook += nook;

    const deliveryDate = hasStart ? toThaiDate(addDays(startDate, cumDays)) : '—';

    // Update cells
    const row = document.getElementById(`row_${i}`);
    if (!row) return;
    row.querySelector('.cell-delivery').textContent = deliveryDate;
    row.querySelector('.cell-total').textContent    = formatNumber(total);
    row.querySelector('.cell-ngpm').textContent     = formatNumber(ngpm);
    row.querySelector('.cell-nook').textContent     = formatNumber(nook);
    row.querySelector('.cell-acc-total').textContent = formatNumber(cumTotal);
    row.querySelector('.cell-acc-ngpm').textContent  = formatNumber(cumNgpm);
    row.querySelector('.cell-acc-nook').textContent  = formatNumber(cumNook);
  });

  // Summary row
  const totalRow = document.getElementById('totalRow');
  if (totalRow) {
    totalRow.querySelector('.cell-total').textContent = formatNumber(contract);
    totalRow.querySelector('.cell-ngpm').textContent  = formatNumber(Math.round(contract * ngpmPct / 100));
    totalRow.querySelector('.cell-nook').textContent  = formatNumber(Math.round(contract * nookPct / 100));
    totalRow.querySelector('.cell-pct').textContent   = sumPct.toFixed(2) + '%';
  }

  // Summary cards
  document.getElementById('s_contract').textContent = contract > 0 ? formatNumber(contract) + ' ฿' : '—';
  document.getElementById('s_ngpm').textContent = contract > 0 ? formatNumber(Math.round(contract * ngpmPct / 100)) + ' ฿' : '—';
  document.getElementById('s_nook').textContent = contract > 0 ? formatNumber(Math.round(contract * nookPct / 100)) + ' ฿' : '—';

  const pctOk = Math.abs(sumPct - 100) < 0.001;
  const pctCard = document.getElementById('s_pct_card');
  pctCard.className = 'summary-card ' + (pctOk ? 'ok' : 'warn');
  document.getElementById('s_pct').innerHTML =
    sumPct.toFixed(2) + '%' +
    (pctOk
      ? '<span class="pct-status pct-ok">✓ ครบ 100%</span>'
      : '<span class="pct-status pct-warn">⚠ ยังไม่ครบ 100%</span>');

  if (hasStart) {
    document.getElementById('s_endDate').textContent = toThaiDate(addDays(startDate, cumDays));
  } else {
    document.getElementById('s_endDate').textContent = '—';
  }

  // Enable export only when % = 100 and contract > 0
  document.getElementById('btnExport').disabled = !(pctOk && contract > 0 && startDate);
}

// ── Render table rows ──────────────────────────────────────────────────────
function renderTable() {
  const tbody = document.getElementById('tableBody');
  tbody.innerHTML = '';

  installments.forEach((inst, i) => {
    const tr = document.createElement('tr');
    tr.id = `row_${i}`;
    tr.innerHTML = `
      <td class="center">${i + 1}</td>
      <td class="editable-cell">
        <input type="number" value="${inst.days}" min="0"
          onchange="installments[${i}].days=this.value; recalcAll()"
          oninput="installments[${i}].days=this.value; recalcAll()">
      </td>
      <td class="editable-cell">
        <input type="number" value="${inst.percent}" min="0" max="100" step="0.01"
          onchange="installments[${i}].percent=this.value; recalcAll()"
          oninput="installments[${i}].percent=this.value; recalcAll()">
      </td>
      <td class="center calc cell-delivery">—</td>
      <td class="calc cell-total">—</td>
      <td class="calc cell-ngpm">—</td>
      <td class="calc cell-nook">—</td>
      <td class="acc cell-acc-total">—</td>
      <td class="acc cell-acc-ngpm">—</td>
      <td class="acc cell-acc-nook">—</td>
      <td class="center">
        <button class="btn-del" onclick="deleteRow(${i})" title="ลบงวดนี้">✕</button>
      </td>
    `;
    tbody.appendChild(tr);
  });

  // Total row
  const totalTr = document.createElement('tr');
  totalTr.id = 'totalRow';
  totalTr.className = 'total-row';
  totalTr.innerHTML = `
    <td colspan="2" class="center">รวมทั้งสิ้น</td>
    <td class="center cell-pct">0.00%</td>
    <td class="center">—</td>
    <td class="cell-total">—</td>
    <td class="cell-ngpm">—</td>
    <td class="cell-nook">—</td>
    <td colspan="3" class="center">—</td>
    <td></td>
  `;
  tbody.appendChild(totalTr);

  recalcAll();
}

// ── Row management ─────────────────────────────────────────────────────────
function addRow() {
  installments.push({days: 0, percent: 0});
  document.getElementById('installmentCount').value = installments.length;
  renderTable();
}

function deleteRow(i) {
  installments.splice(i, 1);
  document.getElementById('installmentCount').value = installments.length;
  renderTable();
}

// ── Handle จำนวนงวด change ─────────────────────────────────────────────────
function onCountChange(n) {
  n = parseInt(n) || 1;
  if (n < 1) n = 1;
  if (installments.length > 0) {
    if (!confirm(`การเปลี่ยนจำนวนงวดเป็น ${n} งวด จะล้างข้อมูลเดิม ต้องการดำเนินการต่อไหม?`)) {
      document.getElementById('installmentCount').value = installments.length;
      return;
    }
  }
  if (n === 27) {
    installments = TEMPLATE_27.map(t => ({...t}));
  } else {
    installments = Array.from({length: n}, () => ({days: 0, percent: 0}));
  }
  renderTable();
}

// ── Excel Export ───────────────────────────────────────────────────────────
function exportExcel() {
  const {contract, startDate, ngpmPct, nookPct} = getInputs();
  const wb = XLSX.utils.book_new();

  // Build rows
  const headers = [
    'งวดที่','จำนวนวัน','ร้อยละ (%)','วันส่งมอบงาน',
    'รวม (บาท)','เงิน งปม. (บาท)','เงินนอก (บาท)',
    'รวมสะสม (บาท)','งปม. สะสม (บาท)','เงินนอก สะสม (บาท)'
  ];

  const rows = [headers];
  let cumDays = 0, cumTotal = 0, cumNgpm = 0, cumNook = 0;

  installments.forEach((inst, i) => {
    const days   = parseFloat(inst.days) || 0;
    const pct    = parseFloat(inst.percent) || 0;
    cumDays += days;
    const total  = Math.round(contract * pct / 100);
    const ngpm   = Math.round(total * ngpmPct / 100);
    const nook   = total - ngpm;
    cumTotal += total; cumNgpm += ngpm; cumNook += nook;
    const delivery = toThaiDate(addDays(startDate, cumDays));
    rows.push([i+1, days, pct, delivery, total, ngpm, nook, cumTotal, cumNgpm, cumNook]);
  });

  // Summary row
  rows.push([
    'รวม', '', installments.reduce((s,r) => s + (parseFloat(r.percent)||0), 0).toFixed(2),
    '',
    Math.round(contract), Math.round(contract*ngpmPct/100), Math.round(contract*nookPct/100),
    '', '', ''
  ]);

  const ws = XLSX.utils.aoa_to_sheet(rows);

  // Column widths
  ws['!cols'] = [
    {wch:8},{wch:12},{wch:12},{wch:18},
    {wch:18},{wch:18},{wch:16},
    {wch:18},{wch:18},{wch:18}
  ];

  XLSX.utils.book_append_sheet(wb, ws, 'งวดงาน');

  const today = new Date();
  const fname = `งวดงาน_${today.getFullYear()}${String(today.getMonth()+1).padStart(2,'0')}${String(today.getDate()).padStart(2,'0')}.xlsx`;
  XLSX.writeFile(wb, fname);
}

// ── Event wiring ───────────────────────────────────────────────────────────
document.getElementById('contractAmount').addEventListener('input', recalcAll);
document.getElementById('startDate').addEventListener('change', recalcAll);
document.getElementById('ngpmPct').addEventListener('input', function() {
  const v = parseFloat(this.value) || 0;
  document.getElementById('nookPct').value = (100 - v).toFixed(2);
  recalcAll();
});
document.getElementById('installmentCount').addEventListener('change', function() {
  onCountChange(this.value);
});

// ── Init: load 27-งวด template on page load ────────────────────────────────
installments = TEMPLATE_27.map(t => ({...t}));
renderTable();
</script>
</body>
</html>
  • Step 2: Open in browser and verify initial render

Open construction-installment-calculator/index.html in a browser (double-click or open construction-installment-calculator/index.html).

Expected:

  • Page loads with blue header

  • Form shows 4 inputs: วงเงิน, วันเริ่มก่อสร้าง, สัดส่วน, จำนวนงวด

  • Table shows 27 rows pre-filled (days column: first 5 rows=20, rows 6-24=30, rows 25-27=40)

  • ร้อยละ column: row 1=3.80, rest=3.70

  • Export button is disabled (grey)

  • Step 3: Test contract amount input

Type 120000000 in วงเงินสัญญา field.

Expected:

  • Summary cards update instantly

  • เงิน งปม. shows 114,000,000 ฿

  • เงินนอก shows 6,000,000 ฿

  • Step 4: Test start date input

Pick a date (e.g., today) in วันเริ่มก่อสร้าง.

Expected:

  • วันส่งมอบงาน column populates with Thai-Buddhist-era dates

  • วันสิ้นสุดสัญญา in summary cards shows a date 790 days later

  • รวมร้อยละ card shows 100.00% with green ✓

  • Export button becomes active (green)

  • Step 5: Test ratio input

Change งปม. to 80. Expected: เงินนอก auto-updates to 20, amounts recalculate.

  • Step 6: Test editable cells

Click on จำนวนวัน in row 1, change from 20 to 25. Expected: วันส่งมอบงาน for all subsequent rows shifts by 5 days. Cumulative columns update.

Change ร้อยละ in row 1 from 3.80 to 3.75. Expected: total % card shows 99.95% in red ⚠.

  • Step 7: Test จำนวนงวด change

Change จำนวนงวด from 27 to 5. Confirm the dialog. Expected: table regenerates with 5 blank rows, % total = 0.00% red.

Change back to 27 and confirm. Expected: template re-fills.

  • Step 8: Test add/delete rows

Click + เพิ่มงวด. Expected: row 28 (or +1) added as blank, จำนวนงวด input increments.

Click ✕ on any row. Expected: row removed, rows renumber, จำนวนงวด input decrements.

  • Step 9: Test Excel export

With 27-template + valid contract + valid start date + 100% total: click Export Excel.

Expected: .xlsx file downloads named งวดงาน_YYYYMMDD.xlsx. Open in Excel/Numbers — should show 27 data rows + header row + summary row, with correct dates and amounts.

  • Step 10: Commit
git -C /Users/nut.looknut/Project/rmutr add construction-installment-calculator/index.html docs/superpowers/specs/2026-05-28-construction-installment-calculator-design.md docs/superpowers/plans/2026-05-28-construction-installment-calculator.md
git -C /Users/nut.looknut/Project/rmutr commit -m "feat: add construction installment calculator (single HTML)"

Self-Review

Spec coverage:

  • ✓ วงเงิน input → Task 1
  • ✓ วันเริ่มก่อสร้าง input → Task 1
  • ✓ สัดส่วน งปม/นอก adjustable → Task 1 (ngpmPct input, nookPct auto-fills)
  • ✓ จำนวนงวด configurable → Task 1 (onCountChange)
  • ✓ Editable วัน and ร้อยละ → Task 1 (editable-cell inputs)
  • ✓ Live recalculation → Task 1 (recalcAll on every input event)
  • ✓ 27-งวด template pre-fill → Task 1 (TEMPLATE_27 + init)
  • ✓ Summary cards → Task 1
  • ✓ Add/delete rows → Task 1 (addRow, deleteRow)
  • ✓ Validation: % total indicator → Task 1 (pct-ok/warn classes)
  • ✓ Export disabled until valid → Task 1 (btnExport.disabled)
  • ✓ Excel export with SheetJS → Task 1 (exportExcel)
  • ✓ Thai Buddhist Era dates → Task 1 (toThaiDate: +543)

Placeholder scan: None found — all code is complete.

Type consistency: All function names used in event handlers (recalcAll, addRow, deleteRow, exportExcel, onCountChange) are defined in the same script block.