| <!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> |
| |
| <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"> |
| |
| |
| <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> |
| |
| |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> |
| |
| |
| <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; |
| } |
| |
| ::-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"> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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 class="flex-1 flex flex-col overflow-y-auto overflow-x-hidden min-h-0 bg-slate-900"> |
|
|
| |
| <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"> |
| |
| <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> |
| |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="p-6 space-y-6 flex-1"> |
|
|
| |
| <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> |
|
|
| |
| <div id="dashboardPanels" class="hidden space-y-6"> |
| |
| |
| <section class="grid grid-cols-1 md:grid-cols-3 gap-6" aria-label="KPI Metrics Summary"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <section class="grid grid-cols-1 lg:grid-cols-12 gap-6" aria-label="Visual Budget Analysis"> |
| |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| </tbody> |
| </table> |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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> |
| |
| let rawDataset = []; |
| let cleanedDataset = []; |
| let filteredDataset = []; |
| |
| let currentPage = 1; |
| const rowsPerPage = 20; |
| |
| |
| let categoryChartInstance = null; |
| let workloadChartInstance = null; |
| let trendChartInstance = null; |
| |
| |
| 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', 'ผู้ทำรายการ'] |
| }; |
| |
| |
| window.addEventListener('DOMContentLoaded', () => { |
| initMobileMenu(); |
| initListeners(); |
| |
| attemptDefaultLoad(); |
| }); |
| |
| |
| 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'); |
| }); |
| } |
| |
| |
| function initListeners() { |
| |
| 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]); |
| } |
| }); |
| }); |
| |
| |
| 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(); |
| }); |
| |
| |
| 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); |
| }); |
| |
| |
| 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); |
| |
| |
| document.getElementById('triggerUploadBtn').addEventListener('click', () => { |
| toggleUploadModal(true); |
| }); |
| |
| |
| 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(); |
| } |
| }); |
| |
| |
| document.getElementById('exportXlsxBtn').addEventListener('click', exportCleanedDataToExcel); |
| } |
| |
| |
| function toggleUploadModal(visible) { |
| const modal = document.getElementById('uploadModal'); |
| if (visible) { |
| modal.classList.remove('hidden'); |
| } else { |
| modal.classList.add('hidden'); |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| async function loadFromUrl(url) { |
| try { |
| |
| 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); |
| } |
| } |
| |
| |
| 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 เท่านั้น"); |
| } |
| } |
| |
| |
| 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); |
| } |
| }); |
| } |
| |
| |
| function cleanAndNormalizeDataset() { |
| |
| 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 => { |
| |
| 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] : ''; |
| |
| |
| const requester = String(rawRequester).trim() || 'ไม่ระบุ'; |
| const item = String(rawItem).trim() || 'ไม่มีชื่อรายการ'; |
| const category = String(rawCategory).trim() || 'ทั่วไป / อื่นๆ'; |
| const createdBy = String(rawCreatedBy).trim() || 'System'; |
| |
| |
| let amount = parseFloat(String(rawAmount).replace(/,/g, '').trim()); |
| if (isNaN(amount)) amount = 0.0; |
| |
| |
| 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 |
| }; |
| }); |
| |
| |
| 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()); |
| |
| |
| populateDropdownFilters(); |
| |
| |
| filteredDataset = [...cleanedDataset]; |
| currentPage = 1; |
| |
| |
| document.getElementById('uploadArea').classList.add('hidden'); |
| document.getElementById('dashboardPanels').classList.remove('hidden'); |
| document.getElementById('exportXlsxBtn').classList.remove('hidden'); |
| |
| updateOverviewMetrics(); |
| renderVisualCharts(); |
| renderTable(); |
| } |
| |
| |
| function parseThaiDateToISO(dateStr) { |
| if (!dateStr) return '2026-01-01'; |
| |
| let trimmed = dateStr.trim(); |
| |
| 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' |
| }; |
| |
| |
| 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); |
| |
| |
| if (year > 2500) { |
| year = year - 543; |
| } else if (year < 100) { |
| |
| year = (year + 2500) - 543; |
| } |
| return `${year}-${month}-${day}`; |
| } |
| |
| |
| return trimmed; |
| } |
| |
| |
| 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'); |
| |
| |
| 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)); |
| }); |
| } |
| |
| |
| 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; |
| updateOverviewMetrics(); |
| updateVisualChartsData(); |
| renderTable(); |
| } |
| |
| |
| function resetFilters() { |
| document.getElementById('filterSearch').value = ''; |
| document.getElementById('filterCategory').value = ''; |
| document.getElementById('filterRequester').value = ''; |
| document.getElementById('filterCreator').value = ''; |
| applyFilters(); |
| } |
| |
| |
| function updateOverviewMetrics() { |
| const totalSpent = filteredDataset.reduce((sum, item) => sum + item.amount, 0); |
| const totalCount = filteredDataset.length; |
| |
| |
| 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>`; |
| |
| |
| document.getElementById('badgeFilteredCount').textContent = `พบข้อมูล ${totalCount.toLocaleString('th-TH')} จาก ${cleanedDataset.length.toLocaleString('th-TH')} รายการ`; |
| } |
| |
| |
| 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'; |
| |
| |
| 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; |
| } |
| |
| |
| function renderVisualCharts() { |
| |
| if(categoryChartInstance) categoryChartInstance.destroy(); |
| if(workloadChartInstance) workloadChartInstance.destroy(); |
| if(trendChartInstance) trendChartInstance.destroy(); |
| |
| |
| const chartStyles = { |
| gridColor: '#334155', |
| textColor: '#94a3b8', |
| fontFamily: "'Sarabun', sans-serif" |
| }; |
| |
| |
| 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)', |
| 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 } } |
| } |
| } |
| } |
| }); |
| |
| |
| const workCtx = document.getElementById('workloadChart').getContext('2d'); |
| workloadChartInstance = new Chart(workCtx, { |
| type: 'doughnut', |
| data: { |
| labels: [], |
| datasets: [{ |
| data: [], |
| backgroundColor: [ |
| 'rgba(34, 211, 238, 0.8)', |
| 'rgba(99, 102, 241, 0.8)', |
| 'rgba(16, 185, 129, 0.8)', |
| 'rgba(245, 158, 11, 0.8)', |
| 'rgba(239, 68, 68, 0.8)' |
| ], |
| borderWidth: 2, |
| borderColor: '#1e293b' |
| }] |
| }, |
| 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%' |
| } |
| }); |
| |
| |
| const trendCtx = document.getElementById('trendChart').getContext('2d'); |
| trendChartInstance = new Chart(trendCtx, { |
| type: 'line', |
| data: { |
| labels: [], |
| datasets: [{ |
| label: 'ยอดสะสมสะสม (บาท)', |
| data: [], |
| borderColor: 'rgb(34, 211, 238)', |
| 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(); |
| } |
| |
| |
| function updateVisualChartsData() { |
| if(!categoryChartInstance) return; |
| |
| |
| const catTotals = {}; |
| filteredDataset.forEach(row => { |
| catTotals[row.category] = (catTotals[row.category] || 0) + row.amount; |
| }); |
| |
| 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(); |
| |
| |
| 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(); |
| |
| |
| |
| 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(); |
| } |
| |
| |
| function exportCleanedDataToExcel() { |
| if (cleanedDataset.length === 0) { |
| showToastError("ไม่มีชุดข้อมูลที่สมบูรณ์สำหรับทำรายการส่งออกในขณะนี้"); |
| return; |
| } |
| |
| |
| 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"); |
| |
| |
| 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`); |
| } |
| |
| |
| function downloadChartAsPNG(canvasId, title) { |
| const canvas = document.getElementById(canvasId); |
| if (!canvas) return; |
| |
| |
| const url = canvas.toDataURL("image/png"); |
| const link = document.createElement('a'); |
| link.download = `${title}.png`; |
| link.href = url; |
| link.click(); |
| } |
| |
| |
| function showToastError(msg) { |
| const alertBox = document.getElementById('alertBox'); |
| const alertMsg = document.getElementById('alertMessage'); |
| alertMsg.textContent = msg; |
| alertBox.classList.remove('hidden'); |
| |
| alertBox.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| } |
| </script> |
| </body> |
| </html> |