feat: add Thai date selector, HTML prototypes, and SQL script
- Replace xlsx with exceljs in construction calculator; switch date input to Thai Buddhist Era selectors - Add disbursement plan preview HTML and schedule table prototype - Add sync_budget_come_from SQL script - Add budget type select menu design plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
@@ -54,8 +54,13 @@ body {
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-group input[type="number"] { width: 90px; }
|
||||
.form-group input[type="date"] { width: 160px; }
|
||||
.form-group input#contractAmount { width: 180px; }
|
||||
.date-sel {
|
||||
border: 1.5px solid #c5d3e8; border-radius: 6px;
|
||||
padding: 0 6px; font-size: 14px; background: white;
|
||||
color: #333; font-family: inherit;
|
||||
height: 37px; box-sizing: border-box; cursor: pointer;
|
||||
}
|
||||
.ratio-group { display: flex; align-items: center; gap: 6px; }
|
||||
.ratio-group span { font-size: 12px; color: #666; }
|
||||
|
||||
@@ -154,24 +159,34 @@ tr.total-row td {
|
||||
<input type="text" id="contractAmount" placeholder="เช่น 120000000" value="">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>วันเริ่มก่อสร้าง</label>
|
||||
<input type="date" id="startDate" value="">
|
||||
<label>วันเริ่มก่อสร้าง (พ.ศ.)</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="startDay" class="date-sel" style="width:58px" onchange="onStartDateChange()"><option value="">วัน</option></select>
|
||||
<select id="startMonth" class="date-sel" style="width:80px" onchange="onStartDateChange()">
|
||||
<option value="">เดือน</option>
|
||||
<option value="1">ม.ค.</option><option value="2">ก.พ.</option><option value="3">มี.ค.</option>
|
||||
<option value="4">เม.ย.</option><option value="5">พ.ค.</option><option value="6">มิ.ย.</option>
|
||||
<option value="7">ก.ค.</option><option value="8">ส.ค.</option><option value="9">ก.ย.</option>
|
||||
<option value="10">ต.ค.</option><option value="11">พ.ย.</option><option value="12">ธ.ค.</option>
|
||||
</select>
|
||||
<select id="startYear" class="date-sel" style="width:80px" onchange="onStartDateChange()"><option value="">ปี</option></select>
|
||||
</div>
|
||||
<input type="hidden" 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>
|
||||
<label>เงินงบประมาณ (%)</label>
|
||||
<input type="number" id="ngpmPct" value="95" min="0" max="100" style="width:80px">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>เงินนอกงบ (%)</label>
|
||||
<input type="number" id="nookPct" value="5" min="0" max="100" style="width:80px" readonly>
|
||||
</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>
|
||||
<button class="btn btn-export" id="btnExport" onclick="exportExcel().catch(console.error)" disabled>⬇ Export Excel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,9 +282,10 @@ function formatNumber(n) {
|
||||
}
|
||||
|
||||
function addDays(dateStr, days) {
|
||||
const d = new Date(dateStr);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
const [y, m, d] = String(dateStr).split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date;
|
||||
}
|
||||
|
||||
function toThaiDate(date) {
|
||||
@@ -440,57 +456,172 @@ function onCountChange(n) {
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function exportExcel() {
|
||||
async function exportExcel() {
|
||||
const {contract, startDate, ngpmPct, nookPct} = getInputs();
|
||||
const wb = XLSX.utils.book_new();
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet('งวดงาน', {
|
||||
views: [{ state: 'frozen', xSplit: 0, ySplit: 4 }]
|
||||
});
|
||||
|
||||
const headers = [
|
||||
'งวดที่','จำนวนวัน','ร้อยละ (%)','วันส่งมอบงาน',
|
||||
'รวม (บาท)','เงิน งปม. (บาท)','เงินนอก (บาท)',
|
||||
'รวมสะสม (บาท)','งปม. สะสม (บาท)','เงินนอก สะสม (บาท)'
|
||||
const NCOLS = 10;
|
||||
ws.columns = [
|
||||
{width:8},{width:10},{width:10},{width:16},
|
||||
{width:18},{width:18},{width:16},
|
||||
{width:18},{width:18},{width:18}
|
||||
];
|
||||
|
||||
const rows = [headers];
|
||||
let cumDays = 0, cumTotal = 0, cumNgpm = 0, cumNook = 0;
|
||||
const thin = { style: 'thin', color: { argb: 'FFB0C4DE' } };
|
||||
const medT = { style: 'medium', color: { argb: 'FF1A56A0' } };
|
||||
const bord = { top: thin, left: thin, bottom: thin, right: thin };
|
||||
|
||||
function fill(cell, argb) {
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb } };
|
||||
}
|
||||
function font(cell, opts) {
|
||||
cell.font = { name: 'Sarabun', ...opts };
|
||||
}
|
||||
function align(cell, h) {
|
||||
cell.alignment = { horizontal: h, vertical: 'middle', wrapText: true };
|
||||
}
|
||||
|
||||
// ── Row 1: Title ──────────────────────────────────────
|
||||
ws.addRow(['การคำนวณงวดงานสิ่งก่อสร้าง']);
|
||||
ws.mergeCells(1, 1, 1, NCOLS);
|
||||
ws.getRow(1).height = 30;
|
||||
const tc = ws.getCell(1, 1);
|
||||
fill(tc, 'FF1A56A0');
|
||||
font(tc, { bold: true, size: 14, color: { argb: 'FFFFFFFF' } });
|
||||
align(tc, 'center');
|
||||
|
||||
// ── Row 2: Info ───────────────────────────────────────
|
||||
const startDisp = startDate ? (() => {
|
||||
const [y, m, d] = startDate.split('-').map(Number);
|
||||
return `${String(d).padStart(2,'0')}/${String(m).padStart(2,'0')}/${y + 543}`;
|
||||
})() : '—';
|
||||
ws.addRow([`วงเงินสัญญา: ${Math.round(contract).toLocaleString('th-TH')} บาท วันเริ่มก่อสร้าง: ${startDisp} สัดส่วน งปม./เงินนอก: ${ngpmPct}% / ${nookPct}%`]);
|
||||
ws.mergeCells(2, 1, 2, NCOLS);
|
||||
ws.getRow(2).height = 20;
|
||||
const ic = ws.getCell(2, 1);
|
||||
fill(ic, 'FFE8F0FB');
|
||||
font(ic, { size: 10, color: { argb: 'FF1A56A0' } });
|
||||
align(ic, 'center');
|
||||
|
||||
// ── Row 3: Header row 1 (merged spans) ───────────────
|
||||
ws.addRow(['งวดที่','จำนวนวัน','ร้อยละ (%)','วันส่งมอบงาน',
|
||||
'จำนวนเงินงวดนั้น (บาท)','','','จำนวนเงินสะสม (บาท)','','']);
|
||||
ws.mergeCells(3,1,4,1); ws.mergeCells(3,2,4,2);
|
||||
ws.mergeCells(3,3,4,3); ws.mergeCells(3,4,4,4);
|
||||
ws.mergeCells(3,5,3,7); ws.mergeCells(3,8,3,10);
|
||||
ws.getRow(3).height = 22;
|
||||
for (let c = 1; c <= NCOLS; c++) {
|
||||
const cell = ws.getCell(3, c);
|
||||
fill(cell, 'FF1A56A0');
|
||||
font(cell, { bold: true, size: 10, color: { argb: 'FFFFFFFF' } });
|
||||
align(cell, 'center');
|
||||
cell.border = bord;
|
||||
}
|
||||
|
||||
// ── Row 4: Header row 2 (sub-header) ─────────────────
|
||||
ws.addRow(['','','','','รวม','เงิน งปม.','เงินนอก','รวม','เงิน งปม.','เงินนอก']);
|
||||
ws.getRow(4).height = 18;
|
||||
for (let c = 5; c <= NCOLS; c++) {
|
||||
const cell = ws.getCell(4, c);
|
||||
fill(cell, 'FF2A6ABF');
|
||||
font(cell, { bold: true, size: 10, color: { argb: 'FFFFFFFF' } });
|
||||
align(cell, 'center');
|
||||
cell.border = bord;
|
||||
}
|
||||
|
||||
// ── Data rows ─────────────────────────────────────────
|
||||
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;
|
||||
const isLast = i === installments.length - 1;
|
||||
cumDays += days;
|
||||
|
||||
const total = isLast ? (contract - cumTotal) : Math.round(contract * pct / 100);
|
||||
const total = isLast ? (contract - cumTotal) : Math.round(contract * pct / 100);
|
||||
const ngpm = isLast ? (Math.round(contract * ngpmPct / 100) - cumNgpm) : 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]);
|
||||
const even = i % 2 === 1;
|
||||
ws.addRow([i+1, days, pct/100, delivery, total, ngpm, nook, cumTotal, cumNgpm, cumNook]);
|
||||
const rn = 5 + i;
|
||||
ws.getRow(rn).height = 18;
|
||||
for (let c = 1; c <= NCOLS; c++) {
|
||||
const cell = ws.getCell(rn, c);
|
||||
const isAcc = c >= 8;
|
||||
fill(cell, isAcc ? (even ? 'FFD8E8F5' : 'FFE8F0FB') : (even ? 'FFF5F8FF' : 'FFFFFFFF'));
|
||||
font(cell, { size: 10, color: { argb: isAcc ? 'FF1A56A0' : 'FF222222' }, bold: isAcc });
|
||||
cell.border = bord;
|
||||
if (c === 1 || c === 2 || c === 4) { align(cell, 'center'); }
|
||||
else if (c === 3) { cell.numFmt = '0.00%'; align(cell, 'center'); }
|
||||
else { cell.numFmt = '#,##0'; align(cell, 'right'); }
|
||||
}
|
||||
});
|
||||
|
||||
rows.push([
|
||||
'รวม', '', installments.reduce((s,r) => s + (parseFloat(r.percent)||0), 0).toFixed(2),
|
||||
'',
|
||||
// ── Total row ─────────────────────────────────────────
|
||||
const sumPct = installments.reduce((s, r) => s + (parseFloat(r.percent) || 0), 0);
|
||||
const tRn = 5 + installments.length;
|
||||
ws.addRow(['รวมทั้งสิ้น','', sumPct/100,'',
|
||||
Math.round(contract), Math.round(contract*ngpmPct/100), Math.round(contract*nookPct/100),
|
||||
'', '', ''
|
||||
]);
|
||||
'','','']);
|
||||
ws.mergeCells(tRn, 1, tRn, 2);
|
||||
ws.getRow(tRn).height = 22;
|
||||
for (let c = 1; c <= NCOLS; c++) {
|
||||
const cell = ws.getCell(tRn, c);
|
||||
fill(cell, 'FFDCE8F8');
|
||||
font(cell, { bold: true, size: 10, color: { argb: 'FF1A56A0' } });
|
||||
cell.border = { ...bord, top: medT };
|
||||
if (c === 1) { align(cell, 'center'); }
|
||||
else if (c === 3) { cell.numFmt = '0.00%'; align(cell, 'center'); }
|
||||
else if (c >= 5 && c <= 7) { cell.numFmt = '#,##0'; align(cell, 'right'); }
|
||||
}
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
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()+543}${String(today.getMonth()+1).padStart(2,'0')}${String(today.getDate()).padStart(2,'0')}.xlsx`;
|
||||
XLSX.writeFile(wb, fname);
|
||||
// ── Download ──────────────────────────────────────────
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const now = new Date();
|
||||
a.download = `งวดงาน_${now.getFullYear()+543}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
document.getElementById('contractAmount').addEventListener('input', recalcAll);
|
||||
document.getElementById('startDate').addEventListener('change', recalcAll);
|
||||
// Init date dropdowns
|
||||
(function() {
|
||||
const dayEl = document.getElementById('startDay');
|
||||
for (let d = 1; d <= 31; d++) {
|
||||
const o = document.createElement('option');
|
||||
o.value = d; o.textContent = d;
|
||||
dayEl.appendChild(o);
|
||||
}
|
||||
const yearEl = document.getElementById('startYear');
|
||||
const curBE = new Date().getFullYear() + 543;
|
||||
for (let y = 2540; y <= 2590; y++) {
|
||||
const o = document.createElement('option');
|
||||
o.value = y; o.textContent = y;
|
||||
yearEl.appendChild(o);
|
||||
}
|
||||
})();
|
||||
|
||||
function onStartDateChange() {
|
||||
const d = parseInt(document.getElementById('startDay').value) || 0;
|
||||
const m = parseInt(document.getElementById('startMonth').value) || 0;
|
||||
const y = parseInt(document.getElementById('startYear').value) || 0;
|
||||
const hidden = document.getElementById('startDate');
|
||||
if (d && m && y) {
|
||||
const ce = y - 543;
|
||||
hidden.value = `${ce}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
} else {
|
||||
hidden.value = '';
|
||||
}
|
||||
recalcAll();
|
||||
}
|
||||
document.getElementById('ngpmPct').addEventListener('input', function() {
|
||||
const v = parseFloat(this.value) || 0;
|
||||
document.getElementById('nookPct').value = (100 - v).toFixed(2);
|
||||
|
||||
Reference in New Issue
Block a user