Compare commits
28 Commits
v1.0.0
...
new_edi_branch
| Author | SHA1 | Date | |
|---|---|---|---|
| e31416d91d | |||
| 193da32ca4 | |||
| f2865be33a | |||
| eb0c3de489 | |||
| 5b03d39e36 | |||
| fee699b529 | |||
| a5f6803f91 | |||
| 899b6fae93 | |||
| e9330b8e2d | |||
| 8c04d91a18 | |||
| b577d5ad1b | |||
| 30d2e932e5 | |||
| 4ce4b21315 | |||
| 9b1eb38dd9 | |||
| 6d31e4db09 | |||
| 05d58b0012 | |||
| b6352dcddc | |||
| 9bb899cc05 | |||
| 1d89d47c53 | |||
| 7e37230894 | |||
| df32dab9aa | |||
| 7231310f93 | |||
| b1a8612571 | |||
| ea9ba5eefc | |||
| e5bdcbd631 | |||
| 9db81d377e | |||
| 84ce4085a0 | |||
| c7e78b910d |
@@ -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
|
||||
+862
-471
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
+21
-16
@@ -7,28 +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,
|
||||
});
|
||||
|
||||
try {
|
||||
const connection = await db.getConnection();
|
||||
const connection = await getConnection();
|
||||
console.log('Database connected successfully!');
|
||||
connection.release();
|
||||
} catch (error) {
|
||||
@@ -53,15 +42,31 @@ 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'],
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
// --- Public server time endpoints (no auth, no cache) ---
|
||||
const timeHandler = (req, res) => {
|
||||
const now = new Date();
|
||||
const ymdKL = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Kuala_Lumpur',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).format(now); // "YYYY-MM-DD"
|
||||
|
||||
app.use('/api/managers', managerRoutes(db));
|
||||
app.use('/api', workerRoutes(db));
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
res.set('Expires', '0');
|
||||
|
||||
res.json({ nowIso: now.toISOString(), tz: 'Asia/Kuala_Lumpur', ymdKL });
|
||||
};
|
||||
app.get('/time', timeHandler); // public path
|
||||
app.get('/api/time', timeHandler); // also under /api
|
||||
|
||||
app.use('/api/managers', managerRoutes());
|
||||
app.use('/api', workerRoutes());
|
||||
|
||||
const httpPort = process.env.HTTP_PORT || 3000;
|
||||
const httpsPort = process.env.HTTPS_PORT || 3443;
|
||||
|
||||
+192
-105
@@ -2,7 +2,7 @@ import express from 'express';
|
||||
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
// Removed unused import
|
||||
import { getConnection } from './pool.js';
|
||||
|
||||
async function validateDeviceForUser(userId, deviceUuid, db) {
|
||||
const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]);
|
||||
@@ -15,13 +15,14 @@ async function validateDeviceForUser(userId, deviceUuid, db) {
|
||||
return { valid: device_uuid === deviceUuid, message: 'Device validation failed' };
|
||||
}
|
||||
|
||||
async function isClockingEnabled(db) {
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD format
|
||||
const [rows] = await db.execute('SELECT 1 FROM enabled_dates WHERE enabled_date = ? LIMIT 1', [today]);
|
||||
async function isClockingEnabled(conn) {
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT 1 FROM enabled_dates WHERE enabled_date = CURDATE() LIMIT 1'
|
||||
);
|
||||
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
|
||||
@@ -30,51 +31,58 @@ export default function(db) {
|
||||
const AUTO_REGISTER_NEW_DEVICES = true;
|
||||
|
||||
router.post('/auth/login', async (req, res) => {
|
||||
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) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
const user = rows[0];
|
||||
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) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
const user = rows[0];
|
||||
|
||||
// Check if the user's status is 'active'
|
||||
if (user.status !== 'active') {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
// Check if the user's status is 'active'
|
||||
if (user.status !== 'active') {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.password_hash);
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
const passwordMatch = await bcrypt.compare(password, user.password_hash);
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Device UUID handling - controlled by configuration flags above
|
||||
if (DEVICE_UUID_ENABLED && user.role === 'worker') {
|
||||
const [deviceRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [user.id]);
|
||||
const existingDeviceUuid = deviceRows[0].device_uuid;
|
||||
// Device UUID handling - controlled by configuration flags above
|
||||
if (DEVICE_UUID_ENABLED && user.role === 'worker') {
|
||||
const [deviceRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [user.id]);
|
||||
const existingDeviceUuid = deviceRows[0].device_uuid;
|
||||
|
||||
if (existingDeviceUuid) {
|
||||
if (deviceUuid && deviceUuid !== existingDeviceUuid) {
|
||||
return res.status(403).json({ message: 'deviceMismatch' });
|
||||
} else if (!deviceUuid) {
|
||||
return res.status(403).json({ message: 'useMobileApp' });
|
||||
}
|
||||
} else {
|
||||
// User has no registered device
|
||||
if (deviceUuid && AUTO_REGISTER_NEW_DEVICES) {
|
||||
const deviceResult = await validateDeviceForUser(user.id, deviceUuid, db);
|
||||
if (!deviceResult.valid) {
|
||||
return res.status(500).json({ message: 'deviceRegistrationFailed' });
|
||||
if (existingDeviceUuid) {
|
||||
if (deviceUuid && deviceUuid !== existingDeviceUuid) {
|
||||
return res.status(403).json({ message: 'deviceMismatch' });
|
||||
} else if (!deviceUuid) {
|
||||
return res.status(403).json({ message: 'useMobileApp' });
|
||||
}
|
||||
} else {
|
||||
// User has no registered device
|
||||
if (deviceUuid && AUTO_REGISTER_NEW_DEVICES) {
|
||||
const deviceResult = await validateDeviceForUser(user.id, deviceUuid, db);
|
||||
if (!deviceResult.valid) {
|
||||
return res.status(500).json({ message: 'deviceRegistrationFailed' });
|
||||
}
|
||||
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
|
||||
return res.status(403).json({ message: 'deviceRequired' });
|
||||
}
|
||||
// console.log(`Device UUID registered for worker ${user.id}: ${deviceUuid}`);
|
||||
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
|
||||
return res.status(403).json({ message: 'deviceRequired' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
// 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) => {
|
||||
@@ -85,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 {
|
||||
@@ -94,32 +102,37 @@ export default function(db) {
|
||||
};
|
||||
|
||||
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) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
|
||||
const currentTimestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
// 1. Kill Switch Enforcement
|
||||
// 1) Kill Switch — now evaluated in the session's local day
|
||||
const clockingAllowed = await isClockingEnabled(db);
|
||||
if (!clockingAllowed) {
|
||||
const note = 'Clock-in/out function is not enabled for today.';
|
||||
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]
|
||||
`INSERT INTO clock_records
|
||||
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
|
||||
VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
|
||||
[userId, qrCodeValue, latitude, longitude, note]
|
||||
);
|
||||
return res.status(403).json({ message: 'error.clockingDisabled' });
|
||||
}
|
||||
|
||||
// 2. Geofence Validation with Distance Calculation
|
||||
// 2) Geofence Validation
|
||||
if (latitude != null && longitude != null) {
|
||||
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 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]);
|
||||
await db.execute(
|
||||
`INSERT INTO clock_records
|
||||
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
|
||||
VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
|
||||
[userId, qrCodeValue, latitude, longitude, note]
|
||||
);
|
||||
return res.status(403).json({ message: 'error.noActiveGeofence' });
|
||||
}
|
||||
|
||||
@@ -132,7 +145,7 @@ export default function(db) {
|
||||
if (!fence.coordinates) continue;
|
||||
const coordinates = JSON.parse(fence.coordinates);
|
||||
const fencePolygon = polygon([coordinates]);
|
||||
parsedPolygons.push(fencePolygon); // Save for distance calculation
|
||||
parsedPolygons.push(fencePolygon);
|
||||
if (booleanPointInPolygon(userLocation, fencePolygon)) {
|
||||
isInside = true;
|
||||
break;
|
||||
@@ -146,18 +159,21 @@ export default function(db) {
|
||||
let minDistance = Infinity;
|
||||
for (const p of parsedPolygons) {
|
||||
const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' });
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
}
|
||||
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]);
|
||||
await db.execute(
|
||||
`INSERT INTO clock_records
|
||||
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
|
||||
VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
|
||||
[userId, qrCodeValue, latitude, longitude, note]
|
||||
);
|
||||
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. QR Code and Status Validation
|
||||
// 3) QR Code and Status Validation
|
||||
if (qrCodeValue !== 'FORCE_CLOCK_OUT') {
|
||||
const [qrRows] = await db.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]);
|
||||
if (qrRows.length === 0 || !qrRows[0].is_active) {
|
||||
@@ -165,65 +181,104 @@ export default function(db) {
|
||||
}
|
||||
}
|
||||
|
||||
const [lastEvent] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]);
|
||||
const [lastEvent] = await db.execute(
|
||||
'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
|
||||
// 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]
|
||||
`INSERT INTO clock_records
|
||||
(worker_id, event_type, qr_code_id, latitude, longitude, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIME())`,
|
||||
[userId, eventType, qrCodeValue, latitude, longitude]
|
||||
);
|
||||
res.status(201).json({ message: 'Clock event recorded.' });
|
||||
|
||||
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 {
|
||||
db.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/workers/:id', async (req, res) => {
|
||||
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.' });
|
||||
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();
|
||||
}
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
router.get('/worker/status/:userId', async (req, res) => {
|
||||
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' });
|
||||
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 { userId } = req.params;
|
||||
const [rows] = await db.execute(`
|
||||
SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName
|
||||
FROM clock_records cr
|
||||
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
|
||||
WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC
|
||||
`, [userId]);
|
||||
res.json(rows);
|
||||
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
|
||||
FROM clock_records cr
|
||||
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
|
||||
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 { userId } = req.user;
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ message: 'Invalid input.' });
|
||||
const db = await getConnection();
|
||||
try {
|
||||
const { userId } = req.user;
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ message: 'Invalid input.' });
|
||||
}
|
||||
const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId]);
|
||||
const passwordMatch = await bcrypt.compare(currentPassword, rows[0].password_hash);
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ message: 'Incorrect current password.' });
|
||||
}
|
||||
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();
|
||||
}
|
||||
const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId]);
|
||||
const passwordMatch = await bcrypt.compare(currentPassword, rows[0].password_hash);
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ message: 'Incorrect current password.' });
|
||||
}
|
||||
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.' });
|
||||
});
|
||||
|
||||
router.post('/location/update', async (req, res) => {
|
||||
@@ -232,30 +287,62 @@ export default function(db) {
|
||||
});
|
||||
|
||||
router.post('/device/register', async (req, res) => {
|
||||
const { userId, deviceUuid } = req.body;
|
||||
const result = await validateDeviceForUser(userId, deviceUuid, db);
|
||||
res.status(result.valid ? 200 : 409).json(result);
|
||||
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 { userId, deviceUuid } = req.body;
|
||||
const result = await validateDeviceForUser(userId, deviceUuid, db);
|
||||
res.json(result);
|
||||
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 { 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]);
|
||||
res.json({
|
||||
latestSecurityCheck: securityRows[0] || null,
|
||||
recentAlerts: alertRows,
|
||||
});
|
||||
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]);
|
||||
res.json({
|
||||
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 [rows] = await db.execute('SELECT package_name FROM app_blacklist');
|
||||
res.json(rows.map(row => row.package_name));
|
||||
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;
|
||||
|
||||
Generated
+896
-6
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"body-parser": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^5.1.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"json2csv": "^6.0.0-alpha.2",
|
||||
|
||||
+19
-3
@@ -1,11 +1,21 @@
|
||||
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 = {}) {
|
||||
const token = sessionStorage.getItem('token');
|
||||
|
||||
const defaultHeaders = {
|
||||
'ngrok-skip-browser-warning': 'true',
|
||||
'Content-Type': 'application/json',
|
||||
// Timezone header used by the backend to set session time_zone
|
||||
'X-User-Timezone': getUserTimezone(),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
@@ -42,12 +52,18 @@ export async function apiFetch(endpoint, options = {}) {
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
// Use the 'details' from our backend error structure, or the message, or a default
|
||||
throw new Error(errorData.details || errorData.message || `API call failed with status: ${response.status}`);
|
||||
throw new Error(
|
||||
errorData.details ||
|
||||
errorData.message ||
|
||||
`API call failed with status: ${response.status}`
|
||||
);
|
||||
} else {
|
||||
// If the server sends back HTML or plain text, use that as the error message.
|
||||
// This prevents the "Unexpected token '<'" error.
|
||||
const textError = await response.text();
|
||||
throw new Error(textError || `Server returned an unhandled error with status: ${response.status}`);
|
||||
throw new Error(
|
||||
textError || `Server returned an unhandled error with status: ${response.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +75,7 @@ export async function apiFetch(endpoint, options = {}) {
|
||||
// Handle file downloads like CSV
|
||||
const disposition = response.headers.get('content-disposition');
|
||||
if (disposition && disposition.includes('attachment')) {
|
||||
return response.blob();
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
|
||||
@@ -6,11 +6,17 @@
|
||||
{{ monthYear }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="prevMonth" 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>
|
||||
<button @click="prevMonth"
|
||||
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>
|
||||
</button>
|
||||
<button @click="nextMonth" 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>
|
||||
<button @click="nextMonth"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,16 +26,51 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<div v-for="day in calendarGrid" :key="day.id"
|
||||
@click="day.isCurrentMonth && onDayClick(day)"
|
||||
:class="getDayClasses(day)">
|
||||
<div v-for="day in calendarGrid" :key="day.id" @click="day.isCurrentMonth && onDayClick(day)"
|
||||
:class="getDayClasses(day)">
|
||||
{{ day.date }}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -52,10 +93,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-3">
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('applyChanges') }}
|
||||
</button>
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('discardChanges') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -64,7 +107,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue';
|
||||
import { apiFetch } from '@/api.js';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -73,20 +116,84 @@ const { t: $t } = useI18n();
|
||||
const toast = useToast();
|
||||
|
||||
const viewDate = ref(new Date());
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
// Server-driven "today" string (YYYY-MM-DD) for the yellow ring
|
||||
const todayStr = ref(null);
|
||||
|
||||
// --- timezone handling
|
||||
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
|
||||
const ymdInTZ = (tz, d = new Date()) =>
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d);
|
||||
|
||||
// Pull today from server; try /api/time then /time; fallback to client TZ
|
||||
async function getServerDate() {
|
||||
const parse = (data) => {
|
||||
if (typeof data?.ymdKL === 'string') return data.ymdKL;
|
||||
if (data?.nowIso) return ymdInTZ(TZ, new Date(data.nowIso));
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const path of ['/api/time', '/time']) {
|
||||
try {
|
||||
const d = await apiFetch(`${path}?_t=${Date.now()}`);
|
||||
const y = parse(d);
|
||||
if (y) return y;
|
||||
} catch (_err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('Server time unavailable; using client time.');
|
||||
return ymdInTZ(TZ, new Date());
|
||||
}
|
||||
|
||||
let _intervalId;
|
||||
onMounted(async () => {
|
||||
const update = async () => {
|
||||
todayStr.value = await getServerDate();
|
||||
};
|
||||
await update();
|
||||
_intervalId = setInterval(update, 60_000);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if (_intervalId) clearInterval(_intervalId);
|
||||
});
|
||||
|
||||
const originalEnabledDates = ref(new Set());
|
||||
const datesToEnable = ref(new Set());
|
||||
const datesToDisable = ref(new Set());
|
||||
|
||||
const hasPendingChanges = computed(() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0);
|
||||
const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort());
|
||||
const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort());
|
||||
const hasPendingChanges = computed(
|
||||
() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0
|
||||
);
|
||||
const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort());
|
||||
const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort());
|
||||
|
||||
const monthYear = computed(() => viewDate.value.toLocaleString('default', { month: 'long', year: 'numeric' }));
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const monthYear = computed(() =>
|
||||
viewDate.value.toLocaleString('default', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: TZ,
|
||||
})
|
||||
);
|
||||
|
||||
const calendarGrid = computed(() => {
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const calendarGrid = computed(() => {
|
||||
const year = viewDate.value.getFullYear();
|
||||
const month = viewDate.value.getMonth();
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||
@@ -104,12 +211,35 @@ 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 = [];
|
||||
|
||||
const getDayClasses = (day) => {
|
||||
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';
|
||||
|
||||
const dateStr = day.id;
|
||||
const classes = ['h-20', 'flex', 'items-center', 'justify-center', 'text-lg', 'rounded-lg', 'cursor-pointer', 'transition-colors', 'relative'];
|
||||
const classes = [
|
||||
'h-20',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-lg',
|
||||
'rounded-lg',
|
||||
'cursor-pointer',
|
||||
'transition-colors',
|
||||
'relative',
|
||||
];
|
||||
|
||||
let isEnabled = originalEnabledDates.value.has(dateStr);
|
||||
if (datesToEnable.value.has(dateStr)) isEnabled = true;
|
||||
@@ -122,21 +252,32 @@ const getDayClasses = (day) => {
|
||||
classes.push('bg-blue-500', 'text-white', 'font-bold');
|
||||
} else if (isPendingDisable) {
|
||||
classes.push('bg-red-200', 'dark:bg-red-800', 'text-red-700', 'dark:text-red-200');
|
||||
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]');
|
||||
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]'
|
||||
);
|
||||
} else if (isEnabled) {
|
||||
classes.push('bg-green-100', 'dark:bg-green-800', 'text-green-800', 'dark:text-green-200');
|
||||
} else {
|
||||
classes.push('bg-white', 'dark:bg-gray-800', 'hover:bg-gray-100', 'dark:hover:bg-gray-700');
|
||||
}
|
||||
|
||||
if (dateStr === todayStr) {
|
||||
if (todayStr.value && dateStr === todayStr.value) {
|
||||
classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500');
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
function onDayClick(day) {
|
||||
function onDayClick(day) {
|
||||
const dateStr = day.id;
|
||||
const isOriginallyEnabled = originalEnabledDates.value.has(dateStr);
|
||||
|
||||
@@ -150,9 +291,36 @@ function onDayClick(day) {
|
||||
: datesToEnable.value.add(dateStr);
|
||||
}
|
||||
}
|
||||
function enableAllCurrentMonth() {
|
||||
const dates = getCurrentMonthDateStrings();
|
||||
|
||||
async function applyChanges() {
|
||||
const confirmed = await toast.showConfirm($t('confirmApplyChanges'))
|
||||
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'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
@@ -177,12 +345,19 @@ function discardChanges() {
|
||||
datesToDisable.value.clear();
|
||||
}
|
||||
|
||||
const prevMonth = () => 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 prevMonth = () =>
|
||||
(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) => new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
});
|
||||
const formatDate = (dateStr) =>
|
||||
new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: TZ,
|
||||
});
|
||||
|
||||
async function fetchEnabledDates() {
|
||||
try {
|
||||
|
||||
@@ -4,28 +4,44 @@
|
||||
<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="flex flex-col gap-2">
|
||||
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName') }}</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')" />
|
||||
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName')
|
||||
}}</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')" />
|
||||
</div>
|
||||
<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>
|
||||
<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')" />
|
||||
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('username')
|
||||
}}</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')" />
|
||||
</div>
|
||||
<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>
|
||||
<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')" />
|
||||
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('password')
|
||||
}}</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')" />
|
||||
</div>
|
||||
<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>
|
||||
<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')" />
|
||||
<label for="department" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('department')
|
||||
}}</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')" />
|
||||
</div>
|
||||
<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>
|
||||
<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')" />
|
||||
<label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position')
|
||||
}}</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')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 invisible">{{ $t('addUser') }}</label>
|
||||
<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">
|
||||
<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">
|
||||
{{ loading ? $t('adding') : $t('addUser') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -37,21 +53,28 @@
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2>
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-end justify-between">
|
||||
<div class="flex-grow">
|
||||
<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" />
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate') }}</label>
|
||||
<input type="date" id="export-start-date" v-model="exportFilters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label>
|
||||
<input type="date" id="export-end-date" v-model="exportFilters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<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 class="flex flex-col gap-2">
|
||||
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('startDate') }}</label>
|
||||
<input type="date" id="export-start-date" v-model="exportFilters.startDate"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
|
||||
}}</label>
|
||||
<input type="date" id="export-end-date" v-model="exportFilters.endDate"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<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 class="overflow-x-auto">
|
||||
@@ -59,20 +82,40 @@
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||
<th class="w-12 px-2 py-3 text-center">
|
||||
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected" class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected"
|
||||
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 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>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<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">
|
||||
<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">
|
||||
<td class="px-2 py-3 text-center">
|
||||
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)" class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)"
|
||||
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||
</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>
|
||||
@@ -87,41 +130,61 @@
|
||||
{{ worker.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ new Date(worker.created_at).toLocaleDateString() }}</td>
|
||||
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
|
||||
<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>
|
||||
<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" />
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ formatLocalDate(worker.created_at) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
|
||||
<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>
|
||||
<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>
|
||||
{{ $t('settings') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="workers.length === 0">
|
||||
<td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400"> {{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
|
||||
<td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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">
|
||||
<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 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">
|
||||
<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">
|
||||
<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" />
|
||||
<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" />
|
||||
<span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="isSettingsModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||
<div v-if="isSettingsModalVisible"
|
||||
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="flex justify-between items-center mb-6">
|
||||
<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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -133,31 +196,65 @@
|
||||
|
||||
<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('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" />
|
||||
<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('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" />
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('changePassword') }}</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ $t('changePassword') }}
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<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" />
|
||||
<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="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" />
|
||||
<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 class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('workerStatus') }}</h4>
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('activeAccount') }}</p>
|
||||
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">
|
||||
{{ $t('workerStatus') }}
|
||||
</h4>
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ $t('activeAccount') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" v-model="editingWorker.isActive" class="sr-only peer">
|
||||
<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>
|
||||
<input type="checkbox" v-model="editingWorker.isActive" class="sr-only peer" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,19 +264,26 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<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">{{ $t('clearDeviceDescription') }}</p>
|
||||
<p class="text-xs text-red-600 dark:text-red-400/80">
|
||||
{{ $t('clearDeviceDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('clearDevice') }}
|
||||
</button>
|
||||
</div>
|
||||
<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">{{ $t('confirmClearDevice') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
{{ $t('confirmClearDevice') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('cancel') }}
|
||||
</button>
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,19 +294,26 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<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">{{ $t('deleteDescription') }}</p>
|
||||
<p class="text-xs text-red-600 dark:text-red-400/80">
|
||||
{{ $t('deleteDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</div>
|
||||
<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">{{ $t('confirmDelete') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
{{ $t('confirmDelete') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('cancel') }}
|
||||
</button>
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -211,25 +322,33 @@
|
||||
</div>
|
||||
|
||||
<div v-if="passwordErrorMessage || passwordSuccessMessage" class="text-center">
|
||||
<p v-if="passwordErrorMessage" class="text-red-500 text-sm">{{ passwordErrorMessage }}</p>
|
||||
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm">{{ passwordSuccessMessage }}</p>
|
||||
<p v-if="passwordErrorMessage" class="text-red-500 text-sm">
|
||||
{{ passwordErrorMessage }}
|
||||
</p>
|
||||
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm">
|
||||
{{ passwordSuccessMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
{{ passwordLoading ? $t('saving') : $t('saveChanges') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isConfirmModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||
<div v-if="isConfirmModalVisible"
|
||||
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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('cancel') }}
|
||||
</button>
|
||||
<button @click="executeConfirmedAction" class="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-md transition-colors">
|
||||
<button @click="executeConfirmedAction"
|
||||
class="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-md transition-colors">
|
||||
{{ $t('confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -248,11 +367,46 @@ import { useI18n } from 'vue-i18n';
|
||||
import { workerCache } from '@/utils/workerCache.js';
|
||||
|
||||
const { t: $t } = useI18n();
|
||||
|
||||
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) => {
|
||||
// Save current search state before navigating away
|
||||
const searchState = {
|
||||
searchQuery: searchQuery.value,
|
||||
currentPage: currentPage.value,
|
||||
@@ -260,7 +414,7 @@ const viewRecords = (workerId) => {
|
||||
totalWorkers: totalWorkers.value,
|
||||
workers: workers.value,
|
||||
selectedWorkerIds: selectedWorkerIds.value,
|
||||
exportFilters: exportFilters.value
|
||||
exportFilters: exportFilters.value,
|
||||
};
|
||||
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
|
||||
|
||||
@@ -271,7 +425,13 @@ const viewRecords = (workerId) => {
|
||||
const workers = ref([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const newWorker = ref({ fullName: '', username: '', password: '', department: '', position: '' });
|
||||
const newWorker = ref({
|
||||
fullName: '',
|
||||
username: '',
|
||||
password: '',
|
||||
department: '',
|
||||
position: '',
|
||||
});
|
||||
const searchQuery = ref('');
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
@@ -290,15 +450,20 @@ const confirmMessage = ref('');
|
||||
const isConfirmModalVisible = ref(false);
|
||||
const exportFilters = ref({ startDate: '', endDate: '' });
|
||||
const exportLoading = ref(false);
|
||||
// Removed workerStatusLoading as it's no longer needed with integrated save
|
||||
const showClearDeviceConfirm = ref(false);
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
// --- COMPUTED ---
|
||||
const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password);
|
||||
const isFormValid = computed(
|
||||
() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password
|
||||
);
|
||||
const totalPages = computed(() => {
|
||||
const pages = Math.ceil(totalWorkers.value / pageSize.value);
|
||||
return pages < 1 ? 1 : pages; // Ensure at least 1 page
|
||||
return pages < 1 ? 1 : pages;
|
||||
});
|
||||
const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length);
|
||||
const isAllSelected = computed(
|
||||
() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length
|
||||
);
|
||||
|
||||
// --- WATCHERS ---
|
||||
watch(searchQuery, () => fetchWorkers(1));
|
||||
@@ -311,18 +476,17 @@ watch(currentPage, (newPage) => {
|
||||
const fetchWorkers = async (page = currentPage.value) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`);
|
||||
const data = await apiFetch(
|
||||
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`
|
||||
);
|
||||
workers.value = data.workers;
|
||||
totalWorkers.value = data.totalCount;
|
||||
|
||||
// Cache worker data
|
||||
if (data.workers && Array.isArray(data.workers)) {
|
||||
data.workers.forEach(worker => {
|
||||
data.workers.forEach((worker) => {
|
||||
workerCache.storeWorkerData(worker.id, worker);
|
||||
});
|
||||
}
|
||||
|
||||
// currentPage is already set to the requested page before fetch
|
||||
} catch (_err) {
|
||||
errorMessage.value = 'Failed to fetch workers.';
|
||||
workers.value = [];
|
||||
@@ -355,12 +519,18 @@ const addWorker = async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
await apiFetch('/api/managers/workers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...newWorker.value, role: 'worker' }),
|
||||
});
|
||||
await apiFetch('/api/managers/workers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...newWorker.value, role: 'worker' }),
|
||||
});
|
||||
await fetchWorkers(1);
|
||||
newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' };
|
||||
newWorker.value = {
|
||||
fullName: '',
|
||||
username: '',
|
||||
password: '',
|
||||
department: '',
|
||||
position: '',
|
||||
};
|
||||
toast.showToast($t('workerAdded'), 'success');
|
||||
} catch (_err) {
|
||||
toast.showToast(_err.message || $t('addUserError'), 'error');
|
||||
@@ -376,7 +546,9 @@ const deleteWorker = async (id) => {
|
||||
try {
|
||||
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
|
||||
toast.showToast($t('workerSoftDeleted'), 'success');
|
||||
fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
|
||||
fetchWorkers(
|
||||
workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value
|
||||
);
|
||||
} catch (_err) {
|
||||
errorMessage.value = 'Failed to soft-delete worker.';
|
||||
}
|
||||
@@ -392,16 +564,15 @@ const clearDevice = async (workerId) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Renamed and refactored updateWorkerPassword to saveWorkerSettings
|
||||
const saveWorkerSettings = async () => {
|
||||
const toast = useToast();
|
||||
passwordErrorMessage.value = '';
|
||||
passwordSuccessMessage.value = '';
|
||||
let passwordUpdated = false;
|
||||
let detailsUpdated = false;
|
||||
toast.showToast($t('savingSettings'), 'info');
|
||||
|
||||
// Handle password change
|
||||
toast.showToast($t('savingSettings'), 'info');
|
||||
|
||||
if (newPassword.value || confirmNewPassword.value) {
|
||||
if (newPassword.value !== confirmNewPassword.value) {
|
||||
passwordErrorMessage.value = 'Passwords do not match.';
|
||||
@@ -414,16 +585,17 @@ const saveWorkerSettings = async () => {
|
||||
passwordUpdated = true;
|
||||
}
|
||||
|
||||
// Handle details change (status, department, position)
|
||||
const originalWorker = workers.value.find(w => w.id === editingWorker.value.id);
|
||||
const newStatus = editingWorker.value.isActive ? 'active' : 'inactive';
|
||||
if (
|
||||
originalWorker.status !== newStatus ||
|
||||
originalWorker.department !== editingWorker.value.department ||
|
||||
originalWorker.position !== editingWorker.value.position
|
||||
) {
|
||||
detailsUpdated = true;
|
||||
}
|
||||
const originalWorker = workers.value.find((w) => w.id === editingWorker.value.id);
|
||||
const newStatus = editingWorker.value.isActive ? 'active' : 'inactive';
|
||||
|
||||
if (
|
||||
originalWorker.status !== newStatus ||
|
||||
originalWorker.department !== editingWorker.value.department ||
|
||||
originalWorker.position !== editingWorker.value.position ||
|
||||
originalWorker.full_name !== editingWorker.value.fullName
|
||||
) {
|
||||
detailsUpdated = true;
|
||||
}
|
||||
|
||||
if (!passwordUpdated && !detailsUpdated) {
|
||||
passwordErrorMessage.value = 'No changes to save.';
|
||||
@@ -447,6 +619,7 @@ const saveWorkerSettings = async () => {
|
||||
status: newStatus,
|
||||
department: editingWorker.value.department,
|
||||
position: editingWorker.value.position,
|
||||
fullName: editingWorker.value.fullName,
|
||||
}),
|
||||
});
|
||||
if (passwordUpdated) {
|
||||
@@ -455,7 +628,6 @@ const saveWorkerSettings = async () => {
|
||||
passwordSuccessMessage.value = 'Worker details updated successfully!';
|
||||
}
|
||||
}
|
||||
|
||||
await fetchWorkers(currentPage.value);
|
||||
setTimeout(() => {
|
||||
closeSettingsModal();
|
||||
@@ -468,7 +640,11 @@ const saveWorkerSettings = async () => {
|
||||
};
|
||||
|
||||
const openSettingsModal = (worker) => {
|
||||
editingWorker.value = { ...worker, isActive: worker.status === 'active' }; // Initialize isActive for checkbox
|
||||
editingWorker.value = {
|
||||
...worker,
|
||||
fullName: worker.full_name,
|
||||
isActive: worker.status === 'active',
|
||||
};
|
||||
isSettingsModalVisible.value = true;
|
||||
};
|
||||
|
||||
@@ -483,9 +659,6 @@ const closeSettingsModal = () => {
|
||||
showDeleteConfirm.value = false;
|
||||
};
|
||||
|
||||
const showClearDeviceConfirm = ref(false);
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
const closeConfirmModal = () => {
|
||||
isConfirmModalVisible.value = false;
|
||||
confirmAction.value = '';
|
||||
@@ -510,28 +683,32 @@ const toggleWorkerSelection = (workerId) => {
|
||||
};
|
||||
|
||||
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 toast = useToast();
|
||||
exportLoading.value = true;
|
||||
toast.showToast($t('exportingRecords'), 'info');
|
||||
toast.showToast($t('exportingRecords'), 'info');
|
||||
const { startDate, endDate } = exportFilters.value;
|
||||
let workerIds = selectedWorkerIds.value.join(',');
|
||||
const workerIds = selectedWorkerIds.value.join(',');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, {
|
||||
const response = await fetch(
|
||||
`${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')}`
|
||||
}
|
||||
});
|
||||
Authorization: `Bearer ${sessionStorage.getItem('token')}`,
|
||||
'X-User-Timezone': getUserTimezone(),
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error('Network response was not ok.');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `work_hours_${startDate}_to_${endDate}.csv`;
|
||||
a.download = `work_hours_${startDate}_to_${endDate}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
@@ -539,12 +716,11 @@ const exportWorkHours = async () => {
|
||||
} catch (_err) {
|
||||
toast.showToast($t('exportRecordsFailed'), 'error');
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
exportLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Check if there's saved search state
|
||||
const savedSearchState = sessionStorage.getItem('personnelSearchState');
|
||||
if (savedSearchState) {
|
||||
try {
|
||||
@@ -556,11 +732,8 @@ onMounted(() => {
|
||||
workers.value = searchState.workers || [];
|
||||
selectedWorkerIds.value = searchState.selectedWorkerIds || [];
|
||||
exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' };
|
||||
|
||||
// Clear the saved search state after restoring it
|
||||
sessionStorage.removeItem('personnelSearchState');
|
||||
} catch (_e) {
|
||||
// If there's an error parsing the saved state, fetch workers normally
|
||||
fetchWorkers();
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -4,18 +4,24 @@
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('failedClockSummary') }}</h2>
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-end gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex-grow">
|
||||
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="flex items-end gap-4 flex-wrap">
|
||||
<div class="flex flex-col">
|
||||
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate') }}</label>
|
||||
<input type="date" id="start-date" v-model="filters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate')
|
||||
}}</label>
|
||||
<input type="date" id="start-date" v-model="filters.startDate"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate') }}</label>
|
||||
<input type="date" id="end-date" v-model="filters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate')
|
||||
}}</label>
|
||||
<input type="date" id="end-date" v-model="filters.endDate"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<button @click="fetchFailedRecords" :disabled="loadingReport" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
||||
<button @click="fetchFailedRecords" :disabled="loadingReport"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
||||
{{ loadingReport ? $t('loading') : $t('fetchRecords') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -24,23 +30,40 @@
|
||||
<table class="min-w-[700px] w-full text-left">
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<tr class="border-b-2 border-gray-200 dark:border-gray-600">
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer" @click="sortBy('full_name')">
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer"
|
||||
@click="sortBy('full_name')">
|
||||
{{ $t('worker') }}
|
||||
<span v-if="sortField === 'full_name'" class="ml-1">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
||||
<span v-if="sortField === 'full_name'" class="ml-1">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer text-center" @click="sortBy('count')">
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer text-center"
|
||||
@click="sortBy('count')">
|
||||
{{ $t('failedCount') }}
|
||||
<span v-if="sortField === 'count'" class="ml-1">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
||||
<span v-if="sortField === 'count'" class="ml-1">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-center">
|
||||
{{ $t('actions') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-center">{{ $t('actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="record in sortedFailedRecords" :key="record.worker_id" class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150">
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white font-medium">{{ record.full_name }}</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white text-center">{{ record.count }}</td>
|
||||
<tr v-for="record in sortedFailedRecords" :key="record.worker_id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150">
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white font-medium">
|
||||
{{ record.full_name }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white text-center">
|
||||
{{ record.count }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button @click="showDetails(record.worker_id, record.full_name)" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<button @click="showDetails(record.worker_id, record.full_name)"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ $t('viewDetails') }}
|
||||
</button>
|
||||
</td>
|
||||
@@ -53,9 +76,12 @@
|
||||
<tr v-if="loadingReport">
|
||||
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div class="flex justify-center items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
</svg>
|
||||
<span>{{ $t('loadingReport') }}</span>
|
||||
</div>
|
||||
@@ -69,8 +95,11 @@
|
||||
<div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<div class="flex justify-between items-center mb-4 border-b pb-3">
|
||||
<h3 class="text-xl font-semibold text-gray-800 dark:text-white">{{ detailModalTitle }}</h3>
|
||||
<button @click="showDetailModal = false" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 text-2xl leading-none">
|
||||
<h3 class="text-xl font-semibold text-gray-800 dark:text-white">
|
||||
{{ detailModalTitle }}
|
||||
</h3>
|
||||
<button @click="showDetailModal = false"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 text-2xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -78,22 +107,37 @@
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('timestamp') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('eventType') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('location') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('notes') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('timestamp') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('eventType') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('location') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('notes') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="detail in detailRecords" :key="detail.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ new Date(detail.timestamp).toLocaleString() }}</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">
|
||||
{{ detail.timestamp }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
{{ $t(detail.event_type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.qrCodeUsedName || $t('nA') }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.notes || $t('nA') }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
||||
{{ detail.qrCodeUsedName || $t('nA') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
||||
{{ detail.notes || $t('nA') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -112,6 +156,47 @@ import { useToast } from '@/composables/useToast';
|
||||
const { t: $t } = useI18n();
|
||||
const toast = useToast();
|
||||
|
||||
// --- timezone-aware formatter (local helper) ---
|
||||
const getUserTimezone = () => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur';
|
||||
} catch {
|
||||
return 'Asia_Kuala_Lumpur';
|
||||
}
|
||||
};
|
||||
|
||||
const formatLocalTimestamp = (utcValue) => {
|
||||
if (!utcValue) return '';
|
||||
const tz = getUserTimezone();
|
||||
|
||||
let iso = utcValue;
|
||||
|
||||
if (utcValue instanceof Date) {
|
||||
iso = utcValue.toISOString();
|
||||
} else if (typeof utcValue === 'string') {
|
||||
if (!iso.endsWith('Z')) {
|
||||
if (iso.includes('T')) {
|
||||
iso = iso + 'Z';
|
||||
} else {
|
||||
iso = iso.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const d = new Date(iso);
|
||||
|
||||
return d.toLocaleString(undefined, {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
};
|
||||
|
||||
// --- STATE ---
|
||||
const searchQuery = ref('');
|
||||
const filters = ref({ startDate: '', endDate: '' });
|
||||
@@ -151,7 +236,7 @@ const fetchFailedRecords = async () => {
|
||||
const url = `/api/managers/failed-records?search=${searchQuery.value}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
|
||||
failedRecords.value = await apiFetch(url);
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch failed records',_err);
|
||||
console.error('Failed to fetch failed records', _err);
|
||||
toast.showToast('Failed to fetch records.', 'error');
|
||||
} finally {
|
||||
loadingReport.value = false;
|
||||
@@ -174,7 +259,7 @@ const showDetails = async (workerId, workerName) => {
|
||||
detailRecords.value = await apiFetch(url);
|
||||
showDetailModal.value = true;
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch details',_err);
|
||||
console.error('Failed to fetch details', _err);
|
||||
toast.showToast('Failed to load details.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
+10
-2
@@ -3,15 +3,23 @@ console.log("[DEBUG] i18n.js loaded!"); // very top
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './locales/en.json';
|
||||
import ms from './locales/ms.json';
|
||||
import tm from './locales/tm.json';
|
||||
import bd from './locales/bd.json';
|
||||
import my from './locales/my.json';
|
||||
import np from './locales/np.json';
|
||||
|
||||
console.log("[DEBUG] en.json:", en);
|
||||
console.log("[DEBUG] ms.json:", ms);
|
||||
console.log("[DEBUG] tm.json:", tm);
|
||||
console.log("[DEBUG] bd.json:", bd);
|
||||
console.log("[DEBUG] my.json:", my);
|
||||
console.log("[DEBUG] np.json:", np);
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en', // Default to English
|
||||
locale: 'en', // keep original; App.vue will override from localStorage
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, ms }
|
||||
messages: { en, ms, tm, bd, my, np } // register all locales here
|
||||
});
|
||||
|
||||
console.log("[DEBUG] i18n instance created:", i18n);
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"appTitle": "উপস্থিতি ব্যবস্থা",
|
||||
"logout": "লগ আউট",
|
||||
"login": "লগ ইন",
|
||||
"username": "ইউজারনেম",
|
||||
"password": "পাসওয়ার্ড",
|
||||
"loggingIn": "লগ ইন করা হচ্ছে...",
|
||||
"language": "ভাষা",
|
||||
"darkMode": "ডার্ক মোড",
|
||||
"toggleDarkMode": "হালকা এবং অন্ধকার থিমের মধ্যে পরিবর্তন করুন",
|
||||
"failedConnection": "সার্ভারের সাথে সংযোগ করতে পারেনি।",
|
||||
"invalidToken": "সার্ভার থেকে অবৈধ টোকেন পাওয়া গেছে।",
|
||||
"invalidCredentials": "ভুল ইউজারনেম বা পাসওয়ার্ড।",
|
||||
"english": "ইংরেজি",
|
||||
"malay": "মালয়",
|
||||
"tamil": "তামিল",
|
||||
"bengali": "বাংলা",
|
||||
"burmese": "বর্মী",
|
||||
"nepali": "নেপালি",
|
||||
|
||||
"yourStatus": "স্ট্যাটাস",
|
||||
"clockedIn": "উপস্থিতি রেকর্ড হয়েছে",
|
||||
"clockedOut": "প্রস্থান রেকর্ড হয়েছে",
|
||||
"clockIn": "উপস্থিতি রেকর্ড",
|
||||
"clockOut": "প্রস্থান রেকর্ড",
|
||||
"clock_in": "উপস্থিতি রেকর্ড",
|
||||
"clock_out": "প্রস্থান রেকর্ড",
|
||||
|
||||
"scanToClock": "{action} রেকর্ড করতে স্ক্যান করুন",
|
||||
"in": "উপস্থিতি",
|
||||
"out": "প্রস্থান",
|
||||
"cancel": "বাতিল",
|
||||
|
||||
"viewMyClockHistory": "আমার উপস্থিতির ইতিহাস দেখুন",
|
||||
"changeMyPassword": "আমার পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"myClockHistory": "আমার উপস্থিতির ইতিহাস",
|
||||
"backToDashboard": "ড্যাশবোর্ডে ফিরে যান",
|
||||
"noClockHistory": "আপনার কোনো উপস্থিতির ইতিহাস নেই।",
|
||||
"clockHistoryFetchFail": "উপস্থিতির ইতিহাস পেতে পারেনি:",
|
||||
"viewClockHistory": "আমার উপস্থিতির ইতিহাস দেখুন",
|
||||
"changePassword": "আমার পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"invalidCurrentPassword": "বর্তমান পাসওয়ার্ড ভুল।",
|
||||
|
||||
"successClockIn": "সফলভাবে উপস্থিতি রেকর্ড হয়েছে।",
|
||||
"successClockOut": "সফলভাবে প্রস্থান রেকর্ড হয়েছে।",
|
||||
"qrFail": "QR কোড খুঁজে পাওয়া যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।",
|
||||
"geoFail": "আপনার অবস্থান খুঁজে পাওয়া যায়নি: {message}। অনুগ্রহ করে লোকেশন সার্ভিস চালু করুন।",
|
||||
"successClock": "{location} এ সফলভাবে রেকর্ড হয়েছে।",
|
||||
"changePasswordTitle": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"currentPassword": "বর্তমান পাসওয়ার্ড",
|
||||
"newPassword": "নতুন পাসওয়ার্ড",
|
||||
"confirmNewPassword": "নতুন পাসওয়ার্ড নিশ্চিত করুন",
|
||||
"updating": "আপডেট করা হচ্ছে...",
|
||||
|
||||
"tabPersonnel": "কর্মী",
|
||||
"tabAttendance": "উপস্থিতি",
|
||||
"tabQrCodes": "QR কোড",
|
||||
"uploadQrImage": "QR ছবি আপলোড করুন",
|
||||
|
||||
"couldNotLoadWorkerInfo": "কর্মীর তথ্য লোড করতে পারেনি",
|
||||
"couldNotVerifyStatus": "সার্ভার থেকে বর্তমান স্ট্যাটাস যাচাই করতে পারেনি",
|
||||
"successfullyClocked": "সফলভাবে {action} রেকর্ড হয়েছে",
|
||||
"site": "স্থানে",
|
||||
"errorOccurred": "একটি ত্রুটি ঘটেছে",
|
||||
"unableToStartCamera": "ক্যামেরা চালু করতে পারেনি।",
|
||||
"tryAgain": "আবার চেষ্টা করুন",
|
||||
"qrDetectedGettingLocation": "QR কোড পাওয়া গেছে। অবস্থান খুঁজে বের করা হচ্ছে...",
|
||||
"geolocationNotSupported": "আপনার ব্রাউজার জিওলোকেশন সাপোর্ট করে না।",
|
||||
"unableToRetrieveLocation": "আপনার অবস্থান খুঁজে পাওয়া যায়নি: {message}। অনুগ্রহ করে লোকেশন সার্ভিস চালু করুন।",
|
||||
"qrNotDetectedTryAgain": "QR কোড খুঁজে পাওয়া যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।",
|
||||
"updatePassword": "পাসওয়ার্ড আপডেট করুন",
|
||||
"passwordsNoMatch": "নতুন পাসওয়ার্ড মিলছে না।",
|
||||
"passwordTooShort": "নতুন পাসওয়ার্ড কমপক্ষে ৬ অক্ষরের হতে হবে।",
|
||||
"passwordUpdated": "পাসওয়ার্ড সফলভাবে আপডেট হয়েছে! এখন আপনি নতুন পাসওয়ার্ড দিয়ে লগ ইন করতে পারবেন।",
|
||||
"passwordUpdateError": "পাসওয়ার্ড আপডেট করার সময় একটি ত্রুটি হয়েছে।",
|
||||
|
||||
"attendanceLogFor": "উপস্থিতির রেকর্ড -",
|
||||
"addManualClockOut": "ম্যানুয়াল প্রস্থান রেকর্ড যোগ করুন",
|
||||
"manualClockOutInstruction": "কর্মী প্রস্থান রেকর্ড করতে ভুলে গেলে এই ফর্মটি ব্যবহার করুন। শেষ ইভেন্ট অবশ্যই উপস্থিতি রেকর্ড হতে হবে।",
|
||||
"clockOutTime": "প্রস্থানের সময়",
|
||||
"reason": "কারণ (যেমন, \"প্রস্থান রেকর্ড করতে ভুলে গেছেন\")",
|
||||
"enterBriefNote": "সংক্ষিপ্ত নোট লিখুন",
|
||||
"addRecord": "রেকর্ড যোগ করুন",
|
||||
|
||||
"startDate": "শুরুর তারিখ",
|
||||
"endDate": "শেষ তারিখ",
|
||||
"filterRecords": "রেকর্ড ফিল্টার করুন",
|
||||
"event": "ইভেন্ট",
|
||||
"timestamp": "সময়ের স্ট্যাম্প",
|
||||
"locationName": "স্থানের নাম",
|
||||
"coordinates": "স্থানাঙ্ক",
|
||||
"notes": "নোট",
|
||||
"noRecordsFound": "এই সময়ের জন্য কোনো রেকর্ড পাওয়া যায়নি।",
|
||||
"showOnMap": "মানচিত্রে দেখুন",
|
||||
"nA": "নেই",
|
||||
"pleaseSelectTimestamp": "অনুগ্রহ করে প্রস্থানের জন্য সময়ের স্ট্যাম্প নির্বাচন করুন।",
|
||||
"pleaseProvideReason": "অনুগ্রহ করে ম্যানুয়াল এন্ট্রির জন্য কারণ/নোট প্রদান করুন।",
|
||||
"manualClockOutSuccess": "ম্যানুয়াল প্রস্থান সফলভাবে রেকর্ড হয়েছে!",
|
||||
"manualClockOutError": "একটি ত্রুটি ঘটেছে: {message}",
|
||||
|
||||
"selectWorkers": "১. কর্মী নির্বাচন করুন",
|
||||
"searchWorkerPlaceholder": "একজন কর্মী খুঁজুন...",
|
||||
"selectAll": "সব নির্বাচন করুন",
|
||||
"addWorkersByTag": "একটি ট্যাগ থেকে সব কর্মী যোগ করুন",
|
||||
"chooseTag": "-- একটি ট্যাগ বেছে নিন --",
|
||||
"addByTag": "ট্যাগ দ্বারা যোগ করুন",
|
||||
"selectedForReport": "রিপোর্টের জন্য নির্বাচিত ({count})",
|
||||
"allWorkersSelected": "সব কর্মী ({count}) নির্বাচিত",
|
||||
"noWorkersSelected": "কোনো কর্মী নির্বাচিত নয়।",
|
||||
"reportSettings": "২. রিপোর্ট সেটিংস",
|
||||
"setting": "সেটিং",
|
||||
"monthlySalary": "মাসিক বেতন (RM)",
|
||||
"salaryAppliedNote": "নির্বাচিত সব কর্মীর জন্য প্রযোজ্য।",
|
||||
"salaryPlaceholder": "যেমন, ৩০০০",
|
||||
"otFactors": "ওভারটাইম ফ্যাক্টর",
|
||||
"weekendFactor": "সাপ্তাহিক ছুটির ফ্যাক্টর",
|
||||
"holidayFactor": "ছুটির দিনের ফ্যাক্টর",
|
||||
"selectPublicHolidays": "সরকারি ছুটির দিন নির্বাচন করুন",
|
||||
"generateReport": "উপস্থিতি ও ওভারটাইম রিপোর্ট তৈরি করুন",
|
||||
"overtimePaySummary": "ওভারটাইম বেতনের সারসংক্ষেপ",
|
||||
"exportOtSummary": "ওভারটাইম সারসংক্ষেপ রপ্তানি করুন (CSV)",
|
||||
"worker": "কর্মী",
|
||||
"totalHoursWorked": "মোট কাজের ঘন্টা",
|
||||
"totalOtPay": "মোট ওভারটাইম বেতন (RM)",
|
||||
"rawAttendanceData": "কাঁচা উপস্থিতির তথ্য",
|
||||
"loadingReport": "রিপোর্ট লোড করা হচ্ছে...",
|
||||
"tagLoadError": "নির্বাচিত ট্যাগের জন্য কর্মীদের লোড করতে পারেনি।",
|
||||
"generateReportError": "অনুগ্রহ করে কর্মী নির্বাচন করুন, সঠিক তারিখের সীমা নির্ধারণ করুন এবং বেতন লিখুন।",
|
||||
"reportGenerationError": "রিপোর্ট তৈরি করার সময় একটি ত্রুটি ঘটেছে।",
|
||||
|
||||
"addNewUser": "নতুন ইউজার যোগ করুন",
|
||||
"fullName": "পূর্ণ নাম",
|
||||
"egJohnSmith": "যেমন John Smith",
|
||||
"egJsmith": "যেমন jsmith",
|
||||
"eg123456": "যেমন ১২৩৪৫৬",
|
||||
"asManager": "ম্যানেজার হিসেবে",
|
||||
"adding": "যোগ করা হচ্ছে...",
|
||||
"addUser": "ইউজার যোগ করুন",
|
||||
"manageTags": "ট্যাগ পরিচালনা করুন",
|
||||
"createNewTag": "নতুন ট্যাগ তৈরি করুন",
|
||||
"egTeam": "যেমন টিম",
|
||||
"createTag": "ট্যাগ তৈরি করুন",
|
||||
"tags": "ট্যাগ",
|
||||
"workerRoster": "কর্মীদের তালিকা",
|
||||
"searchByNameOrUsername": "নাম বা ইউজারনেম দিয়ে খুঁজুন",
|
||||
"filterByTag": "ট্যাগ দিয়ে ফিল্টার করুন",
|
||||
"clearFilter": "ফিল্টার পরিষ্কার করুন",
|
||||
"dateJoined": "যোগদানের তারিখ",
|
||||
"actions": "কার্যক্রম",
|
||||
"editTags": "ট্যাগ সম্পাদনা করুন",
|
||||
"viewRecords": "রেকর্ড দেখুন",
|
||||
"delete": "মুছে ফেলুন",
|
||||
"loadingWorkers": "কর্মীরা লোড হচ্ছে...",
|
||||
"noWorkersFound": "কোনো কর্মী পাওয়া যায়নি।",
|
||||
"previous": "পূর্ববর্তী",
|
||||
"next": "পরবর্তী",
|
||||
"pageOf": "পৃষ্ঠা {current} এর {total}",
|
||||
"noTagsAvailable": "কোনো ট্যাগ উপলব্ধ নেই।",
|
||||
"done": "সম্পন্ন",
|
||||
"bulkEditTags": "একাধিক ট্যাগ সম্পাদনা করুন",
|
||||
"clearSelection": "নির্বাচন পরিষ্কার করুন",
|
||||
"forUser": "ইউজারের জন্য",
|
||||
"savePassword": "পাসওয়ার্ড সেভ করুন",
|
||||
"saving": "সেভ করা হচ্ছে...",
|
||||
"failedToUpdateTags": "ট্যাগ আপডেট করতে পারেনি। অনুগ্রহ করে আবার চেষ্টা করুন।",
|
||||
"tagDeleted": "ট্যাগ সফলভাবে মুছে ফেলা হয়েছে।",
|
||||
"failedToFetchWorkers": "কর্মীদের তথ্য পেতে পারেনি।",
|
||||
"failedToLoadPageData": "পেজের তথ্য লোড করতে পারেনি।",
|
||||
"errorAddingUser": "ইউজার যোগ করার সময় একটি ত্রুটি ঘটেছে।",
|
||||
"failedToDeleteWorker": "কর্মী মুছে ফেলতে পারেনি।",
|
||||
"areYouSureDeleteWorker": "আপনি কি নিশ্চিত এই কর্মীর অ্যাকাউন্ট মুছে ফেলতে চান?",
|
||||
"areYouSureDeleteTag": "আপনি কি নিশ্চিত এই ট্যাগটি মুছে ফেলতে চান? এটি সব কর্মী থেকে সরিয়ে দেওয়া হবে।",
|
||||
"failedToDeleteTag": "ট্যাগ মুছে ফেলতে পারেনি।",
|
||||
"passwordsDoNotMatch": "পাসওয়ার্ড মিলছে না।",
|
||||
"createQrCode": "নতুন QR কোড তৈরি করুন",
|
||||
"qrCodeName": "QR কোডের নাম",
|
||||
"qrNamePlaceholder": "যেমন, 'পশ্চিম গেট প্রবেশদ্বার'",
|
||||
"create": "তৈরি করুন",
|
||||
"newCodeCreated": "নতুন কোড তৈরি হয়েছে!",
|
||||
"saveQrInstruction": "এই ছবিটি সেভ করুন বা নিচের ID ব্যবহার করুন। এটি রিফ্রেশে অদৃশ্য হয়ে যাবে।",
|
||||
"id": "আইডি",
|
||||
"existingQrCodes": "বিদ্যমান QR কোড",
|
||||
"name": "নাম",
|
||||
"status": "স্ট্যাটাস",
|
||||
"active": "সক্রিয়",
|
||||
"inactive": "নিষ্ক্রিয়",
|
||||
"deactivate": "নিষ্ক্রিয় করুন",
|
||||
"activate": "সক্রিয় করুন",
|
||||
"download": "ডাউনলোড",
|
||||
"noQrCodesFound": "কোনো QR কোড পাওয়া যায়নি। উপরে একটি তৈরি করুন!",
|
||||
"deleteQrConfirm": "আপনি কি নিশ্চিত এই QR কোডটি মুছে ফেলতে চান? এটি পূর্বাবস্থায় ফেরানো যাবে না।",
|
||||
"qrDownloadError": "দুঃখিত, QR কোড ডাউনলোড করা যায়নি।",
|
||||
|
||||
"rememberMe": "অটো লগইনের জন্য আমাকে মনে রাখুন",
|
||||
"deviceNotAuthorized": "এই ডিভাইসটি আপনার অ্যাকাউন্টের জন্য অনুমোদিত নয়। অনুগ্রহ করে আপনার অ্যাডমিনিস্ট্রেটরের সাথে যোগাযোগ করুন।",
|
||||
"locationTrackingActive": "লোকেশন ট্র্যাকিং পেছনে চালু আছে",
|
||||
"securityCheckInProgress": "নিরাপত্তা যাচাই চলছে...",
|
||||
"securityCheckComplete": "নিরাপত্তা যাচাই সফলভাবে সম্পন্ন",
|
||||
"highSecurityRisk": "উচ্চ নিরাপত্তা ঝুঁকি শনাক্ত। অনুগ্রহ করে আপনার অ্যাডমিনিস্ট্রেটরের সাথে যোগাযোগ করুন।",
|
||||
"deviceRegistered": "ডিভাইস সফলভাবে নিবন্ধিত হয়েছে",
|
||||
"autoLoginEnabled": "এই ডিভাইসের জন্য অটো লগইন চালু করা হয়েছে",
|
||||
"backgroundLocationEnabled": "পেছনের লোকেশন ট্র্যাকিং চালু করা হয়েছে",
|
||||
"permissionsRequired": "উপস্থিতি ট্র্যাকিংয়ের জন্য লোকেশন অনুমতি প্রয়োজন",
|
||||
"batteryOptimizationWarning": "অবিরাম লোকেশন ট্র্যাকিং নিশ্চিত করতে এই অ্যাপের জন্য ব্যাটারি অপটিমাইজেশন বন্ধ করুন",
|
||||
"gpsSpooferDetected": "GPS স্পুফিং অ্যাপ্লিকেশন শনাক্ত হয়েছে। এটি উপস্থিতির নির্ভুলতা প্রভাবিত করতে পারে।",
|
||||
"mockLocationEnabled": "মক লোকেশন চালু আছে। নির্ভুল উপস্থিতি ট্র্যাকিংয়ের জন্য এটি বন্ধ করুন।",
|
||||
"deviceSecurityWarning": "ডিভাইস নিরাপত্তা সতর্কতা: সন্দেহজনক অ্যাপ্লিকেশন শনাক্ত",
|
||||
"locationUpdateFailed": "লোকেশন আপডেট করতে পারেনি। স্বয়ংক্রিয়ভাবে আবার চেষ্টা করা হবে।",
|
||||
"servicesInitializing": "নেটিভ সার্ভিস চালু করা হচ্ছে...",
|
||||
"servicesReady": "সব সার্ভিস প্রস্তুত",
|
||||
"autoLoginFailed": "অটো লগইন ব্যর্থ। অনুগ্রহ করে ম্যানুয়ালি লগ ইন করুন।",
|
||||
"deviceValidationFailed": "ডিভাইস ভেরিফিকেশন ব্যর্থ। অনুগ্রহ করে সাপোর্টের সাথে যোগাযোগ করুন।",
|
||||
"deviceMismatch": "এই ডিভাইসটি আপনার অ্যাকাউন্টের জন্য অনুমোদিত নয়।",
|
||||
"deviceRegistrationFailed": "ডিভাইস নিবন্ধন ব্যর্থ। আবার চেষ্টা করুন।",
|
||||
"deviceRequired": "কর্মী লগইনের জন্য ডিভাইস নিবন্ধন প্রয়োজন।",
|
||||
|
||||
"servicesStatus": "সার্ভিসের স্ট্যাটাস",
|
||||
"overallStatus": "সামগ্রিক স্ট্যাটাস",
|
||||
"locationTracking": "লোকেশন ট্র্যাকিং",
|
||||
"deviceRegistration": "ডিভাইস নিবন্ধন",
|
||||
"securityStatus": "নিরাপত্তার স্ট্যাটাস",
|
||||
"lastLocationUpdate": "শেষ লোকেশন আপডেট",
|
||||
"deviceId": "ডিভাইস আইডি",
|
||||
"start": "শুরু করুন",
|
||||
"check": "যাচাই করুন",
|
||||
"checking": "যাচাই করা হচ্ছে...",
|
||||
"refresh": "রিফ্রেশ করুন",
|
||||
"refreshing": "রিফ্রেশ করা হচ্ছে...",
|
||||
"notInitialized": "চালু করা হয়নি",
|
||||
"ready": "প্রস্তুত",
|
||||
"webOnly": "শুধু ওয়েব",
|
||||
"registered": "নিবন্ধিত",
|
||||
"pending": "অপেক্ষমান",
|
||||
"notChecked": "যাচাই করা হয়নি",
|
||||
"outdated": "পুরোনো",
|
||||
"current": "বর্তমান",
|
||||
"never": "কখনো না",
|
||||
"justNow": "এইমাত্র",
|
||||
"minutesAgo": "{minutes} মিনিট আগে",
|
||||
"hoursAgo": "{hours} ঘন্টা আগে",
|
||||
"daysAgo": "{days} দিন আগে",
|
||||
"failedToRefreshStatus": "স্ট্যাটাস রিফ্রেশ করতে পারেনি",
|
||||
"locationTrackingStarted": "লোকেশন ট্র্যাকিং সফলভাবে শুরু হয়েছে",
|
||||
"failedToStartLocationTracking": "লোকেশন ট্র্যাকিং শুরু করতে পারেনি",
|
||||
"securityCheckFailed": "নিরাপত্তা যাচাই ব্যর্থ",
|
||||
|
||||
"personal": "ব্যক্তিগত",
|
||||
"clockHistory": "উপস্থিতির ইতিহাস",
|
||||
"openCamera": "ক্যামেরা খুলুন",
|
||||
"scanQRCode": "QR কোড স্ক্যান করুন",
|
||||
"services": "সার্ভিস",
|
||||
"systemServicesStatus": "সিস্টেম সার্ভিস এবং নিরাপত্তার স্ট্যাটাস",
|
||||
"updateYourPassword": "আপনার অ্যাকাউন্টের পাসওয়ার্ড আপডেট করুন",
|
||||
"signOutOfAccount": "আপনার অ্যাকাউন্ট থেকে সাইন আউট করুন",
|
||||
|
||||
"workLocationTracking": "কর্মক্ষেত্রের লোকেশন ট্র্যাকিং",
|
||||
"locationTrackingForAttendance": "কাজের উপস্থিতির জন্য লোকেশন ট্র্যাকিং সক্রিয়",
|
||||
"monitoringLocation": "কাজের উপস্থিতির জন্য লোকেশন নিরীক্ষণ করা হচ্ছে",
|
||||
|
||||
"manualGuide": "ম্যানুয়াল গাইড ",
|
||||
"viewUserManual": "নির্দেশাবলী এবং FAQs পড়ুন",
|
||||
"manual": {
|
||||
"android": {
|
||||
"heading": "Android",
|
||||
"faqs": [
|
||||
{
|
||||
"id": "android-location",
|
||||
"title": "Location কীভাবে খুলবেন (Android)",
|
||||
"steps": [
|
||||
"আপনার ফোনে <strong>Settings</strong> খুলুন।",
|
||||
"<strong>Location</strong> এ যান <span class=\"text-sm text-gray-500\">(কিছু ফোনে <em>Security & privacy</em> এর অধীনে)</span>।",
|
||||
"<strong>Use location</strong> ON করুন।",
|
||||
"<strong>App permissions</strong> খুলুন → <strong>Attendance System</strong> খুঁজুন → <strong>Allow while using the app</strong> সেট করুন।",
|
||||
"উপলব্ধ থাকলে <strong>Precise location</strong> সক্রিয় করুন।",
|
||||
"App এ ফিরে যান এবং আবার clock-in করার চেষ্টা করুন।"
|
||||
],
|
||||
"note": "ব্র্যান্ড অনুযায়ী নাম ভিন্ন: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services।"
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Camera permission সক্রিয় করুন (Android)",
|
||||
"steps": [
|
||||
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> খুলুন।",
|
||||
"<strong>Permissions</strong> → <strong>Camera</strong> ট্যাপ করুন → <strong>Allow</strong> অথবা <strong>Allow while using the app</strong> নির্বাচন করুন।",
|
||||
"App পুনরায় খুলুন এবং আবার scanning এর চেষ্টা করুন।"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in কাজ করছে না? দ্রুত checklist",
|
||||
"steps": [
|
||||
"<strong>Location</strong> ON করুন এবং app permission <strong>Allow while using the app</strong> সেট করুন (উপলব্ধ থাকলে <strong>Precise location</strong> সক্রিয় করুন)।",
|
||||
"Network পরীক্ষা করুন: Wi-Fi অথবা data চালু আছে। <strong>Airplane mode</strong> off→on toggle করুন, তারপর আবার চেষ্টা করুন। হস্তক্ষেপ করলে VPN নিষ্ক্রিয় করুন।",
|
||||
"নিশ্চিত করুন <strong>Automatic date & time</strong> এবং <strong>time zone</strong> Android settings এ সক্রিয় আছে।",
|
||||
"Force close করুন এবং app পুনরায় খুলুন। প্রয়োজনে, <strong>Attendance System</strong> cache clear করুন (Settings → Apps → Attendance System → Storage → Clear cache)।"
|
||||
],
|
||||
"note": "এখনও আটকে আছেন? একটি screenshot নিন এবং আপনার manager বা HR এর সাথে যোগাযোগ করুন।"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "শীঘ্রই আসছে।"
|
||||
}
|
||||
},
|
||||
"statusClockedIn": "আপনি ক্লক ইন করেছেন",
|
||||
"statusClockedOut": "আপনি ক্লক আউট করেছেন",
|
||||
"scanToClockIn": "ক্লক ইন করতে QR কোড স্ক্যান করুন",
|
||||
"scanToClockOut": "ক্লক আউট করতে QR কোড স্ক্যান করুন",
|
||||
"appInformation": "অ্যাপ তথ্য",
|
||||
"version": "সংস্করণ",
|
||||
"platform": "প্ল্যাটফর্ম",
|
||||
"web": "ওয়েব"
|
||||
}
|
||||
|
||||
+57
-2
@@ -13,7 +13,6 @@
|
||||
"english": "English",
|
||||
"malay": "Bahasa Melayu",
|
||||
"setting": "Setting",
|
||||
"settings": "Settings",
|
||||
"appInformation": "App Information",
|
||||
"version": "Version",
|
||||
"platform": "Platform",
|
||||
@@ -308,5 +307,61 @@
|
||||
"deleteManagerFailed": "Failed to delete manager",
|
||||
"openCamera": "Open Camera",
|
||||
"scanQRCode": "Scan QR Code",
|
||||
"signOutOfAccount": "Sign Out of Account"
|
||||
"signOutOfAccount": "Sign Out of Account",
|
||||
"darkMode": "Dark Mode",
|
||||
"enableDarkMode": "Enable dark mode",
|
||||
"disableDarkMode": "Disable dark mode",
|
||||
"tamil": "Tamil",
|
||||
"bengali": "Bengali",
|
||||
"burmese": "Burmese",
|
||||
"nepali": "Nepali",
|
||||
|
||||
"manualGuide": "Manual Guide",
|
||||
"viewUserManual": "Read instructions and FAQs",
|
||||
|
||||
"manual": {
|
||||
"android": {
|
||||
"heading": "Android",
|
||||
"faqs": [
|
||||
{
|
||||
"id": "android-location",
|
||||
"title": "How to open location (Android)",
|
||||
"steps": [
|
||||
"Open <strong>Settings</strong> on your phone.",
|
||||
"Go to <strong>Location</strong> <span class=\"text-sm text-gray-500\">(on some phones under <em>Security & privacy</em>)</span>.",
|
||||
"Turn <strong>Use location</strong> ON.",
|
||||
"Open <strong>App permissions</strong> → find <strong>Nilai Clock</strong> → set to <strong>Allow while using the app</strong>.",
|
||||
"Enable <strong>Precise location</strong> if available.",
|
||||
"Return to the app and try clock-in again."
|
||||
],
|
||||
"note": "Names vary by brand: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services."
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Enable camera permission (Android)",
|
||||
"steps": [
|
||||
"Open <strong>Settings</strong> → <strong>Apps</strong> → <strong>Nilai Clock</strong>.",
|
||||
"Tap <strong>Permissions</strong> → <strong>Camera</strong> → choose <strong>Allow</strong> or <strong>Allow while using the app</strong>.",
|
||||
"Reopen the app and try scanning again."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in not working? Quick checklist",
|
||||
"steps": [
|
||||
"Turn <strong>Location</strong> ON and set app permission to <strong>Allow while using the app</strong> (enable <strong>Precise location</strong> if available).",
|
||||
"Check network: Wi-Fi or data is on. Toggle <strong>Airplane mode</strong> off→on, then retry. Disable VPN if it interferes.",
|
||||
"Ensure <strong>Automatic date & time</strong> and <strong>time zone</strong> are enabled in Android settings.",
|
||||
"Force close and reopen the app. If needed, clear <strong>Attendance System</strong> cache (Settings → Apps → Nilai Clock → Storage → Clear cache)."
|
||||
],
|
||||
"note": "Still stuck? Take a screenshot and contact your manager or HR."
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "Coming soon."
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+63
-7
@@ -13,7 +13,6 @@
|
||||
"english": "English",
|
||||
"malay": "Bahasa Melayu",
|
||||
"setting": "Tetapan",
|
||||
"settings": "Tetapan",
|
||||
"appInformation": "Maklumat Aplikasi",
|
||||
"version": "Versi",
|
||||
"platform": "Platform",
|
||||
@@ -220,7 +219,7 @@
|
||||
"existingGeofences": "Kawasan Disimpan",
|
||||
"view": "Lihat",
|
||||
"noGeofencesFound": "Tiada Geofences Dijumpai",
|
||||
"startOver" : "Mula Semula",
|
||||
"startOver": "Mula Semula",
|
||||
|
||||
"workScheduleTitle": "Jadual Kerja",
|
||||
"workScheduleDescription": "Klik pada tarikh untuk menukar statusnya. Hari yang didayakan berwarna hijau. Perubahan tidak akan disimpan sehingga anda mengklik 'Guna Perubahan'.",
|
||||
@@ -235,7 +234,7 @@
|
||||
"statusClockedOut": "Anda Sudah Keluar Kerja",
|
||||
"scanToClockIn": "Imbas QR untuk Masuk Kerja",
|
||||
"scanToClockOut": "Imbas QR untuk Keluar Kerja",
|
||||
|
||||
|
||||
"error.default": "Ralat tidak dijangka telah berlaku. Sila cuba lagi.",
|
||||
"error.clockingDisabled": "Fungsi masuk/keluar kerja dilumpuhkan untuk hari ini. Percubaan anda telah direkodkan.",
|
||||
"error.noActiveGeofence": "Gagal masuk/keluar: Tiada kawasan kerja aktif yang ditetapkan pada pelayan.",
|
||||
@@ -248,7 +247,7 @@
|
||||
"dangerZone": "Zon Bahaya",
|
||||
"clearDeviceDescription": "Nyahpaut Akaun dengan Peranti.",
|
||||
"settings": "Tetapan",
|
||||
"employeeSettings": "Tetapan Pekerja",
|
||||
"employeeSettings": "Tetapan Pekerja",
|
||||
"accountSettings": "Tetapan Akaun",
|
||||
"workerStatus": "Status Akaun",
|
||||
"activeAccount": "Benarkan Log Masuk",
|
||||
@@ -268,9 +267,9 @@
|
||||
"manager_permissions": "Kebenaran Pentadbir",
|
||||
"confirmDelete": "Adakah anda pasti mahu memadam ini?",
|
||||
"confirm": "Sahkan",
|
||||
|
||||
|
||||
"can_view_workers": "Lihat Pekerja",
|
||||
"can_edit_workers": "Urus Pekerja",
|
||||
"can_edit_workers": "Urus Pekerja",
|
||||
"can_view_alerts": "Lihat Amaran",
|
||||
"can_view_geofences": "Lihat Geofences",
|
||||
"can_manage_geofences": "Urus Geofences",
|
||||
@@ -328,5 +327,62 @@
|
||||
"deleteManagerFailed": "Gagal memadam Pentadbir",
|
||||
"openCamera": "Buka Kamera",
|
||||
"scanQRCode": "Imbas Kod QR",
|
||||
"signOutOfAccount": "Log Keluar Akaun"
|
||||
"signOutOfAccount": "Log Keluar Akaun",
|
||||
|
||||
"darkMode": "Mod Gelap",
|
||||
"enableDarkMode": "Aktifkan mod gelap",
|
||||
"disableDarkMode": "Nyahaktifkan mod gelap",
|
||||
"tamil": "Tamil",
|
||||
"bengali": "Bengali",
|
||||
"burmese": "Burmese",
|
||||
"nepali": "Nepali",
|
||||
|
||||
"manualGuide": "Panduan Manual",
|
||||
"viewUserManual": "Baca arahan dan Soalan Lazim",
|
||||
|
||||
"manual": {
|
||||
"android": {
|
||||
"heading": "Android",
|
||||
"faqs": [
|
||||
{
|
||||
"id": "android-location",
|
||||
"title": "Cara menghidupkan lokasi (Android)",
|
||||
"steps": [
|
||||
"Open <strong>Settings</strong> on your phone.",
|
||||
"Go to <strong>Location</strong> <span class=\"text-sm text-gray-500\">(on some phones under <em>Security & privacy</em>)</span>.",
|
||||
"Turn <strong>Use location</strong> ON.",
|
||||
"Open <strong>App permissions</strong> → find <strong>Nilai Clock</strong> → set to <strong>Allow while using the app</strong>.",
|
||||
"Enable <strong>Precise location</strong> if available.",
|
||||
"Return to the app and try clock-in again."
|
||||
],
|
||||
"note": "Nama menu mungkin berbeza mengikut jenama."
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Benarkan kebenaran kamera (Android)",
|
||||
"steps": [
|
||||
"Open <strong>Settings</strong> → <strong>Apps</strong> → <strong>Nilai Clock</strong>.",
|
||||
"Tap <strong>Permissions</strong> → <strong>Camera</strong> → choose <strong>Allow</strong> or <strong>Allow while using the app</strong>.",
|
||||
"Reopen the app and try scanning again."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in tidak berjaya? Semakan pantas",
|
||||
"steps": [
|
||||
"Turn <strong>Location</strong> ON and set app permission to <strong>Allow while using the app</strong> (enable <strong>Precise location</strong> if available).",
|
||||
"Check network: Wi-Fi or data is on. Toggle <strong>Airplane mode</strong> off→on, then retry. Disable VPN if it interferes.",
|
||||
"Ensure <strong>Automatic date & time</strong> and <strong>time zone</strong> are enabled in Android settings.",
|
||||
"Force close and reopen the app. If needed, clear <strong>Attendance System</strong> cache (Settings → Apps → Nilai Clock → Storage → Clear cache)."
|
||||
],
|
||||
"note": "Jika masih gagal, ambil tangkapan skrin dan hubungi pengurus/HR."
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "Akan datang."
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"appTitle": "တက်ရောက်မှု စနစ်",
|
||||
"logout": "ထွက်ရန်",
|
||||
"login": "ဝင်ရောက်ရန်",
|
||||
"username": "အသုံးပြုသူအမည်",
|
||||
"password": "လျှို့ဝှက်နံပါတ်",
|
||||
"loggingIn": "ဝင်ရောက်နေသည်...",
|
||||
"language": "ဘာသာစကား",
|
||||
"darkMode": "မှောင်မိုက်မုဒ်",
|
||||
"toggleDarkMode": "အလင်းနှင့် မှောင်မိုက်အပြင်အဆင်များကြား ပြောင်းလဲရန်",
|
||||
"failedConnection": "ဆာဗာနှင့် ချိတ်ဆက်၍မရပါ။",
|
||||
"invalidToken": "ဆာဗာမှ မမှန်ကန်သော တိုကင်ရရှိခဲ့သည်။",
|
||||
"invalidCredentials": "အသုံးပြုသူအမည် သို့မဟုတ် လျှို့ဝှက်နံပါတ် မမှန်ကန်ပါ။",
|
||||
"english": "အင်္ဂလိပ်",
|
||||
"malay": "မလေး",
|
||||
"tamil": "တမီးလ်",
|
||||
"bengali": "ဘင်္ဂါလီ",
|
||||
"burmese": "မြန်မာ",
|
||||
"nepali": "နီပေါ",
|
||||
|
||||
"yourStatus": "သင်၏အခြေအနေ",
|
||||
"clockedIn": "အချိန်ဝင်ပြီး",
|
||||
"clockedOut": "အချိန်ထွက်ပြီး",
|
||||
"clockIn": "အချိန်ဝင်ရန်",
|
||||
"clockOut": "အချိန်ထွက်ရန်",
|
||||
"clock_in": "အချိန်ဝင်ရန်",
|
||||
"clock_out": "အချိန်ထွက်ရန်",
|
||||
|
||||
"scanToClock": "{action} အတွက် စကင်န်ဖတ်ပါ",
|
||||
"in": "ဝင်",
|
||||
"out": "ထွက်",
|
||||
"cancel": "ပယ်ဖျက်ရန်",
|
||||
|
||||
"viewMyClockHistory": "ကျွန်ုပ်၏ အချိန်မှတ်တမ်းကြည့်ရန်",
|
||||
"changeMyPassword": "ကျွန်ုပ်၏ လျှို့ဝှက်နံပါတ်ပြောင်းရန်",
|
||||
"myClockHistory": "ကျွန်ုပ်၏ အချိန်မှတ်တမ်း",
|
||||
"backToDashboard": "ပင်မစာမျက်နှာသို့ ပြန်ရန်",
|
||||
"noClockHistory": "သင့်တွင် အချိန်မှတ်တမ်း မရှိပါ။",
|
||||
"clockHistoryFetchFail": "အချိန်မှတ်တမ်း ရယူ၍မရပါ:",
|
||||
"viewClockHistory": "ကျွန်ုပ်၏ အချိန်မှတ်တမ်းကြည့်ရန်",
|
||||
"changePassword": "လျှို့ဝှက်နံပါတ်ပြောင်းရန်",
|
||||
"invalidCurrentPassword": "လက်ရှိလျှို့ဝှက်နံပါတ် မမှန်ကန်ပါ။",
|
||||
|
||||
"successClockIn": "အချိန်ဝင်ခြင်း အောင်မြင်ပါသည်။",
|
||||
"successClockOut": "အချိန်ထွက်ခြင်း အောင်မြင်ပါသည်။",
|
||||
"qrFail": "QR ကုဒ်ကို ဖတ်၍မရပါ။ ထပ်မံကြိုးစားပါ။",
|
||||
"geoFail": "သင့်တည်နေရာကို ရယူ၍မရပါ: {message}။ တည်နေရာဝန်ဆောင်မှုများကို ဖွင့်ထားပါ။",
|
||||
"successClock": "{location} တွင် အချိန်မှတ်ခြင်း အောင်မြင်ပါသည်။",
|
||||
"changePasswordTitle": "လျှို့ဝှက်နံပါတ်ပြောင်းရန်",
|
||||
"currentPassword": "လက်ရှိလျှို့ဝှက်နံပါတ်",
|
||||
"newPassword": "လျှို့ဝှက်နံပါတ်အသစ်",
|
||||
"confirmNewPassword": "လျှို့ဝှက်နံပါတ်အသစ် အတည်ပြုပါ",
|
||||
"updating": "အပ်ဒိတ်လုပ်နေသည်...",
|
||||
|
||||
"tabPersonnel": "ဝန်ထမ်းများ",
|
||||
"tabAttendance": "တက်ရောက်မှု",
|
||||
"tabQrCodes": "QR ကုဒ်များ",
|
||||
"uploadQrImage": "QR ပုံအပ်လုဒ်လုပ်ရန်",
|
||||
|
||||
"couldNotLoadWorkerInfo": "အလုပ်သမားအချက်အလက်များ ရယူ၍မရပါ",
|
||||
"couldNotVerifyStatus": "ဆာဗာမှ လက်ရှိအခြေအနေကို အတည်ပြု၍မရပါ",
|
||||
"successfullyClocked": "{action} အချိန်မှတ်ခြင်း အောင်မြင်ပါသည်",
|
||||
"site": "နေရာ",
|
||||
"errorOccurred": "အမှားအယွင်း ဖြစ်ပွားခဲ့သည်",
|
||||
"unableToStartCamera": "ကင်မရာကို ဖွင့်၍မရပါ။",
|
||||
"tryAgain": "ထပ်မံကြိုးစားပါ",
|
||||
"qrDetectedGettingLocation": "QR ကုဒ် တွေ့ရှိပြီး။ တည်နေရာရယူနေသည်...",
|
||||
"geolocationNotSupported": "သင့်ဘရောက်ဇာတွင် တည်နေရာဝန်ဆောင်မှု မပါဝင်ပါ။",
|
||||
"unableToRetrieveLocation": "သင့်တည်နေရာကို ရယူ၍မရပါ: {message}။ တည်နေရာဝန်ဆောင်မှုများကို ဖွင့်ထားပါ။",
|
||||
"qrNotDetectedTryAgain": "QR ကုဒ်ကို ဖတ်၍မရပါ။ ထပ်မံကြိုးစားပါ။",
|
||||
"updatePassword": "လျှို့ဝှက်နံပါတ် အပ်ဒိတ်လုပ်ရန်",
|
||||
"passwordsNoMatch": "လျှို့ဝှက်နံပါတ်အသစ်များ မတူညီပါ။",
|
||||
"passwordTooShort": "လျှို့ဝှက်နံပါတ်အသစ်သည် အနည်းဆုံး ၆လုံး ရှိရပါမည်။",
|
||||
"passwordUpdated": "လျှို့ဝှက်နံပါတ် အပ်ဒိတ်လုပ်ပြီးပါပြီ။ လျှို့ဝှက်နံပါတ်အသစ်ဖြင့် ဝင်ရောက်နိုင်ပါပြီ။",
|
||||
"passwordUpdateError": "လျှို့ဝှက်နံပါတ် အပ်ဒိတ်လုပ်စဉ် အမှားအယွင်း ဖြစ်ပွားခဲ့သည်။",
|
||||
|
||||
"attendanceLogFor": "အတွက် တက်ရောက်မှုမှတ်တမ်း",
|
||||
"addManualClockOut": "လက်ဖြင့် အချိန်ထွက် ထည့်သွင်းရန်",
|
||||
"manualClockOutInstruction": "အလုပ်သမားက အချိန်ထွက်ရန် မေ့လျော့ပါက ဤပုံစံကို အသုံးပြုပါ။ နောက်ဆုံးဖြစ်ရပ်သည် အချိန်ဝင်ခြင်း ဖြစ်ရပါမည်။",
|
||||
"clockOutTime": "အချိန်ထွက် အချိန်",
|
||||
"reason": "အကြောင်းပြချက် (ဥပမာ \"အချိန်ထွက်ရန် မေ့လျော့ခဲ့သည်\")",
|
||||
"enterBriefNote": "အကျဉ်းချုပ် မှတ်စုရေးပါ",
|
||||
"addRecord": "မှတ်တမ်း ထည့်သွင်းရန်",
|
||||
|
||||
"startDate": "စတင်ရက်",
|
||||
"endDate": "ပြီးဆုံးရက်",
|
||||
"filterRecords": "မှတ်တမ်းများ စစ်ထုတ်ရန်",
|
||||
"event": "ဖြစ်ရပ်",
|
||||
"timestamp": "အချိန်တံဆိပ်",
|
||||
"locationName": "နေရာအမည်",
|
||||
"coordinates": "ကိုအော်ဒီနိတ်များ",
|
||||
"notes": "မှတ်စုများ",
|
||||
"noRecordsFound": "ဤကာလအတွင်း မှတ်တမ်းများ မတွေ့ပါ။",
|
||||
"showOnMap": "မြေပုံပေါ်တွင် ပြရန်",
|
||||
"nA": "မရှိ",
|
||||
"pleaseSelectTimestamp": "အချိန်ထွက်အတွက် အချိန်တံဆိပ်ကို ရွေးချယ်ပါ။",
|
||||
"pleaseProvideReason": "လက်ဖြင့်ထည့်သွင်းရန်အတွက် အကြောင်းပြချက်/မှတ်စု ပေးပါ။",
|
||||
"manualClockOutSuccess": "လက်ဖြင့် အချိန်ထွက် မှတ်တမ်း အောင်မြင်စွာ ပြုလုပ်ပြီးပါပြီ။",
|
||||
"manualClockOutError": "အမှားအယွင်း ဖြစ်ပွားခဲ့သည်: {message}",
|
||||
|
||||
"selectWorkers": "၁။ အလုပ်သမားများ ရွေးချယ်ရန်",
|
||||
"searchWorkerPlaceholder": "အလုပ်သမား ရှာဖွေပါ...",
|
||||
"selectAll": "အားလုံး ရွေးချယ်ရန်",
|
||||
"addWorkersByTag": "တက်မှ အလုပ်သမားအားလုံးကို ထည့်ရန်",
|
||||
"chooseTag": "-- တက် ရွေးချယ်ပါ --",
|
||||
"addByTag": "တက်ဖြင့် ထည့်ရန်",
|
||||
"selectedForReport": "အစီရင်ခံစာအတွက် ရွေးချယ်ပြီး ({count})",
|
||||
"allWorkersSelected": "အလုပ်သမားအားလုံး ({count}) ရွေးချယ်ပြီး",
|
||||
"noWorkersSelected": "အလုပ်သမား မရွေးချယ်ရသေးပါ။",
|
||||
"reportSettings": "၂။ အစီရင်ခံစာ ဆက်တင်များ",
|
||||
"setting": "ဆက်တင်",
|
||||
"monthlySalary": "လစာ (ရူပီး)",
|
||||
"salaryAppliedNote": "ရွေးချယ်ထားသော အလုပ်သမားအားလုံးအတွက် အသုံးပြုသည်။",
|
||||
"salaryPlaceholder": "ဥပမာ ၃၀၀၀",
|
||||
"otFactors": "အပိုအချိန် အချက်များ",
|
||||
"weekendFactor": "စနေတနင်္ဂနွေ အချက်",
|
||||
"holidayFactor": "အားလပ်ရက် အချက်",
|
||||
"selectPublicHolidays": "အများပြည်သူ အားလပ်ရက်များ ရွေးချယ်ရန်",
|
||||
"generateReport": "တက်ရောက်မှုနှင့် အပိုအချိန် အစီရင်ခံစာ ထုတ်လုပ်ရန်",
|
||||
"overtimePaySummary": "အပိုအချိन် လစာအကျဉ်းချုပ်",
|
||||
"exportOtSummary": "အပိုအချိန် အကျဉ်းချုပ် ပို့ထုတ်ရန် (CSV)",
|
||||
"worker": "အလုပ်သမား",
|
||||
"totalHoursWorked": "စုစုပေါင်း အလုပ်လုပ်ခဲ့သော နာရီများ",
|
||||
"totalOtPay": "စုစုပေါင်း အပိုအချိန် လစာ (ရူပီး)",
|
||||
"rawAttendanceData": "တက်ရောက်မှု အချက်အလက်များ",
|
||||
"loadingReport": "အစီရင်ခံစာ ရယူနေသည်...",
|
||||
"tagLoadError": "ရွေးချယ်ထားသော တက်အတွက် အလုပ်သမားများကို ရယူ၍မရပါ။",
|
||||
"generateReportError": "အလုပ်သမားများ ရွေးချယ်ပါ၊ မှန်ကန်သော ရက်စွဲအပိုင်းအခြား သတ်မှတ်ပါ နှင့် လစာထည့်ပါ။",
|
||||
"reportGenerationError": "အစီရင်ခံစာ ထုတ်လုပ်စဉ် အမှားအယွင်း ဖြစ်ပွားခဲ့သည်။",
|
||||
|
||||
"addNewUser": "အသုံးပြုသူအသစ် ထည့်သွင်းရန်",
|
||||
"fullName": "အမည်အပြည့်အစုံ",
|
||||
"egJohnSmith": "ဥပမာ မောင်ကျော်",
|
||||
"egJsmith": "ဥပမာ mkyaw",
|
||||
"eg123456": "ဥပမာ ၁၂၃၄၅၆",
|
||||
"asManager": "မန်နေဂျာအနေဖြင့်",
|
||||
"adding": "ထည့်သွင်းနေသည်...",
|
||||
"addUser": "အသုံးပြုသူ ထည့်သွင်းရန်",
|
||||
"manageTags": "တက်များ စီမံခန့်ခွဲရန်",
|
||||
"createNewTag": "တက်အသစ် ဖန်တီးရန်",
|
||||
"egTeam": "ဥပမာ အဖွဲ့",
|
||||
"createTag": "တက် ဖန်တီးရန်",
|
||||
"tags": "တက်များ",
|
||||
"workerRoster": "အလုပ်သမား စာရင်း",
|
||||
"searchByNameOrUsername": "အမည် သို့မဟုတ် အသုံးပြုသူအမည်ဖြင့် ရှာပါ",
|
||||
"filterByTag": "တက်ဖြင့် စစ်ထုတ်ပါ",
|
||||
"clearFilter": "စစ်ထုတ်မှု ရှင်းလင်းရန်",
|
||||
"dateJoined": "ဝင်ရောက်သည့်ရက်",
|
||||
"actions": "လုပ်ဆောင်ချက်များ",
|
||||
"editTags": "တက်များ တည်းဖြတ်ရန်",
|
||||
"viewRecords": "မှတ်တမ်းများ ကြည့်ရန်",
|
||||
"delete": "ဖျက်ရန်",
|
||||
"loadingWorkers": "အလုပ်သမားများ ရယူနေသည်...",
|
||||
"noWorkersFound": "အလုပ်သမား မတွေ့ပါ။",
|
||||
"previous": "ရှေ့သို့",
|
||||
"next": "နောက်သို့",
|
||||
"pageOf": "စာမျက်နှာ {current} / {total}",
|
||||
"noTagsAvailable": "အသုံးပြုနိုင်သော တက် မရှိပါ။",
|
||||
"done": "ပြီးပါပြီ",
|
||||
"bulkEditTags": "တက်များ အစုလိုက် တည်းဖြတ်ရန်",
|
||||
"clearSelection": "ရွေးချယ်မှု ရှင်းလင်းရန်",
|
||||
"forUser": "အသုံးပြုသူအတွက်",
|
||||
"savePassword": "လျှို့ဝှက်နံပါတ် သိမ်းဆည်းရန်",
|
||||
"saving": "သိမ်းဆည်းနေသည်...",
|
||||
"failedToUpdateTags": "တက်များ အပ်ဒိတ်လုပ်၍မရပါ။ ထပ်မံကြိုးစားပါ။",
|
||||
"tagDeleted": "တက် ဖျက်ပြီးပါပြီ။",
|
||||
"failedToFetchWorkers": "အလုပ်သမားများ ရယူ၍မရပါ။",
|
||||
"failedToLoadPageData": "စာမျက်နှာ အချက်အလက်များ ရယူ၍မရပါ။",
|
||||
"errorAddingUser": "အသုံးပြုသူ ထည့်သွင်းစဉ် အမှားအယွင်း ဖြစ်ပွားခဲ့သည်။",
|
||||
"failedToDeleteWorker": "အလုပ်သမား ဖျက်၍မရပါ။",
|
||||
"areYouSureDeleteWorker": "ဤအလုပ်သမား အကောင့်ကို ဖျက်မည်မှာ သေချာပါသလား။",
|
||||
"areYouSureDeleteTag": "ဤတက်ကို ဖျက်မည်မှာ သေချာပါသလား။ ဤသည်က အလုပ်သမားအားလုံးမှ ဖယ်ရှားလိမ့်မည်။",
|
||||
"failedToDeleteTag": "တက် ဖျက်၍မရပါ။",
|
||||
"passwordsDoNotMatch": "လျှို့ဝှက်နံပါတ်များ မတူညီပါ။",
|
||||
"createQrCode": "QR ကုဒ်အသစ် ဖန်တီးရန်",
|
||||
"qrCodeName": "QR ကုဒ် အမည်",
|
||||
"qrNamePlaceholder": "ဥပမာ 'အနောက်ဂိတ် ဝင်ပေါက်'",
|
||||
"create": "ဖန်တီးရန်",
|
||||
"newCodeCreated": "ကုဒ်အသစ် ဖန်တီးပြီးပါပြီ။",
|
||||
"saveQrInstruction": "ဤပုံကို သိမ်းဆည်းပါ သို့မဟုတ် အောက်ပါ ID ကို အသုံးပြုပါ။ ဤသည်က ပြန်လည်ရယူမှုတွင် ပျောက်ကွယ်သွားလိမ့်မည်။",
|
||||
"id": "အိုင်ဒီ",
|
||||
"existingQrCodes": "ရှိပြီးသား QR ကုဒ်များ",
|
||||
"name": "အမည်",
|
||||
"status": "အခြေအနေ",
|
||||
"deactivate": "ပိတ်ရန်",
|
||||
"activate": "ဖွင့်ရန်",
|
||||
"download": "ဒေါင်းလုဒ်လုပ်ရန်",
|
||||
"noQrCodesFound": "QR ကုဒ် မတွေ့ပါ။ အထက်တွင် တစ်ခု ဖန်တီးပါ။",
|
||||
"deleteQrConfirm": "ဤ QR ကုဒ်ကို ဖျက်မည်မှာ သေချာပါသလား။ ဤသည်ကို နောက်ပြန်မပြောင်းနိုင်ပါ။",
|
||||
"qrDownloadError": "စိတ်မကောင်းပါ၊ QR ကုဒ်ကို ဒေါင်းလုဒ်လုပ်၍မရပါ။",
|
||||
|
||||
"rememberMe": "အလိုအလျောက်ဝင်ရောက်ရန် ကျွန်ုပ်ကို မှတ်ထားပါ",
|
||||
"deviceNotAuthorized": "ဤစက်ပစ္စည်းသည် သင့်အကောင့်အတွက် ခွင့်ပြုချက် မရှိပါ။ သင့်စီမံခန့်ခွဲသူကို ဆက်သွယ်ပါ။",
|
||||
"locationTrackingActive": "နောက်ကွယ်တွင် တည်နေရာခြေရာခံမှု အသက်ဝင်နေသည်",
|
||||
"securityCheckInProgress": "လုံခြုံရေးစစ်ဆေးမှု ပြုလုပ်နေသည်...",
|
||||
"securityCheckComplete": "လုံခြုံရေးစစ်ဆေးမှု အောင်မြင်စွာ ပြီးဆုံးပါပြီ",
|
||||
"highSecurityRisk": "မြင့်မားသော လုံခြုံရေးအန္တရာယ် တွေ့ရှိရသည်။ သင့်စီမံခန့်ခွဲသူကို ဆက်သွယ်ပါ။",
|
||||
"deviceRegistered": "စက်ပစ္စည်း အောင်မြင်စွာ မှတ်ပုံတင်ပြီးပါပြီ",
|
||||
"autoLoginEnabled": "ဤစက်ပစ္စည်းအတွက် အလိုအလျောက်ဝင်ရောက်မှု ဖွင့်ပြီးပါပြီ",
|
||||
"backgroundLocationEnabled": "နောက်ကွယ် တည်နေရာခြေရာခံမှု ဖွင့်ပြီးပါပြီ",
|
||||
"permissionsRequired": "တက်ရောက်မှုခြေရာခံမှုအတွက် တည်နေရာခွင့်ပြုချက်များ လိုအပ်သည်",
|
||||
"batteryOptimizationWarning": "ဆက်တိုက် တည်နေရာခြေရာခံမှုအတွက် ဤအပ်ပ်အတွက် ဘက်ထရီ အကောင်းဆုံးလုပ်ခြင်းကို ပိတ်ပါ",
|
||||
"gpsSpooferDetected": "GPS အတုအပ် အပလီကေးရှင်း တွေ့ရှိရသည်။ ဤသည်က တက်ရောက်မှု တိကျမှုကို ထိခိုက်စေနိုင်သည်။",
|
||||
"mockLocationEnabled": "အတု တည်နေရာ ဖွင့်ထားသည်။ တိကျသော တက်ရောက်မှု ခြေရာခံမှုအတွက် ပိတ်ပါ။",
|
||||
"deviceSecurityWarning": "စက်ပစ္စည်း လုံခြုံရေး သတိပေးချက်: သံသယဖြစ်ဖွယ် အပလီကေးရှင်းများ တွေ့ရှိရသည်",
|
||||
"locationUpdateFailed": "တည်နေရာ အပ်ဒိတ်လုပ်၍မရပါ။ အလိုအလျောက် ထပ်မံကြိုးစားလိမ့်မည်။",
|
||||
"servicesInitializing": "မူလဝန်ဆောင်မှုများ စတင်နေသည်...",
|
||||
"servicesReady": "ဝန်ဆောင်မှုများ အားလုံး အဆင်သင့်ပါပြီ",
|
||||
"autoLoginFailed": "အလိုအလျောက်ဝင်ရောက်မှု မအောင်မြင်ပါ။ လက်ဖြင့် ဝင်ရောက်ပါ။",
|
||||
"deviceValidationFailed": "စက်ပစ္စည်း အတည်ပြုခြင်း မအောင်မြင်ပါ။ ပံ့ပိုးမှုကို ဆက်သွယ်ပါ။",
|
||||
"deviceMismatch": "ဤစက်ပစ္စည်းသည် သင့်အကောင့်အတွက် ခွင့်ပြုမထားပါ။",
|
||||
"deviceRegistrationFailed": "စက်ပစ္စည်း မှတ်ပုံတင်မှု မအောင်မြင်ပါ။ ပြန်လည်ကြိုးစားပါ။",
|
||||
"deviceRequired": "အလုပ်သမား အကောင့်ဝင်ရောက်မှုအတွက် စက်ပစ္စည်း မှတ်ပုံတင်ခြင်း လိုအပ်ပါသည်။",
|
||||
|
||||
"servicesStatus": "ဝန်ဆောင်မှုများ အခြေအနေ",
|
||||
"overallStatus": "ခြုံငုံအခြေအနေ",
|
||||
"locationTracking": "တည်နေရာခြေရာခံမှု",
|
||||
"deviceRegistration": "စက်ပစ္စည်း မှတ်ပုံတင်မှု",
|
||||
"securityStatus": "လုံခြုံရေး အခြေအနေ",
|
||||
"lastLocationUpdate": "နောက်ဆုံး တည်နေရာ အပ်ဒိတ်",
|
||||
"deviceId": "စက်ပစ္စည်း အိုင်ဒီ",
|
||||
"start": "စတင်ရန်",
|
||||
"check": "စစ်ဆေးရန်",
|
||||
"checking": "စစ်ဆေးနေသည်...",
|
||||
"refresh": "ပြန်လည်ရယူရန်",
|
||||
"refreshing": "ပြန်လည်ရယူနေသည်...",
|
||||
"notInitialized": "မစတင်ရသေးပါ",
|
||||
"ready": "အဆင်သင့်",
|
||||
"webOnly": "ဝက်ဘ်တွင်သာ",
|
||||
"active": "လှုပ်ရှားနေသည်",
|
||||
"inactive": "အလုပ်မလုပ်တော့ပါ",
|
||||
"registered": "မှတ်ပုံတင်ပြီး",
|
||||
"pending": "ဆိုင်းငံ့နေသည်",
|
||||
"notChecked": "စစ်ဆေးထားခြင်း မရှိပါ",
|
||||
"outdated": "အဆန်းပြားသွားပါပြီ",
|
||||
"current": "လက်ရှိ",
|
||||
"never": "မတိုင်မီ",
|
||||
"justNow": "ယခုတင်",
|
||||
"minutesAgo": "{minutes} မိနစ် အကြာ",
|
||||
"hoursAgo": "{hours} နာရီ အကြာ",
|
||||
"daysAgo": "{days} ရက် အကြာ",
|
||||
"failedToRefreshStatus": "အခြေအနေ ပြန်လည်ရယူ၍မရပါ",
|
||||
"locationTrackingStarted": "တည်နေရာခြေရာခံမှု အောင်မြင်စွာ စတင်သည်",
|
||||
"failedToStartLocationTracking": "တည်နေရာခြေရာခံမှု စတင်၍မရပါ",
|
||||
"securityCheckFailed": "လုံခြုံရေး စစ်ဆေးမှု မအောင်မြင်ပါ",
|
||||
|
||||
"personal": "ပုဂ္ဂိုလ်ရေး",
|
||||
"clockHistory": "အချိန်မှတ်တမ်း",
|
||||
"openCamera": "ကင်မရာ ဖွင့်ရန်",
|
||||
"scanQRCode": "QR ကုဒ် စကင်ဖတ်ရန်",
|
||||
"services": "ဝန်ဆောင်မှုများ",
|
||||
"systemServicesStatus": "စနစ်ဝန်ဆောင်မှုများနှင့် လုံခြုံရေးအခြေအနေ",
|
||||
"updateYourPassword": "သင့်အကောင့် လျှို့ဝှက်နံပါတ် အပ်ဒိတ်လုပ်ပါ",
|
||||
"signOutOfAccount": "သင့်အကောင့်မှ ထွက်ရန်",
|
||||
|
||||
"workLocationTracking": "အလုပ်တည်နေရာ ခြေရာခံမှု",
|
||||
"locationTrackingForAttendance": "အလုပ်တက်ရောက်မှုအတွက် တည်နေရာခြေရာခံမှု အသက်ဝင်နေသည်",
|
||||
"monitoringLocation": "အလုပ်တက်ရောက်မှုအတွက် တည်နေရာကို ကြည့်ရှုနေသည်",
|
||||
|
||||
"manualGuide": "လက်စွဲလမ်းညွှန်",
|
||||
"viewUserManual": "လမ်းညွှန်ချက်များနှင့် FAQs ကိုဖတ်ပါ",
|
||||
"manual": {
|
||||
"android": {
|
||||
"heading": "Android",
|
||||
"faqs": [
|
||||
{
|
||||
"id": "android-location",
|
||||
"title": "Location ကို ဖွင့်နည်း (Android)",
|
||||
"steps": [
|
||||
"သင့်ဖုန်းရှိ <strong>Settings</strong> ကို ဖွင့်ပါ။",
|
||||
"<strong>Location</strong> သို့သွားပါ <span class=\"text-sm text-gray-500\">(အချို့ဖုန်းများတွင် <em>Security & privacy</em> အောက်တွင်)</span>။",
|
||||
"<strong>Use location</strong> ကို ON ဖွင့်ပါ။",
|
||||
"<strong>App permissions</strong> ကိုဖွင့်ပါ → <strong>Attendance System</strong> ကိုရှာပါ → <strong>Allow while using the app</strong> သတ်မှတ်ပါ။",
|
||||
"ရနိုင်လျှင် <strong>Precise location</strong> ကို ဖွင့်ပါ။",
|
||||
"App သို့ပြန်သွားပြီး clock-in ကို ထပ်စမ်းကြည့်ပါ။"
|
||||
],
|
||||
"note": "အမှတ်တံဆိပ်ပေါ်မူတည်၍ အမည်များကွဲပြားနိုင်သည်: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services။"
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Camera permission ဖွင့်နည်း (Android)",
|
||||
"steps": [
|
||||
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> ကို ဖွင့်ပါ။",
|
||||
"<strong>Permissions</strong> → <strong>Camera</strong> ကို နှိပ်ပါ → <strong>Allow</strong> သို့မဟုတ် <strong>Allow while using the app</strong> ကို ရွေးပါ။",
|
||||
"App ကို ပြန်ဖွင့်ပြီး scanning ကို ထပ်စမ်းကြည့်ပါ။"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in အလုပ်မလုပ်ဘူးလား? အမြန် checklist",
|
||||
"steps": [
|
||||
"<strong>Location</strong> ကို ON ဖွင့်ပြီး app permission ကို <strong>Allow while using the app</strong> သတ်မှတ်ပါ (ရနိုင်လျှင် <strong>Precise location</strong> ကို ဖွင့်ပါ)။",
|
||||
"Network ကို စစ်ဆေးပါ: Wi-Fi သို့မဟုတ် data ကို ဖွင့်ထားသည်။ <strong>Airplane mode</strong> ကို off→on toggle လုပ်ပါ၊ ပြီးနောက် ထပ်စမ်းကြည့်ပါ။ ဝင်စွက်နေလျှင် VPN ကို ပိတ်ပါ။",
|
||||
"Android settings တွင် <strong>Automatic date & time</strong> နှင့် <strong>time zone</strong> တို့ကို ဖွင့်ထားကြောင်း သေချာပါစေ။",
|
||||
"Force close လုပ်ပြီး app ကို ပြန်ဖွင့်ပါ။ လိုအပ်လျှင် <strong>Attendance System</strong> cache ကို clear လုပ်ပါ (Settings → Apps → Attendance System → Storage → Clear cache)။"
|
||||
],
|
||||
"note": "ဆက်လက်ပြဿနာရှိနေပါသလား? Screenshot ရိုက်ပြီး သင့် manager သို့မဟုတ် HR ကို ဆက်သွယ်ပါ။"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "မကြာမီ ရောက်ရှိပါမည်။"
|
||||
}
|
||||
},
|
||||
"statusClockedIn": "သင်သည် ရုံးဝင်ထားပါသည်",
|
||||
"statusClockedOut": "သင်သည် ရုံးထွက်ထားပါသည်",
|
||||
"scanToClockIn": "ရုံးဝင်ရန် QR ကုဒ်ကို စကင်န်ဖတ်ပါ",
|
||||
"scanToClockOut": "ရုံးထွက်ရန် QR ကုဒ်ကို စကင်န်ဖတ်ပါ",
|
||||
"appInformation": "एप जानकारी",
|
||||
"version": "संस्करण",
|
||||
"platform": "प्लेटफर्म",
|
||||
"web": "वेब"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"appTitle": "उपस्थिति प्रणाली",
|
||||
"logout": "लगआउट",
|
||||
"login": "लगइन",
|
||||
"username": "प्रयोगकर्ता नाम",
|
||||
"password": "पासवर्ड",
|
||||
"loggingIn": "लगइन गर्दै...",
|
||||
"language": "भाषा",
|
||||
"darkMode": "डार्क मोड",
|
||||
"toggleDarkMode": "उज्यालो र अँध्यारो थिमहरू बीच स्विच गर्नुहोस्",
|
||||
"failedConnection": "सर्भरसँग जडान गर्न सकिएन।",
|
||||
"invalidToken": "सर्भरबाट अमान्य टोकन प्राप्त भयो।",
|
||||
"invalidCredentials": "गलत प्रयोगकर्ता नाम वा पासवर्ड।",
|
||||
"english": "अंग्रेजी",
|
||||
"malay": "मलय",
|
||||
"tamil": "तामिल",
|
||||
"bengali": "बङ्गाली",
|
||||
"burmese": "बर्मेली",
|
||||
"nepali": "नेपाली",
|
||||
|
||||
"yourStatus": "स्थिति",
|
||||
"clockedIn": "उपस्थित भएको",
|
||||
"clockedOut": "अनुपस्थित भएको",
|
||||
"clockIn": "उपस्थित हुनुहोस्",
|
||||
"clockOut": "अनुपस्थित हुनुहोस्",
|
||||
"clock_in": "उपस्थित हुनुहोस्",
|
||||
"clock_out": "अनुपस्थित हुनुहोस्",
|
||||
|
||||
"scanToClock": "{action} को लागि स्क्यान गर्नुहोस्",
|
||||
"in": "भित्र",
|
||||
"out": "बाहिर",
|
||||
"cancel": "रद्द गर्नुहोस्",
|
||||
|
||||
"viewMyClockHistory": "मेरो उपस्थिति इतिहास हेर्नुहोस्",
|
||||
"changeMyPassword": "मेरो पासवर्ड परिवर्तन गर्नुहोस्",
|
||||
"myClockHistory": "मेरो उपस्थिति इतिहास",
|
||||
"backToDashboard": "ड्यासबोर्डमा फर्कनुहोस्",
|
||||
"noClockHistory": "तपाईंको कुनै उपस्थिति इतिहास छैन।",
|
||||
"clockHistoryFetchFail": "उपस्थिति इतिहास ल्याउन सकिएन:",
|
||||
"viewClockHistory": "मेरो उपस्थिति इतिहास हेर्नुहोस्",
|
||||
"changePassword": "मेरो पासवर्ड परिवर्तन गर्नुहोस्",
|
||||
"invalidCurrentPassword": "गलत वर्तमान पासवर्ड।",
|
||||
|
||||
"successClockIn": "सफलतापूर्वक उपस्थित भएको।",
|
||||
"successClockOut": "सफलतापूर्वक अनुपस्थित भएको।",
|
||||
"qrFail": "QR कोड पत्ता लगाउन सकिएन। कृपया पुनः प्रयास गर्नुहोस्।",
|
||||
"geoFail": "तपाईंको स्थान पत्ता लगाउन सकिएन: {message}। कृपया स्थान सेवाहरू सक्षम पार्नुहोस्।",
|
||||
"successClock": "{location} मा सफलतापूर्वक उपस्थित भएको।",
|
||||
"changePasswordTitle": "पासवर्ड परिवर्तन गर्नुहोस्",
|
||||
"currentPassword": "वर्तमान पासवर्ड",
|
||||
"newPassword": "नयाँ पासवर्ड",
|
||||
"confirmNewPassword": "नयाँ पासवर्ड पुष्टि गर्नुहोस्",
|
||||
"updating": "अपडेट गर्दै...",
|
||||
|
||||
"tabPersonnel": "कर्मचारी",
|
||||
"tabAttendance": "उपस्थिति",
|
||||
"tabQrCodes": "QR कोडहरू",
|
||||
"uploadQrImage": "QR छवि अपलोड गर्नुहोस्",
|
||||
|
||||
"couldNotLoadWorkerInfo": "कर्मचारी जानकारी लोड गर्न सकिएन",
|
||||
"couldNotVerifyStatus": "सर्भरबाट वर्तमान स्थिति प्रमाणित गर्न सकिएन",
|
||||
"successfullyClocked": "सफलतापूर्वक {action} भएको",
|
||||
"site": "साइट",
|
||||
"errorOccurred": "त्रुटि भयो",
|
||||
"unableToStartCamera": "क्यामेरा सुरु गर्न सकिएन।",
|
||||
"tryAgain": "पुनः प्रयास गर्नुहोस्",
|
||||
"qrDetectedGettingLocation": "QR कोड पत्ता लाग्यो। स्थान प्राप्त गर्दै...",
|
||||
"geolocationNotSupported": "तपाईंको ब्राउजरले भौगोलिक स्थान समर्थन गर्दैन।",
|
||||
"unableToRetrieveLocation": "तपाईंको स्थान पत्ता लगाउन सकिएन: {message}। कृपया स्थान सेवाहरू सक्षम पार्नुहोस्।",
|
||||
"qrNotDetectedTryAgain": "QR कोड पत्ता लगाउन सकिएन। कृपया पुनः प्रयास गर्नुहोस्।",
|
||||
"updatePassword": "पासवर्ड अपडेट गर्नुहोस्",
|
||||
"passwordsNoMatch": "नयाँ पासवर्डहरू मेल खाँदैनन्।",
|
||||
"passwordTooShort": "नयाँ पासवर्ड कम्तिमा ६ वर्णको हुनुपर्छ।",
|
||||
"passwordUpdated": "पासवर्ड सफलतापूर्वक अपडेट भयो! तपाईं अब आफ्नो नयाँ पासवर्ड प्रयोग गरेर लगइन गर्न सक्नुहुन्छ।",
|
||||
"passwordUpdateError": "पासवर्ड अपडेट गर्दा त्रुटि भयो।",
|
||||
|
||||
"attendanceLogFor": "को लागि उपस्थिति लग",
|
||||
"addManualClockOut": "म्यानुअल क्लक-आउट थप्नुहोस्",
|
||||
"manualClockOutInstruction": "यदि कर्मचारी क्लक आउट गर्न बिर्सिएका छन् भने यो फारम प्रयोग गर्नुहोस्। अन्तिम घटना क्लक-इन हुनुपर्छ।",
|
||||
"clockOutTime": "क्लक-आउट समय",
|
||||
"reason": "कारण (जस्तै, \"क्लक आउट गर्न बिर्सियो\")",
|
||||
"enterBriefNote": "छोटो टिप्पणी प्रविष्ट गर्नुहोस्",
|
||||
"addRecord": "रेकर्ड थप्नुहोस्",
|
||||
|
||||
"startDate": "सुरु मिति",
|
||||
"endDate": "अन्त्य मिति",
|
||||
"filterRecords": "रेकर्डहरू फिल्टर गर्नुहोस्",
|
||||
"event": "घटना",
|
||||
"timestamp": "समय छाप",
|
||||
"locationName": "स्थानको नाम",
|
||||
"coordinates": "निर्देशाङ्क",
|
||||
"notes": "टिप्पणीहरू",
|
||||
"noRecordsFound": "यस अवधिको लागि कुनै रेकर्ड भेटिएन।",
|
||||
"showOnMap": "नक्सामा देखाउनुहोस्",
|
||||
"nA": "उपलब्ध छैन",
|
||||
"pleaseSelectTimestamp": "कृपया क्लक-आउटको लागि समय छाप चयन गर्नुहोस्।",
|
||||
"pleaseProvideReason": "कृपया म्यानुअल प्रविष्टिको लागि कारण/टिप्पणी प्रदान गर्नुहोस्।",
|
||||
"manualClockOutSuccess": "म्यानुअल क्लक-आउट सफलतापूर्वक रेकर्ड भयो!",
|
||||
"manualClockOutError": "त्रुटि भयो: {message}",
|
||||
|
||||
"selectWorkers": "१. कर्मचारी चयन गर्नुहोस्",
|
||||
"searchWorkerPlaceholder": "कर्मचारी खोज्नुहोस्...",
|
||||
"selectAll": "सबै चयन गर्नुहोस्",
|
||||
"addWorkersByTag": "ट्यागबाट सबै कर्मचारी थप्नुहोस्",
|
||||
"chooseTag": "-- ट्याग छान्नुहोस् --",
|
||||
"addByTag": "ट्यागद्वारा थप्नुहोस्",
|
||||
"selectedForReport": "रिपोर्टको लागि चयनित ({count})",
|
||||
"allWorkersSelected": "सबै कर्मचारी ({count}) चयनित",
|
||||
"noWorkersSelected": "कुनै कर्मचारी चयनित छैन।",
|
||||
"reportSettings": "२. रिपोर्ट सेटिङहरू",
|
||||
"setting": "सेटिङ",
|
||||
"monthlySalary": "मासिक तलब (RM)",
|
||||
"salaryAppliedNote": "सबै चयनित कर्मचारीहरूमा लागू।",
|
||||
"salaryPlaceholder": "जस्तै, ३०००",
|
||||
"otFactors": "OT फ्याक्टरहरू",
|
||||
"weekendFactor": "सप्ताहन्त फ्याक्टर",
|
||||
"holidayFactor": "छुट्टी फ्याक्टर",
|
||||
"selectPublicHolidays": "सार्वजनिक छुट्टीहरू चयन गर्नुहोस्",
|
||||
"generateReport": "उपस्थिति र OT रिपोर्ट उत्पन्न गर्नुहोस्",
|
||||
"overtimePaySummary": "ओभरटाइम भुक्तानी सारांश",
|
||||
"exportOtSummary": "OT सारांश निर्यात गर्नुहोस् (CSV)",
|
||||
"worker": "कर्मचारी",
|
||||
"totalHoursWorked": "कुल काम गरेको घण्टा",
|
||||
"totalOtPay": "कुल OT भुक्तानी (RM)",
|
||||
"rawAttendanceData": "कच्चा उपस्थिति डेटा",
|
||||
"loadingReport": "रिपोर्ट लोड गर्दै...",
|
||||
"tagLoadError": "चयनित ट्यागको लागि कर्मचारी लोड गर्न सकिएन।",
|
||||
"generateReportError": "कृपया कर्मचारी चयन गर्नुहोस्, मान्य मिति दायरा सेट गर्नुहोस्, र तलब प्रविष्ट गर्नुहोस्।",
|
||||
"reportGenerationError": "रिपोर्ट उत्पन्न गर्दा त्रुटि भयो।",
|
||||
|
||||
"addNewUser": "नयाँ प्रयोगकर्ता थप्नुहोस्",
|
||||
"fullName": "पूरा नाम",
|
||||
"egJohnSmith": "जस्तै जोन स्मिथ",
|
||||
"egJsmith": "जस्तै jsmith",
|
||||
"eg123456": "जस्तै १२३४५६",
|
||||
"asManager": "प्रबन्धकको रूपमा",
|
||||
"adding": "थप्दै...",
|
||||
"addUser": "प्रयोगकर्ता थप्नुहोस्",
|
||||
"manageTags": "ट्यागहरू व्यवस्थापन गर्नुहोस्",
|
||||
"createNewTag": "नयाँ ट्याग सिर्जना गर्नुहोस्",
|
||||
"egTeam": "जस्तै टिम",
|
||||
"createTag": "ट्याग सिर्जना गर्नुहोस्",
|
||||
"tags": "ट्यागहरू",
|
||||
"workerRoster": "कर्मचारी सूची",
|
||||
"searchByNameOrUsername": "नाम वा प्रयोगकर्ता नामद्वारा खोज्नुहोस्",
|
||||
"filterByTag": "ट्यागद्वारा फिल्टर गर्नुहोस्",
|
||||
"clearFilter": "फिल्टर हटाउनुहोस्",
|
||||
"dateJoined": "सामेल भएको मिति",
|
||||
"actions": "कार्यहरू",
|
||||
"editTags": "ट्यागहरू सम्पादन गर्नुहोस्",
|
||||
"viewRecords": "रेकर्डहरू हेर्नुहोस्",
|
||||
"delete": "मेटाउनुहोस्",
|
||||
"loadingWorkers": "कर्मचारी लोड गर्दै...",
|
||||
"noWorkersFound": "कुनै कर्मचारी भेटिएन।",
|
||||
"previous": "अघिल्लो",
|
||||
"next": "अर्को",
|
||||
"pageOf": "पृष्ठ {current} को {total}",
|
||||
"noTagsAvailable": "कुनै ट्याग उपलब्ध छैन।",
|
||||
"done": "सकियो",
|
||||
"bulkEditTags": "बल्क ट्याग सम्पादन",
|
||||
"clearSelection": "चयन हटाउनुहोस्",
|
||||
"forUser": "प्रयोगकर्ताको लागि",
|
||||
"savePassword": "पासवर्ड सेभ गर्नुहोस्",
|
||||
"saving": "सेभ गर्दै...",
|
||||
"failedToUpdateTags": "ट्यागहरू अपडेट गर्न सकिएन। कृपया पुनः प्रयास गर्नुहोस्।",
|
||||
"tagDeleted": "ट्याग सफलतापूर्वक मेटाइयो।",
|
||||
"failedToFetchWorkers": "कर्मचारी फेच गर्न सकिएन।",
|
||||
"failedToLoadPageData": "पृष्ठ डेटा लोड गर्न सकिएन।",
|
||||
"errorAddingUser": "प्रयोगकर्ता थप्दा त्रुटि भयो।",
|
||||
"failedToDeleteWorker": "कर्मचारी मेटाउन सकिएन।",
|
||||
"areYouSureDeleteWorker": "के तपाईं यो कर्मचारी खाता मेटाउन निश्चित हुनुहुन्छ?",
|
||||
"areYouSureDeleteTag": "के तपाईं यो ट्याग मेटाउन निश्चित हुनुहुन्छ? यसले सबै कर्मचारीहरूबाट यसलाई हटाउनेछ।",
|
||||
"failedToDeleteTag": "ट्याग मेटाउन सकिएन।",
|
||||
"passwordsDoNotMatch": "पासवर्डहरू मेल खाँदैनन्।",
|
||||
"createQrCode": "नयाँ QR कोड सिर्जना गर्नुहोस्",
|
||||
"qrCodeName": "QR कोड नाम",
|
||||
"qrNamePlaceholder": "जस्तै, 'पश्चिम गेट प्रवेश'",
|
||||
"create": "सिर्जना गर्नुहोस्",
|
||||
"newCodeCreated": "नयाँ कोड सिर्जना भयो!",
|
||||
"saveQrInstruction": "यो छवि सेभ गर्नुहोस् वा तलको ID प्रयोग गर्नुहोस्। यो रिफ्रेसमा हराउनेछ।",
|
||||
"id": "ID",
|
||||
"existingQrCodes": "अवस्थित QR कोडहरू",
|
||||
"name": "नाम",
|
||||
"status": "स्थिति",
|
||||
"deactivate": "निष्क्रिय पार्नुहोस्",
|
||||
"activate": "सक्रिय पार्नुहोस्",
|
||||
"download": "डाउनलोड गर्नुहोस्",
|
||||
"noQrCodesFound": "कुनै QR कोडहरू भेटिएन। माथि एउटा सिर्जना गर्नुहोस्!",
|
||||
"deleteQrConfirm": "के तपाईं यो QR कोड मेटाउन निश्चित हुनुहुन्छ? यो पूर्ववत गर्न सकिँदैन।",
|
||||
"qrDownloadError": "माफ गर्नुहोस्, QR कोड डाउनलोड गर्न सकिएन।",
|
||||
|
||||
"rememberMe": "अटो-लगइनको लागि मलाई सम्झनुहोस्",
|
||||
"deviceNotAuthorized": "यो उपकरण तपाईंको खाताको लागि प्राधिकृत छैन। कृपया आफ्नो व्यवस्थापकलाई सम्पर्क गर्नुहोस्।",
|
||||
"locationTrackingActive": "पृष्ठभूमिमा स्थान ट्र्याकिङ सक्रिय छ",
|
||||
"securityCheckInProgress": "सुरक्षा जाँच भइरहेको छ...",
|
||||
"securityCheckComplete": "सुरक्षा जाँच सफलतापूर्वक सम्पन्न भयो",
|
||||
"highSecurityRisk": "उच्च सुरक्षा जोखिम पत्ता लाग्यो। कृपया आफ्नो व्यवस्थापकलाई सम्पर्क गर्नुहोस्।",
|
||||
"deviceRegistered": "उपकरण सफलतापूर्वक दर्ता भयो",
|
||||
"autoLoginEnabled": "यस उपकरणको लागि अटो-लगइन सक्षम पारियो",
|
||||
"backgroundLocationEnabled": "पृष्ठभूमि स्थान ट्र्याकिङ सक्षम पारियो",
|
||||
"permissionsRequired": "उपस्थिति ट्र्याकिङको लागि स्थान अनुमतिहरू आवश्यक छ",
|
||||
"batteryOptimizationWarning": "निरन्तर स्थान ट्र्याकिङ सुनिश्चित गर्न कृपया यस एपको लागि ब्याट्री अप्टिमाइजेसन अक्षम पार्नुहोस्",
|
||||
"gpsSpooferDetected": "GPS स्पूफिङ एप्लिकेसन पत्ता लाग्यो। यसले उपस्थिति शुद्धतालाई असर गर्न सक्छ।",
|
||||
"mockLocationEnabled": "नक्कली स्थान सक्षम छ। सही उपस्थिति ट्र्याकिङको लागि कृपया यसलाई अक्षम पार्नुहोस्।",
|
||||
"deviceSecurityWarning": "उपकरण सुरक्षा चेतावनी: संदिग्ध एप्लिकेसनहरू पत्ता लाग्यो",
|
||||
"locationUpdateFailed": "स्थान अपडेट गर्न सकिएन। स्वचालित रूपमा पुनः प्रयास गर्नेछ।",
|
||||
"servicesInitializing": "मूल सेवाहरू प्रारम्भ गर्दै...",
|
||||
"servicesReady": "सबै सेवाहरू तयार छन्",
|
||||
"autoLoginFailed": "अटो-लगइन असफल। कृपया म्यानुअल रूपमा लगइन गर्नुहोस्।",
|
||||
"deviceValidationFailed": "उपकरण प्रमाणीकरण असफल। कृपया सहयोगलाई सम्पर्क गर्नुहोस्।",
|
||||
"deviceMismatch": "यो उपकरण तपाईंको खाताको लागि अधिकृत छैन।",
|
||||
"deviceRegistrationFailed": "उपकरण दर्ता असफल। फेरि प्रयास गर्नुहोस्।",
|
||||
"deviceRequired": "कामदार लगइनको लागि उपकरण दर्ता आवश्यक छ।",
|
||||
|
||||
"servicesStatus": "सेवाहरूको स्थिति",
|
||||
"overallStatus": "समग्र स्थिति",
|
||||
"locationTracking": "स्थान ट्र्याकिङ",
|
||||
"deviceRegistration": "उपकरण दर्ता",
|
||||
"securityStatus": "सुरक्षा स्थिति",
|
||||
"lastLocationUpdate": "अन्तिम स्थान अपडेट",
|
||||
"deviceId": "उपकरण ID",
|
||||
"start": "सुरु गर्नुहोस्",
|
||||
"check": "जाँच गर्नुहोस्",
|
||||
"checking": "जाँच गर्दै...",
|
||||
"refresh": "रिफ्रेस गर्नुहोस्",
|
||||
"refreshing": "रिफ्रेस गर्दै...",
|
||||
"notInitialized": "प्रारम्भ गरिएको छैन",
|
||||
"ready": "तयार",
|
||||
"webOnly": "वेब मात्र",
|
||||
"active": "सक्रिय",
|
||||
"inactive": "निष्क्रिय",
|
||||
"registered": "दर्ता गरिएको",
|
||||
"pending": "पेन्डिङ",
|
||||
"notChecked": "जाँच गरिएको छैन",
|
||||
"outdated": "पुरानो",
|
||||
"current": "वर्तमान",
|
||||
"never": "कहिल्यै छैन",
|
||||
"justNow": "भर्खरै",
|
||||
"minutesAgo": "{minutes} मिनेट अघि",
|
||||
"hoursAgo": "{hours} घण्टा अघि",
|
||||
"daysAgo": "{days} दिन अघि",
|
||||
"failedToRefreshStatus": "स्थिति रिफ्रेस गर्न सकिएन",
|
||||
"locationTrackingStarted": "स्थान ट्र्याकिङ सफलतापूर्वक सुरु भयो",
|
||||
"failedToStartLocationTracking": "स्थान ट्र्याकिङ सुरु गर्न सकिएन",
|
||||
"securityCheckFailed": "सुरक्षा जाँच असफल",
|
||||
|
||||
"personal": "व्यक्तिगत",
|
||||
"clockHistory": "उपस्थिति इतिहास",
|
||||
"openCamera": "क्यामेरा खोल्नुहोस्",
|
||||
"scanQRCode": "QR कोड स्क्यान गर्नुहोस्",
|
||||
"services": "सेवाहरू",
|
||||
"systemServicesStatus": "प्रणाली सेवाहरू र सुरक्षा स्थिति",
|
||||
"updateYourPassword": "तपाईंको खाताको पासवर्ड अपडेट गर्नुहोस्",
|
||||
"signOutOfAccount": "तपाईंको खाताबाट साइन आउट गर्नुहोस्",
|
||||
|
||||
"workLocationTracking": "कार्य स्थान ट्र्याकिङ",
|
||||
"locationTrackingForAttendance": "कार्य उपस्थितिको लागि स्थान ट्र्याकिङ सक्रिय",
|
||||
"monitoringLocation": "कार्य उपस्थितिको लागि स्थान निगरानी गर्दै",
|
||||
|
||||
"manualGuide": "म्यानुअल गाइड",
|
||||
"viewUserManual": "निर्देशनहरू र FAQs पढ्नुहोस्",
|
||||
"manual": {
|
||||
"android": {
|
||||
"heading": "Android",
|
||||
"faqs": [
|
||||
{
|
||||
"id": "android-location",
|
||||
"title": "Location कसरी खोल्ने (Android)",
|
||||
"steps": [
|
||||
"आफ्नो फोनमा <strong>Settings</strong> खोल्नुहोस्।",
|
||||
"<strong>Location</strong> मा जानुहोस् <span class=\"text-sm text-gray-500\">(केहि फोनहरूमा <em>Security & privacy</em> अन्तर्गत)</span>।",
|
||||
"<strong>Use location</strong> ON गर्नुहोस्।",
|
||||
"<strong>App permissions</strong> खोल्नुहोस् → <strong>Attendance System</strong> फेला पार्नुहोस् → <strong>Allow while using the app</strong> सेट गर्नुहोस्।",
|
||||
"उपलब्ध भए <strong>Precise location</strong> सक्षम गर्नुहोस्।",
|
||||
"App मा फर्कनुहोस् र फेरि clock-in प्रयास गर्नुहोस्।"
|
||||
],
|
||||
"note": "ब्रान्ड अनुसार नामहरू फरक हुन्छन्: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services।"
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Camera permission सक्षम गर्नुहोस् (Android)",
|
||||
"steps": [
|
||||
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> खोल्नुहोस्।",
|
||||
"<strong>Permissions</strong> → <strong>Camera</strong> ट्याप गर्नुहोस् → <strong>Allow</strong> वा <strong>Allow while using the app</strong> छान्नुहोस्।",
|
||||
"App पुन: खोल्नुहोस् र फेरि scanning प्रयास गर्नुहोस्।"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in काम गरिरहेको छैन? द्रुत checklist",
|
||||
"steps": [
|
||||
"<strong>Location</strong> ON गर्नुहोस् र app permission लाई <strong>Allow while using the app</strong> सेट गर्नुहोस् (उपलब्ध भए <strong>Precise location</strong> सक्षम गर्नुहोस्)।",
|
||||
"Network जाँच गर्नुहोस्: Wi-Fi वा data खुला छ। <strong>Airplane mode</strong> off→on toggle गर्नुहोस्, त्यसपछि फेरि प्रयास गर्नुहोस्। हस्तक्षेप गरेमा VPN निष्क्रिय गर्नुहोस्।",
|
||||
"Android settings मा <strong>Automatic date & time</strong> र <strong>time zone</strong> सक्षम छन् भनी सुनिश्चित गर्नुहोस्।",
|
||||
"Force close गर्नुहोस् र app पुन: खोल्नुहोस्। आवश्यक भएमा, <strong>Attendance System</strong> cache clear गर्नुहोस् (Settings → Apps → Attendance System → Storage → Clear cache)।"
|
||||
],
|
||||
"note": "अझै अड्किनु भयो? Screenshot लिनुहोस् र आफ्नो manager वा HR लाई सम्पर्क गर्नुहोस्।"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "चाँडै आउँदैछ।"
|
||||
}
|
||||
},
|
||||
"statusClockedIn": "तपाईं क्लक इन हुनुभएको छ",
|
||||
"statusClockedOut": "तपाईं क्लक आउट हुनुभएको छ",
|
||||
"scanToClockIn": "क्लक इन गर्न QR कोड स्क्यान गर्नुहोस्",
|
||||
"scanToClockOut": "क्लक आउट गर्न QR कोड स्क्यान गर्नुहोस्",
|
||||
"appInformation": "एप जानकारी",
|
||||
"version": "संस्करण",
|
||||
"platform": "प्लेटफर्म",
|
||||
"web": "वेब"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"appTitle": "வருகை அமைப்பு",
|
||||
"logout": "வெளியேறு",
|
||||
"login": "உள்நுழை",
|
||||
"username": "பயனர் பெயர்",
|
||||
"password": "கடவுச்சொல்",
|
||||
"loggingIn": "உள்நுழைகிறது...",
|
||||
"language": "மொழி",
|
||||
"darkMode": "இருண்ட பயன்முறை",
|
||||
"toggleDarkMode": "வெளிச்சம் மற்றும் இருண்ட தீம்களுக்கு இடையில் மாற்றவும்",
|
||||
"failedConnection": "சர்வருடன் இணைக்க முடியவில்லை.",
|
||||
"invalidToken": "சர்வரிலிருந்து தவறான டோக்கன் பெறப்பட்டது.",
|
||||
"invalidCredentials": "தவறான பயனர் பெயர் அல்லது கடவுச்சொல்.",
|
||||
"english": "ஆங்கிலம்",
|
||||
"malay": "மலாய்",
|
||||
"tamil": "தமிழ்",
|
||||
"bengali": "பெங்காலி",
|
||||
"burmese": "பர்மா",
|
||||
"nepali": "நேபாளி",
|
||||
|
||||
"yourStatus": "நிலைமை",
|
||||
"clockedIn": "வருகை பதிவு செய்யப்பட்டது",
|
||||
"clockedOut": "வெளியேறல் பதிவு செய்யப்பட்டது",
|
||||
"clockIn": "வருகை பதிவு",
|
||||
"clockOut": "வெளியேறல் பதிவு",
|
||||
"clock_in": "வருகை பதிவு",
|
||||
"clock_out": "வெளியேறல் பதிவு",
|
||||
"scanToClock": "{action} பதிவு செய்ய ஸ்கேன் செய்யவும்",
|
||||
"in": "வருகை",
|
||||
"out": "வெளியேறல்",
|
||||
"cancel": "ரத்து",
|
||||
"viewMyClockHistory": "என் வருகை வரலாற்றைப் பார்க்கவும்",
|
||||
"changeMyPassword": "என் கடவுச்சொல்லை மாற்றவும்",
|
||||
"myClockHistory": "என் வருகை வரலாறு",
|
||||
"backToDashboard": "டாஷ்போர்டுக்குத் திரும்பு",
|
||||
"noClockHistory": "உங்களுக்கு வருகை வரலாறு இல்லை.",
|
||||
"clockHistoryFetchFail": "வருகை வரலாற்றை பெற முடியவில்லை:",
|
||||
"viewClockHistory": "என் வருகை வரலாற்றைப் பார்க்கவும்",
|
||||
"changePassword": "என் கடவுச்சொல்லை மாற்றவும்",
|
||||
"invalidCurrentPassword": "தற்போதைய கடவுச்சொல் தவறானது.",
|
||||
"successClockIn": "வெற்றிகரமாக வருகை பதிவு செய்யப்பட்டது.",
|
||||
"successClockOut": "வெற்றிகரமாக வெளியேறல் பதிவு செய்யப்பட்டது.",
|
||||
"qrFail": "QR கோட்டைக் கண்டறிய முடியவில்லை. தயவுசெய்து மீண்டும் முயற்சிக்கவும்.",
|
||||
"geoFail": "உங்கள் இருப்பிடத்தைப் பெற முடியவில்லை: {message}. தயவுசெய்து இருப்பிட சேவைகளை இயக்கவும்.",
|
||||
"successClock": "{location} இல் வெற்றிகரமாக பதிவு செய்யப்பட்டது.",
|
||||
"changePasswordTitle": "கடவுச்சொல்லை மாற்று",
|
||||
"currentPassword": "தற்போதைய கடவுச்சொல்",
|
||||
"newPassword": "புதிய கடவுச்சொல்",
|
||||
"confirmNewPassword": "புதிய கடவுச்சொல்லை உறுதிப்படுத்து",
|
||||
"updating": "புதுப்பிக்கிறது...",
|
||||
"tabPersonnel": "பணியாளர்கள்",
|
||||
"tabAttendance": "வருகை",
|
||||
"tabQrCodes": "QR கோட்கள்",
|
||||
"uploadQrImage": "QR படத்தைப் பதிவேற்று",
|
||||
"couldNotLoadWorkerInfo": "பணியாளர் தகவலைப் பெற முடியவில்லை",
|
||||
"couldNotVerifyStatus": "சர்வரிலிருந்து தற்போதைய நிலைமையை சரிபார்க்க முடியவில்லை",
|
||||
"successfullyClocked": "வெற்றிகரமாக {action} பதிவு செய்யப்பட்டது",
|
||||
"site": "இடத்தில்",
|
||||
"errorOccurred": "பிழை நிகழ்ந்தது",
|
||||
"unableToStartCamera": "கேமராவைத் தொடங்க முடியவில்லை.",
|
||||
"tryAgain": "மீண்டும் முயற்சிக்கவும்",
|
||||
"qrDetectedGettingLocation": "QR கோட் கண்டறியப்பட்டது. இருப்பிடத்தைப் பெறுகிறது...",
|
||||
"geolocationNotSupported": "உங்கள் உலாவியால் புவியிருப்பிடம் ஆதரிக்கப்படவில்லை.",
|
||||
"unableToRetrieveLocation": "உங்கள் இருப்பிடத்தைப் பெற முடியவில்லை: {message}. தயவுசெய்து இருப்பிட சேவைகளை இயக்கவும்.",
|
||||
"qrNotDetectedTryAgain": "QR கோட்டைக் கண்டறிய முடியவில்லை. தயவுசெய்து மீண்டும் முயற்சிக்கவும்.",
|
||||
"updatePassword": "கடவுச்சொல்லைப் புதுப்பிக்கவும்",
|
||||
"passwordsNoMatch": "புதிய கடவுச்சொற்கள் பொருந்தவில்லை.",
|
||||
"passwordTooShort": "புதிய கடவுச்சொல் குறைந்தது 6 எழுத்துக்களாக இருக்க வேண்டும்.",
|
||||
"passwordUpdated": "கடவுச்சொல் வெற்றிகரமாக புதுப்பிக்கப்பட்டது! நீங்கள் இப்போது உங்கள் புதிய கடவுச்சொல்லை உள்நுழைய பயன்படுத்தலாம்.",
|
||||
"passwordUpdateError": "கடவுச்சொல்லை புதுப்பிக்கும்போது பிழை ஏற்பட்டது.",
|
||||
"attendanceLogFor": "வருகை பதிவு -",
|
||||
"addManualClockOut": "கைமுறை வெளியேறல் பதிவு சேர்க்கவும்",
|
||||
"manualClockOutInstruction": "பணியாளர் வெளியேறல் பதிவு செய்ய மறந்தால் இந்த படிவத்தைப் பயன்படுத்தவும். கடைசி நிகழ்வு வருகை பதிவாக இருக்க வேண்டும்.",
|
||||
"clockOutTime": "வெளியேறல் நேரம்",
|
||||
"reason": "காரணம் (எ.கா., \"வெளியேறல் பதிவு செய்ய மறந்துவிட்டார்\")",
|
||||
"enterBriefNote": "சுருக்கமான குறிப்பை உள்ளிடவும்",
|
||||
"addRecord": "பதிவு சேர்க்கவும்",
|
||||
"startDate": "தொடக்க தேதி",
|
||||
"endDate": "இறுதி தேதி",
|
||||
"filterRecords": "பதிவுகளை வடிகட்டு",
|
||||
"event": "நிகழ்வு",
|
||||
"timestamp": "நேர முத்திரை",
|
||||
"locationName": "இடத்தின் பெயர்",
|
||||
"coordinates": "ஆயத்தொலைவுகள்",
|
||||
"notes": "குறிப்புகள்",
|
||||
"noRecordsFound": "இந்த காலத்திற்கான பதிவுகள் எதுவும் கிடைக்கவில்லை.",
|
||||
"showOnMap": "வரைபடத்தில் காட்டு",
|
||||
"nA": "கிடையாது",
|
||||
"pleaseSelectTimestamp": "தயவுசெய்து வெளியேறலுக்கான நேர முத்திரையைத் தேர்ந்தெடுக்கவும்.",
|
||||
"pleaseProvideReason": "தயவுசெய்து கைமுறை பதிவிற்கான காரணம்/குறிப்பை வழங்கவும்.",
|
||||
"manualClockOutSuccess": "கைமுறை வெளியேறல் வெற்றிகரமாக பதிவு செய்யப்பட்டது!",
|
||||
"manualClockOutError": "பிழை ஏற்பட்டது: {message}",
|
||||
"selectWorkers": "1. பணியாளர்களைத் தேர்ந்தெடுக்கவும்",
|
||||
"searchWorkerPlaceholder": "ஒரு பணியாளரைத் தேடவும்...",
|
||||
"selectAll": "அனைத்தையும் தேர்ந்தெடு",
|
||||
"addWorkersByTag": "ஒரு டேக்கிலிருந்து அனைத்து பணியாளர்களையும் சேர்க்கவும்",
|
||||
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
|
||||
"addByTag": "டேக் மூலம் சேர்க்கவும்",
|
||||
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
|
||||
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
|
||||
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
|
||||
"reportSettings": "2. அறிக்கை அமைப்புகள்",
|
||||
"setting": "அமைப்பு",
|
||||
"monthlySalary": "மாதச் சம்பளம் (RM)",
|
||||
"salaryAppliedNote": "தேர்ந்தெடுக்கப்பட்ட அனைத்து பணியாளர்களுக்கும் பயன்படுத்தப்படும்.",
|
||||
"salaryPlaceholder": "எ.கா., 3000",
|
||||
"otFactors": "மேல்நேர காரணிகள்",
|
||||
"weekendFactor": "வாரக்கடைசி காரணி",
|
||||
"holidayFactor": "விடுமுறை காரணி",
|
||||
"selectPublicHolidays": "பொது விடுமுறைகளைத் தேர்ந்தெடுக்கவும்",
|
||||
"generateReport": "வருகை & மேல்நேர அறிக்கையை உருவாக்கு",
|
||||
"overtimePaySummary": "மேல்நேர ஊதிய சுருக்கம்",
|
||||
"exportOtSummary": "மேல்நேர சுருக்கத்தை ஏற்றுமதி செய் (CSV)",
|
||||
"worker": "பணியாளர்",
|
||||
"totalHoursWorked": "மொத்த வேலை நேரங்கள்",
|
||||
"totalOtPay": "மொத்த மேல்நேர ஊதியம் (RM)",
|
||||
"rawAttendanceData": "மூல வருகை தரவு",
|
||||
"loadingReport": "அறிக்கை ஏற்றுகிறது...",
|
||||
"tagLoadError": "தேர்ந்தெடுக்கப்பட்ட டேக்கிற்கான பணியாளர்களை ஏற்ற முடியவில்லை.",
|
||||
"generateReportError": "தயவுசெய்து பணியாளர்களைத் தேர்ந்தெடுத்து, சரியான தேதி வரம்பை அமைத்து, சம்பளத்தை உள்ளிடவும்.",
|
||||
"reportGenerationError": "அறிக்கையை உருவாக்கும்போது பிழை ஏற்பட்டது.",
|
||||
"addNewUser": "புதிய பயனரைச் சேர்க்கவும்",
|
||||
"fullName": "முழு பெயர்",
|
||||
"egJohnSmith": "எ.கா. John Smith",
|
||||
"egJsmith": "எ.கா. jsmith",
|
||||
"eg123456": "எ.கா. 123456",
|
||||
"asManager": "மேலாளராக",
|
||||
"adding": "சேர்க்கிறது...",
|
||||
"addUser": "பயனரைச் சேர்க்கவும்",
|
||||
"manageTags": "டேக்குகளை நிர்வகிக்கவும்",
|
||||
"createNewTag": "புதிய டேக் உருவாக்கவும்",
|
||||
"egTeam": "எ.கா. குழு",
|
||||
"createTag": "டேக் உருவாக்கவும்",
|
||||
"tags": "டேக்குகள்",
|
||||
"workerRoster": "பணியாளர் பட்டியல்",
|
||||
"searchByNameOrUsername": "பெயர் அல்லது பயனர் பெயர் மூலம் தேடவும்",
|
||||
"filterByTag": "டேக் மூலம் வடிகட்டவும்",
|
||||
"clearFilter": "வடிகட்டியைத் துடைக்கவும்",
|
||||
"dateJoined": "சேர்ந்த தேதி",
|
||||
"actions": "செயல்கள்",
|
||||
"editTags": "டேக்குகளைத் திருத்து",
|
||||
"viewRecords": "பதிவுகளைப் பார்க்கவும்",
|
||||
"delete": "நீக்கு",
|
||||
"loadingWorkers": "பணியாளர்கள் ஏற்றப்படுகிறது...",
|
||||
"noWorkersFound": "பணியாளர்கள் எதுவும் கிடைக்கவில்லை.",
|
||||
"previous": "முந்தைய",
|
||||
"next": "அடுத்த",
|
||||
"pageOf": "பக்கம் {current} / {total}",
|
||||
"noTagsAvailable": "டேக்குகள் எதுவும் கிடைக்கவில்லை.",
|
||||
"done": "முடிந்தது",
|
||||
"bulkEditTags": "பல டேக்குகளைத் திருத்து",
|
||||
"clearSelection": "தேர்வைத் துடைக்கவும்",
|
||||
"forUser": "பயனருக்கு",
|
||||
"savePassword": "கடவுச்சொல்லைச் சேமிக்கவும்",
|
||||
"saving": "சேமிக்கிறது...",
|
||||
"failedToUpdateTags": "டேக்குகளை புதுப்பிக்க முடியவில்லை. தயவுசெய்து மீண்டும் முயற்சிக்கவும்.",
|
||||
"tagDeleted": "டேக் வெற்றிகரமாக நீக்கப்பட்டது.",
|
||||
"failedToFetchWorkers": "பணியாளர்களைப் பெற முடியவில்லை.",
|
||||
"failedToLoadPageData": "பக்க தரவை ஏற்ற முடியவில்லை.",
|
||||
"errorAddingUser": "பயனரைச் சேர்க்கும்போது பிழை ஏற்பட்டது.",
|
||||
"failedToDeleteWorker": "பணியாளரை நீக்க முடியவில்லை.",
|
||||
"areYouSureDeleteWorker": "இந்த பணியாளர் கணக்கை நீக்க நீங்கள் உறுதியாக உள்ளீர்களா?",
|
||||
"areYouSureDeleteTag": "இந்த டேக்கை நீக்க நீங்கள் உறுதியாக உள்ளீர்களா? இது அனைத்து பணியாளர்களிடமிருந்தும் அகற்றப்படும்.",
|
||||
"failedToDeleteTag": "டேக்கை நீக்க முடியவில்லை.",
|
||||
"passwordsDoNotMatch": "கடவுச்சொற்கள் பொருந்தவில்லை.",
|
||||
"createQrCode": "புதிய QR கோட் உருவாக்கவும்",
|
||||
"qrCodeName": "QR கோட்டின் பெயர்",
|
||||
"qrNamePlaceholder": "எ.கா., 'மேற்கு வாயில் நுழைவாயில்'",
|
||||
"create": "உருவாக்கு",
|
||||
"newCodeCreated": "புதிய கோட் உருவாக்கப்பட்டது!",
|
||||
"saveQrInstruction": "இந்த படத்தைச் சேமிக்கவும் அல்லது கீழே உள்ள ID ஐப் பயன்படுத்தவும். இது புதுப்பிப்பில் மறைந்துவிடும்.",
|
||||
"id": "ஐடி",
|
||||
"existingQrCodes": "ஏற்கனவே உள்ள QR கோட்கள்",
|
||||
"name": "பெயர்",
|
||||
"status": "நிலைமை",
|
||||
"deactivate": "செயலிழக்கச் செய்",
|
||||
"activate": "செயல்படுத்து",
|
||||
"download": "பதிவிறக்கு",
|
||||
"noQrCodesFound": "QR கோட்கள் எதுவும் கிடைக்கவில்லை. மேலே ஒன்றை உருவாக்கவும்!",
|
||||
"deleteQrConfirm": "இந்த QR கோட்டை நீக்க நீங்கள் உறுதியாக உள்ளீர்களா? இதை மாற்ற முடியாது.",
|
||||
"qrDownloadError": "மன்னிக்கவும், QR கோட்டைப் பதிவிறக்க முடியவில்லை.",
|
||||
"rememberMe": "தானியங்கு உள்நுழைவுக்காக என்னை நினைவில் வைக்கவும்",
|
||||
"deviceNotAuthorized": "இந்த சாதனம் உங்கள் கணக்குக்கு அங்கீகரிக்கப்படவில்லை. தயவுசெய்து உங்கள் நிர்வாகியைத் தொடர்பு கொள்ளவும்.",
|
||||
"locationTrackingActive": "இருப்பிட கண்காணிப்பு பின்னணியில் செயல்படுகிறது",
|
||||
"securityCheckInProgress": "பாதுகாப்பு சரிபார்ப்பு நடைபெறுகிறது...",
|
||||
"securityCheckComplete": "பாதுகாப்பு சரிபார்ப்பு வெற்றிகரமாக முடிவடைந்தது",
|
||||
"highSecurityRisk": "அதிக பாதுகாப்பு அபாயம் கண்டறியப்பட்டது. தயவுசெய்து உங்கள் நிர்வாகியைத் தொடர்பு கொள்ளவும்.",
|
||||
"deviceRegistered": "சாதனம் வெற்றிகரமாக பதிவு செய்யப்பட்டது",
|
||||
"autoLoginEnabled": "இந்த சாதனத்திற்கு தானியங்கு உள்நுழைவு இயக்கப்பட்டது",
|
||||
"backgroundLocationEnabled": "பின்னணி இருப்பிட கண்காணிப்பு இயக்கப்பட்டது",
|
||||
"permissionsRequired": "வருகை கண்காணிப்புக்கு இருப்பிட அனுமதிகள் தேவை",
|
||||
"batteryOptimizationWarning": "தொடர்ச்சியான இருப்பிட கண்காணிப்பை உறுதிப்படுத்த இந்த பயன்பாட்டிற்கு பேட்டரி மேம்படுத்தலை முடக்கவும்",
|
||||
"gpsSpooferDetected": "GPS போலிப் பயன்பாடு கண்டறியப்பட்டது. இது வருகை துல்லியத்தைப் பாதிக்கலாம்.",
|
||||
"mockLocationEnabled": "போலி இருப்பிடம் இயக்கப்பட்டுள்ளது. துல்லியமான வருகை கண்காணிப்புக்கு அதை முடக்கவும்.",
|
||||
"deviceSecurityWarning": "சாதன பாதுகாப்பு எச்சரிக்கை: சந்தேகத்திற்குரிய பயன்பாடுகள் கண்டறியப்பட்டன",
|
||||
"locationUpdateFailed": "இருப்பிடத்தை புதுப்பிக்க முடியவில்லை. தானாகவே மீண்டும் முயற்சிக்கும்.",
|
||||
"servicesInitializing": "அடிப்படை சேவைகளை துவக்குகிறது...",
|
||||
"servicesReady": "அனைத்து சேவைகளும் தயாராக உள்ளன",
|
||||
"autoLoginFailed": "தானியங்கு உள்நுழைவு தோல்வி. தயவுசெய்து கைமுறையாக உள்நுழைக.",
|
||||
"deviceValidationFailed": "சாதன சரிபார்ப்பு தோல்வி. தயவுசெய்து ஆதரவைத் தொடர்பு கொள்ளவும்.",
|
||||
"deviceMismatch": "இந்த சாதனம் உங்கள் கணக்கிற்கு அங்கீகரிக்கப்படவில்லை.",
|
||||
"deviceRegistrationFailed": "சாதன பதிவு தோல்வி. மீண்டும் முயற்சிக்கவும்.",
|
||||
"deviceRequired": "தொழிலாளர் உள்நுழைவிற்கு சாதன பதிவு தேவை.",
|
||||
"servicesStatus": "சேவைகளின் நிலை",
|
||||
"overallStatus": "ஒட்டுமொத்த நிலை",
|
||||
"locationTracking": "இருப்பிட கண்காணிப்பு",
|
||||
"deviceRegistration": "சாதன பதிவு",
|
||||
"securityStatus": "பாதுகாப்பு நிலை",
|
||||
"lastLocationUpdate": "கடைசி இருப்பிட புதுப்பிப்பு",
|
||||
"deviceId": "சாதன ஐடி",
|
||||
"start": "தொடங்கு",
|
||||
"check": "சரிபார்க்கவும்",
|
||||
"checking": "சரிபார்க்கிறது...",
|
||||
"refresh": "புதுப்பிக்கவும்",
|
||||
"refreshing": "புதுப்பிக்கிறது...",
|
||||
"notInitialized": "துவக்கப்படவில்லை",
|
||||
"ready": "தயார்",
|
||||
"webOnly": "வலை மட்டும்",
|
||||
"active": "செயலில்",
|
||||
"inactive": "செயலில் இல்லை",
|
||||
"registered": "பதிவு செய்யப்பட்டது",
|
||||
"pending": "நிலுவையில்",
|
||||
"notChecked": "சரிபார்க்கப்படவில்லை",
|
||||
"outdated": "காலாவதியானது",
|
||||
"current": "தற்போதைய",
|
||||
"never": "ஒருபோதும் இல்லை",
|
||||
"justNow": "இப்போதுதான்",
|
||||
"minutesAgo": "{minutes} நி முன்",
|
||||
"hoursAgo": "{hours} மணி முன்",
|
||||
"daysAgo": "{days} நா முன்",
|
||||
"failedToRefreshStatus": "நிலையை புதுப்பிக்க முடியவில்லை",
|
||||
"locationTrackingStarted": "இருப்பிட கண்காணிப்பு வெற்றிகரமாக தொடங்கப்பட்டது",
|
||||
"failedToStartLocationTracking": "இருப்பிட கண்காணிப்பைத் தொடங்க முடியவில்லை",
|
||||
"securityCheckFailed": "பாதுகாப்பு சரிபார்ப்பு தோல்வி",
|
||||
"personal": "தனிப்பட்ட",
|
||||
"clockHistory": "வருகை வரலாறு",
|
||||
"openCamera": "கேமராவைத் திற",
|
||||
"scanQRCode": "QR கோட் ஸ்கேன் செய்",
|
||||
"services": "சேவைகள்",
|
||||
"systemServicesStatus": "அமைப்பு சேவைகள் மற்றும் பாதுகாப்பு நிலை",
|
||||
"updateYourPassword": "உங்கள் கணக்கு கடவுச்சொல்லை புதுப்பிக்கவும்",
|
||||
"signOutOfAccount": "உங்கள் கணக்கிலிருந்து வெளியேறவும்",
|
||||
"workLocationTracking": "பணியிட இருப்பிட கண்காணிப்பு",
|
||||
"locationTrackingForAttendance": "பணி வருகைக்காக இருப்பிட கண்காணிப்பு செயலில்",
|
||||
"monitoringLocation": "பணி வருகைக்காக இருப்பிடத்தைக் கண்காணிக்கிறது",
|
||||
|
||||
"manualGuide": "கையேடு வழிகாட்டி",
|
||||
"viewUserManual": "வழிமுறைகள் மற்றும் FAQs ஐ படிக்கவும்",
|
||||
"manual": {
|
||||
"android": {
|
||||
"heading": "Android",
|
||||
"faqs": [
|
||||
{
|
||||
"id": "android-location",
|
||||
"title": "Location ஐ எப்படி திறப்பது (Android)",
|
||||
"steps": [
|
||||
"உங்கள் தொலைபேசியில் <strong>Settings</strong> ஐத் திறக்கவும்.",
|
||||
"<strong>Location</strong> க்குச் செல்லவும் <span class=\"text-sm text-gray-500\">(சில தொலைபேசிகளில் <em>Security & privacy</em> கீழ்)</span>.",
|
||||
"<strong>Use location</strong> ஐ ON செய்யவும்.",
|
||||
"<strong>App permissions</strong> ஐத் திறக்கவும் → <strong>Attendance System</strong> ஐக் கண்டறியவும் → <strong>Allow while using the app</strong> என அமைக்கவும்.",
|
||||
"கிடைத்தால் <strong>Precise location</strong> ஐ இயக்கவும்.",
|
||||
"ஆப்பிற்குத் திரும்பி மீண்டும் clock-in செய்ய முயற்சிக்கவும்."
|
||||
],
|
||||
"note": "பிராண்டுகளுக்கு ஏற்ப பெயர்கள் மாறுபடும்: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services."
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Camera permission ஐ இயக்குவது எப்படி (Android)",
|
||||
"steps": [
|
||||
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> ஐத் திறக்கவும்.",
|
||||
"<strong>Permissions</strong> → <strong>Camera</strong> ஐத் தட்டவும் → <strong>Allow</strong> அல்லது <strong>Allow while using the app</strong> தேர்ந்தெடுக்கவும்.",
|
||||
"ஆப்பை மீண்டும் திறந்து scanning செய்ய முயற்சிக்கவும்."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in வேலை செய்யவில்லையா? விரைவு checklist",
|
||||
"steps": [
|
||||
"<strong>Location</strong> ஐ ON செய்து app permission ஐ <strong>Allow while using the app</strong> என அமைக்கவும் (கிடைத்தால் <strong>Precise location</strong> ஐ இயக்கவும்).",
|
||||
"Network ஐ சரிபார்க்கவும்: Wi-Fi அல்லது data இயக்கத்தில் உள்ளது. <strong>Airplane mode</strong> ஐ off→on toggle செய்து, பின்னர் மீண்டும் முயற்சிக்கவும். தடையாக இருந்தால் VPN ஐ முடக்கவும்.",
|
||||
"Android settings இல் <strong>Automatic date & time</strong> மற்றும் <strong>time zone</strong> இயக்கப்பட்டுள்ளதா என உறுதிப்படுத்தவும்.",
|
||||
"Force close செய்து app ஐ மீண்டும் திறக்கவும். தேவைப்பட்டால், <strong>Attendance System</strong> cache ஐ clear செய்யவும் (Settings → Apps → Attendance System → Storage → Clear cache)."
|
||||
],
|
||||
"note": "இன்னும் சிக்கலா? Screenshot எடுத்து உங்கள் manager அல்லது HR ஐ தொடர்பு கொள்ளவும்."
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "விரைவில் வரும்."
|
||||
}
|
||||
},
|
||||
"statusClockedIn": "நீங்கள் வேலைக்கு வந்துள்ளீர்கள்",
|
||||
"statusClockedOut": "நீங்கள் வேலையை விட்டு வெளியேறியுள்ளீர்கள்",
|
||||
"scanToClockIn": "வேலைக்கு வர QR குறியீட்டை ஸ்கேன் செய்யவும்",
|
||||
"scanToClockOut": "வெளியேற QR குறியீட்டை ஸ்கேன் செய்யவும்",
|
||||
"appInformation": "செயலி தகவல்",
|
||||
"version": "பதிப்பு",
|
||||
"platform": "தளம்"
|
||||
}
|
||||
|
||||
@@ -54,6 +54,13 @@ const router = createRouter({
|
||||
component: ManagerPermissions,
|
||||
meta: { requiresAuth: true, role: 'manager' },
|
||||
},
|
||||
{
|
||||
path: '/worker/manual-guide',
|
||||
name: 'ManualGuide',
|
||||
component: () => import('@/views/ManualGuide.vue'),
|
||||
meta: { requiresAuth: true, role: 'worker' }
|
||||
},
|
||||
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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,20 +19,38 @@
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div class="flex flex-col gap-2 flex-grow w-full">
|
||||
<label for="manual-timestamp" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$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" />
|
||||
<label
|
||||
for="manual-timestamp"
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ $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 class="flex flex-col gap-2 flex-grow w-full">
|
||||
<label for="manual-notes" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('reason')
|
||||
}}</label>
|
||||
<input type="text" id="manual-notes" v-model="manualClockOut.notes"
|
||||
<label
|
||||
for="manual-notes"
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ $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"
|
||||
:placeholder="$t('enterBriefNote')" />
|
||||
:placeholder="$t('enterBriefNote')"
|
||||
/>
|
||||
</div>
|
||||
<button @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">
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ $t('addRecord') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -40,23 +58,45 @@
|
||||
|
||||
<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">
|
||||
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $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" />
|
||||
<label
|
||||
for="start-date"
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ $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 class="flex flex-col gap-2 w-full sm:w-auto">
|
||||
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $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" />
|
||||
<label
|
||||
for="end-date"
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ $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>
|
||||
<button @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">
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ $t('filterRecords') }}
|
||||
</button>
|
||||
<button @click="exportRawRecords" :disabled="exportLoading"
|
||||
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
|
||||
@click="exportRawRecords"
|
||||
:disabled="exportLoading"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -87,8 +127,11 @@
|
||||
{{ $t('noRecordsFound') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="record in records" :key="record.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||
<tr
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block px-2 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
|
||||
@@ -96,23 +139,34 @@
|
||||
'bg-green-500': record.event_type === 'clock_in',
|
||||
'bg-red-500': record.event_type === 'clock_out',
|
||||
'bg-yellow-500': record.event_type === 'failed',
|
||||
}">
|
||||
}"
|
||||
>
|
||||
{{ record.event_type.replace('_', ' ') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ new Date(record.timestamp).toLocaleString() }}
|
||||
{{ record.timestamp }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ record.qrCodeUsedName }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.qrCodeUsedName }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a v-if="record.latitude && record.longitude"
|
||||
:href="`https://maps.google.com/?q=${record.latitude},${record.longitude}`" target="_blank"
|
||||
rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 underline font-medium">
|
||||
<a
|
||||
v-if="record.latitude && record.longitude"
|
||||
:href="`https://maps.google.com/?q=${record.latitude},${record.longitude}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:text-blue-800 underline font-medium"
|
||||
>
|
||||
{{ $t('showOnMap') }}
|
||||
</a>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">{{ $t('nA') }}</span>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
{{ $t('nA') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ record.notes || $t('nA') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || $t('nA') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -135,15 +189,59 @@ const records = ref([])
|
||||
const workerName = ref('')
|
||||
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 tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
|
||||
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
|
||||
const localISOTime = new Date(date).toISOString().slice(0, 16)
|
||||
return localISOTime
|
||||
}
|
||||
|
||||
const manualClockOut = ref({
|
||||
timestamp: toLocalISOString(new Date()),
|
||||
notes: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const today = new Date()
|
||||
@@ -152,15 +250,14 @@ setStartDay.setDate(today.getDate() - 60)
|
||||
|
||||
const filters = ref({
|
||||
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 = () => {
|
||||
// Navigate back to the manager dashboard (PersonnelManagement component)
|
||||
window.history.back();
|
||||
};
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
const fetchRecords = async () => {
|
||||
let url = `/api/managers/attendance-records?workerIds=${workerId}`
|
||||
@@ -174,21 +271,19 @@ const fetchRecords = async () => {
|
||||
if (data && Array.isArray(data)) {
|
||||
records.value = data
|
||||
if (!workerName.value && data.length > 0) {
|
||||
// Check if worker data is cached
|
||||
const cachedWorkerData = workerCache.getWorkerData(workerId);
|
||||
const cachedWorkerData = workerCache.getWorkerData(workerId)
|
||||
if (cachedWorkerData) {
|
||||
workerName.value = cachedWorkerData.full_name;
|
||||
workerName.value = cachedWorkerData.full_name
|
||||
} else {
|
||||
workerName.value = data[0].full_name;
|
||||
// Cache the worker data for future use
|
||||
workerCache.storeWorkerData(workerId, { full_name: data[0].full_name });
|
||||
workerName.value = data[0].full_name
|
||||
workerCache.storeWorkerData(workerId, { full_name: data[0].full_name })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
records.value = []
|
||||
}
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch attendance records:',_err)
|
||||
console.error('Failed to fetch attendance records:', _err)
|
||||
alert(_err.message)
|
||||
records.value = []
|
||||
}
|
||||
@@ -208,14 +303,14 @@ const addManualClockOut = async () => {
|
||||
await apiFetch('/api/managers/add-record', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workerId: workerId,
|
||||
eventType: 'clock_out',
|
||||
timestamp: manualClockOut.value.timestamp,
|
||||
notes: manualClockOut.value.notes,
|
||||
}),
|
||||
notes: manualClockOut.value.notes
|
||||
})
|
||||
})
|
||||
|
||||
alert(t('manualClockOutSuccess'))
|
||||
@@ -223,37 +318,45 @@ const addManualClockOut = async () => {
|
||||
manualClockOut.value.timestamp = toLocalISOString(new Date())
|
||||
fetchRecords()
|
||||
} 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 }))
|
||||
}
|
||||
}
|
||||
|
||||
const exportRawRecords = async () => {
|
||||
exportLoading.value = true;
|
||||
const { startDate, endDate } = filters.value;
|
||||
exportLoading.value = true
|
||||
const { startDate, endDate } = filters.value
|
||||
|
||||
const tz = localStorage.getItem('tz') || getUserTimezone()
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}`, {
|
||||
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(
|
||||
tz
|
||||
)}`,
|
||||
{
|
||||
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.');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error('Network response was not ok.')
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (_err) {
|
||||
alert('Failed to export records.');
|
||||
alert('Failed to export records.')
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
exportLoading.value = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Match other pages' blue header -->
|
||||
<header class="fixed left-0 right-0 top-0 z-50 bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6" :style="`padding-top: calc(var(--safe-area-inset-top) + 1.5rem);`">
|
||||
<div class="flex items-center">
|
||||
<button @click="goBack" class="mr-4 p-2 hover:bg-blue-700 rounded-lg transition-colors" aria-label="Back">
|
||||
<ArrowLeftIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<h1 class="text-3xl font-bold">{{ $t('manualGuide') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-with-fixed-header-and-nav p-4 space-y-4">
|
||||
<!-- Android Group -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div class="px-5 pt-5 pb-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ $t('manual.android.heading') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div v-for="item in faqsAndroid" :key="item.id">
|
||||
<details class="group">
|
||||
<summary
|
||||
class="flex items-center justify-between cursor-pointer select-none p-5 focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<span class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<ChevronDownIcon class="w-5 h-5 text-gray-500 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
|
||||
<div class="px-5 pb-5 pt-0 text-gray-700 dark:text-gray-300">
|
||||
<ol v-if="item.steps" class="list-decimal pl-5 space-y-2">
|
||||
<li v-for="(s, i) in item.steps" :key="i" v-html="s"></li>
|
||||
</ol>
|
||||
<p v-if="item.note" class="mt-3 text-sm text-gray-500 dark:text-gray-400" v-html="item.note"></p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- iOS Group -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div class="px-5 pt-5 pb-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ $t('manual.ios.heading') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-5 text-gray-700 dark:text-gray-300">
|
||||
<p class="text-sm">{{ $t('manual.ios.comingSoon') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDownIcon, ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const goBack = () => router.back()
|
||||
|
||||
// Use i18n arrays (we already added these keys in en/ms)
|
||||
const { tm } = useI18n()
|
||||
const faqsAndroid = computed(() => tm('manual.android.faqs') || [])
|
||||
</script>
|
||||
@@ -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');
|
||||
|
||||
+86
-16
@@ -2,11 +2,9 @@
|
||||
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
|
||||
<!-- Back Button -->
|
||||
<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"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
@@ -17,26 +15,43 @@
|
||||
<!-- Empty State -->
|
||||
<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" />
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300">{{ $t('noClockHistory') }}</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">{{ $t('clockHistoryEmptyState') }}</p>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $t('noClockHistory') }}
|
||||
</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">
|
||||
{{ $t('clockHistoryEmptyState') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- History List -->
|
||||
<div v-else class="space-y-4 mt-8 mb-10">
|
||||
<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">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="event.event_type === 'clock_in' ? 'bg-green-100 dark:bg-green-900/50' : 'bg-red-100 dark:bg-red-900/50'">
|
||||
<component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon"
|
||||
:class="['w-8 h-8', event.event_type === 'clock_in' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400']" />
|
||||
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'
|
||||
? 'bg-green-100 dark:bg-green-900/50'
|
||||
: 'bg-red-100 dark:bg-red-900/50'">
|
||||
<component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon" :class="[
|
||||
'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 class="flex-grow">
|
||||
<div class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ $t(event.event_type) }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">{{ event.qrCodeUsedName }}</div>
|
||||
<div class="font-bold text-lg text-gray-900 dark:text-gray-100">
|
||||
{{ $t(event.event_type) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ event.qrCodeUsedName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-medium text-gray-800 dark:text-gray-200">{{ new Date(event.timestamp).toLocaleDateString() }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ new Date(event.timestamp).toLocaleTimeString() }}</div>
|
||||
<div class="font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ event.timestamp }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ event.timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,6 +71,61 @@ const router = useRouter()
|
||||
const clockHistory = ref([])
|
||||
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 () => {
|
||||
if (!userId) {
|
||||
router.push('/')
|
||||
@@ -64,7 +134,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/worker/clock-history/${userId}`)
|
||||
if (data) {
|
||||
clockHistory.value = data.filter(event => event.event_type !== 'failed');
|
||||
clockHistory.value = data.filter(event => event.event_type !== 'failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t('clockHistoryFetchFail'), error)
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
|
||||
<!-- Return Button (same position as settings button in dashboard) -->
|
||||
<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"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
@@ -31,7 +29,8 @@
|
||||
<!-- Settings Menu -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
|
||||
<!-- Clock History -->
|
||||
<router-link to="/worker/history" class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<router-link to="/worker/history"
|
||||
class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||
<ChartBarIcon class="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
@@ -43,7 +42,8 @@
|
||||
</router-link>
|
||||
|
||||
<!-- Change Password -->
|
||||
<router-link to="/worker/change-password" class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<router-link to="/worker/change-password"
|
||||
class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="w-12 h-12 bg-orange-100 dark:bg-orange-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||
<LockClosedIcon class="w-8 h-8 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
@@ -61,12 +61,30 @@
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100 mb-2">{{ $t('language') }}</h3>
|
||||
<select v-model="currentLang" @change="changeLang" class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<select v-model="currentLang" @change="changeLang"
|
||||
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="en">{{ $t('english') }}</option>
|
||||
<option value="ms">{{ $t('malay') }}</option>
|
||||
<option value="tm">{{ $t('tamil') }}</option>
|
||||
<option value="bd">{{ $t('bengali') }}</option>
|
||||
<option value="my">{{ $t('burmese') }}</option>
|
||||
<option value="np">{{ $t('nepali') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Guide (NEW) -->
|
||||
<router-link to="/worker/manual-guide"
|
||||
class="flex items-center p-5 border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||
<BookOpenIcon class="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">{{ $t('manualGuide') }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('viewUserManual') }}</p>
|
||||
</div>
|
||||
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- App Information -->
|
||||
@@ -85,7 +103,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button @click="logout" class="w-full flex items-center justify-center p-5 bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<button @click="logout"
|
||||
class="w-full flex items-center justify-center p-5 bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<div class="w-12 h-12 bg-red-100 dark:bg-red-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||
<ArrowRightOnRectangleIcon class="w-8 h-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
@@ -102,7 +121,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChartBarIcon, LockClosedIcon, LanguageIcon, ArrowRightOnRectangleIcon, ChevronRightIcon, UserIcon } from '@heroicons/vue/24/outline'
|
||||
import { ChartBarIcon, LockClosedIcon, LanguageIcon, ArrowRightOnRectangleIcon, ChevronRightIcon, UserIcon, BookOpenIcon } from '@heroicons/vue/24/outline'
|
||||
// Removed authService dependency for web migration
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
Reference in New Issue
Block a user