Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93f316d8ab | |||
| 5276a7544c | |||
| 80d94f248e | |||
| 356d96a61f | |||
| 411b47e897 | |||
| 8e5fa5652e | |||
| 7473cfccbc | |||
| 0d23a187f0 | |||
| b61e456e2d |
@@ -25,8 +25,6 @@ async function startServer() {
|
|||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
timezone: 'Z',
|
|
||||||
dateStrings: true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+77
-103
@@ -2,12 +2,7 @@ import express from 'express';
|
|||||||
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf';
|
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { db } from './server.js';
|
// Removed unused import
|
||||||
import { withTzSession } from './middleware/withTzSession.js';
|
|
||||||
|
|
||||||
|
|
||||||
// Map IANA (no DST for KL/Jakarta)
|
|
||||||
const sessionOffset = (iana) => (iana === 'Asia/Jakarta' ? '+07:00' : '+08:00');
|
|
||||||
|
|
||||||
async function validateDeviceForUser(userId, deviceUuid, db) {
|
async function validateDeviceForUser(userId, deviceUuid, db) {
|
||||||
const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]);
|
const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]);
|
||||||
@@ -20,10 +15,9 @@ async function validateDeviceForUser(userId, deviceUuid, db) {
|
|||||||
return { valid: device_uuid === deviceUuid, message: 'Device validation failed' };
|
return { valid: device_uuid === deviceUuid, message: 'Device validation failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isClockingEnabled(conn) {
|
async function isClockingEnabled(db) {
|
||||||
const [rows] = await conn.execute(
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD format
|
||||||
'SELECT 1 FROM enabled_dates WHERE enabled_date = CURDATE() LIMIT 1'
|
const [rows] = await db.execute('SELECT 1 FROM enabled_dates WHERE enabled_date = ? LIMIT 1', [today]);
|
||||||
);
|
|
||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,115 +94,95 @@ export default function(db) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
router.use(authenticateJWT);
|
router.use(authenticateJWT);
|
||||||
|
// Definitive version with distance calculation and specific error messages
|
||||||
|
|
||||||
|
// Definitive version with distance calculation and specific error messages
|
||||||
router.post('/clock', async (req, res) => {
|
router.post('/clock', async (req, res) => {
|
||||||
// NEW: borrow a connection so we can set session time_zone
|
try {
|
||||||
const conn = await db.getConnection();
|
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
|
||||||
try {
|
const currentTimestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||||
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
|
|
||||||
|
|
||||||
// NEW: set session time_zone from header (defaults to KL)
|
// 1. Kill Switch Enforcement
|
||||||
const iana = req.headers['x-user-timezone'] || 'Asia/Kuala_Lumpur';
|
const clockingAllowed = await isClockingEnabled(db);
|
||||||
await conn.query('SET time_zone = ?', [sessionOffset(iana)]);
|
if (!clockingAllowed) {
|
||||||
|
const note = 'Clock-in/out function is not enabled for today.';
|
||||||
// 1) Kill Switch — now evaluated in the session's local day
|
await db.execute(
|
||||||
const clockingAllowed = await isClockingEnabled(conn); // CHANGED: pass conn
|
'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)',
|
||||||
if (!clockingAllowed) {
|
[userId, qrCodeValue, latitude, longitude, note, currentTimestamp]
|
||||||
const note = 'Clock-in/out function is not enabled for today.';
|
|
||||||
await conn.execute( // CHANGED: use conn
|
|
||||||
`INSERT INTO clock_records
|
|
||||||
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
|
|
||||||
VALUES (?, "failed", ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
|
|
||||||
[userId, qrCodeValue, latitude, longitude, note]
|
|
||||||
);
|
|
||||||
return res.status(403).json({ message: 'error.clockingDisabled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Geofence Validation (unchanged logic, just switch db -> conn)
|
|
||||||
if (latitude != null && longitude != null) {
|
|
||||||
const [activeFences] = await conn.execute('SELECT coordinates FROM geofences WHERE is_active = 1'); // CHANGED
|
|
||||||
|
|
||||||
if (activeFences.length === 0) {
|
|
||||||
const note = 'Cannot clock in: No active work area is defined.';
|
|
||||||
await conn.execute( // CHANGED
|
|
||||||
`INSERT INTO clock_records
|
|
||||||
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
|
|
||||||
VALUES (?, "failed", ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
|
|
||||||
[userId, qrCodeValue, latitude, longitude, note]
|
|
||||||
);
|
);
|
||||||
return res.status(403).json({ message: 'error.noActiveGeofence' });
|
return res.status(403).json({ message: 'error.clockingDisabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userLocation = point([longitude, latitude]);
|
// 2. Geofence Validation with Distance Calculation
|
||||||
const parsedPolygons = [];
|
if (latitude != null && longitude != null) {
|
||||||
let isInside = false;
|
const [activeFences] = await db.execute('SELECT coordinates FROM geofences WHERE is_active = 1');
|
||||||
|
|
||||||
for (const fence of activeFences) {
|
if (activeFences.length === 0) {
|
||||||
try {
|
const note = 'Cannot clock in: No active work area is defined.';
|
||||||
if (!fence.coordinates) continue;
|
await db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]);
|
||||||
const coordinates = JSON.parse(fence.coordinates);
|
return res.status(403).json({ message: 'error.noActiveGeofence' });
|
||||||
const fencePolygon = polygon([coordinates]);
|
}
|
||||||
parsedPolygons.push(fencePolygon);
|
|
||||||
if (booleanPointInPolygon(userLocation, fencePolygon)) {
|
const userLocation = point([longitude, latitude]);
|
||||||
isInside = true;
|
const parsedPolygons = [];
|
||||||
break;
|
let isInside = false;
|
||||||
|
|
||||||
|
for (const fence of activeFences) {
|
||||||
|
try {
|
||||||
|
if (!fence.coordinates) continue;
|
||||||
|
const coordinates = JSON.parse(fence.coordinates);
|
||||||
|
const fencePolygon = polygon([coordinates]);
|
||||||
|
parsedPolygons.push(fencePolygon); // Save for distance calculation
|
||||||
|
if (booleanPointInPolygon(userLocation, fencePolygon)) {
|
||||||
|
isInside = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
}
|
||||||
console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e });
|
|
||||||
|
if (!isInside) {
|
||||||
|
let minDistance = Infinity;
|
||||||
|
for (const p of parsedPolygons) {
|
||||||
|
const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' });
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const distanceString = minDistance.toFixed(2);
|
||||||
|
const note = `Outside geofence by ${distanceString}m`;
|
||||||
|
await db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]);
|
||||||
|
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isInside) {
|
// 3. QR Code and Status Validation
|
||||||
let minDistance = Infinity;
|
if (qrCodeValue !== 'FORCE_CLOCK_OUT') {
|
||||||
for (const p of parsedPolygons) {
|
const [qrRows] = await db.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]);
|
||||||
const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' });
|
if (qrRows.length === 0 || !qrRows[0].is_active) {
|
||||||
if (distance < minDistance) minDistance = distance;
|
return res.status(400).json({ message: 'error.invalidQrCode' });
|
||||||
}
|
}
|
||||||
const distanceString = minDistance.toFixed(2);
|
|
||||||
const note = `Outside geofence by ${distanceString}m`;
|
|
||||||
await conn.execute( // CHANGED
|
|
||||||
`INSERT INTO clock_records
|
|
||||||
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
|
|
||||||
VALUES (?, "failed", ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
|
|
||||||
[userId, qrCodeValue, latitude, longitude, note]
|
|
||||||
);
|
|
||||||
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 3) QR Code and Status Validation (switch db -> conn; logic unchanged)
|
const [lastEvent] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]);
|
||||||
if (qrCodeValue !== 'FORCE_CLOCK_OUT') {
|
if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) {
|
||||||
const [qrRows] = await conn.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]); // CHANGED
|
const errorKey = eventType === 'clock_in' ? 'error.alreadyClockedIn' : 'error.alreadyClockedOut';
|
||||||
if (qrRows.length === 0 || !qrRows[0].is_active) {
|
return res.status(400).json({ message: errorKey });
|
||||||
return res.status(400).json({ message: 'error.invalidQrCode' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Record Successful Event
|
||||||
|
await db.execute(
|
||||||
|
'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[userId, eventType, qrCodeValue, latitude, longitude, currentTimestamp]
|
||||||
|
);
|
||||||
|
res.status(201).json({ message: 'Clock event recorded.' });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
|
||||||
|
res.status(500).json({ message: 'error.criticalServer' });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
const [lastEvent] = await conn.execute( // CHANGED
|
|
||||||
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) {
|
|
||||||
const errorKey = eventType === 'clock_in' ? 'error.alreadyClockedIn' : 'error.alreadyClockedOut';
|
|
||||||
return res.status(400).json({ message: errorKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Record Successful Event — store UTC via SQL conversion (no JS date math)
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO clock_records
|
|
||||||
(worker_id, event_type, qr_code_id, latitude, longitude, timestamp)
|
|
||||||
VALUES (?, ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
|
|
||||||
[userId, eventType, qrCodeValue, latitude, longitude]
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(201).json({ message: 'Clock event recorded.' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
|
|
||||||
res.status(500).json({ message: 'error.criticalServer' });
|
|
||||||
} finally {
|
|
||||||
if (conn) conn.release();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/workers/:id', async (req, res) => {
|
router.get('/workers/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|||||||
+3
-19
@@ -1,21 +1,11 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
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 = {}) {
|
export async function apiFetch(endpoint, options = {}) {
|
||||||
const token = sessionStorage.getItem('token');
|
const token = sessionStorage.getItem('token');
|
||||||
|
|
||||||
const defaultHeaders = {
|
const defaultHeaders = {
|
||||||
'ngrok-skip-browser-warning': 'true',
|
'ngrok-skip-browser-warning': 'true',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
// Timezone header used by the backend to set session time_zone
|
|
||||||
'X-User-Timezone': getUserTimezone(),
|
|
||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,18 +42,12 @@ export async function apiFetch(endpoint, options = {}) {
|
|||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
// Use the 'details' from our backend error structure, or the message, or a default
|
// Use the 'details' from our backend error structure, or the message, or a default
|
||||||
throw new Error(
|
throw new Error(errorData.details || errorData.message || `API call failed with status: ${response.status}`);
|
||||||
errorData.details ||
|
|
||||||
errorData.message ||
|
|
||||||
`API call failed with status: ${response.status}`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// If the server sends back HTML or plain text, use that as the error message.
|
// If the server sends back HTML or plain text, use that as the error message.
|
||||||
// This prevents the "Unexpected token '<'" error.
|
// This prevents the "Unexpected token '<'" error.
|
||||||
const textError = await response.text();
|
const textError = await response.text();
|
||||||
throw new Error(
|
throw new Error(textError || `Server returned an unhandled error with status: ${response.status}`);
|
||||||
textError || `Server returned an unhandled error with status: ${response.status}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +59,7 @@ export async function apiFetch(endpoint, options = {}) {
|
|||||||
// Handle file downloads like CSV
|
// Handle file downloads like CSV
|
||||||
const disposition = response.headers.get('content-disposition');
|
const disposition = response.headers.get('content-disposition');
|
||||||
if (disposition && disposition.includes('attachment')) {
|
if (disposition && disposition.includes('attachment')) {
|
||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
|
|||||||
@@ -6,17 +6,11 @@
|
|||||||
{{ monthYear }}
|
{{ monthYear }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button @click="prevMonth"
|
<button @click="prevMonth" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
|
||||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button @click="nextMonth"
|
<button @click="nextMonth" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,8 +20,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-7 gap-1">
|
<div class="grid grid-cols-7 gap-1">
|
||||||
<div v-for="day in calendarGrid" :key="day.id" @click="day.isCurrentMonth && onDayClick(day)"
|
<div v-for="day in calendarGrid" :key="day.id"
|
||||||
:class="getDayClasses(day)">
|
@click="day.isCurrentMonth && onDayClick(day)"
|
||||||
|
:class="getDayClasses(day)">
|
||||||
{{ day.date }}
|
{{ day.date }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,12 +52,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex flex-col sm:flex-row gap-3">
|
<div class="mt-6 flex flex-col sm:flex-row gap-3">
|
||||||
<button @click="applyChanges" :disabled="!hasPendingChanges"
|
<button @click="applyChanges" :disabled="!hasPendingChanges" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
{{ $t('applyChanges') }}
|
{{ $t('applyChanges') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="discardChanges" :disabled="!hasPendingChanges"
|
<button @click="discardChanges" :disabled="!hasPendingChanges" class="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
class="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
{{ $t('discardChanges') }}
|
{{ $t('discardChanges') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,30 +73,18 @@ const { t: $t } = useI18n();
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const viewDate = ref(new Date());
|
const viewDate = ref(new Date());
|
||||||
// Server-driven "today" string (YYYY-MM-DD) for the yellow ring
|
// Server-driven KL date for the yellow ring (updates every 60s)
|
||||||
const todayStr = ref(null);
|
const todayStr = ref(null);
|
||||||
|
|
||||||
// --- timezone handling
|
const TZ = 'Asia/Kuala_Lumpur';
|
||||||
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
|
// Helper: format YYYY-MM-DD in a given TZ
|
||||||
const ymdInTZ = (tz, d = new Date()) =>
|
const ymdInTZ = (tz, d = new Date()) =>
|
||||||
new Intl.DateTimeFormat('en-CA', {
|
new Intl.DateTimeFormat('en-CA', {
|
||||||
timeZone: tz,
|
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit'
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
}).format(d);
|
}).format(d);
|
||||||
|
|
||||||
// Pull today from server; try /api/time then /time; fallback to client TZ
|
// Pull today from server; try /api/time then /time; fallback to client KL
|
||||||
async function getServerDate() {
|
async function getServerDate() {
|
||||||
const parse = (data) => {
|
const parse = (data) => {
|
||||||
if (typeof data?.ymdKL === 'string') return data.ymdKL;
|
if (typeof data?.ymdKL === 'string') return data.ymdKL;
|
||||||
@@ -112,49 +93,36 @@ async function getServerDate() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const path of ['/api/time', '/time']) {
|
for (const path of ['/api/time', '/time']) {
|
||||||
try {
|
try {
|
||||||
const d = await apiFetch(`${path}?_t=${Date.now()}`);
|
const d = await apiFetch(`${path}?_t=${Date.now()}`);
|
||||||
const y = parse(d);
|
const y = parse(d);
|
||||||
if (y) return y;
|
if (y) return y;
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
continue;
|
continue; // try next endpoint
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.warn('Server time unavailable; using client time.');
|
console.warn('Server time unavailable; using client KL time.');
|
||||||
return ymdInTZ(TZ, new Date());
|
return ymdInTZ(TZ, new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
let _intervalId;
|
let _intervalId;
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const update = async () => {
|
const update = async () => { todayStr.value = await getServerDate(); };
|
||||||
todayStr.value = await getServerDate();
|
|
||||||
};
|
|
||||||
await update();
|
await update();
|
||||||
_intervalId = setInterval(update, 60_000);
|
_intervalId = setInterval(update, 60_000);
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => { if (_intervalId) clearInterval(_intervalId); });
|
||||||
if (_intervalId) clearInterval(_intervalId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalEnabledDates = ref(new Set());
|
const originalEnabledDates = ref(new Set());
|
||||||
const datesToEnable = ref(new Set());
|
const datesToEnable = ref(new Set());
|
||||||
const datesToDisable = ref(new Set());
|
const datesToDisable = ref(new Set());
|
||||||
|
|
||||||
const hasPendingChanges = computed(
|
const hasPendingChanges = computed(() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0);
|
||||||
() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0
|
|
||||||
);
|
|
||||||
const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort());
|
const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort());
|
||||||
const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort());
|
const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort());
|
||||||
|
|
||||||
const monthYear = computed(() =>
|
const monthYear = computed(() => viewDate.value.toLocaleString('default', { month: 'long', year: 'numeric' }));
|
||||||
viewDate.value.toLocaleString('default', {
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: TZ,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
const calendarGrid = computed(() => {
|
const calendarGrid = computed(() => {
|
||||||
@@ -180,17 +148,7 @@ const getDayClasses = (day) => {
|
|||||||
if (!day.isCurrentMonth) return 'h-20';
|
if (!day.isCurrentMonth) return 'h-20';
|
||||||
|
|
||||||
const dateStr = day.id;
|
const dateStr = day.id;
|
||||||
const classes = [
|
const classes = ['h-20', 'flex', 'items-center', 'justify-center', 'text-lg', 'rounded-lg', 'cursor-pointer', 'transition-colors', 'relative'];
|
||||||
'h-20',
|
|
||||||
'flex',
|
|
||||||
'items-center',
|
|
||||||
'justify-center',
|
|
||||||
'text-lg',
|
|
||||||
'rounded-lg',
|
|
||||||
'cursor-pointer',
|
|
||||||
'transition-colors',
|
|
||||||
'relative',
|
|
||||||
];
|
|
||||||
|
|
||||||
let isEnabled = originalEnabledDates.value.has(dateStr);
|
let isEnabled = originalEnabledDates.value.has(dateStr);
|
||||||
if (datesToEnable.value.has(dateStr)) isEnabled = true;
|
if (datesToEnable.value.has(dateStr)) isEnabled = true;
|
||||||
@@ -203,18 +161,7 @@ const getDayClasses = (day) => {
|
|||||||
classes.push('bg-blue-500', 'text-white', 'font-bold');
|
classes.push('bg-blue-500', 'text-white', 'font-bold');
|
||||||
} else if (isPendingDisable) {
|
} else if (isPendingDisable) {
|
||||||
classes.push('bg-red-200', 'dark:bg-red-800', 'text-red-700', 'dark:text-red-200');
|
classes.push('bg-red-200', 'dark:bg-red-800', 'text-red-700', 'dark:text-red-200');
|
||||||
classes.push(
|
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]');
|
||||||
'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) {
|
} else if (isEnabled) {
|
||||||
classes.push('bg-green-100', 'dark:bg-green-800', 'text-green-800', 'dark:text-green-200');
|
classes.push('bg-green-100', 'dark:bg-green-800', 'text-green-800', 'dark:text-green-200');
|
||||||
} else {
|
} else {
|
||||||
@@ -222,8 +169,8 @@ const getDayClasses = (day) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (todayStr.value && dateStr === todayStr.value) {
|
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;
|
return classes;
|
||||||
};
|
};
|
||||||
@@ -244,7 +191,7 @@ function onDayClick(day) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyChanges() {
|
async function applyChanges() {
|
||||||
const confirmed = await toast.showConfirm($t('confirmApplyChanges'));
|
const confirmed = await toast.showConfirm($t('confirmApplyChanges'))
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -269,19 +216,12 @@ function discardChanges() {
|
|||||||
datesToDisable.value.clear();
|
datesToDisable.value.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevMonth = () =>
|
const prevMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() - 1));
|
||||||
(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 nextMonth = () =>
|
|
||||||
(viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() + 1)));
|
|
||||||
|
|
||||||
const formatDate = (dateStr) =>
|
const formatDate = (dateStr) => new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, {
|
||||||
new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, {
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
weekday: 'long',
|
});
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
timeZone: TZ,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchEnabledDates() {
|
async function fetchEnabledDates() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,44 +4,28 @@
|
|||||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2>
|
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName')
|
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName') }}</label>
|
||||||
}}</label>
|
<input type="text" id="fullName" v-model="newWorker.fullName" 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" :placeholder="$t('egJohnSmith')" />
|
||||||
<input type="text" id="fullName" v-model="newWorker.fullName"
|
|
||||||
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"
|
|
||||||
:placeholder="$t('egJohnSmith')" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('username')
|
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('username') }}</label>
|
||||||
}}</label>
|
<input type="text" id="username" v-model="newWorker.username" 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" :placeholder="$t('egJsmith')" />
|
||||||
<input type="text" id="username" v-model="newWorker.username"
|
|
||||||
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"
|
|
||||||
:placeholder="$t('egJsmith')" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('password')
|
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('password') }}</label>
|
||||||
}}</label>
|
<input type="password" id="password" v-model="newWorker.password" 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" :placeholder="$t('eg123456')" />
|
||||||
<input type="password" id="password" v-model="newWorker.password"
|
|
||||||
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"
|
|
||||||
:placeholder="$t('eg123456')" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="department" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('department')
|
<label for="department" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('department') }}</label>
|
||||||
}}</label>
|
<input type="text" id="department" v-model="newWorker.department" 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" :placeholder="$t('egSales')" />
|
||||||
<input type="text" id="department" v-model="newWorker.department"
|
|
||||||
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"
|
|
||||||
:placeholder="$t('egSales')" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position')
|
<label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position') }}</label>
|
||||||
}}</label>
|
<input type="text" id="position" v-model="newWorker.position" 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" :placeholder="$t('egManager')" />
|
||||||
<input type="text" id="position" v-model="newWorker.position"
|
|
||||||
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"
|
|
||||||
:placeholder="$t('egManager')" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 invisible">{{ $t('addUser') }}</label>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 invisible">{{ $t('addUser') }}</label>
|
||||||
<button @click="addWorker" :disabled="!isFormValid || loading"
|
<button @click="addWorker" :disabled="!isFormValid || loading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
{{ loading ? $t('adding') : $t('addUser') }}
|
{{ loading ? $t('adding') : $t('addUser') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,28 +37,21 @@
|
|||||||
<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="mb-6 flex flex-col sm:flex-row gap-4 sm:items-end justify-between">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')"
|
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" 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="flex items-end gap-4">
|
<div class="flex items-end gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate') }}</label>
|
||||||
$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" />
|
||||||
<input type="date" id="export-start-date" v-model="exportFilters.startDate"
|
</div>
|
||||||
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 class="flex flex-col gap-2">
|
||||||
</div>
|
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label>
|
||||||
<div class="flex flex-col gap-2">
|
<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" />
|
||||||
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
|
</div>
|
||||||
}}</label>
|
<button @click="exportWorkHours" :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">
|
||||||
<input type="date" id="export-end-date" v-model="exportFilters.endDate"
|
{{ exportLoading ? $t('exporting') : $t('exportAll') }}
|
||||||
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" />
|
</button>
|
||||||
</div>
|
|
||||||
<button @click="exportWorkHours"
|
|
||||||
: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">
|
|
||||||
{{ exportLoading ? $t('exporting') : $t('exportAll') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -82,40 +59,20 @@
|
|||||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr class="border-b border-gray-200 dark:border-gray-600">
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
<th class="w-12 px-2 py-3 text-center">
|
<th class="w-12 px-2 py-3 text-center">
|
||||||
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected"
|
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected" class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||||
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
{{ $t('fullName') }}
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
{{ $t('username') }}
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
{{ $t('department') }}
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
{{ $t('position') }}
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
{{ $t('status') }}
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
{{ $t('dateJoined') }}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">
|
|
||||||
{{ $t('actions') }}
|
|
||||||
</th>
|
</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('fullName') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('username') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('department') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('position') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('status') }}</th> <th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('dateJoined') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">{{ $t('actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<tr v-for="worker in workers" :key="worker.id"
|
<tr v-for="worker in workers" :key="worker.id" :class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }" class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||||
:class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }"
|
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
|
||||||
<td class="px-2 py-3 text-center">
|
<td class="px-2 py-3 text-center">
|
||||||
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)"
|
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)" class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||||
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td>
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td>
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
|
||||||
@@ -130,61 +87,41 @@
|
|||||||
{{ worker.status }}
|
{{ worker.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ new Date(worker.created_at).toLocaleDateString() }}</td>
|
||||||
{{ formatLocalDate(worker.created_at) }}
|
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
|
||||||
</td>
|
<button @click="viewRecords(worker.id)" class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('viewRecords') }}</button>
|
||||||
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
|
<button @click="openSettingsModal(worker)" class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 flex items-center gap-1">
|
||||||
<button @click="viewRecords(worker.id)"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
{{ $t('viewRecords') }}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</button>
|
|
||||||
<button @click="openSettingsModal(worker)"
|
|
||||||
class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 flex items-center gap-1">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
{{ $t('settings') }}
|
{{ $t('settings') }}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="workers.length === 0">
|
<tr v-if="workers.length === 0">
|
||||||
<td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400"> {{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
|
||||||
{{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="totalPages > 1"
|
<div v-if="totalPages > 1" class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('previous') }}</button>
|
||||||
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1"
|
|
||||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
|
|
||||||
{{ $t('previous') }}
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input type="number" v-model.number="jumpToPageInput" @keyup.enter="jumpToPage"
|
<input type="number" v-model.number="jumpToPageInput" @keyup.enter="jumpToPage" class="w-20 text-center border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
class="w-20 text-center border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
|
||||||
<span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span>
|
<span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages"
|
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('next') }}</button>
|
||||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
|
|
||||||
{{ $t('next') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="isSettingsModalVisible"
|
<div v-if="isSettingsModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||||
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('employeeSettings') }}</h3>
|
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('employeeSettings') }}</h3>
|
||||||
<button @click="closeSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
<button @click="closeSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -196,47 +133,31 @@
|
|||||||
|
|
||||||
<div class="mb-4 space-y-4">
|
<div class="mb-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('department') }}</label>
|
||||||
{{ $t('department') }}
|
<input type="text" v-model="editingWorker.department" class="w-full 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" />
|
||||||
</label>
|
|
||||||
<input type="text" v-model="editingWorker.department"
|
|
||||||
class="w-full 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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('position') }}</label>
|
||||||
{{ $t('position') }}
|
<input type="text" v-model="editingWorker.position" class="w-full 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" />
|
||||||
</label>
|
|
||||||
<input type="text" v-model="editingWorker.position"
|
|
||||||
class="w-full 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('changePassword') }}</label>
|
||||||
{{ $t('changePassword') }}
|
|
||||||
</label>
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<input type="password" v-model="newPassword" :placeholder="$t('newPassword')"
|
<input type="password" v-model="newPassword" :placeholder="$t('newPassword')" class="w-full 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" />
|
||||||
class="w-full 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" />
|
<input type="password" v-model="confirmNewPassword" :placeholder="$t('confirmNewPassword')" class="w-full 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" />
|
||||||
<input type="password" v-model="confirmNewPassword" :placeholder="$t('confirmNewPassword')"
|
|
||||||
class="w-full 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">
|
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('workerStatus') }}</h4>
|
||||||
{{ $t('workerStatus') }}
|
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('activeAccount') }}</p>
|
||||||
</h4>
|
|
||||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{{ $t('activeAccount') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<input type="checkbox" v-model="editingWorker.isActive" class="sr-only peer" />
|
<input type="checkbox" v-model="editingWorker.isActive" class="sr-only peer">
|
||||||
<div
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600">
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,26 +167,19 @@
|
|||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('clearDevice') }}</h5>
|
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('clearDevice') }}</h5>
|
||||||
<p class="text-xs text-red-600 dark:text-red-400/80">
|
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('clearDeviceDescription') }}</p>
|
||||||
{{ $t('clearDeviceDescription') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button @click="showClearDeviceConfirm = true"
|
<button @click="showClearDeviceConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
|
||||||
class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
|
|
||||||
{{ $t('clearDevice') }}
|
{{ $t('clearDevice') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showClearDeviceConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
|
<div v-if="showClearDeviceConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmClearDevice') }}</p>
|
||||||
{{ $t('confirmClearDevice') }}
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button @click="showClearDeviceConfirm = false"
|
<button @click="showClearDeviceConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
{{ $t('cancel') }}
|
{{ $t('cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="clearDevice(editingWorker.id)"
|
<button @click="clearDevice(editingWorker.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
|
||||||
class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
|
|
||||||
{{ $t('confirm') }}
|
{{ $t('confirm') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,26 +190,19 @@
|
|||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('delete') }}</h5>
|
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('delete') }}</h5>
|
||||||
<p class="text-xs text-red-600 dark:text-red-400/80">
|
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('deleteDescription') }}</p>
|
||||||
{{ $t('deleteDescription') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button @click="showDeleteConfirm = true"
|
<button @click="showDeleteConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
|
||||||
class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
|
|
||||||
{{ $t('delete') }}
|
{{ $t('delete') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showDeleteConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
|
<div v-if="showDeleteConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmDelete') }}</p>
|
||||||
{{ $t('confirmDelete') }}
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button @click="showDeleteConfirm = false"
|
<button @click="showDeleteConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
{{ $t('cancel') }}
|
{{ $t('cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="deleteWorker(editingWorker.id)"
|
<button @click="deleteWorker(editingWorker.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
|
||||||
class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
|
|
||||||
{{ $t('confirm') }}
|
{{ $t('confirm') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,33 +211,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="passwordErrorMessage || passwordSuccessMessage" class="text-center">
|
<div v-if="passwordErrorMessage || passwordSuccessMessage" class="text-center">
|
||||||
<p v-if="passwordErrorMessage" class="text-red-500 text-sm">
|
<p v-if="passwordErrorMessage" class="text-red-500 text-sm">{{ passwordErrorMessage }}</p>
|
||||||
{{ passwordErrorMessage }}
|
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm">{{ passwordSuccessMessage }}</p>
|
||||||
</p>
|
|
||||||
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm">
|
|
||||||
{{ passwordSuccessMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="saveWorkerSettings" :disabled="passwordLoading"
|
<button @click="saveWorkerSettings" :disabled="passwordLoading" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:opacity-50">
|
||||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:opacity-50">
|
|
||||||
{{ passwordLoading ? $t('saving') : $t('saveChanges') }}
|
{{ passwordLoading ? $t('saving') : $t('saveChanges') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isConfirmModalVisible"
|
<div v-if="isConfirmModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||||
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-sm">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-sm">
|
||||||
<h3 class="text-xl font-bold mb-4 text-gray-800 dark:text-white">{{ confirmMessage }}</h3>
|
<h3 class="text-xl font-bold mb-4 text-gray-800 dark:text-white">{{ confirmMessage }}</h3>
|
||||||
<div class="flex justify-end gap-3 mt-6">
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
<button @click="closeConfirmModal"
|
<button @click="closeConfirmModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-medium px-4 py-2 rounded-md transition-colors">
|
||||||
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-medium px-4 py-2 rounded-md transition-colors">
|
|
||||||
{{ $t('cancel') }}
|
{{ $t('cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="executeConfirmedAction"
|
<button @click="executeConfirmedAction" class="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-md transition-colors">
|
||||||
class="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-md transition-colors">
|
|
||||||
{{ $t('confirm') }}
|
{{ $t('confirm') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,46 +248,11 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { workerCache } from '@/utils/workerCache.js';
|
import { workerCache } from '@/utils/workerCache.js';
|
||||||
|
|
||||||
const { t: $t } = useI18n();
|
const { t: $t } = useI18n();
|
||||||
|
|
||||||
const router = useRouter();
|
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) => {
|
const viewRecords = (workerId) => {
|
||||||
|
// Save current search state before navigating away
|
||||||
const searchState = {
|
const searchState = {
|
||||||
searchQuery: searchQuery.value,
|
searchQuery: searchQuery.value,
|
||||||
currentPage: currentPage.value,
|
currentPage: currentPage.value,
|
||||||
@@ -396,7 +260,7 @@ const viewRecords = (workerId) => {
|
|||||||
totalWorkers: totalWorkers.value,
|
totalWorkers: totalWorkers.value,
|
||||||
workers: workers.value,
|
workers: workers.value,
|
||||||
selectedWorkerIds: selectedWorkerIds.value,
|
selectedWorkerIds: selectedWorkerIds.value,
|
||||||
exportFilters: exportFilters.value,
|
exportFilters: exportFilters.value
|
||||||
};
|
};
|
||||||
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
|
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
|
||||||
|
|
||||||
@@ -407,13 +271,7 @@ const viewRecords = (workerId) => {
|
|||||||
const workers = ref([]);
|
const workers = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const newWorker = ref({
|
const newWorker = ref({ fullName: '', username: '', password: '', department: '', position: '' });
|
||||||
fullName: '',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
department: '',
|
|
||||||
position: '',
|
|
||||||
});
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(20);
|
||||||
@@ -432,20 +290,15 @@ const confirmMessage = ref('');
|
|||||||
const isConfirmModalVisible = ref(false);
|
const isConfirmModalVisible = ref(false);
|
||||||
const exportFilters = ref({ startDate: '', endDate: '' });
|
const exportFilters = ref({ startDate: '', endDate: '' });
|
||||||
const exportLoading = ref(false);
|
const exportLoading = ref(false);
|
||||||
const showClearDeviceConfirm = ref(false);
|
// Removed workerStatusLoading as it's no longer needed with integrated save
|
||||||
const showDeleteConfirm = ref(false);
|
|
||||||
|
|
||||||
// --- COMPUTED ---
|
// --- COMPUTED ---
|
||||||
const isFormValid = computed(
|
const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password);
|
||||||
() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password
|
|
||||||
);
|
|
||||||
const totalPages = computed(() => {
|
const totalPages = computed(() => {
|
||||||
const pages = Math.ceil(totalWorkers.value / pageSize.value);
|
const pages = Math.ceil(totalWorkers.value / pageSize.value);
|
||||||
return pages < 1 ? 1 : pages;
|
return pages < 1 ? 1 : pages; // Ensure at least 1 page
|
||||||
});
|
});
|
||||||
const isAllSelected = computed(
|
const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length);
|
||||||
() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- WATCHERS ---
|
// --- WATCHERS ---
|
||||||
watch(searchQuery, () => fetchWorkers(1));
|
watch(searchQuery, () => fetchWorkers(1));
|
||||||
@@ -458,17 +311,18 @@ 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(
|
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`);
|
||||||
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`
|
|
||||||
);
|
|
||||||
workers.value = data.workers;
|
workers.value = data.workers;
|
||||||
totalWorkers.value = data.totalCount;
|
totalWorkers.value = data.totalCount;
|
||||||
|
|
||||||
|
// Cache worker data
|
||||||
if (data.workers && Array.isArray(data.workers)) {
|
if (data.workers && Array.isArray(data.workers)) {
|
||||||
data.workers.forEach((worker) => {
|
data.workers.forEach(worker => {
|
||||||
workerCache.storeWorkerData(worker.id, worker);
|
workerCache.storeWorkerData(worker.id, worker);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentPage is already set to the requested page before fetch
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
errorMessage.value = 'Failed to fetch workers.';
|
errorMessage.value = 'Failed to fetch workers.';
|
||||||
workers.value = [];
|
workers.value = [];
|
||||||
@@ -501,18 +355,12 @@ const addWorker = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
try {
|
try {
|
||||||
await apiFetch('/api/managers/workers', {
|
await apiFetch('/api/managers/workers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ ...newWorker.value, role: 'worker' }),
|
body: JSON.stringify({ ...newWorker.value, role: 'worker' }),
|
||||||
});
|
});
|
||||||
await fetchWorkers(1);
|
await fetchWorkers(1);
|
||||||
newWorker.value = {
|
newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' };
|
||||||
fullName: '',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
department: '',
|
|
||||||
position: '',
|
|
||||||
};
|
|
||||||
toast.showToast($t('workerAdded'), 'success');
|
toast.showToast($t('workerAdded'), 'success');
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast.showToast(_err.message || $t('addUserError'), 'error');
|
toast.showToast(_err.message || $t('addUserError'), 'error');
|
||||||
@@ -528,9 +376,7 @@ const deleteWorker = async (id) => {
|
|||||||
try {
|
try {
|
||||||
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
|
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
|
||||||
toast.showToast($t('workerSoftDeleted'), 'success');
|
toast.showToast($t('workerSoftDeleted'), 'success');
|
||||||
fetchWorkers(
|
fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
|
||||||
workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value
|
|
||||||
);
|
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
errorMessage.value = 'Failed to soft-delete worker.';
|
errorMessage.value = 'Failed to soft-delete worker.';
|
||||||
}
|
}
|
||||||
@@ -546,15 +392,16 @@ const clearDevice = async (workerId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Renamed and refactored updateWorkerPassword to saveWorkerSettings
|
||||||
const saveWorkerSettings = async () => {
|
const saveWorkerSettings = async () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
passwordErrorMessage.value = '';
|
passwordErrorMessage.value = '';
|
||||||
passwordSuccessMessage.value = '';
|
passwordSuccessMessage.value = '';
|
||||||
let passwordUpdated = false;
|
let passwordUpdated = false;
|
||||||
let detailsUpdated = false;
|
let detailsUpdated = false;
|
||||||
|
toast.showToast($t('savingSettings'), 'info');
|
||||||
|
|
||||||
toast.showToast($t('savingSettings'), 'info');
|
// Handle password change
|
||||||
|
|
||||||
if (newPassword.value || confirmNewPassword.value) {
|
if (newPassword.value || confirmNewPassword.value) {
|
||||||
if (newPassword.value !== confirmNewPassword.value) {
|
if (newPassword.value !== confirmNewPassword.value) {
|
||||||
passwordErrorMessage.value = 'Passwords do not match.';
|
passwordErrorMessage.value = 'Passwords do not match.';
|
||||||
@@ -567,9 +414,9 @@ const saveWorkerSettings = async () => {
|
|||||||
passwordUpdated = true;
|
passwordUpdated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalWorker = workers.value.find((w) => w.id === editingWorker.value.id);
|
// Handle details change (status, department, position)
|
||||||
|
const originalWorker = workers.value.find(w => w.id === editingWorker.value.id);
|
||||||
const newStatus = editingWorker.value.isActive ? 'active' : 'inactive';
|
const newStatus = editingWorker.value.isActive ? 'active' : 'inactive';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
originalWorker.status !== newStatus ||
|
originalWorker.status !== newStatus ||
|
||||||
originalWorker.department !== editingWorker.value.department ||
|
originalWorker.department !== editingWorker.value.department ||
|
||||||
@@ -621,7 +468,7 @@ const saveWorkerSettings = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openSettingsModal = (worker) => {
|
const openSettingsModal = (worker) => {
|
||||||
editingWorker.value = { ...worker, isActive: worker.status === 'active' };
|
editingWorker.value = { ...worker, isActive: worker.status === 'active' }; // Initialize isActive for checkbox
|
||||||
isSettingsModalVisible.value = true;
|
isSettingsModalVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -636,6 +483,9 @@ const closeSettingsModal = () => {
|
|||||||
showDeleteConfirm.value = false;
|
showDeleteConfirm.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showClearDeviceConfirm = ref(false);
|
||||||
|
const showDeleteConfirm = ref(false);
|
||||||
|
|
||||||
const closeConfirmModal = () => {
|
const closeConfirmModal = () => {
|
||||||
isConfirmModalVisible.value = false;
|
isConfirmModalVisible.value = false;
|
||||||
confirmAction.value = '';
|
confirmAction.value = '';
|
||||||
@@ -660,7 +510,7 @@ const toggleWorkerSelection = (workerId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelectAll = (event) => {
|
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 () => {
|
const exportWorkHours = async () => {
|
||||||
@@ -668,18 +518,14 @@ const exportWorkHours = async () => {
|
|||||||
exportLoading.value = true;
|
exportLoading.value = true;
|
||||||
toast.showToast($t('exportingRecords'), 'info');
|
toast.showToast($t('exportingRecords'), 'info');
|
||||||
const { startDate, endDate } = exportFilters.value;
|
const { startDate, endDate } = exportFilters.value;
|
||||||
const workerIds = selectedWorkerIds.value.join(',');
|
let workerIds = selectedWorkerIds.value.join(',');
|
||||||
|
|
||||||
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}`, {
|
||||||
`${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')}`
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${sessionStorage.getItem('token')}`,
|
|
||||||
'X-User-Timezone': getUserTimezone(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
if (!response.ok) throw new Error('Network response was not ok.');
|
if (!response.ok) throw new Error('Network response was not ok.');
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
@@ -698,6 +544,7 @@ const exportWorkHours = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Check if there's saved search state
|
||||||
const savedSearchState = sessionStorage.getItem('personnelSearchState');
|
const savedSearchState = sessionStorage.getItem('personnelSearchState');
|
||||||
if (savedSearchState) {
|
if (savedSearchState) {
|
||||||
try {
|
try {
|
||||||
@@ -709,8 +556,11 @@ 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: '' };
|
||||||
|
|
||||||
|
// Clear the saved search state after restoring it
|
||||||
sessionStorage.removeItem('personnelSearchState');
|
sessionStorage.removeItem('personnelSearchState');
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
|
// If there's an error parsing the saved state, fetch workers normally
|
||||||
fetchWorkers();
|
fetchWorkers();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,24 +4,18 @@
|
|||||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('failedClockSummary') }}</h2>
|
<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="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">
|
<div class="flex-grow">
|
||||||
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')"
|
<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" />
|
||||||
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>
|
||||||
<div class="flex items-end gap-4 flex-wrap">
|
<div class="flex items-end gap-4 flex-wrap">
|
||||||
<div class="flex flex-col">
|
<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 for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate') }}</label>
|
||||||
}}</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" />
|
||||||
<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>
|
||||||
<div class="flex flex-col">
|
<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 for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate') }}</label>
|
||||||
}}</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" />
|
||||||
<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>
|
</div>
|
||||||
<button @click="fetchFailedRecords" :disabled="loadingReport"
|
<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">
|
||||||
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') }}
|
{{ loadingReport ? $t('loading') : $t('fetchRecords') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,40 +24,23 @@
|
|||||||
<table class="min-w-[700px] w-full text-left">
|
<table class="min-w-[700px] w-full text-left">
|
||||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||||
<tr class="border-b-2 border-gray-200 dark:border-gray-600">
|
<tr class="border-b-2 border-gray-200 dark:border-gray-600">
|
||||||
<th
|
<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')">
|
||||||
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') }}
|
{{ $t('worker') }}
|
||||||
<span v-if="sortField === 'full_name'" class="ml-1">
|
<span v-if="sortField === 'full_name'" class="ml-1">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
||||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
|
||||||
</span>
|
|
||||||
</th>
|
</th>
|
||||||
<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')">
|
||||||
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') }}
|
{{ $t('failedCount') }}
|
||||||
<span v-if="sortField === 'count'" class="ml-1">
|
<span v-if="sortField === 'count'" class="ml-1">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
||||||
{{ 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>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<tr v-for="record in sortedFailedRecords" :key="record.worker_id"
|
<tr v-for="record in sortedFailedRecords" :key="record.worker_id" class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150">
|
||||||
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 font-medium">
|
<td class="px-4 py-3 text-gray-800 dark:text-white text-center">{{ record.count }}</td>
|
||||||
{{ 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">
|
<td class="px-4 py-3 text-center">
|
||||||
<button @click="showDetails(record.worker_id, record.full_name)"
|
<button @click="showDetails(record.worker_id, record.full_name)" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
class="text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
{{ $t('viewDetails') }}
|
{{ $t('viewDetails') }}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -76,12 +53,9 @@
|
|||||||
<tr v-if="loadingReport">
|
<tr v-if="loadingReport">
|
||||||
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
<div class="flex justify-center items-center">
|
<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"
|
<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">
|
||||||
fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor"
|
<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>
|
||||||
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>
|
</svg>
|
||||||
<span>{{ $t('loadingReport') }}</span>
|
<span>{{ $t('loadingReport') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,11 +69,8 @@
|
|||||||
<div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
|
<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="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">
|
<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">
|
<h3 class="text-xl font-semibold text-gray-800 dark:text-white">{{ detailModalTitle }}</h3>
|
||||||
{{ detailModalTitle }}
|
<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">
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,37 +78,22 @@
|
|||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<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>
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
<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>
|
||||||
{{ $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('location') }}</th>
|
||||||
<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>
|
||||||
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
<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">
|
<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">{{ new Date(detail.timestamp).toLocaleString() }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
||||||
{{ formatLocalTimestamp(detail.timestamp) }}
|
<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">
|
||||||
</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) }}
|
{{ $t(detail.event_type) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.qrCodeUsedName || $t('nA') }}</td>
|
||||||
{{ detail.qrCodeUsedName || $t('nA') }}
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.notes || $t('nA') }}</td>
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
|
||||||
{{ detail.notes || $t('nA') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -156,47 +112,6 @@ import { useToast } from '@/composables/useToast';
|
|||||||
const { t: $t } = useI18n();
|
const { t: $t } = useI18n();
|
||||||
const toast = useToast();
|
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 ---
|
// --- STATE ---
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const filters = ref({ startDate: '', endDate: '' });
|
const filters = ref({ startDate: '', endDate: '' });
|
||||||
@@ -236,7 +151,7 @@ const fetchFailedRecords = async () => {
|
|||||||
const url = `/api/managers/failed-records?search=${searchQuery.value}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
|
const url = `/api/managers/failed-records?search=${searchQuery.value}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
|
||||||
failedRecords.value = await apiFetch(url);
|
failedRecords.value = await apiFetch(url);
|
||||||
} catch (_err) {
|
} 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');
|
toast.showToast('Failed to fetch records.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
loadingReport.value = false;
|
loadingReport.value = false;
|
||||||
@@ -259,7 +174,7 @@ const showDetails = async (workerId, workerName) => {
|
|||||||
detailRecords.value = await apiFetch(url);
|
detailRecords.value = await apiFetch(url);
|
||||||
showDetailModal.value = true;
|
showDetailModal.value = true;
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error('Failed to fetch details', _err);
|
console.error('Failed to fetch details',_err);
|
||||||
toast.showToast('Failed to load details.', 'error');
|
toast.showToast('Failed to load details.', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
console.log("[DEBUG] main.js loaded");
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@@ -10,3 +11,5 @@ const app = createApp(App)
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
console.log("[DEBUG] i18n import in main.js:", i18n);
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -19,38 +19,20 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||||
<div class="flex flex-col gap-2 flex-grow w-full">
|
<div class="flex flex-col gap-2 flex-grow w-full">
|
||||||
<label
|
<label for="manual-timestamp" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||||
for="manual-timestamp"
|
$t('clockOutTime') }}</label>
|
||||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<input type="datetime-local" id="manual-timestamp" v-model="manualClockOut.timestamp"
|
||||||
>
|
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-700 text-gray-900 dark:text-white" />
|
||||||
{{ $t('clockOutTime') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
id="manual-timestamp"
|
|
||||||
v-model="manualClockOut.timestamp"
|
|
||||||
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-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 flex-grow w-full">
|
<div class="flex flex-col gap-2 flex-grow w-full">
|
||||||
<label
|
<label for="manual-notes" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('reason')
|
||||||
for="manual-notes"
|
}}</label>
|
||||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<input type="text" id="manual-notes" v-model="manualClockOut.notes"
|
||||||
>
|
|
||||||
{{ $t('reason') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="manual-notes"
|
|
||||||
v-model="manualClockOut.notes"
|
|
||||||
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-700 text-gray-900 dark:text-white"
|
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-700 text-gray-900 dark:text-white"
|
||||||
:placeholder="$t('enterBriefNote')"
|
:placeholder="$t('enterBriefNote')" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button @click="addManualClockOut"
|
||||||
@click="addManualClockOut"
|
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0">
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"
|
|
||||||
>
|
|
||||||
{{ $t('addRecord') }}
|
{{ $t('addRecord') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,45 +40,23 @@
|
|||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 items-end mb-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="flex flex-col sm:flex-row gap-4 items-end mb-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex flex-col gap-2 w-full sm:w-auto">
|
<div class="flex flex-col gap-2 w-full sm:w-auto">
|
||||||
<label
|
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate')
|
||||||
for="start-date"
|
}}</label>
|
||||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<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 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
{{ $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 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 w-full sm:w-auto">
|
<div class="flex flex-col gap-2 w-full sm:w-auto">
|
||||||
<label
|
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label>
|
||||||
for="end-date"
|
<input type="date" id="end-date" v-model="filters.endDate"
|
||||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
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-700 text-gray-900 dark:text-white" />
|
||||||
>
|
|
||||||
{{ $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 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button @click="fetchRecords"
|
||||||
@click="fetchRecords"
|
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0">
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"
|
|
||||||
>
|
|
||||||
{{ $t('filterRecords') }}
|
{{ $t('filterRecords') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button @click="exportRawRecords" :disabled="exportLoading"
|
||||||
@click="exportRawRecords"
|
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0 disabled:opacity-50">
|
||||||
:disabled="exportLoading"
|
{{ exportLoading ? $t('exporting') : $t('export') }}
|
||||||
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ exportLoading ? $t('exporting') : $t('export') }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,11 +87,8 @@
|
|||||||
{{ $t('noRecordsFound') }}
|
{{ $t('noRecordsFound') }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr v-for="record in records" :key="record.id"
|
||||||
v-for="record in records"
|
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||||
:key="record.id"
|
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
class="inline-block px-2 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
|
class="inline-block px-2 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
|
||||||
@@ -139,34 +96,23 @@
|
|||||||
'bg-green-500': record.event_type === 'clock_in',
|
'bg-green-500': record.event_type === 'clock_in',
|
||||||
'bg-red-500': record.event_type === 'clock_out',
|
'bg-red-500': record.event_type === 'clock_out',
|
||||||
'bg-yellow-500': record.event_type === 'failed',
|
'bg-yellow-500': record.event_type === 'failed',
|
||||||
}"
|
}">
|
||||||
>
|
|
||||||
{{ record.event_type.replace('_', ' ') }}
|
{{ record.event_type.replace('_', ' ') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||||
{{ formatLocalTimestamp(record.timestamp) }}
|
{{ new Date(record.timestamp).toLocaleString() }}
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
|
||||||
{{ record.qrCodeUsedName }}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.qrCodeUsedName }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<a
|
<a v-if="record.latitude && record.longitude"
|
||||||
v-if="record.latitude && record.longitude"
|
:href="`https://maps.google.com/?q=${record.latitude},${record.longitude}`" target="_blank"
|
||||||
:href="`https://maps.google.com/?q=${record.latitude},${record.longitude}`"
|
rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 underline font-medium">
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-blue-600 hover:text-blue-800 underline font-medium"
|
|
||||||
>
|
|
||||||
{{ $t('showOnMap') }}
|
{{ $t('showOnMap') }}
|
||||||
</a>
|
</a>
|
||||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
<span v-else class="text-gray-500 dark:text-gray-400">{{ $t('nA') }}</span>
|
||||||
{{ $t('nA') }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
|
||||||
{{ record.notes || $t('nA') }}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || $t('nA') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -189,60 +135,15 @@ const records = ref([])
|
|||||||
const workerName = ref('')
|
const workerName = ref('')
|
||||||
const workerId = route.params.workerId
|
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 toLocalISOString = (date) => {
|
||||||
const tzoffset = new Date().getTimezoneOffset() * 60000 // offset in ms
|
const tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
|
||||||
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
|
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
|
||||||
return localISOTime
|
return localISOTime
|
||||||
}
|
}
|
||||||
|
|
||||||
const manualClockOut = ref({
|
const manualClockOut = ref({
|
||||||
timestamp: toLocalISOString(new Date()),
|
timestamp: toLocalISOString(new Date()),
|
||||||
notes: ''
|
notes: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -251,14 +152,15 @@ setStartDay.setDate(today.getDate() - 60)
|
|||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
startDate: setStartDay.toISOString().split('T')[0],
|
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 = () => {
|
const goBack = () => {
|
||||||
window.history.back()
|
// Navigate back to the manager dashboard (PersonnelManagement component)
|
||||||
}
|
window.history.back();
|
||||||
|
};
|
||||||
|
|
||||||
const fetchRecords = async () => {
|
const fetchRecords = async () => {
|
||||||
let url = `/api/managers/attendance-records?workerIds=${workerId}`
|
let url = `/api/managers/attendance-records?workerIds=${workerId}`
|
||||||
@@ -272,19 +174,21 @@ const fetchRecords = async () => {
|
|||||||
if (data && Array.isArray(data)) {
|
if (data && Array.isArray(data)) {
|
||||||
records.value = data
|
records.value = data
|
||||||
if (!workerName.value && data.length > 0) {
|
if (!workerName.value && data.length > 0) {
|
||||||
const cachedWorkerData = workerCache.getWorkerData(workerId)
|
// Check if worker data is cached
|
||||||
|
const cachedWorkerData = workerCache.getWorkerData(workerId);
|
||||||
if (cachedWorkerData) {
|
if (cachedWorkerData) {
|
||||||
workerName.value = cachedWorkerData.full_name
|
workerName.value = cachedWorkerData.full_name;
|
||||||
} else {
|
} else {
|
||||||
workerName.value = data[0].full_name
|
workerName.value = data[0].full_name;
|
||||||
workerCache.storeWorkerData(workerId, { full_name: data[0].full_name })
|
// Cache the worker data for future use
|
||||||
|
workerCache.storeWorkerData(workerId, { full_name: data[0].full_name });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
records.value = []
|
records.value = []
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error('Failed to fetch attendance records:', _err)
|
console.error('Failed to fetch attendance records:',_err)
|
||||||
alert(_err.message)
|
alert(_err.message)
|
||||||
records.value = []
|
records.value = []
|
||||||
}
|
}
|
||||||
@@ -304,14 +208,14 @@ const addManualClockOut = async () => {
|
|||||||
await apiFetch('/api/managers/add-record', {
|
await apiFetch('/api/managers/add-record', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
workerId: workerId,
|
workerId: workerId,
|
||||||
eventType: 'clock_out',
|
eventType: 'clock_out',
|
||||||
timestamp: manualClockOut.value.timestamp,
|
timestamp: manualClockOut.value.timestamp,
|
||||||
notes: manualClockOut.value.notes
|
notes: manualClockOut.value.notes,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
alert(t('manualClockOutSuccess'))
|
alert(t('manualClockOutSuccess'))
|
||||||
@@ -319,45 +223,41 @@ const addManualClockOut = async () => {
|
|||||||
manualClockOut.value.timestamp = toLocalISOString(new Date())
|
manualClockOut.value.timestamp = toLocalISOString(new Date())
|
||||||
fetchRecords()
|
fetchRecords()
|
||||||
} catch (_err) {
|
} 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 }))
|
alert(t('manualClockOutError', { msg: _err.message }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportRawRecords = async () => {
|
const exportRawRecords = async () => {
|
||||||
exportLoading.value = true
|
exportLoading.value = true;
|
||||||
const { startDate, endDate } = filters.value
|
const { startDate, endDate } = filters.value;
|
||||||
|
|
||||||
const tz = localStorage.getItem('tz') || getUserTimezone()
|
// pull preferred tz from localStorage; fall back to browser tz
|
||||||
|
const tz = localStorage.getItem('tz') || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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(
|
`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}&tz=${encodeURIComponent(tz)}`,
|
||||||
tz
|
|
||||||
)}`,
|
|
||||||
{
|
{
|
||||||
headers: {
|
headers: { 'Authorization': `Bearer ${sessionStorage.getItem('token')}` }
|
||||||
Authorization: `Bearer ${sessionStorage.getItem('token')}`,
|
|
||||||
'X-User-Timezone': tz
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
if (!response.ok) throw new Error('Network response was not ok.')
|
if (!response.ok) throw new Error('Network response was not ok.');
|
||||||
const blob = await response.blob()
|
const blob = await response.blob();
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a');
|
||||||
a.href = url
|
a.href = url;
|
||||||
a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`
|
a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`;
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a);
|
||||||
a.click()
|
a.click();
|
||||||
a.remove()
|
a.remove();
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
alert('Failed to export records.')
|
alert('Failed to export records.');
|
||||||
} finally {
|
} finally {
|
||||||
exportLoading.value = false
|
exportLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchRecords()
|
fetchRecords()
|
||||||
|
|||||||
+16
-86
@@ -2,9 +2,11 @@
|
|||||||
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
|
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
|
||||||
<!-- Back Button -->
|
<!-- Back Button -->
|
||||||
<div class="fixed bottom-4 right-4 z-50">
|
<div class="fixed bottom-4 right-4 z-50">
|
||||||
<button @click="goBack"
|
<button
|
||||||
|
@click="goBack"
|
||||||
class="bg-white dark:bg-gray-800 shadow-lg rounded-full p-3 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
class="bg-white dark:bg-gray-800 shadow-lg rounded-full p-3 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
aria-label="Return to Dashboard">
|
aria-label="Return to Dashboard"
|
||||||
|
>
|
||||||
<svg class="w-6 h-6 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -15,43 +17,26 @@
|
|||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="!clockHistory.length" class="text-center py-16 mt-8">
|
<div v-if="!clockHistory.length" class="text-center py-16 mt-8">
|
||||||
<ChartBarIcon class="w-16 h-16 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
<ChartBarIcon class="w-16 h-16 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
||||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300">
|
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300">{{ $t('noClockHistory') }}</h2>
|
||||||
{{ $t('noClockHistory') }}
|
<p class="text-gray-500 dark:text-gray-400 mt-2">{{ $t('clockHistoryEmptyState') }}</p>
|
||||||
</h2>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
{{ $t('clockHistoryEmptyState') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History List -->
|
<!-- History List -->
|
||||||
<div v-else class="space-y-4 mt-8 mb-10">
|
<div v-else class="space-y-4 mt-8 mb-10">
|
||||||
<div v-for="event in clockHistory" :key="event.id"
|
<div v-for="event in clockHistory" :key="event.id"
|
||||||
class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-5 flex items-center space-x-4">
|
class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-5 flex items-center space-x-4">
|
||||||
<div class="w-12 h-12 rounded-full flex items-center justify-center" :class="event.event_type === 'clock_in'
|
<div class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||||
? 'bg-green-100 dark:bg-green-900/50'
|
:class="event.event_type === 'clock_in' ? 'bg-green-100 dark:bg-green-900/50' : 'bg-red-100 dark:bg-red-900/50'">
|
||||||
: 'bg-red-100 dark:bg-red-900/50'">
|
<component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon"
|
||||||
<component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon" :class="[
|
:class="['w-8 h-8', event.event_type === 'clock_in' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400']" />
|
||||||
'w-8 h-8',
|
|
||||||
event.event_type === 'clock_in'
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
|
||||||
: 'text-red-600 dark:text-red-400'
|
|
||||||
]" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<div class="font-bold text-lg text-gray-900 dark:text-gray-100">
|
<div class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ $t(event.event_type) }}</div>
|
||||||
{{ $t(event.event_type) }}
|
<div class="text-sm text-gray-600 dark:text-gray-400">{{ event.qrCodeUsedName }}</div>
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{{ event.qrCodeUsedName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="font-medium text-gray-800 dark:text-gray-200">
|
<div class="font-medium text-gray-800 dark:text-gray-200">{{ new Date(event.timestamp).toLocaleDateString() }}</div>
|
||||||
{{ formatLocalDate(event.timestamp) }}
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ new Date(event.timestamp).toLocaleTimeString() }}</div>
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ formatLocalTime(event.timestamp) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,61 +56,6 @@ const router = useRouter()
|
|||||||
const clockHistory = ref([])
|
const clockHistory = ref([])
|
||||||
const userId = sessionStorage.getItem('userId')
|
const userId = sessionStorage.getItem('userId')
|
||||||
|
|
||||||
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 formatLocalDate = (utcValue) => {
|
|
||||||
const iso = normalizeUtcToIso(utcValue)
|
|
||||||
if (!iso) return ''
|
|
||||||
const tz = getUserTimezone()
|
|
||||||
const d = new Date(iso)
|
|
||||||
return d.toLocaleDateString(undefined, {
|
|
||||||
timeZone: tz,
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatLocalTime = (utcValue) => {
|
|
||||||
const iso = normalizeUtcToIso(utcValue)
|
|
||||||
if (!iso) return ''
|
|
||||||
const tz = getUserTimezone()
|
|
||||||
const d = new Date(iso)
|
|
||||||
return d.toLocaleTimeString(undefined, {
|
|
||||||
timeZone: tz,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
@@ -134,7 +64,7 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
const data = await apiFetch(`/api/worker/clock-history/${userId}`)
|
const data = await apiFetch(`/api/worker/clock-history/${userId}`)
|
||||||
if (data) {
|
if (data) {
|
||||||
clockHistory.value = data.filter(event => event.event_type !== 'failed')
|
clockHistory.value = data.filter(event => event.event_type !== 'failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t('clockHistoryFetchFail'), error)
|
console.error(t('clockHistoryFetchFail'), error)
|
||||||
|
|||||||
Reference in New Issue
Block a user