diff --git a/backend/middleware/withTzSession.js b/backend/middleware/withTzSession.js deleted file mode 100644 index cb71c66..0000000 --- a/backend/middleware/withTzSession.js +++ /dev/null @@ -1,21 +0,0 @@ -// backend/middleware/withTzSession.js -import { db } from '../server.js'; - -function tzToOffset(iana) { - // Stable offsets for two timezones - return iana === 'Asia/Jakarta' ? '+07:00' : '+08:00'; -} - -export async function withTzSession(req, res, next) { - const conn = await db.getConnection(); - req.db = conn; - try { - const iana = req.headers['x-user-timezone'] || 'Asia/Kuala_Lumpur'; - await conn.query('SET time_zone = ?', [tzToOffset(iana)]); - req.userTz = iana; - next(); - } catch (e) { - conn.release(); - res.status(500).json({ message: 'Failed to set session time zone' }); - } -} diff --git a/backend/workerRoutes.js b/backend/workerRoutes.js index 792fc31..1283106 100644 --- a/backend/workerRoutes.js +++ b/backend/workerRoutes.js @@ -101,10 +101,7 @@ export default function(db) { router.use(authenticateJWT); -// Definitive version with distance calculation and specific error messages - import { db } from './server.js'; // ensure this exists up top - -router.post('/clock', async (req, res) => { + router.post('/clock', async (req, res) => { // NEW: borrow a connection so we can set session time_zone const conn = await db.getConnection(); try { diff --git a/src/api.js b/src/api.js index 57f8b20..acd459b 100644 --- a/src/api.js +++ b/src/api.js @@ -1,11 +1,21 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; +function getUserTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur'; + } catch { + return 'Asia/Kuala_Lumpur'; + } +} + export async function apiFetch(endpoint, options = {}) { const token = sessionStorage.getItem('token'); const defaultHeaders = { 'ngrok-skip-browser-warning': 'true', 'Content-Type': 'application/json', + // Timezone header used by the backend to set session time_zone + 'X-User-Timezone': getUserTimezone(), ...options.headers, }; @@ -42,12 +52,18 @@ export async function apiFetch(endpoint, options = {}) { document.dispatchEvent(event); } // Use the 'details' from our backend error structure, or the message, or a default - throw new Error(errorData.details || errorData.message || `API call failed with status: ${response.status}`); + throw new Error( + errorData.details || + errorData.message || + `API call failed with status: ${response.status}` + ); } else { // If the server sends back HTML or plain text, use that as the error message. // This prevents the "Unexpected token '<'" error. const textError = await response.text(); - throw new Error(textError || `Server returned an unhandled error with status: ${response.status}`); + throw new Error( + textError || `Server returned an unhandled error with status: ${response.status}` + ); } } @@ -59,7 +75,7 @@ export async function apiFetch(endpoint, options = {}) { // Handle file downloads like CSV const disposition = response.headers.get('content-disposition'); if (disposition && disposition.includes('attachment')) { - return response.blob(); + return response.blob(); } return response.json(); diff --git a/src/components/KillSwitchManagement.vue b/src/components/KillSwitchManagement.vue index 6306d8c..784e506 100644 --- a/src/components/KillSwitchManagement.vue +++ b/src/components/KillSwitchManagement.vue @@ -6,11 +6,17 @@ {{ monthYear }}
- -
@@ -20,9 +26,8 @@
-
+
{{ day.date }}
@@ -52,10 +57,12 @@
- -
@@ -73,18 +80,30 @@ const { t: $t } = useI18n(); const toast = useToast(); const viewDate = ref(new Date()); -// Server-driven KL date for the yellow ring (updates every 60s) +// Server-driven "today" string (YYYY-MM-DD) for the yellow ring const todayStr = ref(null); -const TZ = 'Asia/Kuala_Lumpur'; +// --- timezone handling +const getUserTimezone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur'; + } catch { + return 'Asia/Kuala_Lumpur'; + } +}; + +const TZ = getUserTimezone(); // Helper: format YYYY-MM-DD in a given TZ const ymdInTZ = (tz, d = new Date()) => new Intl.DateTimeFormat('en-CA', { - timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', }).format(d); -// Pull today from server; try /api/time then /time; fallback to client KL +// Pull today from server; try /api/time then /time; fallback to client TZ async function getServerDate() { const parse = (data) => { if (typeof data?.ymdKL === 'string') return data.ymdKL; @@ -93,36 +112,49 @@ async function getServerDate() { }; for (const path of ['/api/time', '/time']) { - try { - const d = await apiFetch(`${path}?_t=${Date.now()}`); - const y = parse(d); - if (y) return y; - } catch (_err) { - continue; // try next endpoint + try { + const d = await apiFetch(`${path}?_t=${Date.now()}`); + const y = parse(d); + if (y) return y; + } catch (_err) { + continue; + } } -} - console.warn('Server time unavailable; using client KL time.'); + console.warn('Server time unavailable; using client time.'); return ymdInTZ(TZ, new Date()); } let _intervalId; onMounted(async () => { - const update = async () => { todayStr.value = await getServerDate(); }; + const update = async () => { + todayStr.value = await getServerDate(); + }; await update(); _intervalId = setInterval(update, 60_000); }); -onUnmounted(() => { if (_intervalId) clearInterval(_intervalId); }); +onUnmounted(() => { + if (_intervalId) clearInterval(_intervalId); +}); const originalEnabledDates = ref(new Set()); const datesToEnable = ref(new Set()); const datesToDisable = ref(new Set()); -const hasPendingChanges = computed(() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0); +const hasPendingChanges = computed( + () => datesToEnable.value.size > 0 || datesToDisable.value.size > 0 +); const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort()); const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort()); -const monthYear = computed(() => viewDate.value.toLocaleString('default', { month: 'long', year: 'numeric' })); +const monthYear = computed(() => + viewDate.value.toLocaleString('default', { + month: 'long', + year: 'numeric', + timeZone: TZ, + }) +); + const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const calendarGrid = computed(() => { @@ -148,7 +180,17 @@ const getDayClasses = (day) => { if (!day.isCurrentMonth) return 'h-20'; const dateStr = day.id; - const classes = ['h-20', 'flex', 'items-center', 'justify-center', 'text-lg', 'rounded-lg', 'cursor-pointer', 'transition-colors', 'relative']; + const classes = [ + 'h-20', + 'flex', + 'items-center', + 'justify-center', + 'text-lg', + 'rounded-lg', + 'cursor-pointer', + 'transition-colors', + 'relative', + ]; let isEnabled = originalEnabledDates.value.has(dateStr); if (datesToEnable.value.has(dateStr)) isEnabled = true; @@ -161,7 +203,18 @@ const getDayClasses = (day) => { classes.push('bg-blue-500', 'text-white', 'font-bold'); } else if (isPendingDisable) { classes.push('bg-red-200', 'dark:bg-red-800', 'text-red-700', 'dark:text-red-200'); - classes.push('after:content-[\'\']', 'after:absolute', 'after:w-3/4', 'after:h-0.5', 'after:bg-red-500', 'after:left-1/2', 'after:top-1/2', 'after:-translate-x-1/2', 'after:-translate-y-1/2', 'after:rotate-[-10deg]'); + classes.push( + 'after:content-[\'\']', + 'after:absolute', + 'after:w-3/4', + 'after:h-0.5', + 'after:bg-red-500', + 'after:left-1/2', + 'after:top-1/2', + 'after:-translate-x-1/2', + 'after:-translate-y-1/2', + 'after:rotate-[-10deg]' + ); } else if (isEnabled) { classes.push('bg-green-100', 'dark:bg-green-800', 'text-green-800', 'dark:text-green-200'); } else { @@ -169,8 +222,8 @@ const getDayClasses = (day) => { } if (todayStr.value && dateStr === todayStr.value) { - classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500'); -} + classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500'); + } return classes; }; @@ -191,7 +244,7 @@ function onDayClick(day) { } async function applyChanges() { - const confirmed = await toast.showConfirm($t('confirmApplyChanges')) + const confirmed = await toast.showConfirm($t('confirmApplyChanges')); if (!confirmed) return; try { @@ -216,12 +269,19 @@ function discardChanges() { datesToDisable.value.clear(); } -const prevMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() - 1)); -const nextMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() + 1)); +const prevMonth = () => + (viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() - 1))); +const nextMonth = () => + (viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() + 1))); -const formatDate = (dateStr) => new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, { - weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', -}); +const formatDate = (dateStr) => + new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: TZ, + }); async function fetchEnabledDates() { try { diff --git a/src/components/PersonnelManagement.vue b/src/components/PersonnelManagement.vue index bdc3e78..2acf1d7 100644 --- a/src/components/PersonnelManagement.vue +++ b/src/components/PersonnelManagement.vue @@ -4,28 +4,44 @@

