Files
Nilai_Clock/src/components/WarningReporting.vue
T
2025-11-03 16:48:13 +08:00

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">
&times;
</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>