8 Commits

Author SHA1 Message Date
Edison 5ffcbc8c71 added manual guide for app 2025-09-29 15:28:50 +08:00
sudomarcma e039b9bfea Implement worker authentication and clocking routes with device UUID validation and geofence checks 2025-09-10 16:18:05 +08:00
sudomarcma 80c9f6ad01 feat: enhance device UUID generation and management, update .gitignore and configuration files 2025-09-03 13:29:49 +08:00
sudomarcma 7345a4e1c8 fix: add lint options to suppress release build checks 2025-09-03 11:11:37 +08:00
sudomarcma bf90ff714c feat(主题): 添加暗黑模式支持并改进WebView兼容性
实现暗黑模式功能,包括:
1. 在Tailwind配置中添加darkMode选项
2. 为所有主要组件添加暗黑样式
3. 创建WebView兼容性工具处理旧版本兼容问题
4. 在设置页面添加暗黑模式切换开关
5. 更新多语言文件支持暗黑模式相关文本
2025-07-24 10:23:57 +08:00
sudomarcma 44c7ea552f 更新.gitignore文件,移除不必要的日志和编辑器配置文件,保留node_modules目录。 2025-07-23 17:28:44 +08:00
sudomarcma e659b2b455 feat:UI enhancements 2025-07-23 17:07:46 +08:00
Edison c04df47418 change wording of malay titles 2025-07-23 12:29:26 +08:00
45 changed files with 2517 additions and 1437 deletions
+4
View File
@@ -0,0 +1,4 @@
VITE_API_BASE_URL=https://10.0.2.2:3443
# VITE_API_BASE_URL=https://myapp.ouji.com/nilai_clock_api
VITE_HTTPS_ENABLED=true
VITE_ALLOW_SELF_SIGNED=true
+5 -29
View File
@@ -1,31 +1,7 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.env
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
android/.idea/deploymentTargetSelector.xml
android/.idea/deploymentTargetSelector.xml
android/app/build.gradle
backend/.env
android/.idea/deploymentTargetSelector.xml
+6
View File
@@ -0,0 +1,6 @@
{
"java.configuration.updateBuildConfiguration": "automatic",
"i18n-ally.localesPaths": [
"src/locales"
]
}
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-03T05:38:10.402873300Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\masongyan\.android\avd\API_30.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>
+1 -1
View File
@@ -8,7 +8,7 @@ android {
minSdk 24
targetSdk 29
versionCode 1
versionName "1.0"
versionName '1.0.1'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.ouji.factory.myapp",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0.1a",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}
-2
View File
@@ -1,5 +1,3 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
+21
View File
@@ -0,0 +1,21 @@
SSL_ENABLED=true
# SSL_KEY_PATH=key.pem
# SSL_CERT_PATH=cert.pem
HTTPS_PORT=3443
HTTP_PORT=3000
# A comma-separated list of allowed origins for CORS.
# Example: https://your-frontend.com,https://another-domain.com
CORS_ALLOWED_ORIGINS=['http://localhost:5173', 'https://localhost:5173', 'capacitor://localhost', 'ionic://localhost', 'http://localhost', 'https://localhost']
DB_HOST=localhost
DB_USER=dev
DB_PASSWORD=678954
DB_NAME=dev
DB_PORT=3306
# JWT Secret - change this to a long, random string
JWT_SECRET=saofopfjaiosdfjioadjfioaspdfjoiaspfjidajf
# Server IP for mobile testing
SERVER_IP=192.168.36.54
+967
View File
@@ -0,0 +1,967 @@
import express from 'express';
import { Parser } from 'json2csv';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
export default function(db) {
const router = express.Router();
// Middleware to authenticate and authorize managers
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err || user.role !== 'manager') {
return res.status(403).json({ message: 'Forbidden' });
}
req.user = { ...user, id: user.userId }; // Correctly map userId to id
next();
});
} else {
res.status(401).json({ message: 'Unauthorized' });
}
};
// Middleware to check for specific permissions
const checkPermission = (requiredPermission) => {
return async (req, res, next) => {
try {
const managerId = req.user.id;
const [rows] = await db.execute(
'SELECT * FROM manager_permissions WHERE manager_id = ?',
[managerId]
);
if (rows.length === 0 || !rows[0][requiredPermission]) {
return res.status(403).json({ message: 'Forbidden: Insufficient permissions.' });
}
next();
} catch (error) {
console.error('Permission check error:', error);
res.status(500).json({ message: 'Database error during permission check.' });
}
};
};
router.use(authenticateJWT);
// --- START: Date Management Routes ---
router.get('/enabled-dates', checkPermission('view_all'), async (req, res) => {
try {
const [rows] = await db.execute('SELECT YEAR(enabled_date) as year, MONTH(enabled_date) as month, DAY(enabled_date) as day FROM enabled_dates');
// Format date safely using components from the database to avoid timezone shifts
const dates = rows.map(r => `${r.year}-${String(r.month).padStart(2, '0')}-${String(r.day).padStart(2, '0')}`);
res.json(dates);
} catch (error) {
console.error('Error fetching enabled dates:', error);
res.status(500).json({ message: 'Database error fetching enabled dates.' });
}
});
// Definitive version using a dedicated database connection
router.post('/enabled-dates/update', checkPermission('manage_resources'), async (req, res) => {
let connection; // Define connection here to ensure it's accessible in the 'finally' block
try {
const { datesToEnable, datesToDisable } = req.body;
if (!Array.isArray(datesToEnable) || !Array.isArray(datesToDisable)) {
return res.status(400).json({ message: 'Invalid input format.' });
}
// 1. Get a single, dedicated connection from the pool
connection = await db.getConnection();
// 2. Process all deletions sequentially on the dedicated connection
for (const date of datesToDisable) {
await connection.execute('DELETE FROM enabled_dates WHERE enabled_date = ?', [date]);
}
// 3. Process all insertions sequentially on the dedicated connection
for (const date of datesToEnable) {
await connection.execute('INSERT IGNORE INTO enabled_dates (enabled_date) VALUES (?)', [date]);
}
res.status(200).json({ message: 'Work schedule updated successfully.' });
} catch (error) {
console.error('Error updating work schedule:', error);
res.status(500).json({ message: 'Database error during schedule update.' });
} finally {
// 4. Ensure the dedicated connection is always released back to the pool
if (connection) {
connection.release();
}
}
});
// --- END: Date Management Routes ---
// --- ATTENDANCE & REPORTING ---
router.get('/failed-records', checkPermission('view_all'), async (req, res) => {
try {
const { search = '', startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
const searchTerm = `%${search}%`;
const params = [startDate, `${endDate} 23:59:59`];
let searchQuery = '';
if (search) {
searchQuery = `AND (w.full_name LIKE ? OR w.department LIKE ?)`;
params.push(searchTerm, searchTerm);
}
const query = `
SELECT cr.worker_id, w.full_name, COUNT(*) as count
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
WHERE cr.event_type = 'failed'
AND cr.timestamp BETWEEN ? AND ?
${searchQuery}
GROUP BY cr.worker_id, w.full_name
ORDER BY count DESC
`;
const [rows] = await db.execute(query, params);
res.json(rows);
} catch (error) {
console.error('Failed records summary error:', error);
res.status(500).json({ message: 'Database error fetching failed records summary.', details: error.message });
}
});
router.get('/failed-records/details', checkPermission('view_all'), async (req, res) => {
try {
const { workerId, startDate, endDate } = req.query;
if (!workerId || !startDate || !endDate) {
return res.status(400).json({ message: 'Worker ID, start date, and end date are required.' });
}
const query = `
SELECT cr.id, cr.timestamp, cr.event_type, COALESCE(qc.name, 'N/A') as qrCodeUsedName, cr.notes
FROM clock_records cr
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
WHERE cr.worker_id = ?
AND cr.event_type = 'failed'
AND cr.timestamp BETWEEN ? AND ?
ORDER BY cr.timestamp DESC
`;
const params = [workerId, startDate, `${endDate} 23:59:59`];
const [rows] = await db.execute(query, params);
res.json(rows);
} catch (error) {
console.error('Failed records details error:', error);
res.status(500).json({ message: 'Database error fetching failed records details.', details: error.message });
}
});
// GET attendance records with a modified query to avoid the MySQL 5.7 bug
router.get('/attendance-records/export-raw', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
let workerIdClause = '';
const params = [startDate, `${endDate} 23:59:59`];
if (workerIds) {
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
if (idsArray.length > 0) {
workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`;
}
}
const query = `
SELECT w.username, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qr_code_name, cr.notes
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}
ORDER BY cr.timestamp DESC
`;
const [rows] = await db.execute(query, params);
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] });
const csv = json2csvParser.parse(rows);
res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv);
} catch (error) {
console.error('Raw attendance export error:', error);
res.status(500).json({ message: 'Database error exporting raw attendance.', details: error.message });
}
});
router.post('/add-record', checkPermission('edit_workers'), async (req, res) => {
try {
const { workerId, eventType, timestamp, notes } = req.body
if (!workerId || !eventType || !timestamp) {
return res
.status(400)
.json({ message: 'Worker ID, event type, and timestamp are required.' })
}
// Check last event to prevent adding a duplicate event type
const [lastEventRows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[workerId],
)
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
const status = eventType === 'clock_in' ? 'in' : 'out'
return res.status(409).json({ message: `Worker is already clocked ${status}.` })
}
// --- THIS IS THE FIX ---
const sanitizedTimestamp = timestamp.replace('T', ' ')
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)',
[workerId, eventType, sanitizedTimestamp, notes],
)
res.status(201).json({ message: 'Manual record added successfully.' })
} catch (error) {
console.error('Add manual record error:', error)
res.status(500).json({ message: 'Database error adding manual record.' })
}
})
router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
let workerIdClause = '';
const params = [startDate, `${endDate} 23:59:59`];
if (workerIds) {
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
if (idsArray.length > 0) {
workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`;
}
}
const query = `
SELECT cr.worker_id, w.username, w.full_name, cr.event_type, cr.timestamp
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}
ORDER BY cr.worker_id, cr.timestamp ASC
`;
const [rows] = await db.execute(query, params);
const workHoursByWorkerAndDay = {};
rows.forEach(row => {
const day = new Date(row.timestamp).toISOString().split('T')[0];
if (!workHoursByWorkerAndDay[row.worker_id]) {
workHoursByWorkerAndDay[row.worker_id] = {
username: row.username,
full_name: row.full_name,
days: {}
};
}
if (!workHoursByWorkerAndDay[row.worker_id].days[day]) {
workHoursByWorkerAndDay[row.worker_id].days[day] = [];
}
workHoursByWorkerAndDay[row.worker_id].days[day].push({
type: row.event_type,
time: new Date(row.timestamp)
});
});
const csvData = [];
for (const workerId in workHoursByWorkerAndDay) {
const workerData = workHoursByWorkerAndDay[workerId];
for (const day in workerData.days) {
const events = workerData.days[day];
let dailyTotalSeconds = 0;
let lastClockIn = null;
events.forEach(event => {
if (event.type === 'clock_in') {
lastClockIn = event.time;
} else if (event.type === 'clock_out' && lastClockIn) {
dailyTotalSeconds += (event.time - lastClockIn) / 1000;
lastClockIn = null;
}
});
csvData.push({
username: workerData.username,
full_name: workerData.full_name,
date: day,
work_hours: (dailyTotalSeconds / 3600).toFixed(2)
});
}
}
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'date', 'work_hours'] });
const csv = json2csvParser.parse(csvData);
res.header('Content-Type', 'text/csv').attachment(`work_hours_${startDate}_to_${endDate}.csv`).send(csv);
} catch (error) {
console.error('Work hours export error:', error);
res.status(500).json({ message: 'Database error exporting work hours.', details: error.message });
}
});
router.get('/attendance-records', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate, format } = req.query;
if (!workerIds) {
return res.status(400).json({ message: 'Worker IDs are required.' });
}
// Ensure all IDs are numbers to prevent SQL injection.
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
if (idsArray.length === 0) {
return res.json([]);
}
// --- MODIFICATION START ---
// Instead of using a '?' placeholder for the IN clause, we build it directly.
// This is safe because we have already sanitized idsArray to be only numbers.
// This change is intended to bypass the specific bug in your MySQL version.
const inClause = idsArray.join(',');
let query = `
SELECT cr.id, w.full_name, cr.event_type, cr.timestamp,
COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName,
cr.latitude, cr.longitude, cr.notes
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
WHERE cr.worker_id IN (${inClause})`; // Placeholder is replaced here
const params = [];
// --- MODIFICATION END ---
if (startDate && endDate) {
query += ' AND cr.timestamp BETWEEN ? AND ?';
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
params.push(startDate, endOfDay);
}
query += ' ORDER BY w.full_name, cr.timestamp DESC';
const [rows] = await db.execute(query, params);
if (format === 'csv') {
const json2csvParser = new Parser({ fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'] });
const csv = json2csvParser.parse(rows);
res.header('Content-Type', 'text/csv').attachment('attendance.csv').send(csv);
} else {
res.json(rows);
}
} catch (error) {
console.error('Attendance records error:', error);
res.status(500).json({ message: 'Database error fetching attendance records.', details: error.message });
}
});
// --- All other manager routes remain the same ---
// GET a specific manager's permissions
router.get('/permissions/:id', async (req, res) => {
try {
const requesterId = req.user.id;
const targetId = parseInt(req.params.id, 10);
// Check if the user is trying to access their own permissions
if (requesterId !== targetId) {
// If not, check if they have permission to manage permissions
const [permissionRows] = await db.execute(
'SELECT can_manage_permissions FROM manager_permissions WHERE manager_id = ?',
[requesterId]
);
if (permissionRows.length === 0 || !permissionRows[0].can_manage_permissions) {
return res.status(403).json({ message: 'Forbidden: Insufficient permissions to view others\' permissions.' });
}
}
// If they are accessing their own, or have permission, fetch the target's permissions
const [rows] = await db.execute(
'SELECT * FROM manager_permissions WHERE manager_id = ?',
[targetId]
);
if (rows.length === 0) {
// If no permissions are set, return a default set of all false
const [fields] = await db.execute('DESCRIBE manager_permissions');
const defaultPermissions = fields.reduce((acc, field) => {
if (field.Field !== 'manager_id') {
acc[field.Field] = 0; // Use 0 for false
}
return acc;
}, {});
return res.json(defaultPermissions);
}
// Convert buffer values to booleans
const permissions = Object.entries(rows[0]).reduce((acc, [key, value]) => {
if (key !== 'manager_id') {
acc[key] = Boolean(value);
}
return acc;
}, {});
res.json(permissions);
} catch (error) {
console.error('Get manager permissions error:', error);
res.status(500).json({ message: 'Database error fetching manager permissions.', details: error.message });
}
});
// PUT (update) a manager's permissions
router.put('/permissions/:id', checkPermission('manager_permissions'), async (req, res) => {
try {
const { id } = req.params;
const permissions = req.body;
const fields = [
'view_all', 'edit_workers', 'manage_resources', 'manager_permissions'
];
const values = fields.map(field => permissions[field] || false);
// Convert to new simplified permissions schema
const query = `
INSERT INTO manager_permissions (manager_id, view_all, edit_workers, manage_resources, manager_permissions)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
view_all = VALUES(view_all),
edit_workers = VALUES(edit_workers),
manage_resources = VALUES(manage_resources),
manager_permissions = VALUES(manager_permissions)
`;
const queryParams = [id, ...values];
await db.execute(query, queryParams);
res.status(200).json({ message: 'Permissions updated successfully.' });
} catch (error) {
console.error('Update manager permissions error:', error);
res.status(500).json({ message: 'Database error updating manager permissions.', details: error.message });
}
});
// GET all workers with filtering and pagination
router.get('/workers', checkPermission('view_all'), async (req, res) => {
try {
const { search = '', page = 1, limit = 20 } = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const searchTerm = `%${search}%`;
let baseQuery = `
SELECT w.id, w.username, w.full_name, w.department, w.position, w.created_at, w.status
FROM workers w
`;
let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`;
const params = [];
const countParams = [];
let whereClauses = ["w.role = 'worker'", "w.status != 'deleted'"]; // Filter out soft-deleted workers
if (search) {
whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`);
params.push(searchTerm, searchTerm);
countParams.push(searchTerm, searchTerm);
}
if (whereClauses.length > 0) {
const whereString = ` WHERE ${whereClauses.join(' AND ')}`;
baseQuery += whereString;
countQuery += whereString;
}
baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?`;
params.push(parseInt(limit), offset);
const [workers] = await db.execute(baseQuery, params);
const [[{ totalCount }]] = await db.execute(countQuery, countParams);
res.json({ workers, totalCount });
} catch (error) {
console.error('Get workers error:', error);
res.status(500).json({ message: 'Database error fetching workers.', details: error.message });
}
});
// GET all managers with their permissions
router.get('/managers', checkPermission('manager_permissions'), async (req, res) => {
try {
const { search = '', page = 1, limit = 20 } = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const searchTerm = `%${search}%`;
let baseQuery = `
SELECT
w.id, w.username, w.full_name, w.department, w.position, w.created_at, w.status,
mp.*
FROM workers w
LEFT JOIN manager_permissions mp ON w.id = mp.manager_id
`;
let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`;
const params = [];
const countParams = [];
let whereClauses = ["w.role = 'manager'", "w.status != 'deleted'"];
if (search) {
whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`);
params.push(searchTerm, searchTerm);
countParams.push(searchTerm, searchTerm);
}
if (whereClauses.length > 0) {
const whereString = ` WHERE ${whereClauses.join(' AND ')}`;
baseQuery += whereString;
countQuery += whereString;
}
baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?`;
params.push(parseInt(limit), offset);
const [managers] = await db.execute(baseQuery, params);
const [[{ totalCount }]] = await db.execute(countQuery, countParams);
res.json({ managers, totalCount });
} catch (error) {
console.error('Get managers error:', error);
res.status(500).json({ message: 'Database error fetching managers.', details: error.message });
}
});
// POST (add) a new manager
router.post('/managers', checkPermission('manager_permissions'), async (req, res) => {
try {
const { username, password, fullName, department, position } = req.body;
if (!username || !password || !fullName) {
return res.status(400).json({ message: 'Username, password, and full name are required.' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const [result] = await db.execute(
'INSERT INTO workers (username, password_hash, full_name, role, department, position, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
[username, hashedPassword, fullName, 'manager', department, position, 'active']
);
// Set default view_all permission
await db.execute(
'INSERT INTO manager_permissions (manager_id, view_all) VALUES (?, ?)',
[result.insertId, true]
);
res.status(201).json({
id: result.insertId,
username,
fullName,
role: 'manager',
department,
position,
status: 'active',
view_all: true
});
} catch (error) {
console.error('Add manager error:', error);
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'Username already exists.' });
}
res.status(500).json({ message: 'Database error adding manager.', details: error.message });
}
});
// POST (add) a new worker
router.post('/workers', checkPermission('edit_workers'), async (req, res) => {
try {
const { username, password, fullName, department, position, role = 'worker' } = req.body;
if (!username || !password || !fullName) {
return res.status(400).json({ message: 'Username, password, and full name are required.' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const [result] = await db.execute(
'INSERT INTO workers (username, password_hash, full_name, role, department, position, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
[username, hashedPassword, fullName, role, department, position, 'active'] // Default status to 'active'
);
res.status(201).json({ id: result.insertId, username, fullName, role, department, position, status: 'active' });
} catch (error) {
console.error('Add worker error:', error);
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'Username already exists.' });
}
res.status(500).json({ message: 'Database error adding worker.', details: error.message });
}
});
// Soft DELETE a worker (update status to 'deleted')
router.delete('/workers/:id', checkPermission('edit_workers'), async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute("UPDATE workers SET status = 'deleted' WHERE id = ? AND role = 'worker'", [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found or already deleted.' });
}
res.status(204).send(); // Maintain existing response for client compatibility
} catch (error) {
console.error('Soft delete worker error:', error);
res.status(500).json({ message: 'Database error soft deleting worker.', details: error.message });
}
});
// Soft DELETE a manager (update status to 'deleted')
router.delete('/managers/:id', checkPermission('manager_permissions'), async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute("UPDATE workers SET status = 'deleted' WHERE id = ? AND role = 'manager'", [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Manager not found or already deleted.' });
}
res.status(204).send();
} catch (error) {
console.error('Soft delete manager error:', error);
res.status(500).json({ message: 'Database error soft deleting manager.', details: error.message });
}
});
// PUT (update) a worker's details (department, position, status)
router.put('/workers/:id', checkPermission('edit_workers'), async (req, res) => {
try {
const { id } = req.params;
const { department, position, status } = req.body;
// Basic validation
if (!department && !position && !status) {
return res.status(400).json({ message: 'No update information provided.' });
}
if (status && !['active', 'inactive'].includes(status)) {
return res.status(400).json({ message: 'Invalid status value.' });
}
let updateQuery = 'UPDATE workers SET';
const params = [];
const fieldsToUpdate = [];
if (department) {
fieldsToUpdate.push('department = ?');
params.push(department);
}
if (position) {
fieldsToUpdate.push('position = ?');
params.push(position);
}
if (status) {
fieldsToUpdate.push('status = ?');
params.push(status);
}
updateQuery += ` ${fieldsToUpdate.join(', ')} WHERE id = ? AND role = 'worker'`;
params.push(id);
const [result] = await db.execute(updateQuery, params);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.status(200).json({ message: 'Worker details updated successfully.' });
} catch (error) {
console.error('Update worker details error:', error);
res.status(500).json({ message: 'Database error updating worker details.', details: error.message });
}
});
// PUT (update) a manager's details (department, position, status)
router.put('/managers/:id', checkPermission('manager_permissions'), async (req, res) => {
try {
const { id } = req.params;
const { department, position, status } = req.body;
// Basic validation
if (!department && !position && !status) {
return res.status(400).json({ message: 'No update information provided.' });
}
if (status && !['active', 'inactive'].includes(status)) {
return res.status(400).json({ message: 'Invalid status value.' });
}
let updateQuery = 'UPDATE workers SET';
const params = [];
const fieldsToUpdate = [];
if (department) {
fieldsToUpdate.push('department = ?');
params.push(department);
}
if (position) {
fieldsToUpdate.push('position = ?');
params.push(position);
}
if (status) {
fieldsToUpdate.push('status = ?');
params.push(status);
}
updateQuery += ` ${fieldsToUpdate.join(', ')} WHERE id = ? AND role = 'manager'`;
params.push(id);
const [result] = await db.execute(updateQuery, params);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Manager not found.' });
}
res.status(200).json({ message: 'Manager details updated successfully.' });
} catch (error) {
console.error('Update manager details error:', error);
res.status(500).json({ message: 'Database error updating manager details.', details: error.message });
}
});
// PUT (update) a worker's password
router.put('/workers/:workerId/password', checkPermission('edit_workers'), async (req, res) => {
try {
const { workerId } = req.params;
const { newPassword } = req.body;
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ message: 'Password must be at least 6 characters long.' });
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
const [result] = await db.execute("UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'worker'", [hashedPassword, workerId]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.status(200).json({ message: 'Password updated successfully.' });
} catch (error) {
console.error('Update password error:', error);
res.status(500).json({ message: 'Database error updating password.', details: error.message });
}
});
// PUT (update) a manager's password
router.put('/managers/:managerId/password', checkPermission('manager_permissions'), async (req, res) => {
try {
const { managerId } = req.params;
const { newPassword } = req.body;
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ message: 'Password must be at least 6 characters long.' });
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
const [result] = await db.execute("UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'manager'", [hashedPassword, managerId]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Manager not found.' });
}
res.status(200).json({ message: 'Password updated successfully.' });
} catch (error) {
console.error('Update manager password error:', error);
res.status(500).json({ message: 'Database error updating manager password.', details: error.message });
}
});
// PUT (clear) a worker's device UUID and/or update status
router.put('/workers/:workerId/reset-device', checkPermission('edit_workers'), async (req, res) => {
try {
const { workerId } = req.params;
const { status } = req.body; // Optional status field
let updateQuery = "UPDATE workers SET device_uuid = NULL";
const params = [workerId];
if (status && ['active', 'inactive', 'deleted'].includes(status)) {
updateQuery += ", status = ?";
params.unshift(status); // Add status to the beginning of params for correct order
}
updateQuery += " WHERE id = ?";
const [result] = await db.execute(updateQuery, params);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.status(200).json({ message: 'Device registration cleared and/or status updated.' });
} catch (error) {
console.error('Reset device/update status error:', error);
res.status(500).json({ message: 'Database error resetting device or updating status.', details: error.message });
}
});
// Geofence Management Routes
router.get('/geofences', checkPermission('view_all'), async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, coordinates, is_active, created_at FROM geofences ORDER BY created_at DESC'
);
const geofences = rows.map(row => ({
...row,
coordinates: JSON.parse(row.coordinates || '[]')
}));
res.json(geofences);
} catch (error) {
console.error('Get geofences error:', error);
res.status(500).json({ message: 'Database error fetching geofences.', details: error.message });
}
});
router.post('/geofences', checkPermission('manage_resources'), async (req, res) => {
try {
const { name, coordinates } = req.body;
if (!name || !coordinates) {
return res.status(400).json({ message: 'Geofence name and coordinates are required.' });
}
const [result] = await db.execute(
'INSERT INTO geofences (name, coordinates, is_active) VALUES (?, ?, ?)',
[name, JSON.stringify(coordinates), true]
);
const newGeofence = {
id: result.insertId,
name,
coordinates,
is_active: true,
};
res.status(201).json(newGeofence);
} catch (error) {
console.error('Add geofence error:', error);
res.status(500).json({ message: 'Database error adding geofence.', details: error.message });
}
});
router.put('/geofences/:id', checkPermission('manage_resources'), async (req, res) => {
try {
const { id } = req.params;
const { is_active } = req.body;
if (typeof is_active !== 'boolean') {
return res.status(400).json({ message: 'is_active must be a boolean.' });
}
const [result] = await db.execute(
'UPDATE geofences SET is_active = ? WHERE id = ?',
[is_active, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Geofence not found.' });
}
res.json({ id, is_active });
} catch (error) {
console.error('Update geofence error:', error);
res.status(500).json({ message: 'Database error updating geofence.', details: error.message });
}
});
router.delete('/geofences/:id', checkPermission('manage_resources'), async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute('DELETE FROM geofences WHERE id = ?', [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Geofence not found.' });
}
res.status(204).send();
} catch (error) {
console.error('Delete geofence error:', error);
res.status(500).json({ message: 'Database error deleting geofence.', details: error.message });
}
});
// QR Code Management Routes
router.get('/qr-codes', checkPermission('view_all'), async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC'
);
res.json(rows);
} catch (error) {
console.error('Get QR codes error:', error);
res.status(500).json({ message: 'Database error fetching QR codes.' });
}
});
router.post('/qr-codes', checkPermission('manage_resources'), async (req, res) => {
try {
const { name } = req.body;
if (!name) return res.status(400).json({ message: 'QR Code name is required.' });
const newQrCode = {
id: uuidv4(),
name,
is_active: true
};
await db.execute(
'INSERT INTO qr_codes (id, name, is_active) VALUES (?, ?, ?)',
[newQrCode.id, newQrCode.name, newQrCode.is_active]
);
res.status(201).json(newQrCode);
} catch (error) {
console.error('Add QR code error:', error);
res.status(500).json({ message: 'Database error adding QR code.' });
}
});
router.put('/qr-codes/:id', checkPermission('manage_resources'), async (req, res) => {
try {
const { id } = req.params;
// Handle both isActive (camelCase) and is_active (snake_case)
const is_active = req.body.is_active ?? req.body.isActive;
if (typeof is_active !== 'boolean') {
return res.status(400).json({ message: 'Status must be a boolean value.' });
}
const [result] = await db.execute(
'UPDATE qr_codes SET is_active = ? WHERE id = ?',
[is_active, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'QR Code not found.' });
}
res.json({ id, is_active });
} catch (error) {
console.error('Update QR code error:', error);
res.status(500).json({ message: 'Database error updating QR code.' });
}
});
router.delete('/qr-codes/:id', checkPermission('manage_resources'), async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute(
'DELETE FROM qr_codes WHERE id = ?',
[id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'QR Code not found.' });
}
res.status(204).send();
} catch (error) {
console.error('Delete QR code error:', error);
res.status(500).json({ message: 'Database error deleting QR code.' });
}
});
return router;
}
@@ -1,54 +0,0 @@
-- OPTIMIZATION: Simplify location_updates table schema
-- Remove redundant and unnecessary fields for better performance
-- Step 1: Create new optimized table structure
CREATE TABLE `location_updates_new` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`longitude` decimal(11,8) NOT NULL COMMENT 'Longitude first for geographic convention',
`latitude` decimal(10,8) NOT NULL COMMENT 'Latitude second for geographic convention',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Single timestamp field',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_created_at` (`created_at`),
KEY `idx_user_created` (`user_id`, `created_at`) COMMENT 'Composite index for user location history'
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Optimized location updates - essential fields only';
-- Step 2: Migrate existing data (longitude, latitude order)
INSERT INTO `location_updates_new` (`user_id`, `longitude`, `latitude`, `created_at`)
SELECT `user_id`, `longitude`, `latitude`, `created_at`
FROM `location_updates`
ORDER BY `created_at` ASC;
-- Step 3: Backup old table and replace with new one
RENAME TABLE `location_updates` TO `location_updates_backup`;
RENAME TABLE `location_updates_new` TO `location_updates`;
-- Step 4: Update the view to work with new schema
DROP VIEW IF EXISTS `recent_location_updates`;
CREATE VIEW `recent_location_updates` AS
SELECT
`lu`.`id` AS `id`,
`lu`.`user_id` AS `user_id`,
`lu`.`longitude` AS `longitude`,
`lu`.`latitude` AS `latitude`,
`lu`.`created_at` AS `created_at`,
`w`.`username` AS `username`,
`w`.`full_name` AS `full_name`,
TIMESTAMPDIFF(MINUTE, `lu`.`created_at`, NOW()) AS `minutes_ago`
FROM (`location_updates` `lu`
JOIN `workers` `w` ON((`lu`.`user_id` = `w`.`id`)))
WHERE (`lu`.`created_at` > (NOW() - INTERVAL 24 HOUR))
ORDER BY `lu`.`created_at` DESC;
-- Step 5: Add comment about optimization
ALTER TABLE `location_updates` COMMENT = 'Optimized for 30-minute updates - essential fields only (longitude, latitude, created_at)';
-- Verification queries (run these to verify the migration)
-- SELECT COUNT(*) as old_count FROM location_updates_backup;
-- SELECT COUNT(*) as new_count FROM location_updates;
-- SELECT * FROM location_updates ORDER BY created_at DESC LIMIT 5;
-- SELECT * FROM recent_location_updates LIMIT 5;
-- Note: After verifying the migration is successful, you can drop the backup table:
-- DROP TABLE `location_updates_backup`;
+52 -1181
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
import express from 'express';
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
// Removed unused import
async function validateDeviceForUser(userId, deviceUuid, db) {
const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]);
if (userRows.length === 0) return { valid: false, message: 'User not found' };
const { device_uuid } = userRows[0];
if (!device_uuid) {
await db.execute('UPDATE workers SET device_uuid = ? WHERE id = ?', [deviceUuid, userId]);
return { valid: true, message: 'Device registered successfully' };
}
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]);
return rows.length > 0;
}
export default function(db) {
const router = express.Router();
// ===== DEVICE UUID CONFIGURATION =====
// Set DEVICE_UUID_ENABLED to false to completely disable device UUID checking
const DEVICE_UUID_ENABLED = true; // Master switch: enables/disables all UUID functionality
const REQUIRE_DEVICE_FOR_WORKERS = true; // When true, workers must have a device UUID to login
const AUTO_REGISTER_NEW_DEVICES = true; // When true, automatically registers new device UUIDs
// =====================================
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];
// Allow both workers and managers to login
// 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' });
}
// 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) {
// User already has a registered device
if (deviceUuid && deviceUuid !== existingDeviceUuid) {
// Different device trying to login
return res.status(403).json({ message: 'deviceMismatch' });
} else if (!deviceUuid) {
// Web login attempt when device is registered
return res.status(403).json({ message: 'useMobileApp' });
}
} else {
// User has no registered device
if (deviceUuid && AUTO_REGISTER_NEW_DEVICES) {
// Register new device
const deviceResult = await validateDeviceForUser(user.id, deviceUuid, db);
if (!deviceResult.valid) {
return res.status(500).json({ message: 'deviceRegistrationFailed' });
}
console.log(`Device UUID registered for worker ${user.id}: ${deviceUuid}`);
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
// No device provided but device is required
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 });
});
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
req.user = { ...user, id: user.userId }; // Correctly map userId to id
next();
});
} else {
res.status(401).json({ message: 'Authorization header required' });
}
};
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) => {
try {
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
const currentTimestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
// 1. Kill Switch Enforcement
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]
);
return res.status(403).json({ message: 'error.clockingDisabled' });
}
// 2. Geofence Validation with Distance Calculation
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]);
return res.status(403).json({ message: 'error.noActiveGeofence' });
}
const userLocation = point([longitude, latitude]);
const parsedPolygons = [];
let isInside = false;
for (const fence of activeFences) {
try {
if (!fence.coordinates) continue;
const coordinates = JSON.parse(fence.coordinates);
const fencePolygon = polygon([coordinates]);
parsedPolygons.push(fencePolygon); // Save for distance calculation
if (booleanPointInPolygon(userLocation, fencePolygon)) {
isInside = true;
break;
}
} catch (e) {
console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e });
}
}
if (!isInside) {
let minDistance = Infinity;
for (const p of parsedPolygons) {
const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' });
if (distance < minDistance) {
minDistance = distance;
}
}
const distanceString = minDistance.toFixed(2);
const note = `Outside geofence by ${distanceString}m`;
await db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]);
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
}
}
// 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) {
return res.status(400).json({ message: 'error.invalidQrCode' });
}
}
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
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
[userId, eventType, qrCodeValue, latitude, longitude, currentTimestamp]
);
res.status(201).json({ message: 'Clock event recorded.' });
} catch (error) {
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
res.status(500).json({ message: 'error.criticalServer' });
}
});
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.' });
}
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' });
});
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);
});
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 [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) => {
// Do nothing, always return location updated
res.json({ message: 'Location updated.' });
});
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);
});
router.post('/device/validate', async (req, res) => {
const { userId, deviceUuid } = req.body;
const result = await validateDeviceForUser(userId, deviceUuid, db);
res.json(result);
});
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,
});
});
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));
});
return router;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>Vite App</title>
<script type="module" crossorigin src="/assets/index-DOxYJapr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-PWd6qU--.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+32 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-gray-100 text-gray-900">
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-300">
<!-- App Blocker Overlay -->
<div v-if="isBlocked" class="blocker-overlay">
<div class="blocker-content">
@@ -15,6 +15,8 @@
<!-- Bottom Navigation (only show for worker routes) -->
<BottomNavigation v-if="showBottomNav && !isBlocked" />
</div>
</template>
@@ -28,6 +30,7 @@ import { SafeArea } from '@capacitor-community/safe-area'
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
import { authService } from '@/services/authService.js'
import BottomNavigation from '@/components/BottomNavigation.vue'
import { initWebViewCompatibility, applyThemeWithCompat, getSystemDarkModePreference } from '@/utils/webviewCompat'
const { locale } = useI18n()
@@ -37,7 +40,26 @@ const route = useRoute()
const isLoggedIn = ref(false)
const isBlocked = ref(false)
const blockMessage = ref('')
// Theme initialization with WebView compatibility
const updateTheme = (isDark) => {
applyThemeWithCompat(isDark)
}
const initializeTheme = () => {
// Initialize WebView compatibility first
initWebViewCompatibility()
// Check for saved theme preference or default to light mode
const savedTheme = localStorage.getItem('theme')
let isDark = false
if (savedTheme) {
isDark = savedTheme === 'dark'
} else {
// Check system preference with WebView compatibility
isDark = getSystemDarkModePreference()
}
updateTheme(isDark)
}
// Show bottom navigation only for worker routes
const showBottomNav = computed(() => {
@@ -112,6 +134,9 @@ watch(
)
onMounted(async () => {
// Initialize theme
initializeTheme()
// Add app blocked event listener
window.addEventListener('app-blocked', handleAppBlocked)
window.addEventListener('user-forced-clock-out', handleForcedClockOut)
@@ -178,6 +203,12 @@ onMounted(async () => {
sessionStorage.setItem('token', token)
}
// Get cached worker data to retrieve username
const cachedWorkerData = await authService.getCachedWorkerData(autoLoginResult.userId);
if (cachedWorkerData && cachedWorkerData.username) {
sessionStorage.setItem('username', cachedWorkerData.username);
}
// Start native services
await nativeServicesManager.onUserLogin()
+204
View File
@@ -100,3 +100,207 @@
padding-bottom: calc(var(--safe-area-inset-bottom) + 4rem); /* 4rem = bottom nav height */
}
}
/* Dark mode overrides */
@layer base {
html.dark {
color-scheme: dark;
}
/* Smooth transitions for theme changes */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* Dark mode scrollbar styling */
html.dark ::-webkit-scrollbar {
width: 8px;
}
html.dark ::-webkit-scrollbar-track {
background: #374151;
}
html.dark ::-webkit-scrollbar-thumb {
background: #6b7280;
border-radius: 4px;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
}
/* WebView Compatibility Styles */
@layer utilities {
/* Force specific styles for older WebView versions */
.webview-compat {
/* Background colors */
--bg-color: #ffffff;
--text-color: #000000;
--card-bg: #ffffff;
--gray-bg: #f3f4f6;
--text-gray: #6b7280;
--border-color: #e5e7eb;
--nav-bg: #ffffff;
--nav-border: #e5e7eb;
}
.webview-compat.dark {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--card-bg: #2d2d2d;
--gray-bg: #1f2937;
--text-gray: #d1d5db;
--border-color: #404040;
--nav-bg: #1f2937;
--nav-border: #374151;
}
/* Apply fallback styles for older WebView */
.webview-compat .bg-white {
background-color: #ffffff;
}
.webview-compat.dark .bg-white {
background-color: #2d2d2d !important;
}
.webview-compat .bg-gray-100 {
background-color: #f3f4f6;
}
.webview-compat.dark .bg-gray-100 {
background-color: #1a1a1a !important;
}
.webview-compat .bg-gray-800 {
background-color: #1f2937;
}
.webview-compat.dark .bg-gray-800 {
background-color: #2d2d2d !important;
}
/* Navigation bar specific styles for WebView compatibility */
.webview-compat .bottom-nav-content {
background-color: #ffffff !important;
border-top: 1px solid #e5e7eb !important;
}
.webview-compat.dark .bottom-nav-content {
background-color: #1f2937 !important;
border-top: 1px solid #374151 !important;
color: #f9fafb !important;
}
/* Text colors */
.webview-compat .text-gray-600 {
color: #4b5563;
}
.webview-compat.dark .text-gray-600 {
color: #d1d5db !important;
}
.webview-compat .text-gray-800 {
color: #1f2937;
}
.webview-compat.dark .text-gray-800 {
color: #f9fafb !important;
}
/* Worker dashboard specific styles */
.webview-compat .text-gray-900 {
color: #111827;
}
.webview-compat.dark .text-gray-900 {
color: #f9fafb !important;
}
.webview-compat .text-gray-400 {
color: #9ca3af;
}
.webview-compat.dark .text-gray-400 {
color: #d1d5db !important;
}
/* Navigation bar text styles */
.webview-compat .bottom-nav-content .text-gray-600 {
color: #4b5563;
}
.webview-compat.dark .bottom-nav-content .text-gray-600 {
color: #d1d5db !important;
}
.webview-compat .bottom-nav-content .text-blue-600 {
color: #2563eb;
}
.webview-compat.dark .bottom-nav-content .text-blue-600 {
color: #60a5fa !important;
}
.webview-compat .bottom-nav-content span {
color: inherit;
}
.webview-compat.dark .bottom-nav-content span {
color: #d1d5db !important;
}
/* Border colors
.webview-compat .border-gray-200 {
border-color: #e5e7eb;
}
.webview-compat.dark .border-gray-200 {
border-color: #404040 !important;
}
/* Ensure transitions work in older WebView */
.webview-compat * {
-webkit-transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
}
/* CSS Variables with fallbacks for older WebView */
:root {
--bg-color: #ffffff;
--text-color: #000000;
--card-bg: #ffffff;
--border-color: #e5e7eb;
}
html.dark {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--card-bg: #2d2d2d;
--border-color: #404040;
}
/* Apply CSS variables with fallbacks */
.theme-bg {
background-color: #ffffff; /* fallback */
background-color: var(--bg-color, #ffffff);
}
.theme-text {
color: #000000; /* fallback */
color: var(--text-color, #000000);
}
.theme-card {
background-color: #ffffff; /* fallback */
background-color: var(--card-bg, #ffffff);
}
.theme-border {
border-color: #e5e7eb; /* fallback */
border-color: var(--border-color, #e5e7eb);
}
+11 -3
View File
@@ -5,7 +5,7 @@
<router-link
to="/worker/dashboard"
class="flex-1 flex flex-col items-center py-3 px-2 text-center transition-colors duration-200"
:class="isClockInActive ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'"
:class="isClockInActive ? 'text-blue-600' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700'"
>
<component :is="isClockInActive ? ClockIconSolid : ClockIconOutline" class="w-7 h-7 mb-1" />
<span class="text-xs font-medium">{{ $t('clockIn') }}</span>
@@ -15,7 +15,7 @@
<router-link
to="/worker/settings"
class="flex-1 flex flex-col items-center py-3 px-2 text-center transition-colors duration-200"
:class="isSettingsActive ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'"
:class="isSettingsActive ? 'text-blue-600 bg-blue-50 dark:bg-blue-900/50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700'"
>
<component :is="isSettingsActive ? SettingsIconSolid : SettingsIconOutline" class="w-7 h-7 mb-1" />
<span class="text-xs font-medium">{{ $t('setting') }}</span>
@@ -38,7 +38,8 @@ const isSettingsActive = computed(() =>
route.path.includes('/worker/settings') ||
route.path.includes('/worker/history') ||
route.path.includes('/worker/change-password') ||
route.path.includes('/worker/services-status')
route.path.includes('/worker/services-status') ||
route.path.includes('/worker/manual-guide')
)
</script>
@@ -73,6 +74,13 @@ const isSettingsActive = computed(() =>
user-select: none;
}
/* Dark mode styles */
html.dark .bottom-nav-content {
background-color: #1f2937;
border-top: 1px solid #374151;
color: #f9fafb;
}
/* Prevent any scroll-related movement */
.bottom-nav-container::before {
content: '';
+53 -1
View File
@@ -6,6 +6,8 @@
"password": "পাসওয়ার্ড",
"loggingIn": "লগ ইন করা হচ্ছে...",
"language": "ভাষা",
"darkMode": "ডার্ক মোড",
"toggleDarkMode": "হালকা এবং অন্ধকার থিমের মধ্যে পরিবর্তন করুন",
"failedConnection": "সার্ভারের সাথে সংযোগ করতে পারেনি।",
"invalidToken": "সার্ভার থেকে অবৈধ টোকেন পাওয়া গেছে।",
"invalidCredentials": "ভুল ইউজারনেম বা পাসওয়ার্ড।",
@@ -208,6 +210,9 @@
"servicesReady": "সব সার্ভিস প্রস্তুত",
"autoLoginFailed": "অটো লগইন ব্যর্থ। অনুগ্রহ করে ম্যানুয়ালি লগ ইন করুন।",
"deviceValidationFailed": "ডিভাইস ভেরিফিকেশন ব্যর্থ। অনুগ্রহ করে সাপোর্টের সাথে যোগাযোগ করুন।",
"deviceMismatch": "এই ডিভাইসটি আপনার অ্যাকাউন্টের জন্য অনুমোদিত নয়।",
"deviceRegistrationFailed": "ডিভাইস নিবন্ধন ব্যর্থ। আবার চেষ্টা করুন।",
"deviceRequired": "কর্মী লগইনের জন্য ডিভাইস নিবন্ধন প্রয়োজন।",
"servicesStatus": "সার্ভিসের স্ট্যাটাস",
"overallStatus": "সামগ্রিক স্ট্যাটাস",
@@ -250,5 +255,52 @@
"workLocationTracking": "কর্মক্ষেত্রের লোকেশন ট্র্যাকিং",
"locationTrackingForAttendance": "কাজের উপস্থিতির জন্য লোকেশন ট্র্যাকিং সক্রিয়",
"monitoringLocation": "কাজের উপস্থিতির জন্য লোকেশন নিরীক্ষণ করা হচ্ছে"
"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 &amp; 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 &amp; 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": "শীঘ্রই আসছে।"
}
}
}
+63 -2
View File
@@ -6,6 +6,8 @@
"password": "Password",
"loggingIn": "Logging in...",
"language": "Language",
"darkMode": "Dark Mode",
"toggleDarkMode": "Switch between light and dark themes",
"failedConnection": "Failed to connect to the server.",
"invalidToken": "Invalid token received from server.",
"invalidCredentials": "Invalid username or password.",
@@ -69,7 +71,7 @@
"updatePassword": "Update Password",
"passwordsNoMatch": "New passwords do not match.",
"passwordTooShort": "New password must be at least 6 characters long.",
"passwordUpdated": "Password updated successfully! You can now use your new password to log in.",
"passwordUpdated": "Password updated successfully!",
"passwordUpdateError": "An error occurred while updating the password.",
"attendanceLogFor": "Attendance Log for",
@@ -206,6 +208,9 @@
"servicesReady": "All services are ready",
"autoLoginFailed": "Auto-login failed. Please log in manually.",
"deviceValidationFailed": "Device validation failed. Please contact support.",
"deviceMismatch": "This device is not authorized for your account.",
"deviceRegistrationFailed": "Failed to register device. Please try again.",
"deviceRequired": "Device registration is required for worker login.",
"servicesStatus": "Services Status",
"overallStatus": "Overall Status",
@@ -250,5 +255,61 @@
"workLocationTracking": "Work Location Tracking",
"locationTrackingForAttendance": "Location tracking active for work attendance",
"monitoringLocation": "Monitoring location for work attendance"
"monitoringLocation": "Monitoring location for work attendance",
"appInformation": "App Information",
"version": "Version",
"platform": "Platform",
"android": "Android",
"web": "Web",
"enableDarkMode": "Enable dark mode",
"disableDarkMode": "Disable dark mode",
"viewUserManual": "Read instructions and FAQs",
"manualGuide": "Manual Guide",
"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 &amp; privacy</em>)</span>.",
"Turn <strong>Use location</strong> ON.",
"Open <strong>App permissions</strong> → find <strong>Attendance System</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>Attendance System</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 &amp; 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 → Attendance System → Storage → Clear cache)."
],
"note": "Still stuck? Take a screenshot and contact your manager or HR."
}
]
},
"ios": {
"heading": "iOS",
"comingSoon": "Coming soon."
}
}
}
+65 -5
View File
@@ -6,6 +6,8 @@
"password": "Kata Laluan",
"loggingIn": "Sedang log masuk...",
"language": "Bahasa",
"darkMode": "Mod Gelap",
"toggleDarkMode": "Tukar antara tema terang dan gelap",
"failedConnection": "Gagal untuk berhubung dengan pelayan.",
"invalidCredentials": "Nama pengguna atau kata laluan tidak sah.",
"invalidToken": "Token tidak sah diterima dari pelayan.",
@@ -29,7 +31,7 @@
"out": "Keluar",
"cancel": "Batal",
"viewMyClockHistory": "Lihat Sejarah Kehadiran Saya",
"viewMyClockHistory": "Lihat Sejarah",
"changeMyPassword": "Tukar Kata Laluan Saya",
"myClockHistory": "Sejarah Kehadiran Saya",
"backToDashboard": "Kembali ke Papan Pemuka",
@@ -69,7 +71,7 @@
"updatePassword": "Kemaskini Kata Laluan",
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
"passwordTooShort": "Kata laluan baharu mesti sekurang-kurangnya 6 aksara.",
"passwordUpdated": "Kata laluan berjaya dikemaskini! Anda boleh guna kata laluan baharu untuk log masuk.",
"passwordUpdated": "Kata laluan berjaya dikemaskini!",
"passwordUpdateError": "Ralat semasa mengemaskini kata laluan.",
"attendanceLogFor": "Log Kehadiran untuk",
@@ -206,6 +208,9 @@
"servicesReady": "Semua perkhidmatan sedia",
"autoLoginFailed": "Log masuk automatik gagal. Sila log masuk secara manual.",
"deviceValidationFailed": "Pengesahan peranti gagal. Sila hubungi sokongan.",
"deviceMismatch": "Peranti ini tidak dibenarkan untuk akaun anda.",
"deviceRegistrationFailed": "Gagal mendaftarkan peranti. Sila cuba lagi.",
"deviceRequired": "Pendaftaran peranti diperlukan untuk log masuk pekerja.",
"personal": "Peribadi",
"clockHistory": "Sejarah Kehadiran",
@@ -213,8 +218,8 @@
"scanQRCode": "Imbas Kod QR",
"services": "Perkhidmatan",
"systemServicesStatus": "Status perkhidmatan sistem dan keselamatan",
"updateYourPassword": "Kemaskini kata laluan akaun anda",
"signOutOfAccount": "Log keluar dari akaun anda",
"updateYourPassword": "Kemas Kini Kata Laluan",
"signOutOfAccount": "Keluar Akaun",
"servicesStatus": "Status Perkhidmatan",
"overallStatus": "Status Keseluruhan",
@@ -249,5 +254,60 @@
"workLocationTracking": "Penjejakan Lokasi Kerja",
"locationTrackingForAttendance": "Penjejakan lokasi aktif untuk kehadiran kerja",
"monitoringLocation": "Memantau lokasi untuk kehadiran kerja",
"failedToVerifyStatus": "Gagal untuk mengesahkan status semasa dari pelayan."
"failedToVerifyStatus": "Gagal untuk mengesahkan status semasa dari pelayan.",
"appInformation": "Maklumat Aplikasi",
"version": "Versi",
"platform": "Platform",
"android": "Android",
"web": "Web",
"enableDarkMode": "Aktifkan mod gelap",
"disableDarkMode": "Nyahaktifkan mod gelap",
"manualGuide": "Panduan Manual",
"viewUserManual": "Baca arahan dan soalan lazim",
"manual": {
"android": {
"heading": "Android",
"faqs": [
{
"id": "android-location",
"title": "Cara membuka lokasi (Android)",
"steps": [
"Buka <strong>Settings</strong> pada telefon anda.",
"Pergi ke <strong>Location</strong> <span class=\"text-sm text-gray-500\">(pada sesetengah telefon di bawah <em>Security &amp; privacy</em>)</span>.",
"Hidupkan <strong>Use location</strong>.",
"Buka <strong>App permissions</strong> → cari <strong>Attendance System</strong> → tetapkan kepada <strong>Allow while using the app</strong>.",
"Dayakan <strong>Precise location</strong> jika tersedia.",
"Kembali ke aplikasi dan cuba daftar masuk semula."
],
"note": "Nama berbeza mengikut jenama: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services."
},
{
"id": "android-camera",
"title": "Dayakan kebenaran kamera (Android)",
"steps": [
"Buka <strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong>.",
"Ketik <strong>Permissions</strong> → <strong>Camera</strong> → pilih <strong>Allow</strong> atau <strong>Allow while using the app</strong>.",
"Buka semula aplikasi dan cuba imbas semula."
]
},
{
"id": "clockin-troubleshoot",
"title": "Daftar masuk tidak berfungsi? Senarai semak pantas",
"steps": [
"Hidupkan <strong>Location</strong> dan tetapkan kebenaran aplikasi kepada <strong>Allow while using the app</strong> (dayakan <strong>Precise location</strong> jika tersedia).",
"Semak rangkaian: Wi-Fi atau data dihidupkan. Toggle <strong>Airplane mode</strong> off→on, kemudian cuba lagi. Lumpuhkan VPN jika mengganggu.",
"Pastikan <strong>Automatic date &amp; time</strong> dan <strong>time zone</strong> didayakan dalam tetapan Android.",
"Tutup paksa dan buka semula aplikasi. Jika perlu, kosongkan cache <strong>Attendance System</strong> (Settings → Apps → Attendance System → Storage → Clear cache)."
],
"note": "Masih tersekat? Ambil tangkapan skrin dan hubungi pengurus atau HR anda."
}
]
},
"ios": {
"heading": "iOS",
"comingSoon": "Akan datang."
}
}
}
+53 -1
View File
@@ -6,6 +6,8 @@
"password": "လျှို့ဝှက်နံပါတ်",
"loggingIn": "ဝင်ရောက်နေသည်...",
"language": "ဘာသာစကား",
"darkMode": "မှောင်မိုက်မုဒ်",
"toggleDarkMode": "အလင်းနှင့် မှောင်မိုက်အပြင်အဆင်များကြား ပြောင်းလဲရန်",
"failedConnection": "ဆာဗာနှင့် ချိတ်ဆက်၍မရပါ။",
"invalidToken": "ဆာဗာမှ မမှန်ကန်သော တိုကင်ရရှိခဲ့သည်။",
"invalidCredentials": "အသုံးပြုသူအမည် သို့မဟုတ် လျှို့ဝှက်နံပါတ် မမှန်ကန်ပါ။",
@@ -206,6 +208,9 @@
"servicesReady": "ဝန်ဆောင်မှုများ အားလုံး အဆင်သင့်ပါပြီ",
"autoLoginFailed": "အလိုအလျောက်ဝင်ရောက်မှု မအောင်မြင်ပါ။ လက်ဖြင့် ဝင်ရောက်ပါ။",
"deviceValidationFailed": "စက်ပစ္စည်း အတည်ပြုခြင်း မအောင်မြင်ပါ။ ပံ့ပိုးမှုကို ဆက်သွယ်ပါ။",
"deviceMismatch": "ဤစက်ပစ္စည်းသည် သင့်အကောင့်အတွက် ခွင့်ပြုမထားပါ။",
"deviceRegistrationFailed": "စက်ပစ္စည်း မှတ်ပုံတင်မှု မအောင်မြင်ပါ။ ပြန်လည်ကြိုးစားပါ။",
"deviceRequired": "အလုပ်သမား အကောင့်ဝင်ရောက်မှုအတွက် စက်ပစ္စည်း မှတ်ပုံတင်ခြင်း လိုအပ်ပါသည်။",
"servicesStatus": "ဝန်ဆောင်မှုများ အခြေအနေ",
"overallStatus": "ခြုံငုံအခြေအနေ",
@@ -250,5 +255,52 @@
"workLocationTracking": "အလုပ်တည်နေရာ ခြေရာခံမှု",
"locationTrackingForAttendance": "အလုပ်တက်ရောက်မှုအတွက် တည်နေရာခြေရာခံမှု အသက်ဝင်နေသည်",
"monitoringLocation": "အလုပ်တက်ရောက်မှုအတွက် တည်နေရာကို ကြည့်ရှုနေသည်"
"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 &amp; 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 &amp; 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": "မကြာမီ ရောက်ရှိပါမည်။"
}
}
}
+53 -1
View File
@@ -6,6 +6,8 @@
"password": "पासवर्ड",
"loggingIn": "लगइन गर्दै...",
"language": "भाषा",
"darkMode": "डार्क मोड",
"toggleDarkMode": "उज्यालो र अँध्यारो थिमहरू बीच स्विच गर्नुहोस्",
"failedConnection": "सर्भरसँग जडान गर्न सकिएन।",
"invalidToken": "सर्भरबाट अमान्य टोकन प्राप्त भयो।",
"invalidCredentials": "गलत प्रयोगकर्ता नाम वा पासवर्ड।",
@@ -206,6 +208,9 @@
"servicesReady": "सबै सेवाहरू तयार छन्",
"autoLoginFailed": "अटो-लगइन असफल। कृपया म्यानुअल रूपमा लगइन गर्नुहोस्।",
"deviceValidationFailed": "उपकरण प्रमाणीकरण असफल। कृपया सहयोगलाई सम्पर्क गर्नुहोस्।",
"deviceMismatch": "यो उपकरण तपाईंको खाताको लागि अधिकृत छैन।",
"deviceRegistrationFailed": "उपकरण दर्ता असफल। फेरि प्रयास गर्नुहोस्।",
"deviceRequired": "कामदार लगइनको लागि उपकरण दर्ता आवश्यक छ।",
"servicesStatus": "सेवाहरूको स्थिति",
"overallStatus": "समग्र स्थिति",
@@ -250,5 +255,52 @@
"workLocationTracking": "कार्य स्थान ट्र्याकिङ",
"locationTrackingForAttendance": "कार्य उपस्थितिको लागि स्थान ट्र्याकिङ सक्रिय",
"monitoringLocation": "कार्य उपस्थितिको लागि स्थान निगरानी गर्दै"
"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 &amp; 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 &amp; 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": "चाँडै आउँदैछ।"
}
}
}
+53 -1
View File
@@ -6,6 +6,8 @@
"password": "கடவுச்சொல்",
"loggingIn": "உள்நுழைகிறது...",
"language": "மொழி",
"darkMode": "இருண்ட பயன்முறை",
"toggleDarkMode": "வெளிச்சம் மற்றும் இருண்ட தீம்களுக்கு இடையில் மாற்றவும்",
"failedConnection": "சர்வருடன் இணைக்க முடியவில்லை.",
"invalidToken": "சர்வரிலிருந்து தவறான டோக்கன் பெறப்பட்டது.",
"invalidCredentials": "தவறான பயனர் பெயர் அல்லது கடவுச்சொல்.",
@@ -196,6 +198,9 @@
"servicesReady": "அனைத்து சேவைகளும் தயாராக உள்ளன",
"autoLoginFailed": "தானியங்கு உள்நுழைவு தோல்வி. தயவுசெய்து கைமுறையாக உள்நுழைக.",
"deviceValidationFailed": "சாதன சரிபார்ப்பு தோல்வி. தயவுசெய்து ஆதரவைத் தொடர்பு கொள்ளவும்.",
"deviceMismatch": "இந்த சாதனம் உங்கள் கணக்கிற்கு அங்கீகரிக்கப்படவில்லை.",
"deviceRegistrationFailed": "சாதன பதிவு தோல்வி. மீண்டும் முயற்சிக்கவும்.",
"deviceRequired": "தொழிலாளர் உள்நுழைவிற்கு சாதன பதிவு தேவை.",
"servicesStatus": "சேவைகளின் நிலை",
"overallStatus": "ஒட்டுமொத்த நிலை",
"locationTracking": "இருப்பிட கண்காணிப்பு",
@@ -237,5 +242,52 @@
"signOutOfAccount": "உங்கள் கணக்கிலிருந்து வெளியேறவும்",
"workLocationTracking": "பணியிட இருப்பிட கண்காணிப்பு",
"locationTrackingForAttendance": "பணி வருகைக்காக இருப்பிட கண்காணிப்பு செயலில்",
"monitoringLocation": "பணி வருகைக்காக இருப்பிடத்தைக் கண்காணிக்கிறது"
"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 &amp; 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 &amp; 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": "விரைவில் வரும்."
}
}
}
+7
View File
@@ -41,6 +41,13 @@ const router = createRouter({
component: SettingsView,
meta: { requiresAuth: true, role: 'worker' },
},
{
path: '/worker/manual-guide',
name: 'ManualGuide',
component: () => import('@/views/ManualGuide.vue'),
meta: { requiresAuth: true, role: 'worker' }
},
],
})
+2 -2
View File
@@ -228,9 +228,9 @@ class AuthService {
credentials
)
// Cache worker data
// Cache worker data including username
if (decodedToken.role === 'worker') {
await this.cacheWorkerData(decodedToken.userId, { full_name: data.fullName })
await this.cacheWorkerData(decodedToken.userId, { full_name: data.fullName, username: username })
}
return {
+12 -51
View File
@@ -1,6 +1,7 @@
import { Device } from '@capacitor/device'
import { Preferences } from '@capacitor/preferences'
import { Capacitor } from '@capacitor/core'
import { v4 as uuidv4 } from 'uuid'
import { apiFetch } from '@/api.js'
import { authService } from '@/services/authService.js'
@@ -19,7 +20,7 @@ class DeviceUuidService {
// Get or create device UUID
this.cachedDeviceUuid = await this.getOrCreateDeviceUuid()
// Get device information
this.cachedDeviceInfo = await this.getDeviceInfo()
this.cachedDeviceInfo = await this.getBasicDeviceInfo()
return true
} catch (error) {
console.error('Failed to initialize device UUID service:', error)
@@ -49,35 +50,19 @@ class DeviceUuidService {
async generateDeviceUuid() {
try {
let baseString = ''
const uuid = uuidv4()
// 可选:记录设备信息用于调试(不影响UUID生成)
if (this.isNative) {
// Get device-specific information for UUID generation
const deviceInfo = await Device.getInfo()
const deviceId = await Device.getId()
// Create a base string from device characteristics
baseString = [
deviceInfo.platform || 'unknown',
deviceInfo.model || 'unknown',
deviceInfo.manufacturer || 'unknown',
deviceId.identifier || 'unknown',
deviceInfo.osVersion || 'unknown'
].join('-')
} else {
// Web fallback - use browser characteristics
baseString = [
navigator.userAgent,
navigator.language,
screen.width,
screen.height,
new Date().getTimezoneOffset()
].join('-')
console.log('Generated UUID for device:', {
uuid,
platform: deviceInfo.platform,
model: deviceInfo.model,
manufacturer: deviceInfo.manufacturer
})
}
// Generate UUID based on device characteristics
const uuid = await this.hashStringToUuid(baseString)
return uuid
} catch (error) {
console.error('Failed to generate device UUID:', error)
@@ -85,33 +70,9 @@ class DeviceUuidService {
}
}
async hashStringToUuid(str) {
try {
// Simple hash function to create consistent UUID from string
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // Convert to 32-bit integer
}
// Convert hash to UUID format
const hashStr = Math.abs(hash).toString(16).padStart(8, '0')
const timestamp = Date.now().toString(16).slice(-8)
const random = Math.random().toString(16).slice(2, 10)
return `${hashStr.slice(0, 8)}-${hashStr.slice(0, 4)}-4${hashStr.slice(1, 4)}-${timestamp.slice(0, 4)}-${random}`
} catch (error) {
console.error('Failed to hash string to UUID:', error)
return this.generateFallbackUuid()
}
}
generateFallbackUuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0
const v = c == 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
// 使用标准UUID库作为fallback
return uuidv4()
}
getBasicDeviceInfo() {
+2 -6
View File
@@ -262,7 +262,7 @@ class NativeServicesManager {
},
deviceUuid: {
initialized: true,
uuid: 'simplified' // Removed complex UUID tracking
uuid: this.services.deviceUuid.cachedDeviceUuid || 'pending'
}
}
}
@@ -296,13 +296,9 @@ class NativeServicesManager {
return this.isNative
}
/**
* Get device UUID (simplified)
*/
async getDeviceUuid() {
return 'simplified-device-uuid' // Simplified implementation
return await this.services.deviceUuid.getDeviceUuid()
}
/**
* Reset all services (for testing/troubleshooting)
*/
+180
View File
@@ -0,0 +1,180 @@
/**
* WebView Compatibility Utilities
* Provides compatibility handling for older WebView versions
*/
/**
* Detect WebView version and capabilities
*/
export const detectWebViewCapabilities = () => {
const capabilities = {
cssVariables: true,
matchMedia: true,
modernCSS: true,
version: null
}
try {
// Check for CSS Variables support
if (!CSS || !CSS.supports || !CSS.supports('color', 'var(--test)')) {
capabilities.cssVariables = false
}
} catch (error) {
capabilities.cssVariables = false
}
try {
// Check for matchMedia support
if (!window.matchMedia || typeof window.matchMedia !== 'function') {
capabilities.matchMedia = false
}
} catch (error) {
capabilities.matchMedia = false
}
try {
// Try to detect WebView version from user agent
const userAgent = navigator.userAgent
const chromeMatch = userAgent.match(/Chrome\/(\d+\.\d+\.\d+\.\d+)/)
if (chromeMatch) {
capabilities.version = chromeMatch[1]
const majorVersion = parseInt(chromeMatch[1].split('.')[0])
// Chrome 88+ has better CSS support
if (majorVersion < 88) {
capabilities.modernCSS = false
}
}
} catch (error) {
console.warn('Could not detect WebView version:', error)
}
return capabilities
}
/**
* Apply WebView compatibility class to document
*/
export const applyWebViewCompatibility = () => {
const capabilities = detectWebViewCapabilities()
// Add webview-compat class if needed
if (!capabilities.cssVariables || !capabilities.modernCSS) {
document.documentElement.classList.add('webview-compat')
console.info('Applied WebView compatibility mode')
}
return capabilities
}
/**
* Enhanced theme application with WebView compatibility
*/
export const applyThemeWithCompat = (isDark) => {
// --- FINAL FIX: Delay execution to prevent a race condition with Vue's rendering cycle. ---
setTimeout(() => {
const capabilities = detectWebViewCapabilities()
try {
if (isDark) {
document.documentElement.classList.add('dark')
if (!capabilities.cssVariables || !capabilities.modernCSS) {
document.documentElement.style.backgroundColor = '#1a1a1a';
document.documentElement.style.color = '#ffffff';
document.body.style.backgroundColor = '#1a1a1a';
document.body.style.color = '#ffffff';
const navElements = document.querySelectorAll('.bottom-nav-content');
navElements.forEach(el => {
el.style.setProperty('background-color', '#1f2937', 'important');
el.style.setProperty('border-top', '1px solid #374151', 'important');
el.style.setProperty('color', '#f9fafb', 'important');
});
const navInactiveElements = document.querySelectorAll('.bottom-nav-content .text-gray-600 span, .bottom-nav-content .text-gray-600 svg');
navInactiveElements.forEach(el => {
el.style.setProperty('color', '#d1d5db', 'important');
});
const navActiveElements = document.querySelectorAll('.bottom-nav-content .text-blue-600');
navActiveElements.forEach(el => {
el.style.setProperty('color', '#60a5fa', 'important');
el.style.setProperty('background-color', 'rgba(96, 165, 250, 0.15)', 'important');
});
}
} else {
document.documentElement.classList.remove('dark');
if (!capabilities.cssVariables || !capabilities.modernCSS) {
document.documentElement.style.backgroundColor = '#ffffff';
document.documentElement.style.color = '#000000';
document.body.style.backgroundColor = '#ffffff';
document.body.style.color = '#000000';
const navElements = document.querySelectorAll('.bottom-nav-content');
navElements.forEach(el => {
el.style.setProperty('background-color', '#ffffff', 'important');
el.style.setProperty('border-top', '1px solid #e5e7eb', 'important');
el.style.removeProperty('color');
});
const navInactiveElements = document.querySelectorAll('.bottom-nav-content .text-gray-600 span, .bottom-nav-content .text-gray-600 svg');
navInactiveElements.forEach(el => {
el.style.setProperty('color', '#4b5563', 'important');
});
const navActiveElements = document.querySelectorAll('.bottom-nav-content .text-blue-600');
navActiveElements.forEach(el => {
el.style.setProperty('color', '#2563eb', 'important');
el.style.setProperty('background-color', '#eff6ff', 'important');
});
}
}
} catch (error) {
console.warn('Error applying theme with compatibility:', error);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
}, 100); // A 100ms delay to ensure Vue has rendered.
}
/**
* Check system dark mode preference with WebView compatibility
*/
export const getSystemDarkModePreference = () => {
try {
if (window.matchMedia && typeof window.matchMedia === 'function') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
} catch (error) {
console.warn('matchMedia not supported, defaulting to light mode')
}
return false
}
/**
* Initialize WebView compatibility on app startup
*/
export const initWebViewCompatibility = () => {
const capabilities = applyWebViewCompatibility()
console.info('WebView Capabilities:', capabilities)
if (!capabilities.cssVariables) {
console.warn('CSS Variables not supported, using fallback styles')
}
if (!capabilities.matchMedia) {
console.warn('matchMedia not supported, system theme detection disabled')
}
if (!capabilities.modernCSS) {
console.warn('Modern CSS features limited, using compatibility mode')
}
return capabilities
}
+18 -23
View File
@@ -1,5 +1,5 @@
<template>
<div class="mobile-viewport bg-gray-100">
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
<!-- 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);">
@@ -13,32 +13,32 @@
</header>
<main class="main-with-fixed-header-and-nav px-4 py-8">
<div class="bg-white rounded-2xl shadow-lg p-8 w-full max-w-lg mx-auto mt-8">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 w-full max-w-lg mx-auto mt-8">
<form @submit.prevent="handleChangePassword" class="space-y-6">
<!-- Success Message -->
<div v-if="successMessage" class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded-lg">
<div v-if="successMessage" class="bg-green-100 dark:bg-green-900 border-l-4 border-green-500 text-green-700 dark:text-green-300 p-4 rounded-lg">
{{ $t(successMessage) }}
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-lg">
<div v-if="errorMessage" class="bg-red-100 dark:bg-red-900 border-l-4 border-red-500 text-red-700 dark:text-red-300 p-4 rounded-lg">
{{ $t(errorMessage) }}
</div>
<!-- Form Fields -->
<div>
<label for="currentPassword" class="block text-sm font-medium text-gray-700 mb-2">{{ $t('currentPassword') }}</label>
<label for="currentPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('currentPassword') }}</label>
<input type="password" id="currentPassword" v-model="passwords.currentPassword" required
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
class="w-full px-4 py-3 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div>
<label for="newPassword" class="block text-sm font-medium text-gray-700 mb-2">{{ $t('newPassword') }}</label>
<label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('newPassword') }}</label>
<input type="password" id="newPassword" v-model="passwords.newPassword" required
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
class="w-full px-4 py-3 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-2">{{ $t('confirmNewPassword') }}</label>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('confirmNewPassword') }}</label>
<input type="password" id="confirmPassword" v-model="passwords.confirmPassword" required
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
class="w-full px-4 py-3 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<!-- Submit Button -->
@@ -84,28 +84,23 @@ const handleChangePassword = async () => {
loading.value = true
try {
const response = await apiFetch('/api/worker/change-password', {
await apiFetch('/api/worker/change-password', {
method: 'PUT',
body: JSON.stringify({
currentPassword: passwords.value.currentPassword,
newPassword: passwords.value.newPassword,
}),
})
if (!response.ok) {
const errorData = await response.json()
if (response.status === 401) {
errorMessage.value = 'invalidCurrentPassword'
} else {
errorMessage.value = errorData.message || 'passwordUpdateError'
}
return
}
successMessage.value = 'passwordUpdated'
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
} catch (err) {
errorMessage.value = err.message || 'passwordUpdateError'
if (err.message.includes('Incorrect current password') || err.message.includes('401')) {
errorMessage.value = 'invalidCurrentPassword'
} else if (err.message.includes('Invalid input')) {
errorMessage.value = 'passwordUpdateError'
} else {
errorMessage.value = 'passwordUpdateError'
}
} finally {
loading.value = false
}
+9 -8
View File
@@ -1,13 +1,13 @@
<template>
<div class="flex justify-center items-center mobile-viewport bg-gray-100 safe-top safe-bottom">
<div class="w-full max-w-sm p-8 space-y-3 bg-white rounded-2xl shadow-lg">
<div class="flex justify-center items-center mobile-viewport bg-gray-100 dark:bg-gray-900 safe-top safe-bottom transition-colors duration-300">
<div class="w-full max-w-sm p-8 space-y-3 bg-white dark:bg-gray-800 rounded-2xl shadow-lg transition-colors duration-300">
<!-- App Logo -->
<div class="flex justify-center">
<ArrowRightOnRectangleIcon class="w-16 h-16 text-blue-600" />
<ArrowRightOnRectangleIcon class="w-16 h-16 text-blue-600 dark:text-blue-400" />
</div>
<!-- Title -->
<h2 class="text-3xl font-extrabold text-center text-gray-900">
<h2 class="text-3xl font-extrabold text-center text-gray-900 dark:text-gray-100">
{{ t('login') }}
</h2>
@@ -16,7 +16,7 @@
<div>
<label for="username" class="sr-only">{{ t('username') }}</label>
<input type="text" id="username" v-model="username" autocomplete="username"
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="w-full px-4 py-3 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-300"
:placeholder="t('username')"
required />
</div>
@@ -25,7 +25,7 @@
<div>
<label for="password" class="sr-only">{{ t('password') }}</label>
<input type="password" id="password" v-model="password" autocomplete="current-password"
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="w-full px-4 py-3 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-300"
:placeholder="t('password')"
required />
</div>
@@ -34,8 +34,8 @@
<div class="flex items-center justify-between">
<div class="flex items-center">
<input type="checkbox" id="rememberMe" v-model="rememberMe"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500" />
<label for="rememberMe" class="ml-2 block text-sm text-gray-900">
class="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 dark:focus:ring-blue-400" />
<label for="rememberMe" class="ml-2 block text-sm text-gray-900 dark:text-gray-200">
{{ t('rememberMe') }}
</label>
</div>
@@ -92,6 +92,7 @@ const handleLogin = async () => {
sessionStorage.setItem('userId', loginResult.userId.toString())
sessionStorage.setItem('userRole', loginResult.userRole)
sessionStorage.setItem('token', loginResult.token)
sessionStorage.setItem('username', username.value)
console.log('✅ SESSION STORAGE SET:', {
userId: sessionStorage.getItem('userId'),
+77
View File
@@ -0,0 +1,77 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 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()
// i18n: t -> strings, tm -> returns objects/arrays from the locale message tree
const { tm } = useI18n()
// Pull translated Android FAQs (array) for current locale
const faqsAndroid = computed(() => tm('manual.android.faqs') || [])
</script>
+100 -37
View File
@@ -1,7 +1,7 @@
<template>
<div class="mobile-viewport bg-gray-100">
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen transition-colors duration-300">
<!-- Fixed Header -->
<header class="fixed-header-safe bg-blue-600 text-white shadow-lg">
<header class="fixed-header-safe bg-blue-600 dark:bg-gray-800 text-white shadow-lg transition-colors duration-300">
<div class="px-4 py-6">
<h1 class="text-3xl font-bold text-center">{{ $t('setting') }}</h1>
</div>
@@ -10,55 +10,60 @@
<!-- Scrollable Main Content -->
<main class="main-with-fixed-header-and-nav px-4 py-8 space-y-4">
<!-- Menu Items -->
<div class="bg-white rounded-2xl shadow-lg overflow-hidden mt-8">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden mt-8 transition-colors duration-300">
<!-- Clock History -->
<router-link to="/worker/history"
class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
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 rounded-xl flex items-center justify-center mr-5">
<ChartBarIcon class="w-8 h-8 text-blue-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900">{{ $t('clockHistory') }}</h3>
<p class="text-sm text-gray-500">{{ $t('viewMyClockHistory') }}</p>
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">{{ $t('clockHistory') }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('viewMyClockHistory') }}</p>
</div>
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
</router-link>
<!-- Service Status -->
<router-link to="/worker/services-status"
class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mr-5">
<CogIcon class="w-8 h-8 text-green-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900">{{ $t('servicesStatus') }}</h3>
<p class="text-sm text-gray-500">{{ $t('systemServicesStatus') }}</p>
</div>
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
<ChevronRightIcon class="w-6 h-6 text-gray-400 dark:text-gray-500" />
</router-link>
<!-- Change Password -->
<router-link to="/worker/change-password"
class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
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 rounded-xl flex items-center justify-center mr-5">
<LockClosedIcon class="w-8 h-8 text-orange-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900">{{ $t('changePassword') }}</h3>
<p class="text-sm text-gray-500">{{ $t('updateYourPassword') }}</p>
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">{{ $t('changePassword') }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('updateYourPassword') }}</p>
</div>
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
<ChevronRightIcon class="w-6 h-6 text-gray-400 dark:text-gray-500" />
</router-link>
<!-- Dark Mode Toggle -->
<div class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700">
<div class="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center mr-5">
<MoonIcon v-if="!isDarkMode" class="w-8 h-8 text-indigo-600" />
<SunIcon v-else class="w-8 h-8 text-indigo-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">{{ $t('darkMode') }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('toggleDarkMode') }}</p>
</div>
<button @click="toggleDarkMode"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="isDarkMode ? 'bg-indigo-600' : 'bg-gray-200'">
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="isDarkMode ? 'translate-x-6' : 'translate-x-1'"></span>
</button>
</div>
<!-- Language Selection -->
<div class="flex items-center p-5">
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mr-5">
<LanguageIcon class="w-8 h-8 text-purple-600" />
</div>
<div class="flex-grow">
<h3 class="font-semibold text-lg text-gray-900 mb-2">{{ $t('language') }}</h3>
<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 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
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 dark:focus:ring-blue-400 focus:border-transparent transition-colors duration-300">
<option value="en">{{ $t('english') }}</option>
<option value="ms">{{ $t('malay') }}</option>
<option value="tm">{{ $t('tamil') }}</option>
@@ -68,17 +73,46 @@
</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/30 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 dark:text-gray-500" />
</router-link>
</div>
<!-- App Information -->
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 transition-colors duration-300">
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100 mb-4">{{ $t('appInformation') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">{{ $t('version') }}</span>
<span class="font-medium text-gray-900 dark:text-gray-100">1.0.0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">{{ $t('platform') }}</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $t('android') }}</span>
</div>
</div>
</div>
<!-- Logout Button -->
<button @click="logout"
class="w-full flex items-center justify-center p-5 bg-white rounded-2xl shadow-lg hover:bg-red-50 transition-colors">
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 duration-300">
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center mr-5">
<ArrowRightOnRectangleIcon class="w-8 h-8 text-red-600" />
</div>
<div class="flex-grow text-left">
<h3 class="font-semibold text-lg text-red-600">{{ $t('logout') }}</h3>
<p class="text-sm text-red-500">{{ $t('signOutOfAccount') }}</p>
<h3 class="font-semibold text-lg text-red-600 dark:text-red-400">{{ $t('logout') }}</h3>
<p class="text-sm text-red-500 dark:text-red-400">{{ $t('signOutOfAccount') }}</p>
</div>
</button>
</main>
@@ -89,20 +123,56 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ChartBarIcon, CogIcon, LockClosedIcon, LanguageIcon, ArrowRightOnRectangleIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
import { ChartBarIcon, LockClosedIcon, LanguageIcon, ArrowRightOnRectangleIcon, ChevronRightIcon, MoonIcon, SunIcon, BookOpenIcon } from '@heroicons/vue/24/outline'
import { authService } from '@/services/authService.js'
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
import { applyThemeWithCompat, getSystemDarkModePreference } from '@/utils/webviewCompat.js'
const { locale } = useI18n()
const router = useRouter()
const currentLang = ref(locale.value)
const isDarkMode = ref(false)
onMounted(() => {
const savedLang = localStorage.getItem('lang')
if (savedLang) {
currentLang.value = savedLang
}
// Initialize dark mode with WebView compatibility
const savedTheme = localStorage.getItem('theme')
const prefersDark = getSystemDarkModePreference()
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
isDarkMode.value = true
applyThemeWithCompat(true)
} else {
isDarkMode.value = false
applyThemeWithCompat(false)
}
})
const changeLang = () => {
locale.value = currentLang.value
localStorage.setItem('lang', currentLang.value)
}
const toggleDarkMode = () => {
isDarkMode.value = !isDarkMode.value
applyThemeWithCompat(isDarkMode.value)
if (isDarkMode.value) {
localStorage.setItem('theme', 'dark')
} else {
localStorage.setItem('theme', 'light')
}
}
const logout = async () => {
try {
await authService.logout()
@@ -117,11 +187,4 @@ const logout = async () => {
router.push('/')
}
}
onMounted(() => {
const savedLang = localStorage.getItem('lang')
if (savedLang) {
currentLang.value = savedLang
}
})
</script>
+39 -14
View File
@@ -1,38 +1,39 @@
<template>
<div class="mobile-viewport bg-gray-100">
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
<!-- Header -->
<header class="fixed-header-safe bg-blue-600 text-white shadow-lg">
<div class="px-4 py-6">
<h1 class="text-3xl font-bold text-center">{{ $t('appTitle') }}</h1>
<p class="text-center text-blue-200 mt-1">{{ workerName }}</p>
</div>
</header>
<main class="main-with-fixed-header px-4 py-8 space-y-8">
<!-- Clock Status Card -->
<div class="bg-white rounded-2xl shadow-lg p-6 text-center mt-16">
<!-- Worker Name Display -->
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 text-center mt-16">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">{{ workerName }}</h1>
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ username }}</p>
<div class="flex items-center justify-center gap-4 mb-4">
<component :is="isClockedIn ? CheckCircleIcon : ClockIcon"
:class="['w-16 h-16', isClockedIn ? 'text-green-500' : 'text-red-500']" />
:class="['w-16 h-16', isClockedIn ? 'text-green-500 dark:text-green-400' : 'text-red-500 dark:text-red-400']" />
</div>
<p class="text-lg text-gray-600 mb-1">{{ $t('yourStatus') }}</p>
<h2 class="text-3xl font-bold" :class="isClockedIn ? 'text-green-600' : 'text-red-600'">
<p class="text-lg text-gray-600 dark:text-gray-400 mb-1">{{ $t('yourStatus') }}</p>
<h2 class="text-3xl font-bold" :class="isClockedIn ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ clockStatus }}
</h2>
</div>
<!-- Messages -->
<div v-if="successMessage" class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded-lg shadow-md">
<div v-if="successMessage" class="bg-green-100 dark:bg-green-900 border-l-4 border-green-500 text-green-700 dark:text-green-300 p-4 rounded-lg shadow-md">
{{ successMessage }}
</div>
<div v-if="errorMessage" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-lg shadow-md">
<div v-if="errorMessage" class="bg-red-100 dark:bg-red-900 border-l-4 border-red-500 text-red-700 dark:text-red-300 p-4 rounded-lg shadow-md">
{{ errorMessage }}
</div>
<!-- QR Scanner Card -->
<div class="bg-white rounded-2xl shadow-lg p-6">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6">
<div v-if="!isScannerActive" class="space-y-4 text-center">
<h3 class="text-xl font-semibold text-gray-800 mb-4">
<h3 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
{{ $t('scanToClock', { action: $t(isClockedIn ? 'out' : 'in') }) }}
</h3>
<button @click="startScanner"
@@ -46,9 +47,9 @@
<!-- QR Scanner Overlay -->
<div id="qr-reader-container" v-show="isScannerActive"
class="fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center z-50 p-4">
<div class="bg-white rounded-2xl p-6 w-full max-w-md">
<h3 class="text-2xl font-bold text-gray-900 text-center mb-4">{{ $t('scanQRCode') }}</h3>
<div id="qr-reader" class="w-full rounded-lg overflow-hidden border-4 border-gray-300"></div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-md">
<h3 class="text-2xl font-bold text-gray-900 dark:text-gray-100 text-center mb-4">{{ $t('scanQRCode') }}</h3>
<div id="qr-reader" class="w-full rounded-lg overflow-hidden border-4 border-gray-300 dark:border-gray-600"></div>
</div>
<button @click="stopScanner"
class="mt-8 bg-red-600 hover:bg-red-700 text-white font-bold px-10 py-4 rounded-xl transition-transform transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 shadow-lg">
@@ -82,16 +83,31 @@ const isScannerActive = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const workerName = ref('')
const username = ref('')
let userId = sessionStorage.getItem('userId')
const clockStatus = computed(() => (isClockedIn.value ? t('clockedIn') : t('clockedOut')))
const fetchWorkerDetails = async () => {
// Try to get cached worker data first
try {
const cachedData = await authService.getCachedWorkerData(userId);
if (cachedData && cachedData.full_name) {
workerName.value = cachedData.full_name;
return;
}
} catch (cacheError) {
console.log('No cached worker data available, fetching from API:', cacheError);
}
// Fallback to API if no cached data
try {
const data = await apiFetch(`/api/workers/${userId}`)
if (data) {
workerName.value = data.full_name
// Cache the data for future use
await authService.cacheWorkerData(userId, { full_name: data.full_name, username: username.value });
}
} catch (err) {
errorMessage.value = t('couldNotLoadWorkerInfo') + `: ${err.message}`
@@ -204,6 +220,15 @@ onMounted(async () => {
console.error('Failed to initialize native services:', error)
}
// Get username from the current approach
const storedUsername = sessionStorage.getItem('username');
if (storedUsername) {
username.value = storedUsername;
} else {
// Fallback to placeholder if not found
username.value = t('username');
}
fetchWorkerDetails()
fetchCurrentStatus()
})
+11 -11
View File
@@ -1,5 +1,5 @@
<template>
<div class="mobile-viewport bg-gray-100">
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
<!-- 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);">
@@ -15,27 +15,27 @@
<main class="main-with-fixed-header-and-nav px-4 py-8">
<!-- Empty State -->
<div v-if="!clockHistory.length" class="text-center py-16 mt-8">
<ChartBarIcon class="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h2 class="text-2xl font-semibold text-gray-700">{{ $t('noClockHistory') }}</h2>
<p class="text-gray-500 mt-2">{{ $t('clockHistoryEmptyState') }}</p>
<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>
</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 rounded-2xl shadow-lg p-5 flex items-center space-x-4">
class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-5 flex items-center space-x-4">
<div class="w-12 h-12 rounded-full flex items-center justify-center"
:class="event.event_type === 'clock_in' ? 'bg-green-100' : 'bg-red-100'">
: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' : 'text-red-600']" />
: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">{{ $t(event.event_type) }}</div>
<div class="text-sm text-gray-600">{{ 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">{{ new Date(event.timestamp).toLocaleDateString() }}</div>
<div class="text-sm text-gray-500">{{ new Date(event.timestamp).toLocaleTimeString() }}</div>
<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>
</div>
</div>
+1
View File
@@ -4,6 +4,7 @@ export default {
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},