20 Commits

Author SHA1 Message Date
Edison e31416d91d add enable month and disable 2025-11-14 11:20:36 +08:00
winter.liang 193da32ca4 Merge branch 'main' of https://git.wlcent.cn/NiLai_Clock/Nilai_Clock 2025-11-11 14:00:08 +08:00
Edison f2865be33a Merge branch 'main' of https://git.wlcent.cn/Marc.ma/Nilai_Clock 2025-11-11 13:55:16 +08:00
Edison eb0c3de489 Merge branch 'main' into new_edi_branch 2025-11-11 13:52:44 +08:00
Edison 5b03d39e36 Merge branch 'main' into new_edi_branch 2025-11-11 13:45:04 +08:00
winter.liang fee699b529 fix:connection lost 2025-11-07 11:28:44 +08:00
Edison a5f6803f91 Merge branch 'main' into new_edi_branch 2025-11-06 16:19:56 +08:00
winter.liang 899b6fae93 fix:connection lost 2025-11-06 10:05:07 +08:00
winter.liang e9330b8e2d fix:timezone has no effect 2025-11-05 15:32:27 +08:00
Edison 8c04d91a18 rehydrate for delete and also allow fullName changing 2025-11-04 10:05:13 +08:00
winter.liang b577d5ad1b fix:time display 2025-11-03 18:18:24 +08:00
winter.liang 30d2e932e5 fix:time display 2025-11-03 18:05:37 +08:00
winter.liang 4ce4b21315 fix:time display 2025-11-03 17:59:20 +08:00
winter.liang 9b1eb38dd9 fix:time display 2025-11-03 17:34:03 +08:00
winter.liang 6d31e4db09 Merge remote-tracking branch 'origin/fix-timestamp'
# Conflicts:
#	backend/server.js
#	backend/workerRoutes.js
#	src/views/ManagerAttendanceRecord.vue
2025-11-03 17:09:07 +08:00
winter.liang 9bb899cc05 Merge remote-tracking branch 'origin/main' into new_edi_branch 2025-11-03 14:31:01 +08:00
Edison 1d89d47c53 frontend and backend to follow db.js strictly 2025-11-03 14:23:35 +08:00
Edison 7e37230894 calculations 2025-11-03 13:35:35 +08:00
longke df32dab9aa console log 2025-11-03 12:01:23 +08:00
Edison 7231310f93 timezone update 2025-11-03 11:31:34 +08:00
11 changed files with 1304 additions and 852 deletions
+8
View File
@@ -0,0 +1,8 @@
// backend/config/db.js
import 'dotenv/config';
export const APP_TIMEZONE =
process.env.APP_TIMEZONE || '+07:00'; // default for Nilai
//process.env.APP_TIMEZONE || 'Asia/Jakarta'; // default for Indonesia
// All dates from DB are treated as if they are in this timezone
+745 -497
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
import mysql from 'mysql2/promise'
import { APP_TIMEZONE } from './config/db.js'
import dotenv from 'dotenv'
import path from 'path'
import { fileURLToPath } from 'url'
dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') });
const db = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
// timezone: '+08:00',
dateStrings: true,
});
const originalGetConnection = db.getConnection.bind(db);
db.getConnection = async () => {
const connection = await originalGetConnection();
// 设置时区
await connection.execute(`SET time_zone = '${APP_TIMEZONE}'`);
return connection;
};
export const getConnection = async () => {
return await db.getConnection()
}
+5 -18
View File
@@ -7,30 +7,17 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import mysql from 'mysql2/promise';
import managerRoutes from './managerRoutes.js';
import workerRoutes from './workerRoutes.js';
import { getConnection } from './pool.js'
async function startServer() {
dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') });
const app = express();
const db = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
timezone: 'Z',
dateStrings: true
});
try {
const connection = await db.getConnection();
const connection = await getConnection();
console.log('Database connected successfully!');
connection.release();
} catch (error) {
@@ -55,7 +42,7 @@ async function startServer() {
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning'],
allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning', 'X-User-Timezone'], //added X-User-Timezone for my development (Edison)
exposedHeaders: ['Content-Range', 'X-Content-Range'],
};
@@ -78,8 +65,8 @@ async function startServer() {
app.get('/time', timeHandler); // public path
app.get('/api/time', timeHandler); // also under /api
app.use('/api/managers', managerRoutes(db));
app.use('/api', workerRoutes(db));
app.use('/api/managers', managerRoutes());
app.use('/api', workerRoutes());
const httpPort = process.env.HTTP_PORT || 3000;
const httpsPort = process.env.HTTPS_PORT || 3443;
+92 -31
View File
@@ -2,12 +2,7 @@ import express from 'express';
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { db } from './server.js';
import { withTzSession } from './middleware/withTzSession.js';
// Map IANA (no DST for KL/Jakarta)
const sessionOffset = (iana) => (iana === 'Asia/Jakarta' ? '+07:00' : '+08:00');
import { getConnection } from './pool.js';
async function validateDeviceForUser(userId, deviceUuid, db) {
const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]);
@@ -27,7 +22,7 @@ async function isClockingEnabled(conn) {
return rows.length > 0;
}
export default function(db) {
export default function() {
const router = express.Router();
// Set DEVICE_UUID_ENABLED to false to completely disable device UUID checking
@@ -36,6 +31,8 @@ export default function(db) {
const AUTO_REGISTER_NEW_DEVICES = true;
router.post('/auth/login', async (req, res) => {
const db = await getConnection();
try {
const { username, password, deviceUuid } = req.body;
const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]);
if (rows.length === 0) {
@@ -71,7 +68,6 @@ export default function(db) {
if (!deviceResult.valid) {
return res.status(500).json({ message: 'deviceRegistrationFailed' });
}
// console.log(`Device UUID registered for worker ${user.id}: ${deviceUuid}`);
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
return res.status(403).json({ message: 'deviceRequired' });
}
@@ -81,6 +77,12 @@ export default function(db) {
// Managers can always login, workers without device_uuid can login
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' });
res.json({ token });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ message: 'Server error during login' });
} finally {
db.release();
}
});
const authenticateJWT = (req, res, next) => {
@@ -91,7 +93,7 @@ export default function(db) {
if (err) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
req.user = { ...user, id: user.userId }; // Correctly map userId to id
req.user = { ...user, id: user.userId };
next();
});
} else {
@@ -102,38 +104,33 @@ export default function(db) {
router.use(authenticateJWT);
router.post('/clock', async (req, res) => {
// NEW: borrow a connection so we can set session time_zone
const conn = await db.getConnection();
const db = await getConnection();
try {
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
// NEW: set session time_zone from header (defaults to KL)
const iana = req.headers['x-user-timezone'] || 'Asia/Kuala_Lumpur';
await conn.query('SET time_zone = ?', [sessionOffset(iana)]);
// 1) Kill Switch — now evaluated in the session's local day
const clockingAllowed = await isClockingEnabled(conn); // CHANGED: pass conn
const clockingAllowed = await isClockingEnabled(db);
if (!clockingAllowed) {
const note = 'Clock-in/out function is not enabled for today.';
await conn.execute( // CHANGED: use conn
await db.execute(
`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'))`,
VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
[userId, qrCodeValue, latitude, longitude, note]
);
return res.status(403).json({ message: 'error.clockingDisabled' });
}
// 2) Geofence Validation (unchanged logic, just switch db -> conn)
// 2) Geofence Validation
if (latitude != null && longitude != null) {
const [activeFences] = await conn.execute('SELECT coordinates FROM geofences WHERE is_active = 1'); // CHANGED
const [activeFences] = await db.execute('SELECT coordinates FROM geofences WHERE is_active = 1');
if (activeFences.length === 0) {
const note = 'Cannot clock in: No active work area is defined.';
await conn.execute( // CHANGED
await db.execute(
`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'))`,
VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
[userId, qrCodeValue, latitude, longitude, note]
);
return res.status(403).json({ message: 'error.noActiveGeofence' });
@@ -166,25 +163,25 @@ export default function(db) {
}
const distanceString = minDistance.toFixed(2);
const note = `Outside geofence by ${distanceString}m`;
await conn.execute( // CHANGED
await db.execute(
`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'))`,
VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
[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)
// 3) QR Code and Status Validation
if (qrCodeValue !== 'FORCE_CLOCK_OUT') {
const [qrRows] = await conn.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]); // CHANGED
const [qrRows] = await db.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]);
if (qrRows.length === 0 || !qrRows[0].is_active) {
return res.status(400).json({ message: 'error.invalidQrCode' });
}
}
const [lastEvent] = await conn.execute( // CHANGED
const [lastEvent] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[userId]
);
@@ -193,11 +190,11 @@ export default function(db) {
return res.status(400).json({ message: errorKey });
}
// 4) Record Successful Event — store UTC via SQL conversion (no JS date math)
await conn.execute(
// 4) Record Successful Event
await db.execute(
`INSERT INTO clock_records
(worker_id, event_type, qr_code_id, latitude, longitude, timestamp)
VALUES (?, ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
VALUES (?, ?, ?, ?, ?, CURRENT_TIME())`,
[userId, eventType, qrCodeValue, latitude, longitude]
);
@@ -206,26 +203,44 @@ export default function(db) {
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
res.status(500).json({ message: 'error.criticalServer' });
} finally {
if (conn) conn.release();
db.release();
}
});
router.get('/workers/:id', async (req, res) => {
const db = await getConnection();
try {
const { id } = req.params;
const [rows] = await db.execute("SELECT full_name FROM workers WHERE id = ? AND role = 'worker'", [id]);
if (rows.length === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.json(rows[0]);
} catch (error) {
console.error('Get worker error:', error);
res.status(500).json({ message: 'Server error fetching worker' });
} finally {
db.release();
}
});
router.get('/worker/status/:userId', async (req, res) => {
const db = await getConnection();
try {
const { userId } = req.params;
const [rows] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]);
res.json({ eventType: rows.length > 0 ? rows[0].event_type : 'clock_out' });
} catch (error) {
console.error('Get worker status error:', error);
res.status(500).json({ message: 'Server error fetching worker status' });
} finally {
db.release();
}
});
router.get('/worker/clock-history/:userId', async (req, res) => {
const db = await getConnection();
try {
const { userId } = req.params;
const [rows] = await db.execute(`
SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName
@@ -234,9 +249,17 @@ export default function(db) {
WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC
`, [userId]);
res.json(rows);
} catch (error) {
console.error('Get clock history error:', error);
res.status(500).json({ message: 'Server error fetching clock history' });
} finally {
db.release();
}
});
router.put('/worker/change-password', async (req, res) => {
const db = await getConnection();
try {
const { userId } = req.user;
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword || newPassword.length < 6) {
@@ -250,6 +273,12 @@ export default function(db) {
const newHashedPassword = await bcrypt.hash(newPassword, 10);
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [newHashedPassword, userId]);
res.json({ message: 'Password updated successfully.' });
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({ message: 'Server error changing password' });
} finally {
db.release();
}
});
router.post('/location/update', async (req, res) => {
@@ -258,18 +287,36 @@ export default function(db) {
});
router.post('/device/register', async (req, res) => {
const db = await getConnection();
try {
const { userId, deviceUuid } = req.body;
const result = await validateDeviceForUser(userId, deviceUuid, db);
res.status(result.valid ? 200 : 409).json(result);
} catch (error) {
console.error('Device register error:', error);
res.status(500).json({ message: 'Server error registering device' });
} finally {
db.release();
}
});
router.post('/device/validate', async (req, res) => {
const db = await getConnection();
try {
const { userId, deviceUuid } = req.body;
const result = await validateDeviceForUser(userId, deviceUuid, db);
res.json(result);
} catch (error) {
console.error('Device validate error:', error);
res.status(500).json({ message: 'Server error validating device' });
} finally {
db.release();
}
});
router.get('/security/status/:userId', async (req, res) => {
const db = await getConnection();
try {
const { userId } = req.params;
const [securityRows] = await db.execute('SELECT * FROM security_checks WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [userId]);
const [alertRows] = await db.execute('SELECT * FROM security_alerts WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)', [userId]);
@@ -277,11 +324,25 @@ export default function(db) {
latestSecurityCheck: securityRows[0] || null,
recentAlerts: alertRows,
});
} catch (error) {
console.error('Security status error:', error);
res.status(500).json({ message: 'Server error fetching security status' });
} finally {
db.release();
}
});
router.get('/security/app-blacklist', async (req, res) => {
const db = await getConnection();
try {
const [rows] = await db.execute('SELECT package_name FROM app_blacklist');
res.json(rows.map(row => row.package_name));
} catch (error) {
console.error('App blacklist error:', error);
res.status(500).json({ message: 'Server error fetching app blacklist' });
} finally {
db.release();
}
});
return router;
+77 -1
View File
@@ -34,7 +34,43 @@
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 sticky top-4">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-white">{{ $t('pendingChanges') }}</h3>
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-white">
{{ $t('pendingChanges') }}
</h3>
<div class="mb-6">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
This will enable all dates on the current month you are on
</p>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
@click="enableAllCurrentMonth"
class="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg text-sm font-medium
text-green-700 bg-green-50 hover:bg-green-100 border border-green-200
dark:text-green-200 dark:bg-green-900/20 dark:hover:bg-green-900/40 dark:border-green-800
transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Enable ({{ viewDate.toLocaleString('default', { month: 'long' }) }})
</button>
<button
type="button"
@click="disableAllCurrentMonth"
class="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg text-sm font-medium
text-red-700 bg-red-50 hover:bg-red-100 border border-red-200
dark:text-red-200 dark:bg-red-900/20 dark:hover:bg-red-900/40 dark:border-red-800
transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Disable ({{ viewDate.toLocaleString('default', { month: 'long' }) }})
</button>
</div>
</div>
<div v-if="!hasPendingChanges" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ $t('noPendingChanges') }}
</div>
@@ -175,6 +211,19 @@ const calendarGrid = computed(() => {
return grid;
});
const getCurrentMonthDateStrings = () => {
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const list = [];
for (let i = 1; i <= daysInMonth; i++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
list.push(dateStr);
}
return list;
};
const getDayClasses = (day) => {
if (!day.isCurrentMonth) return 'h-20';
@@ -242,6 +291,33 @@ function onDayClick(day) {
: datesToEnable.value.add(dateStr);
}
}
function enableAllCurrentMonth() {
const dates = getCurrentMonthDateStrings();
dates.forEach((dateStr) => {
datesToDisable.value.delete(dateStr);
if (originalEnabledDates.value.has(dateStr)) {
datesToEnable.value.delete(dateStr);
} else {
datesToEnable.value.add(dateStr);
}
});
}
function disableAllCurrentMonth() {
const dates = getCurrentMonthDateStrings();
dates.forEach((dateStr) => {
datesToEnable.value.delete(dateStr);
if (originalEnabledDates.value.has(dateStr)) {
datesToDisable.value.add(dateStr);
} else {
datesToDisable.value.delete(dateStr);
}
});
}
async function applyChanges() {
const confirmed = await toast.showConfirm($t('confirmApplyChanges'));
+30 -7
View File
@@ -195,19 +195,37 @@
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('accountSettings') }}</h4>
<div class="mb-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ $t('fullName') }}
</label>
<input
type="text"
v-model="editingWorker.fullName"
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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ $t('department') }}
</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" />
<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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ $t('position') }}
</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" />
<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>
@@ -573,7 +591,8 @@ const saveWorkerSettings = async () => {
if (
originalWorker.status !== newStatus ||
originalWorker.department !== editingWorker.value.department ||
originalWorker.position !== editingWorker.value.position
originalWorker.position !== editingWorker.value.position ||
originalWorker.full_name !== editingWorker.value.fullName
) {
detailsUpdated = true;
}
@@ -600,6 +619,7 @@ const saveWorkerSettings = async () => {
status: newStatus,
department: editingWorker.value.department,
position: editingWorker.value.position,
fullName: editingWorker.value.fullName,
}),
});
if (passwordUpdated) {
@@ -608,7 +628,6 @@ const saveWorkerSettings = async () => {
passwordSuccessMessage.value = 'Worker details updated successfully!';
}
}
await fetchWorkers(currentPage.value);
setTimeout(() => {
closeSettingsModal();
@@ -621,7 +640,11 @@ const saveWorkerSettings = async () => {
};
const openSettingsModal = (worker) => {
editingWorker.value = { ...worker, isActive: worker.status === 'active' };
editingWorker.value = {
...worker,
fullName: worker.full_name,
isActive: worker.status === 'active',
};
isSettingsModalVisible.value = true;
};
+1 -1
View File
@@ -124,7 +124,7 @@
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="detail in detailRecords" :key="detail.id">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
{{ formatLocalTimestamp(detail.timestamp) }}
{{ detail.timestamp }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
<span
+2 -3
View File
@@ -145,7 +145,7 @@
</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ formatLocalTimestamp(record.timestamp) }}
{{ record.timestamp }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ record.qrCodeUsedName }}
@@ -235,8 +235,7 @@ const formatLocalTimestamp = (utcValue) => {
}
const toLocalISOString = (date) => {
const tzoffset = new Date().getTimezoneOffset() * 60000 // offset in ms
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
const localISOTime = new Date(date).toISOString().slice(0, 16)
return localISOTime
}
+17 -1
View File
@@ -186,13 +186,29 @@ const fetchCurrentStatus = async () => {
}
}
const getTimeContext = () => {
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
const offsetMin = -new Date().getTimezoneOffset() // east of UTC => positive
return { device_epoch_ms: Date.now(), tz_name: tzName, offset_min: offsetMin }
}
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
try {
const payload = {
userId,
eventType,
qrCodeValue,
latitude,
longitude,
...getTimeContext() // new: device_epoch_ms, tz_name, offset_min
}
await apiFetch('/api/clock', {
method: 'POST',
body: JSON.stringify({ userId, eventType, qrCodeValue, latitude, longitude }),
body: JSON.stringify(payload),
});
const newClockStatus = !isClockedIn.value
isClockedIn.value = newClockStatus
triggerOverlay(t(newClockStatus ? 'successClockIn' : 'successClockOut'), 'success');
+2 -2
View File
@@ -47,10 +47,10 @@
</div>
<div class="text-right">
<div class="font-medium text-gray-800 dark:text-gray-200">
{{ formatLocalDate(event.timestamp) }}
{{ event.timestamp }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ formatLocalTime(event.timestamp) }}
{{ event.timestamp }}
</div>
</div>
</div>