Files
gravity/จัดตารางเวร/index.php
T
2026-09-16 23:20:08 +07:00

1722 lines
87 KiB
PHP

<?php
session_start();
require_once 'config.php';
header('Content-Type: text/html; charset=utf-8'); // Override JSON header from config.php
if (isset($_GET['logout'])) {
session_destroy();
header("Location: index.php");
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password'])) {
if ($_POST['password'] === APP_PASSWORD) {
$_SESSION['logged_in'] = true;
header("Location: index.php");
exit;
} else {
$error = "รหัสผ่านไม่ถูกต้อง";
}
}
if (empty($_SESSION['logged_in'])) {
?>
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>เข้าสู่ระบบ - ระบบจัดตารางเวร</title>
<link rel="icon" href="logo.png" type="image/png">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>body { font-family: 'Sarabun', sans-serif; }</style>
</head>
<body class="bg-slate-100 min-h-screen flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-md">
<div class="flex justify-center mb-6">
<img src="logo.png" alt="Logo" class="w-24 h-24 object-contain rounded-full shadow-sm">
</div>
<h1 class="text-2xl font-bold text-center text-blue-900 mb-6">เข้าสู่ระบบจัดตารางเวร</h1>
<?php if (isset($error)): ?>
<div class="bg-red-50 border border-red-200 text-red-600 text-sm px-4 py-3 rounded-md mb-4 text-center">
<?php echo $error; ?>
</div>
<?php endif; ?>
<form method="POST" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">รหัสผ่าน</label>
<input type="password" name="password" required autofocus
class="w-full border border-slate-300 rounded-md px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors">
เข้าสู่ระบบ
</button>
</form>
</div>
</body>
</html>
<?php
exit;
}
?>
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ระบบจัดตารางเวร - ศูนย์คอมพิวเตอร์</title>
<link rel="icon" href="logo.png" type="image/png">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;500;600;700&display=swap"
rel="stylesheet">
<style>
/* Shiny Logo Effect */
.logo-shiny {
position: relative;
overflow: hidden;
border-radius: 50%;
display: inline-block;
}
.logo-shiny::before {
content: '';
position: absolute;
top: 0;
left: -150%;
width: 50%;
height: 100%;
background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(255,255,255,0.8) 50%, rgba(255,255,255,0) 100%);
transform: skewX(-25deg);
animation: shine 3s infinite;
z-index: 10;
}
@keyframes shine {
0% { left: -150%; }
50% { left: 150%; }
100% { left: 150%; }
}
body {
font-family: 'Sarabun', sans-serif;
background-color: #f3f4f6;
}
/* Custom scrollbar for table container */
.table-container::-webkit-scrollbar {
height: 8px;
width: 8px;
}
.table-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Cell styles */
.cell-weekend {
background-color: #e2e8f0;
/* bg-slate-200 */
}
.cell-holiday {
background-color: #fca5a5;
/* bg-red-300 */
}
.text-circle {
display: inline-block;
width: 28px;
height: 28px;
line-height: 24px;
text-align: center;
border-radius: 50%;
border: 2px solid #334155;
font-weight: bold;
}
/* Shift colors */
.shift-m {
color: #2563eb;
font-weight: 600;
}
/* Morning: Blue */
.shift-a {
color: #16a34a;
font-weight: 600;
}
/* Afternoon: Green */
.shift-ma {
color: #9333ea;
font-weight: 600;
}
/* Morning/Afternoon: Purple */
/* Leave: Red */
.shift-l {
color: #dc2626;
font-weight: 600;
}
/* Meeting: Orange */
.shift-meet {
color: #f97316;
font-weight: 600;
}
/* Loading overlay */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
font-size: 1.5rem;
color: #334155;
}
.hidden-overlay {
display: none !important;
}
.exporting .hide-on-export {
display: none !important;
}
</style>
</head>
<body class="p-4 md:p-8">
<div id="loadingOverlay" class="hidden-overlay">
กำลังโหลดข้อมูล...
</div>
<div class="max-w-7xl mx-auto bg-white rounded-xl shadow-lg p-6 mb-8 relative">
<a href="?logout=1"
class="absolute top-4 right-4 sm:top-6 sm:right-6 whitespace-nowrap bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg text-xs sm:text-sm transition-all shadow-sm flex items-center gap-2 hover:shadow group">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transform group-hover:translate-x-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span class="hidden sm:inline">ออกจากระบบ</span>
</a>
<div class="flex flex-col items-center mb-6">
<div class="logo-shiny mb-4 w-24 h-24 sm:w-32 sm:h-32">
<img src="logo.png" alt="Logo" class="w-full h-full object-contain">
</div>
<h1 class="text-2xl md:text-3xl font-bold text-center text-slate-800 mb-2">
แบบฟอร์มตารางเวร ประจำเดือน <span id="monthYearDisplay"></span>
</h1>
<h2 class="text-lg text-center text-slate-600">งานศูนย์คอมพิวเตอร์ ฝ่ายคอมพิวเตอร์และสารสนเทศ</h2>
</div>
<div class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4">
<div class="flex gap-2">
<select id="monthSelect"
class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="0">มกราคม</option>
<option value="1">กุมภาพันธ์</option>
<option value="2">มีนาคม</option>
<option value="3">เมษายน</option>
<option value="4">พฤษภาคม</option>
<option value="5">มิถุนายน</option>
<option value="6">กรกฎาคม</option>
<option value="7">สิงหาคม</option>
<option value="8">กันยายน</option>
<option value="9">ตุลาคม</option>
<option value="10">พฤศจิกายน</option>
<option value="11">ธันวาคม</option>
</select>
<input type="number" id="yearSelect" value=""
class="border border-gray-300 rounded-md px-3 py-2 text-sm w-24 focus:outline-none focus:ring-2 focus:ring-blue-500">
<button id="updateMonthBtn"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm transition-colors shadow-sm">อัปเดต</button>
</div>
<div class="flex gap-2 text-sm flex-wrap justify-center">
<span class="inline-flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-blue-600"></span> เช้า
(ช)</span>
<span class="inline-flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-green-600"></span>
บ่าย (บ)</span>
<span class="inline-flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-purple-600"></span>
เช้า/บ่าย (ช/บ)</span>
<span class="inline-flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-red-600"></span> ลา
(ลา)</span>
<span class="inline-flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-orange-500"></span> ประชุม (ป)</span>
</div>
<div class="flex items-center gap-2 flex-wrap justify-center">
<button id="toggleLockBtn" onclick="toggleMonthLock()"
class="bg-slate-200 hover:bg-slate-300 text-slate-700 p-2 rounded-md transition-colors shadow-sm flex items-center justify-center" title="ล็อกเดือนนี้">
<svg id="lockIcon" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
<span id="lockText" class="hidden">ล็อกเดือนนี้</span>
</button>
<button onclick="autoGenerateSchedule()"
class="whitespace-nowrap bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-md text-sm transition-colors shadow-sm flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
จัดเวรอัตโนมัติ
</button>
<button id="exportImageBtn"
class="whitespace-nowrap bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm transition-colors shadow-sm flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
ส่งออกเป็นรูปภาพ
</button>
<button onclick="openDocuments()"
class="whitespace-nowrap bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm transition-colors shadow-sm flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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" />
</svg>
เอกสาร
</button>
</div>
</div>
<!-- Add an ID to the container we want to capture -->
<div id="captureArea" class="bg-white p-2">
<!-- Title for image export (hidden by default, shown during export) -->
<div id="exportHeader" class="hidden flex-col items-center mb-6">
<div class="mb-4 w-24 h-24 sm:w-32 sm:h-32">
<img src="logo.png" alt="Logo" class="w-full h-full object-contain">
</div>
<h1 class="text-2xl font-bold text-slate-800 text-center mb-2">
แบบฟอร์มตารางเวร ประจำเดือน <span id="exportMonthYear"></span>
</h1>
<h2 class="text-lg text-slate-600 text-center">งานศูนย์คอมพิวเตอร์ ฝ่ายคอมพิวเตอร์และสารสนเทศ</h2>
</div>
<!-- Shift Summary Box -->
<div id="shiftSummary" class="flex gap-4 mb-4 justify-end text-sm hide-on-export">
<div class="bg-blue-50 text-blue-800 px-3 py-1.5 rounded-md border border-blue-200 shadow-sm flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-blue-600"></span>
เวรเช้าคงเหลือ: <span id="remainMorning" class="font-bold text-lg leading-none">0</span> / <span id="totalMorning">0</span>
</div>
<div class="bg-green-50 text-green-800 px-3 py-1.5 rounded-md border border-green-200 shadow-sm flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-green-600"></span>
เวรบ่ายคงเหลือ: <span id="remainAfternoon" class="font-bold text-lg leading-none">0</span> / <span id="totalAfternoon">0</span>
</div>
</div>
<div class="overflow-x-auto table-container border border-slate-300 rounded-lg">
<table class="w-full text-sm text-center border-collapse whitespace-nowrap" id="scheduleTable">
<thead class="bg-slate-100 text-slate-700">
<tr id="daysHeaderRow">
<!-- จะถูกสร้างโดย JavaScript -->
</tr>
</thead>
<tbody id="scheduleBody">
<!-- จะถูกสร้างโดย JavaScript -->
</tbody>
</table>
</div>
<!-- ข้อมูลเพิ่มเติมส่วนท้ายตาราง (อ้างอิงตามรูปภาพตัวอย่าง) -->
<div class="mt-8 px-4 text-sm text-slate-700">
<div class="overflow-x-auto shadow-sm rounded-lg border border-slate-300">
<table class="w-full text-left border-collapse bg-white whitespace-nowrap">
<thead class="bg-blue-50 text-slate-800">
<tr>
<th class="py-3 px-4 border border-slate-300 font-bold" colspan="2">
<div class="flex items-center justify-between text-blue-900">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
</svg>
เวลา 24.00 - 08.00 น. สามารถติดต่อได้ที่
</div>
<button onclick="openAddStaffModal()" class="hide-on-export text-xs bg-blue-600 hover:bg-blue-700 text-white py-1 px-2 rounded flex items-center gap-1 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
เพิ่มบุคคล
</button>
</div>
</th>
<th class="py-2 px-3 border border-slate-300 font-bold text-center bg-slate-100" colspan="5">
ผู้ติดต่อหลักประจำวัน
<span class="block text-[10px] font-normal text-slate-500">* คลิกช่องด้านล่างเพื่อเปลี่ยน</span>
</th>
</tr>
<tr class="bg-slate-50 text-center">
<th class="py-2 px-4 border border-slate-300 w-12 text-slate-500 font-medium">ลำดับ</th>
<th class="py-2 px-4 border border-slate-300 text-left font-medium">ชื่อ - เบอร์โทรศัพท์</th>
<th class="py-2 px-3 border border-slate-300 font-medium w-12 bg-slate-100">จ</th>
<th class="py-2 px-3 border border-slate-300 font-medium w-12 bg-slate-100">อ</th>
<th class="py-2 px-3 border border-slate-300 font-medium w-12 bg-slate-100">พ</th>
<th class="py-2 px-3 border border-slate-300 font-medium w-12 bg-slate-100">พฤ</th>
<th class="py-2 px-3 border border-slate-300 font-medium w-12 bg-slate-100">ศ</th>
</tr>
</thead>
<tbody id="contactAndOncallBody">
<!-- จะถูกสร้างโดย JavaScript -->
</tbody>
</table>
</div>
</div>
</div> <!-- ปิด captureArea -->
</div> <!-- ปิด max-w-7xl -->
<!-- Edit Shift Modal -->
<div id="editModal" class="fixed inset-0 bg-black/50 hidden flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl p-6 w-96 max-w-[90%]">
<h3 class="text-lg font-bold mb-4" id="modalTitle">กำหนดเวร</h3>
<p class="text-sm text-slate-600 mb-4" id="modalSubtitle"></p>
<div class="space-y-3">
<label class="flex items-center space-x-3 p-2 rounded hover:bg-slate-50 cursor-pointer">
<input type="radio" name="shiftType" value="" class="form-radio text-blue-600" checked>
<span>ไม่มีเวร (ว่าง)</span>
</label>
<label class="flex items-center space-x-3 p-2 rounded hover:bg-slate-50 cursor-pointer"
id="morningOption">
<input type="radio" name="shiftType" value="ช" class="form-radio text-blue-600">
<span class="font-medium text-blue-600">เวรเช้า (ช)</span>
</label>
<label class="flex items-center space-x-3 p-2 rounded hover:bg-slate-50 cursor-pointer">
<input type="radio" name="shiftType" value="บ" class="form-radio text-green-600">
<span class="font-medium text-green-600">เวรบ่าย (บ)</span>
</label>
<label class="flex items-center space-x-3 p-2 rounded hover:bg-slate-50 cursor-pointer" id="bothOption">
<input type="radio" name="shiftType" value="ช/บ" class="form-radio text-purple-600">
<span class="font-medium text-purple-600">เวรเช้าและบ่าย (ช/บ)</span>
</label>
<label class="flex items-center space-x-3 p-2 rounded hover:bg-slate-50 cursor-pointer">
<input type="radio" name="shiftType" value="ลา" class="form-radio text-red-600">
<span class="font-medium text-red-600">ลาพัก/ลากิจ (ลา)</span>
</label>
<label class="flex items-center space-x-3 p-2 rounded hover:bg-slate-50 cursor-pointer">
<input type="radio" name="shiftType" value="ป" class="form-radio text-orange-500">
<span class="font-medium text-orange-500">ประชุม (ป)</span>
</label>
</div>
<div class="mt-6 flex justify-end gap-2">
<button id="cancelModalBtn"
class="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-md transition-colors">ยกเลิก</button>
<button id="saveModalBtn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors flex items-center">
<span id="saveModalText">บันทึก</span>
<svg id="saveModalSpinner" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
<div class="mt-4 text-sm text-slate-500 italic border-t pt-4">
* คลิกที่เซลล์ในตารางเพื่อกำหนดเวร
<br>
* คลิกที่ชื่อพนักงานเพื่อแก้ไขชื่อ
</div>
</div>
</div>
<!-- Edit Name Modal -->
<div id="editNameModal" class="fixed inset-0 bg-black/50 hidden flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl p-6 w-96 max-w-[90%]">
<h3 class="text-lg font-bold mb-4">แก้ไขชื่อพนักงาน</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">ชื่อ - นามสกุล</label>
<input type="text" id="editNameInput"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">เบอร์โทรศัพท์</label>
<input type="text" id="editPhoneInput" placeholder="เช่น 081-234-5678"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<hr class="border-gray-200">
<div class="bg-blue-50 rounded p-3">
<p class="text-xs font-bold text-blue-900 mb-2">ตั้งค่าสำหรับตัวช่วยจัดเวรอัตโนมัติ</p>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-700 mb-1">วันปกติ (จ.-ศ.) ที่ต้องการเวรบ่าย:</label>
<div class="flex flex-wrap gap-2">
<label class="inline-flex items-center"><input type="checkbox" name="pref_weekdays" value="1" class="form-checkbox h-4 w-4 text-blue-600"><span class="ml-1 text-sm">จ.</span></label>
<label class="inline-flex items-center"><input type="checkbox" name="pref_weekdays" value="2" class="form-checkbox h-4 w-4 text-blue-600"><span class="ml-1 text-sm">อ.</span></label>
<label class="inline-flex items-center"><input type="checkbox" name="pref_weekdays" value="3" class="form-checkbox h-4 w-4 text-blue-600"><span class="ml-1 text-sm">พ.</span></label>
<label class="inline-flex items-center"><input type="checkbox" name="pref_weekdays" value="4" class="form-checkbox h-4 w-4 text-blue-600"><span class="ml-1 text-sm">พฤ.</span></label>
<label class="inline-flex items-center"><input type="checkbox" name="pref_weekdays" value="5" class="form-checkbox h-4 w-4 text-blue-600"><span class="ml-1 text-sm">ศ.</span></label>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">วันหยุด (ส.-อา., นักขัตฤกษ์) กะที่ต้องการ:</label>
<div class="flex flex-wrap gap-3">
<label class="inline-flex items-center"><input type="radio" name="pref_weekend" value="" class="form-radio h-4 w-4 text-blue-600" checked><span class="ml-1 text-sm">ไม่ระบุ</span></label>
<label class="inline-flex items-center"><input type="radio" name="pref_weekend" value="ช" class="form-radio h-4 w-4 text-blue-600"><span class="ml-1 text-sm">เช้า</span></label>
<label class="inline-flex items-center"><input type="radio" name="pref_weekend" value="บ" class="form-radio h-4 w-4 text-blue-600"><span class="ml-1 text-sm">บ่าย</span></label>
<label class="inline-flex items-center"><input type="radio" name="pref_weekend" value="ช/บ" class="form-radio h-4 w-4 text-blue-600"><span class="ml-1 text-sm">เช้า/บ่าย</span></label>
</div>
</div>
</div>
</div>
<div class="mt-6 flex justify-between gap-2">
<button id="deleteStaffBtn" onclick="deleteStaff()" class="px-4 py-2 text-red-600 hover:bg-red-50 border border-red-200 rounded-md transition-colors">ลบรายชื่อ</button>
<div class="flex gap-2">
<button id="cancelNameModalBtn"
class="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-md transition-colors">ยกเลิก</button>
<button id="saveNameModalBtn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors flex items-center">
<span id="saveNameModalText">บันทึก</span>
<svg id="saveNameModalSpinner" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Conflict Warning Modal -->
<div id="conflictModal" class="fixed inset-0 bg-black/50 hidden flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl p-6 w-[28rem] max-w-[90%] transform transition-all">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 bg-amber-100 rounded-full p-2">
<svg class="h-6 w-6 text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-900 mb-2">แจ้งเตือนเวรซ้ำซ้อน</h3>
<p class="text-sm text-slate-600 mb-4">วันดังกล่าวมีการจัดเวรให้บุคคลอื่นแล้ว:</p>
<div id="conflictStaffList" class="bg-amber-50 border border-amber-200 rounded text-sm text-amber-800 p-3 mb-4 space-y-1">
<!-- รายชื่อผู้มีเวรซ้ำซ้อน -->
</div>
<p class="text-sm font-medium text-slate-700">คุณแน่ใจหรือไม่ว่าต้องการบันทึกเวรซ้อนกัน?</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button id="cancelConflictBtn" onclick="closeConflictModal()"
class="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-md transition-colors text-sm font-medium">ยกเลิก</button>
<button id="confirmConflictBtn" onclick="forceSaveShift()"
class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-md transition-colors text-sm font-medium flex items-center">
<span id="confirmConflictText">ยืนยันบันทึก</span>
<svg id="confirmConflictSpinner" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</svg>
</button>
</div>
</div>
</div>
<!-- Lock Warning Modal -->
<div id="lockAlertModal" class="fixed inset-0 bg-black/50 hidden flex items-center justify-center z-[9999]">
<div class="bg-white rounded-lg shadow-xl p-6 w-[28rem] max-w-[90%] transform transition-all">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 bg-red-100 rounded-full p-2">
<svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-900 mb-2">แจ้งเตือน</h3>
<p id="lockAlertMessage" class="text-sm text-slate-600 mb-6">เดือนนี้ถูกล็อกไว้ ไม่สามารถแก้ไขได้</p>
</div>
</div>
<div class="mt-2 flex justify-end">
<button onclick="closeLockAlert()"
class="px-4 py-2 bg-slate-600 hover:bg-slate-700 text-white rounded-md transition-colors text-sm font-medium">
ตกลง
</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toastContainer" class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none"></div>
<!-- SweetAlert2 library -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<!-- html2canvas library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script>
// App State
let staffList = [];
let scheduleData = {};
// Custom holidays (Format: 'YYYY-MM-DD')
let customHolidays = [];
let fetchedHolidayYear = null;
let currentMonth = new Date().getMonth(); // 0-11
let currentYear = new Date().getFullYear(); // AD
let currentEditingCell = null;
let currentEditingStaffId = null;
// Elements
const daysHeaderRow = document.getElementById('daysHeaderRow');
const scheduleBody = document.getElementById('scheduleBody');
const monthYearDisplay = document.getElementById('monthYearDisplay');
const monthSelect = document.getElementById('monthSelect');
const yearSelect = document.getElementById('yearSelect');
const updateMonthBtn = document.getElementById('updateMonthBtn');
const exportImageBtn = document.getElementById('exportImageBtn');
const editModal = document.getElementById('editModal');
const modalTitle = document.getElementById('modalTitle');
const modalSubtitle = document.getElementById('modalSubtitle');
const cancelModalBtn = document.getElementById('cancelModalBtn');
const saveModalBtn = document.getElementById('saveModalBtn');
const loadingOverlay = document.getElementById('loadingOverlay');
const saveModalText = document.getElementById('saveModalText');
const saveModalSpinner = document.getElementById('saveModalSpinner');
// Name Edit Modal Elements
const editNameModal = document.getElementById('editNameModal');
const editNameInput = document.getElementById('editNameInput');
const cancelNameModalBtn = document.getElementById('cancelNameModalBtn');
const saveNameModalBtn = document.getElementById('saveNameModalBtn');
const saveNameModalText = document.getElementById('saveNameModalText');
const saveNameModalSpinner = document.getElementById('saveNameModalSpinner');
// Options hiding/showing based on day type
const morningOption = document.getElementById('morningOption');
const bothOption = document.getElementById('bothOption');
let currentIsLocked = false;
function showLockAlert(message) {
document.getElementById('lockAlertMessage').textContent = message || 'เดือนนี้ถูกล็อกไว้ ไม่สามารถแก้ไขได้';
document.getElementById('lockAlertModal').classList.remove('hidden');
}
function closeLockAlert() {
document.getElementById('lockAlertModal').classList.add('hidden');
}
function showToast(message, type = 'warning') {
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
// Base classes
let classes = "flex items-center w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800 pointer-events-auto transform transition-all duration-300 translate-y-[-1rem] opacity-0";
let iconHtml = '';
if (type === 'warning') {
iconHtml = `<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-orange-500 bg-orange-100 rounded-lg dark:bg-orange-700 dark:text-orange-200">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z"/>
</svg>
<span class="sr-only">Warning icon</span>
</div>`;
} else if (type === 'success') {
iconHtml = `<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-green-500 bg-green-100 rounded-lg dark:bg-green-800 dark:text-green-200">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z"/>
</svg>
<span class="sr-only">Check icon</span>
</div>`;
}
toast.className = classes;
toast.innerHTML = `
${iconHtml}
<div class="ml-3 text-sm font-normal text-slate-800 font-sarabun">${message}</div>
`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => {
toast.classList.remove('translate-y-[-1rem]', 'opacity-0');
});
// Remove after 3 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-[-1rem]');
setTimeout(() => {
if (container.contains(toast)) {
container.removeChild(toast);
}
}, 300);
}, 3000);
}
async function fetchMonthLock(year, month) {
try {
const res = await fetch(`api/locks.php?year=${year}&month=${month}`);
const result = await res.json();
if (result.status === 'success') {
currentIsLocked = result.is_locked;
updateLockUI();
}
} catch (err) {
console.error(err);
}
}
function updateLockUI() {
const btn = document.getElementById('toggleLockBtn');
const icon = document.getElementById('lockIcon');
const text = document.getElementById('lockText');
if (!btn) return;
if (currentIsLocked) {
btn.className = "bg-red-500 hover:bg-red-600 text-white p-2 rounded-md transition-colors shadow-sm flex items-center justify-center";
btn.title = "ปลดล็อกเดือนนี้";
if(text) text.textContent = "ปลดล็อกเดือนนี้";
// closed lock icon
icon.innerHTML = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />`;
} else {
btn.className = "bg-slate-200 hover:bg-slate-300 text-slate-700 p-2 rounded-md transition-colors shadow-sm flex items-center justify-center";
btn.title = "ล็อกเดือนนี้";
if(text) text.textContent = "ล็อกเดือนนี้";
// open lock icon
icon.innerHTML = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />`;
}
}
async function toggleMonthLock() {
try {
showLoading();
const newState = !currentIsLocked;
const res = await fetch('api/locks.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
year: currentYear,
month: currentMonth + 1,
is_locked: newState
})
});
const result = await res.json();
if (result.status === 'success') {
currentIsLocked = result.is_locked;
updateLockUI();
showToast(currentIsLocked ? 'ล็อกเดือนนี้เรียบร้อยแล้ว' : 'ปลดล็อกเดือนนี้เรียบร้อยแล้ว', 'success');
} else {
showToast('เกิดข้อผิดพลาด: ' + result.message, 'warning');
}
} catch (err) {
console.error(err);
showToast('เกิดข้อผิดพลาดในการเชื่อมต่อ', 'warning');
} finally {
hideLoading();
}
}
// Initialize UI with current date
monthSelect.value = currentMonth;
yearSelect.value = currentYear + 543;
// Fetch Initial Data
async function initApp() {
showLoading();
await fetchStaff();
await fetchOncall();
await fetchHolidays(currentYear);
await fetchMonthLock(currentYear, currentMonth + 1);
await fetchSchedules(currentYear, currentMonth + 1);
hideLoading();
renderSchedule();
renderContactAndOncall();
}
function formatPhoneNumber(phone) {
if (!phone) return '-';
// Strip all non-digit characters
const cleaned = ('' + phone).replace(/\D/g, '');
// Format 10 digit numbers (mobile)
if (cleaned.length === 10) {
return `${cleaned.slice(0,3)}-${cleaned.slice(3,6)}-${cleaned.slice(6)}`;
}
// Format 9 digit numbers (landline)
if (cleaned.length === 9) {
return `${cleaned.slice(0,2)}-${cleaned.slice(2,5)}-${cleaned.slice(5)}`;
}
// Return original if it doesn't match standard Thai lengths
return phone;
}
function renderContactAndOncall() {
const tbody = document.getElementById('contactAndOncallBody');
let html = '';
staffList.forEach((staff, index) => {
const phoneFormatted = formatPhoneNumber(staff.phone);
const nameCleaned = staff.name.replace(/^(นาย|นางสาว|นาง)\s*/, '');
html += `<tr class="hover:bg-blue-50 transition-colors">`;
// Column 1: Number
html += `<td class="py-2 px-4 text-center border border-slate-300 align-middle">
<span class="inline-flex items-center justify-center bg-blue-200 text-blue-800 text-xs font-bold w-5 h-5 rounded-full">${index + 1}</span>
</td>`;
// Column 2: Name & Phone
html += `<td class="py-2 px-4 border border-slate-300 align-middle">
<div class="flex items-center justify-between min-w-[200px]">
<span class="font-medium text-slate-700">คุณ${nameCleaned}</span>
<a href="tel:${staff.phone ? staff.phone.replace(/\D/g, '') : ''}" class="text-blue-600 font-semibold tracking-wide hover:underline ml-4">${phoneFormatted}</a>
</div>
</td>`;
// Columns 3-7: Oncall Days (Mon-Fri)
for (let day = 1; day <= 5; day++) {
const isOncall = parseInt(oncallData[day]) === parseInt(staff.id);
const mark = isOncall ? '<span class="text-blue-600 font-bold">/</span>' : '';
const bgClass = isOncall ? 'bg-blue-50' : 'cursor-pointer hover:bg-blue-100';
html += `<td class="border border-slate-300 px-3 w-12 text-center align-middle transition-colors ${bgClass}"
onclick="handleOncallClick(${staff.id}, ${day})">${mark}</td>`;
}
html += `</tr>`;
});
tbody.innerHTML = html;
}
async function fetchHolidays(year) {
if (fetchedHolidayYear === year) return; // Already fetched for this year
try {
const response = await fetch(`api/holidays.php?year=${year}`);
const result = await response.json();
if (result.status === 'success') {
customHolidays = result.data;
fetchedHolidayYear = year;
} else {
console.error('Failed to load holidays', result.message);
}
} catch (error) {
console.error('API Error:', error);
}
}
let oncallData = {};
async function fetchOncall() {
try {
const response = await fetch('api/oncall.php');
const result = await response.json();
if (result.status === 'success') {
oncallData = result.data;
}
} catch (error) {
console.error('API Error:', error);
}
}
async function handleOncallClick(staffId, dayOfWeek) {
if (currentIsLocked) {
showLockAlert('เดือนนี้ถูกล็อกไว้ ไม่สามารถแก้ไขผู้ติดต่อหลักได้');
return;
}
// Optimistic update
oncallData[dayOfWeek] = staffId;
renderContactAndOncall();
try {
const response = await fetch('api/oncall.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ day_of_week: dayOfWeek, staff_id: staffId })
});
const result = await response.json();
if (result.status !== 'success') {
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาดในการบันทึกผู้ติดต่อหลัก: ' + result.message, 'error');
await fetchOncall(); // revert on fail
renderContactAndOncall();
}
} catch (error) {
console.error('API Error:', error);
await fetchOncall();
renderContactAndOncall();
}
}
async function fetchStaff() {
try {
const response = await fetch('api/staff.php');
const result = await response.json();
if (result.status === 'success') {
staffList = result.data;
} else {
console.error('Failed to load staff', result.message);
}
} catch (error) {
console.error('API Error:', error);
}
}
async function fetchSchedules(year, month) {
try {
const response = await fetch(`api/schedules.php?year=${year}&month=${month}`);
const result = await response.json();
if (result.status === 'success') {
scheduleData = result.data;
// Normalize data just in case
for (const staffId in scheduleData) {
for (const day in scheduleData[staffId]) {
if (scheduleData[staffId][day] === 'ชบ') {
scheduleData[staffId][day] = 'ช/บ';
}
}
}
} else {
console.error('Failed to load schedules', result.message);
}
} catch (error) {
console.error('API Error:', error);
}
}
function showLoading() {
loadingOverlay.classList.remove('hidden-overlay');
}
function hideLoading() {
loadingOverlay.classList.add('hidden-overlay');
}
// Get number of days in month
function getDaysInMonth(month, year) {
return new Date(year, month + 1, 0).getDate();
}
// Check if weekend
function isWeekend(day, month, year) {
const date = new Date(year, month, day);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // Sunday(0) or Saturday(6)
}
// Check if custom holiday
function isHoliday(day, month, year) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
// If customHolidays is an object, use hasOwnProperty. If array, use includes (backward compat just in case)
if (Array.isArray(customHolidays)) return customHolidays.includes(dateStr);
return customHolidays.hasOwnProperty(dateStr);
}
function getHolidayName(day, month, year) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (Array.isArray(customHolidays)) return 'วันหยุด';
return customHolidays[dateStr] || 'วันหยุด';
}
// Calculate shift count
function calculateShiftCount(staffId) {
let count = 0;
const staffSchedule = scheduleData[staffId] || {};
for (const day in staffSchedule) {
const shift = staffSchedule[day];
if (shift === 'ช' || shift === 'บ') {
count += 1;
} else if (shift === 'ช/บ') {
count += 2; // Count as 2 shifts
}
}
return count;
}
function renderSchedule() {
const daysInMonth = getDaysInMonth(currentMonth, currentYear);
const thaiMonths = ["มกราคม", "กุมภาพันธ์", "มีนาคม", "เมษายน", "พฤษภาคม", "มิถุนายน", "กรกฎาคม", "สิงหาคม", "กันยายน", "ตุลาคม", "พฤศจิกายน", "ธันวาคม"];
// Update title
monthYearDisplay.textContent = `${thaiMonths[currentMonth]} ${currentYear + 543}`;
// 1. Render Headers
let headerHTML = `<th class="border border-slate-300 p-2 w-[160px] min-w-[160px] max-w-[160px] sticky left-0 bg-slate-100 z-20 truncate">ชื่อ - นามสกุล</th>`;
headerHTML += `<th class="border border-slate-300 p-2 w-[70px] min-w-[70px] max-w-[70px] sticky left-[160px] bg-slate-100 z-20">จำนวน</th>`;
for (let day = 1; day <= daysInMonth; day++) {
const weekend = isWeekend(day, currentMonth, currentYear);
const holiday = isHoliday(day, currentMonth, currentYear);
let holidayTitleAttr = "";
let thClass = "border border-slate-300 p-2 w-10 min-w-[40px]";
if (weekend) {
thClass += " cell-weekend";
} else if (holiday) {
thClass += " cell-holiday";
holidayTitleAttr = `title="${getHolidayName(day, currentMonth, currentYear)}"`;
}
if (weekend || holiday) {
// Use SVG for flawless html2canvas rendering
headerHTML += `<th class="${thClass}" ${holidayTitleAttr}>
<svg width="28" height="28" style="display: block; margin: 0 auto;">
<circle cx="14" cy="14" r="12" stroke="#334155" stroke-width="2" fill="none" />
<text x="14" y="14" text-anchor="middle" dy=".35em" font-weight="bold" fill="#334155" font-family="Sarabun, sans-serif" font-size="14px">${day}</text>
</svg>
</th>`;
} else {
headerHTML += `<th class="${thClass}">${day}</th>`;
}
}
daysHeaderRow.innerHTML = headerHTML;
// 2. Render Body
let bodyHTML = '';
staffList.forEach(staff => {
const shiftCount = calculateShiftCount(staff.id);
bodyHTML += `<tr>`;
bodyHTML += `<td class="border border-slate-300 p-2 text-left w-[160px] min-w-[160px] max-w-[160px] sticky left-0 bg-white z-10 font-medium cursor-pointer hover:bg-slate-50 transition-colors align-middle truncate" data-action="edit-name" data-staff-id="${staff.id}" title="${staff.name}">${staff.name}</td>`;
bodyHTML += `<td class="border border-slate-300 p-2 font-bold align-middle text-center w-[70px] min-w-[70px] max-w-[70px] sticky left-[160px] bg-white z-10" id="count-${staff.id}">${shiftCount}</td>`;
for (let day = 1; day <= daysInMonth; day++) {
const weekend = isWeekend(day, currentMonth, currentYear);
const holiday = isHoliday(day, currentMonth, currentYear);
let tdClass = "border border-slate-300 p-1 cursor-pointer hover:bg-blue-50 transition-colors h-10 align-middle text-center";
let holidayTitleAttr = "";
if (weekend) tdClass += " cell-weekend hover:bg-slate-300";
if (holiday) {
tdClass += " cell-holiday hover:bg-red-400";
holidayTitleAttr = `title="${getHolidayName(day, currentMonth, currentYear)}"`;
}
const shift = scheduleData[staff.id]?.[day] || '';
let displayShift = shift;
let shiftClass = 'inline-block align-middle leading-none mt-1';
if (shift === 'ช') shiftClass += ' shift-m';
else if (shift === 'บ') shiftClass += ' shift-a';
else if (shift === 'ช/บ') shiftClass += ' shift-ma text-xs';
else if (shift === 'ลา') shiftClass += ' shift-l text-xs';
else if (shift === 'ป') shiftClass += ' shift-meet text-xs';
bodyHTML += `<td class="${tdClass}" ${holidayTitleAttr}
data-staff-id="${staff.id}"
data-day="${day}"
data-weekend="${weekend || holiday}">
<span class="${shiftClass}">${displayShift}</span>
</td>`;
}
bodyHTML += `</tr>`;
});
scheduleBody.innerHTML = bodyHTML;
// Attach click events
const shiftCells = scheduleBody.querySelectorAll('td[data-day]');
shiftCells.forEach(cell => {
cell.addEventListener('click', handleCellClick);
});
const nameCells = scheduleBody.querySelectorAll('td[data-action="edit-name"]');
nameCells.forEach(cell => {
cell.addEventListener('click', handleNameClick);
});
updateShiftSummary();
}
function updateShiftSummary() {
const daysInMonth = getDaysInMonth(currentMonth, currentYear);
let totalMorning = 0;
let totalAfternoon = daysInMonth;
for (let day = 1; day <= daysInMonth; day++) {
if (isWeekend(day, currentMonth, currentYear) || isHoliday(day, currentMonth, currentYear)) {
totalMorning++;
}
}
let usedMorning = 0;
let usedAfternoon = 0;
for (const staffId in scheduleData) {
for (const day in scheduleData[staffId]) {
const shift = scheduleData[staffId][day];
if (shift === 'ช') {
usedMorning++;
} else if (shift === 'บ') {
usedAfternoon++;
} else if (shift === 'ช/บ') {
usedMorning++;
usedAfternoon++;
}
}
}
const remainMorning = totalMorning - usedMorning;
const remainAfternoon = totalAfternoon - usedAfternoon;
document.getElementById('totalMorning').textContent = totalMorning;
document.getElementById('remainMorning').textContent = remainMorning;
const remainMorningEl = document.getElementById('remainMorning');
if (remainMorning === 0) {
remainMorningEl.className = "font-bold text-lg leading-none text-gray-500";
} else if (remainMorning < 0) {
remainMorningEl.className = "font-bold text-lg leading-none text-red-600";
} else {
remainMorningEl.className = "font-bold text-lg leading-none text-blue-800";
}
document.getElementById('totalAfternoon').textContent = totalAfternoon;
document.getElementById('remainAfternoon').textContent = remainAfternoon;
const remainAfternoonEl = document.getElementById('remainAfternoon');
if (remainAfternoon === 0) {
remainAfternoonEl.className = "font-bold text-lg leading-none text-gray-500";
} else if (remainAfternoon < 0) {
remainAfternoonEl.className = "font-bold text-lg leading-none text-red-600";
} else {
remainAfternoonEl.className = "font-bold text-lg leading-none text-green-800";
}
}
const editPhoneInput = document.getElementById('editPhoneInput');
function openAddStaffModal() {
if (currentIsLocked) {
showLockAlert('เดือนนี้ถูกล็อกไว้ ไม่สามารถแก้ไขหรือเพิ่มบุคคลได้');
return;
}
currentEditingStaffId = 'new';
document.querySelector('#editNameModal h3').textContent = 'เพิ่มพนักงานใหม่';
editNameInput.value = '';
editPhoneInput.value = '';
// Reset preferences
document.querySelectorAll('input[name="pref_weekdays"]').forEach(cb => cb.checked = false);
document.querySelector('input[name="pref_weekend"][value=""]').checked = true;
document.getElementById('deleteStaffBtn').classList.add('hidden');
editNameModal.classList.remove('hidden');
editNameInput.focus();
}
function handleNameClick(e) {
if (currentIsLocked) {
showLockAlert('เดือนนี้ถูกล็อกไว้ ไม่สามารถแก้ไขหรือเพิ่มบุคคลได้');
return;
}
const cell = e.currentTarget;
currentEditingStaffId = parseInt(cell.dataset.staffId);
const staff = staffList.find(s => s.id === currentEditingStaffId);
if (staff) {
document.querySelector('#editNameModal h3').textContent = 'แก้ไขชื่อพนักงาน';
document.getElementById('deleteStaffBtn').classList.remove('hidden');
editNameInput.value = staff.name;
editPhoneInput.value = staff.phone || '';
// Populate preferences
const prefWeekdays = staff.pref_weekdays ? staff.pref_weekdays.split(',') : [];
document.querySelectorAll('input[name="pref_weekdays"]').forEach(cb => {
cb.checked = prefWeekdays.includes(cb.value);
});
const prefWeekend = staff.pref_weekend || '';
const weekendRadio = document.querySelector(`input[name="pref_weekend"][value="${prefWeekend}"]`);
if (weekendRadio) weekendRadio.checked = true;
editNameModal.classList.remove('hidden');
editNameInput.focus();
}
}
async function saveStaffName() {
if (currentEditingStaffId === null) return;
const newName = editNameInput.value.trim();
const newPhone = editPhoneInput.value.trim();
const selectedWeekdays = Array.from(document.querySelectorAll('input[name="pref_weekdays"]:checked')).map(cb => cb.value).join(',');
const selectedWeekend = document.querySelector('input[name="pref_weekend"]:checked').value;
if (newName) {
let shouldSave = false;
let payload = {
name: newName,
phone: newPhone,
pref_weekdays: selectedWeekdays,
pref_weekend: selectedWeekend
};
if (currentEditingStaffId === 'new') {
shouldSave = true;
} else {
const staffIndex = staffList.findIndex(s => s.id === currentEditingStaffId);
if (staffIndex !== -1 && (
staffList[staffIndex].name !== newName ||
staffList[staffIndex].phone !== newPhone ||
(staffList[staffIndex].pref_weekdays || '') !== selectedWeekdays ||
(staffList[staffIndex].pref_weekend || '') !== selectedWeekend
)) {
shouldSave = true;
payload.id = currentEditingStaffId;
}
}
if (shouldSave) {
// Show loading
saveNameModalText.classList.add('hidden');
saveNameModalSpinner.classList.remove('hidden');
saveNameModalBtn.disabled = true;
try {
const res = await fetch('api/staff.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.status === 'success') {
if (currentEditingStaffId === 'new') {
staffList.push({
id: result.id,
name: newName,
phone: newPhone,
pref_weekdays: selectedWeekdays,
pref_weekend: selectedWeekend
});
} else {
const staffIndex = staffList.findIndex(s => s.id === currentEditingStaffId);
staffList[staffIndex].name = newName;
staffList[staffIndex].phone = newPhone;
staffList[staffIndex].pref_weekdays = selectedWeekdays;
staffList[staffIndex].pref_weekend = selectedWeekend;
}
renderSchedule();
renderContactAndOncall();
} else {
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาด: ' + result.message, 'error');
}
} catch (err) {
console.error(err);
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์', 'error');
} finally {
// Hide loading
saveNameModalText.classList.remove('hidden');
saveNameModalSpinner.classList.add('hidden');
saveNameModalBtn.disabled = false;
}
}
}
closeNameModal();
}
function closeNameModal() {
editNameModal.classList.add('hidden');
currentEditingStaffId = null;
}
async function deleteStaff() {
if (!currentEditingStaffId || currentEditingStaffId === 'new') return;
const swalResult = await Swal.fire({
title: 'ยืนยันการลบ',
text: 'คุณต้องการลบรายชื่อนี้ใช่หรือไม่? การกระทำนี้ไม่สามารถย้อนกลับได้ และข้อมูลการเข้าเวรของเจ้าหน้าที่คนนี้จะถูกลบไปด้วย',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'ลบ',
cancelButtonText: 'ยกเลิก'
});
if (swalResult.isConfirmed) {
try {
const res = await fetch('api/staff.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: currentEditingStaffId })
});
const result = await res.json();
if (result.status === 'success') {
showToast('ลบรายชื่อเรียบร้อยแล้ว', 'success');
staffList = staffList.filter(s => s.id !== currentEditingStaffId);
delete scheduleData[currentEditingStaffId];
renderSchedule();
renderContactAndOncall();
closeNameModal();
} else {
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาดในการลบ: ' + result.message, 'error');
}
} catch (err) {
console.error(err);
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์', 'error');
}
}
}
function handleCellClick(e) {
if (currentIsLocked) {
showLockAlert('เดือนนี้ถูกล็อกไว้ ไม่สามารถแก้ไขตารางเวรได้');
return;
}
const cell = e.currentTarget;
currentEditingCell = cell;
const staffId = cell.dataset.staffId;
const day = cell.dataset.day;
const isWeekendOrHoliday = cell.dataset.weekend === 'true';
const staffName = staffList.find(s => s.id == staffId).name;
const currentShift = scheduleData[staffId]?.[day] || '';
modalTitle.textContent = `กำหนดเวรให้ ${staffName}`;
modalSubtitle.textContent = `วันที่ ${day} ${monthYearDisplay.textContent}`;
if (!isWeekendOrHoliday) {
morningOption.classList.add('hidden');
bothOption.classList.add('hidden');
} else {
morningOption.classList.remove('hidden');
bothOption.classList.remove('hidden');
}
const radios = editModal.querySelectorAll('input[name="shiftType"]');
radios.forEach(radio => {
if (radio.value === currentShift) {
radio.checked = true;
} else if (currentShift === '' && radio.value === '') {
radio.checked = true;
}
});
editModal.classList.remove('hidden');
}
function getFullShiftName(shift) {
if (shift === 'ช') return 'เช้า';
if (shift === 'บ') return 'บ่าย';
if (shift === 'ช/บ') return 'เช้า/บ่าย';
return shift;
}
let currentConflicts = [];
function checkShiftConflict(targetStaffId, day, selectedShift) {
if (!selectedShift || selectedShift === 'ลา') return null;
let conflictingStaffs = [];
for (const sId in scheduleData) {
if (sId === targetStaffId) continue;
const existingShift = scheduleData[sId][day];
if (!existingShift || existingShift === 'ลา') continue;
// Check overlapping
const isConflict =
(selectedShift === 'ช' && (existingShift === 'ช' || existingShift === 'ช/บ')) ||
(selectedShift === 'บ' && (existingShift === 'บ' || existingShift === 'ช/บ')) ||
(selectedShift === 'ช/บ' && (existingShift === 'ช' || existingShift === 'บ' || existingShift === 'ช/บ'));
if (isConflict) {
const staffName = staffList.find(s => s.id == sId)?.name || 'พนักงาน';
conflictingStaffs.push({
id: sId,
text: `${staffName} (เวร${getFullShiftName(existingShift)})`
});
}
}
return conflictingStaffs.length > 0 ? conflictingStaffs : null;
}
function closeConflictModal() {
document.getElementById('conflictModal').classList.add('hidden');
}
async function forceSaveShift() {
window.bypassConflictCheck = true;
// Show loading on conflict modal
document.getElementById('confirmConflictText').classList.add('hidden');
document.getElementById('confirmConflictSpinner').classList.remove('hidden');
document.getElementById('confirmConflictBtn').disabled = true;
const day = currentEditingCell.dataset.day;
// Remove conflicting shifts first
for (const conflict of currentConflicts) {
try {
await fetch('api/schedules.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
staff_id: conflict.id,
year: currentYear,
month: currentMonth + 1,
day: day,
shift_type: '' // Clear
})
});
if (scheduleData[conflict.id]) {
delete scheduleData[conflict.id][day];
}
// Update UI for the conflicting staff
const conflictCell = document.querySelector(`td[data-staff-id="${conflict.id}"][data-day="${day}"]`);
if (conflictCell) conflictCell.innerHTML = '';
const countEl = document.getElementById(`count-${conflict.id}`);
if (countEl) countEl.textContent = calculateShiftCount(conflict.id);
} catch (e) {
console.error('Failed to clear conflicting shift:', e);
}
}
// Now save the new shift
saveShift().then(() => {
closeConflictModal();
// Reset loading
document.getElementById('confirmConflictText').classList.remove('hidden');
document.getElementById('confirmConflictSpinner').classList.add('hidden');
document.getElementById('confirmConflictBtn').disabled = false;
window.bypassConflictCheck = false;
});
}
async function saveShift() {
if (!currentEditingCell) return;
const staffId = currentEditingCell.dataset.staffId;
const day = currentEditingCell.dataset.day;
const selectedShift = editModal.querySelector('input[name="shiftType"]:checked').value;
// ตรวจสอบความซ้ำซ้อน
if (!window.bypassConflictCheck) {
const conflict = checkShiftConflict(staffId, day, selectedShift);
if (conflict) {
currentConflicts = conflict;
const listEl = document.getElementById('conflictStaffList');
listEl.innerHTML = conflict.map(c => `<div>• ${c.text}</div>`).join('');
document.getElementById('conflictModal').classList.remove('hidden');
return;
}
}
// Show loading
saveModalText.classList.add('hidden');
saveModalSpinner.classList.remove('hidden');
saveModalBtn.disabled = true;
try {
const res = await fetch('api/schedules.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
staff_id: staffId,
year: currentYear,
month: currentMonth + 1,
day: day,
shift_type: selectedShift
})
});
const result = await res.json();
if (result.status === 'success') {
// Update local data
if (!scheduleData[staffId]) {
scheduleData[staffId] = {};
}
if (selectedShift === '') {
delete scheduleData[staffId][day];
} else {
scheduleData[staffId][day] = selectedShift;
}
// Update UI optimistically
let shiftClass = 'inline-block align-middle leading-none mt-1';
if (selectedShift === 'ช') shiftClass += ' shift-m';
else if (selectedShift === 'บ') shiftClass += ' shift-a';
else if (selectedShift === 'ช/บ') shiftClass += ' shift-ma text-xs';
else if (selectedShift === 'ลา') shiftClass += ' shift-l text-xs';
currentEditingCell.innerHTML = `<span class="${shiftClass}">${selectedShift}</span>`;
const newCount = calculateShiftCount(staffId);
document.getElementById(`count-${staffId}`).textContent = newCount;
closeModal();
} else {
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาด: ' + result.message, 'error');
}
} catch (err) {
console.error(err);
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์', 'error');
} finally {
// Restore button
saveModalText.classList.remove('hidden');
saveModalSpinner.classList.add('hidden');
saveModalBtn.disabled = false;
}
}
function closeModal() {
editModal.classList.add('hidden');
currentEditingCell = null;
}
updateMonthBtn.addEventListener('click', async () => {
currentMonth = parseInt(monthSelect.value);
const y = parseInt(yearSelect.value);
if (y > 2500) {
currentYear = y - 543;
} else {
currentYear = y;
}
showLoading();
await fetchHolidays(currentYear);
await fetchSchedules(currentYear, currentMonth + 1);
hideLoading();
renderSchedule();
});
// Image Export Logic
exportImageBtn.addEventListener('click', async () => {
const captureArea = document.getElementById('captureArea');
const exportHeader = document.getElementById('exportHeader');
const exportMonthYear = document.getElementById('exportMonthYear');
exportMonthYear.textContent = monthYearDisplay.textContent;
exportHeader.classList.remove('hidden');
exportHeader.classList.add('flex');
captureArea.classList.add('exporting');
const stickyElements = captureArea.querySelectorAll('.sticky');
stickyElements.forEach(el => el.classList.remove('sticky'));
try {
const originalText = exportImageBtn.innerHTML;
exportImageBtn.innerHTML = 'กำลังประมวลผล...';
exportImageBtn.disabled = true;
const canvas = await html2canvas(captureArea, {
scale: 2,
backgroundColor: '#ffffff',
logging: false,
useCORS: true
});
const image = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = image;
link.download = `ตารางเวร_${monthYearDisplay.textContent.replace(/\s+/g, '_')}.png`;
link.click();
} catch (error) {
console.error('Error exporting image:', error);
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาดในการสร้างรูปภาพ', 'error');
} finally {
exportHeader.classList.add('hidden');
exportHeader.classList.remove('flex');
captureArea.classList.remove('exporting');
stickyElements.forEach(el => el.classList.add('sticky'));
exportImageBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
ส่งออกเป็นรูปภาพ
`;
exportImageBtn.disabled = false;
}
});
// Event Listeners for Shift Modal
cancelModalBtn.addEventListener('click', closeModal);
saveModalBtn.addEventListener('click', saveShift);
// Event Listeners for Name Modal
cancelNameModalBtn.addEventListener('click', closeNameModal);
saveNameModalBtn.addEventListener('click', saveStaffName);
editNameInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
saveStaffName();
}
});
editModal.addEventListener('click', (e) => {
if (e.target === editModal) {
closeModal();
}
});
editNameModal.addEventListener('click', (e) => {
if (e.target === editNameModal) {
closeNameModal();
}
});
async function autoGenerateSchedule() {
if (currentIsLocked) {
showLockAlert('เดือนนี้ถูกล็อกไว้ ไม่สามารถจัดเวรอัตโนมัติได้');
return;
}
// Check if month already has data
const hasData = Object.keys(scheduleData).some(staffId => Object.keys(scheduleData[staffId]).length > 0);
if (hasData) {
const swalResult = await Swal.fire({
title: '⚠️ แจ้งเตือน ⚠️',
text: `เดือน ${monthYearDisplay.textContent} มีการจัดเวรไว้แล้ว\nหากกดยืนยัน ระบบจะลบข้อมูลเดิมของเดือนนี้ทั้งหมดและจัดตารางใหม่\n\nต้องการจัดตารางใหม่หรือไม่?`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'ยืนยัน',
cancelButtonText: 'ยกเลิก'
});
if (!swalResult.isConfirmed) return;
} else {
const swalResult = await Swal.fire({
title: 'ยืนยัน',
text: `ต้องการให้ระบบจัดตารางเวรอัตโนมัติสำหรับเดือน ${monthYearDisplay.textContent} หรือไม่?`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'ใช่',
cancelButtonText: 'ยกเลิก'
});
if (!swalResult.isConfirmed) return;
}
showLoading();
try {
const daysInMonth = getDaysInMonth(currentMonth, currentYear);
let newSchedules = [];
for (let day = 1; day <= daysInMonth; day++) {
const isWknd = isWeekend(day, currentMonth, currentYear);
const isHol = isHoliday(day, currentMonth, currentYear);
const isOffDay = isWknd || isHol;
const dateObj = new Date(currentYear, currentMonth, day);
let dayOfWeek = dateObj.getDay(); // 0:Sun, 1:Mon, ..., 6:Sat
if (dayOfWeek === 0) dayOfWeek = 7; // Map Sun to 7, though standard pref expects 1-5 for Mon-Fri
// Shuffle staff list to distribute fairly if multiple people prefer the same day
const shuffledStaff = [...staffList].sort(() => 0.5 - Math.random());
if (!isOffDay) {
// Weekday: Look for staff who preferred this day (dayOfWeek 1-5)
for (const staff of shuffledStaff) {
if (staff.pref_weekdays) {
const prefs = staff.pref_weekdays.split(',');
if (prefs.includes(dayOfWeek.toString())) {
newSchedules.push({
staff_id: staff.id,
day: day,
shift_type: 'บ' // Default afternoon for weekdays
});
break; // Only 1 person per shift type usually, but we can just stop at the first match for simplicity
}
}
}
} else {
// Weekend/Holiday
let morningFilled = false;
let afternoonFilled = false;
for (const staff of shuffledStaff) {
if (!staff.pref_weekend) continue;
if (staff.pref_weekend === 'ช' && !morningFilled) {
newSchedules.push({ staff_id: staff.id, day: day, shift_type: 'ช' });
morningFilled = true;
} else if (staff.pref_weekend === 'บ' && !afternoonFilled) {
newSchedules.push({ staff_id: staff.id, day: day, shift_type: 'บ' });
afternoonFilled = true;
} else if (staff.pref_weekend === 'ช/บ' && !morningFilled && !afternoonFilled) {
newSchedules.push({ staff_id: staff.id, day: day, shift_type: 'ช/บ' });
morningFilled = true;
afternoonFilled = true;
}
if (morningFilled && afternoonFilled) break;
}
}
}
// Send bulk update to API
const response = await fetch('api/schedules.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bulk: true,
clear_month: true, // we wipe out previous schedules for the month
year: currentYear,
month: currentMonth + 1,
schedules: newSchedules
})
});
const result = await response.json();
if (result.status === 'success') {
// Refetch and re-render
await fetchSchedules(currentYear, currentMonth + 1);
renderSchedule();
Swal.fire('สำเร็จ', 'จัดเวรอัตโนมัติเสร็จสมบูรณ์!', 'success');
} else {
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาดในการจัดเวร: ' + result.message, 'error');
}
} catch (error) {
console.error(error);
Swal.fire('ข้อผิดพลาด', 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์', 'error');
} finally {
hideLoading();
}
}
// Start App
initApp();
function openDocuments() {
window.open(`documents.php?year=${currentYear}&month=${currentMonth + 1}`, '_blank');
}
</script>
</body>
</html>