Download data source/index.html from Pali05/P_A: direct link, hf CLI and curl.
- Browser
- Download file 71.9 kB
-
https://huggingface.co/spaces/Pali05/P_A/resolve/main/data%20source/index.html
- Command line
-
hf download 'hf://spaces/Pali05/P_A/data source/index.html'
-
curl -L -o index.html https://huggingface.co/spaces/Pali05/P_A/resolve/main/data%20source/index.html
71.9 kB
| <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> |