{{ $t('addNewUser') }}

- - + +
- - + +
- - + +
- - + +
- - + +
-
@@ -37,21 +53,28 @@

{{ $t('workerRoster') }}

- +
-
- - -
-
- - -
- +
+ + +
+
+ + +
+
@@ -59,20 +82,40 @@ - + + + + {{ $t('fullName') }} + + + {{ $t('username') }} + + + {{ $t('department') }} + + + {{ $t('position') }} + + + {{ $t('status') }} + + + {{ $t('dateJoined') }} + + + {{ $t('actions') }} - {{ $t('fullName') }} - {{ $t('username') }} - {{ $t('department') }} - {{ $t('position') }} - {{ $t('status') }} {{ $t('dateJoined') }} - {{ $t('actions') }} - + - + {{ worker.full_name }} {{ worker.username }} @@ -87,41 +130,61 @@ {{ worker.status }} - {{ new Date(worker.created_at).toLocaleDateString() }} - - -
-
- +
+
- + / {{ totalPages }}
- +
-
+

{{ $t('employeeSettings') }}

@@ -133,31 +196,47 @@
- - + +
- - + +
- +
- - + +
-

{{ $t('workerStatus') }}

-

{{ $t('activeAccount') }}

+

+ {{ $t('workerStatus') }} +

