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/views/edit/components/DialogExcelPreview.vue b/src/modules/04_registryPerson/views/edit/components/DialogExcelPreview.vue new file mode 100644 index 000000000..c100d9ba9 --- /dev/null +++ b/src/modules/04_registryPerson/views/edit/components/DialogExcelPreview.vue @@ -0,0 +1,263 @@ + + + diff --git a/src/modules/04_registryPerson/views/edit/components/Table.vue b/src/modules/04_registryPerson/views/edit/components/Table.vue index ab1a275aa..f7cb3a8d6 100644 --- a/src/modules/04_registryPerson/views/edit/components/Table.vue +++ b/src/modules/04_registryPerson/views/edit/components/Table.vue @@ -21,6 +21,7 @@ import type { import DialogForm from "@/modules/04_registryPerson/views/edit/components/DialogForm.vue"; import DialogSort from "@/modules/04_registryPerson/views/edit/components/DialogSort.vue"; +import DialogExcelPreview from "@/modules/04_registryPerson/views/edit/components/DialogExcelPreview.vue"; import CurruncyInput from "@/components/CurruncyInput.vue"; const $q = useQuasar(); @@ -53,6 +54,11 @@ const amountRef = ref(null); const amountSpecialRef = ref(null); const currencyPopupRef = ref(null); +// Excel Preview +const excelPreviewModal = ref(false); +const selectedExcelFile = ref(null); +const isParsingExcel = ref(false); + //Table const isLoad = ref(true); const rowIndex = ref(0); @@ -851,7 +857,7 @@ async function validateAndSave( /** * ฟังก์ชันอัปโหลดไฟล์ Excel - * ส่งไฟล์ Excel ไปยัง API โดยตรง + * เปิด preview modal ก่อนอัปโหลด */ function handUploadFile() { const input = document.createElement("input"); @@ -859,48 +865,73 @@ function handUploadFile() { input.accept = ".xlsx,.xls"; input.onchange = async (e: Event) => { - try { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; - // ตรวจสอบชนิดไฟล์โดยตรวจสอบนามสกุล - const validExtensions = [".xlsx", ".xls"]; - const fileExtension = file.name - .substring(file.name.lastIndexOf(".")) - .toLowerCase(); + // ตรวจสอบชนิดไฟล์โดยตรวจสอบนามสกุล + const validExtensions = [".xlsx", ".xls"]; + const fileExtension = file.name + .substring(file.name.lastIndexOf(".")) + .toLowerCase(); - if (!validExtensions.includes(fileExtension)) { - messageError("กรุณาเลือกไฟล์ Excel เท่านั้น (.xlsx, .xls)"); - return; - } - - showLoader(); - const type = empType.value === "officer" ? "office" : "employee"; - const formData = new FormData(); - formData.append("file", file); - - await http.post( - config.API.uploadProfile(type, profileId.value), - formData, - { - headers: { - "Content-Type": "multipart/form-data", + if (!validExtensions.includes(fileExtension)) { + messageError($q, { + response: { + data: { + title: "กรุณาเลือกไฟล์ Excel เท่านั้น (.xlsx, .xls)", }, - } - ); - - success($q, "อัปโหลดไฟล์สำเร็จ"); - await fetchData(); - } catch (error) { - messageError($q, error); - } finally { - hideLoader(); + }, + }); + return; } + + // เก็บไฟล์และเปิด preview modal + selectedExcelFile.value = file; + excelPreviewModal.value = true; }; input.click(); } +/** + * ฟังก์ชันยืนยันการอัปโหลดไฟล์ + * ส่งไฟล์ไปยัง API เมื่อ user ยืนยันจาก preview modal + */ +async function onConfirmUpload(file: File) { + try { + isParsingExcel.value = true; + showLoader(); + + const type = empType.value === "officer" ? "office" : "employee"; + const formData = new FormData(); + formData.append("file", file); + + await http.post(config.API.uploadProfile(type, profileId.value), formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + success($q, "อัปโหลดไฟล์สำเร็จ"); + await fetchData(); + excelPreviewModal.value = false; + } catch (error) { + messageError($q, error); + } finally { + hideLoader(); + isParsingExcel.value = false; + } +} + +/** + * ฟังก์ชันยกเลิกการอัปโหลด + * ปิด preview modal และล้างค่า + */ +function onCancelUpload() { + selectedExcelFile.value = null; + excelPreviewModal.value = false; +} + onMounted(async () => { await Promise.all([fetchData(), fetchType()]); }); @@ -1794,6 +1825,13 @@ onMounted(async () => { :fetch-data="fetchData" :columns="columns" /> + +