Compare commits
8 Commits
edison_clock
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ffcbc8c71 | |||
| e039b9bfea | |||
| 80c9f6ad01 | |||
| 7345a4e1c8 | |||
| bf90ff714c | |||
| 44c7ea552f | |||
| e659b2b455 | |||
| c04df47418 |
@@ -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
@@ -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
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic",
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/locales"
|
||||
]
|
||||
}
|
||||
Generated
+3
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
Generated
+6
@@ -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>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
+18
@@ -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>
|
||||
Generated
+13
@@ -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>
|
||||
Generated
+10
@@ -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>
|
||||
Generated
+9
@@ -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>
|
||||
Generated
+17
@@ -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>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="StudioBotProjectSettings">
|
||||
<option name="shareContext" value="OptedIn" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Vendored
+17
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 & privacy</em> এর অধীনে)</span>।",
|
||||
"<strong>Use location</strong> ON করুন।",
|
||||
"<strong>App permissions</strong> খুলুন → <strong>Attendance System</strong> খুঁজুন → <strong>Allow while using the app</strong> সেট করুন।",
|
||||
"উপলব্ধ থাকলে <strong>Precise location</strong> সক্রিয় করুন।",
|
||||
"App এ ফিরে যান এবং আবার clock-in করার চেষ্টা করুন।"
|
||||
],
|
||||
"note": "ব্র্যান্ড অনুযায়ী নাম ভিন্ন: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services।"
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Camera permission সক্রিয় করুন (Android)",
|
||||
"steps": [
|
||||
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> খুলুন।",
|
||||
"<strong>Permissions</strong> → <strong>Camera</strong> ট্যাপ করুন → <strong>Allow</strong> অথবা <strong>Allow while using the app</strong> নির্বাচন করুন।",
|
||||
"App পুনরায় খুলুন এবং আবার scanning এর চেষ্টা করুন।"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in কাজ করছে না? দ্রুত checklist",
|
||||
"steps": [
|
||||
"<strong>Location</strong> ON করুন এবং app permission <strong>Allow while using the app</strong> সেট করুন (উপলব্ধ থাকলে <strong>Precise location</strong> সক্রিয় করুন)।",
|
||||
"Network পরীক্ষা করুন: Wi-Fi অথবা data চালু আছে। <strong>Airplane mode</strong> off→on toggle করুন, তারপর আবার চেষ্টা করুন। হস্তক্ষেপ করলে VPN নিষ্ক্রিয় করুন।",
|
||||
"নিশ্চিত করুন <strong>Automatic date & time</strong> এবং <strong>time zone</strong> Android settings এ সক্রিয় আছে।",
|
||||
"Force close করুন এবং app পুনরায় খুলুন। প্রয়োজনে, <strong>Attendance System</strong> cache clear করুন (Settings → Apps → Attendance System → Storage → Clear cache)।"
|
||||
],
|
||||
"note": "এখনও আটকে আছেন? একটি screenshot নিন এবং আপনার manager বা HR এর সাথে যোগাযোগ করুন।"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "শীঘ্রই আসছে।"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-2
@@ -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 & 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 & 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
@@ -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 & 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 & 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
@@ -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 & privacy</em> အောက်တွင်)</span>။",
|
||||
"<strong>Use location</strong> ကို ON ဖွင့်ပါ။",
|
||||
"<strong>App permissions</strong> ကိုဖွင့်ပါ → <strong>Attendance System</strong> ကိုရှာပါ → <strong>Allow while using the app</strong> သတ်မှတ်ပါ။",
|
||||
"ရနိုင်လျှင် <strong>Precise location</strong> ကို ဖွင့်ပါ။",
|
||||
"App သို့ပြန်သွားပြီး clock-in ကို ထပ်စမ်းကြည့်ပါ။"
|
||||
],
|
||||
"note": "အမှတ်တံဆိပ်ပေါ်မူတည်၍ အမည်များကွဲပြားနိုင်သည်: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services။"
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Camera permission ဖွင့်နည်း (Android)",
|
||||
"steps": [
|
||||
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> ကို ဖွင့်ပါ။",
|
||||
"<strong>Permissions</strong> → <strong>Camera</strong> ကို နှိပ်ပါ → <strong>Allow</strong> သို့မဟုတ် <strong>Allow while using the app</strong> ကို ရွေးပါ။",
|
||||
"App ကို ပြန်ဖွင့်ပြီး scanning ကို ထပ်စမ်းကြည့်ပါ။"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in အလုပ်မလုပ်ဘူးလား? အမြန် checklist",
|
||||
"steps": [
|
||||
"<strong>Location</strong> ကို ON ဖွင့်ပြီး app permission ကို <strong>Allow while using the app</strong> သတ်မှတ်ပါ (ရနိုင်လျှင် <strong>Precise location</strong> ကို ဖွင့်ပါ)။",
|
||||
"Network ကို စစ်ဆေးပါ: Wi-Fi သို့မဟုတ် data ကို ဖွင့်ထားသည်။ <strong>Airplane mode</strong> ကို off→on toggle လုပ်ပါ၊ ပြီးနောက် ထပ်စမ်းကြည့်ပါ။ ဝင်စွက်နေလျှင် VPN ကို ပိတ်ပါ။",
|
||||
"Android settings တွင် <strong>Automatic date & time</strong> နှင့် <strong>time zone</strong> တို့ကို ဖွင့်ထားကြောင်း သေချာပါစေ။",
|
||||
"Force close လုပ်ပြီး app ကို ပြန်ဖွင့်ပါ။ လိုအပ်လျှင် <strong>Attendance System</strong> cache ကို clear လုပ်ပါ (Settings → Apps → Attendance System → Storage → Clear cache)။"
|
||||
],
|
||||
"note": "ဆက်လက်ပြဿနာရှိနေပါသလား? Screenshot ရိုက်ပြီး သင့် manager သို့မဟုတ် HR ကို ဆက်သွယ်ပါ။"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "မကြာမီ ရောက်ရှိပါမည်။"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-1
@@ -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 & privacy</em> अन्तर्गत)</span>।",
|
||||
"<strong>Use location</strong> ON गर्नुहोस्।",
|
||||
"<strong>App permissions</strong> खोल्नुहोस् → <strong>Attendance System</strong> फेला पार्नुहोस् → <strong>Allow while using the app</strong> सेट गर्नुहोस्।",
|
||||
"उपलब्ध भए <strong>Precise location</strong> सक्षम गर्नुहोस्।",
|
||||
"App मा फर्कनुहोस् र फेरि clock-in प्रयास गर्नुहोस्।"
|
||||
],
|
||||
"note": "ब्रान्ड अनुसार नामहरू फरक हुन्छन्: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services।"
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Camera permission सक्षम गर्नुहोस् (Android)",
|
||||
"steps": [
|
||||
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> खोल्नुहोस्।",
|
||||
"<strong>Permissions</strong> → <strong>Camera</strong> ट्याप गर्नुहोस् → <strong>Allow</strong> वा <strong>Allow while using the app</strong> छान्नुहोस्।",
|
||||
"App पुन: खोल्नुहोस् र फेरि scanning प्रयास गर्नुहोस्।"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in काम गरिरहेको छैन? द्रुत checklist",
|
||||
"steps": [
|
||||
"<strong>Location</strong> ON गर्नुहोस् र app permission लाई <strong>Allow while using the app</strong> सेट गर्नुहोस् (उपलब्ध भए <strong>Precise location</strong> सक्षम गर्नुहोस्)।",
|
||||
"Network जाँच गर्नुहोस्: Wi-Fi वा data खुला छ। <strong>Airplane mode</strong> off→on toggle गर्नुहोस्, त्यसपछि फेरि प्रयास गर्नुहोस्। हस्तक्षेप गरेमा VPN निष्क्रिय गर्नुहोस्।",
|
||||
"Android settings मा <strong>Automatic date & time</strong> र <strong>time zone</strong> सक्षम छन् भनी सुनिश्चित गर्नुहोस्।",
|
||||
"Force close गर्नुहोस् र app पुन: खोल्नुहोस्। आवश्यक भएमा, <strong>Attendance System</strong> cache clear गर्नुहोस् (Settings → Apps → Attendance System → Storage → Clear cache)।"
|
||||
],
|
||||
"note": "अझै अड्किनु भयो? Screenshot लिनुहोस् र आफ्नो manager वा HR लाई सम्पर्क गर्नुहोस्।"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "चाँडै आउँदैछ।"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-1
@@ -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 & privacy</em> கீழ்)</span>.",
|
||||
"<strong>Use location</strong> ஐ ON செய்யவும்.",
|
||||
"<strong>App permissions</strong> ஐத் திறக்கவும் → <strong>Attendance System</strong> ஐக் கண்டறியவும் → <strong>Allow while using the app</strong> என அமைக்கவும்.",
|
||||
"கிடைத்தால் <strong>Precise location</strong> ஐ இயக்கவும்.",
|
||||
"ஆப்பிற்குத் திரும்பி மீண்டும் clock-in செய்ய முயற்சிக்கவும்."
|
||||
],
|
||||
"note": "பிராண்டுகளுக்கு ஏற்ப பெயர்கள் மாறுபடும்: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services."
|
||||
},
|
||||
{
|
||||
"id": "android-camera",
|
||||
"title": "Camera permission ஐ இயக்குவது எப்படி (Android)",
|
||||
"steps": [
|
||||
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> ஐத் திறக்கவும்.",
|
||||
"<strong>Permissions</strong> → <strong>Camera</strong> ஐத் தட்டவும் → <strong>Allow</strong> அல்லது <strong>Allow while using the app</strong> தேர்ந்தெடுக்கவும்.",
|
||||
"ஆப்பை மீண்டும் திறந்து scanning செய்ய முயற்சிக்கவும்."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockin-troubleshoot",
|
||||
"title": "Clock-in வேலை செய்யவில்லையா? விரைவு checklist",
|
||||
"steps": [
|
||||
"<strong>Location</strong> ஐ ON செய்து app permission ஐ <strong>Allow while using the app</strong> என அமைக்கவும் (கிடைத்தால் <strong>Precise location</strong> ஐ இயக்கவும்).",
|
||||
"Network ஐ சரிபார்க்கவும்: Wi-Fi அல்லது data இயக்கத்தில் உள்ளது. <strong>Airplane mode</strong> ஐ off→on toggle செய்து, பின்னர் மீண்டும் முயற்சிக்கவும். தடையாக இருந்தால் VPN ஐ முடக்கவும்.",
|
||||
"Android settings இல் <strong>Automatic date & time</strong> மற்றும் <strong>time zone</strong> இயக்கப்பட்டுள்ளதா என உறுதிப்படுத்தவும்.",
|
||||
"Force close செய்து app ஐ மீண்டும் திறக்கவும். தேவைப்பட்டால், <strong>Attendance System</strong> cache ஐ clear செய்யவும் (Settings → Apps → Attendance System → Storage → Clear cache)."
|
||||
],
|
||||
"note": "இன்னும் சிக்கலா? Screenshot எடுத்து உங்கள் manager அல்லது HR ஐ தொடர்பு கொள்ளவும்."
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"heading": "iOS",
|
||||
"comingSoon": "விரைவில் வரும்."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
},
|
||||
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user