273 lines
11 KiB
Vue
273 lines
11 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-8 pb-20">
|
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('failedClockSummary') }}</h2>
|
|
<div class="mb-6 flex flex-col sm:flex-row sm:items-end gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
<div class="flex-grow">
|
|
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')"
|
|
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
|
</div>
|
|
<div class="flex items-end gap-4 flex-wrap">
|
|
<div class="flex flex-col">
|
|
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate')
|
|
}}</label>
|
|
<input type="date" id="start-date" v-model="filters.startDate"
|
|
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
|
</div>
|
|
<div class="flex flex-col">
|
|
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate')
|
|
}}</label>
|
|
<input type="date" id="end-date" v-model="filters.endDate"
|
|
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
|
</div>
|
|
<button @click="fetchFailedRecords" :disabled="loadingReport"
|
|
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
|
{{ loadingReport ? $t('loading') : $t('fetchRecords') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-[700px] w-full text-left">
|
|
<thead class="bg-gray-100 dark:bg-gray-700">
|
|
<tr class="border-b-2 border-gray-200 dark:border-gray-600">
|
|
<th
|
|
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer"
|
|
@click="sortBy('full_name')">
|
|
{{ $t('worker') }}
|
|
<span v-if="sortField === 'full_name'" class="ml-1">
|
|
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
|
</span>
|
|
</th>
|
|
<th
|
|
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer text-center"
|
|
@click="sortBy('count')">
|
|
{{ $t('failedCount') }}
|
|
<span v-if="sortField === 'count'" class="ml-1">
|
|
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
|
</span>
|
|
</th>
|
|
<th
|
|
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-center">
|
|
{{ $t('actions') }}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
<tr v-for="record in sortedFailedRecords" :key="record.worker_id"
|
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150">
|
|
<td class="px-4 py-3 text-gray-800 dark:text-white font-medium">
|
|
{{ record.full_name }}
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-800 dark:text-white text-center">
|
|
{{ record.count }}
|
|
</td>
|
|
<td class="px-4 py-3 text-center">
|
|
<button @click="showDetails(record.worker_id, record.full_name)"
|
|
class="text-blue-600 dark:text-blue-400 hover:underline">
|
|
{{ $t('viewDetails') }}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="failedRecords.length === 0 && !loadingReport">
|
|
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400 italic">
|
|
{{ $t('noRecordsFound') }}
|
|
</td>
|
|
</tr>
|
|
<tr v-if="loadingReport">
|
|
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
<div class="flex justify-center items-center">
|
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg"
|
|
fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
|
</path>
|
|
</svg>
|
|
<span>{{ $t('loadingReport') }}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl max-h-[90vh] flex flex-col">
|
|
<div class="flex justify-between items-center mb-4 border-b pb-3">
|
|
<h3 class="text-xl font-semibold text-gray-800 dark:text-white">
|
|
{{ detailModalTitle }}
|
|
</h3>
|
|
<button @click="showDetailModal = false"
|
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 text-2xl leading-none">
|
|
×
|
|
</button>
|
|
</div>
|
|
<div class="details-content overflow-y-auto flex-grow">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th
|
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{{ $t('timestamp') }}</th>
|
|
<th
|
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{{ $t('eventType') }}</th>
|
|
<th
|
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{{ $t('location') }}</th>
|
|
<th
|
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{{ $t('notes') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
<tr v-for="detail in detailRecords" :key="detail.id">
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
|
{{ formatLocalTimestamp(detail.timestamp) }}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
|
<span
|
|
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
|
{{ $t(detail.event_type) }}
|
|
</span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
|
{{ detail.qrCodeUsedName || $t('nA') }}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
|
{{ detail.notes || $t('nA') }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { apiFetch } from '@/api.js';
|
|
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: '' });
|
|
const loadingReport = ref(false);
|
|
const failedRecords = ref([]);
|
|
const showDetailModal = ref(false);
|
|
const detailRecords = ref([]);
|
|
const detailModalTitle = ref('');
|
|
const sortField = ref('count');
|
|
const sortDirection = ref('desc');
|
|
|
|
// --- COMPUTED ---
|
|
const sortedFailedRecords = computed(() => {
|
|
return [...failedRecords.value].sort((a, b) => {
|
|
const modifier = sortDirection.value === 'asc' ? 1 : -1;
|
|
if (a[sortField.value] < b[sortField.value]) return -1 * modifier;
|
|
if (a[sortField.value] > b[sortField.value]) return 1 * modifier;
|
|
return 0;
|
|
});
|
|
});
|
|
|
|
// --- METHODS ---
|
|
const fetchInitialData = async () => {
|
|
const today = new Date();
|
|
filters.value.endDate = today.toISOString().split('T')[0];
|
|
const twoMonthsAgo = new Date();
|
|
twoMonthsAgo.setMonth(today.getMonth() - 2);
|
|
filters.value.startDate = twoMonthsAgo.toISOString().split('T')[0];
|
|
await fetchFailedRecords();
|
|
};
|
|
|
|
const fetchFailedRecords = async () => {
|
|
loadingReport.value = true;
|
|
failedRecords.value = [];
|
|
|
|
try {
|
|
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);
|
|
toast.showToast('Failed to fetch records.', 'error');
|
|
} finally {
|
|
loadingReport.value = false;
|
|
}
|
|
};
|
|
|
|
const sortBy = (field) => {
|
|
if (sortField.value === field) {
|
|
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
sortField.value = field;
|
|
sortDirection.value = 'asc';
|
|
}
|
|
};
|
|
|
|
const showDetails = async (workerId, workerName) => {
|
|
try {
|
|
detailModalTitle.value = `${$t('failedRecordsFor')} ${workerName}`;
|
|
const url = `/api/managers/failed-records/details?workerId=${workerId}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
|
|
detailRecords.value = await apiFetch(url);
|
|
showDetailModal.value = true;
|
|
} catch (_err) {
|
|
console.error('Failed to fetch details', _err);
|
|
toast.showToast('Failed to load details.', 'error');
|
|
}
|
|
};
|
|
|
|
watch(searchQuery, fetchFailedRecords);
|
|
|
|
onMounted(() => {
|
|
fetchInitialData();
|
|
});
|
|
</script>
|