chore: add construction installment calculator design spec and plan
This commit is contained in:
@@ -0,0 +1,630 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Construction Installment Calculator — Design Spec
|
||||||
|
|
||||||
|
Date: 2026-05-28
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Single HTML file tool for calculating construction project payment installments (งวดงาน). Users input contract details and edit installment structure; the page calculates dates, amounts, and cumulative totals, then exports to Excel.
|
||||||
|
|
||||||
|
## Inputs (top form)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| วงเงินสัญญา | Number | Total contract amount (บาท) |
|
||||||
|
| วันเริ่มก่อสร้าง | Date | Construction start date |
|
||||||
|
| สัดส่วน งปม. (%) | Number | Budget money percentage (default 95) |
|
||||||
|
| สัดส่วนเงินนอก (%) | Number | Outside budget percentage (default 5); auto-fills to 100 - งปม% |
|
||||||
|
| จำนวนงวด | Number | Number of installments; changing this regenerates table rows |
|
||||||
|
|
||||||
|
When จำนวนงวด = 27, pre-fill the standard structure from the Excel template (see below).
|
||||||
|
|
||||||
|
## Installment Structure (fixed template for 27 งวด)
|
||||||
|
|
||||||
|
| งวดที่ | วัน | ร้อยละ |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 20 | 3.80 |
|
||||||
|
| 2–5 | 20 | 3.70 |
|
||||||
|
| 6–24 | 30 | 3.70 |
|
||||||
|
| 25–27 | 40 | 3.70 |
|
||||||
|
|
||||||
|
Total: 790 days, 100.00%
|
||||||
|
|
||||||
|
For other installment counts, rows are generated blank (days=0, %=0) for user to fill in.
|
||||||
|
|
||||||
|
## Table Columns
|
||||||
|
|
||||||
|
| Column | Editable? | Calculation |
|
||||||
|
|---|---|---|
|
||||||
|
| งวดที่ | No | Sequential 1..n |
|
||||||
|
| จำนวนวัน | Yes | User input |
|
||||||
|
| ร้อยละ (%) | Yes | User input |
|
||||||
|
| วันส่งมอบงาน | No | startDate + cumulative days |
|
||||||
|
| รวม (บาท) | No | contractAmount × % / 100 |
|
||||||
|
| เงิน งปม. (บาท) | No | รวม × งปม% / 100 |
|
||||||
|
| เงินนอก (บาท) | No | รวม × เงินนอก% / 100 |
|
||||||
|
| รวมสะสม (บาท) | No | Cumulative รวม |
|
||||||
|
| งปม. สะสม (บาท) | No | Cumulative เงิน งปม. |
|
||||||
|
| เงินนอก สะสม (บาท) | No | Cumulative เงินนอก |
|
||||||
|
| ลบ | — | Button to remove row |
|
||||||
|
|
||||||
|
## Summary Cards (above table)
|
||||||
|
|
||||||
|
- วงเงินสัญญา
|
||||||
|
- เงิน งปม. (งปม%×contract)
|
||||||
|
- เงินนอก (เงินนอก%×contract)
|
||||||
|
- รวมร้อยละ (sum of all % rows) — shown in red if ≠ 100%, green if = 100%
|
||||||
|
- วันสิ้นสุดสัญญา (startDate + sum of all days)
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- **Live recalculation**: every change to วัน, %, or header inputs recalculates all derived columns instantly (no Calculate button needed)
|
||||||
|
- **Add row**: "+ เพิ่มงวด" button appends a blank row at the bottom
|
||||||
|
- **Delete row**: ✕ button removes that row; remaining rows renumber
|
||||||
|
- **จำนวนงวด input**: changing it regenerates rows (27 → template, other → blank rows); warns user before clearing existing data
|
||||||
|
- **Validation**: total % card turns red/green; Export button disabled if total % ≠ 100%
|
||||||
|
|
||||||
|
## Excel Export
|
||||||
|
|
||||||
|
Uses SheetJS (xlsx CDN). Exported file includes:
|
||||||
|
- Sheet name: "งวดงาน"
|
||||||
|
- All 10 data columns + header row
|
||||||
|
- Summary rows at top (contract amount, dates, totals)
|
||||||
|
- Filename: `งวดงาน_YYYYMMDD.xlsx`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Single `index.html` file
|
||||||
|
- Vanilla JS (no framework)
|
||||||
|
- SheetJS loaded from CDN for Excel export
|
||||||
|
- Thai date display in Buddhist Era (พ.ศ.)
|
||||||
|
- No backend, no build step required
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
`/Users/nut.looknut/Project/rmutr/construction-installment-calculator/index.html`
|
||||||
Reference in New Issue
Block a user