+

+ {{ $t('activeAccount') }} +

@@ -167,19 +246,26 @@
{{ $t('clearDevice') }}
-

{{ $t('clearDeviceDescription') }}

+

+ {{ $t('clearDeviceDescription') }} +

-
-

{{ $t('confirmClearDevice') }}

+

+ {{ $t('confirmClearDevice') }} +

- -
@@ -190,19 +276,26 @@
{{ $t('delete') }}
-

{{ $t('deleteDescription') }}

+

+ {{ $t('deleteDescription') }} +

-
-

{{ $t('confirmDelete') }}

+

+ {{ $t('confirmDelete') }} +

- -
@@ -211,25 +304,33 @@
-

{{ passwordErrorMessage }}

-

{{ passwordSuccessMessage }}

+

+ {{ passwordErrorMessage }} +

+

+ {{ passwordSuccessMessage }} +

-
-
+

{{ confirmMessage }}

- -
@@ -248,11 +349,46 @@ import { useI18n } from 'vue-i18n'; import { workerCache } from '@/utils/workerCache.js'; const { t: $t } = useI18n(); - const router = useRouter(); +// --- timezone helpers (for consistent local display + export header) --- +const getUserTimezone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur'; + } catch { + return 'Asia/Kuala_Lumpur'; + } +}; + +const formatLocalDate = (utcValue) => { + if (!utcValue) return ''; + const tz = getUserTimezone(); + + let iso = utcValue; + + if (utcValue instanceof Date) { + iso = utcValue.toISOString(); + } else if (typeof utcValue === 'string') { + if (!iso.endsWith('Z')) { + if (iso.includes('T')) { + iso = iso + 'Z'; + } else { + iso = iso.replace(' ', 'T') + 'Z'; + } + } + } + + const d = new Date(iso); + + return d.toLocaleDateString(undefined, { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +}; + const viewRecords = (workerId) => { - // Save current search state before navigating away const searchState = { searchQuery: searchQuery.value, currentPage: currentPage.value, @@ -260,7 +396,7 @@ const viewRecords = (workerId) => { totalWorkers: totalWorkers.value, workers: workers.value, selectedWorkerIds: selectedWorkerIds.value, - exportFilters: exportFilters.value + exportFilters: exportFilters.value, }; sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState)); @@ -271,7 +407,13 @@ const viewRecords = (workerId) => { const workers = ref([]); const loading = ref(false); const errorMessage = ref(''); -const newWorker = ref({ fullName: '', username: '', password: '', department: '', position: '' }); +const newWorker = ref({ + fullName: '', + username: '', + password: '', + department: '', + position: '', +}); const searchQuery = ref(''); const currentPage = ref(1); const pageSize = ref(20); @@ -290,15 +432,20 @@ const confirmMessage = ref(''); const isConfirmModalVisible = ref(false); const exportFilters = ref({ startDate: '', endDate: '' }); const exportLoading = ref(false); -// Removed workerStatusLoading as it's no longer needed with integrated save +const showClearDeviceConfirm = ref(false); +const showDeleteConfirm = ref(false); // --- COMPUTED --- -const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password); +const isFormValid = computed( + () => newWorker.value.fullName && newWorker.value.username && newWorker.value.password +); const totalPages = computed(() => { const pages = Math.ceil(totalWorkers.value / pageSize.value); - return pages < 1 ? 1 : pages; // Ensure at least 1 page + return pages < 1 ? 1 : pages; }); -const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length); +const isAllSelected = computed( + () => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length +); // --- WATCHERS --- watch(searchQuery, () => fetchWorkers(1)); @@ -311,18 +458,17 @@ watch(currentPage, (newPage) => { const fetchWorkers = async (page = currentPage.value) => { loading.value = true; try { - const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`); + const data = await apiFetch( + `/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}` + ); workers.value = data.workers; totalWorkers.value = data.totalCount; - // Cache worker data if (data.workers && Array.isArray(data.workers)) { - data.workers.forEach(worker => { + data.workers.forEach((worker) => { workerCache.storeWorkerData(worker.id, worker); }); } - - // currentPage is already set to the requested page before fetch } catch (_err) { errorMessage.value = 'Failed to fetch workers.'; workers.value = []; @@ -355,12 +501,18 @@ const addWorker = async () => { loading.value = true; errorMessage.value = ''; try { - await apiFetch('/api/managers/workers', { - method: 'POST', - body: JSON.stringify({ ...newWorker.value, role: 'worker' }), - }); + await apiFetch('/api/managers/workers', { + method: 'POST', + body: JSON.stringify({ ...newWorker.value, role: 'worker' }), + }); await fetchWorkers(1); - newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' }; + newWorker.value = { + fullName: '', + username: '', + password: '', + department: '', + position: '', + }; toast.showToast($t('workerAdded'), 'success'); } catch (_err) { toast.showToast(_err.message || $t('addUserError'), 'error'); @@ -376,7 +528,9 @@ const deleteWorker = async (id) => { try { await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' }); toast.showToast($t('workerSoftDeleted'), 'success'); - fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value); + fetchWorkers( + workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value + ); } catch (_err) { errorMessage.value = 'Failed to soft-delete worker.'; } @@ -392,16 +546,15 @@ const clearDevice = async (workerId) => { } }; -// Renamed and refactored updateWorkerPassword to saveWorkerSettings const saveWorkerSettings = async () => { const toast = useToast(); passwordErrorMessage.value = ''; passwordSuccessMessage.value = ''; let passwordUpdated = false; let detailsUpdated = false; - toast.showToast($t('savingSettings'), 'info'); - // Handle password change + toast.showToast($t('savingSettings'), 'info'); + if (newPassword.value || confirmNewPassword.value) { if (newPassword.value !== confirmNewPassword.value) { passwordErrorMessage.value = 'Passwords do not match.'; @@ -414,9 +567,9 @@ const saveWorkerSettings = async () => { passwordUpdated = true; } - // Handle details change (status, department, position) - const originalWorker = workers.value.find(w => w.id === editingWorker.value.id); + const originalWorker = workers.value.find((w) => w.id === editingWorker.value.id); const newStatus = editingWorker.value.isActive ? 'active' : 'inactive'; + if ( originalWorker.status !== newStatus || originalWorker.department !== editingWorker.value.department || @@ -468,7 +621,7 @@ const saveWorkerSettings = async () => { }; const openSettingsModal = (worker) => { - editingWorker.value = { ...worker, isActive: worker.status === 'active' }; // Initialize isActive for checkbox + editingWorker.value = { ...worker, isActive: worker.status === 'active' }; isSettingsModalVisible.value = true; }; @@ -483,9 +636,6 @@ const closeSettingsModal = () => { showDeleteConfirm.value = false; }; -const showClearDeviceConfirm = ref(false); -const showDeleteConfirm = ref(false); - const closeConfirmModal = () => { isConfirmModalVisible.value = false; confirmAction.value = ''; @@ -510,7 +660,7 @@ const toggleWorkerSelection = (workerId) => { }; const toggleSelectAll = (event) => { - selectedWorkerIds.value = event.target.checked ? workers.value.map(w => w.id) : []; + selectedWorkerIds.value = event.target.checked ? workers.value.map((w) => w.id) : []; }; const exportWorkHours = async () => { @@ -518,14 +668,18 @@ const exportWorkHours = async () => { exportLoading.value = true; toast.showToast($t('exportingRecords'), 'info'); const { startDate, endDate } = exportFilters.value; - let workerIds = selectedWorkerIds.value.join(','); + const workerIds = selectedWorkerIds.value.join(','); try { - const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, { - headers: { - 'Authorization': `Bearer ${sessionStorage.getItem('token')}` + const response = await fetch( + `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, + { + 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); @@ -544,7 +698,6 @@ const exportWorkHours = async () => { }; onMounted(() => { - // Check if there's saved search state const savedSearchState = sessionStorage.getItem('personnelSearchState'); if (savedSearchState) { try { @@ -556,11 +709,8 @@ onMounted(() => { workers.value = searchState.workers || []; selectedWorkerIds.value = searchState.selectedWorkerIds || []; exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' }; - - // Clear the saved search state after restoring it sessionStorage.removeItem('personnelSearchState'); } catch (_e) { - // If there's an error parsing the saved state, fetch workers normally fetchWorkers(); } } else { diff --git a/src/components/WarningReporting.vue b/src/components/WarningReporting.vue index 3622d04..78d188e 100644 --- a/src/components/WarningReporting.vue +++ b/src/components/WarningReporting.vue @@ -4,18 +4,24 @@

