P_A / data source /index.html
Pali05's picture
Upload 2 files
20b59f5 verified
Raw
History Blame Contribute Delete
71.9 kB
<!DOCTYPE html>
<html lang="th" class="h-full bg-slate-900 text-slate-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ระบบติดตามและวิเคราะห์งบประมาณรายจ่าย มรพส.</title>
<!-- Google Fonts: Sarabun & JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Sarabun:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS (Play CDN v4) -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- PapaParse for CSV, SheetJS for Excel and Export -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
body {
font-family: 'Sarabun', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-variant-numeric: tabular-nums;
}
/* Custom scrollbar for slate theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
</style>
</head>
<body class="h-full flex flex-col md:flex-row overflow-hidden bg-slate-900">
<!-- Mobile Header / Top Bar -->
<header class="md:hidden flex items-center justify-between bg-slate-800 px-6 py-4 border-b border-slate-700 z-30">
<div class="flex items-center space-x-3">
<svg class="w-8 h-8 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<span class="font-bold text-lg text-slate-100 tracking-wide">ทะเบียนคุมเบิก มรพส.</span>
</div>
<button id="mobileMenuBtn" class="text-slate-100 hover:text-indigo-400 focus:outline-none" aria-label="Toggle Menu">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path id="menuIcon" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</header>
<!-- Sidebar (Desktop Fixed, Mobile Collapsible) -->
<aside id="sidebar" class="hidden md:flex md:flex-col w-full md:w-80 bg-slate-800 border-r border-slate-700 shrink-0 z-20 flex-col overflow-y-auto">
<!-- Brand Signature -->
<div class="hidden md:flex items-center space-x-3 px-6 py-6 border-b border-slate-700">
<div class="p-2 bg-indigo-500/10 rounded-lg">
<svg class="w-8 h-8 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div>
<h1 class="font-bold text-slate-100 text-sm leading-snug">วิเคราะห์งบประมาณเบิกจ่าย</h1>
<p class="text-xs text-slate-400 font-mono font-bold">มรพส. DASHBOARD</p>
</div>
</div>
<!-- Sidebar Content: Filters & Controls -->
<div class="p-6 space-y-6 flex-1">
<div class="space-y-2">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wider block">ค้นหารายการ</label>
<div class="relative">
<input type="text" id="filterSearch" placeholder="ระบุรายการเบิกจ่าย..." class="w-full bg-slate-900 border border-slate-700 rounded-lg py-2 pl-10 pr-4 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all">
<div class="absolute left-3 top-2.5 text-slate-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
</div>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wider block">หมวดรายจ่าย</label>
<select id="filterCategory" class="w-full bg-slate-900 border border-slate-700 rounded-lg py-2 px-3 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all">
<option value="">ทั้งหมด ทุกหมวดรายจ่าย</option>
</select>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wider block">ผู้ขอเบิก</label>
<select id="filterRequester" class="w-full bg-slate-900 border border-slate-700 rounded-lg py-2 px-3 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all">
<option value="">ทั้งหมด ทุกผู้ขอเบิก</option>
</select>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wider block">ผู้บันทึก (Created By)</label>
<select id="filterCreator" class="w-full bg-slate-900 border border-slate-700 rounded-lg py-2 px-3 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all">
<option value="">ทั้งหมด ทุกผู้บันทึก</option>
</select>
</div>
<div class="pt-4 border-t border-slate-700 space-y-3">
<button id="resetFiltersBtn" class="w-full bg-slate-700/50 hover:bg-slate-700 text-slate-200 hover:text-white rounded-lg py-2 px-4 text-xs font-semibold transition-all flex items-center justify-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 15H19M9 5h7a2 2 0 012 2v1"></path></svg>
<span>ล้างตัวกรองทั้งหมด</span>
</button>
</div>
</div>
<!-- Sticky Footer Info in Sidebar -->
<div class="p-4 border-t border-slate-700 bg-slate-800/80 backdrop-blur text-xs text-slate-400">
<div class="flex items-center justify-between mb-1">
<span>สถานะตัวกรอง:</span>
<span id="badgeFilteredCount" class="font-bold text-cyan-400 font-mono">0 / 0 รายการ</span>
</div>
<p class="text-[10px] text-slate-500 text-center mt-2 font-mono">Powered by Canvas & Chart.js</p>
</div>
</aside>
<!-- Main Content Container -->
<main class="flex-1 flex flex-col overflow-y-auto overflow-x-hidden min-h-0 bg-slate-900">
<!-- Top bar with active info and Actions -->
<div class="bg-slate-800/50 border-b border-slate-700 px-6 py-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 shrink-0">
<div>
<h2 class="text-xl font-bold text-slate-100">ระบบติดตามและวิเคราะห์งบประมาณรายจ่าย</h2>
<p class="text-xs text-slate-400 mt-1">ทะเบียนคุมหลักฐานการเบิก มหาวิทยาลัยราชภัฏพิบูลสงคราม (มรพส.)</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<!-- Action: Download Export Cleaned File -->
<button id="exportXlsxBtn" class="hidden bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold px-4 py-2.5 rounded-lg shadow-sm transition-all flex items-center space-x-2" aria-label="Export Cleaned Dataset to Excel">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>ดาวน์โหลดข้อมูลที่คลีนแล้ว (.xlsx)</span>
</button>
<!-- Action: Open Upload Modal manually -->
<button id="triggerUploadBtn" class="bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold px-4 py-2.5 rounded-lg shadow-sm transition-all flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
<span>อัปโหลดข้อมูล</span>
</button>
</div>
</div>
<!-- Alert Notification Box (Floating style, hidden unless activated) -->
<div id="alertBox" class="hidden mx-6 mt-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl flex items-start space-x-3 text-red-300">
<svg class="w-5 h-5 shrink-0 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="flex-1 text-sm">
<span id="alertMessage" class="font-medium"></span>
</div>
<button onclick="document.getElementById('alertBox').classList.add('hidden')" class="text-red-400 hover:text-red-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<!-- Dynamic Content Body -->
<div class="p-6 space-y-6 flex-1">
<!-- Data State Initial Dropzone Section (Visible if no data loaded) -->
<section id="uploadArea" class="bg-slate-800 border-2 border-dashed border-slate-700 rounded-2xl p-8 text-center max-w-4xl mx-auto my-12 transition-all hover:border-indigo-500/50">
<div class="max-w-md mx-auto space-y-6">
<div class="p-4 bg-indigo-500/10 rounded-full w-16 h-16 flex items-center justify-center mx-auto text-indigo-400">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-100">อัปโหลดไฟล์ ทะเบียนคุมการเบิกจ่าย</h3>
<p class="text-xs text-slate-400 mt-2">ลากและวางไฟล์หรือเลือกไฟล์ประเภท CSV, Excel (.xlsx, .xls) เพื่อทำการคลีนข้อมูลและวิเคราะห์แบบไดนามิกแบบออฟไลน์ทันที</p>
</div>
<div class="flex items-center justify-center space-x-3">
<label for="fileInput" class="cursor-pointer bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs px-5 py-3 rounded-lg transition-all shadow-md">
เลือกไฟล์จากคอมพิวเตอร์
</label>
<input type="file" id="fileInput" accept=".csv, .xlsx, .xls" class="hidden">
</div>
<div class="relative flex py-2 items-center">
<div class="flex-grow border-t border-slate-700"></div>
<span class="flex-shrink mx-4 text-slate-500 text-[10px] uppercase font-bold tracking-wider">หรือระบุลิงก์</span>
<div class="flex-grow border-t border-slate-700"></div>
</div>
<div class="space-y-3">
<label for="sheetUrlInput" class="text-xs font-semibold text-slate-400 block text-left">URL ของ Google Sheets (เผยแพร่เป็น CSV/เว็บ แล้ว):</label>
<div class="flex space-x-2">
<input type="url" id="sheetUrlInput" placeholder="https://docs.google.com/spreadsheets/d/e/.../pub?output=csv" class="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<button id="fetchUrlBtn" class="bg-slate-700 hover:bg-slate-600 text-slate-200 text-xs font-semibold px-4 py-2 rounded-lg transition-all shrink-0">นำเข้าข้อมูล</button>
</div>
</div>
<div class="pt-4 text-left border-t border-slate-700/50 text-[11px] text-slate-500 space-y-1">
<p class="font-semibold text-slate-400">ระบบจะทำการจัดกลุ่มข้อมูลและคลีนข้อมูลโดยอัตโนมัติ:</p>
<ul class="list-disc pl-4 space-y-0.5">
<li>ตรวจสอบคอลัมน์: วันที่, ผู้ขอเบิก, รายการ, หมวดรายจ่าย, จำนวนเงิน, วันที่ส่งคืน, รายการแก้ไข, วันที่รับคืน, created_by</li>
<li>แปลงวันที่พุทธศักราชสากลและแปลงเงินให้อยู่ในรูปแบบตัวเลขทศนิยมที่สมบูรณ์</li>
</ul>
</div>
</div>
</section>
<!-- Dashboard Content Panels (Hidden until data is successfully compiled) -->
<div id="dashboardPanels" class="hidden space-y-6">
<!-- KPI metrics dynamic row -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-6" aria-label="KPI Metrics Summary">
<!-- KPI Card 1: Total Spent -->
<article class="bg-slate-800 p-6 rounded-2xl border border-slate-700 shadow-lg relative overflow-hidden group hover:-translate-y-1 transition-all duration-300">
<div class="absolute -right-4 -bottom-4 text-indigo-500/10 transition-transform group-hover:scale-110 duration-500">
<svg class="w-32 h-32" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div class="flex justify-between items-start mb-4">
<div class="p-3 bg-indigo-500/10 text-indigo-400 rounded-xl">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<span class="text-xs font-bold text-slate-500 uppercase tracking-widest font-mono">Total Budget Spent</span>
</div>
<p class="text-xs text-slate-400 font-medium">ยอดรวมงบประมาณที่เบิกจ่าย</p>
<p id="kpiTotalSpent" class="text-3xl font-bold text-slate-100 font-mono mt-2 tracking-tight">0.00 <span class="text-sm font-sans font-normal text-slate-400">บาท</span></p>
</article>
<!-- KPI Card 2: Transaction count -->
<article class="bg-slate-800 p-6 rounded-2xl border border-slate-700 shadow-lg relative overflow-hidden group hover:-translate-y-1 transition-all duration-300">
<div class="absolute -right-4 -bottom-4 text-cyan-500/10 transition-transform group-hover:scale-110 duration-500">
<svg class="w-32 h-32" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path></svg>
</div>
<div class="flex justify-between items-start mb-4">
<div class="p-3 bg-cyan-500/10 text-cyan-400 rounded-xl">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path></svg>
</div>
<span class="text-xs font-bold text-slate-500 uppercase tracking-widest font-mono">Total Transactions</span>
</div>
<p class="text-xs text-slate-400 font-medium">จำนวนรายการทั้งหมด</p>
<p id="kpiTotalCount" class="text-3xl font-bold text-slate-100 font-mono mt-2 tracking-tight">0 <span class="text-sm font-sans font-normal text-slate-400">รายการ</span></p>
</article>
<!-- KPI Card 3: Returned / Flagged Items -->
<article class="bg-slate-800 p-6 rounded-2xl border border-slate-700 shadow-lg relative overflow-hidden group hover:-translate-y-1 transition-all duration-300">
<div class="absolute -right-4 -bottom-4 text-amber-500/10 transition-transform group-hover:scale-110 duration-500">
<svg class="w-32 h-32" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
</div>
<div class="flex justify-between items-start mb-4">
<div class="p-3 bg-amber-500/10 text-amber-400 rounded-xl">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
</div>
<span class="text-xs font-bold text-slate-500 uppercase tracking-widest font-mono">Flagged/Returned Items</span>
</div>
<p class="text-xs text-slate-400 font-medium">รายการที่มีการส่งคืนหรือส่งแก้ไข</p>
<p id="kpiFlaggedCount" class="text-3xl font-bold text-amber-400 font-mono mt-2 tracking-tight">0 <span class="text-sm font-sans font-normal text-slate-400">รายการ</span></p>
</article>
</section>
<!-- Visual Charts Section (Grid: Main and supporting) -->
<section class="grid grid-cols-1 lg:grid-cols-12 gap-6" aria-label="Visual Budget Analysis">
<!-- Main Horizontal Bar Chart - Category wise spend -->
<div class="bg-slate-800 p-6 rounded-2xl border border-slate-700 shadow-lg lg:col-span-8 flex flex-col justify-between">
<div class="flex items-center justify-between mb-4 border-b border-slate-700/50 pb-3">
<div>
<h3 class="font-bold text-slate-100 text-sm">งบประมาณจำแนกตามหมวดรายจ่าย</h3>
<p class="text-xs text-slate-400">เรียงตามยอดรวมการเบิกจ่ายจากสูงสุดไปน้อยสุด</p>
</div>
<button onclick="downloadChartAsPNG('categoryChart', 'งบประมาณจำแนกตามหมวดรายจ่าย')" class="text-xs text-slate-400 hover:text-indigo-400 flex items-center space-x-1" aria-label="Save chart image">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
<span>เซฟรูปภาพ</span>
</button>
</div>
<div class="relative flex-1 min-h-[320px]">
<canvas id="categoryChart"></canvas>
</div>
</div>
<!-- Workload Distribution - Creator (Donut) -->
<div class="bg-slate-800 p-6 rounded-2xl border border-slate-700 shadow-lg lg:col-span-4 flex flex-col justify-between">
<div class="flex items-center justify-between mb-4 border-b border-slate-700/50 pb-3">
<div>
<h3 class="font-bold text-slate-100 text-sm">การกระจายตัวของภาระงาน</h3>
<p class="text-xs text-slate-400">สัดส่วนข้อมูลตามผู้บันทึก (created_by)</p>
</div>
<button onclick="downloadChartAsPNG('workloadChart', 'สัดส่วนตามผู้บันทึก')" class="text-xs text-slate-400 hover:text-indigo-400 flex items-center space-x-1" aria-label="Save chart image">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
<span>เซฟรูปภาพ</span>
</button>
</div>
<div class="relative flex-1 min-h-[260px] flex items-center justify-center">
<canvas id="workloadChart"></canvas>
</div>
</div>
<!-- Time Line Chart - Budget trend over time -->
<div class="bg-slate-800 p-6 rounded-2xl border border-slate-700 shadow-lg lg:col-span-12 flex flex-col justify-between">
<div class="flex items-center justify-between mb-4 border-b border-slate-700/50 pb-3">
<div>
<h3 class="font-bold text-slate-100 text-sm">แนวโน้มการสะสมงบประมาณเบิกจ่ายตามช่วงเวลา</h3>
<p class="text-xs text-slate-400">แกนเวลาแสดงข้อมูลที่ถูกจัดรูปแบบสากลเรียงตามลำดับเหตุการณ์จริง</p>
</div>
<button onclick="downloadChartAsPNG('trendChart', 'แนวโน้มงบประมาณสะสม')" class="text-xs text-slate-400 hover:text-indigo-400 flex items-center space-x-1" aria-label="Save chart image">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
<span>เซฟรูปภาพ</span>
</button>
</div>
<div class="relative flex-1 min-h-[240px]">
<canvas id="trendChart"></canvas>
</div>
</div>
</section>
<!-- Clean Data Table Section -->
<section class="bg-slate-800 rounded-2xl border border-slate-700 shadow-lg overflow-hidden flex flex-col" aria-label="Main Data Table">
<div class="p-6 border-b border-slate-700 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h3 class="font-bold text-slate-100 text-sm">ตารางรายละเอียดหลักฐานการเบิกจ่าย</h3>
<p class="text-xs text-slate-400 mt-0.5">เรียงลำดับตามวันที่ทำรายการ (พยากรณ์ข้อมูลปรับระดับการแสดงผลทีละ 20 บรรทัด)</p>
</div>
<div class="flex items-center space-x-2 text-xs">
<span class="text-slate-400">แสดงรายการ: </span>
<span id="paginationRange" class="font-bold text-indigo-400 font-mono">0 - 0</span>
<span class="text-slate-400">จากทั้งหมด</span>
<span id="paginationTotal" class="font-bold text-slate-100 font-mono">0</span>
</div>
</div>
<!-- Responsive Table Wrapper -->
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse whitespace-nowrap">
<thead>
<tr class="bg-slate-900 border-b border-slate-700 text-xs font-bold text-slate-300 uppercase tracking-wider sticky top-0 z-10">
<th class="py-3.5 px-4 text-center">วันที่เบิก</th>
<th class="py-3.5 px-4">ผู้ขอเบิก</th>
<th class="py-3.5 px-4">รายการ</th>
<th class="py-3.5 px-4">หมวดรายจ่าย</th>
<th class="py-3.5 px-4 text-right">จำนวนเงิน (บาท)</th>
<th class="py-3.5 px-4 text-center">วันที่ส่งคืน / แก้ไข</th>
<th class="py-3.5 px-4">รายการแก้ไข</th>
<th class="py-3.5 px-4 text-center">วันที่รับคืน</th>
<th class="py-3.5 px-4 text-center">ผู้บันทึก</th>
</tr>
</thead>
<tbody id="tableBody" class="divide-y divide-slate-700/60 text-xs text-slate-300">
<!-- Dynamic Rows Inserted Here -->
</tbody>
</table>
</div>
<!-- Client-Side Pagination Controls -->
<div class="bg-slate-900/60 border-t border-slate-700 px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="text-xs text-slate-400">
หน้า <span id="currentPageNum" class="font-bold text-slate-100 font-mono">1</span> ใน <span id="totalPageNum" class="font-bold text-slate-100 font-mono">1</span> หน้า
</div>
<div class="flex items-center space-x-2">
<button id="prevPageBtn" class="bg-slate-800 hover:bg-slate-700 disabled:opacity-40 disabled:hover:bg-slate-800 text-slate-200 text-xs font-semibold px-4 py-2 rounded-lg transition-all flex items-center space-x-1" disabled>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
<span>ก่อนหน้า</span>
</button>
<button id="nextPageBtn" class="bg-slate-800 hover:bg-slate-700 disabled:opacity-40 disabled:hover:bg-slate-800 text-slate-200 text-xs font-semibold px-4 py-2 rounded-lg transition-all flex items-center space-x-1" disabled>
<span>ถัดไป</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</button>
</div>
</div>
</section>
</div>
</div>
</main>
<!-- Upload/Reload Floating Modal Backdrop (Can be toggled) -->
<div id="uploadModal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-slate-800 border border-slate-700 rounded-2xl max-w-lg w-full overflow-hidden shadow-2xl flex flex-col">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-700">
<h3 class="font-bold text-slate-100">นำเข้าชุดข้อมูลเบิกจ่าย</h3>
<button onclick="toggleUploadModal(false)" class="text-slate-400 hover:text-slate-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="p-6 space-y-4">
<div id="modalDragZone" class="border-2 border-dashed border-slate-600 rounded-xl p-8 text-center hover:border-indigo-500/50 transition-all cursor-pointer">
<p class="text-xs text-slate-300">คลิกที่นี่ หรือ ลากไฟล์ CSV / Excel มาวางในกรอบนี้</p>
<input type="file" id="modalFileInput" accept=".csv, .xlsx, .xls" class="hidden">
</div>
<div class="space-y-2">
<label for="modalUrlInput" class="text-xs font-semibold text-slate-400 block">ลิงก์ Google Sheets สาธารณะ:</label>
<div class="flex space-x-2">
<input type="url" id="modalUrlInput" placeholder="https://docs.google.com/spreadsheets/d/e/.../pub?output=csv" class="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<button id="modalFetchUrlBtn" class="bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold px-4 py-2 rounded-lg transition-all">ดึงข้อมูล</button>
</div>
</div>
</div>
</div>
</div>
<!-- Script Application Logic -->
<script>
// Global State Variables
let rawDataset = [];
let cleanedDataset = [];
let filteredDataset = [];
let currentPage = 1;
const rowsPerPage = 20;
// Chart References
let categoryChartInstance = null;
let workloadChartInstance = null;
let trendChartInstance = null;
// Configuration Map for columns matching (various spelling variations of source files)
const columnMap = {
date: ['วันที่', 'วันที่เบิก', 'date', 'Date'],
requester: ['ผู้ขอเบิก', 'requester', 'Requester', 'ชื่อผู้ขอเบิก'],
item: ['รายการ', 'รายการเบิก', 'item', 'Item', 'รายละเอียด'],
category: ['หมวดรายจ่าย', 'หมวด', 'category', 'Category', 'ประเภทรายจ่าย'],
amount: ['จำนวนเงิน', 'เงิน', 'ยอดเงิน', 'amount', 'Amount', 'จำนวนเงินเบิก'],
returnDate: ['วันที่ส่งคืน', 'ส่งคืน', 'return_date', 'ReturnDate'],
revisions: ['รายการแก้ไข', 'แก้ไข', 'revisions', 'Revision', 'สาเหตุที่แก้ไข'],
receiveReturnDate: ['วันที่รับคืน', 'รับคืน', 'receive_return_date', 'ReceiveDate'],
createdBy: ['created_by', 'ผู้บันทึก', 'createdby', 'ผู้ทำรายการ']
};
// --- Core Application Initialization ---
window.addEventListener('DOMContentLoaded', () => {
initMobileMenu();
initListeners();
// Pre-load default CSV dataset if it exists
attemptDefaultLoad();
});
// Initialize Mobile Navigation Menu Interaction
function initMobileMenu() {
const btn = document.getElementById('mobileMenuBtn');
const sidebar = document.getElementById('sidebar');
btn.addEventListener('click', () => {
sidebar.classList.toggle('hidden');
sidebar.classList.toggle('absolute');
sidebar.classList.toggle('top-[65px]');
sidebar.classList.toggle('left-0');
sidebar.classList.toggle('right-0');
sidebar.classList.toggle('bottom-0');
});
}
// Initialize general event listeners
function initListeners() {
// Drag and Drop implementation for files
const mainDropzone = document.getElementById('uploadArea');
const modalDropzone = document.getElementById('modalDragZone');
[mainDropzone, modalDropzone].forEach(zone => {
if(!zone) return;
zone.addEventListener('dragover', (e) => {
e.preventDefault();
zone.classList.add('border-indigo-500', 'bg-indigo-500/5');
});
zone.addEventListener('dragleave', () => {
zone.classList.remove('border-indigo-500', 'bg-indigo-500/5');
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('border-indigo-500', 'bg-indigo-500/5');
const files = e.dataTransfer.files;
if(files.length > 0) {
handleUploadedFile(files[0]);
}
});
});
// Local Computer file input triggers
document.getElementById('fileInput').addEventListener('change', (e) => {
if(e.target.files.length > 0) handleUploadedFile(e.target.files[0]);
});
document.getElementById('modalFileInput').addEventListener('change', (e) => {
if(e.target.files.length > 0) handleUploadedFile(e.target.files[0]);
});
document.getElementById('modalDragZone').addEventListener('click', () => {
document.getElementById('modalFileInput').click();
});
// Fetch from URL inputs
document.getElementById('fetchUrlBtn').addEventListener('click', () => {
const url = document.getElementById('sheetUrlInput').value.trim();
if(url) loadFromUrl(url);
});
document.getElementById('modalFetchUrlBtn').addEventListener('click', () => {
const url = document.getElementById('modalUrlInput').value.trim();
if(url) loadFromUrl(url);
});
// Dynamic filter listeners
document.getElementById('filterSearch').addEventListener('input', applyFilters);
document.getElementById('filterCategory').addEventListener('change', applyFilters);
document.getElementById('filterRequester').addEventListener('change', applyFilters);
document.getElementById('filterCreator').addEventListener('change', applyFilters);
document.getElementById('resetFiltersBtn').addEventListener('click', resetFilters);
// Trigger Modal Manual Action
document.getElementById('triggerUploadBtn').addEventListener('click', () => {
toggleUploadModal(true);
});
// Pagination Controls Action
document.getElementById('prevPageBtn').addEventListener('click', () => {
if(currentPage > 1) {
currentPage--;
renderTable();
}
});
document.getElementById('nextPageBtn').addEventListener('click', () => {
const totalPages = Math.ceil(filteredDataset.length / rowsPerPage);
if(currentPage < totalPages) {
currentPage++;
renderTable();
}
});
// Clean Export to Excel Action
document.getElementById('exportXlsxBtn').addEventListener('click', exportCleanedDataToExcel);
}
// Toggle Upload Modal Overlay view
function toggleUploadModal(visible) {
const modal = document.getElementById('uploadModal');
if (visible) {
modal.classList.remove('hidden');
} else {
modal.classList.add('hidden');
}
}
// Auto attempt load data from predefined path
async function attemptDefaultLoad() {
const path = './datasource/ทะเบียนคุมหลักฐานการเบิก_20260615_165709.csv';
try {
const response = await fetch(path);
if (!response.ok) throw new Error("Predefined dataset not present in datasource relative path.");
const dataText = await response.text();
processRawCsv(dataText);
} catch (err) {
console.log("No default fallback CSV loaded. Waiting for user interaction. Details: ", err.message);
}
}
// Load data from remote URL endpoint
async function loadFromUrl(url) {
try {
// If it's a standard Google Sheet sharing link, convert it to CSV output format
if (url.includes("docs.google.com/spreadsheets") && !url.includes("output=csv")) {
const match = url.match(/\/d\/([a-zA-Z0-9-_]+)/);
if (match && match[1]) {
url = `https://docs.google.com/spreadsheets/d/${match[1]}/export?format=csv`;
}
}
const response = await fetch(url);
if (!response.ok) throw new Error("ไม่สามารถดาวน์โหลดข้อมูลจาก URL ที่ระบุได้ กรุณาตรวจสอบสิทธิ์และการเชื่อมต่อ");
const text = await response.text();
processRawCsv(text);
toggleUploadModal(false);
} catch (err) {
showToastError(err.message);
}
}
// File Handler Routing
function handleUploadedFile(file) {
const extension = file.name.split('.').pop().toLowerCase();
if (extension === 'csv') {
const reader = new FileReader();
reader.onload = function(e) {
processRawCsv(e.target.result);
toggleUploadModal(false);
};
reader.readAsText(file, 'UTF-8');
} else if (['xlsx', 'xls'].includes(extension)) {
const reader = new FileReader();
reader.onload = function(e) {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, {type: 'array'});
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const csv = XLSX.utils.sheet_to_csv(worksheet);
processRawCsv(csv);
toggleUploadModal(false);
};
reader.readAsArrayBuffer(file);
} else {
showToastError("รองรับเฉพาะรูปแบบไฟล์ CSV, XLSX หรือ XLS เท่านั้น");
}
}
// CSV parsing using Papaparse
function processRawCsv(csvText) {
Papa.parse(csvText, {
header: true,
skipEmptyLines: true,
complete: function(results) {
if (results.data && results.data.length > 0) {
rawDataset = results.data;
document.getElementById('alertBox').classList.add('hidden');
cleanAndNormalizeDataset();
} else {
showToastError("ไม่สามารถวิเคราะห์โครงสร้างข้อมูลในไฟล์ได้ หรือไฟล์ไม่มีข้อมูล");
}
},
error: function(err) {
showToastError("ข้อผิดพลาดในการประมวลผลไฟล์: " + err.message);
}
});
}
// --- Data Cleaning, Parsing & Normalization Engine ---
function cleanAndNormalizeDataset() {
// Find key-value map indices based on headers in the parsed CSV
const sampleRow = rawDataset[0];
const actualHeaders = Object.keys(sampleRow);
const resolvedMap = {};
for (const [key, aliases] of Object.entries(columnMap)) {
resolvedMap[key] = actualHeaders.find(header =>
aliases.some(alias => header.trim().toLowerCase() === alias.toLowerCase())
) || null;
}
cleanedDataset = rawDataset.map(row => {
// Read values safely
let rawDate = resolvedMap.date ? row[resolvedMap.date] : '';
let rawRequester = resolvedMap.requester ? row[resolvedMap.requester] : '';
let rawItem = resolvedMap.item ? row[resolvedMap.item] : '';
let rawCategory = resolvedMap.category ? row[resolvedMap.category] : '';
let rawAmount = resolvedMap.amount ? row[resolvedMap.amount] : '0';
let rawReturnDate = resolvedMap.returnDate ? row[resolvedMap.returnDate] : '';
let rawRevisions = resolvedMap.revisions ? row[resolvedMap.revisions] : '';
let rawReceiveReturnDate = resolvedMap.receiveReturnDate ? row[resolvedMap.receiveReturnDate] : '';
let rawCreatedBy = resolvedMap.createdBy ? row[resolvedMap.createdBy] : '';
// 1. Trim text categories
const requester = String(rawRequester).trim() || 'ไม่ระบุ';
const item = String(rawItem).trim() || 'ไม่มีชื่อรายการ';
const category = String(rawCategory).trim() || 'ทั่วไป / อื่นๆ';
const createdBy = String(rawCreatedBy).trim() || 'System';
// 2. Amount Cleaning
let amount = parseFloat(String(rawAmount).replace(/,/g, '').trim());
if (isNaN(amount)) amount = 0.0;
// 3. Normalization of Thai Date to YYYY-MM-DD
const dateISO = parseThaiDateToISO(rawDate);
const returnDateClean = String(rawReturnDate).trim() ? String(rawReturnDate).trim() : '-';
const revisionsClean = String(rawRevisions).trim() ? String(rawRevisions).trim() : 'ไม่มีการแก้ไข';
const receiveReturnClean = String(rawReceiveReturnDate).trim() ? String(rawReceiveReturnDate).trim() : '-';
return {
date: dateISO,
rawDate: rawDate,
requester,
item,
category,
amount,
returnDate: returnDateClean,
revisions: revisionsClean,
receiveReturnDate: receiveReturnClean,
createdBy
};
});
// Remove duplicated records dynamically
const uniqueMap = new Map();
cleanedDataset.forEach(item => {
const uniqueKey = `${item.date}_${item.requester}_${item.item}_${item.amount}`;
uniqueMap.set(uniqueKey, item);
});
cleanedDataset = Array.from(uniqueMap.values());
// Build select dropdown option filters based on unique clean entries
populateDropdownFilters();
// Set default view setup
filteredDataset = [...cleanedDataset];
currentPage = 1;
// Display elements & charts updates
document.getElementById('uploadArea').classList.add('hidden');
document.getElementById('dashboardPanels').classList.remove('hidden');
document.getElementById('exportXlsxBtn').classList.remove('hidden');
updateOverviewMetrics();
renderVisualCharts();
renderTable();
}
// Parse varied Thai date formats to Standard ISO 'YYYY-MM-DD'
function parseThaiDateToISO(dateStr) {
if (!dateStr) return '2026-01-01'; // Fallback
let trimmed = dateStr.trim();
// Match formats: "1-พ.ค.-69", "10-พ.ค.-69"
const thMonthMap = {
'ม.ค.': '01', 'ก.พ.': '02', 'มี.ค.': '03', 'เม.ย.': '04', 'พ.ค.': '05', 'มิ.ย.': '06',
'ก.ค.': '07', 'ส.ค.': '08', 'ก.ย.': '09', 'ต.ค.': '10', 'พ.ย.': '11', 'ธ.ค.': '12',
'มกราคม': '01', 'กุมภาพันธ์': '02', 'มีนาคม': '03', 'เมษายน': '04', 'พฤษภาคม': '05', 'มิถุนายน': '06',
'กรกฎาคม': '07', 'สิงหาคม': '08', 'กันยายน': '09', 'ตุลาคม': '10', 'พฤศจิกายน': '11', 'ธันวาคม': '12'
};
// Standardize separator characters to slash/dash
trimmed = trimmed.replace(/\//g, '-').replace(/\s+/g, '-');
const parts = trimmed.split('-');
if (parts.length === 3) {
let day = parts[0].padStart(2, '0');
let monthStr = parts[1];
let yearStr = parts[2];
let month = thMonthMap[monthStr] || '01';
let year = parseInt(yearStr);
// Convert Thai Buddhist Era (พ.ศ.) to CE (ค.ศ.)
if (year > 2500) {
year = year - 543;
} else if (year < 100) {
// Two digit representation, e.g. '69' -> 2569 - 543 = 2026
year = (year + 2500) - 543;
}
return `${year}-${month}-${day}`;
}
// Normal fallback if pattern doesn't parse cleanly
return trimmed;
}
// Build dynamically filtered parameters in the navigation sidebar
function populateDropdownFilters() {
const categories = new Set();
const requesters = new Set();
const creators = new Set();
cleanedDataset.forEach(row => {
categories.add(row.category);
requesters.add(row.requester);
creators.add(row.createdBy);
});
const catSelect = document.getElementById('filterCategory');
const reqSelect = document.getElementById('filterRequester');
const creatorSelect = document.getElementById('filterCreator');
// Reset options except the first "All" option
catSelect.innerHTML = '<option value="">ทั้งหมด ทุกหมวดรายจ่าย</option>';
reqSelect.innerHTML = '<option value="">ทั้งหมด ทุกผู้ขอเบิก</option>';
creatorSelect.innerHTML = '<option value="">ทั้งหมด ทุกผู้บันทึก</option>';
Array.from(categories).sort().forEach(cat => {
catSelect.add(new Option(cat, cat));
});
Array.from(requesters).sort().forEach(req => {
reqSelect.add(new Option(req, req));
});
Array.from(creators).sort().forEach(creator => {
creatorSelect.add(new Option(creator, creator));
});
}
// --- Real-time Filter Logic ---
function applyFilters() {
const searchVal = document.getElementById('filterSearch').value.toLowerCase().trim();
const catVal = document.getElementById('filterCategory').value;
const reqVal = document.getElementById('filterRequester').value;
const creatorVal = document.getElementById('filterCreator').value;
filteredDataset = cleanedDataset.filter(row => {
const matchSearch = !searchVal ||
row.item.toLowerCase().includes(searchVal) ||
row.requester.toLowerCase().includes(searchVal) ||
row.revisions.toLowerCase().includes(searchVal);
const matchCat = !catVal || row.category === catVal;
const matchReq = !reqVal || row.requester === reqVal;
const matchCreator = !creatorVal || row.createdBy === creatorVal;
return matchSearch && matchCat && matchReq && matchCreator;
});
currentPage = 1; // Reset to page 1 on search
updateOverviewMetrics();
updateVisualChartsData();
renderTable();
}
// Reset filter inputs to original state
function resetFilters() {
document.getElementById('filterSearch').value = '';
document.getElementById('filterCategory').value = '';
document.getElementById('filterRequester').value = '';
document.getElementById('filterCreator').value = '';
applyFilters();
}
// Dynamic metrics display
function updateOverviewMetrics() {
const totalSpent = filteredDataset.reduce((sum, item) => sum + item.amount, 0);
const totalCount = filteredDataset.length;
// Count of flagged items where returnDate is present or revisions is not 'ไม่มีการแก้ไข'
const flaggedCount = filteredDataset.filter(item =>
(item.returnDate && item.returnDate !== '-') ||
(item.revisions && item.revisions !== 'ไม่มีการแก้ไข' && item.revisions !== '-')
).length;
document.getElementById('kpiTotalSpent').innerHTML = `${totalSpent.toLocaleString('th-TH', {minimumFractionDigits: 2, maximumFractionDigits: 2})} <span class="text-sm font-sans font-normal text-slate-400">บาท</span>`;
document.getElementById('kpiTotalCount').innerHTML = `${totalCount.toLocaleString('th-TH')} <span class="text-sm font-sans font-normal text-slate-400">รายการ</span>`;
document.getElementById('kpiFlaggedCount').innerHTML = `${flaggedCount.toLocaleString('th-TH')} <span class="text-sm font-sans font-normal text-slate-400">รายการ</span>`;
// Badge count tracker
document.getElementById('badgeFilteredCount').textContent = `พบข้อมูล ${totalCount.toLocaleString('th-TH')} จาก ${cleanedDataset.length.toLocaleString('th-TH')} รายการ`;
}
// Render Dynamic Data Table view
function renderTable() {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
const totalRows = filteredDataset.length;
const totalPages = Math.ceil(totalRows / rowsPerPage) || 1;
document.getElementById('currentPageNum').textContent = currentPage;
document.getElementById('totalPageNum').textContent = totalPages;
if (totalRows === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="py-8 text-center text-slate-500 font-medium">ไม่พบข้อมูลที่ตรงกับเงื่อนไขการค้นหา</td>
</tr>
`;
document.getElementById('paginationRange').textContent = '0 - 0';
document.getElementById('paginationTotal').textContent = '0';
document.getElementById('prevPageBtn').disabled = true;
document.getElementById('nextPageBtn').disabled = true;
return;
}
const startIndex = (currentPage - 1) * rowsPerPage;
const endIndex = Math.min(startIndex + rowsPerPage, totalRows);
document.getElementById('paginationRange').textContent = `${(startIndex + 1).toLocaleString('th-TH')} - ${endIndex.toLocaleString('th-TH')}`;
document.getElementById('paginationTotal').textContent = totalRows.toLocaleString('th-TH');
const pageData = filteredDataset.slice(startIndex, endIndex);
pageData.forEach((row, idx) => {
const tr = document.createElement('tr');
tr.className = idx % 2 === 0 ? 'bg-slate-800/30 hover:bg-slate-700/40' : 'bg-slate-800/80 hover:bg-slate-700/40';
// Condition for highlight status
const hasReturn = row.returnDate !== '-' || row.revisions !== 'ไม่มีการแก้ไข';
const statusBadge = hasReturn
? `<span class="bg-amber-500/10 text-amber-400 border border-amber-500/20 px-2 py-0.5 rounded text-[10px] font-semibold">ส่งคืน/แก้ไข</span>`
: `<span class="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded text-[10px] font-semibold">สมบูรณ์</span>`;
tr.innerHTML = `
<td class="py-3 px-4 text-center font-mono font-bold text-slate-400">${row.date}</td>
<td class="py-3 px-4 font-semibold text-slate-200 truncate max-w-[150px]" title="${row.requester}">${row.requester}</td>
<td class="py-3 px-4 font-medium truncate max-w-[220px]" title="${row.item}">${row.item}</td>
<td class="py-3 px-4"><span class="bg-slate-700 text-slate-300 px-2.5 py-1 rounded text-[11px] font-medium">${row.category}</span></td>
<td class="py-3 px-4 text-right font-mono font-bold text-cyan-400">${row.amount.toLocaleString('th-TH', {minimumFractionDigits: 2, maximumFractionDigits: 2})}</td>
<td class="py-3 px-4 text-center font-mono text-slate-400">${row.returnDate}</td>
<td class="py-3 px-4 truncate max-w-[180px] text-slate-400" title="${row.revisions}">${row.revisions}</td>
<td class="py-3 px-4 text-center font-mono text-slate-400">${row.receiveReturnDate}</td>
<td class="py-3 px-4 text-center">
<div class="flex items-center justify-center space-x-2">
<span class="text-slate-400 font-mono text-[11px]">${row.createdBy}</span>
${statusBadge}
</div>
</td>
`;
tbody.appendChild(tr);
});
document.getElementById('prevPageBtn').disabled = currentPage === 1;
document.getElementById('nextPageBtn').disabled = currentPage === totalPages;
}
// --- Analytical Charts Layer ---
function renderVisualCharts() {
// Destroy existing instances if present (on re-upload)
if(categoryChartInstance) categoryChartInstance.destroy();
if(workloadChartInstance) workloadChartInstance.destroy();
if(trendChartInstance) trendChartInstance.destroy();
// Config shared design tokens for dark theme look and feel
const chartStyles = {
gridColor: '#334155',
textColor: '#94a3b8',
fontFamily: "'Sarabun', sans-serif"
};
// 1. Horizontal Category Spend Chart setup
const catCtx = document.getElementById('categoryChart').getContext('2d');
categoryChartInstance = new Chart(catCtx, {
type: 'bar',
data: {
labels: [],
datasets: [{
label: 'ยอดรวมเบิกจ่าย (บาท)',
data: [],
backgroundColor: 'rgba(99, 102, 241, 0.8)', // Indigo
borderColor: 'rgb(129, 140, 248)',
borderWidth: 1.5,
borderRadius: 6
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
bodyFont: { family: "'JetBrains Mono', monospace" },
titleFont: { family: "'Sarabun', sans-serif" }
}
},
scales: {
x: {
grid: { color: chartStyles.gridColor },
ticks: {
color: chartStyles.textColor,
font: { family: "'JetBrains Mono', monospace" },
callback: function(value) { return value.toLocaleString('th-TH'); }
}
},
y: {
grid: { display: false },
ticks: { color: chartStyles.textColor, font: { family: chartStyles.fontFamily } }
}
}
}
});
// 2. Creator Workload Distribution (Donut Chart)
const workCtx = document.getElementById('workloadChart').getContext('2d');
workloadChartInstance = new Chart(workCtx, {
type: 'doughnut',
data: {
labels: [],
datasets: [{
data: [],
backgroundColor: [
'rgba(34, 211, 238, 0.8)', // Cyan
'rgba(99, 102, 241, 0.8)', // Indigo
'rgba(16, 185, 129, 0.8)', // Emerald
'rgba(245, 158, 11, 0.8)', // Amber
'rgba(239, 68, 68, 0.8)' // Red
],
borderWidth: 2,
borderColor: '#1e293b' // Match dark surface background
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
color: chartStyles.textColor,
font: { family: chartStyles.fontFamily, size: 10 }
}
},
tooltip: {
bodyFont: { family: "'JetBrains Mono', monospace" }
}
},
cutout: '65%'
}
});
// 3. Line Accumulation Trend Chart
const trendCtx = document.getElementById('trendChart').getContext('2d');
trendChartInstance = new Chart(trendCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'ยอดสะสมสะสม (บาท)',
data: [],
borderColor: 'rgb(34, 211, 238)', // Cyan focus
backgroundColor: 'rgba(34, 211, 238, 0.1)',
fill: true,
tension: 0.3,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
bodyFont: { family: "'JetBrains Mono', monospace" }
}
},
scales: {
x: {
grid: { color: chartStyles.gridColor },
ticks: {
color: chartStyles.textColor,
font: { family: "'JetBrains Mono', monospace" },
maxRotation: 45,
minRotation: 45
}
},
y: {
grid: { color: chartStyles.gridColor },
ticks: {
color: chartStyles.textColor,
font: { family: "'JetBrains Mono', monospace" },
callback: function(value) { return value.toLocaleString('th-TH'); }
}
}
}
}
});
updateVisualChartsData();
}
// Dynamically feeding dynamic values into Chart.js elements
function updateVisualChartsData() {
if(!categoryChartInstance) return;
// 1. Process & Sort Budget Categories
const catTotals = {};
filteredDataset.forEach(row => {
catTotals[row.category] = (catTotals[row.category] || 0) + row.amount;
});
// Convert to sorted array of objects
const sortedCats = Object.entries(catTotals)
.map(([name, val]) => ({ name, val }))
.sort((a, b) => b.val - a.val);
categoryChartInstance.data.labels = sortedCats.map(c => c.name);
categoryChartInstance.data.datasets[0].data = sortedCats.map(c => c.val);
categoryChartInstance.update();
// 2. Process workloads (by creator)
const creatorCounts = {};
filteredDataset.forEach(row => {
creatorCounts[row.createdBy] = (creatorCounts[row.createdBy] || 0) + 1;
});
workloadChartInstance.data.labels = Object.keys(creatorCounts);
workloadChartInstance.data.datasets[0].data = Object.values(creatorCounts);
workloadChartInstance.update();
// 3. Process accumulative timeline trend
// Sort data by ISO Date ascending
const sortedByDate = [...filteredDataset].sort((a, b) => new Date(a.date) - new Date(b.date));
const dateAggregates = {};
sortedByDate.forEach(row => {
dateAggregates[row.date] = (dateAggregates[row.date] || 0) + row.amount;
});
const dates = Object.keys(dateAggregates).sort();
let sumAccumulator = 0;
const accumulativeValues = dates.map(d => {
sumAccumulator += dateAggregates[d];
return sumAccumulator;
});
trendChartInstance.data.labels = dates;
trendChartInstance.data.datasets[0].data = accumulativeValues;
trendChartInstance.update();
}
// Utility: Export Cleaned Dataset back into clean formatting .XLSX via SheetJS
function exportCleanedDataToExcel() {
if (cleanedDataset.length === 0) {
showToastError("ไม่มีชุดข้อมูลที่สมบูรณ์สำหรับทำรายการส่งออกในขณะนี้");
return;
}
// Map keys into clean localized format header for accounting spreadsheet
const exportData = cleanedDataset.map(row => ({
'วันที่': row.date,
'ผู้ขอเบิก': row.requester,
'รายการ': row.item,
'หมวดรายจ่าย': row.category,
'จำนวนเงิน (บาท)': row.amount,
'วันที่ส่งคืน': row.returnDate,
'รายการแก้ไข': row.revisions,
'วันที่รับคืน': row.receiveReturnDate,
'ผู้ทำรายการ': row.createdBy
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "ทะเบียนคุมหลักฐานการเบิก_Clean");
// Generate filename with timestamp
const now = new Date();
const dateStr = now.toISOString().split('T')[0].replace(/-/g, '');
const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '');
XLSX.writeFile(workbook, `ทะเบียนคุมหลักฐานการเบิก_คลีนแล้ว_${dateStr}_${timeStr}.xlsx`);
}
// Helper to allow downloading images of dynamic Chart configurations
function downloadChartAsPNG(canvasId, title) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
// Generate temporary image link
const url = canvas.toDataURL("image/png");
const link = document.createElement('a');
link.download = `${title}.png`;
link.href = url;
link.click();
}
// Alert message interface builder
function showToastError(msg) {
const alertBox = document.getElementById('alertBox');
const alertMsg = document.getElementById('alertMessage');
alertMsg.textContent = msg;
alertBox.classList.remove('hidden');
// Auto scroll to message
alertBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
</script>
</body>
</html>