From 99f80a25d063f75f341d5ed4a444f446b1c2b9a8 Mon Sep 17 00:00:00 2001 From: Edison Date: Thu, 19 Mar 2026 16:33:22 +0800 Subject: [PATCH] added txt file export as extra export. --- backend/managerRoutes.js | 34 +++++++++++++++++++++ src/components/PersonnelManagement.vue | 41 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/backend/managerRoutes.js b/backend/managerRoutes.js index abdf0a7..df1e066 100644 --- a/backend/managerRoutes.js +++ b/backend/managerRoutes.js @@ -392,6 +392,7 @@ export default function () { } const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx' + const wantTxt = String(req.query.format || '').toLowerCase() === 'txt' let workerIdClause = '' let departmentClause = '' @@ -522,6 +523,39 @@ export default function () { byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows) } + // ===== TXT branch ===== + if (wantTxt) { + const lines = [] + for (const wId in workByDay) { + const w = workByDay[wId] + const seen = new Set() + for (const day of Object.keys(w.days).sort()) { + const events = w.days[day].slice().sort((a, b) => a.time - b.time) + for (const e of events) { + const code = e.type === 'clock_in' ? '1' : '0' + const date = ymdInTZ(e.time, TZ) + const timeStr = new Intl.DateTimeFormat('en-GB', { + timeZone: TZ, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(e.time) + const line = `${w.username},"${code}","${date} ${timeStr}";"${w.full_name}"` + if (!seen.has(line)) { + seen.add(line) + lines.push(line) + } + } + } + } + res.set('X-Export-TZ', TZ) + res.header('Content-Type', 'text/plain') + .attachment(`attendance_${startDate}_to_${endDate}.txt`) + .send(lines.join('\n')) + return + } + // ===== XLSX branch: grouped header + per-day summary sheet ===== if (wantXlsx) { const wb = new ExcelJS.Workbook() diff --git a/src/components/PersonnelManagement.vue b/src/components/PersonnelManagement.vue index 284f332..e5a4df6 100644 --- a/src/components/PersonnelManagement.vue +++ b/src/components/PersonnelManagement.vue @@ -95,6 +95,13 @@ {{ exportLoading ? $t('exporting') : $t('exportAll') }} +
+ +
@@ -471,6 +478,7 @@ const confirmMessage = ref(''); const isConfirmModalVisible = ref(false); const exportFilters = ref({ startDate: '', endDate: '' }); const exportLoading = ref(false); +const txtExportLoading = ref(false); const showClearDeviceConfirm = ref(false); const showDeleteConfirm = ref(false); const departments = ref([]); @@ -762,6 +770,39 @@ const exportWorkHours = async () => { } }; +const exportTxt = async () => { + const toast = useToast(); + txtExportLoading.value = true; + const { startDate, endDate } = exportFilters.value; + const workerIds = selectedWorkerIds.value.join(','); + let exportUrl = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=txt&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`; + if (selectedDepartment.value) { + exportUrl += `&department=${encodeURIComponent(selectedDepartment.value)}`; + } + try { + const response = await fetch(exportUrl, { + headers: { + Authorization: `Bearer ${sessionStorage.getItem('token')}`, + 'X-User-Timezone': getUserTimezone(), + }, + }); + if (!response.ok) throw new Error('Network response was not ok.'); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `attendance_${startDate}_to_${endDate}.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (_err) { + toast.showToast('Export TXT failed.', 'error'); + } finally { + txtExportLoading.value = false; + } +}; + onMounted(() => { fetchDepartments(); const savedSearchState = sessionStorage.getItem('personnelSearchState');