diff --git a/package.json b/package.json index 6e2ddd2c2..088a0ead7 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "axios": "^1.6.7", "bma-org-chart": "^0.0.8", "esri-loader": "^3.7.0", + "exceljs": "^4.4.0", "html-to-image": "^1.11.13", "keycloak-js": "^20.0.2", "moment": "^2.29.4", diff --git a/src/api/02_organizational/api.organization.ts b/src/api/02_organizational/api.organization.ts index bcb7e54d9..b30ec3ec1 100644 --- a/src/api/02_organizational/api.organization.ts +++ b/src/api/02_organizational/api.organization.ts @@ -192,6 +192,8 @@ export default { `${orgProfile}/keycloak/permissionProfile/${rootId}`, profileidPosition: (type: string) => `${orgProfile}${type}/profileid/position`, + uploadProfile: (type: string, id: string) => + `${organization}/upload/${type}-profileSalaryTemp/${id}`, workflowCommanderOperate: `${workflow}/commander/operate`, workflowCommanderSign: `${workflow}/commander/sign`, diff --git a/src/modules/04_registryPerson/interface/response/Edit.ts b/src/modules/04_registryPerson/interface/response/Edit.ts index 8af17bae8..d9deaf0bb 100644 --- a/src/modules/04_registryPerson/interface/response/Edit.ts +++ b/src/modules/04_registryPerson/interface/response/Edit.ts @@ -70,6 +70,7 @@ interface DataPosition { status: string; posNumCodeSitAbb: string; posNumCodeSit: string; + positionExecutiveField: string; } export type { DataSalaryPos, DataPosition }; diff --git a/src/modules/04_registryPerson/utils/excelParser.ts b/src/modules/04_registryPerson/utils/excelParser.ts new file mode 100644 index 000000000..b954edfc0 --- /dev/null +++ b/src/modules/04_registryPerson/utils/excelParser.ts @@ -0,0 +1,177 @@ +import * as XLSX from "xlsx"; + +export interface ExcelPreviewData { + fileName: string; + fileSize: number; + sheetNames: string[]; + headers: string[]; + rows: Array>; + totalRows: number; + previewRows: number; +} + +export interface ParseOptions { + maxPreviewRows?: number; + sheetIndex?: number; +} + +const DEFAULT_CONFIG = { + MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB + MAX_PREVIEW_ROWS: 0, // 0 = ไม่จำกัด แสดงทั้งหมด + ALLOWED_EXTENSIONS: [".xlsx", ".xls"], +}; + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; +} + +function getFileExtension(fileName: string): string { + return fileName.substring(fileName.lastIndexOf(".")).toLowerCase(); +} + +function validateFile(file: File): { message: string } | null { + const extension = getFileExtension(file.name); + + if (!DEFAULT_CONFIG.ALLOWED_EXTENSIONS.includes(extension)) { + return { + message: `กรุณาเลือกไฟล์ Excel เท่านั้น (${DEFAULT_CONFIG.ALLOWED_EXTENSIONS.join(", ")})`, + }; + } + + if (file.size > DEFAULT_CONFIG.MAX_FILE_SIZE) { + const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2); + const maxSizeMB = (DEFAULT_CONFIG.MAX_FILE_SIZE / (1024 * 1024)).toFixed(2); + return { + message: `ขนาดไฟล์เกิน ${maxSizeMB}MB (ไฟล์ของคุณ: ${fileSizeMB}MB)`, + }; + } + + return null; +} + +export async function parseExcelFile( + file: File, + options: ParseOptions = {} +): Promise { + const maxPreviewRows = options.maxPreviewRows ?? DEFAULT_CONFIG.MAX_PREVIEW_ROWS; + const sheetIndex = options.sheetIndex ?? 0; + + // Validate file + const validationError = validateFile(file); + if (validationError) { + throw new Error(validationError.message); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result; + if (!data) { + reject(new Error("ไม่สามารถอ่านไฟล์ได้")); + return; + } + + const workbook = XLSX.read(data, { type: "binary" }); + + if (workbook.SheetNames.length === 0) { + reject(new Error("ไฟล์ไม่มีข้อมูล")); + return; + } + + const sheetName = workbook.SheetNames[sheetIndex]; + const worksheet = workbook.Sheets[sheetName]; + + // ใช้ sheet_to_json กับ raw: true เพื่ออ่าน raw values ก่อน + const jsonData = XLSX.utils.sheet_to_json(worksheet, { + header: 1, + defval: "", + raw: true, // อ่าน raw values + }) as any[][]; + + // หลังจากได้ raw data แล้ว ต้องไปอ่านค่าจาก formula ที่ cells โดยตรง + // เพราะบาง formula อาจจะยังไม่ถูกคำนวณ + const range = XLSX.utils.decode_range(worksheet["!ref"] || "A1"); + + // อ่านค่าจาก cells ที่มี formula (column สุดท้าย) + for (let row = range.s.r; row <= range.e.r; row++) { + for (let col = range.s.c; col <= range.e.c; col++) { + const cellAddress = XLSX.utils.encode_cell({ r: row, c: col }); + const cell = worksheet[cellAddress]; + + // ถ้ามี formula ให้ใช้ผลลัพธ์ที่คำนวณแล้ว + if (cell && cell.f && !cell.v) { + // ลองใช้ cell.w (formatted value) ถ้ามี + if (cell.w) { + // แปลง array index ให้ถูกต้อง (row + 1 เพราะมี header row) + const arrayRowIndex = row - range.s.r; + if (jsonData[arrayRowIndex] && jsonData[arrayRowIndex][col] !== undefined) { + jsonData[arrayRowIndex][col] = cell.w; + } + } + } + } + } + + if (jsonData.length === 0) { + reject(new Error("ไฟล์ไม่มีข้อมูล")); + return; + } + + // Extract headers from first row (ภาษาไทย) + const headers = jsonData[0].map((h: any) => String(h ?? "")); + + // Extract data rows (skip header row) + const dataRows = jsonData.slice(1).filter((row) => row.some((cell) => cell !== "")); + + if (dataRows.length === 0) { + reject(new Error("ไฟล์ไม่มีข้อมูล")); + return; + } + + // Convert to array of objects - ใช้ header จาก Excel เป็น field names โดยตรง + // ถ้า maxPreviewRows = 0 จะแสดงทั้งหมด มิฉะนั้นจะแสดงตามจำนวนที่กำหนด + const rowsToProcess = maxPreviewRows === 0 ? dataRows : dataRows.slice(0, maxPreviewRows); + const rows = rowsToProcess.map((row, rowIndex) => { + const obj: Record = { + id: `row_${rowIndex}`, + }; + + // ใช้ header เป็น field names โดยตรง + headers.forEach((header, index) => { + const cellValue = row[index] ?? ""; + // ใช้ค่าจาก Excel โดยตรง ไม่แปลงค่า + obj[header] = cellValue; + }); + + return obj; + }); + + resolve({ + fileName: file.name, + fileSize: file.size, + sheetNames: workbook.SheetNames, + headers, + rows, + totalRows: dataRows.length, + previewRows: rows.length, + }); + } catch (error) { + reject(new Error("ไม่สามารถอ่านไฟล์ Excel ได้ กรุณาตรวจสอบไฟล์อีกครั้ง")); + } + }; + + reader.onerror = () => { + reject(new Error("ไม่สามารถอ่านไฟล์ได้")); + }; + + reader.readAsBinaryString(file); + }); +} + +export { formatFileSize }; diff --git a/src/modules/04_registryPerson/utils/exportPosition.ts b/src/modules/04_registryPerson/utils/exportPosition.ts new file mode 100644 index 000000000..144033429 --- /dev/null +++ b/src/modules/04_registryPerson/utils/exportPosition.ts @@ -0,0 +1,157 @@ +import ExcelJS from "exceljs"; +import { useEditPosDataStore } from "@/modules/04_registryPerson/stores/Edit"; + +import type { DataPosition } from "@/modules/04_registryPerson/interface/response/Edit"; + +const store = useEditPosDataStore(); + +// ฟังก์ชันแปลงวันที่จาก ISO format เป็นรูปแบบ dd/mm/yyyy (เช่น 18/05/2564) +function formatDateToDDMMYYYY(dateString: string | null | Date): string { + if (!dateString) return ""; + + const date = new Date(dateString); + if (isNaN(date.getTime())) return ""; + + const day = String(date.getDate()).padStart(2, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const year = date.getFullYear() + 543; // แปลงเป็นปีพุทธศักราช + + return `${day}/${month}/${year}`; +} + +export async function exportToExcelPosition(data: DataPosition[]) { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("รายการประวัติตำแหน่งเงินเดือน"); + + // --- ส่วนที่ 1: สร้าง Master Data Sheet สำหรับอ้างอิง ID --- + // เราจะซ่อนแผ่นงานนี้ไว้ (hidden) เพื่อใช้ทำ Dropdown และ VLOOKUP + const masterSheet = workbook.addWorksheet("MasterData", { state: "hidden" }); + const masterData = store.commandCodeData; // [{id: 1, name: "ย้าย"}, ...] + + masterData.forEach((item, index) => { + masterSheet.getCell(`A${index + 1}`).value = item.name; + masterSheet.getCell(`B${index + 1}`).value = item.id; + }); + + // --- ส่วนที่ 2: กำหนด Columns --- + worksheet.columns = [ + { header: "ลำดับ", key: "no", width: 8 }, + { header: "วันที่คำสั่งมีผล", key: "commandDateAffect", width: 18 }, + { header: "ตำแหน่งในสายงาน", key: "positionName", width: 25 }, + { header: "ตำแหน่งประเภท", key: "positionType", width: 18 }, + { header: "ระดับ", key: "positionLevel", width: 12 }, + { header: "ระดับซี", key: "positionCee", width: 12 }, + { header: "สายงาน", key: "positionLine", width: 20 }, + { header: "ด้าน/สาขา", key: "positionPathSide", width: 15 }, + { header: "ตำแหน่งทางการบริหาร", key: "positionExecutive", width: 20 }, + { header: "ด้านทางการบริหาร", key: "positionExecutiveField", width: 20 }, + { header: "เงินเดือน", key: "amount", width: 15 }, + { header: "เงินค่าตอบแทนรายเดือน", key: "mouthSalaryAmount", width: 15 }, + { header: "เงินประจำตำแหน่ง", key: "positionSalaryAmount", width: 15 }, + { header: "เงินค่าตอบแทนพิเศษ", key: "amountSpecial", width: 15 }, + { header: "หน่วยงาน", key: "organization", width: 30 }, + { header: "ส่วนราชการระดับ 1", key: "orgChild1", width: 20 }, + { header: "ส่วนราชการระดับ 2", key: "orgChild2", width: 20 }, + { header: "ส่วนราชการระดับ 3", key: "orgChild3", width: 20 }, + { header: "ส่วนราชการระดับ 4", key: "orgChild4", width: 20 }, + { header: "ตัวย่อเลขที่ตำแหน่ง", key: "posNoAbb", width: 15 }, + { header: "เลขที่ตำแหน่ง", key: "posNo", width: 15 }, + { header: "หน่วยงานที่ออกคำสั่ง", key: "posNumCodeSit", width: 20 }, + { + header: "ตัวย่อหน่วยงานที่ออกคำสั่ง", + key: "posNumCodeSitAbb", + width: 15, + }, + { header: "เลขที่คำสั่ง", key: "commandNo", width: 15 }, + { header: "ปีเลขที่คำสั่ง", key: "commandYear", width: 12 }, + { header: "วันที่ลงนาม", key: "commandDateSign", width: 18 }, + { header: "ประเภทคำสั่ง", key: "commandCodeName", width: 25 }, // AA + { header: "หมายเหตุ", key: "remark", width: 20 }, + { header: "commandId", key: "commandId", width: 20 }, // AC (ลำดับที่ 29) + { header: "commandCode", key: "commandCode", width: 20 }, // AD (ลำดับที่ 30) + ]; + + // 3. Map ข้อมูล + const newData = data.map((e, index) => ({ + no: index + 1, + commandDateAffect: e.commandDateAffect + ? formatDateToDDMMYYYY(e.commandDateAffect) + : "", + positionName: e.positionName, + positionType: e.positionType, + positionLevel: e.positionLevel, + positionCee: e.positionCee, + positionLine: e.positionLine || "", + positionPathSide: e.positionPathSide || "", + positionExecutive: e.positionExecutive, + positionExecutiveField: e.positionExecutiveField || "", + amount: e.amount || 0, + mouthSalaryAmount: e.mouthSalaryAmount || 0, + positionSalaryAmount: e.positionSalaryAmount || 0, + amountSpecial: e.amountSpecial || 0, + organization: e.orgRoot, + orgChild1: e.orgChild1, + orgChild2: e.orgChild2, + orgChild3: e.orgChild3, + orgChild4: e.orgChild4, + posNoAbb: e.posNoAbb, + posNo: e.posNo, + posNumCodeSit: e.posNumCodeSit, + posNumCodeSitAbb: e.posNumCodeSitAbb, + commandNo: e.commandNo, + commandYear: e.commandYear ? Number(e.commandYear) + 543 : "", + commandDateSign: e.commandDateSign + ? formatDateToDDMMYYYY(e.commandDateSign) + : "", + commandCodeName: store.convertCommandCodeName(e.commandCode), + remark: e.remark, + commandId: e.commandId || "", + commandCode: e.commandCode || "", // ใส่ค่าเริ่มต้นไว้ + })); + + worksheet.addRows(newData); + + // 4. ตกแต่งสี, Dropdown และ สูตร VLOOKUP + newData.forEach((_, index) => { + const rowIndex = index + 2; + + // --- ตั้งค่า Format สำหรับเซลล์วันที่ --- + // เซลล์ B (commandDateAffect) และ Z (commandDateSign) + const dateAffectCell = worksheet.getCell(`B${rowIndex}`); + const dateSignCell = worksheet.getCell(`Z${rowIndex}`); + + [dateAffectCell, dateSignCell].forEach((cell) => { + cell.numFmt = "dd/mm/yyyy"; + }); + + // --- ทำ Dropdown คอลัมน์ AA --- + // อ้างอิงรายการจาก MasterData Sheet จะทำให้ Excel ทำงานได้เสถียรกว่า (กรณีรายการเยอะ) + worksheet.getCell(`AA${rowIndex}`).dataValidation = { + type: "list", + allowBlank: true, + formulae: [`MasterData!$A$1:$A$${masterData.length}`], + }; + + // --- ผูกสูตรให้ commandCode (AD) เปลี่ยนตามการเลือกใน AA --- + // AA คือประเภทคำสั่ง, AD คือ commandCode + worksheet.getCell(`AD${rowIndex}`).value = { + formula: `=IFERROR(VLOOKUP(AA${rowIndex}, MasterData!$A$1:$B$${masterData.length}, 2, FALSE), "")`, + }; + }); + + // 5. สไตล์ Header + worksheet.getRow(1).font = { bold: true }; + worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" }; + + // 6. เขียนไฟล์ + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "รายการประวัติตำแหน่งเงินเดือน.xlsx"; + a.click(); + window.URL.revokeObjectURL(url); +} diff --git a/src/modules/04_registryPerson/views/edit/components/DialogExcelPreview.vue b/src/modules/04_registryPerson/views/edit/components/DialogExcelPreview.vue new file mode 100644 index 000000000..326a767c1 --- /dev/null +++ b/src/modules/04_registryPerson/views/edit/components/DialogExcelPreview.vue @@ -0,0 +1,270 @@ + + + diff --git a/src/modules/04_registryPerson/views/edit/components/Table.vue b/src/modules/04_registryPerson/views/edit/components/Table.vue index 7d99fd725..f470254c6 100644 --- a/src/modules/04_registryPerson/views/edit/components/Table.vue +++ b/src/modules/04_registryPerson/views/edit/components/Table.vue @@ -1,13 +1,13 @@