ជំហាន ១
បើក
Google Sheets ថ្មីមួយ (ចុចចូលទៅកាន់
sheets.new)
ជំហាន ២
ចុចលើ Menu Extensions (ផ្នែកបន្ថែម) ➔ ជ្រើសរើស Apps Script
ជំហាន ៣
លុបកូដចាស់ដែលនៅក្នុងនោះចោល រួចចុចប៊ូតុង "📋 Copy កូដ Apps Script" ខាងលើ យកទៅ Paste (Ctrl+V) ចូល
ជំហាន ៤
ចុចប៊ូតុង Save (Ctrl+S) ➔ ត្រង់ប្រអប់រើស Function ជ្រើសរើសយក initialSetup រួចចុច Run ម្តង (ដើម្បីបង្កើត Sheets និង Headers ស្វ័យប្រវត្តិ)
ជំហាន ៥
ចុចប៊ូតុងពណ៌ខៀវខាងលើស្តាំ Deploy ➔ ជ្រើសរើស New deployment ➔ រើសប្រភេទ Web app
ជំហាន ៦
កំណត់ Execute as: Me និង Who has access: ជ្រើសរើស Anyone (អ្នកណាក៏ដោយ) ➔ រួចចុច Deploy (ផ្តល់សិទ្ធិ Authorize Access)
ជំហាន ៧
Copy យក Web app URL (ដែលបញ្ចប់ដោយ /exec) មក Paste ដាក់ក្នុងប្រអប់ Card 1 ខាងលើ រួចចុច "💾 រក្សាទុកការកំណត់" ➔ រួចចុច "📤 Push ឡើង Google Sheet (Sync All)" ជាការស្រេច! 🎉
💡
របៀប Update កូដថ្មី (ប្រសិនបើធ្លាប់បាន Deploy Google Sheets ពីមុនរួចហើយ)៖
ចូល Google Sheets ➔ Menu Extensions ➔ Apps Script ➔ Paste កូដថ្មីចូល ➔ ចុច Save ➔ ចុច Deploy ➔ Manage deployments ➔ ចុចរូបខ្មៅដៃ (Edit) ➔ Version: ជ្រើសរើស New version ➔ ចុច Deploy ជាការស្រេច!
/**
* ==============================================================================
* 007 PRO - QR Code Staff Attendance & Google Sheets Backend API
* ប្រព័ន្ធគ្រប់គ្រងម៉ោងចេញចូលបុគ្គលិកតាម QR Code ភ្ជាប់ជាមួយ Google Sheets
* ==============================================================================
*
* របៀបតម្លើង (Setup Instructions):
* 1. បង្កើត Google Sheets ថ្មីមួយ (https://sheets.new)
* 2. ចូលទៅកាន់ Extensions -> Apps Script
* 3. លុបកូដចាស់ទាំងអស់ចេញ ហើយ Paste កូដនេះចូល
* 4. ចុច Save (Ctrl+S)
* 5. ចុច Run លើ function "initialSetup" ម្តង ដើម្បីបង្កើត Sheet និង Header ស្វ័យប្រវត្តិ
* 6. ចុច Deploy -> New deployment -> Select type "Web app"
* - Description: "Attendance API v1.0"
* - Execute as: "Me"
* - Who has access: "Anyone" (សំខាន់ខ្លាំងណាស់ ដើម្បីឱ្យទូរស័ព្ទអាចផ្ញើទិន្នន័យបាន)
* 7. Copy "Web app URL" យកទៅ Paste ក្នុងកម្មវិធី Attendance App
* ==============================================================================
*/
// ឈ្មោះ Sheets ក្នុង Google Spreadsheet
const SHEET_LOGS = "Attendance_Logs"; // កំណត់ត្រាវត្តមានចេញចូល
const SHEET_STAFF = "Staff_List"; // បញ្ជីឈ្មោះបុគ្គលិក
const SHEET_SUMMARY = "Monthly_Summary"; // តារាងសង្ខេបប្រចាំខែ
const SHEET_SETTINGS = "Settings"; // ការកំណត់ម៉ោង និងប្រព័ន្ធ
/**
* មុខងារបង្កើតរចនាសម្ព័ន្ធ Google Sheet ដោយស្វ័យប្រវត្តិ (Run this once)
*/
function initialSetup() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
// 1. បង្កើត Sheet: Attendance_Logs
let logsSheet = ss.getSheetByName(SHEET_LOGS);
if (!logsSheet) {
logsSheet = ss.insertSheet(SHEET_LOGS);
}
logsSheet.clear();
const logsHeaders = [
"Log ID", "កាលបរិច្ឆេទ (Date)", "ម៉ោង (Time)", "អត្តលេខ (Staff ID)",
"ឈ្មោះបុគ្គលិក (Staff Name)", "ផ្នែក/តួនាទី (Department)", "ប្រភេទ (Action)",
"ស្ថានភាព (Status)", "ម៉ោងយឺត (Late Mins)", "ទីតាំង GPS (Location)",
"ឧបករណ៍ (Device/IP)", "សម្គាល់ (Notes)", "Timestamp"
];
logsSheet.getRange(1, 1, 1, logsHeaders.length).setValues([logsHeaders]);
formatHeaderRow(logsSheet, "#1e293b", "#ffffff");
logsSheet.setFrozenRows(1);
logsSheet.autoResizeColumns(1, logsHeaders.length);
// 2. បង្កើត Sheet: Staff_List
let staffSheet = ss.getSheetByName(SHEET_STAFF);
if (!staffSheet) {
staffSheet = ss.insertSheet(SHEET_STAFF);
}
if (staffSheet.getLastRow() === 0) {
const staffHeaders = [
"Staff ID", "ឈ្មោះបុគ្គលិក (Full Name)", "ភេទ (Gender)", "ផ្នែក (Department)",
"តួនាទី (Position)", "ទូរស័ព្ទ (Phone)", "ម៉ោងចូលគោល (Work Start)",
"ម៉ោងចេញគោល (Work End)", "ស្ថានភាព (Status)", "QR Code Data", "កាលបរិច្ឆេទចូលធ្វើការ"
];
staffSheet.getRange(1, 1, 1, staffHeaders.length).setValues([staffHeaders]);
formatHeaderRow(staffSheet, "#0f766e", "#ffffff");
staffSheet.setFrozenRows(1);
// បញ្ចូលទិន្នន័យគំរូ (Sample Staff Data)
const sampleStaff = [
["EMP-001", "សុខ សាន", "ប្រុស", "ព័ត៌មានវិទ្យា (IT)", "Lead Developer", "012 345 678", "09:00", "17:00", "Active", "EMP-001|សុខ សាន", "2024-01-01"],
["EMP-002", "ចាន់ ធីតា", "ស្រី", "គណនេយ្យ (Accounting)", "Senior Accountant", "098 765 432", "09:00", "17:00", "Active", "EMP-002|ចាន់ ធីតា", "2024-01-15"],
["EMP-003", "ម៉េង ហួរ", "ប្រុស", "ទីផ្សារ (Marketing)", "Marketing Officer", "088 112 233", "09:00", "17:00", "Active", "EMP-003|ម៉េង ហួរ", "2024-02-01"],
["EMP-004", "លី ស្រីមុំ", "ស្រី", "រដ្ឋបាល (Admin/HR)", "HR Assistant", "010 998 877", "09:00", "17:00", "Active", "EMP-004|លី ស្រីមុំ", "2024-02-15"]
];
staffSheet.getRange(2, 1, sampleStaff.length, sampleStaff[0].length).setValues(sampleStaff);
staffSheet.autoResizeColumns(1, staffHeaders.length);
}
// 3. បង្កើត Sheet: Settings
let settingsSheet = ss.getSheetByName(SHEET_SETTINGS);
if (!settingsSheet) {
settingsSheet = ss.insertSheet(SHEET_SETTINGS);
}
settingsSheet.clear();
const settingsData = [
["ការកំណត់ (Setting)", "តម្លៃ (Value)", "ការពន្យល់ (Description)"],
["Company Name", "007 PRO Enterprise", "ឈ្មោះក្រុមហ៊ុន"],
["Work Start Time", "09:00", "ម៉ោងចូលធ្វើការស្តង់ដារ (Format HH:mm)"],
["Work End Time", "17:00", "ម៉ោងចេញស្តង់ដារ (Format HH:mm)"],
["Grace Period (Minutes)", "15", "ចំនួននាទីដែលអនុញ្ញាតឱ្យយឺតដោយមិនកាត់"],
["Late Cut-off Time", "09:15", "ម៉ោងចាប់គិតថាយឺត"],
["Office Latitude", "11.5564", "រយៈទទឹង GPS ការិយាល័យ (Optional)"],
["Office Longitude", "104.9282", "រយៈបណ្តោយ GPS ការិយាល័យ (Optional)"],
["Allowed Radius (Meters)", "200", "កាំចម្ងាយអនុញ្ញាតឱ្យស្កេនជុំវិញការិយាល័យ"],
["Admin API Key", "", "កូដសម្ងាត់ការពារ Admin (អាចដាក់នៅទីនេះ ឬក្នុង Script Properties)"],
["API Version", "1.0.0", "ជំនាន់ប្រព័ន្ធ"]
];
settingsSheet.getRange(1, 1, settingsData.length, settingsData[0].length).setValues(settingsData);
formatHeaderRow(settingsSheet, "#4338ca", "#ffffff");
settingsSheet.autoResizeColumns(1, 3);
// 4. បង្កើត Sheet: Monthly_Summary
let summarySheet = ss.getSheetByName(SHEET_SUMMARY);
if (!summarySheet) {
summarySheet = ss.insertSheet(SHEET_SUMMARY);
}
summarySheet.clear();
const summaryHeaders = [
"ខែ-ឆ្នាំ (Month-Year)", "Staff ID", "ឈ្មោះបុគ្គលិក (Staff Name)",
"ថ្ងៃមកធ្វើការសរុប (Present)", "ថ្ងៃយឺតសរុប (Late Days)", "នាទីយឺតសរុប (Total Late Mins)",
"ម៉ោងធ្វើការសរុប (Work Hours)", "ម៉ោងថែមសរុប (OT Hours)"
];
summarySheet.getRange(1, 1, 1, summaryHeaders.length).setValues([summaryHeaders]);
formatHeaderRow(summarySheet, "#c2410c", "#ffffff");
summarySheet.setFrozenRows(1);
summarySheet.autoResizeColumns(1, summaryHeaders.length);
Logger.log("Setup Completed Successfully!");
}
/**
* តុបតែង Header ឱ្យមានពណ៌ស្អាត
*/
function formatHeaderRow(sheet, bgColor, textColor) {
const headerRange = sheet.getRange(1, 1, 1, sheet.getLastColumn());
headerRange.setBackground(bgColor);
headerRange.setFontColor(textColor);
headerRange.setFontWeight("bold");
headerRange.setFontFamily("Khmer OS Battambang");
headerRange.setFontSize(10);
headerRange.setHorizontalAlignment("center");
headerRange.setVerticalAlignment("middle");
sheet.setRowHeight(1, 35);
}
// ==============================================================================
// ADMIN API KEY SECURITY & VERIFICATION
// ==============================================================================
/**
* ទាញយក Admin API Key ដោយពិនិត្យមើល Script Properties ជាមុន និង Sheet Settings បន្ទាប់
*/
function getAdminApiKey_() {
var key = "";
try {
key = PropertiesService.getScriptProperties().getProperty("ADMIN_API_KEY") || "";
} catch (e) {}
if (!key) {
try {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var settings = getSettingsData(ss);
key = settings["Admin API Key"] || "";
} catch (e) {}
}
return key ? String(key).trim() : "";
}
/**
* ផ្ទៀងផ្ទាត់ Admin API Key សម្រាប់ការពារទិន្នន័យសំខាន់ៗ
*/
function verifyAdminApiKey(suppliedKey) {
var expectedKey = getAdminApiKey_();
// អនុញ្ញាតប្រសិនបើមិនទាន់កំណត់ Key ទាំងសងខាង (Graceful Mode សម្រាប់អ្នកទើបដំឡើងថ្មី)
if (!expectedKey) return true;
return Boolean(suppliedKey && String(suppliedKey).trim() === expectedKey);
}
/**
* មុខងារជំនួយសម្រាប់ភាពឆបគ្នាជាមួយកូដចាស់ (Backward Compatibility)
*/
function isValidAdminApiKey_(providedKey) {
return verifyAdminApiKey(providedKey);
}
// ==============================================================================
// REST API: GET Requests
// ប្រើសម្រាប់ទាញទិន្នន័យ (Fetch Staff, Today Logs, Settings, Summary)
// ==============================================================================
function doGet(e) {
try {
const params = (e && e.parameter) ? e.parameter : {};
const action = params.action || "ping";
const ss = SpreadsheetApp.getActiveSpreadsheet();
let result = {};
// បញ្ជី Admin-Only Actions ដែលទាមទារ Admin API Key
const adminOnlyActions = {
"getAllData": true,
"getInitialData": true,
"getSettings": true,
"getMonthlyReport": true
};
if (adminOnlyActions[action] && !verifyAdminApiKey(params.adminApiKey)) {
return createJsonResponse({
status: "error",
message: "Unauthorized: Admin API key required."
});
}
// =============================
// PUBLIC / STAFF SAFE ACTIONS
// =============================
if (action === "ping") {
result = {
status: "success",
message: "007 PRO Attendance API is active!",
timestamp: new Date().toISOString()
};
}
else if (action === "getStaff") {
result = {
status: "success",
staff: getStaffList(ss)
};
}
else if (action === "getTodayLogs") {
const today = getFormattedDate(new Date());
result = {
status: "success",
date: today,
logs: getLogsByDate(ss, today)
};
}
else if (action === "getAllLogs" || action === "getLogs") {
const targetDate = params.date || "";
result = {
status: "success",
date: targetDate,
logs: getLogsByDate(ss, targetDate)
};
}
// =============================
// ADMIN-ONLY DATA ACTIONS
// =============================
else if (action === "getAllData" || action === "getInitialData") {
result = {
status: "success",
staff: getStaffList(ss),
logs: getLogsByDate(ss, ""),
settings: getSettingsData(ss)
};
}
else if (action === "getSettings") {
result = {
status: "success",
settings: getSettingsData(ss)
};
}
else if (action === "getMonthlyReport") {
const monthYear = params.month || getFormattedMonth(new Date());
result = {
status: "success",
month: monthYear,
report: getMonthlyReport(ss, monthYear)
};
}
else if (action === "getSummary") {
result = {
status: "success",
system: "007 PRO Attendance API",
todayLogsCount: getLogsByDate(ss, getFormattedDate(new Date())).length,
totalStaff: getStaffList(ss).length
};
}
else {
// បិទមិនឱ្យលុបទិន្នន័យតាម GET (clearAllLogs / deleteLog ត្រូវតែប្រើ POST)
result = {
status: "error",
message: "Invalid or unauthorized action. Destructive actions must use POST."
};
}
return createJsonResponse(result);
} catch (error) {
return createJsonResponse({ status: "error", message: error.toString() });
}
}
// ==============================================================================
// REST API: POST Requests
// ប្រើសម្រាប់កត់ត្រាវត្តមាន (Check In, Check Out, Sync Data)
// ==============================================================================
function doPost(e) {
try {
let postData = {};
if (e && e.postData && e.postData.contents) {
try {
postData = JSON.parse(e.postData.contents);
} catch (err) {
postData = e.parameter || {};
}
} else if (e && e.parameter) {
postData = e.parameter;
}
const action = postData.action || "logAttendance";
const ss = SpreadsheetApp.getActiveSpreadsheet();
let result = {};
// បញ្ជី Admin-Only Actions ដែលទាមទារ Admin API Key
const adminOnlyActions = {
"bulkSyncStaff": true,
"saveStaff": true,
"updateSettings": true,
"clearAllLogs": true,
"deleteLog": true,
"bulkDeleteLogs": true
};
if (adminOnlyActions[action]) {
const key = postData.adminApiKey || (e && e.parameter && e.parameter.adminApiKey) || "";
if (!verifyAdminApiKey(key)) {
return createJsonResponse({
status: "error",
message: "Unauthorized: Admin API key required."
});
}
}
if (action === "logAttendance" || action === "checkIn" || action === "checkOut") {
result = recordAttendance(ss, postData);
}
else if (action === "bulkSync") {
result = recordBulkAttendance(ss, postData.logs || []);
}
else if (action === "bulkSyncStaff") {
result = saveBulkStaff(ss, postData.staff || []);
}
else if (action === "saveStaff") {
result = saveOrUpdateStaff(ss, postData.staff);
}
else if (action === "updateSettings") {
result = updateSystemSettings(ss, postData.settings);
}
else if (action === "clearAllLogs") {
result = clearAllAttendanceLogs(ss);
}
else if (action === "deleteLog") {
result = deleteLogFromSheet(ss, postData.logId || postData.id);
}
else if (action === "bulkDeleteLogs") {
result = bulkDeleteLogsFromSheet(ss, postData.logIds || postData.ids || []);
}
else {
result = { status: "error", message: "Invalid action requested: " + action };
}
return createJsonResponse(result);
} catch (error) {
return createJsonResponse({ status: "error", message: error.toString() });
}
}
// ==============================================================================
// ATTENDANCE LOGIC & PROCESSING
// ==============================================================================
/**
* មុខងារកត់ត្រាវត្តមានចូលក្នុង Google Sheets
*/
function recordAttendance(ss, data) {
let sheet = ss.getSheetByName(SHEET_LOGS);
if (!sheet) {
initialSetup();
sheet = ss.getSheetByName(SHEET_LOGS);
}
const now = new Date();
const dateStr = data.date || getFormattedDate(now);
const timeStr = data.time || data.check_in || getFormattedTime(now);
const checkIn = data.check_in || (data.actionType === "Check-In" ? timeStr : "");
const checkOut = data.check_out || (data.actionType === "Check-Out" ? timeStr : "");
const staffId = data.staffId || "N/A";
const staffName = data.staffName || "Unknown";
const department = data.department || "General";
const actionType = data.actionType || (checkOut ? "Check-Out" : "Check-In");
const location = data.location || "Office Scanner";
const device = data.device || "Mobile Device";
const notes = data.notes || "";
const logId = data.id || data.logId || ("LOG-" + now.getTime());
// គណនាស្ថានភាពយឺត ឬទាន់ពេល (Late / On-Time)
const settings = getSettingsData(ss);
const workStartTime = settings["Work Start Time"] || "09:00";
const graceMinutes = parseInt(settings["Grace Period (Minutes)"] || "15", 10);
let status = "ទាន់ពេល (On-Time)";
let lateMinutes = 0;
if (actionType.indexOf("ឈប់អត់ច្បាប់") !== -1 || actionType.indexOf("Unauthorized") !== -1) {
status = data.status || "ឈប់អត់ច្បាប់ (Unauthorized)";
lateMinutes = 0;
} else if (actionType.indexOf("ថ្ងៃសម្រាក") !== -1 || actionType.indexOf("Day Off") !== -1) {
status = data.status || "ថ្ងៃសម្រាក (Day Off)";
lateMinutes = 0;
} else if (actionType.indexOf("បន្ថែម") !== -1 || actionType.indexOf("Extra Day") !== -1) {
status = data.status || "ធ្វើការបន្ថែម (Extra Day)";
lateMinutes = 0;
} else if (actionType.indexOf("ច្បាប់") !== -1 || actionType.indexOf("Leave") !== -1) {
status = data.status || "សុំច្បាប់ (Leave)";
lateMinutes = 0;
} else {
if (checkIn && checkIn.indexOf(":") !== -1) {
lateMinutes = calculateLateMinutes(checkIn, workStartTime, graceMinutes);
}
const workEndTime = settings["Work End Time"] || "17:00";
let earlyMinutes = 0;
if (checkOut && checkOut.indexOf(":") !== -1) {
earlyMinutes = calculateEarlyLeaveMinutes(checkOut, workEndTime);
}
if (lateMinutes > 0 && earlyMinutes > 0) {
status = "យឺត " + lateMinutes + " នាទី & ចេញមុន " + earlyMinutes + " នាទី";
} else if (lateMinutes > 0) {
status = "យឺត " + lateMinutes + " នាទី";
} else if (earlyMinutes > 0) {
status = "ចេញមុន " + earlyMinutes + " នាទី";
} else {
status = "ទាន់ពេល (On-Time)";
}
}
// ស្វែងរកជួរចាស់របស់បុគ្គលិកក្នុងថ្ងៃនេះ (ស្វែងរកពីក្រោមឡើងលើដើម្បីល្បឿនលឿន)
const lastRow = sheet.getLastRow();
let updated = false;
if (lastRow > 1) {
const dataRange = sheet.getRange(2, 1, lastRow - 1, 13).getDisplayValues();
for (let r = dataRange.length - 1; r >= 0; r--) {
const rowDate = String(dataRange[r][1]).trim();
const rowStaff = String(dataRange[r][3]).trim();
const rowAction = String(dataRange[r][6]).trim();
// កាត់លេខ 0 ខាងមុខចេញ ដើម្បីឱ្យស្គាល់បុគ្គលិកទោះបីជា Format លេខខុសគ្នាតិចតួច (007 ស្មើ 7)
if (
String(rowStaff).replace(/^0+/, "") === String(staffId).trim().replace(/^0+/, "") &&
(rowDate === dateStr || rowDate.startsWith(dateStr))
) {
const actualRow = r + 2;
// ការពារទិន្នន័យសុំច្បាប់ (បើមាន Leave រួចហើយ មិនឱ្យស្កេនជាន់លើឡើយ)
const isLeaveRow =
rowAction.toLowerCase().includes("leave") ||
rowAction.includes("ច្បាប់");
if (checkOut || actionType === "Check-Out" || actionType.indexOf("ចេញ") !== -1) {
if (isLeaveRow) break;
const outVal = checkOut || timeStr;
sheet.getRange(actualRow, 3).setValue(outVal);
sheet.getRange(actualRow, 5).setValue(staffName);
sheet.getRange(actualRow, 6).setValue(department);
sheet.getRange(actualRow, 7).setValue("Check-Out");
sheet.getRange(actualRow, 8).setValue(status);
sheet.getRange(actualRow, 9).setValue(lateMinutes);
if (notes) sheet.getRange(actualRow, 12).setValue(notes);
updated = true;
break;
} else if (checkIn || actionType === "Check-In" || actionType.indexOf("ចូល") !== -1) {
if (isLeaveRow) break;
const inVal = checkIn || timeStr;
sheet.getRange(actualRow, 3).setValue(inVal);
sheet.getRange(actualRow, 5).setValue(staffName);
sheet.getRange(actualRow, 6).setValue(department);
sheet.getRange(actualRow, 7).setValue("Check-In");
sheet.getRange(actualRow, 8).setValue(status);
sheet.getRange(actualRow, 9).setValue(lateMinutes);
if (notes) sheet.getRange(actualRow, 12).setValue(notes);
updated = true;
break;
}
}
}
}
// បើមិនទាន់មាន Record ក្នុងថ្ងៃនោះ (ឬជា Record ច្បាប់ដែលបានការពារ) បង្កើតជួរថ្មី
if (!updated) {
const newRow = [
logId,
dateStr,
timeStr,
staffId,
staffName,
department,
actionType,
status,
lateMinutes,
location,
device,
notes,
now.toISOString()
];
sheet.appendRow(newRow);
const newLastRow = sheet.getLastRow();
const rowRange = sheet.getRange(newLastRow, 1, 1, newRow.length);
rowRange.setFontFamily("Khmer OS Battambang");
rowRange.setFontSize(9);
rowRange.setVerticalAlignment("middle");
}
return {
status: "success",
message: "កត់ត្រាវត្តមានជោគជ័យ!",
data: {
logId: logId,
staffId: staffId,
staffName: staffName,
actionType: actionType,
date: dateStr,
time: timeStr,
attendanceStatus: status,
lateMinutes: lateMinutes
}
};
}
/**
* មុខងារ Sync ទិន្នន័យច្រើនជួរ (Batch Sync) ចូល Sheet Attendance_Logs
*/
function recordBulkAttendance(ss, logsArray) {
if (!logsArray || logsArray.length === 0) {
return { status: "success", count: 0 };
}
let sheet = ss.getSheetByName(SHEET_LOGS);
if (!sheet) {
initialSetup();
sheet = ss.getSheetByName(SHEET_LOGS);
}
const rowsToInsert = [];
const settings = getSettingsData(ss);
const workStartTime = settings["Work Start Time"] || "09:00";
const graceMinutes = parseInt(settings["Grace Period (Minutes)"] || "15", 10);
const nowIso = new Date().toISOString();
for (let i = 0; i < logsArray.length; i++) {
const data = logsArray[i];
const logId = data.id || data.logId || ("LOG-" + (Date.now() + i));
const dateStr = data.date || getFormattedDate(new Date());
const timeStr = data.time || data.check_in || "09:00";
const staffId = data.staffId || data.employee_id || "N/A";
const staffName = data.staffName || "Unknown";
const department = data.department || "General";
const actionType = data.actionType || data.type || "Check-In";
const location = data.location || "Office Scanner / Offline Sync";
const device = data.device || "Desktop / Mobile";
const notes = data.notes || "";
let status = data.status || "ទាន់ពេល (On-Time)";
let lateMinutes = data.lateMinutes || 0;
if (!data.status) {
if (actionType.indexOf("ឈប់អត់ច្បាប់") !== -1 || actionType.indexOf("Unauthorized") !== -1) {
status = "ឈប់អត់ច្បាប់ (Unauthorized)";
} else if (actionType.indexOf("ថ្ងៃសម្រាក") !== -1 || actionType.indexOf("Day Off") !== -1) {
status = "ថ្ងៃសម្រាក (Day Off)";
} else if (actionType.indexOf("បន្ថែម") !== -1 || actionType.indexOf("Extra Day") !== -1) {
status = "ធ្វើការបន្ថែម (Extra Day)";
} else if (actionType.indexOf("ច្បាប់") !== -1 || actionType.indexOf("Leave") !== -1) {
status = "សុំច្បាប់ (Leave)";
} else if (actionType === "Check-In" || actionType === "ចូលធ្វើការ") {
lateMinutes = calculateLateMinutes(timeStr, workStartTime, graceMinutes);
if (lateMinutes > 0) {
status = "យឺត " + lateMinutes + " នាទី (Late)";
}
}
}
rowsToInsert.push([
logId,
dateStr,
timeStr,
staffId,
staffName,
department,
actionType,
status,
lateMinutes,
location,
device,
notes,
nowIso
]);
}
if (rowsToInsert.length > 0) {
const lastRow = sheet.getLastRow();
sheet.getRange(lastRow + 1, 1, rowsToInsert.length, 13).setValues(rowsToInsert);
}
return { status: "success", count: rowsToInsert.length, message: "Sync " + rowsToInsert.length + " logs successfully!" };
}
/**
* មុខងារ Sync បញ្ជីបុគ្គលិកទាំងអស់ចូល Sheet Staff_List
*/
function saveBulkStaff(ss, staffArray) {
if (!staffArray || staffArray.length === 0) return { status: "success", count: 0 };
let sheet = ss.getSheetByName(SHEET_STAFF);
if (!sheet) {
initialSetup();
sheet = ss.getSheetByName(SHEET_STAFF);
}
// Clear old staff data after header
if (sheet.getLastRow() > 1) {
sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).clearContent();
}
const rows = staffArray.map(s => [
s.id || "",
s.name || "",
s.gender || "ប្រុស",
s.department || "General",
s.position || "Staff",
s.phone || "",
s.workStart || "09:00",
s.workEnd || "17:00",
s.status || "Active",
s.qrData || (s.id + "|" + s.name),
s.joinDate || (s.salary ? ("$" + s.salary) : "")
]);
sheet.getRange(2, 1, rows.length, 11).setValues(rows);
return { status: "success", count: rows.length, message: "Sync " + rows.length + " staff successfully!" };
}
function saveOrUpdateStaff(ss, staff) {
return saveBulkStaff(ss, [staff]);
}
/**
* ទាញបញ្ជីបុគ្គលិកពី Sheet
*/
function getStaffList(ss) {
const sheet = ss.getSheetByName(SHEET_STAFF);
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const staffList = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row[0]) continue; // Skip empty rows
staffList.push({
id: row[0],
name: row[1],
gender: row[2] || "",
department: row[3] || "General",
position: row[4] || "",
phone: row[5] || "",
workStart: row[6] || "09:00",
workEnd: row[7] || "17:00",
status: row[8] || "Active",
qrData: row[9] || (row[0] + "|" + row[1]),
joinDate: row[10] ? row[10].toString() : "",
salary: row[11] || row[10] || 250
});
}
return staffList;
}
/**
* ទាញយកកំណត់ត្រាវត្តមានតាមថ្ងៃ
*/
function getLogsByDate(ss, targetDate) {
const sheet = ss.getSheetByName(SHEET_LOGS);
if (!sheet || sheet.getLastRow() <= 1) return [];
const displayData = sheet.getDataRange().getDisplayValues();
const logs = [];
for (let i = displayData.length - 1; i >= 1; i--) { // Reverse order (newest first)
const row = displayData[i];
const logDate = row[1] ? row[1].trim() : "";
if (!targetDate || logDate === targetDate) {
logs.push({
logId: row[0],
date: row[1],
time: row[2],
staffId: row[3],
staffName: row[4],
department: row[5],
actionType: row[6],
status: row[7],
lateMinutes: row[8],
location: row[9],
device: row[10],
notes: row[11],
timestamp: row[12]
});
}
}
return logs;
}
/**
* ទាញយកការកំណត់ (Settings)
*/
function getSettingsData(ss) {
const sheet = ss.getSheetByName(SHEET_SETTINGS);
if (!sheet || sheet.getLastRow() <= 1) {
return {
"Company Name": "007 PRO Enterprise",
"Work Start Time": "09:00",
"Work End Time": "17:00",
"Grace Period (Minutes)": "15"
};
}
const data = sheet.getDataRange().getValues();
const settings = {};
for (let i = 1; i < data.length; i++) {
const key = data[i][0];
const val = data[i][1];
if (key) {
settings[key] = val;
}
}
return settings;
}
/**
* កែប្រែការកំណត់ប្រព័ន្ធក្នុង Sheet Settings
*/
function updateSystemSettings(ss, newSettings) {
if (!newSettings || typeof newSettings !== "object") {
return { status: "error", message: "No settings provided" };
}
let sheet = ss.getSheetByName(SHEET_SETTINGS);
if (!sheet) {
initialSetup();
sheet = ss.getSheetByName(SHEET_SETTINGS);
}
const data = sheet.getDataRange().getValues();
const existingKeys = {};
for (let i = 1; i < data.length; i++) {
const key = String(data[i][0]).trim();
if (key) existingKeys[key] = i + 1; // 1-based row index
}
for (let key in newSettings) {
if (Object.prototype.hasOwnProperty.call(newSettings, key)) {
const val = newSettings[key];
if (existingKeys[key]) {
sheet.getRange(existingKeys[key], 2).setValue(val);
} else {
sheet.appendRow([key, val, "Custom Setting"]);
}
}
}
return { status: "success", message: "Settings updated successfully!", settings: getSettingsData(ss) };
}
/**
* ទាញយករាយការណ៍សង្ខេបប្រចាំខែ
*/
function getMonthlyReport(ss, monthYear) {
const summarySheet = ss.getSheetByName(SHEET_SUMMARY);
const reports = [];
if (summarySheet && summarySheet.getLastRow() > 1) {
const data = summarySheet.getDataRange().getDisplayValues();
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!monthYear || String(row[0]).trim() === String(monthYear).trim()) {
reports.push({
month: row[0],
staffId: row[1],
staffName: row[2],
presentDays: row[3],
lateDays: row[4],
lateMinutes: row[5],
workHours: row[6],
otHours: row[7]
});
}
}
}
return reports;
}
/**
* គណនាយឺតប៉ុន្មាននាទី (Minutes Late)
*/
function calculateLateMinutes(actualTimeStr, standardStartStr, graceMins) {
try {
const actualParts = parseTimeToMinutes(actualTimeStr);
const standardParts = parseTimeToMinutes(standardStartStr);
if (actualParts === null || standardParts === null) return 0;
const diff = actualParts - standardParts;
if (diff > graceMins) {
return diff;
}
return 0;
} catch (e) {
return 0;
}
}
/**
* គណនាចេញមុនប៉ុន្មាននាទី (Minutes Early Leave)
*/
function calculateEarlyLeaveMinutes(actualTimeStr, standardEndStr) {
try {
const actualParts = parseTimeToMinutes(actualTimeStr);
const standardParts = parseTimeToMinutes(standardEndStr);
if (actualParts === null || standardParts === null) return 0;
const diff = standardParts - actualParts;
if (diff > 0) {
return diff;
}
return 0;
} catch (e) {
return 0;
}
}
function parseTimeToMinutes(timeStr) {
if (!timeStr) return null;
const isPM = /PM/i.test(timeStr);
const isAM = /AM/i.test(timeStr);
const cleanStr = timeStr.replace(/(AM|PM)/ig, "").trim();
const parts = cleanStr.split(":");
if (parts.length < 2) return null;
let hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
if (isPM && hours < 12) hours += 12;
if (isAM && hours === 12) hours = 0;
return hours * 60 + minutes;
}
function getFormattedDate(d) {
const year = d.getFullYear();
const month = ("0" + (d.getMonth() + 1)).slice(-2);
const day = ("0" + d.getDate()).slice(-2);
return year + "-" + month + "-" + day;
}
function getFormattedMonth(d) {
const year = d.getFullYear();
const month = ("0" + (d.getMonth() + 1)).slice(-2);
return year + "-" + month;
}
function getFormattedTime(d) {
let hours = d.getHours();
const minutes = ("0" + d.getMinutes()).slice(-2);
const seconds = ("0" + d.getSeconds()).slice(-2);
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12;
hours = hours ? hours : 12;
const hoursStr = ("0" + hours).slice(-2);
return hoursStr + ":" + minutes + ":" + seconds + " " + ampm;
}
function createJsonResponse(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
/**
* មុខងារលុបកំណត់ត្រាវត្តមានទាំងអស់ចេញពី Sheet Attendance_Logs
*/
function clearAllAttendanceLogs(ss) {
let sheet = ss.getSheetByName(SHEET_LOGS);
if (!sheet) return { status: "success", count: 0, message: "No logs sheet found." };
const lastRow = sheet.getLastRow();
if (lastRow > 1) {
sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).clearContent();
}
return { status: "success", message: "All attendance logs cleared successfully from Google Sheets." };
}
/**
* មុខងារលុបកំណត់ត្រាមួយជួរតាម Log ID
*/
function deleteLogFromSheet(ss, logId) {
if (!logId) return { status: "error", message: "Missing logId" };
let sheet = ss.getSheetByName(SHEET_LOGS);
if (!sheet || sheet.getLastRow() <= 1) return { status: "success", deleted: false };
const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues();
for (let r = 0; r < data.length; r++) {
if (String(data[r][0]).trim() === String(logId).trim()) {
sheet.deleteRow(r + 2);
return { status: "success", deleted: true, logId: logId };
}
}
return { status: "success", deleted: false, message: "Log not found on Sheet" };
}
/**
* មុខងារលុបកំណត់ត្រាច្រើនជួរតាម Log IDs
*/
function bulkDeleteLogsFromSheet(ss, logIds) {
if (!logIds || logIds.length === 0) return { status: "success", count: 0 };
let sheet = ss.getSheetByName(SHEET_LOGS);
if (!sheet || sheet.getLastRow() <= 1) return { status: "success", count: 0 };
const idSet = {};
logIds.forEach(id => { idSet[String(id).trim()] = true; });
const lastRow = sheet.getLastRow();
const data = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
let deletedCount = 0;
// Delete from bottom to top to preserve row indices
for (let r = data.length - 1; r >= 0; r--) {
const rowId = String(data[r][0]).trim();
if (idSet[rowId]) {
sheet.deleteRow(r + 2);
deletedCount++;
}
}
return { status: "success", count: deletedCount };
}