{{ $t('failedClockSummary') }}

- +
- - + +
- - + +
-
@@ -24,23 +30,40 @@ - - + - - - - + + + @@ -53,9 +76,12 @@ - + + - + -
+ {{ $t('worker') }} - {{ sortDirection === 'asc' ? '↑' : '↓' }} + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + {{ $t('failedCount') }} - {{ sortDirection === 'asc' ? '↑' : '↓' }} + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + + {{ $t('actions') }} {{ $t('actions') }}
{{ record.full_name }}{{ record.count }}
+ {{ record.full_name }} + + {{ record.count }} + -
- + - + + {{ $t('loadingReport') }}
@@ -69,8 +95,11 @@
-

{{ detailModalTitle }}

-
@@ -78,22 +107,37 @@ - - - - + + + + - + - - + +
{{ $t('timestamp') }}{{ $t('eventType') }}{{ $t('location') }}{{ $t('notes') }} + {{ $t('timestamp') }} + {{ $t('eventType') }} + {{ $t('location') }} + {{ $t('notes') }}
{{ new Date(detail.timestamp).toLocaleString() }} - + {{ formatLocalTimestamp(detail.timestamp) }} + + {{ $t(detail.event_type) }} {{ detail.qrCodeUsedName || $t('nA') }}{{ detail.notes || $t('nA') }} + {{ detail.qrCodeUsedName || $t('nA') }} + + {{ detail.notes || $t('nA') }} +
@@ -112,6 +156,47 @@ import { useToast } from '@/composables/useToast'; const { t: $t } = useI18n(); const toast = useToast(); +// --- timezone-aware formatter (local helper) --- +const getUserTimezone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur'; + } catch { + return 'Asia_Kuala_Lumpur'; + } +}; + +const formatLocalTimestamp = (utcValue) => { + if (!utcValue) return ''; + const tz = getUserTimezone(); + + let iso = utcValue; + + if (utcValue instanceof Date) { + iso = utcValue.toISOString(); + } else if (typeof utcValue === 'string') { + if (!iso.endsWith('Z')) { + if (iso.includes('T')) { + iso = iso + 'Z'; + } else { + iso = iso.replace(' ', 'T') + 'Z'; + } + } + } + + const d = new Date(iso); + + return d.toLocaleString(undefined, { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +}; + // --- STATE --- const searchQuery = ref(''); const filters = ref({ startDate: '', endDate: '' }); @@ -151,7 +236,7 @@ const fetchFailedRecords = async () => { const url = `/api/managers/failed-records?search=${searchQuery.value}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`; failedRecords.value = await apiFetch(url); } catch (_err) { - console.error('Failed to fetch failed records',_err); + console.error('Failed to fetch failed records', _err); toast.showToast('Failed to fetch records.', 'error'); } finally { loadingReport.value = false; @@ -174,7 +259,7 @@ const showDetails = async (workerId, workerName) => { detailRecords.value = await apiFetch(url); showDetailModal.value = true; } catch (_err) { - console.error('Failed to fetch details',_err); + console.error('Failed to fetch details', _err); toast.showToast('Failed to load details.', 'error'); } }; diff --git a/src/utils/time.js b/src/utils/time.js new file mode 100644 index 0000000..113e4fd --- /dev/null +++ b/src/utils/time.js @@ -0,0 +1,50 @@ +// src/utils/time.js + +// Same logic as apiFetch and KillSwitchManagement +export function getUserTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur'; + } catch { + return 'Asia_Kuala_Lumpur'; + } +} + +// utcValue can be: "2025-11-03 16:30:00", ISO string, or Date +export function formatUtcToLocal(utcValue, options = {}) { + if (!utcValue) return ''; + + const tz = options.timeZone || getUserTimezone(); + const locale = options.locale || 'en-MY'; + + let d; + + if (utcValue instanceof Date) { + d = utcValue; + } else if (typeof utcValue === 'string') { + // Normalize: DB gives "YYYY-MM-DD HH:mm:ss" (UTC) – turn into ISO UTC + let iso = utcValue; + if (!iso.endsWith('Z')) { + if (iso.includes('T')) { + iso = iso + 'Z'; + } else { + iso = iso.replace(' ', 'T') + 'Z'; + } + } + d = new Date(iso); + } else if (typeof utcValue === 'number') { + d = new Date(utcValue); + } else { + return ''; + } + + return d.toLocaleString(locale, { + timeZone: tz, + year: options.year ?? 'numeric', + month: options.month ?? '2-digit', + day: options.day ?? '2-digit', + hour: options.hour ?? '2-digit', + minute: options.minute ?? '2-digit', + second: options.second ?? '2-digit', + hour12: options.hour12 ?? false, + }); +} diff --git a/src/views/ManagerAttendanceRecord.vue b/src/views/ManagerAttendanceRecord.vue index caa622b..237e4ed 100644 --- a/src/views/ManagerAttendanceRecord.vue +++ b/src/views/ManagerAttendanceRecord.vue @@ -19,20 +19,38 @@

