department filter and excel support

This commit is contained in:
Edison
2026-01-02 14:51:36 +08:00
parent 2e7de997ff
commit 2d7ddbb96a
5 changed files with 114 additions and 27 deletions
+40 -4
View File
@@ -394,6 +394,7 @@ export default function () {
const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx' const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx'
let workerIdClause = '' let workerIdClause = ''
let departmentClause = ''
const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`] const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`]
if (workerIds) { if (workerIds) {
@@ -406,6 +407,12 @@ export default function () {
} }
} }
const { department } = req.query
if (department) {
departmentClause = ` AND LOWER(w.department) = LOWER(?)`
params.push(department)
}
const query = ` const query = `
SELECT SELECT
cr.worker_id, cr.worker_id,
@@ -418,7 +425,7 @@ export default function () {
FROM clock_records cr FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id JOIN workers w ON cr.worker_id = w.id
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause} WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}${departmentClause}
AND cr.event_type IN ('clock_in','clock_out') AND cr.event_type IN ('clock_in','clock_out')
ORDER BY cr.worker_id, cr.timestamp ASC ORDER BY cr.worker_id, cr.timestamp ASC
` `
@@ -850,9 +857,16 @@ export default function () {
let whereClauses = ["w.role = 'worker'", "w.status != 'deleted'"] // Filter out soft-deleted workers let whereClauses = ["w.role = 'worker'", "w.status != 'deleted'"] // Filter out soft-deleted workers
if (search) { if (search) {
whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`) whereClauses.push(`w.full_name LIKE ?`)
params.push(searchTerm, searchTerm) params.push(searchTerm)
countParams.push(searchTerm, searchTerm) countParams.push(searchTerm)
}
const { department } = req.query
if (department) {
whereClauses.push(`LOWER(w.department) = LOWER(?)`)
params.push(department)
countParams.push(department)
} }
if (whereClauses.length > 0) { if (whereClauses.length > 0) {
@@ -1479,6 +1493,28 @@ export default function () {
db.release() db.release()
} }
}) })
// GET distinct departments for filter tabs
router.get('/departments', checkPermission('view_all'), async (req, res) => {
const db = await getConnection()
try {
const [rows] = await db.execute(`
SELECT DISTINCT department
FROM workers
WHERE role = 'worker'
AND status != 'deleted'
AND department IS NOT NULL
AND department != ''
ORDER BY department ASC
`)
const departments = rows.map((r) => r.department)
res.json(departments)
} catch (error) {
console.error('Get departments error:', error)
res.status(500).json({ message: 'Database error fetching departments.', details: error.message })
} finally {
db.release()
}
})
return router return router
} }
+60 -21
View File
@@ -51,25 +51,39 @@
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <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('workerRoster') }}</h2> <h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2>
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-end justify-between">
<div class="flex-grow"> <div class="mb-6 flex items-end gap-4">
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" <div class="flex-1 min-w-0">
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByName')"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div> </div>
<div class="shrink-0 flex flex-col gap-2">
<div class="flex items-end gap-4"> <label for="department-filter" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
<div class="flex flex-col gap-2"> $t('departmentFilter') }}</label>
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ <select
$t('startDate') }}</label> id="department-filter"
<input type="date" id="export-start-date" v-model="exportFilters.startDate" v-model="selectedDepartment"
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-700 text-gray-900 dark:text-white" /> 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-700 text-gray-900 dark:text-white min-w-[180px]"
</div> >
<div class="flex flex-col gap-2"> <option value="">{{ $t('allDepartments') }}</option>
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') <option v-for="dept in departments" :key="dept" :value="dept">
}}</label> {{ dept }}
<input type="date" id="export-end-date" v-model="exportFilters.endDate" </option>
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-700 text-gray-900 dark:text-white" /> </select>
</div> </div>
<div class="shrink-0 flex flex-col gap-2">
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
$t('startDate') }}</label>
<input type="date" id="export-start-date" v-model="exportFilters.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-700 text-gray-900 dark:text-white" />
</div>
<div class="shrink-0 flex flex-col gap-2">
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
}}</label>
<input type="date" id="export-end-date" v-model="exportFilters.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-700 text-gray-900 dark:text-white" />
</div>
<div class="shrink-0">
<button @click="exportWorkHours" <button @click="exportWorkHours"
:disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading" :disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading"
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50"> class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
@@ -77,6 +91,7 @@
</button> </button>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-[700px] w-full text-left"> <table class="min-w-[700px] w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
@@ -415,6 +430,7 @@ const viewRecords = (workerId) => {
workers: workers.value, workers: workers.value,
selectedWorkerIds: selectedWorkerIds.value, selectedWorkerIds: selectedWorkerIds.value,
exportFilters: exportFilters.value, exportFilters: exportFilters.value,
selectedDepartment: selectedDepartment.value,
}; };
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState)); sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
@@ -452,6 +468,8 @@ const exportFilters = ref({ startDate: '', endDate: '' });
const exportLoading = ref(false); const exportLoading = ref(false);
const showClearDeviceConfirm = ref(false); const showClearDeviceConfirm = ref(false);
const showDeleteConfirm = ref(false); const showDeleteConfirm = ref(false);
const departments = ref([]);
const selectedDepartment = ref('');
// --- COMPUTED --- // --- COMPUTED ---
const isFormValid = computed( const isFormValid = computed(
@@ -467,6 +485,10 @@ const isAllSelected = computed(
// --- WATCHERS --- // --- WATCHERS ---
watch(searchQuery, () => fetchWorkers(1)); watch(searchQuery, () => fetchWorkers(1));
watch(selectedDepartment, () => {
currentPage.value = 1;
fetchWorkers(1);
});
watch(currentPage, (newPage) => { watch(currentPage, (newPage) => {
selectedWorkerIds.value = []; selectedWorkerIds.value = [];
jumpToPageInput.value = newPage; jumpToPageInput.value = newPage;
@@ -476,9 +498,11 @@ watch(currentPage, (newPage) => {
const fetchWorkers = async (page = currentPage.value) => { const fetchWorkers = async (page = currentPage.value) => {
loading.value = true; loading.value = true;
try { try {
const data = await apiFetch( let url = `/api/managers/workers?search=${encodeURIComponent(searchQuery.value)}&page=${page}&limit=${pageSize.value}`;
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}` if (selectedDepartment.value) {
); url += `&department=${encodeURIComponent(selectedDepartment.value)}`;
}
const data = await apiFetch(url);
workers.value = data.workers; workers.value = data.workers;
totalWorkers.value = data.totalCount; totalWorkers.value = data.totalCount;
@@ -496,6 +520,14 @@ const fetchWorkers = async (page = currentPage.value) => {
loading.value = false; loading.value = false;
} }
}; };
const fetchDepartments = async () => {
try {
const data = await apiFetch('/api/managers/departments');
departments.value = data;
} catch (_err) {
console.error('Failed to fetch departments');
}
};
const changePage = (page) => { const changePage = (page) => {
if (page > 0 && page <= totalPages.value) { if (page > 0 && page <= totalPages.value) {
@@ -693,9 +725,14 @@ const exportWorkHours = async () => {
const { startDate, endDate } = exportFilters.value; const { startDate, endDate } = exportFilters.value;
const workerIds = selectedWorkerIds.value.join(','); const workerIds = selectedWorkerIds.value.join(',');
let exportUrl = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`;
if (selectedDepartment.value) {
exportUrl += `&department=${encodeURIComponent(selectedDepartment.value)}`;
}
try { try {
const response = await fetch( const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, exportUrl,
{ {
headers: { headers: {
Authorization: `Bearer ${sessionStorage.getItem('token')}`, Authorization: `Bearer ${sessionStorage.getItem('token')}`,
@@ -721,6 +758,7 @@ const exportWorkHours = async () => {
}; };
onMounted(() => { onMounted(() => {
fetchDepartments();
const savedSearchState = sessionStorage.getItem('personnelSearchState'); const savedSearchState = sessionStorage.getItem('personnelSearchState');
if (savedSearchState) { if (savedSearchState) {
try { try {
@@ -732,6 +770,7 @@ onMounted(() => {
workers.value = searchState.workers || []; workers.value = searchState.workers || [];
selectedWorkerIds.value = searchState.selectedWorkerIds || []; selectedWorkerIds.value = searchState.selectedWorkerIds || [];
exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' }; exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' };
selectedDepartment.value = searchState.selectedDepartment || '';
sessionStorage.removeItem('personnelSearchState'); sessionStorage.removeItem('personnelSearchState');
} catch (_e) { } catch (_e) {
fetchWorkers(); fetchWorkers();
+5
View File
@@ -115,6 +115,7 @@
"chooseTag": "-- Choose a tag --", "chooseTag": "-- Choose a tag --",
"addByTag": "Add by Tag", "addByTag": "Add by Tag",
"selectedForReport": "Selected for Report ({count})", "selectedForReport": "Selected for Report ({count})",
"all": "All",
"allWorkersSelected": "All Workers ({count}) Selected", "allWorkersSelected": "All Workers ({count}) Selected",
"noWorkersSelected": "No workers selected.", "noWorkersSelected": "No workers selected.",
"reportSettings": "2. Report Settings", "reportSettings": "2. Report Settings",
@@ -139,6 +140,9 @@
"exportAll": "Export All", "exportAll": "Export All",
"export": "Export", "export": "Export",
"filterByDepartment": "Filter by Department",
"departmentFilter": "Departments",
"allDepartments": "All Departments",
"addNewUser": "Add New User", "addNewUser": "Add New User",
"fullName": "Full Name", "fullName": "Full Name",
"department": "Department", "department": "Department",
@@ -159,6 +163,7 @@
"tags": "Tags", "tags": "Tags",
"workerRoster": "Employee List", "workerRoster": "Employee List",
"searchByNameOrUsername": "Search by name/username", "searchByNameOrUsername": "Search by name/username",
"searchByName": "Search by Name",
"searchByNameOrDepartment": "Search by name/department", "searchByNameOrDepartment": "Search by name/department",
"filterByTag": "Filter by tag", "filterByTag": "Filter by tag",
"clearFilter": "Clear filter", "clearFilter": "Clear filter",
+5
View File
@@ -116,6 +116,7 @@
"chooseTag": "-- Pilih tag --", "chooseTag": "-- Pilih tag --",
"addByTag": "Tambah melalui Tag", "addByTag": "Tambah melalui Tag",
"selectedForReport": "Dipilih untuk Laporan ({count})", "selectedForReport": "Dipilih untuk Laporan ({count})",
"all": "Semua",
"allWorkersSelected": "Semua Pekerja ({count}) Dipilih", "allWorkersSelected": "Semua Pekerja ({count}) Dipilih",
"noWorkersSelected": "Tiada pekerja dipilih.", "noWorkersSelected": "Tiada pekerja dipilih.",
"reportSettings": "2. Tetapan Laporan", "reportSettings": "2. Tetapan Laporan",
@@ -139,6 +140,9 @@
"reportGenerationError": "Ralat semasa menjana laporan.", "reportGenerationError": "Ralat semasa menjana laporan.",
"exportAll": "Eksport Semua", "exportAll": "Eksport Semua",
"export": "Eksport", "export": "Eksport",
"filterByDepartment": "Tapis mengikut Jabatan",
"departmentFilter": "Jabatan:",
"allDepartments": "Semua Jabatan",
"addNewUser": "Tambah Pengguna Baru", "addNewUser": "Tambah Pengguna Baru",
"fullName": "Nama Penuh", "fullName": "Nama Penuh",
"department": "Jabatan", "department": "Jabatan",
@@ -158,6 +162,7 @@
"createTag": "Cipta Tag", "createTag": "Cipta Tag",
"tags": "Tag", "tags": "Tag",
"workerRoster": "Deftar Pekerja", "workerRoster": "Deftar Pekerja",
"searchByName": "Cari mengikut Nama",
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna", "searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
"searchByNameOrDepartment": " Cari nama atau jabatan", "searchByNameOrDepartment": " Cari nama atau jabatan",
"filterByTag": "Tapis mengikut tag", "filterByTag": "Tapis mengikut tag",
+4 -2
View File
@@ -97,7 +97,8 @@
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --", "chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
"addByTag": "டேக் மூலம் சேர்க்கவும்", "addByTag": "டேக் மூலம் சேர்க்கவும்",
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})", "selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்", "all": "அனைத்தும்",
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.", "noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
"reportSettings": "2. அறிக்கை அமைப்புகள்", "reportSettings": "2. அறிக்கை அமைப்புகள்",
"setting": "அமைப்பு", "setting": "அமைப்பு",
@@ -133,7 +134,8 @@
"createTag": "டேக் உருவாக்கவும்", "createTag": "டேக் உருவாக்கவும்",
"tags": "டேக்குகள்", "tags": "டேக்குகள்",
"workerRoster": "பணியாளர் பட்டியல்", "workerRoster": "பணியாளர் பட்டியல்",
"searchByNameOrUsername": "பெயர் அல்லது பயனர் பெயர் மூலம் தேடவும்", "searchByName": "பெயரால் தேட",
"searchByNameOrUsername": "பெயர் அல்லது பயனர்பெயரால் தேடு",
"filterByTag": "டேக் மூலம் வடிகட்டவும்", "filterByTag": "டேக் மூலம் வடிகட்டவும்",
"clearFilter": "வடிகட்டியைத் துடைக்கவும்", "clearFilter": "வடிகட்டியைத் துடைக்கவும்",
"dateJoined": "சேர்ந்த தேதி", "dateJoined": "சேர்ந்த தேதி",