- - + +
- - + {{ $t('reason') }} + + + :placeholder="$t('enterBriefNote')" + />
-
@@ -40,23 +58,45 @@
- - + +
- - + +
- -
@@ -87,8 +127,11 @@ {{ $t('noRecordsFound') }}
+ }" + > {{ record.event_type.replace('_', ' ') }} - {{ new Date(record.timestamp).toLocaleString() }} + {{ formatLocalTimestamp(record.timestamp) }} + + {{ record.qrCodeUsedName }} {{ record.qrCodeUsedName }} - + {{ $t('showOnMap') }} - {{ $t('nA') }} + + {{ $t('nA') }} + + + {{ record.notes || $t('nA') }} {{ record.notes || $t('nA') }}
@@ -135,15 +189,60 @@ const records = ref([]) const workerName = ref('') const workerId = route.params.workerId +const getUserTimezone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur' + } catch { + return 'Asia/Kuala_Lumpur' + } +} + +const normalizeUtcToIso = (utcValue) => { + if (!utcValue) return null + + if (utcValue instanceof Date) { + return utcValue.toISOString() + } + + let iso = utcValue + if (typeof iso === 'string') { + if (!iso.endsWith('Z')) { + if (iso.includes('T')) { + iso = iso + 'Z' + } else { + iso = iso.replace(' ', 'T') + 'Z' + } + } + } + return iso +} + +const formatLocalTimestamp = (utcValue) => { + const iso = normalizeUtcToIso(utcValue) + if (!iso) return '' + const tz = getUserTimezone() + const d = new Date(iso) + return d.toLocaleString(undefined, { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) +} + const toLocalISOString = (date) => { - const tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds + const tzoffset = new Date().getTimezoneOffset() * 60000 // offset in ms const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16) return localISOTime } const manualClockOut = ref({ timestamp: toLocalISOString(new Date()), - notes: '', + notes: '' }) const today = new Date() @@ -152,15 +251,14 @@ setStartDay.setDate(today.getDate() - 60) const filters = ref({ startDate: setStartDay.toISOString().split('T')[0], - endDate: today.toISOString().split('T')[0], + endDate: today.toISOString().split('T')[0] }) -const exportLoading = ref(false); +const exportLoading = ref(false) const goBack = () => { - // Navigate back to the manager dashboard (PersonnelManagement component) - window.history.back(); -}; + window.history.back() +} const fetchRecords = async () => { let url = `/api/managers/attendance-records?workerIds=${workerId}` @@ -174,21 +272,19 @@ const fetchRecords = async () => { if (data && Array.isArray(data)) { records.value = data if (!workerName.value && data.length > 0) { - // Check if worker data is cached - const cachedWorkerData = workerCache.getWorkerData(workerId); + const cachedWorkerData = workerCache.getWorkerData(workerId) if (cachedWorkerData) { - workerName.value = cachedWorkerData.full_name; + workerName.value = cachedWorkerData.full_name } else { - workerName.value = data[0].full_name; - // Cache the worker data for future use - workerCache.storeWorkerData(workerId, { full_name: data[0].full_name }); + workerName.value = data[0].full_name + workerCache.storeWorkerData(workerId, { full_name: data[0].full_name }) } } } else { records.value = [] } } catch (_err) { - console.error('Failed to fetch attendance records:',_err) + console.error('Failed to fetch attendance records:', _err) alert(_err.message) records.value = [] } @@ -208,14 +304,14 @@ const addManualClockOut = async () => { await apiFetch('/api/managers/add-record', { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }, body: JSON.stringify({ workerId: workerId, eventType: 'clock_out', timestamp: manualClockOut.value.timestamp, - notes: manualClockOut.value.notes, - }), + notes: manualClockOut.value.notes + }) }) alert(t('manualClockOutSuccess')) @@ -223,41 +319,45 @@ const addManualClockOut = async () => { manualClockOut.value.timestamp = toLocalISOString(new Date()) fetchRecords() } catch (_err) { - console.error('Failed to submit manual clock-out:',_err) + console.error('Failed to submit manual clock-out:', _err) alert(t('manualClockOutError', { msg: _err.message })) } } const exportRawRecords = async () => { - exportLoading.value = true; - const { startDate, endDate } = filters.value; + exportLoading.value = true + const { startDate, endDate } = filters.value - // pull preferred tz from localStorage; fall back to browser tz - const tz = localStorage.getItem('tz') || Intl.DateTimeFormat().resolvedOptions().timeZone; + const tz = localStorage.getItem('tz') || getUserTimezone() try { const response = await fetch( - `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}&tz=${encodeURIComponent(tz)}`, + `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}&tz=${encodeURIComponent( + tz + )}`, { - headers: { 'Authorization': `Bearer ${sessionStorage.getItem('token')}` } + headers: { + Authorization: `Bearer ${sessionStorage.getItem('token')}`, + 'X-User-Timezone': tz + } } - ); - 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 = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`; - document.body.appendChild(a); - a.click(); - a.remove(); - window.URL.revokeObjectURL(url); + ) + 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 = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv` + document.body.appendChild(a) + a.click() + a.remove() + window.URL.revokeObjectURL(url) } catch (_err) { - alert('Failed to export records.'); + alert('Failed to export records.') } finally { - exportLoading.value = false; + exportLoading.value = false } -}; +} onMounted(() => { fetchRecords() diff --git a/src/views/WorkerHistory.vue b/src/views/WorkerHistory.vue index 7f8495e..83f22b0 100644 --- a/src/views/WorkerHistory.vue +++ b/src/views/WorkerHistory.vue @@ -2,11 +2,9 @@
-