Merge branch 'main' into edison_dev2
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
NiLai-Clock is a worker attendance management system with role-based dashboards for workers and managers. It's built as a Vue.js SPA with an Express.js backend, featuring QR code attendance tracking, geofencing, and comprehensive reporting capabilities.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
- `npm run dev` - Start frontend development server (Vite on port 5173)
|
||||||
|
- `npm run backend` - Start backend server (Express on port 3000)
|
||||||
|
- `npm run dev:all` - Run both frontend and backend concurrently
|
||||||
|
- `npm install` - Install dependencies
|
||||||
|
|
||||||
|
### Production
|
||||||
|
- `npm run build` - Build for production using Vite
|
||||||
|
- `npm run preview` - Preview production build locally
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- `npm run lint` - Run ESLint with auto-fix
|
||||||
|
- `npm run format` - Format code using Prettier
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Frontend (Vue.js)
|
||||||
|
- **Framework**: Vue 3 with Composition API
|
||||||
|
- **Build Tool**: Vite with Vue plugin and TailwindCSS
|
||||||
|
- **Routing**: Vue Router with hash-based routing and role-based guards
|
||||||
|
- **State**: Session storage for authentication (userId, userRole, token)
|
||||||
|
- **Styling**: TailwindCSS v4 with responsive design
|
||||||
|
- **Internationalization**: Vue I18n with English (en) and Malay (ms) locales
|
||||||
|
|
||||||
|
### Backend (Express.js)
|
||||||
|
- **Server**: Express with CORS, supports both HTTP and HTTPS
|
||||||
|
- **Database**: MySQL with connection pooling (mysql2)
|
||||||
|
- **Authentication**: JWT tokens with bcrypt for password hashing
|
||||||
|
- **Routes**: Separated into manager (`/api/managers/*`) and worker (`/api/*`) routes
|
||||||
|
|
||||||
|
### Key Components Structure
|
||||||
|
|
||||||
|
#### Views (Role-Based)
|
||||||
|
- **Worker**: `WorkerDashboard`, `WorkerHistory`, `WorkerSettings`, `WorkerChangePassword`
|
||||||
|
- **Manager**: `ManagerDashboard`, `ManagerAttendanceRecord`
|
||||||
|
- **Auth**: `Login` (supports both roles)
|
||||||
|
|
||||||
|
#### Components
|
||||||
|
- **Management**: `PersonnelManagement`, `GeofenceManagement`, `QrCodeManagement`, `KillSwitchManagement`
|
||||||
|
- **UI**: `Toast` notifications with `useToast` composable
|
||||||
|
- **Reporting**: `WarningReporting` with CSV export capabilities
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
- Role-based routing with `meta: { requiresAuth: true, role: 'worker|manager' }`
|
||||||
|
- Navigation guards redirect users to appropriate dashboards
|
||||||
|
- Session storage manages authentication state
|
||||||
|
- API requests include JWT tokens via Authorization header
|
||||||
|
|
||||||
|
### Key Libraries
|
||||||
|
- **Maps**: Leaflet with drawing capabilities for geofencing
|
||||||
|
- **QR Codes**: html5-qrcode for scanning, qrcode for generation
|
||||||
|
- **Geospatial**: @turf/turf for geographical calculations
|
||||||
|
- **Data Export**: json2csv for attendance reports
|
||||||
|
- **UUID**: For device identification and worker login tracking
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
Create `.env` file with:
|
||||||
|
```
|
||||||
|
DB_HOST=your_database_host
|
||||||
|
DB_USER=your_database_user
|
||||||
|
DB_PASSWORD=your_database_password
|
||||||
|
DB_NAME=your_database_name
|
||||||
|
DB_PORT=your_database_port
|
||||||
|
VITE_API_BASE_URL=your_api_base_url
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional SSL configuration:
|
||||||
|
- `SSL_ENABLED=true`
|
||||||
|
- `HTTP_PORT=3000`
|
||||||
|
- `HTTPS_PORT=3443`
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- Frontend uses hash-based routing for better deployment compatibility
|
||||||
|
- Backend supports both HTTP and HTTPS with configurable ports
|
||||||
|
- CORS configured for multiple origins including mobile app protocols
|
||||||
|
- Database connection is tested on server startup with proper error handling
|
||||||
|
- All API calls go through centralized `apiFetch` utility with error handling
|
||||||
|
- ESLint configured to ignore unused variables/parameters prefixed with underscore
|
||||||
|
- Prettier integrated with Vue ESLint config for consistent formatting
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The application manages worker attendance with these core entities:
|
||||||
|
- **Workers**: Authentication, device UUID tracking, role assignment
|
||||||
|
- **Managers**: Administrative access and permissions
|
||||||
|
- **Attendance Records**: Clock in/out with location and timestamp data
|
||||||
|
- **Geofences**: Location boundaries for valid attendance tracking
|
||||||
|
- **QR Codes**: Dynamic codes for attendance verification
|
||||||
|
|
||||||
|
## Docker Support
|
||||||
|
|
||||||
|
Includes Docker configuration:
|
||||||
|
- `Dockerfile` - Node.js Alpine-based container
|
||||||
|
- `docker-compose.yml` - Multi-service setup with nginx reverse proxy
|
||||||
|
- Production deployment on port 18080 (nginx) and 18081 (app)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEajCCAtKgAwIBAgIQCidY0lKaDwojBgr6MpeBzzANBgkqhkiG9w0BAQsFADCB
|
||||||
|
kTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlM
|
||||||
|
XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMM
|
||||||
|
MW1rY2VydCBNQUlMXG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lh
|
||||||
|
bikwHhcNMjUwNzA0MDc0NjExWhcNMjcxMDA0MDc0NjExWjBeMScwJQYDVQQKEx5t
|
||||||
|
a2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxMzAxBgNVBAsMKk1BSUxcbWFz
|
||||||
|
b25neWFuQERFU0tUT1AtSVFVOERERCAobWFzb25neWFuKTCCASIwDQYJKoZIhvcN
|
||||||
|
AQEBBQADggEPADCCAQoCggEBANl8SofEGCDGYv2J22Qanu6LgxvvKd9wKB1Lf2x6
|
||||||
|
eBD84tHmVZXKuQElo9ZkEbljKA9M8dNCTrxNFzGL6dB2b3fRHBnEYhiANKnMohgb
|
||||||
|
oul+Tiq2/Pye4SHWglvsM6DboImARRW58L8FyA3mnS9VgS7TUb3W2tRQhLHU1s/R
|
||||||
|
QjZulIQvpe+k0dW+S1zd7wBg790K5GNs9va/8KEM1v3esBNOpCbKeWzeRT/Si9ZA
|
||||||
|
Dfm72SSWslHQEXtuz8AQVtfk0qJMUB0URmyadir0aJwuDC6m5iQSKtLTvQp+n0/Z
|
||||||
|
lundQQbsnm71FnCAD9PSz+IaB3euEOwUGbGnDW9+10kGTekCAwEAAaNwMG4wDgYD
|
||||||
|
VR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFElR
|
||||||
|
m5C15845O14vXvSvwjxwtiJEMCYGA1UdEQQfMB2CCWxvY2FsaG9zdIcECgACAocE
|
||||||
|
fwAAAYcEwKgkNjANBgkqhkiG9w0BAQsFAAOCAYEAsOdvadeTxsAT0Le63PPEYPiZ
|
||||||
|
drkEJdTyu9Thv9nFhLCD4vUYIZrlE3brFXD1iVTR1muJsalfnmW9azIwGBHw52bZ
|
||||||
|
B2XdA6HNZEklSRtqNMEAGJsdnbGuCTPa1lLNuzCQodSnmbvu6Y5K13Pq/asl3DVW
|
||||||
|
h/hczwX5NrQvlvyDwI0kVSDRmEb5AYnEic5h64gEyILTVWopT8RzA+B8AtW3oP3d
|
||||||
|
pfoCErwQvxfkNd3UGWk+rDlQWwApzh+N4P+3vAjhAra7Yoj+JtT0SnXeAjXhbB0E
|
||||||
|
WmDcMNQwxUg1FN5ATR5pAMoSSNviLaf/jYb93naZ6YZKgSfSIKNgUJz+ppgHNBFr
|
||||||
|
326JOYH0yzyhWXUXchzsn1ytMkhddNVZhRbGceOkyZEkaSynZR4om8ZGxPJYfCBB
|
||||||
|
m9sH27eCeJBy9DXk0ZUkJg+y3C+jizenHiPnED92Z1EZ0ke7fNufiVZs0yQl2uxg
|
||||||
|
V5mgoQSLxu4LHXQnTm/NQugY9S8rfbz510WutGKi
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDZfEqHxBggxmL9
|
||||||
|
idtkGp7ui4Mb7ynfcCgdS39sengQ/OLR5lWVyrkBJaPWZBG5YygPTPHTQk68TRcx
|
||||||
|
i+nQdm930RwZxGIYgDSpzKIYG6Lpfk4qtvz8nuEh1oJb7DOg26CJgEUVufC/BcgN
|
||||||
|
5p0vVYEu01G91trUUISx1NbP0UI2bpSEL6XvpNHVvktc3e8AYO/dCuRjbPb2v/Ch
|
||||||
|
DNb93rATTqQmynls3kU/0ovWQA35u9kklrJR0BF7bs/AEFbX5NKiTFAdFEZsmnYq
|
||||||
|
9GicLgwupuYkEirS070Kfp9P2Zbp3UEG7J5u9RZwgA/T0s/iGgd3rhDsFBmxpw1v
|
||||||
|
ftdJBk3pAgMBAAECggEAIeDztzx7ybc9umMcMvbWpTBEZziVXEIbbZzSJ7LYO0U5
|
||||||
|
jBsGYAQpV51mbUI/ZJKmreN9lDwzCbA0mbpC3P9mE9MWPolSAqEOExlWcszzTs4n
|
||||||
|
HQ5OUIfraBsDSZB85mTwGBtMJ7tEXm1nIYs4FySJsCKpDBqJEiPM1+rg35SobNP5
|
||||||
|
aOvuLgXe3V6wVuihakoGj8nUtCgKsPr/14ybcF6Fcv5ULI6Tls0G8HOY92Kesb/o
|
||||||
|
NZL1YmMVevY+RKYzrZKca6mRanMIjnjrnYGX5V404mh6GQKpGdgrcMEONMbJje2H
|
||||||
|
44MjyJYhQ67/ItOKOuC1JG1LuRq/5SXTAS2WW7g+1QKBgQDiWefUn2v3pYd4CIFd
|
||||||
|
Bz43TpHuQZiqX5UOvPFOrk5LT+EhYHTpSCThrc5piqk+XsnV3G1dyDnbBK8k4FPa
|
||||||
|
yyrUuNOSvQlspSr0u++5i7cRLwq7C6kRtTzW8nr6Az8bE6u1prvXKFIWKP/doWeg
|
||||||
|
U7jPMCVKN+oxvNN6Fi0meecLxwKBgQD1+RkfrUCg7xpr+gn2R2LxryL2u/oxVRmo
|
||||||
|
4TZqBQoXcQJBx+UrTcIL8XENohYYI/7HCZfD/cBxpFGNqclD3DjzjH2NZ43MBlbN
|
||||||
|
up3wD+Ks2LVOilyOrxK3be/cnvPyQJantd/NBnHOTsQoBUPdhbrqdyrjYW0o4WZQ
|
||||||
|
5c36f934zwKBgQCRiTEQeviWoG279ewHfpK4SOJ3iOG6Gf7jHQUii9x3fALKzRQe
|
||||||
|
sm5UVMZ1AdzT52prAXGobQcWFarvUPVZpmwBnl0a6kTXAFPgS75VVMn+WHrTzSmF
|
||||||
|
4zwdEIeVnOTEah9riqsYKiqtaOsq+45/fZVEUjaHw+/mzvxCcWPSa2rtHQKBgEUe
|
||||||
|
amDsXmzaw6Hz8TizdqpTfI+44uVZ9IvwPUotgFh1+Rxi/5LbltukTRB3q528/6sO
|
||||||
|
lwcMFzfX5NLaEyRujdJieCV0I/RhE6Nb/WWoERphCxG276topunEitKEGCjK3Yrj
|
||||||
|
ILCMTw6aM6TLVfa5zXx1YCflCLekHww8h1UM+WMhAoGAH6U1XzkW3ozty7sQ5vxZ
|
||||||
|
jzri0xUpp06EA/EtfhkCRPgaYCkL5aXan+jNAZPfTG6mGudULWjTIfEEQrMJ54CN
|
||||||
|
sItMoPP2S4EDuj4xdQWe8eTeMqtGG/lAmG2Yr9QajWofNLwaBtsXANYCDGadNUxa
|
||||||
|
2pog6+BDaFEC64IwkoBYgZ8=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
+369
-37
@@ -16,17 +16,39 @@ export default function(db) {
|
|||||||
if (err || user.role !== 'manager') {
|
if (err || user.role !== 'manager') {
|
||||||
return res.status(403).json({ message: 'Forbidden' });
|
return res.status(403).json({ message: 'Forbidden' });
|
||||||
}
|
}
|
||||||
req.user = user;
|
req.user = { ...user, id: user.userId }; // Correctly map userId to id
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(401).json({ message: 'Unauthorized' });
|
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);
|
router.use(authenticateJWT);
|
||||||
|
|
||||||
// --- START: Date Management Routes ---
|
// --- START: Date Management Routes ---
|
||||||
router.get('/enabled-dates', async (req, res) => {
|
router.get('/enabled-dates', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
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');
|
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
|
// Format date safely using components from the database to avoid timezone shifts
|
||||||
@@ -39,7 +61,7 @@ export default function(db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Definitive version using a dedicated database connection
|
// Definitive version using a dedicated database connection
|
||||||
router.post('/enabled-dates/update', async (req, res) => {
|
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
|
let connection; // Define connection here to ensure it's accessible in the 'finally' block
|
||||||
try {
|
try {
|
||||||
const { datesToEnable, datesToDisable } = req.body;
|
const { datesToEnable, datesToDisable } = req.body;
|
||||||
@@ -77,7 +99,7 @@ export default function(db) {
|
|||||||
|
|
||||||
// --- ATTENDANCE & REPORTING ---
|
// --- ATTENDANCE & REPORTING ---
|
||||||
|
|
||||||
router.get('/failed-records', async (req, res) => {
|
router.get('/failed-records', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search = '', startDate, endDate } = req.query;
|
const { search = '', startDate, endDate } = req.query;
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
@@ -112,7 +134,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/failed-records/details', async (req, res) => {
|
router.get('/failed-records/details', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workerId, startDate, endDate } = req.query;
|
const { workerId, startDate, endDate } = req.query;
|
||||||
if (!workerId || !startDate || !endDate) {
|
if (!workerId || !startDate || !endDate) {
|
||||||
@@ -139,7 +161,7 @@ export default function(db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET attendance records with a modified query to avoid the MySQL 5.7 bug
|
// GET attendance records with a modified query to avoid the MySQL 5.7 bug
|
||||||
router.get('/attendance-records/export-raw', async (req, res) => {
|
router.get('/attendance-records/export-raw', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workerIds, startDate, endDate } = req.query;
|
const { workerIds, startDate, endDate } = req.query;
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
@@ -177,7 +199,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/add-record', authenticateJWT, async (req, res) => {
|
router.post('/add-record', checkPermission('edit_workers'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workerId, eventType, timestamp, notes } = req.body
|
const { workerId, eventType, timestamp, notes } = req.body
|
||||||
|
|
||||||
@@ -212,7 +234,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/attendance-records/export', async (req, res) => {
|
router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workerIds, startDate, endDate } = req.query;
|
const { workerIds, startDate, endDate } = req.query;
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
@@ -295,7 +317,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/attendance-records', async (req, res) => {
|
router.get('/attendance-records', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workerIds, startDate, endDate, format } = req.query;
|
const { workerIds, startDate, endDate, format } = req.query;
|
||||||
if (!workerIds) {
|
if (!workerIds) {
|
||||||
@@ -351,22 +373,107 @@ export default function(db) {
|
|||||||
|
|
||||||
// --- All other manager routes remain the same ---
|
// --- 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
|
// GET all workers with filtering and pagination
|
||||||
router.get('/workers', async (req, res) => {
|
router.get('/workers', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search = '', page = 1, limit = 20 } = req.query;
|
const { search = '', page = 1, limit = 20 } = req.query;
|
||||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||||
const searchTerm = `%${search}%`;
|
const searchTerm = `%${search}%`;
|
||||||
|
|
||||||
let baseQuery = `
|
let baseQuery = `
|
||||||
SELECT w.id, w.username, w.full_name, w.department, w.position, w.created_at
|
SELECT w.id, w.username, w.full_name, w.department, w.position, w.created_at, w.status
|
||||||
FROM workers w
|
FROM workers w
|
||||||
`;
|
`;
|
||||||
let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`;
|
let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`;
|
||||||
|
|
||||||
const params = [];
|
const params = [];
|
||||||
const countParams = [];
|
const countParams = [];
|
||||||
let whereClauses = ["w.role = 'worker'"];
|
let whereClauses = ["w.role = 'worker'", "w.status != 'deleted'"]; // Filter out soft-deleted workers
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`);
|
whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`);
|
||||||
@@ -393,8 +500,91 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// POST (add) a new worker
|
||||||
router.post('/workers', async (req, res) => {
|
router.post('/workers', checkPermission('edit_workers'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password, fullName, department, position, role = 'worker' } = req.body;
|
const { username, password, fullName, department, position, role = 'worker' } = req.body;
|
||||||
if (!username || !password || !fullName) {
|
if (!username || !password || !fullName) {
|
||||||
@@ -402,10 +592,10 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
const [result] = await db.execute(
|
const [result] = await db.execute(
|
||||||
'INSERT INTO workers (username, password_hash, full_name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO workers (username, password_hash, full_name, role, department, position, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[username, hashedPassword, fullName, role, department, position]
|
[username, hashedPassword, fullName, role, department, position, 'active'] // Default status to 'active'
|
||||||
);
|
);
|
||||||
res.status(201).json({ id: result.insertId, username, fullName, role, department, position });
|
res.status(201).json({ id: result.insertId, username, fullName, role, department, position, status: 'active' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Add worker error:', error);
|
console.error('Add worker error:', error);
|
||||||
if (error.code === 'ER_DUP_ENTRY') {
|
if (error.code === 'ER_DUP_ENTRY') {
|
||||||
@@ -415,23 +605,132 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE a worker
|
// Soft DELETE a worker (update status to 'deleted')
|
||||||
router.delete('/workers/:id', async (req, res) => {
|
router.delete('/workers/:id', checkPermission('edit_workers'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [id]);
|
const [result] = await db.execute("UPDATE workers SET status = 'deleted' WHERE id = ? AND role = 'worker'", [id]);
|
||||||
if (result.affectedRows === 0) {
|
if (result.affectedRows === 0) {
|
||||||
return res.status(404).json({ message: 'Worker not found.' });
|
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();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete worker error:', error);
|
console.error('Soft delete manager error:', error);
|
||||||
res.status(500).json({ message: 'Database error deleting worker.', details: error.message });
|
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
|
// PUT (update) a worker's password
|
||||||
router.put('/workers/:workerId/password', async (req, res) => {
|
router.put('/workers/:workerId/password', checkPermission('edit_workers'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workerId } = req.params;
|
const { workerId } = req.params;
|
||||||
const { newPassword } = req.body;
|
const { newPassword } = req.body;
|
||||||
@@ -450,24 +749,57 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT (clear) a worker's device UUID
|
// PUT (update) a manager's password
|
||||||
router.put('/workers/:workerId/reset-device', async (req, res) => {
|
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 {
|
try {
|
||||||
const { workerId } = req.params;
|
const { workerId } = req.params;
|
||||||
const [result] = await db.execute("UPDATE workers SET device_uuid = NULL WHERE id = ?", [workerId]);
|
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) {
|
if (result.affectedRows === 0) {
|
||||||
return res.status(404).json({ message: 'Worker not found.' });
|
return res.status(404).json({ message: 'Worker not found.' });
|
||||||
}
|
}
|
||||||
res.status(200).json({ message: 'Device registration cleared.' });
|
res.status(200).json({ message: 'Device registration cleared and/or status updated.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Reset device error:', error);
|
console.error('Reset device/update status error:', error);
|
||||||
res.status(500).json({ message: 'Database error resetting device.', details: error.message });
|
res.status(500).json({ message: 'Database error resetting device or updating status.', details: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Geofence Management Routes
|
// Geofence Management Routes
|
||||||
router.get('/geofences', async (req, res) => {
|
router.get('/geofences', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.execute(
|
const [rows] = await db.execute(
|
||||||
'SELECT id, name, coordinates, is_active, created_at FROM geofences ORDER BY created_at DESC'
|
'SELECT id, name, coordinates, is_active, created_at FROM geofences ORDER BY created_at DESC'
|
||||||
@@ -483,7 +815,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/geofences', async (req, res) => {
|
router.post('/geofences', checkPermission('manage_resources'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, coordinates } = req.body;
|
const { name, coordinates } = req.body;
|
||||||
if (!name || !coordinates) {
|
if (!name || !coordinates) {
|
||||||
@@ -508,7 +840,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/geofences/:id', async (req, res) => {
|
router.put('/geofences/:id', checkPermission('manage_resources'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { is_active } = req.body;
|
const { is_active } = req.body;
|
||||||
@@ -533,7 +865,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/geofences/:id', async (req, res) => {
|
router.delete('/geofences/:id', checkPermission('manage_resources'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const [result] = await db.execute('DELETE FROM geofences WHERE id = ?', [id]);
|
const [result] = await db.execute('DELETE FROM geofences WHERE id = ?', [id]);
|
||||||
@@ -551,7 +883,7 @@ export default function(db) {
|
|||||||
|
|
||||||
|
|
||||||
// QR Code Management Routes
|
// QR Code Management Routes
|
||||||
router.get('/qr-codes', authenticateJWT, async (req, res) => {
|
router.get('/qr-codes', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.execute(
|
const [rows] = await db.execute(
|
||||||
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC'
|
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC'
|
||||||
@@ -563,7 +895,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/qr-codes', authenticateJWT, async (req, res) => {
|
router.post('/qr-codes', checkPermission('manage_resources'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
if (!name) return res.status(400).json({ message: 'QR Code name is required.' });
|
if (!name) return res.status(400).json({ message: 'QR Code name is required.' });
|
||||||
@@ -586,7 +918,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/qr-codes/:id', authenticateJWT, async (req, res) => {
|
router.put('/qr-codes/:id', checkPermission('manage_resources'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
// Handle both isActive (camelCase) and is_active (snake_case)
|
// Handle both isActive (camelCase) and is_active (snake_case)
|
||||||
@@ -612,7 +944,7 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/qr-codes/:id', authenticateJWT, async (req, res) => {
|
router.delete('/qr-codes/:id', checkPermission('manage_resources'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const [result] = await db.execute(
|
const [result] = await db.execute(
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIE8zCCA1ugAwIBAgIQGdkeqkj233eI/7av8ih4aTANBgkqhkiG9w0BAQsFADCB
|
||||||
|
kTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlM
|
||||||
|
XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMM
|
||||||
|
MW1rY2VydCBNQUlMXG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lh
|
||||||
|
bikwHhcNMjUwNzAzMDg1NDI2WhcNMzUwNzAzMDg1NDI2WjCBkTEeMBwGA1UEChMV
|
||||||
|
bWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlMXG1hc29uZ3lhbkBE
|
||||||
|
RVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMMMW1rY2VydCBNQUlM
|
||||||
|
XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikwggGiMA0GCSqG
|
||||||
|
SIb3DQEBAQUAA4IBjwAwggGKAoIBgQC5YL+VRL/bDBg0SP78IZTCemeLr7Q4Zxtg
|
||||||
|
8MiaWrDnh6ssVFzmAY3PEnfTdSL/j0JV2I0cSZhmMkUAzoo7136paLA3aGD4QP0B
|
||||||
|
fDEt6xQZF30U3bRhTglEY8a1zhy6fJGTYOcl2/OTbS0q90fEaLx8wkVa0lf/2wA7
|
||||||
|
fYG65BSu9CgTdob6NBWbI3Jpsesxd+36WZCqa6ZPSk07nXozqjMFsG8CThr1Wmei
|
||||||
|
mZJZF6+ji0mI6RqiqgdWrKBp2FZbPERQS+QfYfKD5/N0cWpwUAxejSLlPxU886Ns
|
||||||
|
Tcld9vxHQjzcE0afJe7rO4IrzzIeL1oLsz3xhEBgn8JCUeWbU12pk+9j1z+/M0+U
|
||||||
|
LUt/g+cwHk8fKl7qoL1ydR7afDdFBR8ns+g5l40ZE/uwhgQA8uTsi2E18B5agAtQ
|
||||||
|
C6+dJC4bMiVn9iyCeQmPKS+xw4YOVmn0yfrkqRLRgSZDjQEd4pUAep4J/8WbI1BY
|
||||||
|
lNqRwmqBcLuuyQLpExlMBYPMWiWYBakCAwEAAaNFMEMwDgYDVR0PAQH/BAQDAgIE
|
||||||
|
MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFElRm5C15845O14vXvSvwjxw
|
||||||
|
tiJEMA0GCSqGSIb3DQEBCwUAA4IBgQAIjI0pKdY1/NKIaCg0WuQcWh8/noTjqYdl
|
||||||
|
7RDJ+JQZB1W0SkN7XvSLiRcEroWalbq9BMwE/bwV9jcgW1NvQ+00VzUWKh0r9z3B
|
||||||
|
xjEnKnK+1pEXRdBfkG6bVi2XehNs7KOvqR07xv7o9GdB41R7TSo1Vr228ot82FNO
|
||||||
|
6B2iDumfIr9RESsx8nVntHvRuFTee/DlhVUEgJPWmw0Kwcewmd2p5XNijdA2V2nI
|
||||||
|
zwhUxQuuu0LtV8RXmBi5vDanrJHwZZ1kFvGG9SGiVNx6aEtGBjTMIFRpQyLFzeq9
|
||||||
|
TPbVLsEGjvi8wLqO8U/aj56BEkFNKAx0idohgyfF2qohRMXoL0MRtEQIpJdL2kMP
|
||||||
|
Gqg1aY7MWooEM9swji1hHuoDwLriVNS6W3LvT9qXWlI3e/J7f5aLT/QyP4VUW+4N
|
||||||
|
1oGUL54aXCMYymVXooU3QomakxCildlGbH0jdcf8uX8JVnI0Zeo3ftCmtf46Q+Lu
|
||||||
|
7Mhu4NO8kQGHH/m0wQwwahh+mBfwYwk=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
+38
-6
@@ -24,23 +24,55 @@ async function isClockingEnabled(db) {
|
|||||||
export default function(db) {
|
export default function(db) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Set DEVICE_UUID_ENABLED to false to completely disable device UUID checking
|
||||||
|
const DEVICE_UUID_ENABLED = false;
|
||||||
|
const REQUIRE_DEVICE_FOR_WORKERS = true;
|
||||||
|
const AUTO_REGISTER_NEW_DEVICES = true;
|
||||||
|
|
||||||
router.post('/auth/login', async (req, res) => {
|
router.post('/auth/login', async (req, res) => {
|
||||||
const { username, password, deviceUuid } = req.body;
|
const { username, password, deviceUuid } = req.body;
|
||||||
const [rows] = await db.execute('SELECT id, role, password_hash FROM workers WHERE username = ?', [username]);
|
const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return res.status(401).json({ message: 'Invalid credentials' });
|
return res.status(401).json({ message: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
|
|
||||||
|
// 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);
|
const passwordMatch = await bcrypt.compare(password, user.password_hash);
|
||||||
if (!passwordMatch) {
|
if (!passwordMatch) {
|
||||||
return res.status(401).json({ message: 'Invalid credentials' });
|
return res.status(401).json({ message: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
if (deviceUuid && user.role !== 'manager') {
|
|
||||||
const deviceValidation = await validateDeviceForUser(user.id, deviceUuid, db);
|
// Device UUID handling - controlled by configuration flags above
|
||||||
if (!deviceValidation.valid) {
|
if (DEVICE_UUID_ENABLED && user.role === 'worker') {
|
||||||
return res.status(403).json({ message: deviceValidation.message });
|
const [deviceRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [user.id]);
|
||||||
|
const existingDeviceUuid = deviceRows[0].device_uuid;
|
||||||
|
|
||||||
|
if (existingDeviceUuid) {
|
||||||
|
if (deviceUuid && deviceUuid !== existingDeviceUuid) {
|
||||||
|
return res.status(403).json({ message: 'deviceMismatch' });
|
||||||
|
} else if (!deviceUuid) {
|
||||||
|
return res.status(403).json({ message: 'useMobileApp' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User has no registered device
|
||||||
|
if (deviceUuid && AUTO_REGISTER_NEW_DEVICES) {
|
||||||
|
const deviceResult = await validateDeviceForUser(user.id, deviceUuid, db);
|
||||||
|
if (!deviceResult.valid) {
|
||||||
|
return res.status(500).json({ message: 'deviceRegistrationFailed' });
|
||||||
|
}
|
||||||
|
// console.log(`Device UUID registered for worker ${user.id}: ${deviceUuid}`);
|
||||||
|
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
|
||||||
|
return res.status(403).json({ message: 'deviceRequired' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managers can always login, workers without device_uuid can login
|
||||||
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
res.json({ token });
|
res.json({ token });
|
||||||
});
|
});
|
||||||
@@ -53,7 +85,7 @@ export default function(db) {
|
|||||||
if (err) {
|
if (err) {
|
||||||
return res.status(403).json({ message: 'Invalid or expired token' });
|
return res.status(403).json({ message: 'Invalid or expired token' });
|
||||||
}
|
}
|
||||||
req.user = user;
|
req.user = { ...user, id: user.userId }; // Correctly map userId to id
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
-- Simple geofence management table
|
|
||||||
CREATE TABLE IF NOT EXISTS `geofences` (
|
|
||||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
||||||
`name` varchar(100) NOT NULL,
|
|
||||||
`coordinates` text NOT NULL,
|
|
||||||
`is_active` tinyint(1) DEFAULT 1,
|
|
||||||
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
|
|
||||||
|
|
||||||
-- Insert current geofence as default
|
|
||||||
INSERT INTO `geofences` (`name`, `coordinates`) VALUES
|
|
||||||
('Main Work Area', '[[113.35311466293217,23.161344441258407],[113.28591534444001,23.161344441258407],[113.28591534444001,23.091366234233973],[113.35311466293217,23.091366234233973],[113.35311466293217,23.161344441258407]]');
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||||
/>
|
/>
|
||||||
<title>Vite App</title>
|
<title>Ouji Kehadiran</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+31
-1066
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -13,9 +13,7 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/cli": "^7.4.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@capacitor/core": "^7.4.0",
|
|
||||||
"@primeuix/themes": "^1.1.2",
|
|
||||||
"@turf/turf": "^7.2.0",
|
"@turf/turf": "^7.2.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
@@ -26,7 +24,6 @@
|
|||||||
"json2csv": "^6.0.0-alpha.2",
|
"json2csv": "^6.0.0-alpha.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mysql2": "^3.14.2",
|
"mysql2": "^3.14.2",
|
||||||
"primevue": "^4.3.5",
|
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
+17
-4
@@ -1,23 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="min-h-screen bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors duration-300">
|
class="min-h-screen bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors duration-300">
|
||||||
|
<div class="fixed bottom-4 right-4 space-y-2 z-50 z-9999">
|
||||||
|
<component v-for="toast in renderToasts()" :is="toast" :key="toast.key" />
|
||||||
|
</div>
|
||||||
<header
|
<header
|
||||||
class="flex justify-between items-center px-4 py-3 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm transition-colors duration-300">
|
class="flex justify-between items-center px-4 py-3 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm transition-colors duration-300">
|
||||||
<h1 class="text-xl sm:text-2xl font-bold">{{ $t('appTitle') }}</h1>
|
<h1 class="text-xl sm:text-2xl font-bold">{{ $t('appTitle') }}</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Language Selector - Hidden on mobile -->
|
||||||
<!-- Language Selector -->
|
<select v-if="!isMobile" v-model="currentLang" @change="changeLang"
|
||||||
<select v-model="currentLang" @change="changeLang"
|
|
||||||
class="px-2 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
|
class="px-2 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
|
||||||
style="font-size: 0.9em;">
|
style="font-size: 0.9em;">
|
||||||
<option value="en">{{ $t('english') }}</option>
|
<option value="en">{{ $t('english') }}</option>
|
||||||
<option value="ms">{{ $t('malay') }}</option>
|
<option value="ms">{{ $t('malay') }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button v-if="isLoggedIn" @click="logout"
|
<!-- Logout Button - Hidden on mobile -->
|
||||||
|
<button v-if="isLoggedIn && !isMobile" @click="logout"
|
||||||
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-md font-semibold transition-colors duration-200">
|
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-md font-semibold transition-colors duration-200">
|
||||||
{{ $t('logout') }}
|
{{ $t('logout') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button @click="toggleTheme"
|
<button @click="toggleTheme"
|
||||||
class="flex items-center justify-center w-11 h-11 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-lg rounded-full transition-colors duration-200"
|
class="flex items-center justify-center w-11 h-11 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-lg rounded-full transition-colors duration-200"
|
||||||
title="Toggle Theme">
|
title="Toggle Theme">
|
||||||
@@ -36,14 +40,17 @@
|
|||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
const { renderToasts } = useToast()
|
||||||
|
|
||||||
const isDarkMode = ref(false)
|
const isDarkMode = ref(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const isLoggedIn = ref(!!sessionStorage.getItem('userId'))
|
const isLoggedIn = ref(!!sessionStorage.getItem('userId'))
|
||||||
|
const isMobile = ref(window.innerWidth < 640)
|
||||||
|
|
||||||
// Language switch logic
|
// Language switch logic
|
||||||
const currentLang = ref(locale.value)
|
const currentLang = ref(locale.value)
|
||||||
@@ -82,6 +89,12 @@ const updateTheme = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
isMobile.value = window.innerWidth < 640
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
// Restore theme
|
// Restore theme
|
||||||
const savedTheme = localStorage.getItem('darkMode')
|
const savedTheme = localStorage.getItem('darkMode')
|
||||||
isDarkMode.value = savedTheme === 'true'
|
isDarkMode.value = savedTheme === 'true'
|
||||||
|
|||||||
+10
@@ -31,6 +31,16 @@ export async function apiFetch(endpoint, options = {}) {
|
|||||||
// If the server sends back a JSON error, parse it.
|
// If the server sends back a JSON error, parse it.
|
||||||
if (contentType && contentType.includes('application/json')) {
|
if (contentType && contentType.includes('application/json')) {
|
||||||
errorData = await response.json();
|
errorData = await response.json();
|
||||||
|
if (response.status === 403) {
|
||||||
|
const event = new CustomEvent('show-toast', {
|
||||||
|
detail: {
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Permission Denied',
|
||||||
|
detail: errorData.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
// Use the 'details' from our backend error structure, or the message, or a default
|
// Use the 'details' from our backend error structure, or the message, or a default
|
||||||
throw new Error(errorData.details || errorData.message || `API call failed with status: ${response.status}`);
|
throw new Error(errorData.details || errorData.message || `API call failed with status: ${response.status}`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -83,10 +83,23 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||||
import { apiFetch } from '@/api.js';
|
import { apiFetch } from '@/api.js';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import 'leaflet-draw/dist/leaflet.draw.css';
|
import 'leaflet-draw/dist/leaflet.draw.css';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet-draw';
|
import 'leaflet-draw';
|
||||||
|
import icon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
import iconRetina from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
|
||||||
|
L.Icon.Default.prototype.options.iconUrl = icon;
|
||||||
|
L.Icon.Default.prototype.options.iconRetinaUrl = iconRetina;
|
||||||
|
L.Icon.Default.prototype.options.shadowUrl = iconShadow;
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
|
||||||
|
const { t: $t } = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const geofences = ref([]);
|
const geofences = ref([]);
|
||||||
const newGeofenceName = ref('');
|
const newGeofenceName = ref('');
|
||||||
@@ -97,13 +110,11 @@ const fenceLayers = {};
|
|||||||
|
|
||||||
const canSave = computed(() => newGeofenceName.value && newGeofenceCoords.value);
|
const canSave = computed(() => newGeofenceName.value && newGeofenceCoords.value);
|
||||||
|
|
||||||
// START: Added Function
|
|
||||||
const startOver = () => {
|
const startOver = () => {
|
||||||
drawnItems.clearLayers();
|
drawnItems.clearLayers();
|
||||||
newGeofenceCoords.value = null;
|
newGeofenceCoords.value = null;
|
||||||
newGeofenceName.value = '';
|
newGeofenceName.value = '';
|
||||||
};
|
};
|
||||||
// END: Added Function
|
|
||||||
|
|
||||||
const fetchGeofences = async () => {
|
const fetchGeofences = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -111,6 +122,7 @@ const fetchGeofences = async () => {
|
|||||||
displayGeofencesOnMap();
|
displayGeofencesOnMap();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch geofences:', error);
|
console.error('Failed to fetch geofences:', error);
|
||||||
|
toast.showToast($t('fetchGeofencesFailed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,7 +149,7 @@ const initMap = () => {
|
|||||||
map.addControl(drawControl);
|
map.addControl(drawControl);
|
||||||
|
|
||||||
map.on(L.Draw.Event.CREATED, (event) => {
|
map.on(L.Draw.Event.CREATED, (event) => {
|
||||||
drawnItems.clearLayers(); // Clear previous unsaved drawings
|
drawnItems.clearLayers();
|
||||||
const layer = event.layer;
|
const layer = event.layer;
|
||||||
drawnItems.addLayer(layer);
|
drawnItems.addLayer(layer);
|
||||||
const latLngs = layer.getLatLngs()[0];
|
const latLngs = layer.getLatLngs()[0];
|
||||||
@@ -148,7 +160,6 @@ const initMap = () => {
|
|||||||
|
|
||||||
const displayGeofencesOnMap = () => {
|
const displayGeofencesOnMap = () => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
// Clear existing fence layers before redrawing
|
|
||||||
Object.values(fenceLayers).forEach(layer => map.removeLayer(layer));
|
Object.values(fenceLayers).forEach(layer => map.removeLayer(layer));
|
||||||
|
|
||||||
geofences.value.forEach(fence => {
|
geofences.value.forEach(fence => {
|
||||||
@@ -170,15 +181,19 @@ const saveGeofence = async () => {
|
|||||||
body: JSON.stringify({ name: newGeofenceName.value, coordinates: newGeofenceCoords.value })
|
body: JSON.stringify({ name: newGeofenceName.value, coordinates: newGeofenceCoords.value })
|
||||||
});
|
});
|
||||||
geofences.value.unshift(newFence);
|
geofences.value.unshift(newFence);
|
||||||
startOver(); // Use startOver to clear the form
|
startOver();
|
||||||
displayGeofencesOnMap();
|
displayGeofencesOnMap();
|
||||||
|
toast.showToast($t('geofenceSaved'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save geofence:', error);
|
console.error('Failed to save geofence:', error);
|
||||||
|
toast.showToast($t('saveGeofenceFailed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteGeofence = async (id) => {
|
const deleteGeofence = async (id) => {
|
||||||
if (!confirm('Are you sure you want to delete this geofence?')) return;
|
const confirmed = await toast.showConfirm($t('confirmDeleteGeofence'))
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/managers/geofences/${id}`, { method: 'DELETE' });
|
await apiFetch(`/api/managers/geofences/${id}`, { method: 'DELETE' });
|
||||||
if (fenceLayers[id]) {
|
if (fenceLayers[id]) {
|
||||||
@@ -186,8 +201,10 @@ const deleteGeofence = async (id) => {
|
|||||||
delete fenceLayers[id];
|
delete fenceLayers[id];
|
||||||
}
|
}
|
||||||
geofences.value = geofences.value.filter(g => g.id !== id);
|
geofences.value = geofences.value.filter(g => g.id !== id);
|
||||||
|
toast.showToast($t('geofenceDeleted'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete geofence:', error);
|
console.error('Failed to delete geofence:', error);
|
||||||
|
toast.showToast($t('deleteGeofenceFailed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,8 +221,10 @@ const toggleGeofenceStatus = async (fence) => {
|
|||||||
if (fenceLayers[fence.id]) {
|
if (fenceLayers[fence.id]) {
|
||||||
fenceLayers[fence.id].setStyle({ color: updatedFence.is_active ? '#3388ff' : '#888888' });
|
fenceLayers[fence.id].setStyle({ color: updatedFence.is_active ? '#3388ff' : '#888888' });
|
||||||
}
|
}
|
||||||
|
toast.showToast($t('geofenceStatusUpdated'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle geofence status:', error);
|
console.error('Failed to toggle geofence status:', error);
|
||||||
|
toast.showToast($t('updateGeofenceStatusFailed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { apiFetch } from '@/api.js';
|
import { apiFetch } from '@/api.js';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t: $t } = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const viewDate = ref(new Date());
|
const viewDate = ref(new Date());
|
||||||
const todayStr = new Date().toISOString().slice(0, 10);
|
const todayStr = new Date().toISOString().slice(0, 10);
|
||||||
@@ -124,7 +129,6 @@ const getDayClasses = (day) => {
|
|||||||
classes.push('bg-white', 'dark:bg-gray-800', 'hover:bg-gray-100', 'dark:hover:bg-gray-700');
|
classes.push('bg-white', 'dark:bg-gray-800', 'hover:bg-gray-100', 'dark:hover:bg-gray-700');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a yellow ring for today's date
|
|
||||||
if (dateStr === todayStr) {
|
if (dateStr === todayStr) {
|
||||||
classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500');
|
classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500');
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,6 @@ const getDayClasses = (day) => {
|
|||||||
return classes;
|
return classes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function onDayClick(day) {
|
function onDayClick(day) {
|
||||||
const dateStr = day.id;
|
const dateStr = day.id;
|
||||||
const isOriginallyEnabled = originalEnabledDates.value.has(dateStr);
|
const isOriginallyEnabled = originalEnabledDates.value.has(dateStr);
|
||||||
@@ -149,7 +152,8 @@ function onDayClick(day) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyChanges() {
|
async function applyChanges() {
|
||||||
if (!confirm('Are you sure you want to apply these changes to the work schedule?')) return;
|
const confirmed = await toast.showConfirm($t('confirmApplyChanges'))
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiFetch('/api/managers/enabled-dates/update', {
|
await apiFetch('/api/managers/enabled-dates/update', {
|
||||||
@@ -161,10 +165,10 @@ async function applyChanges() {
|
|||||||
});
|
});
|
||||||
await fetchEnabledDates();
|
await fetchEnabledDates();
|
||||||
discardChanges();
|
discardChanges();
|
||||||
alert('Work schedule updated successfully!');
|
toast.showToast($t('scheduleUpdateSuccess'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to apply changes:', error);
|
console.error('Failed to apply changes:', error);
|
||||||
alert('Failed to update schedule. Please try again.');
|
toast.showToast($t('scheduleUpdateFailed'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +190,7 @@ async function fetchEnabledDates() {
|
|||||||
originalEnabledDates.value = new Set(dates);
|
originalEnabledDates.value = new Set(dates);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch enabled dates:', error);
|
console.error('Failed to fetch enabled dates:', error);
|
||||||
|
toast.showToast($t('fetchDatesFailed'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-8 pb-20">
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('managerRoster') }}</h2>
|
||||||
|
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-center justify-between">
|
||||||
|
<input type="text" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full sm:max-w-xs focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
<button @click="showAddManager = true" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md">
|
||||||
|
{{ $t('addManager') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showAddManager" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('addManager') }}</h3>
|
||||||
|
<button @click="closeAddManagerModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('fullName') }}</label>
|
||||||
|
<input type="text" v-model="newManager.fullName" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('username') }}</label>
|
||||||
|
<input type="text" v-model="newManager.username" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('password') }}</label>
|
||||||
|
<input type="password" v-model="newManager.password" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('department') }}</label>
|
||||||
|
<input type="text" v-model="newManager.department" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('position') }}</label>
|
||||||
|
<input type="text" v-model="newManager.position" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="addManager" :disabled="!isManagerFormValid || addingManager" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:opacity-50">
|
||||||
|
{{ addingManager ? $t('adding') : $t('addManager') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full w-full text-left">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('fullName') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('username') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('department') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('status') }}</th>
|
||||||
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">{{ $t('actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr v-for="manager in managers" :key="manager.id" class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||||
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ manager.full_name }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ manager.username }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ manager.department }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span :class="{
|
||||||
|
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': manager.status === 'active',
|
||||||
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': manager.status === 'inactive',
|
||||||
|
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': manager.status === 'deleted'
|
||||||
|
}" class="px-2.5 py-0.5 rounded-full text-xs font-medium capitalize">
|
||||||
|
{{ manager.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 flex justify-end">
|
||||||
|
<button @click="openSettingsModal(manager)" class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 flex items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
{{ $t('settings') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="managers.length === 0">
|
||||||
|
<td colspan="5" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
{{ loading ? $t('loadingManagers') : $t('noManagersFound') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('previous') }}</button>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="number" v-model.number="jumpToPageInput" @keyup.enter="jumpToPage" class="w-20 text-center border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
<span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span>
|
||||||
|
</div>
|
||||||
|
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('next') }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="isSettingsModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('managerSettings') }}</h3>
|
||||||
|
<button @click="closeSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('accountSettings') }}</h4>
|
||||||
|
<div class="mb-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('department') }}</label>
|
||||||
|
<input type="text" v-model="editingManager.department" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('position') }}</label>
|
||||||
|
<input type="text" v-model="editingManager.position" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('changePassword') }}</label>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input type="password" v-model="newPassword" :placeholder="$t('newPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
<input type="password" v-model="confirmNewPassword" :placeholder="$t('confirmNewPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('managerStatus') }}</h4>
|
||||||
|
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('activeAccount') }}</p>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="editingManager.isActive" class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 border border-red-100 dark:border-red-900/30">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('delete') }}</h5>
|
||||||
|
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('deleteDescription') }}</p>
|
||||||
|
</div>
|
||||||
|
<button @click="showDeleteConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
|
||||||
|
{{ $t('delete') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showDeleteConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmDelete') }}</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showDeleteConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
{{ $t('cancel') }}
|
||||||
|
</button>
|
||||||
|
<button @click="deleteManager(editingManager.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
|
||||||
|
{{ $t('confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('managerPermissions') }}</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-for="permission in permissionsList" :key="permission.key" class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ $t(permission.key) }}</span>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="editingManager[permission.key]" class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button @click="closeSettingsModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-medium px-4 py-2 rounded-md transition-colors">
|
||||||
|
{{ $t('cancel') }}
|
||||||
|
</button>
|
||||||
|
<button @click="saveManagerSettings" class="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors" :disabled="saving">
|
||||||
|
{{ saving ? $t('saving') : $t('saveChanges') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, watch, nextTick } from 'vue';
|
||||||
|
import { apiFetch } from '@/api.js';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const managers = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const totalManagers = ref(0);
|
||||||
|
const jumpToPageInput = ref(1);
|
||||||
|
const isSettingsModalVisible = ref(false);
|
||||||
|
const editingManager = ref(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const newPassword = ref('');
|
||||||
|
const confirmNewPassword = ref('');
|
||||||
|
const passwordErrorMessage = ref('');
|
||||||
|
const passwordSuccessMessage = ref('');
|
||||||
|
const showDeleteConfirm = ref(false);
|
||||||
|
const showAddManager = ref(false);
|
||||||
|
const addingManager = ref(false);
|
||||||
|
const newManager = ref({
|
||||||
|
fullName: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
department: '',
|
||||||
|
position: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const { t: $t } = useI18n();
|
||||||
|
|
||||||
|
const isManagerFormValid = computed(() =>
|
||||||
|
newManager.value.fullName &&
|
||||||
|
newManager.value.username &&
|
||||||
|
newManager.value.password
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissionsList = ref([
|
||||||
|
{ key: 'view_all', label: 'View All (Workers, Reports, Alerts, Geofences, QR Codes, Kill Switch)' },
|
||||||
|
{ key: 'edit_workers', label: 'Edit Worker Accounts' },
|
||||||
|
{ key: 'manage_resources', label: 'Manage Resources (Geofences & QR Codes)' },
|
||||||
|
// { key: 'manager_permissions', label: 'Manage Managers (Add/Edit/Delete & Permissions)' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const initialLoad = ref(true);
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
const pages = Math.ceil(totalManagers.value / pageSize.value);
|
||||||
|
return pages < 1 ? 1 : pages;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(searchQuery, () => fetchManagers(1));
|
||||||
|
|
||||||
|
const fetchManagers = async (page = currentPage.value) => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await apiFetch(`/api/managers/managers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`);
|
||||||
|
managers.value = data.managers.map(manager => {
|
||||||
|
// Map old permissions to new structure if needed
|
||||||
|
const managerPermissions = {
|
||||||
|
view_all: Boolean(manager.view_all ||
|
||||||
|
(manager.can_view_workers && manager.can_view_reports && manager.can_view_alerts &&
|
||||||
|
manager.can_view_geofences && manager.can_view_qrcodes && manager.can_manage_killswitch)),
|
||||||
|
edit_workers: Boolean(manager.edit_workers || manager.can_edit_workers),
|
||||||
|
manage_resources: Boolean(manager.manage_resources ||
|
||||||
|
(manager.can_manage_geofences || manager.can_manage_qrcodes)),
|
||||||
|
manager_permissions: Boolean(manager.manager_permissions ||
|
||||||
|
(manager.can_manage_permissions || manager.can_edit_managers || manager.can_delete_managers))
|
||||||
|
};
|
||||||
|
return { ...manager, ...managerPermissions, isActive: manager.status === 'active' };
|
||||||
|
});
|
||||||
|
totalManagers.value = data.totalCount;
|
||||||
|
currentPage.value = page;
|
||||||
|
} catch (_err) {
|
||||||
|
errorMessage.value = 'Failed to fetch managers.';
|
||||||
|
managers.value = [];
|
||||||
|
totalManagers.value = 0;
|
||||||
|
toast.showToast($t('fetchManagersFailed'), 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePage = (page) => {
|
||||||
|
if (page > 0 && page <= totalPages.value) {
|
||||||
|
fetchManagers(page);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jumpToPage = () => {
|
||||||
|
const page = Number(jumpToPageInput.value);
|
||||||
|
if (!isNaN(page) && page >= 1 && page <= totalPages.value) {
|
||||||
|
changePage(page);
|
||||||
|
} else {
|
||||||
|
jumpToPageInput.value = currentPage.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettingsModal = (manager) => {
|
||||||
|
initialLoad.value = true;
|
||||||
|
editingManager.value = { ...manager };
|
||||||
|
isSettingsModalVisible.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
initialLoad.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSettingsModal = () => {
|
||||||
|
isSettingsModalVisible.value = false;
|
||||||
|
editingManager.value = null;
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmNewPassword.value = '';
|
||||||
|
passwordErrorMessage.value = '';
|
||||||
|
passwordSuccessMessage.value = '';
|
||||||
|
showDeleteConfirm.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveManagerSettings = async () => {
|
||||||
|
if (!editingManager.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
passwordErrorMessage.value = '';
|
||||||
|
passwordSuccessMessage.value = '';
|
||||||
|
console.log('Starting saveManagerSettings...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save all changes in parallel for better performance
|
||||||
|
const results = await Promise.all([
|
||||||
|
// Save permissions
|
||||||
|
apiFetch(`/api/managers/permissions/${editingManager.value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
view_all: editingManager.value.view_all || false,
|
||||||
|
edit_workers: editingManager.value.edit_workers || false,
|
||||||
|
manage_resources: editingManager.value.manage_resources || false,
|
||||||
|
manager_permissions: editingManager.value.manager_permissions || false
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Save manager details
|
||||||
|
apiFetch(`/api/managers/managers/${editingManager.value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
department: editingManager.value.department,
|
||||||
|
position: editingManager.value.position,
|
||||||
|
status: editingManager.value.isActive ? 'active' : 'inactive',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// Save password if changed
|
||||||
|
newPassword.value && newPassword.value === confirmNewPassword.value
|
||||||
|
? apiFetch(`/api/managers/managers/${editingManager.value.id}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ newPassword: newPassword.value }),
|
||||||
|
})
|
||||||
|
: Promise.resolve()
|
||||||
|
]);
|
||||||
|
const [permissionsResponse] = results;
|
||||||
|
|
||||||
|
console.log('API responses:', results);
|
||||||
|
// Check for explicit error responses
|
||||||
|
if (permissionsResponse && permissionsResponse.error) {
|
||||||
|
console.log('Permissions update failed:', permissionsResponse.error);
|
||||||
|
throw new Error(permissionsResponse.error || 'Failed to update permissions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.value && newPassword.value !== confirmNewPassword.value) {
|
||||||
|
console.log('Password mismatch detected');
|
||||||
|
throw new Error('Passwords do not match.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
await fetchManagers(currentPage.value);
|
||||||
|
toast.showToast($t('managerSettingsSaved'), 'success');
|
||||||
|
|
||||||
|
// Wait for toast to appear before closing modal
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Reset form and close modal - ensure all state is cleared
|
||||||
|
editingManager.value = null;
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmNewPassword.value = '';
|
||||||
|
passwordErrorMessage.value = '';
|
||||||
|
passwordSuccessMessage.value = '';
|
||||||
|
showDeleteConfirm.value = false;
|
||||||
|
isSettingsModalVisible.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in saveManagerSettings:', err);
|
||||||
|
passwordErrorMessage.value = err.message || 'Failed to save settings.';
|
||||||
|
toast.showToast(err.message || 'Failed to save settings.', 'error');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAddManagerModal = () => {
|
||||||
|
showAddManager.value = false;
|
||||||
|
newManager.value = {
|
||||||
|
fullName: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
department: '',
|
||||||
|
position: ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addManager = async () => {
|
||||||
|
if (!isManagerFormValid.value) return;
|
||||||
|
addingManager.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First create the manager
|
||||||
|
const response = await apiFetch('/api/managers/managers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...newManager.value,
|
||||||
|
role: 'manager'
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response || !response.id) {
|
||||||
|
throw new Error('Failed to create manager - invalid response from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then set default permissions (view_all = true)
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/managers/permissions/${response.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
view_all: true,
|
||||||
|
edit_workers: false,
|
||||||
|
manage_resources: false,
|
||||||
|
manager_permissions: false
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (permError) {
|
||||||
|
// If permissions fail, delete the manager to maintain consistency
|
||||||
|
await apiFetch(`/api/managers/managers/${response.id}`, { method: 'DELETE' });
|
||||||
|
throw new Error('Failed to set default permissions: ' + (permError.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchManagers(1); // Auto-refetch the manager list after adding
|
||||||
|
closeAddManagerModal(); // Auto-close the modal after successful addition
|
||||||
|
toast.showToast($t('managerAddedSuccess'), 'success');
|
||||||
|
} catch (_err) {
|
||||||
|
toast.showToast($t('addManagerError'), 'error');
|
||||||
|
} finally {
|
||||||
|
addingManager.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteManager = async (id) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/managers/managers/${id}`, { method: 'DELETE' });
|
||||||
|
toast.showToast($t('managerDeletedSuccess'), 'success');
|
||||||
|
// Adjust page number if the last manager on a page was deleted
|
||||||
|
fetchManagers(managers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
|
||||||
|
closeSettingsModal();
|
||||||
|
} catch (_err) {
|
||||||
|
toast.showToast($t('deleteManagerFailed'), 'error');
|
||||||
|
errorMessage.value = 'Failed to Delete manager.'; // This could also be removed if toast is sufficient
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchManagers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-8 pb-20">
|
<div class="flex flex-col gap-8 pb-20">
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<section v-if="permissions.edit_workers" class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2>
|
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -23,11 +23,8 @@
|
|||||||
<label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position') }}</label>
|
<label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position') }}</label>
|
||||||
<input type="text" id="position" v-model="newWorker.position" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egManager')" />
|
<input type="text" id="position" v-model="newWorker.position" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egManager')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-end">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="flex items-center text-sm mb-2 cursor-pointer">
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 invisible">{{ $t('addUser') }}</label>
|
||||||
<input type="checkbox" v-model="isManager" class="form-checkbox h-4 w-4 text-blue-600 rounded mr-2 focus:ring-blue-500" />
|
|
||||||
<span class="text-gray-700 dark:text-gray-300">{{ $t('asManager') }}</span>
|
|
||||||
</label>
|
|
||||||
<button @click="addWorker" :disabled="!isFormValid || loading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
<button @click="addWorker" :disabled="!isFormValid || loading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
{{ loading ? $t('adding') : $t('addUser') }}
|
{{ loading ? $t('adding') : $t('addUser') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -38,9 +35,12 @@
|
|||||||
|
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2>
|
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2>
|
||||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-center justify-between">
|
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-end justify-between">
|
||||||
<input type="text" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full sm:max-w-xs focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
<div class="flex-grow">
|
||||||
<div class="flex items-center gap-4">
|
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate') }}</label>
|
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate') }}</label>
|
||||||
<input type="date" id="export-start-date" v-model="exportFilters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
<input type="date" id="export-start-date" v-model="exportFilters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label>
|
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label>
|
||||||
<input type="date" id="export-end-date" v-model="exportFilters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
<input type="date" id="export-end-date" v-model="exportFilters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
</div>
|
</div>
|
||||||
<button @click="exportWorkHours" :disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading" class="self-end bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
<button @click="exportWorkHours" :disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading" class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
||||||
{{ exportLoading ? $t('exporting') : $t('exportAll') }}
|
{{ exportLoading ? $t('exporting') : $t('exportAll') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('username') }}</th>
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('username') }}</th>
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('department') }}</th>
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('department') }}</th>
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('position') }}</th>
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('position') }}</th>
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('dateJoined') }}</th>
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('status') }}</th> <th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('dateJoined') }}</th>
|
||||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">{{ $t('actions') }}</th>
|
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">{{ $t('actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -78,17 +78,29 @@
|
|||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.department }}</td>
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.department }}</td>
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.position }}</td>
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.position }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span :class="{
|
||||||
|
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': worker.status === 'active',
|
||||||
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': worker.status === 'inactive',
|
||||||
|
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': worker.status === 'deleted'
|
||||||
|
}" class="px-2.5 py-0.5 rounded-full text-xs font-medium capitalize">
|
||||||
|
{{ worker.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ new Date(worker.created_at).toLocaleDateString() }}</td>
|
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ new Date(worker.created_at).toLocaleDateString() }}</td>
|
||||||
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
|
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
|
||||||
<button @click="openPasswordModal(worker)" class="bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('password') }}</button>
|
|
||||||
<button @click="viewRecords(worker.id)" class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('viewRecords') }}</button>
|
<button @click="viewRecords(worker.id)" class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('viewRecords') }}</button>
|
||||||
<button @click="clearDevice(worker.id)" class="bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('clearDevice') }}</button>
|
<button @click="openSettingsModal(worker)" class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 flex items-center gap-1">
|
||||||
<button @click="deleteWorker(worker.id)" class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('delete') }}</button>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
{{ $t('settings') }}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="workers.length === 0">
|
<tr v-if="workers.length === 0">
|
||||||
<td colspan="7" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400"> {{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
|
||||||
{{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -96,37 +108,131 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="totalPages > 1" class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
<div v-if="totalPages > 1" class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('previous') }}</button>
|
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('previous') }}</button>
|
||||||
<span class="text-gray-700 dark:text-gray-200">{{ $t('pageOf', { current: currentPage, total: totalPages }) }}</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="number" v-model.number="jumpToPageInput" @keyup.enter="jumpToPage" class="w-20 text-center border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
<span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span>
|
||||||
|
</div>
|
||||||
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('next') }}</button>
|
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('next') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="isPasswordModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
<div v-if="isSettingsModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||||
<h3 class="text-2xl font-bold mb-2 text-gray-800 dark:text-white">{{ $t('changePassword') }}</h3>
|
<div class="flex justify-between items-center mb-6">
|
||||||
<p v-if="editingWorkerPassword" class="mb-6 text-gray-600 dark:text-gray-300">
|
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('employeeSettings') }}</h3>
|
||||||
{{ $t('forUser') }}: <span class="font-semibold">{{ editingWorkerPassword.full_name }}</span>
|
<button @click="closeSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||||
</p>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<form @submit.prevent="updateWorkerPassword">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
<div class="flex flex-col gap-4">
|
</svg>
|
||||||
<div>
|
</button>
|
||||||
<label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('newPassword') }}</label>
|
</div>
|
||||||
<input type="password" id="newPassword" v-model="newPassword" required class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
|
||||||
</div>
|
<div class="space-y-6">
|
||||||
<div>
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
<label for="confirmNewPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('confirmNewPassword') }}</label>
|
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('accountSettings') }}</h4>
|
||||||
<input type="password" id="confirmNewPassword" v-model="confirmNewPassword" required class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
|
||||||
</div>
|
<div class="mb-4 space-y-4">
|
||||||
<p v-if="passwordErrorMessage" class="text-red-500 text-sm -mt-2">{{ passwordErrorMessage }}</p>
|
<div>
|
||||||
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm -mt-2">{{ passwordSuccessMessage }}</p>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('department') }}</label>
|
||||||
</div>
|
<input type="text" v-model="editingWorker.department" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
<div class="flex justify-end gap-4 mt-8">
|
</div>
|
||||||
<button type="button" @click="closePasswordModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors">{{ $t('cancel') }}</button>
|
<div>
|
||||||
<button type="submit" :disabled="passwordLoading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors disabled:opacity-50">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('position') }}</label>
|
||||||
{{ passwordLoading ? $t('saving') : $t('savePassword') }}
|
<input type="text" v-model="editingWorker.position" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('changePassword') }}</label>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input type="password" v-model="newPassword" :placeholder="$t('newPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
<input type="password" v-model="confirmNewPassword" :placeholder="$t('confirmNewPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('workerStatus') }}</h4>
|
||||||
|
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('activeAccount') }}</p>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="editingWorker.isActive" class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 border border-red-100 dark:border-red-900/30">
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('clearDevice') }}</h5>
|
||||||
|
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('clearDeviceDescription') }}</p>
|
||||||
|
</div>
|
||||||
|
<button @click="showClearDeviceConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
|
||||||
|
{{ $t('clearDevice') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showClearDeviceConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmClearDevice') }}</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showClearDeviceConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
{{ $t('cancel') }}
|
||||||
|
</button>
|
||||||
|
<button @click="clearDevice(editingWorker.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
|
||||||
|
{{ $t('confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('delete') }}</h5>
|
||||||
|
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('deleteDescription') }}</p>
|
||||||
|
</div>
|
||||||
|
<button @click="showDeleteConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
|
||||||
|
{{ $t('delete') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showDeleteConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmDelete') }}</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showDeleteConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
{{ $t('cancel') }}
|
||||||
|
</button>
|
||||||
|
<button @click="deleteWorker(editingWorker.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
|
||||||
|
{{ $t('confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="passwordErrorMessage || passwordSuccessMessage" class="text-center">
|
||||||
|
<p v-if="passwordErrorMessage" class="text-red-500 text-sm">{{ passwordErrorMessage }}</p>
|
||||||
|
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm">{{ passwordSuccessMessage }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="saveWorkerSettings" :disabled="passwordLoading" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:opacity-50">
|
||||||
|
{{ passwordLoading ? $t('saving') : $t('saveChanges') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isConfirmModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-sm">
|
||||||
|
<h3 class="text-xl font-bold mb-4 text-gray-800 dark:text-white">{{ confirmMessage }}</h3>
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button @click="closeConfirmModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-medium px-4 py-2 rounded-md transition-colors">
|
||||||
|
{{ $t('cancel') }}
|
||||||
|
</button>
|
||||||
|
<button @click="executeConfirmedAction" class="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-md transition-colors">
|
||||||
|
{{ $t('confirm') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,11 +241,29 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, watch } from 'vue';
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
import { apiFetch } from '@/api.js';
|
import { apiFetch } from '@/api.js';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { permissions } from '@/stores/permissions.js';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { workerCache } from '@/utils/workerCache.js';
|
||||||
|
|
||||||
|
const { t: $t } = useI18n();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const viewRecords = (workerId) => {
|
const viewRecords = (workerId) => {
|
||||||
|
// Save current search state before navigating away
|
||||||
|
const searchState = {
|
||||||
|
searchQuery: searchQuery.value,
|
||||||
|
currentPage: currentPage.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
totalWorkers: totalWorkers.value,
|
||||||
|
workers: workers.value,
|
||||||
|
selectedWorkerIds: selectedWorkerIds.value,
|
||||||
|
exportFilters: exportFilters.value
|
||||||
|
};
|
||||||
|
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
|
||||||
|
|
||||||
router.push(`/manager/attendance/${workerId}`);
|
router.push(`/manager/attendance/${workerId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,30 +272,40 @@ const workers = ref([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const newWorker = ref({ fullName: '', username: '', password: '', department: '', position: '' });
|
const newWorker = ref({ fullName: '', username: '', password: '', department: '', position: '' });
|
||||||
const isManager = ref(false);
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(20);
|
||||||
const totalWorkers = ref(0);
|
const totalWorkers = ref(0);
|
||||||
|
const jumpToPageInput = ref(1);
|
||||||
const selectedWorkerIds = ref([]);
|
const selectedWorkerIds = ref([]);
|
||||||
const isPasswordModalVisible = ref(false);
|
const isSettingsModalVisible = ref(false);
|
||||||
const editingWorkerPassword = ref(null);
|
const editingWorker = ref(null);
|
||||||
const newPassword = ref('');
|
const newPassword = ref('');
|
||||||
const confirmNewPassword = ref('');
|
const confirmNewPassword = ref('');
|
||||||
const passwordErrorMessage = ref('');
|
const passwordErrorMessage = ref('');
|
||||||
const passwordSuccessMessage = ref('');
|
const passwordSuccessMessage = ref('');
|
||||||
const passwordLoading = ref(false);
|
const passwordLoading = ref(false);
|
||||||
|
const confirmAction = ref('');
|
||||||
|
const confirmMessage = ref('');
|
||||||
|
const isConfirmModalVisible = ref(false);
|
||||||
const exportFilters = ref({ startDate: '', endDate: '' });
|
const exportFilters = ref({ startDate: '', endDate: '' });
|
||||||
const exportLoading = ref(false);
|
const exportLoading = ref(false);
|
||||||
|
// Removed workerStatusLoading as it's no longer needed with integrated save
|
||||||
|
|
||||||
// --- COMPUTED ---
|
// --- COMPUTED ---
|
||||||
const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password);
|
const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password);
|
||||||
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value));
|
const totalPages = computed(() => {
|
||||||
|
const pages = Math.ceil(totalWorkers.value / pageSize.value);
|
||||||
|
return pages < 1 ? 1 : pages; // Ensure at least 1 page
|
||||||
|
});
|
||||||
const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length);
|
const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length);
|
||||||
|
|
||||||
// --- WATCHERS ---
|
// --- WATCHERS ---
|
||||||
watch(searchQuery, () => fetchWorkers(1));
|
watch(searchQuery, () => fetchWorkers(1));
|
||||||
watch(currentPage, () => selectedWorkerIds.value = []);
|
watch(currentPage, (newPage) => {
|
||||||
|
selectedWorkerIds.value = [];
|
||||||
|
jumpToPageInput.value = newPage;
|
||||||
|
});
|
||||||
|
|
||||||
// --- METHODS ---
|
// --- METHODS ---
|
||||||
const fetchWorkers = async (page = currentPage.value) => {
|
const fetchWorkers = async (page = currentPage.value) => {
|
||||||
@@ -180,74 +314,95 @@ const fetchWorkers = async (page = currentPage.value) => {
|
|||||||
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`);
|
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`);
|
||||||
workers.value = data.workers;
|
workers.value = data.workers;
|
||||||
totalWorkers.value = data.totalCount;
|
totalWorkers.value = data.totalCount;
|
||||||
currentPage.value = page;
|
|
||||||
|
// Cache worker data
|
||||||
|
if (data.workers && Array.isArray(data.workers)) {
|
||||||
|
data.workers.forEach(worker => {
|
||||||
|
workerCache.storeWorkerData(worker.id, worker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentPage is already set to the requested page before fetch
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
errorMessage.value = 'Failed to fetch workers.';
|
errorMessage.value = 'Failed to fetch workers.';
|
||||||
|
workers.value = [];
|
||||||
|
totalWorkers.value = 0;
|
||||||
|
currentPage.value = 1;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const changePage = (page) => {
|
const changePage = (page) => {
|
||||||
if (page > 0 && page <= totalPages.value) fetchWorkers(page);
|
if (page > 0 && page <= totalPages.value) {
|
||||||
|
currentPage.value = page;
|
||||||
|
fetchWorkers(page);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jumpToPage = () => {
|
||||||
|
const page = Number(jumpToPageInput.value);
|
||||||
|
if (!isNaN(page) && page >= 1 && page <= totalPages.value) {
|
||||||
|
changePage(page);
|
||||||
|
} else {
|
||||||
|
jumpToPageInput.value = currentPage.value;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addWorker = async () => {
|
const addWorker = async () => {
|
||||||
|
const toast = useToast();
|
||||||
if (!isFormValid.value) return;
|
if (!isFormValid.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
try {
|
try {
|
||||||
await apiFetch('/api/managers/workers', {
|
await apiFetch('/api/managers/workers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ ...newWorker.value, role: isManager.value ? 'manager' : 'worker' }),
|
body: JSON.stringify({ ...newWorker.value, role: 'worker' }),
|
||||||
});
|
});
|
||||||
await fetchWorkers(1);
|
await fetchWorkers(1);
|
||||||
newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' };
|
newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' };
|
||||||
isManager.value = false;
|
toast.showToast($t('workerAdded'), 'success');
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
errorMessage.value = _err.message || 'Error adding user.';
|
toast.showToast(_err.message || $t('addUserError'), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteWorker = async (id) => {
|
const deleteWorker = async (id) => {
|
||||||
if (!confirm('Are you sure you want to delete this worker?')) return;
|
const toast = useToast();
|
||||||
|
const confirmed = await toast.showConfirm($t('confirmDeleteWorker'));
|
||||||
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
|
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
|
||||||
|
toast.showToast($t('workerSoftDeleted'), 'success');
|
||||||
fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
|
fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
errorMessage.value = 'Failed to delete worker.';
|
errorMessage.value = 'Failed to soft-delete worker.';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearDevice = async (workerId) => {
|
const clearDevice = async (workerId) => {
|
||||||
if (!confirm('Are you sure you want to clear the registered device for this worker?')) return;
|
const toast = useToast();
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/managers/workers/${workerId}/reset-device`, { method: 'PUT' });
|
await apiFetch(`/api/managers/workers/${workerId}/reset-device`, { method: 'PUT' });
|
||||||
alert('Worker device cleared successfully.');
|
toast.showToast($t('deviceCleared'), 'success');
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
alert(_err.message || 'Failed to clear device.');
|
toast.showToast(_err.message || $t('clearDeviceFailed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPasswordModal = (worker) => {
|
// Renamed and refactored updateWorkerPassword to saveWorkerSettings
|
||||||
editingWorkerPassword.value = worker;
|
const saveWorkerSettings = async () => {
|
||||||
isPasswordModalVisible.value = true;
|
const toast = useToast();
|
||||||
};
|
|
||||||
|
|
||||||
const closePasswordModal = () => {
|
|
||||||
isPasswordModalVisible.value = false;
|
|
||||||
editingWorkerPassword.value = null;
|
|
||||||
newPassword.value = '';
|
|
||||||
confirmNewPassword.value = '';
|
|
||||||
passwordErrorMessage.value = '';
|
passwordErrorMessage.value = '';
|
||||||
passwordSuccessMessage.value = '';
|
passwordSuccessMessage.value = '';
|
||||||
passwordLoading.value = false;
|
let passwordUpdated = false;
|
||||||
};
|
let detailsUpdated = false;
|
||||||
|
toast.showToast($t('savingSettings'), 'info');
|
||||||
|
|
||||||
const updateWorkerPassword = async () => {
|
// Handle password change
|
||||||
passwordErrorMessage.value = '';
|
if (newPassword.value || confirmNewPassword.value) {
|
||||||
if (newPassword.value !== confirmNewPassword.value) {
|
if (newPassword.value !== confirmNewPassword.value) {
|
||||||
passwordErrorMessage.value = 'Passwords do not match.';
|
passwordErrorMessage.value = 'Passwords do not match.';
|
||||||
return;
|
return;
|
||||||
@@ -256,21 +411,96 @@ const updateWorkerPassword = async () => {
|
|||||||
passwordErrorMessage.value = 'Password must be at least 6 characters long.';
|
passwordErrorMessage.value = 'Password must be at least 6 characters long.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
passwordUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle details change (status, department, position)
|
||||||
|
const originalWorker = workers.value.find(w => w.id === editingWorker.value.id);
|
||||||
|
const newStatus = editingWorker.value.isActive ? 'active' : 'inactive';
|
||||||
|
if (
|
||||||
|
originalWorker.status !== newStatus ||
|
||||||
|
originalWorker.department !== editingWorker.value.department ||
|
||||||
|
originalWorker.position !== editingWorker.value.position
|
||||||
|
) {
|
||||||
|
detailsUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordUpdated && !detailsUpdated) {
|
||||||
|
passwordErrorMessage.value = 'No changes to save.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
passwordLoading.value = true;
|
passwordLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/managers/workers/${editingWorkerPassword.value.id}/password`, {
|
if (passwordUpdated) {
|
||||||
|
await apiFetch(`/api/managers/workers/${editingWorker.value.id}/password`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ newPassword: newPassword.value }),
|
body: JSON.stringify({ newPassword: newPassword.value }),
|
||||||
});
|
});
|
||||||
passwordSuccessMessage.value = 'Password updated successfully!';
|
passwordSuccessMessage.value = 'Password updated successfully!';
|
||||||
setTimeout(closePasswordModal, 2000);
|
}
|
||||||
|
|
||||||
|
if (detailsUpdated) {
|
||||||
|
await apiFetch(`/api/managers/workers/${editingWorker.value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: newStatus,
|
||||||
|
department: editingWorker.value.department,
|
||||||
|
position: editingWorker.value.position,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (passwordUpdated) {
|
||||||
|
passwordSuccessMessage.value += ' Worker details also updated.';
|
||||||
|
} else {
|
||||||
|
passwordSuccessMessage.value = 'Worker details updated successfully!';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchWorkers(currentPage.value);
|
||||||
|
setTimeout(() => {
|
||||||
|
closeSettingsModal();
|
||||||
|
}, 2000);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
passwordErrorMessage.value = _err.message || 'Failed to update password.';
|
passwordErrorMessage.value = _err.message || 'Failed to save settings.';
|
||||||
} finally {
|
} finally {
|
||||||
passwordLoading.value = false;
|
passwordLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSettingsModal = (worker) => {
|
||||||
|
editingWorker.value = { ...worker, isActive: worker.status === 'active' }; // Initialize isActive for checkbox
|
||||||
|
isSettingsModalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSettingsModal = () => {
|
||||||
|
isSettingsModalVisible.value = false;
|
||||||
|
editingWorker.value = null;
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmNewPassword.value = '';
|
||||||
|
passwordErrorMessage.value = '';
|
||||||
|
passwordSuccessMessage.value = '';
|
||||||
|
showClearDeviceConfirm.value = false;
|
||||||
|
showDeleteConfirm.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showClearDeviceConfirm = ref(false);
|
||||||
|
const showDeleteConfirm = ref(false);
|
||||||
|
|
||||||
|
const closeConfirmModal = () => {
|
||||||
|
isConfirmModalVisible.value = false;
|
||||||
|
confirmAction.value = '';
|
||||||
|
confirmMessage.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeConfirmedAction = () => {
|
||||||
|
if (confirmAction.value === 'clearDevice') {
|
||||||
|
clearDevice(editingWorker.value.id);
|
||||||
|
} else if (confirmAction.value === 'delete') {
|
||||||
|
deleteWorker(editingWorker.value.id);
|
||||||
|
}
|
||||||
|
closeConfirmModal();
|
||||||
|
};
|
||||||
|
|
||||||
const isWorkerSelected = (workerId) => selectedWorkerIds.value.includes(workerId);
|
const isWorkerSelected = (workerId) => selectedWorkerIds.value.includes(workerId);
|
||||||
|
|
||||||
const toggleWorkerSelection = (workerId) => {
|
const toggleWorkerSelection = (workerId) => {
|
||||||
@@ -284,7 +514,9 @@ const toggleSelectAll = (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exportWorkHours = async () => {
|
const exportWorkHours = async () => {
|
||||||
|
const toast = useToast();
|
||||||
exportLoading.value = true;
|
exportLoading.value = true;
|
||||||
|
toast.showToast($t('exportingRecords'), 'info');
|
||||||
const { startDate, endDate } = exportFilters.value;
|
const { startDate, endDate } = exportFilters.value;
|
||||||
let workerIds = selectedWorkerIds.value.join(',');
|
let workerIds = selectedWorkerIds.value.join(',');
|
||||||
|
|
||||||
@@ -305,13 +537,34 @@ const exportWorkHours = async () => {
|
|||||||
a.remove();
|
a.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
alert('Failed to export records.');
|
toast.showToast($t('exportRecordsFailed'), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
exportLoading.value = false;
|
exportLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Check if there's saved search state
|
||||||
|
const savedSearchState = sessionStorage.getItem('personnelSearchState');
|
||||||
|
if (savedSearchState) {
|
||||||
|
try {
|
||||||
|
const searchState = JSON.parse(savedSearchState);
|
||||||
|
searchQuery.value = searchState.searchQuery || '';
|
||||||
|
currentPage.value = searchState.currentPage || 1;
|
||||||
|
pageSize.value = searchState.pageSize || 20;
|
||||||
|
totalWorkers.value = searchState.totalWorkers || 0;
|
||||||
|
workers.value = searchState.workers || [];
|
||||||
|
selectedWorkerIds.value = searchState.selectedWorkerIds || [];
|
||||||
|
exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' };
|
||||||
|
|
||||||
|
// Clear the saved search state after restoring it
|
||||||
|
sessionStorage.removeItem('personnelSearchState');
|
||||||
|
} catch (_e) {
|
||||||
|
// If there's an error parsing the saved state, fetch workers normally
|
||||||
fetchWorkers();
|
fetchWorkers();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fetchWorkers();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -94,18 +94,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { apiFetch } from '@/api.js'
|
import { apiFetch } from '@/api.js'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t: $t } = useI18n()
|
||||||
const qrCodes = ref([])
|
const qrCodes = ref([])
|
||||||
const newQrName = ref('')
|
const newQrName = ref('')
|
||||||
const newlyGeneratedQr = ref(null)
|
const newlyGeneratedQr = ref(null)
|
||||||
const newQrCanvas = ref(null)
|
const newQrCanvas = ref(null)
|
||||||
|
|
||||||
|
import { permissions } from '@/stores/permissions.js'
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const userRole = sessionStorage.getItem('userRole')
|
const userRole = sessionStorage.getItem('userRole')
|
||||||
if (userRole !== 'manager') {
|
if (userRole !== 'manager' || !permissions.value.view_all) {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -114,18 +120,17 @@ onMounted(() => {
|
|||||||
|
|
||||||
const fetchQrCodes = async () => {
|
const fetchQrCodes = async () => {
|
||||||
try {
|
try {
|
||||||
// CORRECT: Get the data directly from apiFetch
|
|
||||||
const data = await apiFetch('/api/managers/qr-codes')
|
const data = await apiFetch('/api/managers/qr-codes')
|
||||||
qrCodes.value = data
|
qrCodes.value = data
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error('Failed to fetch QR codes:',_err)
|
console.error('Failed to fetch QR codes:',_err)
|
||||||
|
toast.showToast('Failed to fetch QR codes', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addQrCode = async () => {
|
const addQrCode = async () => {
|
||||||
if (!newQrName.value) return
|
if (!newQrName.value) return
|
||||||
try {
|
try {
|
||||||
// CORRECT: Get the new QR object directly
|
|
||||||
const newQr = await apiFetch('/api/managers/qr-codes', {
|
const newQr = await apiFetch('/api/managers/qr-codes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name: newQrName.value }),
|
body: JSON.stringify({ name: newQrName.value }),
|
||||||
@@ -141,45 +146,48 @@ const addQrCode = async () => {
|
|||||||
newQr.id,
|
newQr.id,
|
||||||
{ width: 220, margin: 2, color: { dark: '#050505', light: '#FFFFFF' } },
|
{ width: 220, margin: 2, color: { dark: '#050505', light: '#FFFFFF' } },
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error) console.error(error)
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.showToast('Failed to generate QR code image', 'error')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error('Failed to add QR code:',_err)
|
console.error('Failed to add QR code:',_err)
|
||||||
|
toast.showToast('Failed to create QR code', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleQrStatus = async (qr) => {
|
const toggleQrStatus = async (qr) => {
|
||||||
try {
|
try {
|
||||||
// CORRECT: No need to check response, catch block will handle errors
|
|
||||||
await apiFetch(`/api/managers/qr-codes/${qr.id}`, {
|
await apiFetch(`/api/managers/qr-codes/${qr.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ isActive: !qr.is_active }),
|
body: JSON.stringify({ isActive: !qr.is_active }),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update status locally on success
|
|
||||||
const index = qrCodes.value.findIndex((q) => q.id === qr.id)
|
const index = qrCodes.value.findIndex((q) => q.id === qr.id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
qrCodes.value[index].is_active = !qrCodes.value[index].is_active
|
qrCodes.value[index].is_active = !qrCodes.value[index].is_active
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error('Failed to update QR status:',_err)
|
console.error('Failed to update QR status:',_err)
|
||||||
|
toast.showToast('Failed to update QR code status', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteQrCode = async (id) => {
|
const deleteQrCode = async (id) => {
|
||||||
if (!confirm($t('deleteQrConfirm'))) {
|
const confirmed = await toast.showConfirm($t('deleteQrConfirm'))
|
||||||
return
|
if (!confirmed) return
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
// CORRECT: No need to check response, just await the call
|
|
||||||
await apiFetch(`/api/managers/qr-codes/${id}`, {
|
await apiFetch(`/api/managers/qr-codes/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
// Filter out the deleted QR code on success
|
|
||||||
qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id)
|
qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id)
|
||||||
|
toast.showToast('QR code deleted successfully', 'success')
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error('Failed to delete QR code:',_err)
|
console.error('Failed to delete QR code:',_err)
|
||||||
|
toast.showToast('Failed to delete QR code', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,8 +206,9 @@ const downloadQrCode = async (qr) => {
|
|||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
} catch {
|
} catch (_err) {
|
||||||
alert($t('qrDownloadError'))
|
console.error('Failed to download QR code:', _err)
|
||||||
|
toast.showToast($t('qrDownloadError'), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="app-toast">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed z-9999 bottom-4 right-4 p-4 rounded-lg shadow-lg max-w-xs"
|
||||||
|
:class="{
|
||||||
|
'bg-green-100 text-green-800 border border-green-200': type === 'success',
|
||||||
|
'bg-red-100 text-red-800 border border-red-200': type === 'error',
|
||||||
|
'bg-blue-100 text-blue-800 border border-blue-200': type === 'info',
|
||||||
|
'bg-yellow-100 text-yellow-800 border border-yellow-200': type === 'warning',
|
||||||
|
'bg-gray-100 text-gray-800 border border-gray-200': type === 'default'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
:class="{
|
||||||
|
'text-green-500': type === 'success',
|
||||||
|
'text-red-500': type === 'error',
|
||||||
|
'text-blue-500': type === 'info',
|
||||||
|
'text-yellow-500': type === 'warning',
|
||||||
|
'text-gray-500': type === 'default'
|
||||||
|
}"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
v-if="type === 'success'"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-if="type === 'error'"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-if="type === 'info' || type === 'default'"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-if="type === 'warning'"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
<div v-if="$slots.actions" class="mt-2 flex gap-2">
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<button
|
||||||
|
@click="visible = false"
|
||||||
|
class="inline-flex rounded-md focus:outline-none"
|
||||||
|
:class="{
|
||||||
|
'text-green-500 hover:text-green-600 focus:text-green-600': type === 'success',
|
||||||
|
'text-red-500 hover:text-red-600 focus:text-red-600': type === 'error',
|
||||||
|
'text-blue-500 hover:text-blue-600 focus:text-blue-600': type === 'info',
|
||||||
|
'text-yellow-500 hover:text-yellow-600 focus:text-yellow-600': type === 'warning',
|
||||||
|
'text-gray-500 hover:text-gray-600 focus:text-gray-600': type === 'default'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'AppToast'
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
validator: (value) => ['success', 'error', 'info', 'warning', 'default'].includes(value)
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 5000
|
||||||
|
},
|
||||||
|
persistent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = ref(true);
|
||||||
|
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
if (newVal && !props.persistent) {
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = false;
|
||||||
|
}, props.duration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-toast-enter-active,
|
||||||
|
.app-toast-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-enter-from,
|
||||||
|
.app-toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,12 +2,11 @@
|
|||||||
<div class="flex flex-col gap-8 pb-20">
|
<div class="flex flex-col gap-8 pb-20">
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('failedClockSummary') }}</h2>
|
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('failedClockSummary') }}</h2>
|
||||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
<div class="mb-6 flex flex-col sm:flex-row sm:items-end gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<label for="search-worker" class="sr-only">{{ $t('searchByNameOrDepartment') }}</label>
|
|
||||||
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 flex-wrap">
|
<div class="flex items-end gap-4 flex-wrap">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate') }}</label>
|
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate') }}</label>
|
||||||
<input type="date" id="start-date" v-model="filters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
<input type="date" id="start-date" v-model="filters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
@@ -16,7 +15,7 @@
|
|||||||
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate') }}</label>
|
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate') }}</label>
|
||||||
<input type="date" id="end-date" v-model="filters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
<input type="date" id="end-date" v-model="filters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||||
</div>
|
</div>
|
||||||
<button @click="fetchFailedRecords" :disabled="loadingReport" class="self-end bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
<button @click="fetchFailedRecords" :disabled="loadingReport" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
||||||
{{ loadingReport ? $t('loading') : $t('fetchRecords') }}
|
{{ loadingReport ? $t('loading') : $t('fetchRecords') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +66,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Details Modal -->
|
|
||||||
<div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
|
<div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl max-h-[90vh] flex flex-col">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||||
<div class="flex justify-between items-center mb-4 border-b pb-3">
|
<div class="flex justify-between items-center mb-4 border-b pb-3">
|
||||||
@@ -109,8 +107,10 @@
|
|||||||
import { ref, onMounted, computed, watch } from 'vue';
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { apiFetch } from '@/api.js';
|
import { apiFetch } from '@/api.js';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
const { t: $t } = useI18n();
|
const { t: $t } = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
// --- STATE ---
|
// --- STATE ---
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
@@ -152,7 +152,7 @@ const fetchFailedRecords = async () => {
|
|||||||
failedRecords.value = await apiFetch(url);
|
failedRecords.value = await apiFetch(url);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error('Failed to fetch failed records',_err);
|
console.error('Failed to fetch failed records',_err);
|
||||||
alert('Failed to fetch records.');
|
toast.showToast('Failed to fetch records.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
loadingReport.value = false;
|
loadingReport.value = false;
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@ const showDetails = async (workerId, workerName) => {
|
|||||||
showDetailModal.value = true;
|
showDetailModal.value = true;
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
console.error('Failed to fetch details',_err);
|
console.error('Failed to fetch details',_err);
|
||||||
alert('Failed to load details.');
|
toast.showToast('Failed to load details.', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { ref, h } from 'vue';
|
||||||
|
import AppToast from '@/components/Toast.vue';
|
||||||
|
|
||||||
|
const toasts = ref([]);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const showToast = (message, type = 'default', duration = 5000) => {
|
||||||
|
const id = Date.now();
|
||||||
|
const toast = {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
duration
|
||||||
|
};
|
||||||
|
|
||||||
|
toasts.value.push(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeToast = (id) => {
|
||||||
|
toasts.value = toasts.value.filter(toast => toast.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showConfirm = async (message) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const id = Date.now();
|
||||||
|
const toast = {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
type: 'info',
|
||||||
|
duration: 0,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: 'Confirm',
|
||||||
|
handler: () => {
|
||||||
|
removeToast(id);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
handler: () => {
|
||||||
|
removeToast(id);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
toasts.value.push(toast);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderToasts = () => {
|
||||||
|
return toasts.value.map(toast => {
|
||||||
|
return h(AppToast, {
|
||||||
|
key: toast.id,
|
||||||
|
message: toast.message,
|
||||||
|
type: toast.type,
|
||||||
|
class: "z-9999",
|
||||||
|
duration: toast.duration,
|
||||||
|
persistent: !!toast.actions,
|
||||||
|
onClose: () => removeToast(toast.id)
|
||||||
|
}, {
|
||||||
|
actions: toast.actions ? () => toast.actions.map(action =>
|
||||||
|
h('button', {
|
||||||
|
class: 'px-3 py-1 text-sm rounded-md',
|
||||||
|
onClick: action.handler
|
||||||
|
}, action.text)
|
||||||
|
) : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
showToast,
|
||||||
|
showConfirm,
|
||||||
|
renderToasts,
|
||||||
|
toasts
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-2
@@ -3,15 +3,23 @@ console.log("[DEBUG] i18n.js loaded!"); // very top
|
|||||||
import { createI18n } from 'vue-i18n';
|
import { createI18n } from 'vue-i18n';
|
||||||
import en from './locales/en.json';
|
import en from './locales/en.json';
|
||||||
import ms from './locales/ms.json';
|
import ms from './locales/ms.json';
|
||||||
|
import tm from './locales/tm.json';
|
||||||
|
import bd from './locales/bd.json';
|
||||||
|
import my from './locales/my.json';
|
||||||
|
import np from './locales/np.json';
|
||||||
|
|
||||||
console.log("[DEBUG] en.json:", en);
|
console.log("[DEBUG] en.json:", en);
|
||||||
console.log("[DEBUG] ms.json:", ms);
|
console.log("[DEBUG] ms.json:", ms);
|
||||||
|
console.log("[DEBUG] tm.json:", tm);
|
||||||
|
console.log("[DEBUG] bd.json:", bd);
|
||||||
|
console.log("[DEBUG] my.json:", my);
|
||||||
|
console.log("[DEBUG] np.json:", np);
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en', // Default to English
|
locale: 'en', // keep original; App.vue will override from localStorage
|
||||||
fallbackLocale: 'en',
|
fallbackLocale: 'en',
|
||||||
messages: { en, ms }
|
messages: { en, ms, tm, bd, my, np } // register all locales here
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[DEBUG] i18n instance created:", i18n);
|
console.log("[DEBUG] i18n instance created:", i18n);
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
{
|
||||||
|
"appTitle": "উপস্থিতি ব্যবস্থা",
|
||||||
|
"logout": "লগ আউট",
|
||||||
|
"login": "লগ ইন",
|
||||||
|
"username": "ইউজারনেম",
|
||||||
|
"password": "পাসওয়ার্ড",
|
||||||
|
"loggingIn": "লগ ইন করা হচ্ছে...",
|
||||||
|
"language": "ভাষা",
|
||||||
|
"darkMode": "ডার্ক মোড",
|
||||||
|
"toggleDarkMode": "হালকা এবং অন্ধকার থিমের মধ্যে পরিবর্তন করুন",
|
||||||
|
"failedConnection": "সার্ভারের সাথে সংযোগ করতে পারেনি।",
|
||||||
|
"invalidToken": "সার্ভার থেকে অবৈধ টোকেন পাওয়া গেছে।",
|
||||||
|
"invalidCredentials": "ভুল ইউজারনেম বা পাসওয়ার্ড।",
|
||||||
|
"english": "ইংরেজি",
|
||||||
|
"malay": "মালয়",
|
||||||
|
"tamil": "তামিল",
|
||||||
|
"bengali": "বাংলা",
|
||||||
|
"burmese": "বর্মী",
|
||||||
|
"nepali": "নেপালি",
|
||||||
|
|
||||||
|
"yourStatus": "স্ট্যাটাস",
|
||||||
|
"clockedIn": "উপস্থিতি রেকর্ড হয়েছে",
|
||||||
|
"clockedOut": "প্রস্থান রেকর্ড হয়েছে",
|
||||||
|
"clockIn": "উপস্থিতি রেকর্ড",
|
||||||
|
"clockOut": "প্রস্থান রেকর্ড",
|
||||||
|
"clock_in": "উপস্থিতি রেকর্ড",
|
||||||
|
"clock_out": "প্রস্থান রেকর্ড",
|
||||||
|
|
||||||
|
"scanToClock": "{action} রেকর্ড করতে স্ক্যান করুন",
|
||||||
|
"in": "উপস্থিতি",
|
||||||
|
"out": "প্রস্থান",
|
||||||
|
"cancel": "বাতিল",
|
||||||
|
|
||||||
|
"viewMyClockHistory": "আমার উপস্থিতির ইতিহাস দেখুন",
|
||||||
|
"changeMyPassword": "আমার পাসওয়ার্ড পরিবর্তন করুন",
|
||||||
|
"myClockHistory": "আমার উপস্থিতির ইতিহাস",
|
||||||
|
"backToDashboard": "ড্যাশবোর্ডে ফিরে যান",
|
||||||
|
"noClockHistory": "আপনার কোনো উপস্থিতির ইতিহাস নেই।",
|
||||||
|
"clockHistoryFetchFail": "উপস্থিতির ইতিহাস পেতে পারেনি:",
|
||||||
|
"viewClockHistory": "আমার উপস্থিতির ইতিহাস দেখুন",
|
||||||
|
"changePassword": "আমার পাসওয়ার্ড পরিবর্তন করুন",
|
||||||
|
"invalidCurrentPassword": "বর্তমান পাসওয়ার্ড ভুল।",
|
||||||
|
|
||||||
|
"successClockIn": "সফলভাবে উপস্থিতি রেকর্ড হয়েছে।",
|
||||||
|
"successClockOut": "সফলভাবে প্রস্থান রেকর্ড হয়েছে।",
|
||||||
|
"qrFail": "QR কোড খুঁজে পাওয়া যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।",
|
||||||
|
"geoFail": "আপনার অবস্থান খুঁজে পাওয়া যায়নি: {message}। অনুগ্রহ করে লোকেশন সার্ভিস চালু করুন।",
|
||||||
|
"successClock": "{location} এ সফলভাবে রেকর্ড হয়েছে।",
|
||||||
|
"changePasswordTitle": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||||
|
"currentPassword": "বর্তমান পাসওয়ার্ড",
|
||||||
|
"newPassword": "নতুন পাসওয়ার্ড",
|
||||||
|
"confirmNewPassword": "নতুন পাসওয়ার্ড নিশ্চিত করুন",
|
||||||
|
"updating": "আপডেট করা হচ্ছে...",
|
||||||
|
|
||||||
|
"tabPersonnel": "কর্মী",
|
||||||
|
"tabAttendance": "উপস্থিতি",
|
||||||
|
"tabQrCodes": "QR কোড",
|
||||||
|
"uploadQrImage": "QR ছবি আপলোড করুন",
|
||||||
|
|
||||||
|
"couldNotLoadWorkerInfo": "কর্মীর তথ্য লোড করতে পারেনি",
|
||||||
|
"couldNotVerifyStatus": "সার্ভার থেকে বর্তমান স্ট্যাটাস যাচাই করতে পারেনি",
|
||||||
|
"successfullyClocked": "সফলভাবে {action} রেকর্ড হয়েছে",
|
||||||
|
"site": "স্থানে",
|
||||||
|
"errorOccurred": "একটি ত্রুটি ঘটেছে",
|
||||||
|
"unableToStartCamera": "ক্যামেরা চালু করতে পারেনি।",
|
||||||
|
"tryAgain": "আবার চেষ্টা করুন",
|
||||||
|
"qrDetectedGettingLocation": "QR কোড পাওয়া গেছে। অবস্থান খুঁজে বের করা হচ্ছে...",
|
||||||
|
"geolocationNotSupported": "আপনার ব্রাউজার জিওলোকেশন সাপোর্ট করে না।",
|
||||||
|
"unableToRetrieveLocation": "আপনার অবস্থান খুঁজে পাওয়া যায়নি: {message}। অনুগ্রহ করে লোকেশন সার্ভিস চালু করুন।",
|
||||||
|
"qrNotDetectedTryAgain": "QR কোড খুঁজে পাওয়া যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।",
|
||||||
|
"updatePassword": "পাসওয়ার্ড আপডেট করুন",
|
||||||
|
"passwordsNoMatch": "নতুন পাসওয়ার্ড মিলছে না।",
|
||||||
|
"passwordTooShort": "নতুন পাসওয়ার্ড কমপক্ষে ৬ অক্ষরের হতে হবে।",
|
||||||
|
"passwordUpdated": "পাসওয়ার্ড সফলভাবে আপডেট হয়েছে! এখন আপনি নতুন পাসওয়ার্ড দিয়ে লগ ইন করতে পারবেন।",
|
||||||
|
"passwordUpdateError": "পাসওয়ার্ড আপডেট করার সময় একটি ত্রুটি হয়েছে।",
|
||||||
|
|
||||||
|
"attendanceLogFor": "উপস্থিতির রেকর্ড -",
|
||||||
|
"addManualClockOut": "ম্যানুয়াল প্রস্থান রেকর্ড যোগ করুন",
|
||||||
|
"manualClockOutInstruction": "কর্মী প্রস্থান রেকর্ড করতে ভুলে গেলে এই ফর্মটি ব্যবহার করুন। শেষ ইভেন্ট অবশ্যই উপস্থিতি রেকর্ড হতে হবে।",
|
||||||
|
"clockOutTime": "প্রস্থানের সময়",
|
||||||
|
"reason": "কারণ (যেমন, \"প্রস্থান রেকর্ড করতে ভুলে গেছেন\")",
|
||||||
|
"enterBriefNote": "সংক্ষিপ্ত নোট লিখুন",
|
||||||
|
"addRecord": "রেকর্ড যোগ করুন",
|
||||||
|
|
||||||
|
"startDate": "শুরুর তারিখ",
|
||||||
|
"endDate": "শেষ তারিখ",
|
||||||
|
"filterRecords": "রেকর্ড ফিল্টার করুন",
|
||||||
|
"event": "ইভেন্ট",
|
||||||
|
"timestamp": "সময়ের স্ট্যাম্প",
|
||||||
|
"locationName": "স্থানের নাম",
|
||||||
|
"coordinates": "স্থানাঙ্ক",
|
||||||
|
"notes": "নোট",
|
||||||
|
"noRecordsFound": "এই সময়ের জন্য কোনো রেকর্ড পাওয়া যায়নি।",
|
||||||
|
"showOnMap": "মানচিত্রে দেখুন",
|
||||||
|
"nA": "নেই",
|
||||||
|
"pleaseSelectTimestamp": "অনুগ্রহ করে প্রস্থানের জন্য সময়ের স্ট্যাম্প নির্বাচন করুন।",
|
||||||
|
"pleaseProvideReason": "অনুগ্রহ করে ম্যানুয়াল এন্ট্রির জন্য কারণ/নোট প্রদান করুন।",
|
||||||
|
"manualClockOutSuccess": "ম্যানুয়াল প্রস্থান সফলভাবে রেকর্ড হয়েছে!",
|
||||||
|
"manualClockOutError": "একটি ত্রুটি ঘটেছে: {message}",
|
||||||
|
|
||||||
|
"selectWorkers": "১. কর্মী নির্বাচন করুন",
|
||||||
|
"searchWorkerPlaceholder": "একজন কর্মী খুঁজুন...",
|
||||||
|
"selectAll": "সব নির্বাচন করুন",
|
||||||
|
"addWorkersByTag": "একটি ট্যাগ থেকে সব কর্মী যোগ করুন",
|
||||||
|
"chooseTag": "-- একটি ট্যাগ বেছে নিন --",
|
||||||
|
"addByTag": "ট্যাগ দ্বারা যোগ করুন",
|
||||||
|
"selectedForReport": "রিপোর্টের জন্য নির্বাচিত ({count})",
|
||||||
|
"allWorkersSelected": "সব কর্মী ({count}) নির্বাচিত",
|
||||||
|
"noWorkersSelected": "কোনো কর্মী নির্বাচিত নয়।",
|
||||||
|
"reportSettings": "২. রিপোর্ট সেটিংস",
|
||||||
|
"setting": "সেটিং",
|
||||||
|
"monthlySalary": "মাসিক বেতন (RM)",
|
||||||
|
"salaryAppliedNote": "নির্বাচিত সব কর্মীর জন্য প্রযোজ্য।",
|
||||||
|
"salaryPlaceholder": "যেমন, ৩০০০",
|
||||||
|
"otFactors": "ওভারটাইম ফ্যাক্টর",
|
||||||
|
"weekendFactor": "সাপ্তাহিক ছুটির ফ্যাক্টর",
|
||||||
|
"holidayFactor": "ছুটির দিনের ফ্যাক্টর",
|
||||||
|
"selectPublicHolidays": "সরকারি ছুটির দিন নির্বাচন করুন",
|
||||||
|
"generateReport": "উপস্থিতি ও ওভারটাইম রিপোর্ট তৈরি করুন",
|
||||||
|
"overtimePaySummary": "ওভারটাইম বেতনের সারসংক্ষেপ",
|
||||||
|
"exportOtSummary": "ওভারটাইম সারসংক্ষেপ রপ্তানি করুন (CSV)",
|
||||||
|
"worker": "কর্মী",
|
||||||
|
"totalHoursWorked": "মোট কাজের ঘন্টা",
|
||||||
|
"totalOtPay": "মোট ওভারটাইম বেতন (RM)",
|
||||||
|
"rawAttendanceData": "কাঁচা উপস্থিতির তথ্য",
|
||||||
|
"loadingReport": "রিপোর্ট লোড করা হচ্ছে...",
|
||||||
|
"tagLoadError": "নির্বাচিত ট্যাগের জন্য কর্মীদের লোড করতে পারেনি।",
|
||||||
|
"generateReportError": "অনুগ্রহ করে কর্মী নির্বাচন করুন, সঠিক তারিখের সীমা নির্ধারণ করুন এবং বেতন লিখুন।",
|
||||||
|
"reportGenerationError": "রিপোর্ট তৈরি করার সময় একটি ত্রুটি ঘটেছে।",
|
||||||
|
|
||||||
|
"addNewUser": "নতুন ইউজার যোগ করুন",
|
||||||
|
"fullName": "পূর্ণ নাম",
|
||||||
|
"egJohnSmith": "যেমন John Smith",
|
||||||
|
"egJsmith": "যেমন jsmith",
|
||||||
|
"eg123456": "যেমন ১২৩৪৫৬",
|
||||||
|
"asManager": "ম্যানেজার হিসেবে",
|
||||||
|
"adding": "যোগ করা হচ্ছে...",
|
||||||
|
"addUser": "ইউজার যোগ করুন",
|
||||||
|
"manageTags": "ট্যাগ পরিচালনা করুন",
|
||||||
|
"createNewTag": "নতুন ট্যাগ তৈরি করুন",
|
||||||
|
"egTeam": "যেমন টিম",
|
||||||
|
"createTag": "ট্যাগ তৈরি করুন",
|
||||||
|
"tags": "ট্যাগ",
|
||||||
|
"workerRoster": "কর্মীদের তালিকা",
|
||||||
|
"searchByNameOrUsername": "নাম বা ইউজারনেম দিয়ে খুঁজুন",
|
||||||
|
"filterByTag": "ট্যাগ দিয়ে ফিল্টার করুন",
|
||||||
|
"clearFilter": "ফিল্টার পরিষ্কার করুন",
|
||||||
|
"dateJoined": "যোগদানের তারিখ",
|
||||||
|
"actions": "কার্যক্রম",
|
||||||
|
"editTags": "ট্যাগ সম্পাদনা করুন",
|
||||||
|
"viewRecords": "রেকর্ড দেখুন",
|
||||||
|
"delete": "মুছে ফেলুন",
|
||||||
|
"loadingWorkers": "কর্মীরা লোড হচ্ছে...",
|
||||||
|
"noWorkersFound": "কোনো কর্মী পাওয়া যায়নি।",
|
||||||
|
"previous": "পূর্ববর্তী",
|
||||||
|
"next": "পরবর্তী",
|
||||||
|
"pageOf": "পৃষ্ঠা {current} এর {total}",
|
||||||
|
"noTagsAvailable": "কোনো ট্যাগ উপলব্ধ নেই।",
|
||||||
|
"done": "সম্পন্ন",
|
||||||
|
"bulkEditTags": "একাধিক ট্যাগ সম্পাদনা করুন",
|
||||||
|
"clearSelection": "নির্বাচন পরিষ্কার করুন",
|
||||||
|
"forUser": "ইউজারের জন্য",
|
||||||
|
"savePassword": "পাসওয়ার্ড সেভ করুন",
|
||||||
|
"saving": "সেভ করা হচ্ছে...",
|
||||||
|
"failedToUpdateTags": "ট্যাগ আপডেট করতে পারেনি। অনুগ্রহ করে আবার চেষ্টা করুন।",
|
||||||
|
"tagDeleted": "ট্যাগ সফলভাবে মুছে ফেলা হয়েছে।",
|
||||||
|
"failedToFetchWorkers": "কর্মীদের তথ্য পেতে পারেনি।",
|
||||||
|
"failedToLoadPageData": "পেজের তথ্য লোড করতে পারেনি।",
|
||||||
|
"errorAddingUser": "ইউজার যোগ করার সময় একটি ত্রুটি ঘটেছে।",
|
||||||
|
"failedToDeleteWorker": "কর্মী মুছে ফেলতে পারেনি।",
|
||||||
|
"areYouSureDeleteWorker": "আপনি কি নিশ্চিত এই কর্মীর অ্যাকাউন্ট মুছে ফেলতে চান?",
|
||||||
|
"areYouSureDeleteTag": "আপনি কি নিশ্চিত এই ট্যাগটি মুছে ফেলতে চান? এটি সব কর্মী থেকে সরিয়ে দেওয়া হবে।",
|
||||||
|
"failedToDeleteTag": "ট্যাগ মুছে ফেলতে পারেনি।",
|
||||||
|
"passwordsDoNotMatch": "পাসওয়ার্ড মিলছে না।",
|
||||||
|
"createQrCode": "নতুন QR কোড তৈরি করুন",
|
||||||
|
"qrCodeName": "QR কোডের নাম",
|
||||||
|
"qrNamePlaceholder": "যেমন, 'পশ্চিম গেট প্রবেশদ্বার'",
|
||||||
|
"create": "তৈরি করুন",
|
||||||
|
"newCodeCreated": "নতুন কোড তৈরি হয়েছে!",
|
||||||
|
"saveQrInstruction": "এই ছবিটি সেভ করুন বা নিচের ID ব্যবহার করুন। এটি রিফ্রেশে অদৃশ্য হয়ে যাবে।",
|
||||||
|
"id": "আইডি",
|
||||||
|
"existingQrCodes": "বিদ্যমান QR কোড",
|
||||||
|
"name": "নাম",
|
||||||
|
"status": "স্ট্যাটাস",
|
||||||
|
"active": "সক্রিয়",
|
||||||
|
"inactive": "নিষ্ক্রিয়",
|
||||||
|
"deactivate": "নিষ্ক্রিয় করুন",
|
||||||
|
"activate": "সক্রিয় করুন",
|
||||||
|
"download": "ডাউনলোড",
|
||||||
|
"noQrCodesFound": "কোনো QR কোড পাওয়া যায়নি। উপরে একটি তৈরি করুন!",
|
||||||
|
"deleteQrConfirm": "আপনি কি নিশ্চিত এই QR কোডটি মুছে ফেলতে চান? এটি পূর্বাবস্থায় ফেরানো যাবে না।",
|
||||||
|
"qrDownloadError": "দুঃখিত, QR কোড ডাউনলোড করা যায়নি।",
|
||||||
|
|
||||||
|
"rememberMe": "অটো লগইনের জন্য আমাকে মনে রাখুন",
|
||||||
|
"deviceNotAuthorized": "এই ডিভাইসটি আপনার অ্যাকাউন্টের জন্য অনুমোদিত নয়। অনুগ্রহ করে আপনার অ্যাডমিনিস্ট্রেটরের সাথে যোগাযোগ করুন।",
|
||||||
|
"locationTrackingActive": "লোকেশন ট্র্যাকিং পেছনে চালু আছে",
|
||||||
|
"securityCheckInProgress": "নিরাপত্তা যাচাই চলছে...",
|
||||||
|
"securityCheckComplete": "নিরাপত্তা যাচাই সফলভাবে সম্পন্ন",
|
||||||
|
"highSecurityRisk": "উচ্চ নিরাপত্তা ঝুঁকি শনাক্ত। অনুগ্রহ করে আপনার অ্যাডমিনিস্ট্রেটরের সাথে যোগাযোগ করুন।",
|
||||||
|
"deviceRegistered": "ডিভাইস সফলভাবে নিবন্ধিত হয়েছে",
|
||||||
|
"autoLoginEnabled": "এই ডিভাইসের জন্য অটো লগইন চালু করা হয়েছে",
|
||||||
|
"backgroundLocationEnabled": "পেছনের লোকেশন ট্র্যাকিং চালু করা হয়েছে",
|
||||||
|
"permissionsRequired": "উপস্থিতি ট্র্যাকিংয়ের জন্য লোকেশন অনুমতি প্রয়োজন",
|
||||||
|
"batteryOptimizationWarning": "অবিরাম লোকেশন ট্র্যাকিং নিশ্চিত করতে এই অ্যাপের জন্য ব্যাটারি অপটিমাইজেশন বন্ধ করুন",
|
||||||
|
"gpsSpooferDetected": "GPS স্পুফিং অ্যাপ্লিকেশন শনাক্ত হয়েছে। এটি উপস্থিতির নির্ভুলতা প্রভাবিত করতে পারে।",
|
||||||
|
"mockLocationEnabled": "মক লোকেশন চালু আছে। নির্ভুল উপস্থিতি ট্র্যাকিংয়ের জন্য এটি বন্ধ করুন।",
|
||||||
|
"deviceSecurityWarning": "ডিভাইস নিরাপত্তা সতর্কতা: সন্দেহজনক অ্যাপ্লিকেশন শনাক্ত",
|
||||||
|
"locationUpdateFailed": "লোকেশন আপডেট করতে পারেনি। স্বয়ংক্রিয়ভাবে আবার চেষ্টা করা হবে।",
|
||||||
|
"servicesInitializing": "নেটিভ সার্ভিস চালু করা হচ্ছে...",
|
||||||
|
"servicesReady": "সব সার্ভিস প্রস্তুত",
|
||||||
|
"autoLoginFailed": "অটো লগইন ব্যর্থ। অনুগ্রহ করে ম্যানুয়ালি লগ ইন করুন।",
|
||||||
|
"deviceValidationFailed": "ডিভাইস ভেরিফিকেশন ব্যর্থ। অনুগ্রহ করে সাপোর্টের সাথে যোগাযোগ করুন।",
|
||||||
|
"deviceMismatch": "এই ডিভাইসটি আপনার অ্যাকাউন্টের জন্য অনুমোদিত নয়।",
|
||||||
|
"deviceRegistrationFailed": "ডিভাইস নিবন্ধন ব্যর্থ। আবার চেষ্টা করুন।",
|
||||||
|
"deviceRequired": "কর্মী লগইনের জন্য ডিভাইস নিবন্ধন প্রয়োজন।",
|
||||||
|
|
||||||
|
"servicesStatus": "সার্ভিসের স্ট্যাটাস",
|
||||||
|
"overallStatus": "সামগ্রিক স্ট্যাটাস",
|
||||||
|
"locationTracking": "লোকেশন ট্র্যাকিং",
|
||||||
|
"deviceRegistration": "ডিভাইস নিবন্ধন",
|
||||||
|
"securityStatus": "নিরাপত্তার স্ট্যাটাস",
|
||||||
|
"lastLocationUpdate": "শেষ লোকেশন আপডেট",
|
||||||
|
"deviceId": "ডিভাইস আইডি",
|
||||||
|
"start": "শুরু করুন",
|
||||||
|
"check": "যাচাই করুন",
|
||||||
|
"checking": "যাচাই করা হচ্ছে...",
|
||||||
|
"refresh": "রিফ্রেশ করুন",
|
||||||
|
"refreshing": "রিফ্রেশ করা হচ্ছে...",
|
||||||
|
"notInitialized": "চালু করা হয়নি",
|
||||||
|
"ready": "প্রস্তুত",
|
||||||
|
"webOnly": "শুধু ওয়েব",
|
||||||
|
"registered": "নিবন্ধিত",
|
||||||
|
"pending": "অপেক্ষমান",
|
||||||
|
"notChecked": "যাচাই করা হয়নি",
|
||||||
|
"outdated": "পুরোনো",
|
||||||
|
"current": "বর্তমান",
|
||||||
|
"never": "কখনো না",
|
||||||
|
"justNow": "এইমাত্র",
|
||||||
|
"minutesAgo": "{minutes} মিনিট আগে",
|
||||||
|
"hoursAgo": "{hours} ঘন্টা আগে",
|
||||||
|
"daysAgo": "{days} দিন আগে",
|
||||||
|
"failedToRefreshStatus": "স্ট্যাটাস রিফ্রেশ করতে পারেনি",
|
||||||
|
"locationTrackingStarted": "লোকেশন ট্র্যাকিং সফলভাবে শুরু হয়েছে",
|
||||||
|
"failedToStartLocationTracking": "লোকেশন ট্র্যাকিং শুরু করতে পারেনি",
|
||||||
|
"securityCheckFailed": "নিরাপত্তা যাচাই ব্যর্থ",
|
||||||
|
|
||||||
|
"personal": "ব্যক্তিগত",
|
||||||
|
"clockHistory": "উপস্থিতির ইতিহাস",
|
||||||
|
"openCamera": "ক্যামেরা খুলুন",
|
||||||
|
"scanQRCode": "QR কোড স্ক্যান করুন",
|
||||||
|
"services": "সার্ভিস",
|
||||||
|
"systemServicesStatus": "সিস্টেম সার্ভিস এবং নিরাপত্তার স্ট্যাটাস",
|
||||||
|
"updateYourPassword": "আপনার অ্যাকাউন্টের পাসওয়ার্ড আপডেট করুন",
|
||||||
|
"signOutOfAccount": "আপনার অ্যাকাউন্ট থেকে সাইন আউট করুন",
|
||||||
|
|
||||||
|
"workLocationTracking": "কর্মক্ষেত্রের লোকেশন ট্র্যাকিং",
|
||||||
|
"locationTrackingForAttendance": "কাজের উপস্থিতির জন্য লোকেশন ট্র্যাকিং সক্রিয়",
|
||||||
|
"monitoringLocation": "কাজের উপস্থিতির জন্য লোকেশন নিরীক্ষণ করা হচ্ছে",
|
||||||
|
|
||||||
|
"manualGuide": "ম্যানুয়াল গাইড ",
|
||||||
|
"viewUserManual": "নির্দেশাবলী এবং FAQs পড়ুন",
|
||||||
|
"manual": {
|
||||||
|
"android": {
|
||||||
|
"heading": "Android",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"id": "android-location",
|
||||||
|
"title": "Location কীভাবে খুলবেন (Android)",
|
||||||
|
"steps": [
|
||||||
|
"আপনার ফোনে <strong>Settings</strong> খুলুন।",
|
||||||
|
"<strong>Location</strong> এ যান <span class=\"text-sm text-gray-500\">(কিছু ফোনে <em>Security & privacy</em> এর অধীনে)</span>।",
|
||||||
|
"<strong>Use location</strong> ON করুন।",
|
||||||
|
"<strong>App permissions</strong> খুলুন → <strong>Attendance System</strong> খুঁজুন → <strong>Allow while using the app</strong> সেট করুন।",
|
||||||
|
"উপলব্ধ থাকলে <strong>Precise location</strong> সক্রিয় করুন।",
|
||||||
|
"App এ ফিরে যান এবং আবার clock-in করার চেষ্টা করুন।"
|
||||||
|
],
|
||||||
|
"note": "ব্র্যান্ড অনুযায়ী নাম ভিন্ন: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services।"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "android-camera",
|
||||||
|
"title": "Camera permission সক্রিয় করুন (Android)",
|
||||||
|
"steps": [
|
||||||
|
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> খুলুন।",
|
||||||
|
"<strong>Permissions</strong> → <strong>Camera</strong> ট্যাপ করুন → <strong>Allow</strong> অথবা <strong>Allow while using the app</strong> নির্বাচন করুন।",
|
||||||
|
"App পুনরায় খুলুন এবং আবার scanning এর চেষ্টা করুন।"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clockin-troubleshoot",
|
||||||
|
"title": "Clock-in কাজ করছে না? দ্রুত checklist",
|
||||||
|
"steps": [
|
||||||
|
"<strong>Location</strong> ON করুন এবং app permission <strong>Allow while using the app</strong> সেট করুন (উপলব্ধ থাকলে <strong>Precise location</strong> সক্রিয় করুন)।",
|
||||||
|
"Network পরীক্ষা করুন: Wi-Fi অথবা data চালু আছে। <strong>Airplane mode</strong> off→on toggle করুন, তারপর আবার চেষ্টা করুন। হস্তক্ষেপ করলে VPN নিষ্ক্রিয় করুন।",
|
||||||
|
"নিশ্চিত করুন <strong>Automatic date & time</strong> এবং <strong>time zone</strong> Android settings এ সক্রিয় আছে।",
|
||||||
|
"Force close করুন এবং app পুনরায় খুলুন। প্রয়োজনে, <strong>Attendance System</strong> cache clear করুন (Settings → Apps → Attendance System → Storage → Clear cache)।"
|
||||||
|
],
|
||||||
|
"note": "এখনও আটকে আছেন? একটি screenshot নিন এবং আপনার manager বা HR এর সাথে যোগাযোগ করুন।"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"heading": "iOS",
|
||||||
|
"comingSoon": "শীঘ্রই আসছে।"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"statusClockedIn": "আপনি ক্লক ইন করেছেন",
|
||||||
|
"statusClockedOut": "আপনি ক্লক আউট করেছেন",
|
||||||
|
"scanToClockIn": "ক্লক ইন করতে QR কোড স্ক্যান করুন",
|
||||||
|
"scanToClockOut": "ক্লক আউট করতে QR কোড স্ক্যান করুন",
|
||||||
|
"appInformation": "অ্যাপ তথ্য",
|
||||||
|
"version": "সংস্করণ",
|
||||||
|
"platform": "প্ল্যাটফর্ম",
|
||||||
|
"web": "ওয়েব"
|
||||||
|
}
|
||||||
|
|
||||||
+149
-19
@@ -1,16 +1,22 @@
|
|||||||
{
|
{
|
||||||
"appTitle": "Clock-In/Out System",
|
"appTitle": "Attendance System",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"rememberMe": "Remember me",
|
||||||
"loggingIn": "Logging in...",
|
"loggingIn": "Logging in...",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"failedConnection": "Failed to connect to the server.",
|
"failedConnection": "Failed to connect to the server.",
|
||||||
|
"useMobileApp": "Please use your mobile app to login",
|
||||||
"invalidToken": "Invalid token received from server.",
|
"invalidToken": "Invalid token received from server.",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"malay": "Malay",
|
"malay": "Bahasa Melayu",
|
||||||
"setting": "Setting",
|
"setting": "Setting",
|
||||||
|
"appInformation": "App Information",
|
||||||
|
"version": "Version",
|
||||||
|
"platform": "Platform",
|
||||||
|
"web": "Web",
|
||||||
|
|
||||||
"yourStatus": "Your Status",
|
"yourStatus": "Your Status",
|
||||||
"clockedIn": "Clocked In",
|
"clockedIn": "Clocked In",
|
||||||
@@ -32,9 +38,10 @@
|
|||||||
"myClockHistory": "My Clock History",
|
"myClockHistory": "My Clock History",
|
||||||
"backToDashboard": "Back to Dashboard",
|
"backToDashboard": "Back to Dashboard",
|
||||||
"noClockHistory": "You have no clocking history.",
|
"noClockHistory": "You have no clocking history.",
|
||||||
|
"clockHistoryEmptyState": "No clock history available.",
|
||||||
"clockHistoryFetchFail": "Failed to fetch clock history:",
|
"clockHistoryFetchFail": "Failed to fetch clock history:",
|
||||||
"viewClockHistory": "View My Clock History",
|
"viewClockHistory": "View My Clock History",
|
||||||
"changePassword": "Change My Password",
|
"changePassword": "Change Password",
|
||||||
|
|
||||||
"successClockIn": "Successfully clocked in.",
|
"successClockIn": "Successfully clocked in.",
|
||||||
"successClockOut": "Successfully clocked out.",
|
"successClockOut": "Successfully clocked out.",
|
||||||
@@ -48,15 +55,15 @@
|
|||||||
|
|
||||||
"tabPersonnel": "Personnel",
|
"tabPersonnel": "Personnel",
|
||||||
"tabAttendance": "Attendance",
|
"tabAttendance": "Attendance",
|
||||||
"tabWarning": "Warnings",
|
"tabWarning": "Alerts",
|
||||||
"warningSettings": "Warning Settings",
|
"warningSettings": "Warning Settings",
|
||||||
"failedClockSummary": "Failed Clock Summary",
|
"failedClockSummary": "Failed Clock Events ",
|
||||||
"failedCount": "Failed Count",
|
"failedCount": "Failed Count",
|
||||||
"viewDetails": "View Details",
|
"viewDetails": "View Details",
|
||||||
"fetchRecords": "Fetch Records",
|
"fetchRecords": "Show List",
|
||||||
"failedRecordsFor": "Failed Records for ",
|
"failedRecordsFor": "Failed Records for ",
|
||||||
"eventType": "Event Type",
|
"eventType": "Event Type",
|
||||||
"tabQrCodes": "QR Codes",
|
"tabQrCodes": "QR",
|
||||||
"uploadQrImage": "Upload QR Image",
|
"uploadQrImage": "Upload QR Image",
|
||||||
|
|
||||||
"couldNotLoadWorkerInfo": "Could not load worker information",
|
"couldNotLoadWorkerInfo": "Could not load worker information",
|
||||||
@@ -73,8 +80,9 @@
|
|||||||
"updatePassword": "Update Password",
|
"updatePassword": "Update Password",
|
||||||
"passwordsNoMatch": "New passwords do not match.",
|
"passwordsNoMatch": "New passwords do not match.",
|
||||||
"passwordTooShort": "New password must be at least 6 characters long.",
|
"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.",
|
"passwordUpdateError": "An error occurred while updating the password.",
|
||||||
|
"invalidCurrentPassword": "The current password you entered is incorrect.",
|
||||||
|
|
||||||
"attendanceLogFor": "Attendance Log for",
|
"attendanceLogFor": "Attendance Log for",
|
||||||
"addManualClockOut": "Add Manual Clock-Out",
|
"addManualClockOut": "Add Manual Clock-Out",
|
||||||
@@ -136,11 +144,12 @@
|
|||||||
"department": "Department",
|
"department": "Department",
|
||||||
"position": "Position",
|
"position": "Position",
|
||||||
"egJohnSmith": "e.g. John Smith",
|
"egJohnSmith": "e.g. John Smith",
|
||||||
"egJsmith": "e.g. jsmith",
|
"egJsmith": "e.g. 123456",
|
||||||
"eg123456": "e.g. 123456",
|
"eg123456": "eg. 123456",
|
||||||
"asManager": "As Manager",
|
"asManager": "As Manager",
|
||||||
"adding": "Adding...",
|
"adding": "Adding...",
|
||||||
"addUser": "Add User",
|
"addUser": "Add User",
|
||||||
|
"addManager": "Add Manager",
|
||||||
"manageTags": "Manage Tags",
|
"manageTags": "Manage Tags",
|
||||||
"createNewTag": "Create New Tag",
|
"createNewTag": "Create New Tag",
|
||||||
"egTeam": "e.g. Team",
|
"egTeam": "e.g. Team",
|
||||||
@@ -148,9 +157,9 @@
|
|||||||
"egManager": "e.g. Manager",
|
"egManager": "e.g. Manager",
|
||||||
"createTag": "Create Tag",
|
"createTag": "Create Tag",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"workerRoster": "Worker Roster",
|
"workerRoster": "Employee List",
|
||||||
"searchByNameOrUsername": "Search by name or username",
|
"searchByNameOrUsername": "Search by name/username",
|
||||||
"searchByNameOrDepartment": "Search by name or department",
|
"searchByNameOrDepartment": "Search by name/department",
|
||||||
"filterByTag": "Filter by tag",
|
"filterByTag": "Filter by tag",
|
||||||
"clearFilter": "Clear filter",
|
"clearFilter": "Clear filter",
|
||||||
"dateJoined": "Date Joined",
|
"dateJoined": "Date Joined",
|
||||||
@@ -188,7 +197,7 @@
|
|||||||
"newCodeCreated": "New Code Created!",
|
"newCodeCreated": "New Code Created!",
|
||||||
"saveQrInstruction": "Save this image or use the ID below. This will disappear on refresh.",
|
"saveQrInstruction": "Save this image or use the ID below. This will disappear on refresh.",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"existingQrCodes": "Existing QR Codes",
|
"existingQrCodes": "QR Code List",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
@@ -202,12 +211,12 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
|
||||||
"tabGeofencing": "Geofencing",
|
"tabGeofencing": "Geofencing",
|
||||||
"createGeofence": "Create Geofence",
|
"createGeofence": "Add Geofence Area",
|
||||||
"drawInstruction": "Click the polygon tool on the map to start drawing a new geofence. Click the first point to finish.",
|
"drawInstruction": "Use the polygon tool to draw your area on the map. Click your starting point again to finish.",
|
||||||
"geofenceName": "Geofence Name",
|
"geofenceName": "Geofence Name",
|
||||||
"geofenceNamePlaceholder": "e.g., Main Warehouse Zone",
|
"geofenceNamePlaceholder": "e.g., Main Warehouse Zone",
|
||||||
"saveGeofence": "Save Geofence",
|
"saveGeofence": "Save Area",
|
||||||
"existingGeofences": "Existing Geofences",
|
"existingGeofences": "Saved Area",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"noGeofencesFound": "No Geofences Found",
|
"noGeofencesFound": "No Geofences Found",
|
||||||
"startOver" : "Start Over",
|
"startOver" : "Start Over",
|
||||||
@@ -233,5 +242,126 @@
|
|||||||
"error.invalidQrCode": "Clocking failed: The scanned QR Code is invalid or no longer active.",
|
"error.invalidQrCode": "Clocking failed: The scanned QR Code is invalid or no longer active.",
|
||||||
"error.alreadyClockedIn": "Action failed: You are already clocked in.",
|
"error.alreadyClockedIn": "Action failed: You are already clocked in.",
|
||||||
"error.alreadyClockedOut": "Action failed: You are already clocked out.",
|
"error.alreadyClockedOut": "Action failed: You are already clocked out.",
|
||||||
"error.criticalServer": "A critical server error occurred. Please contact support."
|
"error.criticalServer": "A critical server error occurred. Please contact support.",
|
||||||
|
|
||||||
|
"dangerZone": "Danger Zone",
|
||||||
|
"clearDeviceDescription": "Unlink Account with Device.",
|
||||||
|
"settings": "Settings",
|
||||||
|
"employeeSettings": "Employee Settings",
|
||||||
|
"accountSettings": "Account Settings",
|
||||||
|
"workerStatus": "Account Status",
|
||||||
|
"activeAccount": "Allow Login",
|
||||||
|
"deleteDescription": "User will be deleted.",
|
||||||
|
"saveChanges": "Save Changes",
|
||||||
|
|
||||||
|
"managerPermissions": "Manager",
|
||||||
|
"can_view_workers": "View Workers",
|
||||||
|
"can_edit_workers": "Manage Workers",
|
||||||
|
"can_view_alerts": "View Alerts",
|
||||||
|
"can_view_geofences": "View Geofences",
|
||||||
|
"can_manage_geofences": "Manage Geofences",
|
||||||
|
"can_view_qrcodes": "View QR Codes",
|
||||||
|
"can_manage_qrcodes": "Manage QR Codes",
|
||||||
|
"can_view_reports": "View Reports",
|
||||||
|
"can_manage_killswitch": "Manage Schedule",
|
||||||
|
"can_manage_permissions": "Manage Permissions",
|
||||||
|
"can_edit_managers": "Edit Managers",
|
||||||
|
"can_delete_managers": "Delete Managers",
|
||||||
|
"noManagersFound": "No managers found",
|
||||||
|
"managerRoster": "Manager Roster",
|
||||||
|
"loadingManagers": "Loading managers...",
|
||||||
|
"managerSettings": "Manager Settings",
|
||||||
|
"managerStatus": "Manager Status",
|
||||||
|
"confirmDeleteWorker": "Are you sure you want to delete this account?",
|
||||||
|
"confirmClearDevice": "Are you sure you want to clear this device? The worker will need to re-login.",
|
||||||
|
"view_all": "View All",
|
||||||
|
"edit_workers": "Edit Workers",
|
||||||
|
"manage_resources": "Manage Resources",
|
||||||
|
"manager_permissions": "Manager Permissions",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this?",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"scheduleUpdateFailed": "Failed to update schedule. Please try again.",
|
||||||
|
"confirmApplyChanges": "Are you sure you want to apply these schedule changes?",
|
||||||
|
"scheduleUpdateSuccess": "Schedule updated successfully",
|
||||||
|
"fetchRecordsFailed": "Failed to fetch records",
|
||||||
|
"loadDetailsFailed": "Failed to load details",
|
||||||
|
"fetchQRCodesFailed": "Failed to fetch QR codes",
|
||||||
|
"generateQRCodeFailed": "Failed to generate QR code image",
|
||||||
|
"createQRCodeFailed": "Failed to create QR code",
|
||||||
|
"updateQRStatusFailed": "Failed to update QR code status",
|
||||||
|
"qrCodeDeleted": "QR code deleted successfully",
|
||||||
|
"deleteQRCodeFailed": "Failed to delete QR code",
|
||||||
|
"workerAdded": "Worker added successfully",
|
||||||
|
"addUserError": "Error adding user",
|
||||||
|
"workerSoftDeleted": "Worker deleted successfully",
|
||||||
|
"deviceCleared": "Worker device cleared successfully",
|
||||||
|
"clearDeviceFailed": "Failed to clear device",
|
||||||
|
"savingSettings": "Saving settings...",
|
||||||
|
"exportingRecords": "Exporting records...",
|
||||||
|
"exportRecordsFailed": "Failed to export records",
|
||||||
|
"fetchManagersFailed": "Failed to fetch managers",
|
||||||
|
"managerSettingsSaved": "Manager settings saved successfully",
|
||||||
|
"saveSettingsFailed": "Failed to save settings",
|
||||||
|
"managerAdded": "Manager added successfully",
|
||||||
|
"managerDeleted": "Manager deleted successfully",
|
||||||
|
"deleteManagerFailed": "Failed to delete manager",
|
||||||
|
"openCamera": "Open Camera",
|
||||||
|
"scanQRCode": "Scan QR Code",
|
||||||
|
"signOutOfAccount": "Sign Out of Account",
|
||||||
|
"darkMode": "Dark Mode",
|
||||||
|
"enableDarkMode": "Enable dark mode",
|
||||||
|
"disableDarkMode": "Disable dark mode",
|
||||||
|
"tamil": "Tamil",
|
||||||
|
"bengali": "Bengali",
|
||||||
|
"burmese": "Burmese",
|
||||||
|
"nepali": "Nepali",
|
||||||
|
|
||||||
|
"manualGuide": "Manual Guide",
|
||||||
|
"viewUserManual": "Read instructions and FAQs",
|
||||||
|
|
||||||
|
"manual": {
|
||||||
|
"android": {
|
||||||
|
"heading": "Android",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"id": "android-location",
|
||||||
|
"title": "How to open location (Android)",
|
||||||
|
"steps": [
|
||||||
|
"Open <strong>Settings</strong> on your phone.",
|
||||||
|
"Go to <strong>Location</strong> <span class=\"text-sm text-gray-500\">(on some phones under <em>Security & privacy</em>)</span>.",
|
||||||
|
"Turn <strong>Use location</strong> ON.",
|
||||||
|
"Open <strong>App permissions</strong> → find <strong>Nilai Clock</strong> → set to <strong>Allow while using the app</strong>.",
|
||||||
|
"Enable <strong>Precise location</strong> if available.",
|
||||||
|
"Return to the app and try clock-in again."
|
||||||
|
],
|
||||||
|
"note": "Names vary by brand: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "android-camera",
|
||||||
|
"title": "Enable camera permission (Android)",
|
||||||
|
"steps": [
|
||||||
|
"Open <strong>Settings</strong> → <strong>Apps</strong> → <strong>Nilai Clock</strong>.",
|
||||||
|
"Tap <strong>Permissions</strong> → <strong>Camera</strong> → choose <strong>Allow</strong> or <strong>Allow while using the app</strong>.",
|
||||||
|
"Reopen the app and try scanning again."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clockin-troubleshoot",
|
||||||
|
"title": "Clock-in not working? Quick checklist",
|
||||||
|
"steps": [
|
||||||
|
"Turn <strong>Location</strong> ON and set app permission to <strong>Allow while using the app</strong> (enable <strong>Precise location</strong> if available).",
|
||||||
|
"Check network: Wi-Fi or data is on. Toggle <strong>Airplane mode</strong> off→on, then retry. Disable VPN if it interferes.",
|
||||||
|
"Ensure <strong>Automatic date & time</strong> and <strong>time zone</strong> are enabled in Android settings.",
|
||||||
|
"Force close and reopen the app. If needed, clear <strong>Attendance System</strong> cache (Settings → Apps → Nilai Clock → Storage → Clear cache)."
|
||||||
|
],
|
||||||
|
"note": "Still stuck? Take a screenshot and contact your manager or HR."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"heading": "iOS",
|
||||||
|
"comingSoon": "Coming soon."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+187
-36
@@ -1,16 +1,22 @@
|
|||||||
{
|
{
|
||||||
"appTitle": "Sistem Kehadiran",
|
"appTitle": "Sistem Kehadiran",
|
||||||
"logout": "Log Keluar",
|
"logout": "Keluar",
|
||||||
"login": "Log Masuk",
|
"login": "Masuk",
|
||||||
"username": "Nama Pengguna",
|
"username": "Nama Pengguna",
|
||||||
"password": "Kata Laluan",
|
"password": "Kata Laluan",
|
||||||
|
"rememberMe": "Ingat Saya",
|
||||||
"loggingIn": "Sedang log masuk...",
|
"loggingIn": "Sedang log masuk...",
|
||||||
"language": "Bahasa",
|
"language": "Bahasa",
|
||||||
"failedConnection": "Gagal untuk berhubung dengan pelayan.",
|
"failedConnection": "Gagal untuk berhubung dengan pelayan.",
|
||||||
|
"useMobileApp": "Sila gunakan aplikasi mudah alih untuk log masuk",
|
||||||
"invalidToken": "Token tidak sah diterima dari pelayan.",
|
"invalidToken": "Token tidak sah diterima dari pelayan.",
|
||||||
"english": "Bahasa Inggeris",
|
"english": "English",
|
||||||
"malay": "Bahasa Melayu",
|
"malay": "Bahasa Melayu",
|
||||||
"setting": "Tetapan",
|
"setting": "Tetapan",
|
||||||
|
"appInformation": "Maklumat Aplikasi",
|
||||||
|
"version": "Versi",
|
||||||
|
"platform": "Platform",
|
||||||
|
"web": "Web",
|
||||||
|
|
||||||
"yourStatus": "Status Anda",
|
"yourStatus": "Status Anda",
|
||||||
"clockedIn": "Sudah Masuk",
|
"clockedIn": "Sudah Masuk",
|
||||||
@@ -25,20 +31,21 @@
|
|||||||
"out": "Keluar",
|
"out": "Keluar",
|
||||||
"cancel": "Batal",
|
"cancel": "Batal",
|
||||||
|
|
||||||
"clockHistroy": "Sejarah Kehadiran",
|
"clockHistory": "Sejarah Kehadiran",
|
||||||
"viewMyClockHistory": "Lihat Sejarah Kehadiran Saya",
|
"viewMyClockHistory": "Lihat Sejarah Kehadiran Saya",
|
||||||
"changeMyPassword": "Tukar Kata Laluan Saya",
|
"changeMyPassword": "Tukar Kata Laluan Saya",
|
||||||
"updateYourPassword": "Tukar Kata Laluan Anda",
|
"updateYourPassword": "Tukar Kata Laluan Anda",
|
||||||
"myClockHistory": "Sejarah Kehadiran Saya",
|
"myClockHistory": "Sejarah Kehadiran Saya",
|
||||||
"backToDashboard": "Kembali ke Papan Pemuka",
|
"backToDashboard": "Kembali ke Papan Pemuka",
|
||||||
"noClockHistory": "Tiada rekod kehadiran.",
|
"noClockHistory": "Tiada rekod kehadiran.",
|
||||||
|
"clockHistoryEmptyState": "Tiada rekod kehadiran.",
|
||||||
"clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:",
|
"clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:",
|
||||||
"viewClockHistory": "Lihat Sejarah Kehadiran Saya",
|
"viewClockHistory": "Lihat Sejarah Kehadiran Saya",
|
||||||
"changePassword": "Tukar Kata Laluan Saya",
|
"changePassword": "Tukar Kata Laluan Saya",
|
||||||
|
|
||||||
"successClockIn": "Berjaya masuk kerja.",
|
"successClockIn": "Berjaya masuk kerja.",
|
||||||
"successClockOut": "Berjaya keluar kerja.",
|
"successClockOut": "Berjaya keluar kerja.",
|
||||||
"qrFail": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
|
"qrFail": "QR tidak dapat dikesan. Sila cuba lagi.",
|
||||||
"geoFail": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.",
|
"geoFail": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.",
|
||||||
"successClock": "Berjaya daftar di {location}.",
|
"successClock": "Berjaya daftar di {location}.",
|
||||||
"changePasswordTitle": "Tukar Kata Laluan",
|
"changePasswordTitle": "Tukar Kata Laluan",
|
||||||
@@ -54,10 +61,10 @@
|
|||||||
"failedClockSummary": "Ringkasan Kegagalan Clock",
|
"failedClockSummary": "Ringkasan Kegagalan Clock",
|
||||||
"failedCount": "Bilangan Gagal",
|
"failedCount": "Bilangan Gagal",
|
||||||
"viewDetails": "Lihat Butiran",
|
"viewDetails": "Lihat Butiran",
|
||||||
"fetchRecords": "Dapatkan Rekod",
|
"fetchRecords": "Lihat Senarai",
|
||||||
"failedRecordsFor": "Rekod Gagal untuk ",
|
"failedRecordsFor": "Rekod Gagal untuk ",
|
||||||
"eventType": "Jenis Peristiwa",
|
"eventType": "Jenis Peristiwa",
|
||||||
"tabQrCodes": "Kod QR",
|
"tabQrCodes": "QR",
|
||||||
"uploadQrImage": "Muat Naik Imej QR",
|
"uploadQrImage": "Muat Naik Imej QR",
|
||||||
|
|
||||||
"couldNotLoadWorkerInfo": "Tidak dapat memuatkan maklumat pekerja",
|
"couldNotLoadWorkerInfo": "Tidak dapat memuatkan maklumat pekerja",
|
||||||
@@ -67,33 +74,34 @@
|
|||||||
"errorOccurred": "Ralat telah berlaku",
|
"errorOccurred": "Ralat telah berlaku",
|
||||||
"unableToStartCamera": "Tidak dapat menghidupkan kamera.",
|
"unableToStartCamera": "Tidak dapat menghidupkan kamera.",
|
||||||
"tryAgain": "Cuba Lagi",
|
"tryAgain": "Cuba Lagi",
|
||||||
"qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...",
|
"qrDetectedGettingLocation": "QR dikesan. Mengambil lokasi...",
|
||||||
"geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.",
|
"geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.",
|
||||||
"unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda. Sila semak kebenaran lokasi. (Butiran: {message})",
|
"unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda. Sila semak kebenaran lokasi. (Butiran: {message})",
|
||||||
"qrNotDetectedTryAgain": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
|
"qrNotDetectedTryAgain": "QR tidak dapat dikesan. Sila cuba lagi.",
|
||||||
"updatePassword": "Kemaskini Kata Laluan",
|
"updatePassword": "Kemaskini Kata Laluan",
|
||||||
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
|
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
|
||||||
"passwordTooShort": "Kata laluan baharu mesti sekurang-kurangnya 6 aksara.",
|
"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.",
|
"passwordUpdateError": "Ralat semasa mengemaskini kata laluan.",
|
||||||
|
"invalidCurrentPassword": "Kata laluan semasa yang anda masukkan tidak betul.",
|
||||||
|
|
||||||
"attendanceLogFor": "Log Kehadiran untuk",
|
"attendanceLogFor": "Log Kehadiran untuk",
|
||||||
"addManualClockOut": "Tambah Clock-Out Manual",
|
"addManualClockOut": "Tambah Clock-Out Secara Manual",
|
||||||
"manualClockOutInstruction": "Gunakan borang ini jika pekerja lupa untuk clock-out. Acara terakhir mesti clock-in.",
|
"manualClockOutInstruction": "Gunakan borang ini jika pekerja lupa untuk clock-out. Acara terakhir mesti clock-in.",
|
||||||
"clockOutTime": "Masa Clock-Out",
|
"clockOutTime": "Masa Clock-Out",
|
||||||
"reason": "Sebab (cth: \"Lupa clock-out\")",
|
"reason": "Sebab (cth: \"Lupa clock-out\")",
|
||||||
"enterBriefNote": "Masukkan nota ringkas",
|
"enterBriefNote": "Tulis nota(jika perlu)",
|
||||||
"addRecord": "Tambah Rekod",
|
"addRecord": "Tambah Rekod",
|
||||||
|
|
||||||
"startDate": "Tarikh Mula",
|
"startDate": "Dari",
|
||||||
"endDate": "Tarikh Tamat",
|
"endDate": "Hingga",
|
||||||
"filterRecords": "Tapis Rekod",
|
"filterRecords": "Tapis Rekod",
|
||||||
"event": "Acara",
|
"event": "Acara",
|
||||||
"timestamp": "Cap Masa",
|
"timestamp": "Cap Masa",
|
||||||
"locationName": "Nama Lokasi",
|
"locationName": "Nama Lokasi",
|
||||||
"coordinates": "Koordinat",
|
"coordinates": "Koordinat",
|
||||||
"notes": "Nota",
|
"notes": "Nota",
|
||||||
"noRecordsFound": "Tiada rekod untuk tempoh ini.",
|
"noRecordsFound": "Belum ada rekod untuk tempod ini.",
|
||||||
"showOnMap": "Papar di peta",
|
"showOnMap": "Papar di peta",
|
||||||
"nA": "Tiada",
|
"nA": "Tiada",
|
||||||
"pleaseSelectTimestamp": "Sila pilih cap masa untuk clock-out.",
|
"pleaseSelectTimestamp": "Sila pilih cap masa untuk clock-out.",
|
||||||
@@ -131,26 +139,27 @@
|
|||||||
"reportGenerationError": "Ralat semasa menjana laporan.",
|
"reportGenerationError": "Ralat semasa menjana laporan.",
|
||||||
"exportAll": "Eksport Semua",
|
"exportAll": "Eksport Semua",
|
||||||
"export": "Eksport",
|
"export": "Eksport",
|
||||||
"addNewUser": "Tambah Pengguna Baharu",
|
"addNewUser": "Tambah Pengguna Baru",
|
||||||
"fullName": "Nama Penuh",
|
"fullName": "Nama Penuh",
|
||||||
"department": "Jabatan",
|
"department": "Jabatan",
|
||||||
"position": "Jawatan",
|
"position": "Jawatan",
|
||||||
"egJohnSmith": "cth. John Smith",
|
"egJohnSmith": "cth. John Smith",
|
||||||
"egJsmith": "cth. jsmith",
|
"egJsmith": "cth. 123456",
|
||||||
"eg123456": "cth. 123456",
|
"eg123456": "cth. 123456",
|
||||||
"asManager": "Sebagai Pengurus",
|
"asManager": "Sebagai Pentadbir",
|
||||||
"adding": "Sedang menambah...",
|
"adding": "Sedang menambah...",
|
||||||
"addUser": "Tambah Pengguna",
|
"addUser": "Tambah Pengguna",
|
||||||
|
"addManager": "Tambah Pentadbir",
|
||||||
"manageTags": "Urus Tag",
|
"manageTags": "Urus Tag",
|
||||||
"createNewTag": "Cipta Tag Baharu",
|
"createNewTag": "Cipta Tag Baharu",
|
||||||
"egTeam": "cth. Pasukan",
|
"egTeam": "cth. Pasukan",
|
||||||
"egSales": "cth. Jualan",
|
"egSales": "cth. Jualan",
|
||||||
"egManager": "cth. Pengurus",
|
"egManager": "cth. Pentadbir",
|
||||||
"createTag": "Cipta Tag",
|
"createTag": "Cipta Tag",
|
||||||
"tags": "Tag",
|
"tags": "Tag",
|
||||||
"workerRoster": "Senarai Pekerja",
|
"workerRoster": "Deftar Pekerja",
|
||||||
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
|
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
|
||||||
"searchByNameOrDepartment": "Cari mengikut nama atau jabatan",
|
"searchByNameOrDepartment": " Cari nama atau jabatan",
|
||||||
"filterByTag": "Tapis mengikut tag",
|
"filterByTag": "Tapis mengikut tag",
|
||||||
"clearFilter": "Padam tapisan",
|
"clearFilter": "Padam tapisan",
|
||||||
"dateJoined": "Tarikh Sertai",
|
"dateJoined": "Tarikh Sertai",
|
||||||
@@ -181,33 +190,33 @@
|
|||||||
"areYouSureDeleteTag": "Adakah anda pasti mahu memadam tag ini? Ia akan dikeluarkan daripada semua pekerja.",
|
"areYouSureDeleteTag": "Adakah anda pasti mahu memadam tag ini? Ia akan dikeluarkan daripada semua pekerja.",
|
||||||
"failedToDeleteTag": "Gagal memadam tag.",
|
"failedToDeleteTag": "Gagal memadam tag.",
|
||||||
"passwordsDoNotMatch": "Kata laluan tidak sepadan.",
|
"passwordsDoNotMatch": "Kata laluan tidak sepadan.",
|
||||||
"createQrCode": "Cipta Kod QR Baharu",
|
"createQrCode": "Cipta QR Baharu",
|
||||||
"qrCodeName": "Nama Kod QR",
|
"qrCodeName": "Nama QR",
|
||||||
"qrNamePlaceholder": "cth: 'Pintu Masuk Barat'",
|
"qrNamePlaceholder": "cth: 'Pintu Masuk Barat'",
|
||||||
"create": "Cipta",
|
"create": "Cipta",
|
||||||
"newCodeCreated": "Kod Baharu Telah Dicipta!",
|
"newCodeCreated": "Kod Baharu Telah Dicipta!",
|
||||||
"saveQrInstruction": "Simpan imej ini atau gunakan ID di bawah. Ini akan hilang selepas segar semula.",
|
"saveQrInstruction": "Simpan imej ini atau gunakan ID di bawah. Ini akan hilang selepas segar semula.",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"existingQrCodes": "Kod QR Sedia Ada",
|
"existingQrCodes": "Senarai Kod QR",
|
||||||
"name": "Nama",
|
"name": "Nama",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"active": "Aktif",
|
"active": "Aktif",
|
||||||
"inactive": "Tidak Aktif",
|
"inactive": "Tidak Aktif",
|
||||||
"deactivate": "Nyahaktif",
|
"deactivate": "Tutup",
|
||||||
"activate": "Aktifkan",
|
"activate": "Aktifkan",
|
||||||
"download": "Muat Turun",
|
"download": "Muat Turun",
|
||||||
"noQrCodesFound": "Tiada kod QR dijumpai. Sila cipta di atas!",
|
"noQrCodesFound": "Tiada QR dijumpai. Sila cipta di atas!",
|
||||||
"deleteQrConfirm": "Adakah anda pasti ingin memadam kod QR ini? Tindakan ini tidak boleh diundur.",
|
"deleteQrConfirm": "Adakah anda pasti ingin memadam QR ini? Tindakan ini tidak boleh diundur.",
|
||||||
"qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun.",
|
"qrDownloadError": "Maaf, QR tidak dapat dimuat turun.",
|
||||||
"loading": "Memuatkan...",
|
"loading": "Memuatkan...",
|
||||||
|
|
||||||
"tabGeofencing": "Geofencing",
|
"tabGeofencing": "Geofencing",
|
||||||
"createGeofence": "Cipta Geofence",
|
"createGeofence": "Lukis Zon Baharu",
|
||||||
"drawInstruction": "Klik alat poligon di peta untuk mula menggambar geofence baru. Klik titik pertama untuk selesai.",
|
"drawInstruction": "Guna alat polygon untuk lukis Kawasan di peta. Klik semula titik pertama untuk siap.",
|
||||||
"geofenceName": "Nama Geofence",
|
"geofenceName": "Nama Geofence",
|
||||||
"geofenceNamePlaceholder": "cth., Zon Gudang Utama",
|
"geofenceNamePlaceholder": "cth., Zon Gudang Utama",
|
||||||
"saveGeofence": "Simpan Geofence",
|
"saveGeofence": "Simpan Kawasan",
|
||||||
"existingGeofences": "Geofences Sedia Ada",
|
"existingGeofences": "Kawasan Disimpan",
|
||||||
"view": "Lihat",
|
"view": "Lihat",
|
||||||
"noGeofencesFound": "Tiada Geofences Dijumpai",
|
"noGeofencesFound": "Tiada Geofences Dijumpai",
|
||||||
"startOver": "Mula Semula",
|
"startOver": "Mula Semula",
|
||||||
@@ -223,15 +232,157 @@
|
|||||||
|
|
||||||
"statusClockedIn": "Anda Sudah Masuk Kerja",
|
"statusClockedIn": "Anda Sudah Masuk Kerja",
|
||||||
"statusClockedOut": "Anda Sudah Keluar Kerja",
|
"statusClockedOut": "Anda Sudah Keluar Kerja",
|
||||||
"scanToClockIn": "Imbas Kod QR untuk Masuk Kerja",
|
"scanToClockIn": "Imbas QR untuk Masuk Kerja",
|
||||||
"scanToClockOut": "Imbas Kod QR untuk Keluar Kerja",
|
"scanToClockOut": "Imbas QR untuk Keluar Kerja",
|
||||||
|
|
||||||
"error.default": "Ralat tidak dijangka telah berlaku. Sila cuba lagi.",
|
"error.default": "Ralat tidak dijangka telah berlaku. Sila cuba lagi.",
|
||||||
"error.clockingDisabled": "Fungsi masuk/keluar kerja dilumpuhkan untuk hari ini. Percubaan anda telah direkodkan.",
|
"error.clockingDisabled": "Fungsi masuk/keluar kerja dilumpuhkan untuk hari ini. Percubaan anda telah direkodkan.",
|
||||||
"error.noActiveGeofence": "Gagal masuk/keluar: Tiada kawasan kerja aktif yang ditetapkan pada pelayan.",
|
"error.noActiveGeofence": "Gagal masuk/keluar: Tiada kawasan kerja aktif yang ditetapkan pada pelayan.",
|
||||||
"error.outsideGeofence": "Gagal masuk/keluar: Anda berada di luar kawasan kerja yang ditetapkan sejauh {distance}m.",
|
"error.outsideGeofence": "Gagal masuk/keluar: Anda berada di luar kawasan kerja yang ditetapkan sejauh {distance}m.",
|
||||||
"error.invalidQrCode": "Gagal masuk/keluar: Kod QR yang diimbas tidak sah atau tidak lagi aktif.",
|
"error.invalidQrCode": "Gagal masuk/keluar: QR yang diimbas tidak sah atau tidak lagi aktif.",
|
||||||
"error.alreadyClockedIn": "Tindakan gagal: Anda sudah masuk kerja.",
|
"error.alreadyClockedIn": "Tindakan gagal: Anda sudah masuk kerja.",
|
||||||
"error.alreadyClockedOut": "Tindakan gagal: Anda sudah keluar kerja.",
|
"error.alreadyClockedOut": "Tindakan gagal: Anda sudah keluar kerja.",
|
||||||
"error.criticalServer": "Ralat kritikal pada pelayan telah berlaku. Sila hubungi sokongan."
|
"error.criticalServer": "Ralat kritikal pada pelayan telah berlaku. Sila hubungi sokongan.",
|
||||||
|
|
||||||
|
"dangerZone": "Zon Bahaya",
|
||||||
|
"clearDeviceDescription": "Nyahpaut Akaun dengan Peranti.",
|
||||||
|
"settings": "Tetapan",
|
||||||
|
"employeeSettings": "Tetapan Pekerja",
|
||||||
|
"accountSettings": "Tetapan Akaun",
|
||||||
|
"workerStatus": "Status Akaun",
|
||||||
|
"activeAccount": "Benarkan Log Masuk",
|
||||||
|
"deleteDescription": "Pengguna akan dipadam.",
|
||||||
|
"saveChanges": "Simpan Perubahan",
|
||||||
|
"confirmDeleteWorker": "Adakah anda pasti mahu memadam pekerja ini?",
|
||||||
|
"managerPermissions": "Pentadbir",
|
||||||
|
"managerRoster": "Daftar Pentadbir",
|
||||||
|
"noManagersFound": "Tiada Pentadbir dijumpai",
|
||||||
|
"loadingManagers": "Memuatkan Pentadbir...",
|
||||||
|
"managerSettings": "Tetapan Pentadbir",
|
||||||
|
"managerStatus": "Status Pentadbir",
|
||||||
|
"confirmClearDevice": "Adakah anda pasti mahu mengosongkan peranti ini? Pekerja perlu log masuk semula.",
|
||||||
|
"view_all": "Lihat Semua",
|
||||||
|
"edit_workers": "Sunting Pekerja",
|
||||||
|
"manage_resources": "Urus Sumber",
|
||||||
|
"manager_permissions": "Kebenaran Pentadbir",
|
||||||
|
"confirmDelete": "Adakah anda pasti mahu memadam ini?",
|
||||||
|
"confirm": "Sahkan",
|
||||||
|
|
||||||
|
"can_view_workers": "Lihat Pekerja",
|
||||||
|
"can_edit_workers": "Urus Pekerja",
|
||||||
|
"can_view_alerts": "Lihat Amaran",
|
||||||
|
"can_view_geofences": "Lihat Geofences",
|
||||||
|
"can_manage_geofences": "Urus Geofences",
|
||||||
|
"can_view_qrcodes": "Lihat Kod QR",
|
||||||
|
"can_manage_qrcodes": "Urus Kod QR",
|
||||||
|
"can_view_reports": "Lihat Laporan",
|
||||||
|
"can_manage_killswitch": "Urus Jadual",
|
||||||
|
"can_manage_permissions": "Urus Kebenaran",
|
||||||
|
"can_edit_managers": "Sunting Pentadbir",
|
||||||
|
"can_delete_managers": "Padam Pentadbir",
|
||||||
|
|
||||||
|
"Worker added successfully": "Pekerja berjaya ditambah",
|
||||||
|
"Worker soft-deleted successfully.": "Pekerja berjaya dipadam",
|
||||||
|
"Worker device cleared successfully.": "Peranti pekerja berjaya dikosongkan",
|
||||||
|
"Manager settings saved successfully!": "Tetapan Pentadbir berjaya disimpan!",
|
||||||
|
"Manager added successfully!": "Pentadbir berjaya ditambah!",
|
||||||
|
"Manager Deleted successfully.": "Pentadbir berjaya dipadam.",
|
||||||
|
"QR code deleted successfully": "Kod QR berjaya dipadam",
|
||||||
|
"Failed to fetch records.": "Gagal mengambil rekod.",
|
||||||
|
"Failed to load details.": "Gagal memuatkan butiran.",
|
||||||
|
"Failed to fetch QR codes": "Gagal mengambil kod QR",
|
||||||
|
"Failed to generate QR code image": "Gagal menjana imej kod QR",
|
||||||
|
"Failed to create QR code": "Gagal mencipta kod QR",
|
||||||
|
"Failed to update QR code status": "Gagal mengemas kini status kod QR",
|
||||||
|
"Failed to delete QR code": "Gagal memadam kod QR",
|
||||||
|
"Failed to export records.": "Gagal mengeksport rekod.",
|
||||||
|
"Failed to fetch managers.": "Gagal mengambil senarai Pentadbir.",
|
||||||
|
"Failed to delete manager.": "Gagal memadam Pentadbir.",
|
||||||
|
"Saving settings...": "Menyimpan tetapan...",
|
||||||
|
"Exporting records...": "Mengeksport rekod...",
|
||||||
|
"scheduleUpdateFailed": "Gagal mengemas kini jadual. Sila cuba lagi.",
|
||||||
|
"confirmApplyChanges": "Adakah anda pasti mahu mengguna pakai perubahan jadual ini?",
|
||||||
|
"scheduleUpdateSuccess": "Jadual berjaya dikemas kini",
|
||||||
|
"fetchRecordsFailed": "Gagal mengambil rekod",
|
||||||
|
"loadDetailsFailed": "Gagal memuatkan butiran",
|
||||||
|
"fetchQRCodesFailed": "Gagal mengambil kod QR",
|
||||||
|
"generateQRCodeFailed": "Gagal menjana imej kod QR",
|
||||||
|
"createQRCodeFailed": "Gagal mencipta kod QR",
|
||||||
|
"updateQRStatusFailed": "Gagal mengemas kini status kod QR",
|
||||||
|
"qrCodeDeleted": "Kod QR berjaya dipadam",
|
||||||
|
"deleteQRCodeFailed": "Gagal memadam kod QR",
|
||||||
|
"workerAdded": "Pekerja berjaya ditambah",
|
||||||
|
"addUserError": "Ralat menambah pengguna",
|
||||||
|
"workerSoftDeleted": "Pekerja berjaya dipadam (soft delete)",
|
||||||
|
"deviceCleared": "Peranti pekerja berjaya dikosongkan",
|
||||||
|
"clearDeviceFailed": "Gagal mengosongkan peranti",
|
||||||
|
"savingSettings": "Menyimpan tetapan...",
|
||||||
|
"exportingRecords": "Mengeksport rekod...",
|
||||||
|
"exportRecordsFailed": "Gagal mengeksport rekod",
|
||||||
|
"fetchManagersFailed": "Gagal mengambil senarai Pentadbir",
|
||||||
|
"managerSettingsSaved": "Tetapan Pentadbir berjaya disimpan",
|
||||||
|
"saveSettingsFailed": "Gagal menyimpan tetapan",
|
||||||
|
"managerAdded": "Pentadbir berjaya ditambah",
|
||||||
|
"managerDeleted": "Pentadbir berjaya dipadam",
|
||||||
|
"deleteManagerFailed": "Gagal memadam Pentadbir",
|
||||||
|
"openCamera": "Buka Kamera",
|
||||||
|
"scanQRCode": "Imbas Kod QR",
|
||||||
|
"signOutOfAccount": "Log Keluar Akaun",
|
||||||
|
|
||||||
|
"darkMode": "Mod Gelap",
|
||||||
|
"enableDarkMode": "Aktifkan mod gelap",
|
||||||
|
"disableDarkMode": "Nyahaktifkan mod gelap",
|
||||||
|
"tamil": "Tamil",
|
||||||
|
"bengali": "Bengali",
|
||||||
|
"burmese": "Burmese",
|
||||||
|
"nepali": "Nepali",
|
||||||
|
|
||||||
|
"manualGuide": "Panduan Manual",
|
||||||
|
"viewUserManual": "Baca arahan dan Soalan Lazim",
|
||||||
|
|
||||||
|
"manual": {
|
||||||
|
"android": {
|
||||||
|
"heading": "Android",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"id": "android-location",
|
||||||
|
"title": "Cara menghidupkan lokasi (Android)",
|
||||||
|
"steps": [
|
||||||
|
"Open <strong>Settings</strong> on your phone.",
|
||||||
|
"Go to <strong>Location</strong> <span class=\"text-sm text-gray-500\">(on some phones under <em>Security & privacy</em>)</span>.",
|
||||||
|
"Turn <strong>Use location</strong> ON.",
|
||||||
|
"Open <strong>App permissions</strong> → find <strong>Nilai Clock</strong> → set to <strong>Allow while using the app</strong>.",
|
||||||
|
"Enable <strong>Precise location</strong> if available.",
|
||||||
|
"Return to the app and try clock-in again."
|
||||||
|
],
|
||||||
|
"note": "Nama menu mungkin berbeza mengikut jenama."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "android-camera",
|
||||||
|
"title": "Benarkan kebenaran kamera (Android)",
|
||||||
|
"steps": [
|
||||||
|
"Open <strong>Settings</strong> → <strong>Apps</strong> → <strong>Nilai Clock</strong>.",
|
||||||
|
"Tap <strong>Permissions</strong> → <strong>Camera</strong> → choose <strong>Allow</strong> or <strong>Allow while using the app</strong>.",
|
||||||
|
"Reopen the app and try scanning again."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clockin-troubleshoot",
|
||||||
|
"title": "Clock-in tidak berjaya? Semakan pantas",
|
||||||
|
"steps": [
|
||||||
|
"Turn <strong>Location</strong> ON and set app permission to <strong>Allow while using the app</strong> (enable <strong>Precise location</strong> if available).",
|
||||||
|
"Check network: Wi-Fi or data is on. Toggle <strong>Airplane mode</strong> off→on, then retry. Disable VPN if it interferes.",
|
||||||
|
"Ensure <strong>Automatic date & time</strong> and <strong>time zone</strong> are enabled in Android settings.",
|
||||||
|
"Force close and reopen the app. If needed, clear <strong>Attendance System</strong> cache (Settings → Apps → Nilai Clock → Storage → Clear cache)."
|
||||||
|
],
|
||||||
|
"note": "Jika masih gagal, ambil tangkapan skrin dan hubungi pengurus/HR."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"heading": "iOS",
|
||||||
|
"comingSoon": "Akan datang."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
{
|
||||||
|
"appTitle": "တက်ရောက်မှု စနစ်",
|
||||||
|
"logout": "ထွက်ရန်",
|
||||||
|
"login": "ဝင်ရောက်ရန်",
|
||||||
|
"username": "အသုံးပြုသူအမည်",
|
||||||
|
"password": "လျှို့ဝှက်နံပါတ်",
|
||||||
|
"loggingIn": "ဝင်ရောက်နေသည်...",
|
||||||
|
"language": "ဘာသာစကား",
|
||||||
|
"darkMode": "မှောင်မိုက်မုဒ်",
|
||||||
|
"toggleDarkMode": "အလင်းနှင့် မှောင်မိုက်အပြင်အဆင်များကြား ပြောင်းလဲရန်",
|
||||||
|
"failedConnection": "ဆာဗာနှင့် ချိတ်ဆက်၍မရပါ။",
|
||||||
|
"invalidToken": "ဆာဗာမှ မမှန်ကန်သော တိုကင်ရရှိခဲ့သည်။",
|
||||||
|
"invalidCredentials": "အသုံးပြုသူအမည် သို့မဟုတ် လျှို့ဝှက်နံပါတ် မမှန်ကန်ပါ။",
|
||||||
|
"english": "အင်္ဂလိပ်",
|
||||||
|
"malay": "မလေး",
|
||||||
|
"tamil": "တမီးလ်",
|
||||||
|
"bengali": "ဘင်္ဂါလီ",
|
||||||
|
"burmese": "မြန်မာ",
|
||||||
|
"nepali": "နီပေါ",
|
||||||
|
|
||||||
|
"yourStatus": "သင်၏အခြေအနေ",
|
||||||
|
"clockedIn": "အချိန်ဝင်ပြီး",
|
||||||
|
"clockedOut": "အချိန်ထွက်ပြီး",
|
||||||
|
"clockIn": "အချိန်ဝင်ရန်",
|
||||||
|
"clockOut": "အချိန်ထွက်ရန်",
|
||||||
|
"clock_in": "အချိန်ဝင်ရန်",
|
||||||
|
"clock_out": "အချိန်ထွက်ရန်",
|
||||||
|
|
||||||
|
"scanToClock": "{action} အတွက် စကင်န်ဖတ်ပါ",
|
||||||
|
"in": "ဝင်",
|
||||||
|
"out": "ထွက်",
|
||||||
|
"cancel": "ပယ်ဖျက်ရန်",
|
||||||
|
|
||||||
|
"viewMyClockHistory": "ကျွန်ုပ်၏ အချိန်မှတ်တမ်းကြည့်ရန်",
|
||||||
|
"changeMyPassword": "ကျွန်ုပ်၏ လျှို့ဝှက်နံပါတ်ပြောင်းရန်",
|
||||||
|
"myClockHistory": "ကျွန်ုပ်၏ အချိန်မှတ်တမ်း",
|
||||||
|
"backToDashboard": "ပင်မစာမျက်နှာသို့ ပြန်ရန်",
|
||||||
|
"noClockHistory": "သင့်တွင် အချိန်မှတ်တမ်း မရှိပါ။",
|
||||||
|
"clockHistoryFetchFail": "အချိန်မှတ်တမ်း ရယူ၍မရပါ:",
|
||||||
|
"viewClockHistory": "ကျွန်ုပ်၏ အချိန်မှတ်တမ်းကြည့်ရန်",
|
||||||
|
"changePassword": "လျှို့ဝှက်နံပါတ်ပြောင်းရန်",
|
||||||
|
"invalidCurrentPassword": "လက်ရှိလျှို့ဝှက်နံပါတ် မမှန်ကန်ပါ။",
|
||||||
|
|
||||||
|
"successClockIn": "အချိန်ဝင်ခြင်း အောင်မြင်ပါသည်။",
|
||||||
|
"successClockOut": "အချိန်ထွက်ခြင်း အောင်မြင်ပါသည်။",
|
||||||
|
"qrFail": "QR ကုဒ်ကို ဖတ်၍မရပါ။ ထပ်မံကြိုးစားပါ။",
|
||||||
|
"geoFail": "သင့်တည်နေရာကို ရယူ၍မရပါ: {message}။ တည်နေရာဝန်ဆောင်မှုများကို ဖွင့်ထားပါ။",
|
||||||
|
"successClock": "{location} တွင် အချိန်မှတ်ခြင်း အောင်မြင်ပါသည်။",
|
||||||
|
"changePasswordTitle": "လျှို့ဝှက်နံပါတ်ပြောင်းရန်",
|
||||||
|
"currentPassword": "လက်ရှိလျှို့ဝှက်နံပါတ်",
|
||||||
|
"newPassword": "လျှို့ဝှက်နံပါတ်အသစ်",
|
||||||
|
"confirmNewPassword": "လျှို့ဝှက်နံပါတ်အသစ် အတည်ပြုပါ",
|
||||||
|
"updating": "အပ်ဒိတ်လုပ်နေသည်...",
|
||||||
|
|
||||||
|
"tabPersonnel": "ဝန်ထမ်းများ",
|
||||||
|
"tabAttendance": "တက်ရောက်မှု",
|
||||||
|
"tabQrCodes": "QR ကုဒ်များ",
|
||||||
|
"uploadQrImage": "QR ပုံအပ်လုဒ်လုပ်ရန်",
|
||||||
|
|
||||||
|
"couldNotLoadWorkerInfo": "အလုပ်သမားအချက်အလက်များ ရယူ၍မရပါ",
|
||||||
|
"couldNotVerifyStatus": "ဆာဗာမှ လက်ရှိအခြေအနေကို အတည်ပြု၍မရပါ",
|
||||||
|
"successfullyClocked": "{action} အချိန်မှတ်ခြင်း အောင်မြင်ပါသည်",
|
||||||
|
"site": "နေရာ",
|
||||||
|
"errorOccurred": "အမှားအယွင်း ဖြစ်ပွားခဲ့သည်",
|
||||||
|
"unableToStartCamera": "ကင်မရာကို ဖွင့်၍မရပါ။",
|
||||||
|
"tryAgain": "ထပ်မံကြိုးစားပါ",
|
||||||
|
"qrDetectedGettingLocation": "QR ကုဒ် တွေ့ရှိပြီး။ တည်နေရာရယူနေသည်...",
|
||||||
|
"geolocationNotSupported": "သင့်ဘရောက်ဇာတွင် တည်နေရာဝန်ဆောင်မှု မပါဝင်ပါ။",
|
||||||
|
"unableToRetrieveLocation": "သင့်တည်နေရာကို ရယူ၍မရပါ: {message}။ တည်နေရာဝန်ဆောင်မှုများကို ဖွင့်ထားပါ။",
|
||||||
|
"qrNotDetectedTryAgain": "QR ကုဒ်ကို ဖတ်၍မရပါ။ ထပ်မံကြိုးစားပါ။",
|
||||||
|
"updatePassword": "လျှို့ဝှက်နံပါတ် အပ်ဒိတ်လုပ်ရန်",
|
||||||
|
"passwordsNoMatch": "လျှို့ဝှက်နံပါတ်အသစ်များ မတူညီပါ။",
|
||||||
|
"passwordTooShort": "လျှို့ဝှက်နံပါတ်အသစ်သည် အနည်းဆုံး ၆လုံး ရှိရပါမည်။",
|
||||||
|
"passwordUpdated": "လျှို့ဝှက်နံပါတ် အပ်ဒိတ်လုပ်ပြီးပါပြီ။ လျှို့ဝှက်နံပါတ်အသစ်ဖြင့် ဝင်ရောက်နိုင်ပါပြီ။",
|
||||||
|
"passwordUpdateError": "လျှို့ဝှက်နံပါတ် အပ်ဒိတ်လုပ်စဉ် အမှားအယွင်း ဖြစ်ပွားခဲ့သည်။",
|
||||||
|
|
||||||
|
"attendanceLogFor": "အတွက် တက်ရောက်မှုမှတ်တမ်း",
|
||||||
|
"addManualClockOut": "လက်ဖြင့် အချိန်ထွက် ထည့်သွင်းရန်",
|
||||||
|
"manualClockOutInstruction": "အလုပ်သမားက အချိန်ထွက်ရန် မေ့လျော့ပါက ဤပုံစံကို အသုံးပြုပါ။ နောက်ဆုံးဖြစ်ရပ်သည် အချိန်ဝင်ခြင်း ဖြစ်ရပါမည်။",
|
||||||
|
"clockOutTime": "အချိန်ထွက် အချိန်",
|
||||||
|
"reason": "အကြောင်းပြချက် (ဥပမာ \"အချိန်ထွက်ရန် မေ့လျော့ခဲ့သည်\")",
|
||||||
|
"enterBriefNote": "အကျဉ်းချုပ် မှတ်စုရေးပါ",
|
||||||
|
"addRecord": "မှတ်တမ်း ထည့်သွင်းရန်",
|
||||||
|
|
||||||
|
"startDate": "စတင်ရက်",
|
||||||
|
"endDate": "ပြီးဆုံးရက်",
|
||||||
|
"filterRecords": "မှတ်တမ်းများ စစ်ထုတ်ရန်",
|
||||||
|
"event": "ဖြစ်ရပ်",
|
||||||
|
"timestamp": "အချိန်တံဆိပ်",
|
||||||
|
"locationName": "နေရာအမည်",
|
||||||
|
"coordinates": "ကိုအော်ဒီနိတ်များ",
|
||||||
|
"notes": "မှတ်စုများ",
|
||||||
|
"noRecordsFound": "ဤကာလအတွင်း မှတ်တမ်းများ မတွေ့ပါ။",
|
||||||
|
"showOnMap": "မြေပုံပေါ်တွင် ပြရန်",
|
||||||
|
"nA": "မရှိ",
|
||||||
|
"pleaseSelectTimestamp": "အချိန်ထွက်အတွက် အချိန်တံဆိပ်ကို ရွေးချယ်ပါ။",
|
||||||
|
"pleaseProvideReason": "လက်ဖြင့်ထည့်သွင်းရန်အတွက် အကြောင်းပြချက်/မှတ်စု ပေးပါ။",
|
||||||
|
"manualClockOutSuccess": "လက်ဖြင့် အချိန်ထွက် မှတ်တမ်း အောင်မြင်စွာ ပြုလုပ်ပြီးပါပြီ။",
|
||||||
|
"manualClockOutError": "အမှားအယွင်း ဖြစ်ပွားခဲ့သည်: {message}",
|
||||||
|
|
||||||
|
"selectWorkers": "၁။ အလုပ်သမားများ ရွေးချယ်ရန်",
|
||||||
|
"searchWorkerPlaceholder": "အလုပ်သမား ရှာဖွေပါ...",
|
||||||
|
"selectAll": "အားလုံး ရွေးချယ်ရန်",
|
||||||
|
"addWorkersByTag": "တက်မှ အလုပ်သမားအားလုံးကို ထည့်ရန်",
|
||||||
|
"chooseTag": "-- တက် ရွေးချယ်ပါ --",
|
||||||
|
"addByTag": "တက်ဖြင့် ထည့်ရန်",
|
||||||
|
"selectedForReport": "အစီရင်ခံစာအတွက် ရွေးချယ်ပြီး ({count})",
|
||||||
|
"allWorkersSelected": "အလုပ်သမားအားလုံး ({count}) ရွေးချယ်ပြီး",
|
||||||
|
"noWorkersSelected": "အလုပ်သမား မရွေးချယ်ရသေးပါ။",
|
||||||
|
"reportSettings": "၂။ အစီရင်ခံစာ ဆက်တင်များ",
|
||||||
|
"setting": "ဆက်တင်",
|
||||||
|
"monthlySalary": "လစာ (ရူပီး)",
|
||||||
|
"salaryAppliedNote": "ရွေးချယ်ထားသော အလုပ်သမားအားလုံးအတွက် အသုံးပြုသည်။",
|
||||||
|
"salaryPlaceholder": "ဥပမာ ၃၀၀၀",
|
||||||
|
"otFactors": "အပိုအချိန် အချက်များ",
|
||||||
|
"weekendFactor": "စနေတနင်္ဂနွေ အချက်",
|
||||||
|
"holidayFactor": "အားလပ်ရက် အချက်",
|
||||||
|
"selectPublicHolidays": "အများပြည်သူ အားလပ်ရက်များ ရွေးချယ်ရန်",
|
||||||
|
"generateReport": "တက်ရောက်မှုနှင့် အပိုအချိန် အစီရင်ခံစာ ထုတ်လုပ်ရန်",
|
||||||
|
"overtimePaySummary": "အပိုအချိन် လစာအကျဉ်းချုပ်",
|
||||||
|
"exportOtSummary": "အပိုအချိန် အကျဉ်းချုပ် ပို့ထုတ်ရန် (CSV)",
|
||||||
|
"worker": "အလုပ်သမား",
|
||||||
|
"totalHoursWorked": "စုစုပေါင်း အလုပ်လုပ်ခဲ့သော နာရီများ",
|
||||||
|
"totalOtPay": "စုစုပေါင်း အပိုအချိန် လစာ (ရူပီး)",
|
||||||
|
"rawAttendanceData": "တက်ရောက်မှု အချက်အလက်များ",
|
||||||
|
"loadingReport": "အစီရင်ခံစာ ရယူနေသည်...",
|
||||||
|
"tagLoadError": "ရွေးချယ်ထားသော တက်အတွက် အလုပ်သမားများကို ရယူ၍မရပါ။",
|
||||||
|
"generateReportError": "အလုပ်သမားများ ရွေးချယ်ပါ၊ မှန်ကန်သော ရက်စွဲအပိုင်းအခြား သတ်မှတ်ပါ နှင့် လစာထည့်ပါ။",
|
||||||
|
"reportGenerationError": "အစီရင်ခံစာ ထုတ်လုပ်စဉ် အမှားအယွင်း ဖြစ်ပွားခဲ့သည်။",
|
||||||
|
|
||||||
|
"addNewUser": "အသုံးပြုသူအသစ် ထည့်သွင်းရန်",
|
||||||
|
"fullName": "အမည်အပြည့်အစုံ",
|
||||||
|
"egJohnSmith": "ဥပမာ မောင်ကျော်",
|
||||||
|
"egJsmith": "ဥပမာ mkyaw",
|
||||||
|
"eg123456": "ဥပမာ ၁၂၃၄၅၆",
|
||||||
|
"asManager": "မန်နေဂျာအနေဖြင့်",
|
||||||
|
"adding": "ထည့်သွင်းနေသည်...",
|
||||||
|
"addUser": "အသုံးပြုသူ ထည့်သွင်းရန်",
|
||||||
|
"manageTags": "တက်များ စီမံခန့်ခွဲရန်",
|
||||||
|
"createNewTag": "တက်အသစ် ဖန်တီးရန်",
|
||||||
|
"egTeam": "ဥပမာ အဖွဲ့",
|
||||||
|
"createTag": "တက် ဖန်တီးရန်",
|
||||||
|
"tags": "တက်များ",
|
||||||
|
"workerRoster": "အလုပ်သမား စာရင်း",
|
||||||
|
"searchByNameOrUsername": "အမည် သို့မဟုတ် အသုံးပြုသူအမည်ဖြင့် ရှာပါ",
|
||||||
|
"filterByTag": "တက်ဖြင့် စစ်ထုတ်ပါ",
|
||||||
|
"clearFilter": "စစ်ထုတ်မှု ရှင်းလင်းရန်",
|
||||||
|
"dateJoined": "ဝင်ရောက်သည့်ရက်",
|
||||||
|
"actions": "လုပ်ဆောင်ချက်များ",
|
||||||
|
"editTags": "တက်များ တည်းဖြတ်ရန်",
|
||||||
|
"viewRecords": "မှတ်တမ်းများ ကြည့်ရန်",
|
||||||
|
"delete": "ဖျက်ရန်",
|
||||||
|
"loadingWorkers": "အလုပ်သမားများ ရယူနေသည်...",
|
||||||
|
"noWorkersFound": "အလုပ်သမား မတွေ့ပါ။",
|
||||||
|
"previous": "ရှေ့သို့",
|
||||||
|
"next": "နောက်သို့",
|
||||||
|
"pageOf": "စာမျက်နှာ {current} / {total}",
|
||||||
|
"noTagsAvailable": "အသုံးပြုနိုင်သော တက် မရှိပါ။",
|
||||||
|
"done": "ပြီးပါပြီ",
|
||||||
|
"bulkEditTags": "တက်များ အစုလိုက် တည်းဖြတ်ရန်",
|
||||||
|
"clearSelection": "ရွေးချယ်မှု ရှင်းလင်းရန်",
|
||||||
|
"forUser": "အသုံးပြုသူအတွက်",
|
||||||
|
"savePassword": "လျှို့ဝှက်နံပါတ် သိမ်းဆည်းရန်",
|
||||||
|
"saving": "သိမ်းဆည်းနေသည်...",
|
||||||
|
"failedToUpdateTags": "တက်များ အပ်ဒိတ်လုပ်၍မရပါ။ ထပ်မံကြိုးစားပါ။",
|
||||||
|
"tagDeleted": "တက် ဖျက်ပြီးပါပြီ။",
|
||||||
|
"failedToFetchWorkers": "အလုပ်သမားများ ရယူ၍မရပါ။",
|
||||||
|
"failedToLoadPageData": "စာမျက်နှာ အချက်အလက်များ ရယူ၍မရပါ။",
|
||||||
|
"errorAddingUser": "အသုံးပြုသူ ထည့်သွင်းစဉ် အမှားအယွင်း ဖြစ်ပွားခဲ့သည်။",
|
||||||
|
"failedToDeleteWorker": "အလုပ်သမား ဖျက်၍မရပါ။",
|
||||||
|
"areYouSureDeleteWorker": "ဤအလုပ်သမား အကောင့်ကို ဖျက်မည်မှာ သေချာပါသလား။",
|
||||||
|
"areYouSureDeleteTag": "ဤတက်ကို ဖျက်မည်မှာ သေချာပါသလား။ ဤသည်က အလုပ်သမားအားလုံးမှ ဖယ်ရှားလိမ့်မည်။",
|
||||||
|
"failedToDeleteTag": "တက် ဖျက်၍မရပါ။",
|
||||||
|
"passwordsDoNotMatch": "လျှို့ဝှက်နံပါတ်များ မတူညီပါ။",
|
||||||
|
"createQrCode": "QR ကုဒ်အသစ် ဖန်တီးရန်",
|
||||||
|
"qrCodeName": "QR ကုဒ် အမည်",
|
||||||
|
"qrNamePlaceholder": "ဥပမာ 'အနောက်ဂိတ် ဝင်ပေါက်'",
|
||||||
|
"create": "ဖန်တီးရန်",
|
||||||
|
"newCodeCreated": "ကုဒ်အသစ် ဖန်တီးပြီးပါပြီ။",
|
||||||
|
"saveQrInstruction": "ဤပုံကို သိမ်းဆည်းပါ သို့မဟုတ် အောက်ပါ ID ကို အသုံးပြုပါ။ ဤသည်က ပြန်လည်ရယူမှုတွင် ပျောက်ကွယ်သွားလိမ့်မည်။",
|
||||||
|
"id": "အိုင်ဒီ",
|
||||||
|
"existingQrCodes": "ရှိပြီးသား QR ကုဒ်များ",
|
||||||
|
"name": "အမည်",
|
||||||
|
"status": "အခြေအနေ",
|
||||||
|
"deactivate": "ပိတ်ရန်",
|
||||||
|
"activate": "ဖွင့်ရန်",
|
||||||
|
"download": "ဒေါင်းလုဒ်လုပ်ရန်",
|
||||||
|
"noQrCodesFound": "QR ကုဒ် မတွေ့ပါ။ အထက်တွင် တစ်ခု ဖန်တီးပါ။",
|
||||||
|
"deleteQrConfirm": "ဤ QR ကုဒ်ကို ဖျက်မည်မှာ သေချာပါသလား။ ဤသည်ကို နောက်ပြန်မပြောင်းနိုင်ပါ။",
|
||||||
|
"qrDownloadError": "စိတ်မကောင်းပါ၊ QR ကုဒ်ကို ဒေါင်းလုဒ်လုပ်၍မရပါ။",
|
||||||
|
|
||||||
|
"rememberMe": "အလိုအလျောက်ဝင်ရောက်ရန် ကျွန်ုပ်ကို မှတ်ထားပါ",
|
||||||
|
"deviceNotAuthorized": "ဤစက်ပစ္စည်းသည် သင့်အကောင့်အတွက် ခွင့်ပြုချက် မရှိပါ။ သင့်စီမံခန့်ခွဲသူကို ဆက်သွယ်ပါ။",
|
||||||
|
"locationTrackingActive": "နောက်ကွယ်တွင် တည်နေရာခြေရာခံမှု အသက်ဝင်နေသည်",
|
||||||
|
"securityCheckInProgress": "လုံခြုံရေးစစ်ဆေးမှု ပြုလုပ်နေသည်...",
|
||||||
|
"securityCheckComplete": "လုံခြုံရေးစစ်ဆေးမှု အောင်မြင်စွာ ပြီးဆုံးပါပြီ",
|
||||||
|
"highSecurityRisk": "မြင့်မားသော လုံခြုံရေးအန္တရာယ် တွေ့ရှိရသည်။ သင့်စီမံခန့်ခွဲသူကို ဆက်သွယ်ပါ။",
|
||||||
|
"deviceRegistered": "စက်ပစ္စည်း အောင်မြင်စွာ မှတ်ပုံတင်ပြီးပါပြီ",
|
||||||
|
"autoLoginEnabled": "ဤစက်ပစ္စည်းအတွက် အလိုအလျောက်ဝင်ရောက်မှု ဖွင့်ပြီးပါပြီ",
|
||||||
|
"backgroundLocationEnabled": "နောက်ကွယ် တည်နေရာခြေရာခံမှု ဖွင့်ပြီးပါပြီ",
|
||||||
|
"permissionsRequired": "တက်ရောက်မှုခြေရာခံမှုအတွက် တည်နေရာခွင့်ပြုချက်များ လိုအပ်သည်",
|
||||||
|
"batteryOptimizationWarning": "ဆက်တိုက် တည်နေရာခြေရာခံမှုအတွက် ဤအပ်ပ်အတွက် ဘက်ထရီ အကောင်းဆုံးလုပ်ခြင်းကို ပိတ်ပါ",
|
||||||
|
"gpsSpooferDetected": "GPS အတုအပ် အပလီကေးရှင်း တွေ့ရှိရသည်။ ဤသည်က တက်ရောက်မှု တိကျမှုကို ထိခိုက်စေနိုင်သည်။",
|
||||||
|
"mockLocationEnabled": "အတု တည်နေရာ ဖွင့်ထားသည်။ တိကျသော တက်ရောက်မှု ခြေရာခံမှုအတွက် ပိတ်ပါ။",
|
||||||
|
"deviceSecurityWarning": "စက်ပစ္စည်း လုံခြုံရေး သတိပေးချက်: သံသယဖြစ်ဖွယ် အပလီကေးရှင်းများ တွေ့ရှိရသည်",
|
||||||
|
"locationUpdateFailed": "တည်နေရာ အပ်ဒိတ်လုပ်၍မရပါ။ အလိုအလျောက် ထပ်မံကြိုးစားလိမ့်မည်။",
|
||||||
|
"servicesInitializing": "မူလဝန်ဆောင်မှုများ စတင်နေသည်...",
|
||||||
|
"servicesReady": "ဝန်ဆောင်မှုများ အားလုံး အဆင်သင့်ပါပြီ",
|
||||||
|
"autoLoginFailed": "အလိုအလျောက်ဝင်ရောက်မှု မအောင်မြင်ပါ။ လက်ဖြင့် ဝင်ရောက်ပါ။",
|
||||||
|
"deviceValidationFailed": "စက်ပစ္စည်း အတည်ပြုခြင်း မအောင်မြင်ပါ။ ပံ့ပိုးမှုကို ဆက်သွယ်ပါ။",
|
||||||
|
"deviceMismatch": "ဤစက်ပစ္စည်းသည် သင့်အကောင့်အတွက် ခွင့်ပြုမထားပါ။",
|
||||||
|
"deviceRegistrationFailed": "စက်ပစ္စည်း မှတ်ပုံတင်မှု မအောင်မြင်ပါ။ ပြန်လည်ကြိုးစားပါ။",
|
||||||
|
"deviceRequired": "အလုပ်သမား အကောင့်ဝင်ရောက်မှုအတွက် စက်ပစ္စည်း မှတ်ပုံတင်ခြင်း လိုအပ်ပါသည်။",
|
||||||
|
|
||||||
|
"servicesStatus": "ဝန်ဆောင်မှုများ အခြေအနေ",
|
||||||
|
"overallStatus": "ခြုံငုံအခြေအနေ",
|
||||||
|
"locationTracking": "တည်နေရာခြေရာခံမှု",
|
||||||
|
"deviceRegistration": "စက်ပစ္စည်း မှတ်ပုံတင်မှု",
|
||||||
|
"securityStatus": "လုံခြုံရေး အခြေအနေ",
|
||||||
|
"lastLocationUpdate": "နောက်ဆုံး တည်နေရာ အပ်ဒိတ်",
|
||||||
|
"deviceId": "စက်ပစ္စည်း အိုင်ဒီ",
|
||||||
|
"start": "စတင်ရန်",
|
||||||
|
"check": "စစ်ဆေးရန်",
|
||||||
|
"checking": "စစ်ဆေးနေသည်...",
|
||||||
|
"refresh": "ပြန်လည်ရယူရန်",
|
||||||
|
"refreshing": "ပြန်လည်ရယူနေသည်...",
|
||||||
|
"notInitialized": "မစတင်ရသေးပါ",
|
||||||
|
"ready": "အဆင်သင့်",
|
||||||
|
"webOnly": "ဝက်ဘ်တွင်သာ",
|
||||||
|
"active": "လှုပ်ရှားနေသည်",
|
||||||
|
"inactive": "အလုပ်မလုပ်တော့ပါ",
|
||||||
|
"registered": "မှတ်ပုံတင်ပြီး",
|
||||||
|
"pending": "ဆိုင်းငံ့နေသည်",
|
||||||
|
"notChecked": "စစ်ဆေးထားခြင်း မရှိပါ",
|
||||||
|
"outdated": "အဆန်းပြားသွားပါပြီ",
|
||||||
|
"current": "လက်ရှိ",
|
||||||
|
"never": "မတိုင်မီ",
|
||||||
|
"justNow": "ယခုတင်",
|
||||||
|
"minutesAgo": "{minutes} မိနစ် အကြာ",
|
||||||
|
"hoursAgo": "{hours} နာရီ အကြာ",
|
||||||
|
"daysAgo": "{days} ရက် အကြာ",
|
||||||
|
"failedToRefreshStatus": "အခြေအနေ ပြန်လည်ရယူ၍မရပါ",
|
||||||
|
"locationTrackingStarted": "တည်နေရာခြေရာခံမှု အောင်မြင်စွာ စတင်သည်",
|
||||||
|
"failedToStartLocationTracking": "တည်နေရာခြေရာခံမှု စတင်၍မရပါ",
|
||||||
|
"securityCheckFailed": "လုံခြုံရေး စစ်ဆေးမှု မအောင်မြင်ပါ",
|
||||||
|
|
||||||
|
"personal": "ပုဂ္ဂိုလ်ရေး",
|
||||||
|
"clockHistory": "အချိန်မှတ်တမ်း",
|
||||||
|
"openCamera": "ကင်မရာ ဖွင့်ရန်",
|
||||||
|
"scanQRCode": "QR ကုဒ် စကင်ဖတ်ရန်",
|
||||||
|
"services": "ဝန်ဆောင်မှုများ",
|
||||||
|
"systemServicesStatus": "စနစ်ဝန်ဆောင်မှုများနှင့် လုံခြုံရေးအခြေအနေ",
|
||||||
|
"updateYourPassword": "သင့်အကောင့် လျှို့ဝှက်နံပါတ် အပ်ဒိတ်လုပ်ပါ",
|
||||||
|
"signOutOfAccount": "သင့်အကောင့်မှ ထွက်ရန်",
|
||||||
|
|
||||||
|
"workLocationTracking": "အလုပ်တည်နေရာ ခြေရာခံမှု",
|
||||||
|
"locationTrackingForAttendance": "အလုပ်တက်ရောက်မှုအတွက် တည်နေရာခြေရာခံမှု အသက်ဝင်နေသည်",
|
||||||
|
"monitoringLocation": "အလုပ်တက်ရောက်မှုအတွက် တည်နေရာကို ကြည့်ရှုနေသည်",
|
||||||
|
|
||||||
|
"manualGuide": "လက်စွဲလမ်းညွှန်",
|
||||||
|
"viewUserManual": "လမ်းညွှန်ချက်များနှင့် FAQs ကိုဖတ်ပါ",
|
||||||
|
"manual": {
|
||||||
|
"android": {
|
||||||
|
"heading": "Android",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"id": "android-location",
|
||||||
|
"title": "Location ကို ဖွင့်နည်း (Android)",
|
||||||
|
"steps": [
|
||||||
|
"သင့်ဖုန်းရှိ <strong>Settings</strong> ကို ဖွင့်ပါ။",
|
||||||
|
"<strong>Location</strong> သို့သွားပါ <span class=\"text-sm text-gray-500\">(အချို့ဖုန်းများတွင် <em>Security & privacy</em> အောက်တွင်)</span>။",
|
||||||
|
"<strong>Use location</strong> ကို ON ဖွင့်ပါ။",
|
||||||
|
"<strong>App permissions</strong> ကိုဖွင့်ပါ → <strong>Attendance System</strong> ကိုရှာပါ → <strong>Allow while using the app</strong> သတ်မှတ်ပါ။",
|
||||||
|
"ရနိုင်လျှင် <strong>Precise location</strong> ကို ဖွင့်ပါ။",
|
||||||
|
"App သို့ပြန်သွားပြီး clock-in ကို ထပ်စမ်းကြည့်ပါ။"
|
||||||
|
],
|
||||||
|
"note": "အမှတ်တံဆိပ်ပေါ်မူတည်၍ အမည်များကွဲပြားနိုင်သည်: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services။"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "android-camera",
|
||||||
|
"title": "Camera permission ဖွင့်နည်း (Android)",
|
||||||
|
"steps": [
|
||||||
|
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> ကို ဖွင့်ပါ။",
|
||||||
|
"<strong>Permissions</strong> → <strong>Camera</strong> ကို နှိပ်ပါ → <strong>Allow</strong> သို့မဟုတ် <strong>Allow while using the app</strong> ကို ရွေးပါ။",
|
||||||
|
"App ကို ပြန်ဖွင့်ပြီး scanning ကို ထပ်စမ်းကြည့်ပါ။"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clockin-troubleshoot",
|
||||||
|
"title": "Clock-in အလုပ်မလုပ်ဘူးလား? အမြန် checklist",
|
||||||
|
"steps": [
|
||||||
|
"<strong>Location</strong> ကို ON ဖွင့်ပြီး app permission ကို <strong>Allow while using the app</strong> သတ်မှတ်ပါ (ရနိုင်လျှင် <strong>Precise location</strong> ကို ဖွင့်ပါ)။",
|
||||||
|
"Network ကို စစ်ဆေးပါ: Wi-Fi သို့မဟုတ် data ကို ဖွင့်ထားသည်။ <strong>Airplane mode</strong> ကို off→on toggle လုပ်ပါ၊ ပြီးနောက် ထပ်စမ်းကြည့်ပါ။ ဝင်စွက်နေလျှင် VPN ကို ပိတ်ပါ။",
|
||||||
|
"Android settings တွင် <strong>Automatic date & time</strong> နှင့် <strong>time zone</strong> တို့ကို ဖွင့်ထားကြောင်း သေချာပါစေ။",
|
||||||
|
"Force close လုပ်ပြီး app ကို ပြန်ဖွင့်ပါ။ လိုအပ်လျှင် <strong>Attendance System</strong> cache ကို clear လုပ်ပါ (Settings → Apps → Attendance System → Storage → Clear cache)။"
|
||||||
|
],
|
||||||
|
"note": "ဆက်လက်ပြဿနာရှိနေပါသလား? Screenshot ရိုက်ပြီး သင့် manager သို့မဟုတ် HR ကို ဆက်သွယ်ပါ။"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"heading": "iOS",
|
||||||
|
"comingSoon": "မကြာမီ ရောက်ရှိပါမည်။"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"statusClockedIn": "သင်သည် ရုံးဝင်ထားပါသည်",
|
||||||
|
"statusClockedOut": "သင်သည် ရုံးထွက်ထားပါသည်",
|
||||||
|
"scanToClockIn": "ရုံးဝင်ရန် QR ကုဒ်ကို စကင်န်ဖတ်ပါ",
|
||||||
|
"scanToClockOut": "ရုံးထွက်ရန် QR ကုဒ်ကို စကင်န်ဖတ်ပါ",
|
||||||
|
"appInformation": "एप जानकारी",
|
||||||
|
"version": "संस्करण",
|
||||||
|
"platform": "प्लेटफर्म",
|
||||||
|
"web": "वेब"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
{
|
||||||
|
"appTitle": "उपस्थिति प्रणाली",
|
||||||
|
"logout": "लगआउट",
|
||||||
|
"login": "लगइन",
|
||||||
|
"username": "प्रयोगकर्ता नाम",
|
||||||
|
"password": "पासवर्ड",
|
||||||
|
"loggingIn": "लगइन गर्दै...",
|
||||||
|
"language": "भाषा",
|
||||||
|
"darkMode": "डार्क मोड",
|
||||||
|
"toggleDarkMode": "उज्यालो र अँध्यारो थिमहरू बीच स्विच गर्नुहोस्",
|
||||||
|
"failedConnection": "सर्भरसँग जडान गर्न सकिएन।",
|
||||||
|
"invalidToken": "सर्भरबाट अमान्य टोकन प्राप्त भयो।",
|
||||||
|
"invalidCredentials": "गलत प्रयोगकर्ता नाम वा पासवर्ड।",
|
||||||
|
"english": "अंग्रेजी",
|
||||||
|
"malay": "मलय",
|
||||||
|
"tamil": "तामिल",
|
||||||
|
"bengali": "बङ्गाली",
|
||||||
|
"burmese": "बर्मेली",
|
||||||
|
"nepali": "नेपाली",
|
||||||
|
|
||||||
|
"yourStatus": "स्थिति",
|
||||||
|
"clockedIn": "उपस्थित भएको",
|
||||||
|
"clockedOut": "अनुपस्थित भएको",
|
||||||
|
"clockIn": "उपस्थित हुनुहोस्",
|
||||||
|
"clockOut": "अनुपस्थित हुनुहोस्",
|
||||||
|
"clock_in": "उपस्थित हुनुहोस्",
|
||||||
|
"clock_out": "अनुपस्थित हुनुहोस्",
|
||||||
|
|
||||||
|
"scanToClock": "{action} को लागि स्क्यान गर्नुहोस्",
|
||||||
|
"in": "भित्र",
|
||||||
|
"out": "बाहिर",
|
||||||
|
"cancel": "रद्द गर्नुहोस्",
|
||||||
|
|
||||||
|
"viewMyClockHistory": "मेरो उपस्थिति इतिहास हेर्नुहोस्",
|
||||||
|
"changeMyPassword": "मेरो पासवर्ड परिवर्तन गर्नुहोस्",
|
||||||
|
"myClockHistory": "मेरो उपस्थिति इतिहास",
|
||||||
|
"backToDashboard": "ड्यासबोर्डमा फर्कनुहोस्",
|
||||||
|
"noClockHistory": "तपाईंको कुनै उपस्थिति इतिहास छैन।",
|
||||||
|
"clockHistoryFetchFail": "उपस्थिति इतिहास ल्याउन सकिएन:",
|
||||||
|
"viewClockHistory": "मेरो उपस्थिति इतिहास हेर्नुहोस्",
|
||||||
|
"changePassword": "मेरो पासवर्ड परिवर्तन गर्नुहोस्",
|
||||||
|
"invalidCurrentPassword": "गलत वर्तमान पासवर्ड।",
|
||||||
|
|
||||||
|
"successClockIn": "सफलतापूर्वक उपस्थित भएको।",
|
||||||
|
"successClockOut": "सफलतापूर्वक अनुपस्थित भएको।",
|
||||||
|
"qrFail": "QR कोड पत्ता लगाउन सकिएन। कृपया पुनः प्रयास गर्नुहोस्।",
|
||||||
|
"geoFail": "तपाईंको स्थान पत्ता लगाउन सकिएन: {message}। कृपया स्थान सेवाहरू सक्षम पार्नुहोस्।",
|
||||||
|
"successClock": "{location} मा सफलतापूर्वक उपस्थित भएको।",
|
||||||
|
"changePasswordTitle": "पासवर्ड परिवर्तन गर्नुहोस्",
|
||||||
|
"currentPassword": "वर्तमान पासवर्ड",
|
||||||
|
"newPassword": "नयाँ पासवर्ड",
|
||||||
|
"confirmNewPassword": "नयाँ पासवर्ड पुष्टि गर्नुहोस्",
|
||||||
|
"updating": "अपडेट गर्दै...",
|
||||||
|
|
||||||
|
"tabPersonnel": "कर्मचारी",
|
||||||
|
"tabAttendance": "उपस्थिति",
|
||||||
|
"tabQrCodes": "QR कोडहरू",
|
||||||
|
"uploadQrImage": "QR छवि अपलोड गर्नुहोस्",
|
||||||
|
|
||||||
|
"couldNotLoadWorkerInfo": "कर्मचारी जानकारी लोड गर्न सकिएन",
|
||||||
|
"couldNotVerifyStatus": "सर्भरबाट वर्तमान स्थिति प्रमाणित गर्न सकिएन",
|
||||||
|
"successfullyClocked": "सफलतापूर्वक {action} भएको",
|
||||||
|
"site": "साइट",
|
||||||
|
"errorOccurred": "त्रुटि भयो",
|
||||||
|
"unableToStartCamera": "क्यामेरा सुरु गर्न सकिएन।",
|
||||||
|
"tryAgain": "पुनः प्रयास गर्नुहोस्",
|
||||||
|
"qrDetectedGettingLocation": "QR कोड पत्ता लाग्यो। स्थान प्राप्त गर्दै...",
|
||||||
|
"geolocationNotSupported": "तपाईंको ब्राउजरले भौगोलिक स्थान समर्थन गर्दैन।",
|
||||||
|
"unableToRetrieveLocation": "तपाईंको स्थान पत्ता लगाउन सकिएन: {message}। कृपया स्थान सेवाहरू सक्षम पार्नुहोस्।",
|
||||||
|
"qrNotDetectedTryAgain": "QR कोड पत्ता लगाउन सकिएन। कृपया पुनः प्रयास गर्नुहोस्।",
|
||||||
|
"updatePassword": "पासवर्ड अपडेट गर्नुहोस्",
|
||||||
|
"passwordsNoMatch": "नयाँ पासवर्डहरू मेल खाँदैनन्।",
|
||||||
|
"passwordTooShort": "नयाँ पासवर्ड कम्तिमा ६ वर्णको हुनुपर्छ।",
|
||||||
|
"passwordUpdated": "पासवर्ड सफलतापूर्वक अपडेट भयो! तपाईं अब आफ्नो नयाँ पासवर्ड प्रयोग गरेर लगइन गर्न सक्नुहुन्छ।",
|
||||||
|
"passwordUpdateError": "पासवर्ड अपडेट गर्दा त्रुटि भयो।",
|
||||||
|
|
||||||
|
"attendanceLogFor": "को लागि उपस्थिति लग",
|
||||||
|
"addManualClockOut": "म्यानुअल क्लक-आउट थप्नुहोस्",
|
||||||
|
"manualClockOutInstruction": "यदि कर्मचारी क्लक आउट गर्न बिर्सिएका छन् भने यो फारम प्रयोग गर्नुहोस्। अन्तिम घटना क्लक-इन हुनुपर्छ।",
|
||||||
|
"clockOutTime": "क्लक-आउट समय",
|
||||||
|
"reason": "कारण (जस्तै, \"क्लक आउट गर्न बिर्सियो\")",
|
||||||
|
"enterBriefNote": "छोटो टिप्पणी प्रविष्ट गर्नुहोस्",
|
||||||
|
"addRecord": "रेकर्ड थप्नुहोस्",
|
||||||
|
|
||||||
|
"startDate": "सुरु मिति",
|
||||||
|
"endDate": "अन्त्य मिति",
|
||||||
|
"filterRecords": "रेकर्डहरू फिल्टर गर्नुहोस्",
|
||||||
|
"event": "घटना",
|
||||||
|
"timestamp": "समय छाप",
|
||||||
|
"locationName": "स्थानको नाम",
|
||||||
|
"coordinates": "निर्देशाङ्क",
|
||||||
|
"notes": "टिप्पणीहरू",
|
||||||
|
"noRecordsFound": "यस अवधिको लागि कुनै रेकर्ड भेटिएन।",
|
||||||
|
"showOnMap": "नक्सामा देखाउनुहोस्",
|
||||||
|
"nA": "उपलब्ध छैन",
|
||||||
|
"pleaseSelectTimestamp": "कृपया क्लक-आउटको लागि समय छाप चयन गर्नुहोस्।",
|
||||||
|
"pleaseProvideReason": "कृपया म्यानुअल प्रविष्टिको लागि कारण/टिप्पणी प्रदान गर्नुहोस्।",
|
||||||
|
"manualClockOutSuccess": "म्यानुअल क्लक-आउट सफलतापूर्वक रेकर्ड भयो!",
|
||||||
|
"manualClockOutError": "त्रुटि भयो: {message}",
|
||||||
|
|
||||||
|
"selectWorkers": "१. कर्मचारी चयन गर्नुहोस्",
|
||||||
|
"searchWorkerPlaceholder": "कर्मचारी खोज्नुहोस्...",
|
||||||
|
"selectAll": "सबै चयन गर्नुहोस्",
|
||||||
|
"addWorkersByTag": "ट्यागबाट सबै कर्मचारी थप्नुहोस्",
|
||||||
|
"chooseTag": "-- ट्याग छान्नुहोस् --",
|
||||||
|
"addByTag": "ट्यागद्वारा थप्नुहोस्",
|
||||||
|
"selectedForReport": "रिपोर्टको लागि चयनित ({count})",
|
||||||
|
"allWorkersSelected": "सबै कर्मचारी ({count}) चयनित",
|
||||||
|
"noWorkersSelected": "कुनै कर्मचारी चयनित छैन।",
|
||||||
|
"reportSettings": "२. रिपोर्ट सेटिङहरू",
|
||||||
|
"setting": "सेटिङ",
|
||||||
|
"monthlySalary": "मासिक तलब (RM)",
|
||||||
|
"salaryAppliedNote": "सबै चयनित कर्मचारीहरूमा लागू।",
|
||||||
|
"salaryPlaceholder": "जस्तै, ३०००",
|
||||||
|
"otFactors": "OT फ्याक्टरहरू",
|
||||||
|
"weekendFactor": "सप्ताहन्त फ्याक्टर",
|
||||||
|
"holidayFactor": "छुट्टी फ्याक्टर",
|
||||||
|
"selectPublicHolidays": "सार्वजनिक छुट्टीहरू चयन गर्नुहोस्",
|
||||||
|
"generateReport": "उपस्थिति र OT रिपोर्ट उत्पन्न गर्नुहोस्",
|
||||||
|
"overtimePaySummary": "ओभरटाइम भुक्तानी सारांश",
|
||||||
|
"exportOtSummary": "OT सारांश निर्यात गर्नुहोस् (CSV)",
|
||||||
|
"worker": "कर्मचारी",
|
||||||
|
"totalHoursWorked": "कुल काम गरेको घण्टा",
|
||||||
|
"totalOtPay": "कुल OT भुक्तानी (RM)",
|
||||||
|
"rawAttendanceData": "कच्चा उपस्थिति डेटा",
|
||||||
|
"loadingReport": "रिपोर्ट लोड गर्दै...",
|
||||||
|
"tagLoadError": "चयनित ट्यागको लागि कर्मचारी लोड गर्न सकिएन।",
|
||||||
|
"generateReportError": "कृपया कर्मचारी चयन गर्नुहोस्, मान्य मिति दायरा सेट गर्नुहोस्, र तलब प्रविष्ट गर्नुहोस्।",
|
||||||
|
"reportGenerationError": "रिपोर्ट उत्पन्न गर्दा त्रुटि भयो।",
|
||||||
|
|
||||||
|
"addNewUser": "नयाँ प्रयोगकर्ता थप्नुहोस्",
|
||||||
|
"fullName": "पूरा नाम",
|
||||||
|
"egJohnSmith": "जस्तै जोन स्मिथ",
|
||||||
|
"egJsmith": "जस्तै jsmith",
|
||||||
|
"eg123456": "जस्तै १२३४५६",
|
||||||
|
"asManager": "प्रबन्धकको रूपमा",
|
||||||
|
"adding": "थप्दै...",
|
||||||
|
"addUser": "प्रयोगकर्ता थप्नुहोस्",
|
||||||
|
"manageTags": "ट्यागहरू व्यवस्थापन गर्नुहोस्",
|
||||||
|
"createNewTag": "नयाँ ट्याग सिर्जना गर्नुहोस्",
|
||||||
|
"egTeam": "जस्तै टिम",
|
||||||
|
"createTag": "ट्याग सिर्जना गर्नुहोस्",
|
||||||
|
"tags": "ट्यागहरू",
|
||||||
|
"workerRoster": "कर्मचारी सूची",
|
||||||
|
"searchByNameOrUsername": "नाम वा प्रयोगकर्ता नामद्वारा खोज्नुहोस्",
|
||||||
|
"filterByTag": "ट्यागद्वारा फिल्टर गर्नुहोस्",
|
||||||
|
"clearFilter": "फिल्टर हटाउनुहोस्",
|
||||||
|
"dateJoined": "सामेल भएको मिति",
|
||||||
|
"actions": "कार्यहरू",
|
||||||
|
"editTags": "ट्यागहरू सम्पादन गर्नुहोस्",
|
||||||
|
"viewRecords": "रेकर्डहरू हेर्नुहोस्",
|
||||||
|
"delete": "मेटाउनुहोस्",
|
||||||
|
"loadingWorkers": "कर्मचारी लोड गर्दै...",
|
||||||
|
"noWorkersFound": "कुनै कर्मचारी भेटिएन।",
|
||||||
|
"previous": "अघिल्लो",
|
||||||
|
"next": "अर्को",
|
||||||
|
"pageOf": "पृष्ठ {current} को {total}",
|
||||||
|
"noTagsAvailable": "कुनै ट्याग उपलब्ध छैन।",
|
||||||
|
"done": "सकियो",
|
||||||
|
"bulkEditTags": "बल्क ट्याग सम्पादन",
|
||||||
|
"clearSelection": "चयन हटाउनुहोस्",
|
||||||
|
"forUser": "प्रयोगकर्ताको लागि",
|
||||||
|
"savePassword": "पासवर्ड सेभ गर्नुहोस्",
|
||||||
|
"saving": "सेभ गर्दै...",
|
||||||
|
"failedToUpdateTags": "ट्यागहरू अपडेट गर्न सकिएन। कृपया पुनः प्रयास गर्नुहोस्।",
|
||||||
|
"tagDeleted": "ट्याग सफलतापूर्वक मेटाइयो।",
|
||||||
|
"failedToFetchWorkers": "कर्मचारी फेच गर्न सकिएन।",
|
||||||
|
"failedToLoadPageData": "पृष्ठ डेटा लोड गर्न सकिएन।",
|
||||||
|
"errorAddingUser": "प्रयोगकर्ता थप्दा त्रुटि भयो।",
|
||||||
|
"failedToDeleteWorker": "कर्मचारी मेटाउन सकिएन।",
|
||||||
|
"areYouSureDeleteWorker": "के तपाईं यो कर्मचारी खाता मेटाउन निश्चित हुनुहुन्छ?",
|
||||||
|
"areYouSureDeleteTag": "के तपाईं यो ट्याग मेटाउन निश्चित हुनुहुन्छ? यसले सबै कर्मचारीहरूबाट यसलाई हटाउनेछ।",
|
||||||
|
"failedToDeleteTag": "ट्याग मेटाउन सकिएन।",
|
||||||
|
"passwordsDoNotMatch": "पासवर्डहरू मेल खाँदैनन्।",
|
||||||
|
"createQrCode": "नयाँ QR कोड सिर्जना गर्नुहोस्",
|
||||||
|
"qrCodeName": "QR कोड नाम",
|
||||||
|
"qrNamePlaceholder": "जस्तै, 'पश्चिम गेट प्रवेश'",
|
||||||
|
"create": "सिर्जना गर्नुहोस्",
|
||||||
|
"newCodeCreated": "नयाँ कोड सिर्जना भयो!",
|
||||||
|
"saveQrInstruction": "यो छवि सेभ गर्नुहोस् वा तलको ID प्रयोग गर्नुहोस्। यो रिफ्रेसमा हराउनेछ।",
|
||||||
|
"id": "ID",
|
||||||
|
"existingQrCodes": "अवस्थित QR कोडहरू",
|
||||||
|
"name": "नाम",
|
||||||
|
"status": "स्थिति",
|
||||||
|
"deactivate": "निष्क्रिय पार्नुहोस्",
|
||||||
|
"activate": "सक्रिय पार्नुहोस्",
|
||||||
|
"download": "डाउनलोड गर्नुहोस्",
|
||||||
|
"noQrCodesFound": "कुनै QR कोडहरू भेटिएन। माथि एउटा सिर्जना गर्नुहोस्!",
|
||||||
|
"deleteQrConfirm": "के तपाईं यो QR कोड मेटाउन निश्चित हुनुहुन्छ? यो पूर्ववत गर्न सकिँदैन।",
|
||||||
|
"qrDownloadError": "माफ गर्नुहोस्, QR कोड डाउनलोड गर्न सकिएन।",
|
||||||
|
|
||||||
|
"rememberMe": "अटो-लगइनको लागि मलाई सम्झनुहोस्",
|
||||||
|
"deviceNotAuthorized": "यो उपकरण तपाईंको खाताको लागि प्राधिकृत छैन। कृपया आफ्नो व्यवस्थापकलाई सम्पर्क गर्नुहोस्।",
|
||||||
|
"locationTrackingActive": "पृष्ठभूमिमा स्थान ट्र्याकिङ सक्रिय छ",
|
||||||
|
"securityCheckInProgress": "सुरक्षा जाँच भइरहेको छ...",
|
||||||
|
"securityCheckComplete": "सुरक्षा जाँच सफलतापूर्वक सम्पन्न भयो",
|
||||||
|
"highSecurityRisk": "उच्च सुरक्षा जोखिम पत्ता लाग्यो। कृपया आफ्नो व्यवस्थापकलाई सम्पर्क गर्नुहोस्।",
|
||||||
|
"deviceRegistered": "उपकरण सफलतापूर्वक दर्ता भयो",
|
||||||
|
"autoLoginEnabled": "यस उपकरणको लागि अटो-लगइन सक्षम पारियो",
|
||||||
|
"backgroundLocationEnabled": "पृष्ठभूमि स्थान ट्र्याकिङ सक्षम पारियो",
|
||||||
|
"permissionsRequired": "उपस्थिति ट्र्याकिङको लागि स्थान अनुमतिहरू आवश्यक छ",
|
||||||
|
"batteryOptimizationWarning": "निरन्तर स्थान ट्र्याकिङ सुनिश्चित गर्न कृपया यस एपको लागि ब्याट्री अप्टिमाइजेसन अक्षम पार्नुहोस्",
|
||||||
|
"gpsSpooferDetected": "GPS स्पूफिङ एप्लिकेसन पत्ता लाग्यो। यसले उपस्थिति शुद्धतालाई असर गर्न सक्छ।",
|
||||||
|
"mockLocationEnabled": "नक्कली स्थान सक्षम छ। सही उपस्थिति ट्र्याकिङको लागि कृपया यसलाई अक्षम पार्नुहोस्।",
|
||||||
|
"deviceSecurityWarning": "उपकरण सुरक्षा चेतावनी: संदिग्ध एप्लिकेसनहरू पत्ता लाग्यो",
|
||||||
|
"locationUpdateFailed": "स्थान अपडेट गर्न सकिएन। स्वचालित रूपमा पुनः प्रयास गर्नेछ।",
|
||||||
|
"servicesInitializing": "मूल सेवाहरू प्रारम्भ गर्दै...",
|
||||||
|
"servicesReady": "सबै सेवाहरू तयार छन्",
|
||||||
|
"autoLoginFailed": "अटो-लगइन असफल। कृपया म्यानुअल रूपमा लगइन गर्नुहोस्।",
|
||||||
|
"deviceValidationFailed": "उपकरण प्रमाणीकरण असफल। कृपया सहयोगलाई सम्पर्क गर्नुहोस्।",
|
||||||
|
"deviceMismatch": "यो उपकरण तपाईंको खाताको लागि अधिकृत छैन।",
|
||||||
|
"deviceRegistrationFailed": "उपकरण दर्ता असफल। फेरि प्रयास गर्नुहोस्।",
|
||||||
|
"deviceRequired": "कामदार लगइनको लागि उपकरण दर्ता आवश्यक छ।",
|
||||||
|
|
||||||
|
"servicesStatus": "सेवाहरूको स्थिति",
|
||||||
|
"overallStatus": "समग्र स्थिति",
|
||||||
|
"locationTracking": "स्थान ट्र्याकिङ",
|
||||||
|
"deviceRegistration": "उपकरण दर्ता",
|
||||||
|
"securityStatus": "सुरक्षा स्थिति",
|
||||||
|
"lastLocationUpdate": "अन्तिम स्थान अपडेट",
|
||||||
|
"deviceId": "उपकरण ID",
|
||||||
|
"start": "सुरु गर्नुहोस्",
|
||||||
|
"check": "जाँच गर्नुहोस्",
|
||||||
|
"checking": "जाँच गर्दै...",
|
||||||
|
"refresh": "रिफ्रेस गर्नुहोस्",
|
||||||
|
"refreshing": "रिफ्रेस गर्दै...",
|
||||||
|
"notInitialized": "प्रारम्भ गरिएको छैन",
|
||||||
|
"ready": "तयार",
|
||||||
|
"webOnly": "वेब मात्र",
|
||||||
|
"active": "सक्रिय",
|
||||||
|
"inactive": "निष्क्रिय",
|
||||||
|
"registered": "दर्ता गरिएको",
|
||||||
|
"pending": "पेन्डिङ",
|
||||||
|
"notChecked": "जाँच गरिएको छैन",
|
||||||
|
"outdated": "पुरानो",
|
||||||
|
"current": "वर्तमान",
|
||||||
|
"never": "कहिल्यै छैन",
|
||||||
|
"justNow": "भर्खरै",
|
||||||
|
"minutesAgo": "{minutes} मिनेट अघि",
|
||||||
|
"hoursAgo": "{hours} घण्टा अघि",
|
||||||
|
"daysAgo": "{days} दिन अघि",
|
||||||
|
"failedToRefreshStatus": "स्थिति रिफ्रेस गर्न सकिएन",
|
||||||
|
"locationTrackingStarted": "स्थान ट्र्याकिङ सफलतापूर्वक सुरु भयो",
|
||||||
|
"failedToStartLocationTracking": "स्थान ट्र्याकिङ सुरु गर्न सकिएन",
|
||||||
|
"securityCheckFailed": "सुरक्षा जाँच असफल",
|
||||||
|
|
||||||
|
"personal": "व्यक्तिगत",
|
||||||
|
"clockHistory": "उपस्थिति इतिहास",
|
||||||
|
"openCamera": "क्यामेरा खोल्नुहोस्",
|
||||||
|
"scanQRCode": "QR कोड स्क्यान गर्नुहोस्",
|
||||||
|
"services": "सेवाहरू",
|
||||||
|
"systemServicesStatus": "प्रणाली सेवाहरू र सुरक्षा स्थिति",
|
||||||
|
"updateYourPassword": "तपाईंको खाताको पासवर्ड अपडेट गर्नुहोस्",
|
||||||
|
"signOutOfAccount": "तपाईंको खाताबाट साइन आउट गर्नुहोस्",
|
||||||
|
|
||||||
|
"workLocationTracking": "कार्य स्थान ट्र्याकिङ",
|
||||||
|
"locationTrackingForAttendance": "कार्य उपस्थितिको लागि स्थान ट्र्याकिङ सक्रिय",
|
||||||
|
"monitoringLocation": "कार्य उपस्थितिको लागि स्थान निगरानी गर्दै",
|
||||||
|
|
||||||
|
"manualGuide": "म्यानुअल गाइड",
|
||||||
|
"viewUserManual": "निर्देशनहरू र FAQs पढ्नुहोस्",
|
||||||
|
"manual": {
|
||||||
|
"android": {
|
||||||
|
"heading": "Android",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"id": "android-location",
|
||||||
|
"title": "Location कसरी खोल्ने (Android)",
|
||||||
|
"steps": [
|
||||||
|
"आफ्नो फोनमा <strong>Settings</strong> खोल्नुहोस्।",
|
||||||
|
"<strong>Location</strong> मा जानुहोस् <span class=\"text-sm text-gray-500\">(केहि फोनहरूमा <em>Security & privacy</em> अन्तर्गत)</span>।",
|
||||||
|
"<strong>Use location</strong> ON गर्नुहोस्।",
|
||||||
|
"<strong>App permissions</strong> खोल्नुहोस् → <strong>Attendance System</strong> फेला पार्नुहोस् → <strong>Allow while using the app</strong> सेट गर्नुहोस्।",
|
||||||
|
"उपलब्ध भए <strong>Precise location</strong> सक्षम गर्नुहोस्।",
|
||||||
|
"App मा फर्कनुहोस् र फेरि clock-in प्रयास गर्नुहोस्।"
|
||||||
|
],
|
||||||
|
"note": "ब्रान्ड अनुसार नामहरू फरक हुन्छन्: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services।"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "android-camera",
|
||||||
|
"title": "Camera permission सक्षम गर्नुहोस् (Android)",
|
||||||
|
"steps": [
|
||||||
|
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> खोल्नुहोस्।",
|
||||||
|
"<strong>Permissions</strong> → <strong>Camera</strong> ट्याप गर्नुहोस् → <strong>Allow</strong> वा <strong>Allow while using the app</strong> छान्नुहोस्।",
|
||||||
|
"App पुन: खोल्नुहोस् र फेरि scanning प्रयास गर्नुहोस्।"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clockin-troubleshoot",
|
||||||
|
"title": "Clock-in काम गरिरहेको छैन? द्रुत checklist",
|
||||||
|
"steps": [
|
||||||
|
"<strong>Location</strong> ON गर्नुहोस् र app permission लाई <strong>Allow while using the app</strong> सेट गर्नुहोस् (उपलब्ध भए <strong>Precise location</strong> सक्षम गर्नुहोस्)।",
|
||||||
|
"Network जाँच गर्नुहोस्: Wi-Fi वा data खुला छ। <strong>Airplane mode</strong> off→on toggle गर्नुहोस्, त्यसपछि फेरि प्रयास गर्नुहोस्। हस्तक्षेप गरेमा VPN निष्क्रिय गर्नुहोस्।",
|
||||||
|
"Android settings मा <strong>Automatic date & time</strong> र <strong>time zone</strong> सक्षम छन् भनी सुनिश्चित गर्नुहोस्।",
|
||||||
|
"Force close गर्नुहोस् र app पुन: खोल्नुहोस्। आवश्यक भएमा, <strong>Attendance System</strong> cache clear गर्नुहोस् (Settings → Apps → Attendance System → Storage → Clear cache)।"
|
||||||
|
],
|
||||||
|
"note": "अझै अड्किनु भयो? Screenshot लिनुहोस् र आफ्नो manager वा HR लाई सम्पर्क गर्नुहोस्।"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"heading": "iOS",
|
||||||
|
"comingSoon": "चाँडै आउँदैछ।"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"statusClockedIn": "तपाईं क्लक इन हुनुभएको छ",
|
||||||
|
"statusClockedOut": "तपाईं क्लक आउट हुनुभएको छ",
|
||||||
|
"scanToClockIn": "क्लक इन गर्न QR कोड स्क्यान गर्नुहोस्",
|
||||||
|
"scanToClockOut": "क्लक आउट गर्न QR कोड स्क्यान गर्नुहोस्",
|
||||||
|
"appInformation": "एप जानकारी",
|
||||||
|
"version": "संस्करण",
|
||||||
|
"platform": "प्लेटफर्म",
|
||||||
|
"web": "वेब"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
{
|
||||||
|
"appTitle": "வருகை அமைப்பு",
|
||||||
|
"logout": "வெளியேறு",
|
||||||
|
"login": "உள்நுழை",
|
||||||
|
"username": "பயனர் பெயர்",
|
||||||
|
"password": "கடவுச்சொல்",
|
||||||
|
"loggingIn": "உள்நுழைகிறது...",
|
||||||
|
"language": "மொழி",
|
||||||
|
"darkMode": "இருண்ட பயன்முறை",
|
||||||
|
"toggleDarkMode": "வெளிச்சம் மற்றும் இருண்ட தீம்களுக்கு இடையில் மாற்றவும்",
|
||||||
|
"failedConnection": "சர்வருடன் இணைக்க முடியவில்லை.",
|
||||||
|
"invalidToken": "சர்வரிலிருந்து தவறான டோக்கன் பெறப்பட்டது.",
|
||||||
|
"invalidCredentials": "தவறான பயனர் பெயர் அல்லது கடவுச்சொல்.",
|
||||||
|
"english": "ஆங்கிலம்",
|
||||||
|
"malay": "மலாய்",
|
||||||
|
"tamil": "தமிழ்",
|
||||||
|
"bengali": "பெங்காலி",
|
||||||
|
"burmese": "பர்மா",
|
||||||
|
"nepali": "நேபாளி",
|
||||||
|
|
||||||
|
"yourStatus": "நிலைமை",
|
||||||
|
"clockedIn": "வருகை பதிவு செய்யப்பட்டது",
|
||||||
|
"clockedOut": "வெளியேறல் பதிவு செய்யப்பட்டது",
|
||||||
|
"clockIn": "வருகை பதிவு",
|
||||||
|
"clockOut": "வெளியேறல் பதிவு",
|
||||||
|
"clock_in": "வருகை பதிவு",
|
||||||
|
"clock_out": "வெளியேறல் பதிவு",
|
||||||
|
"scanToClock": "{action} பதிவு செய்ய ஸ்கேன் செய்யவும்",
|
||||||
|
"in": "வருகை",
|
||||||
|
"out": "வெளியேறல்",
|
||||||
|
"cancel": "ரத்து",
|
||||||
|
"viewMyClockHistory": "என் வருகை வரலாற்றைப் பார்க்கவும்",
|
||||||
|
"changeMyPassword": "என் கடவுச்சொல்லை மாற்றவும்",
|
||||||
|
"myClockHistory": "என் வருகை வரலாறு",
|
||||||
|
"backToDashboard": "டாஷ்போர்டுக்குத் திரும்பு",
|
||||||
|
"noClockHistory": "உங்களுக்கு வருகை வரலாறு இல்லை.",
|
||||||
|
"clockHistoryFetchFail": "வருகை வரலாற்றை பெற முடியவில்லை:",
|
||||||
|
"viewClockHistory": "என் வருகை வரலாற்றைப் பார்க்கவும்",
|
||||||
|
"changePassword": "என் கடவுச்சொல்லை மாற்றவும்",
|
||||||
|
"invalidCurrentPassword": "தற்போதைய கடவுச்சொல் தவறானது.",
|
||||||
|
"successClockIn": "வெற்றிகரமாக வருகை பதிவு செய்யப்பட்டது.",
|
||||||
|
"successClockOut": "வெற்றிகரமாக வெளியேறல் பதிவு செய்யப்பட்டது.",
|
||||||
|
"qrFail": "QR கோட்டைக் கண்டறிய முடியவில்லை. தயவுசெய்து மீண்டும் முயற்சிக்கவும்.",
|
||||||
|
"geoFail": "உங்கள் இருப்பிடத்தைப் பெற முடியவில்லை: {message}. தயவுசெய்து இருப்பிட சேவைகளை இயக்கவும்.",
|
||||||
|
"successClock": "{location} இல் வெற்றிகரமாக பதிவு செய்யப்பட்டது.",
|
||||||
|
"changePasswordTitle": "கடவுச்சொல்லை மாற்று",
|
||||||
|
"currentPassword": "தற்போதைய கடவுச்சொல்",
|
||||||
|
"newPassword": "புதிய கடவுச்சொல்",
|
||||||
|
"confirmNewPassword": "புதிய கடவுச்சொல்லை உறுதிப்படுத்து",
|
||||||
|
"updating": "புதுப்பிக்கிறது...",
|
||||||
|
"tabPersonnel": "பணியாளர்கள்",
|
||||||
|
"tabAttendance": "வருகை",
|
||||||
|
"tabQrCodes": "QR கோட்கள்",
|
||||||
|
"uploadQrImage": "QR படத்தைப் பதிவேற்று",
|
||||||
|
"couldNotLoadWorkerInfo": "பணியாளர் தகவலைப் பெற முடியவில்லை",
|
||||||
|
"couldNotVerifyStatus": "சர்வரிலிருந்து தற்போதைய நிலைமையை சரிபார்க்க முடியவில்லை",
|
||||||
|
"successfullyClocked": "வெற்றிகரமாக {action} பதிவு செய்யப்பட்டது",
|
||||||
|
"site": "இடத்தில்",
|
||||||
|
"errorOccurred": "பிழை நிகழ்ந்தது",
|
||||||
|
"unableToStartCamera": "கேமராவைத் தொடங்க முடியவில்லை.",
|
||||||
|
"tryAgain": "மீண்டும் முயற்சிக்கவும்",
|
||||||
|
"qrDetectedGettingLocation": "QR கோட் கண்டறியப்பட்டது. இருப்பிடத்தைப் பெறுகிறது...",
|
||||||
|
"geolocationNotSupported": "உங்கள் உலாவியால் புவியிருப்பிடம் ஆதரிக்கப்படவில்லை.",
|
||||||
|
"unableToRetrieveLocation": "உங்கள் இருப்பிடத்தைப் பெற முடியவில்லை: {message}. தயவுசெய்து இருப்பிட சேவைகளை இயக்கவும்.",
|
||||||
|
"qrNotDetectedTryAgain": "QR கோட்டைக் கண்டறிய முடியவில்லை. தயவுசெய்து மீண்டும் முயற்சிக்கவும்.",
|
||||||
|
"updatePassword": "கடவுச்சொல்லைப் புதுப்பிக்கவும்",
|
||||||
|
"passwordsNoMatch": "புதிய கடவுச்சொற்கள் பொருந்தவில்லை.",
|
||||||
|
"passwordTooShort": "புதிய கடவுச்சொல் குறைந்தது 6 எழுத்துக்களாக இருக்க வேண்டும்.",
|
||||||
|
"passwordUpdated": "கடவுச்சொல் வெற்றிகரமாக புதுப்பிக்கப்பட்டது! நீங்கள் இப்போது உங்கள் புதிய கடவுச்சொல்லை உள்நுழைய பயன்படுத்தலாம்.",
|
||||||
|
"passwordUpdateError": "கடவுச்சொல்லை புதுப்பிக்கும்போது பிழை ஏற்பட்டது.",
|
||||||
|
"attendanceLogFor": "வருகை பதிவு -",
|
||||||
|
"addManualClockOut": "கைமுறை வெளியேறல் பதிவு சேர்க்கவும்",
|
||||||
|
"manualClockOutInstruction": "பணியாளர் வெளியேறல் பதிவு செய்ய மறந்தால் இந்த படிவத்தைப் பயன்படுத்தவும். கடைசி நிகழ்வு வருகை பதிவாக இருக்க வேண்டும்.",
|
||||||
|
"clockOutTime": "வெளியேறல் நேரம்",
|
||||||
|
"reason": "காரணம் (எ.கா., \"வெளியேறல் பதிவு செய்ய மறந்துவிட்டார்\")",
|
||||||
|
"enterBriefNote": "சுருக்கமான குறிப்பை உள்ளிடவும்",
|
||||||
|
"addRecord": "பதிவு சேர்க்கவும்",
|
||||||
|
"startDate": "தொடக்க தேதி",
|
||||||
|
"endDate": "இறுதி தேதி",
|
||||||
|
"filterRecords": "பதிவுகளை வடிகட்டு",
|
||||||
|
"event": "நிகழ்வு",
|
||||||
|
"timestamp": "நேர முத்திரை",
|
||||||
|
"locationName": "இடத்தின் பெயர்",
|
||||||
|
"coordinates": "ஆயத்தொலைவுகள்",
|
||||||
|
"notes": "குறிப்புகள்",
|
||||||
|
"noRecordsFound": "இந்த காலத்திற்கான பதிவுகள் எதுவும் கிடைக்கவில்லை.",
|
||||||
|
"showOnMap": "வரைபடத்தில் காட்டு",
|
||||||
|
"nA": "கிடையாது",
|
||||||
|
"pleaseSelectTimestamp": "தயவுசெய்து வெளியேறலுக்கான நேர முத்திரையைத் தேர்ந்தெடுக்கவும்.",
|
||||||
|
"pleaseProvideReason": "தயவுசெய்து கைமுறை பதிவிற்கான காரணம்/குறிப்பை வழங்கவும்.",
|
||||||
|
"manualClockOutSuccess": "கைமுறை வெளியேறல் வெற்றிகரமாக பதிவு செய்யப்பட்டது!",
|
||||||
|
"manualClockOutError": "பிழை ஏற்பட்டது: {message}",
|
||||||
|
"selectWorkers": "1. பணியாளர்களைத் தேர்ந்தெடுக்கவும்",
|
||||||
|
"searchWorkerPlaceholder": "ஒரு பணியாளரைத் தேடவும்...",
|
||||||
|
"selectAll": "அனைத்தையும் தேர்ந்தெடு",
|
||||||
|
"addWorkersByTag": "ஒரு டேக்கிலிருந்து அனைத்து பணியாளர்களையும் சேர்க்கவும்",
|
||||||
|
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
|
||||||
|
"addByTag": "டேக் மூலம் சேர்க்கவும்",
|
||||||
|
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
|
||||||
|
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
|
||||||
|
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
|
||||||
|
"reportSettings": "2. அறிக்கை அமைப்புகள்",
|
||||||
|
"setting": "அமைப்பு",
|
||||||
|
"monthlySalary": "மாதச் சம்பளம் (RM)",
|
||||||
|
"salaryAppliedNote": "தேர்ந்தெடுக்கப்பட்ட அனைத்து பணியாளர்களுக்கும் பயன்படுத்தப்படும்.",
|
||||||
|
"salaryPlaceholder": "எ.கா., 3000",
|
||||||
|
"otFactors": "மேல்நேர காரணிகள்",
|
||||||
|
"weekendFactor": "வாரக்கடைசி காரணி",
|
||||||
|
"holidayFactor": "விடுமுறை காரணி",
|
||||||
|
"selectPublicHolidays": "பொது விடுமுறைகளைத் தேர்ந்தெடுக்கவும்",
|
||||||
|
"generateReport": "வருகை & மேல்நேர அறிக்கையை உருவாக்கு",
|
||||||
|
"overtimePaySummary": "மேல்நேர ஊதிய சுருக்கம்",
|
||||||
|
"exportOtSummary": "மேல்நேர சுருக்கத்தை ஏற்றுமதி செய் (CSV)",
|
||||||
|
"worker": "பணியாளர்",
|
||||||
|
"totalHoursWorked": "மொத்த வேலை நேரங்கள்",
|
||||||
|
"totalOtPay": "மொத்த மேல்நேர ஊதியம் (RM)",
|
||||||
|
"rawAttendanceData": "மூல வருகை தரவு",
|
||||||
|
"loadingReport": "அறிக்கை ஏற்றுகிறது...",
|
||||||
|
"tagLoadError": "தேர்ந்தெடுக்கப்பட்ட டேக்கிற்கான பணியாளர்களை ஏற்ற முடியவில்லை.",
|
||||||
|
"generateReportError": "தயவுசெய்து பணியாளர்களைத் தேர்ந்தெடுத்து, சரியான தேதி வரம்பை அமைத்து, சம்பளத்தை உள்ளிடவும்.",
|
||||||
|
"reportGenerationError": "அறிக்கையை உருவாக்கும்போது பிழை ஏற்பட்டது.",
|
||||||
|
"addNewUser": "புதிய பயனரைச் சேர்க்கவும்",
|
||||||
|
"fullName": "முழு பெயர்",
|
||||||
|
"egJohnSmith": "எ.கா. John Smith",
|
||||||
|
"egJsmith": "எ.கா. jsmith",
|
||||||
|
"eg123456": "எ.கா. 123456",
|
||||||
|
"asManager": "மேலாளராக",
|
||||||
|
"adding": "சேர்க்கிறது...",
|
||||||
|
"addUser": "பயனரைச் சேர்க்கவும்",
|
||||||
|
"manageTags": "டேக்குகளை நிர்வகிக்கவும்",
|
||||||
|
"createNewTag": "புதிய டேக் உருவாக்கவும்",
|
||||||
|
"egTeam": "எ.கா. குழு",
|
||||||
|
"createTag": "டேக் உருவாக்கவும்",
|
||||||
|
"tags": "டேக்குகள்",
|
||||||
|
"workerRoster": "பணியாளர் பட்டியல்",
|
||||||
|
"searchByNameOrUsername": "பெயர் அல்லது பயனர் பெயர் மூலம் தேடவும்",
|
||||||
|
"filterByTag": "டேக் மூலம் வடிகட்டவும்",
|
||||||
|
"clearFilter": "வடிகட்டியைத் துடைக்கவும்",
|
||||||
|
"dateJoined": "சேர்ந்த தேதி",
|
||||||
|
"actions": "செயல்கள்",
|
||||||
|
"editTags": "டேக்குகளைத் திருத்து",
|
||||||
|
"viewRecords": "பதிவுகளைப் பார்க்கவும்",
|
||||||
|
"delete": "நீக்கு",
|
||||||
|
"loadingWorkers": "பணியாளர்கள் ஏற்றப்படுகிறது...",
|
||||||
|
"noWorkersFound": "பணியாளர்கள் எதுவும் கிடைக்கவில்லை.",
|
||||||
|
"previous": "முந்தைய",
|
||||||
|
"next": "அடுத்த",
|
||||||
|
"pageOf": "பக்கம் {current} / {total}",
|
||||||
|
"noTagsAvailable": "டேக்குகள் எதுவும் கிடைக்கவில்லை.",
|
||||||
|
"done": "முடிந்தது",
|
||||||
|
"bulkEditTags": "பல டேக்குகளைத் திருத்து",
|
||||||
|
"clearSelection": "தேர்வைத் துடைக்கவும்",
|
||||||
|
"forUser": "பயனருக்கு",
|
||||||
|
"savePassword": "கடவுச்சொல்லைச் சேமிக்கவும்",
|
||||||
|
"saving": "சேமிக்கிறது...",
|
||||||
|
"failedToUpdateTags": "டேக்குகளை புதுப்பிக்க முடியவில்லை. தயவுசெய்து மீண்டும் முயற்சிக்கவும்.",
|
||||||
|
"tagDeleted": "டேக் வெற்றிகரமாக நீக்கப்பட்டது.",
|
||||||
|
"failedToFetchWorkers": "பணியாளர்களைப் பெற முடியவில்லை.",
|
||||||
|
"failedToLoadPageData": "பக்க தரவை ஏற்ற முடியவில்லை.",
|
||||||
|
"errorAddingUser": "பயனரைச் சேர்க்கும்போது பிழை ஏற்பட்டது.",
|
||||||
|
"failedToDeleteWorker": "பணியாளரை நீக்க முடியவில்லை.",
|
||||||
|
"areYouSureDeleteWorker": "இந்த பணியாளர் கணக்கை நீக்க நீங்கள் உறுதியாக உள்ளீர்களா?",
|
||||||
|
"areYouSureDeleteTag": "இந்த டேக்கை நீக்க நீங்கள் உறுதியாக உள்ளீர்களா? இது அனைத்து பணியாளர்களிடமிருந்தும் அகற்றப்படும்.",
|
||||||
|
"failedToDeleteTag": "டேக்கை நீக்க முடியவில்லை.",
|
||||||
|
"passwordsDoNotMatch": "கடவுச்சொற்கள் பொருந்தவில்லை.",
|
||||||
|
"createQrCode": "புதிய QR கோட் உருவாக்கவும்",
|
||||||
|
"qrCodeName": "QR கோட்டின் பெயர்",
|
||||||
|
"qrNamePlaceholder": "எ.கா., 'மேற்கு வாயில் நுழைவாயில்'",
|
||||||
|
"create": "உருவாக்கு",
|
||||||
|
"newCodeCreated": "புதிய கோட் உருவாக்கப்பட்டது!",
|
||||||
|
"saveQrInstruction": "இந்த படத்தைச் சேமிக்கவும் அல்லது கீழே உள்ள ID ஐப் பயன்படுத்தவும். இது புதுப்பிப்பில் மறைந்துவிடும்.",
|
||||||
|
"id": "ஐடி",
|
||||||
|
"existingQrCodes": "ஏற்கனவே உள்ள QR கோட்கள்",
|
||||||
|
"name": "பெயர்",
|
||||||
|
"status": "நிலைமை",
|
||||||
|
"deactivate": "செயலிழக்கச் செய்",
|
||||||
|
"activate": "செயல்படுத்து",
|
||||||
|
"download": "பதிவிறக்கு",
|
||||||
|
"noQrCodesFound": "QR கோட்கள் எதுவும் கிடைக்கவில்லை. மேலே ஒன்றை உருவாக்கவும்!",
|
||||||
|
"deleteQrConfirm": "இந்த QR கோட்டை நீக்க நீங்கள் உறுதியாக உள்ளீர்களா? இதை மாற்ற முடியாது.",
|
||||||
|
"qrDownloadError": "மன்னிக்கவும், QR கோட்டைப் பதிவிறக்க முடியவில்லை.",
|
||||||
|
"rememberMe": "தானியங்கு உள்நுழைவுக்காக என்னை நினைவில் வைக்கவும்",
|
||||||
|
"deviceNotAuthorized": "இந்த சாதனம் உங்கள் கணக்குக்கு அங்கீகரிக்கப்படவில்லை. தயவுசெய்து உங்கள் நிர்வாகியைத் தொடர்பு கொள்ளவும்.",
|
||||||
|
"locationTrackingActive": "இருப்பிட கண்காணிப்பு பின்னணியில் செயல்படுகிறது",
|
||||||
|
"securityCheckInProgress": "பாதுகாப்பு சரிபார்ப்பு நடைபெறுகிறது...",
|
||||||
|
"securityCheckComplete": "பாதுகாப்பு சரிபார்ப்பு வெற்றிகரமாக முடிவடைந்தது",
|
||||||
|
"highSecurityRisk": "அதிக பாதுகாப்பு அபாயம் கண்டறியப்பட்டது. தயவுசெய்து உங்கள் நிர்வாகியைத் தொடர்பு கொள்ளவும்.",
|
||||||
|
"deviceRegistered": "சாதனம் வெற்றிகரமாக பதிவு செய்யப்பட்டது",
|
||||||
|
"autoLoginEnabled": "இந்த சாதனத்திற்கு தானியங்கு உள்நுழைவு இயக்கப்பட்டது",
|
||||||
|
"backgroundLocationEnabled": "பின்னணி இருப்பிட கண்காணிப்பு இயக்கப்பட்டது",
|
||||||
|
"permissionsRequired": "வருகை கண்காணிப்புக்கு இருப்பிட அனுமதிகள் தேவை",
|
||||||
|
"batteryOptimizationWarning": "தொடர்ச்சியான இருப்பிட கண்காணிப்பை உறுதிப்படுத்த இந்த பயன்பாட்டிற்கு பேட்டரி மேம்படுத்தலை முடக்கவும்",
|
||||||
|
"gpsSpooferDetected": "GPS போலிப் பயன்பாடு கண்டறியப்பட்டது. இது வருகை துல்லியத்தைப் பாதிக்கலாம்.",
|
||||||
|
"mockLocationEnabled": "போலி இருப்பிடம் இயக்கப்பட்டுள்ளது. துல்லியமான வருகை கண்காணிப்புக்கு அதை முடக்கவும்.",
|
||||||
|
"deviceSecurityWarning": "சாதன பாதுகாப்பு எச்சரிக்கை: சந்தேகத்திற்குரிய பயன்பாடுகள் கண்டறியப்பட்டன",
|
||||||
|
"locationUpdateFailed": "இருப்பிடத்தை புதுப்பிக்க முடியவில்லை. தானாகவே மீண்டும் முயற்சிக்கும்.",
|
||||||
|
"servicesInitializing": "அடிப்படை சேவைகளை துவக்குகிறது...",
|
||||||
|
"servicesReady": "அனைத்து சேவைகளும் தயாராக உள்ளன",
|
||||||
|
"autoLoginFailed": "தானியங்கு உள்நுழைவு தோல்வி. தயவுசெய்து கைமுறையாக உள்நுழைக.",
|
||||||
|
"deviceValidationFailed": "சாதன சரிபார்ப்பு தோல்வி. தயவுசெய்து ஆதரவைத் தொடர்பு கொள்ளவும்.",
|
||||||
|
"deviceMismatch": "இந்த சாதனம் உங்கள் கணக்கிற்கு அங்கீகரிக்கப்படவில்லை.",
|
||||||
|
"deviceRegistrationFailed": "சாதன பதிவு தோல்வி. மீண்டும் முயற்சிக்கவும்.",
|
||||||
|
"deviceRequired": "தொழிலாளர் உள்நுழைவிற்கு சாதன பதிவு தேவை.",
|
||||||
|
"servicesStatus": "சேவைகளின் நிலை",
|
||||||
|
"overallStatus": "ஒட்டுமொத்த நிலை",
|
||||||
|
"locationTracking": "இருப்பிட கண்காணிப்பு",
|
||||||
|
"deviceRegistration": "சாதன பதிவு",
|
||||||
|
"securityStatus": "பாதுகாப்பு நிலை",
|
||||||
|
"lastLocationUpdate": "கடைசி இருப்பிட புதுப்பிப்பு",
|
||||||
|
"deviceId": "சாதன ஐடி",
|
||||||
|
"start": "தொடங்கு",
|
||||||
|
"check": "சரிபார்க்கவும்",
|
||||||
|
"checking": "சரிபார்க்கிறது...",
|
||||||
|
"refresh": "புதுப்பிக்கவும்",
|
||||||
|
"refreshing": "புதுப்பிக்கிறது...",
|
||||||
|
"notInitialized": "துவக்கப்படவில்லை",
|
||||||
|
"ready": "தயார்",
|
||||||
|
"webOnly": "வலை மட்டும்",
|
||||||
|
"active": "செயலில்",
|
||||||
|
"inactive": "செயலில் இல்லை",
|
||||||
|
"registered": "பதிவு செய்யப்பட்டது",
|
||||||
|
"pending": "நிலுவையில்",
|
||||||
|
"notChecked": "சரிபார்க்கப்படவில்லை",
|
||||||
|
"outdated": "காலாவதியானது",
|
||||||
|
"current": "தற்போதைய",
|
||||||
|
"never": "ஒருபோதும் இல்லை",
|
||||||
|
"justNow": "இப்போதுதான்",
|
||||||
|
"minutesAgo": "{minutes} நி முன்",
|
||||||
|
"hoursAgo": "{hours} மணி முன்",
|
||||||
|
"daysAgo": "{days} நா முன்",
|
||||||
|
"failedToRefreshStatus": "நிலையை புதுப்பிக்க முடியவில்லை",
|
||||||
|
"locationTrackingStarted": "இருப்பிட கண்காணிப்பு வெற்றிகரமாக தொடங்கப்பட்டது",
|
||||||
|
"failedToStartLocationTracking": "இருப்பிட கண்காணிப்பைத் தொடங்க முடியவில்லை",
|
||||||
|
"securityCheckFailed": "பாதுகாப்பு சரிபார்ப்பு தோல்வி",
|
||||||
|
"personal": "தனிப்பட்ட",
|
||||||
|
"clockHistory": "வருகை வரலாறு",
|
||||||
|
"openCamera": "கேமராவைத் திற",
|
||||||
|
"scanQRCode": "QR கோட் ஸ்கேன் செய்",
|
||||||
|
"services": "சேவைகள்",
|
||||||
|
"systemServicesStatus": "அமைப்பு சேவைகள் மற்றும் பாதுகாப்பு நிலை",
|
||||||
|
"updateYourPassword": "உங்கள் கணக்கு கடவுச்சொல்லை புதுப்பிக்கவும்",
|
||||||
|
"signOutOfAccount": "உங்கள் கணக்கிலிருந்து வெளியேறவும்",
|
||||||
|
"workLocationTracking": "பணியிட இருப்பிட கண்காணிப்பு",
|
||||||
|
"locationTrackingForAttendance": "பணி வருகைக்காக இருப்பிட கண்காணிப்பு செயலில்",
|
||||||
|
"monitoringLocation": "பணி வருகைக்காக இருப்பிடத்தைக் கண்காணிக்கிறது",
|
||||||
|
|
||||||
|
"manualGuide": "கையேடு வழிகாட்டி",
|
||||||
|
"viewUserManual": "வழிமுறைகள் மற்றும் FAQs ஐ படிக்கவும்",
|
||||||
|
"manual": {
|
||||||
|
"android": {
|
||||||
|
"heading": "Android",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"id": "android-location",
|
||||||
|
"title": "Location ஐ எப்படி திறப்பது (Android)",
|
||||||
|
"steps": [
|
||||||
|
"உங்கள் தொலைபேசியில் <strong>Settings</strong> ஐத் திறக்கவும்.",
|
||||||
|
"<strong>Location</strong> க்குச் செல்லவும் <span class=\"text-sm text-gray-500\">(சில தொலைபேசிகளில் <em>Security & privacy</em> கீழ்)</span>.",
|
||||||
|
"<strong>Use location</strong> ஐ ON செய்யவும்.",
|
||||||
|
"<strong>App permissions</strong> ஐத் திறக்கவும் → <strong>Attendance System</strong> ஐக் கண்டறியவும் → <strong>Allow while using the app</strong> என அமைக்கவும்.",
|
||||||
|
"கிடைத்தால் <strong>Precise location</strong> ஐ இயக்கவும்.",
|
||||||
|
"ஆப்பிற்குத் திரும்பி மீண்டும் clock-in செய்ய முயற்சிக்கவும்."
|
||||||
|
],
|
||||||
|
"note": "பிராண்டுகளுக்கு ஏற்ப பெயர்கள் மாறுபடும்: Samsung → Settings → Location → App permissions. Xiaomi → Settings → Location → Location services."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "android-camera",
|
||||||
|
"title": "Camera permission ஐ இயக்குவது எப்படி (Android)",
|
||||||
|
"steps": [
|
||||||
|
"<strong>Settings</strong> → <strong>Apps</strong> → <strong>Attendance System</strong> ஐத் திறக்கவும்.",
|
||||||
|
"<strong>Permissions</strong> → <strong>Camera</strong> ஐத் தட்டவும் → <strong>Allow</strong> அல்லது <strong>Allow while using the app</strong> தேர்ந்தெடுக்கவும்.",
|
||||||
|
"ஆப்பை மீண்டும் திறந்து scanning செய்ய முயற்சிக்கவும்."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clockin-troubleshoot",
|
||||||
|
"title": "Clock-in வேலை செய்யவில்லையா? விரைவு checklist",
|
||||||
|
"steps": [
|
||||||
|
"<strong>Location</strong> ஐ ON செய்து app permission ஐ <strong>Allow while using the app</strong> என அமைக்கவும் (கிடைத்தால் <strong>Precise location</strong> ஐ இயக்கவும்).",
|
||||||
|
"Network ஐ சரிபார்க்கவும்: Wi-Fi அல்லது data இயக்கத்தில் உள்ளது. <strong>Airplane mode</strong> ஐ off→on toggle செய்து, பின்னர் மீண்டும் முயற்சிக்கவும். தடையாக இருந்தால் VPN ஐ முடக்கவும்.",
|
||||||
|
"Android settings இல் <strong>Automatic date & time</strong> மற்றும் <strong>time zone</strong> இயக்கப்பட்டுள்ளதா என உறுதிப்படுத்தவும்.",
|
||||||
|
"Force close செய்து app ஐ மீண்டும் திறக்கவும். தேவைப்பட்டால், <strong>Attendance System</strong> cache ஐ clear செய்யவும் (Settings → Apps → Attendance System → Storage → Clear cache)."
|
||||||
|
],
|
||||||
|
"note": "இன்னும் சிக்கலா? Screenshot எடுத்து உங்கள் manager அல்லது HR ஐ தொடர்பு கொள்ளவும்."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"heading": "iOS",
|
||||||
|
"comingSoon": "விரைவில் வரும்."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"statusClockedIn": "நீங்கள் வேலைக்கு வந்துள்ளீர்கள்",
|
||||||
|
"statusClockedOut": "நீங்கள் வேலையை விட்டு வெளியேறியுள்ளீர்கள்",
|
||||||
|
"scanToClockIn": "வேலைக்கு வர QR குறியீட்டை ஸ்கேன் செய்யவும்",
|
||||||
|
"scanToClockOut": "வெளியேற QR குறியீட்டை ஸ்கேன் செய்யவும்",
|
||||||
|
"appInformation": "செயலி தகவல்",
|
||||||
|
"version": "பதிப்பு",
|
||||||
|
"platform": "தளம்"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ const app = createApp(App)
|
|||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
console.log("[DEBUG] i18n import in main.js:", i18n);
|
console.log("[DEBUG] i18n import in main.js:", i18n);
|
||||||
|
|||||||
+67
-35
@@ -1,71 +1,103 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import LoginView from '../views/LoginView.vue'
|
import Login from '../views/Login.vue'
|
||||||
// import WorkerDashboardView from '../views/WorkerDashboardView.vue'
|
import WorkerDashboard from '../views/WorkerDashboard.vue'
|
||||||
import ManagerDashboardView from '../views/ManagerDashboardView.vue'
|
import ManagerDashboard from '../views/ManagerDashboard.vue'
|
||||||
// import WorkerHistoryView from '../views/WorkerHistoryView.vue'
|
import WorkerHistory from '../views/WorkerHistory.vue'
|
||||||
import AttendanceRecordView from '../views/AttendanceRecordView.vue'
|
import ManagerAttendanceRecord from '../views/ManagerAttendanceRecord.vue'
|
||||||
// import ChangePasswordView from '../views/ChangePasswordView.vue'
|
import ManagerPermissions from '../components/ManagerPermissions.vue'
|
||||||
|
import WorkerChangePassword from '../views/WorkerChangePassword.vue'
|
||||||
|
import WorkerSettings from '../views/WorkerSettings.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'login', component: LoginView },
|
{ path: '/', name: 'login', component: Login },
|
||||||
// {
|
{
|
||||||
// path: '/worker/dashboard',
|
path: '/worker/dashboard',
|
||||||
// name: 'worker-dashboard',
|
name: 'worker-dashboard',
|
||||||
// component: WorkerDashboardView,
|
component: WorkerDashboard,
|
||||||
// meta: { requiresAuth: true, role: 'worker' },
|
meta: { requiresAuth: true, role: 'worker' },
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// path: '/worker/history',
|
path: '/worker/history',
|
||||||
// name: 'worker-history',
|
name: 'worker-history',
|
||||||
// component: WorkerHistoryView,
|
component: WorkerHistory,
|
||||||
// meta: { requiresAuth: true, role: 'worker' },
|
meta: { requiresAuth: true, role: 'worker' },
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// path: '/worker/change-password',
|
path: '/worker/change-password',
|
||||||
// name: 'worker-change-password',
|
name: 'worker-change-password',
|
||||||
// component: ChangePasswordView,
|
component: WorkerChangePassword,
|
||||||
// meta: { requiresAuth: true, role: 'worker' },
|
meta: { requiresAuth: true, role: 'worker' },
|
||||||
// },
|
},
|
||||||
|
{
|
||||||
|
path: '/worker/settings',
|
||||||
|
name: 'worker-settings',
|
||||||
|
component: WorkerSettings,
|
||||||
|
meta: { requiresAuth: true, role: 'worker' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/manager/dashboard',
|
path: '/manager/dashboard',
|
||||||
name: 'manager-dashboard',
|
name: 'manager-dashboard',
|
||||||
component: ManagerDashboardView,
|
component: ManagerDashboard,
|
||||||
meta: { requiresAuth: true, role: 'manager' },
|
meta: { requiresAuth: true, role: 'manager' },
|
||||||
},
|
},
|
||||||
// --- ADD THIS NEW ROUTE ---
|
|
||||||
{
|
{
|
||||||
path: '/manager/attendance/:workerId',
|
path: '/manager/attendance/:workerId',
|
||||||
name: 'manager-attendance-record',
|
name: 'manager-attendance-record',
|
||||||
component: AttendanceRecordView,
|
component: ManagerAttendanceRecord,
|
||||||
meta: { requiresAuth: true, role: 'manager' },
|
meta: { requiresAuth: true, role: 'manager' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/manager/permissions',
|
||||||
|
name: 'manager-permissions',
|
||||||
|
component: ManagerPermissions,
|
||||||
|
meta: { requiresAuth: true, role: 'manager' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/worker/manual-guide',
|
||||||
|
name: 'ManualGuide',
|
||||||
|
component: () => import('@/views/ManualGuide.vue'),
|
||||||
|
meta: { requiresAuth: true, role: 'worker' }
|
||||||
|
},
|
||||||
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- ALIGNMENT CHANGE: Navigation Guard ---
|
// Updated navigation guard to support both worker and manager roles
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const isLoggedIn = !!sessionStorage.getItem('userId')
|
const isLoggedIn = !!sessionStorage.getItem('userId')
|
||||||
const userRole = sessionStorage.getItem('userRole')
|
const userRole = sessionStorage.getItem('userRole')
|
||||||
|
|
||||||
if (to.meta.requiresAuth) {
|
if (to.meta.requiresAuth) {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
// Since worker login is disabled, we only check for manager role
|
// Allow both worker and manager roles
|
||||||
if (userRole === 'manager') {
|
if (userRole === to.meta.role || userRole === 'manager') {
|
||||||
next()
|
next()
|
||||||
} else {
|
} else {
|
||||||
// If a non-manager is somehow logged in, or role is missing, redirect to login
|
// Redirect to appropriate dashboard based on role
|
||||||
sessionStorage.clear() // Clear session for safety
|
if (userRole === 'worker') {
|
||||||
|
next('/worker/dashboard')
|
||||||
|
} else if (userRole === 'manager') {
|
||||||
|
next('/manager/dashboard')
|
||||||
|
} else {
|
||||||
|
sessionStorage.clear()
|
||||||
next('/')
|
next('/')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// User is not logged in, redirect to login page
|
// User is not logged in, redirect to login page
|
||||||
next('/')
|
next('/')
|
||||||
}
|
}
|
||||||
} else if (to.name === 'login' && isLoggedIn && userRole === 'manager') {
|
} else if (to.name === 'login' && isLoggedIn) {
|
||||||
// If a logged-in manager tries to visit the login page, redirect to their dashboard
|
// If a logged-in user tries to visit the login page, redirect to their dashboard
|
||||||
|
if (userRole === 'worker') {
|
||||||
|
next('/worker/dashboard')
|
||||||
|
} else if (userRole === 'manager') {
|
||||||
next('/manager/dashboard')
|
next('/manager/dashboard')
|
||||||
|
} else {
|
||||||
|
next('/')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// For public routes
|
// For public routes
|
||||||
next()
|
next()
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { apiFetch } from '@/api.js';
|
||||||
|
|
||||||
|
export const permissions = ref({});
|
||||||
|
|
||||||
|
export async function fetchPermissions() {
|
||||||
|
const managerId = sessionStorage.getItem('userId');
|
||||||
|
if (!managerId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch(`/api/managers/permissions/${managerId}`);
|
||||||
|
// Map old permissions to new structure if needed
|
||||||
|
permissions.value = {
|
||||||
|
view_all: Boolean(data.view_all ||
|
||||||
|
(data.can_view_workers && data.can_view_reports && data.can_view_alerts &&
|
||||||
|
data.can_view_geofences && data.can_view_qrcodes && data.can_manage_killswitch)),
|
||||||
|
edit_workers: Boolean(data.edit_workers || data.can_edit_workers),
|
||||||
|
manage_resources: Boolean(data.manage_resources ||
|
||||||
|
(data.can_manage_geofences || data.can_manage_qrcodes)),
|
||||||
|
manager_permissions: Boolean(data.manager_permissions ||
|
||||||
|
(data.can_manage_permissions || data.can_edit_managers || data.can_delete_managers))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch permissions:', error);
|
||||||
|
// Set default permissions to false if fetch fails
|
||||||
|
permissions.value = {
|
||||||
|
view_all: false,
|
||||||
|
edit_workers: false,
|
||||||
|
manage_resources: false,
|
||||||
|
manager_permissions: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Worker data caching utility
|
||||||
|
const CACHE_KEY_PREFIX = 'worker_data_';
|
||||||
|
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||||
|
|
||||||
|
export const workerCache = {
|
||||||
|
// Store worker data in session storage with timestamp
|
||||||
|
storeWorkerData(workerId, data) {
|
||||||
|
const cacheEntry = {
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(`${CACHE_KEY_PREFIX}${workerId}`, JSON.stringify(cacheEntry));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Retrieve worker data from session storage if not expired
|
||||||
|
getWorkerData(workerId) {
|
||||||
|
const cached = sessionStorage.getItem(`${CACHE_KEY_PREFIX}${workerId}`);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheEntry = JSON.parse(cached);
|
||||||
|
// Check if cache is still valid
|
||||||
|
if (Date.now() - cacheEntry.timestamp < CACHE_DURATION) {
|
||||||
|
return cacheEntry.data;
|
||||||
|
} else {
|
||||||
|
// Remove expired cache
|
||||||
|
sessionStorage.removeItem(`${CACHE_KEY_PREFIX}${workerId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Remove invalid cache entry
|
||||||
|
sessionStorage.removeItem(`${CACHE_KEY_PREFIX}${workerId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear worker data cache
|
||||||
|
clearWorkerData(workerId) {
|
||||||
|
sessionStorage.removeItem(`${CACHE_KEY_PREFIX}${workerId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear all worker data cache
|
||||||
|
clearAllWorkerData() {
|
||||||
|
Object.keys(sessionStorage).forEach(key => {
|
||||||
|
if (key.startsWith(CACHE_KEY_PREFIX)) {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="max-w-md mx-auto px-4 py-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-800 dark:text-white text-center mb-8">
|
|
||||||
{{ $t('changePasswordTitle') }}
|
|
||||||
</h1>
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<form @submit.prevent="handleChangePassword">
|
|
||||||
<div v-if="successMessage" class="bg-green-100 text-green-700 p-3 rounded-md text-center mb-4">
|
|
||||||
{{ $t(successMessage) }}
|
|
||||||
</div>
|
|
||||||
<div v-if="errorMessage" class="bg-red-100 text-red-700 p-3 rounded-md text-center mb-4">
|
|
||||||
{{ $t(errorMessage) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="currentPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
|
|
||||||
$t('currentPassword') }}</label>
|
|
||||||
<input type="password" id="currentPassword" v-model="passwords.currentPassword" required
|
|
||||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
|
|
||||||
$t('newPassword') }}</label>
|
|
||||||
<input type="password" id="newPassword" v-model="passwords.newPassword" required
|
|
||||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
|
|
||||||
$t('confirmNewPassword') }}</label>
|
|
||||||
<input type="password" id="confirmPassword" v-model="passwords.confirmPassword" required
|
|
||||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
|
||||||
</div>
|
|
||||||
<button type="submit" :disabled="loading"
|
|
||||||
class="w-full py-3 text-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md transition-colors duration-200 disabled:opacity-50">
|
|
||||||
{{ loading ? $t('updating') : $t('updatePassword') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<router-link to="/worker/dashboard"
|
|
||||||
class="block text-center mt-6 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline">
|
|
||||||
← {{ $t('backToDashboard') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { apiFetch } from '@/api.js'
|
|
||||||
|
|
||||||
|
|
||||||
const passwords = ref({
|
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
})
|
|
||||||
const loading = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const successMessage = ref('')
|
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
|
||||||
errorMessage.value = ''
|
|
||||||
successMessage.value = ''
|
|
||||||
|
|
||||||
if (passwords.value.newPassword !== passwords.value.confirmPassword) {
|
|
||||||
errorMessage.value = 'passwordsNoMatch'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (passwords.value.newPassword.length < 6) {
|
|
||||||
errorMessage.value = 'passwordTooShort'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/worker/change-password', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentPassword: passwords.value.currentPassword,
|
|
||||||
newPassword: passwords.value.newPassword,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
successMessage.value = 'passwordUpdated'
|
|
||||||
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
|
|
||||||
} catch (_err) {
|
|
||||||
errorMessage.value = _err.message || 'passwordUpdateError'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -22,6 +22,16 @@
|
|||||||
class="block w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
class="block w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Remember Me Checkbox -->
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<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 dark:text-gray-300">
|
||||||
|
{{ t('rememberMe') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-md text-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-md text-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
@@ -41,14 +51,13 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
const { t, locale } = useI18n()
|
import { apiFetch } from '@/api.js'
|
||||||
// Debug
|
|
||||||
console.log("Current locale:", locale.value)
|
|
||||||
console.log("t('login') gives:", t('login'))
|
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const rememberMe = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
@@ -56,42 +65,42 @@ const handleLogin = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/login`, {
|
const data = await apiFetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({
|
||||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
username: username.value,
|
||||||
|
password: password.value,
|
||||||
|
rememberMe: rememberMe.value
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
if (data && data.token) {
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
sessionStorage.setItem('token', data.token)
|
sessionStorage.setItem('token', data.token)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decodedToken = JSON.parse(atob(data.token.split('.')[1]))
|
const decodedToken = JSON.parse(atob(data.token.split('.')[1]))
|
||||||
sessionStorage.setItem('userId', decodedToken.userId)
|
sessionStorage.setItem('userId', decodedToken.userId)
|
||||||
sessionStorage.setItem('userRole', decodedToken.role)
|
sessionStorage.setItem('userRole', decodedToken.role)
|
||||||
|
// Store username in session storage
|
||||||
|
sessionStorage.setItem('username', username.value)
|
||||||
|
|
||||||
if (decodedToken.role === 'worker') {
|
if (decodedToken.role === 'worker') {
|
||||||
router.push('/worker/dashboard')
|
router.push('/worker/dashboard')
|
||||||
} else if (decodedToken.role === 'manager') {
|
} else if (decodedToken.role === 'manager') {
|
||||||
router.push('/manager/dashboard')
|
router.push('/manager/dashboard')
|
||||||
|
} else {
|
||||||
|
error.value = 'This application is designed for workers and managers only.'
|
||||||
|
await apiFetch('/api/auth/logout', { method: 'POST' })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
error.value = 'invalidToken'
|
error.value = 'invalidToken'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use translation keys for known errors
|
error.value = 'loginFailed'
|
||||||
if (data.message === 'Invalid token received from server.') {
|
|
||||||
error.value = 'invalidToken'
|
|
||||||
} else if (data.message === 'Failed to connect to the server.') {
|
|
||||||
error.value = 'failedConnection'
|
|
||||||
} else {
|
|
||||||
// You can map more backend messages here if needed
|
|
||||||
error.value = data.message
|
|
||||||
}
|
}
|
||||||
}
|
} catch (err) {
|
||||||
} catch {
|
console.error('Login error:', err)
|
||||||
error.value = 'failedConnection'
|
error.value = err.message || 'failedConnection'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<router-link to="/manager/dashboard" class="text-blue-600 hover:text-blue-800 font-medium">
|
<button @click="goBack" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||||
← {{ $t('backToDashboard') }}
|
← {{ $t('backToDashboard') }}
|
||||||
</router-link>
|
</button>
|
||||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mt-2">
|
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mt-2">
|
||||||
{{ $t('attendanceLogFor') }} {{ workerName }}
|
{{ $t('attendanceLogFor') }} {{ workerName }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -126,6 +126,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { apiFetch } from '@/api.js'
|
import { apiFetch } from '@/api.js'
|
||||||
|
import { workerCache } from '@/utils/workerCache.js'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -156,6 +157,11 @@ const filters = ref({
|
|||||||
|
|
||||||
const exportLoading = ref(false);
|
const exportLoading = ref(false);
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
// Navigate back to the manager dashboard (PersonnelManagement component)
|
||||||
|
window.history.back();
|
||||||
|
};
|
||||||
|
|
||||||
const fetchRecords = async () => {
|
const fetchRecords = async () => {
|
||||||
let url = `/api/managers/attendance-records?workerIds=${workerId}`
|
let url = `/api/managers/attendance-records?workerIds=${workerId}`
|
||||||
if (filters.value.startDate && filters.value.endDate) {
|
if (filters.value.startDate && filters.value.endDate) {
|
||||||
@@ -168,7 +174,15 @@ const fetchRecords = async () => {
|
|||||||
if (data && Array.isArray(data)) {
|
if (data && Array.isArray(data)) {
|
||||||
records.value = data
|
records.value = data
|
||||||
if (!workerName.value && data.length > 0) {
|
if (!workerName.value && data.length > 0) {
|
||||||
workerName.value = data[0].full_name
|
// Check if worker data is cached
|
||||||
|
const cachedWorkerData = workerCache.getWorkerData(workerId);
|
||||||
|
if (cachedWorkerData) {
|
||||||
|
workerName.value = cachedWorkerData.full_name;
|
||||||
|
} else {
|
||||||
|
workerName.value = data[0].full_name;
|
||||||
|
// Cache the worker data for future use
|
||||||
|
workerCache.storeWorkerData(workerId, { full_name: data[0].full_name });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
records.value = []
|
records.value = []
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="max-w-full mx-auto px-4 py-4">
|
<div class="max-w-full mx-auto px-4 py-4">
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap justify-center gap-2 sm:gap-4 border-b-2 border-gray-200 dark:border-gray-700 mb-6 pb-2 relative z-10">
|
class="flex flex-wrap justify-center gap-2 sm:gap-4 border-b-2 border-gray-200 dark:border-gray-700 mb-6 pb-2 relative z-10">
|
||||||
<button @click="activeTab = 'personnel'" :class="{
|
<button v-if="permissions.view_all || permissions.edit_workers" @click="activeTab = 'personnel'" :class="{
|
||||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||||
activeTab === 'personnel',
|
activeTab === 'personnel',
|
||||||
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||||
{{ $t('tabPersonnel') }}
|
{{ $t('tabPersonnel') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="activeTab = 'warning'" :class="{
|
<button v-if="permissions.view_all" @click="activeTab = 'warning'" :class="{
|
||||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||||
activeTab === 'warning',
|
activeTab === 'warning',
|
||||||
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||||
{{ $t('tabWarning') }}
|
{{ $t('tabWarning') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="activeTab = 'qr'" :class="{
|
<button v-if="permissions.view_all || permissions.manage_resources" @click="activeTab = 'qr'" :class="{
|
||||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||||
activeTab === 'qr',
|
activeTab === 'qr',
|
||||||
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||||
{{ $t('tabQrCodes') }}
|
{{ $t('tabQrCodes') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="activeTab = 'geofencing'" :class="{
|
<button v-if="permissions.view_all || permissions.manage_resources" @click="activeTab = 'geofencing'" :class="{
|
||||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||||
activeTab === 'geofencing',
|
activeTab === 'geofencing',
|
||||||
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||||
{{ $t('tabGeofencing') }}
|
{{ $t('tabGeofencing') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="activeTab = 'killSwitch'" :class="{
|
<button v-if="permissions.view_all" @click="activeTab = 'killSwitch'" :class="{
|
||||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||||
activeTab === 'killSwitch',
|
activeTab === 'killSwitch',
|
||||||
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||||
@@ -47,26 +47,52 @@
|
|||||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||||
{{ $t('workScheduleTitle') }}
|
{{ $t('workScheduleTitle') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="permissions.manager_permissions" @click="activeTab = 'permissions'" :class="{
|
||||||
|
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||||
|
activeTab === 'permissions',
|
||||||
|
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||||
|
activeTab !== 'permissions',
|
||||||
|
}"
|
||||||
|
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||||
|
{{ $t('managerPermissions') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<WarningReporting v-if="activeTab === 'warning'" />
|
<WarningReporting v-if="activeTab === 'warning' && permissions.view_all" />
|
||||||
<QrCodeManagement v-if="activeTab === 'qr'" />
|
<QrCodeManagement v-if="activeTab === 'qr' && (permissions.view_all || permissions.manage_resources)" />
|
||||||
<PersonnelManagement v-if="activeTab === 'personnel'" />
|
<PersonnelManagement v-if="activeTab === 'personnel' && (permissions.view_all || permissions.edit_workers)" />
|
||||||
<GeofenceManagement v-if="activeTab === 'geofencing'" />
|
<ManagerPermissions v-if="activeTab === 'permissions' && permissions.manager_permissions" />
|
||||||
<KillSwitchManagement v-if="activeTab === 'killSwitch'" />
|
<GeofenceManagement v-if="activeTab === 'geofencing' && (permissions.view_all || permissions.manage_resources)" />
|
||||||
|
<KillSwitchManagement v-if="activeTab === 'killSwitch' && permissions.view_all" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { permissions, fetchPermissions } from '@/stores/permissions.js'
|
||||||
import WarningReporting from '@/components/WarningReporting.vue'
|
import WarningReporting from '@/components/WarningReporting.vue'
|
||||||
import QrCodeManagement from '@/components/QrCodeManagement.vue'
|
import QrCodeManagement from '@/components/QrCodeManagement.vue'
|
||||||
import PersonnelManagement from '@/components/PersonnelManagement.vue'
|
import PersonnelManagement from '@/components/PersonnelManagement.vue'
|
||||||
|
import ManagerPermissions from '@/components/ManagerPermissions.vue'
|
||||||
import GeofenceManagement from '@/components/GeofenceManagement.vue'
|
import GeofenceManagement from '@/components/GeofenceManagement.vue'
|
||||||
import KillSwitchManagement from '@/components/KillSwitchManagement.vue'
|
import KillSwitchManagement from '@/components/KillSwitchManagement.vue'
|
||||||
|
|
||||||
const activeTab = ref('personnel')
|
const activeTab = ref('personnel')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchPermissions();
|
||||||
|
// Set the default active tab to the first one the user has permission for
|
||||||
|
if (permissions.value.view_all) {
|
||||||
|
activeTab.value = 'personnel'; // Default to personnel tab if view_all is true
|
||||||
|
} else if (permissions.value.edit_workers) {
|
||||||
|
activeTab.value = 'personnel';
|
||||||
|
} else if (permissions.value.manager_permissions) {
|
||||||
|
activeTab.value = 'permissions';
|
||||||
|
} else if (permissions.value.manage_resources) {
|
||||||
|
activeTab.value = 'qr';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<!-- Match other pages' blue header -->
|
||||||
|
<header class="fixed left-0 right-0 top-0 z-50 bg-blue-600 text-white shadow-lg">
|
||||||
|
<div class="px-4 py-6" :style="`padding-top: calc(var(--safe-area-inset-top) + 1.5rem);`">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button @click="goBack" class="mr-4 p-2 hover:bg-blue-700 rounded-lg transition-colors" aria-label="Back">
|
||||||
|
<ArrowLeftIcon class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<h1 class="text-3xl font-bold">{{ $t('manualGuide') }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-with-fixed-header-and-nav p-4 space-y-4">
|
||||||
|
<!-- Android Group -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div class="px-5 pt-5 pb-3">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('manual.android.heading') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<div v-for="item in faqsAndroid" :key="item.id">
|
||||||
|
<details class="group">
|
||||||
|
<summary
|
||||||
|
class="flex items-center justify-between cursor-pointer select-none p-5 focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon class="w-5 h-5 text-gray-500 transition-transform group-open:rotate-180" />
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="px-5 pb-5 pt-0 text-gray-700 dark:text-gray-300">
|
||||||
|
<ol v-if="item.steps" class="list-decimal pl-5 space-y-2">
|
||||||
|
<li v-for="(s, i) in item.steps" :key="i" v-html="s"></li>
|
||||||
|
</ol>
|
||||||
|
<p v-if="item.note" class="mt-3 text-sm text-gray-500 dark:text-gray-400" v-html="item.note"></p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- iOS Group -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div class="px-5 pt-5 pb-3">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t('manual.ios.heading') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 text-gray-700 dark:text-gray-300">
|
||||||
|
<p class="text-sm">{{ $t('manual.ios.comingSoon') }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ChevronDownIcon, ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.back()
|
||||||
|
|
||||||
|
// Use i18n arrays (we already added these keys in en/ms)
|
||||||
|
const { tm } = useI18n()
|
||||||
|
const faqsAndroid = computed(() => tm('manual.android.faqs') || [])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
|
||||||
|
<!-- Back Button -->
|
||||||
|
<div class="fixed bottom-4 right-4 z-50">
|
||||||
|
<button
|
||||||
|
@click="goBack"
|
||||||
|
class="bg-white dark:bg-gray-800 shadow-lg rounded-full p-3 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
aria-label="Return to Dashboard"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="px-4 py-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 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 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 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 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 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 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 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 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 -->
|
||||||
|
<button type="submit" :disabled="loading"
|
||||||
|
class="w-full py-3 text-lg font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-transform transform hover:scale-105 disabled:opacity-60 disabled:cursor-not-allowed shadow-md">
|
||||||
|
{{ loading ? $t('updating') : $t('updatePassword') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { apiFetch } from '@/api.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const passwords = ref({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
|
||||||
|
if (passwords.value.newPassword !== passwords.value.confirmPassword) {
|
||||||
|
errorMessage.value = 'passwordsNoMatch'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (passwords.value.newPassword.length < 6) {
|
||||||
|
errorMessage.value = 'passwordTooShort'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/worker/change-password', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword: passwords.value.currentPassword,
|
||||||
|
newPassword: passwords.value.newPassword,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// apiFetch already handles response parsing and throws errors for non-200 status
|
||||||
|
// If we reach here, the request was successful
|
||||||
|
successMessage.value = 'passwordUpdated'
|
||||||
|
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
|
||||||
|
} catch (err) {
|
||||||
|
// Handle specific error cases based on the error message from server
|
||||||
|
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 {
|
||||||
|
// For any other errors, use the generic error message
|
||||||
|
errorMessage.value = 'passwordUpdateError'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
|
||||||
|
<!-- Settings Button -->
|
||||||
|
<div class="fixed bottom-4 right-4 z-50">
|
||||||
|
<button
|
||||||
|
@click="navigateTo('/worker/settings')"
|
||||||
|
class="bg-white dark:bg-gray-800 shadow-lg rounded-full p-3 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
aria-label="Settings"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="px-4 pt-2 pb-8 space-y-8 flex flex-col justify-center">
|
||||||
|
<!-- Worker Name Display -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 text-center">
|
||||||
|
<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 dark:text-green-400' : 'text-red-500 dark:text-red-400']" />
|
||||||
|
</div>
|
||||||
|
<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'">
|
||||||
|
{{ clockStatusText }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 dark:text-gray-200 mb-4">
|
||||||
|
{{ scanActionText }}
|
||||||
|
</h3>
|
||||||
|
<button @click="startScanner"
|
||||||
|
class="w-full py-4 text-xl flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-transform transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-lg">
|
||||||
|
<CameraIcon class="w-8 h-8" />
|
||||||
|
<span>{{ $t('openCamera') }}</span>
|
||||||
|
</button>
|
||||||
|
<input ref="fileInput" type="file" accept="image/*" @change="handleFileUpload" hidden />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 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">
|
||||||
|
{{ $t('cancel') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div v-if="showOverlay" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-[100] p-4" @click.self="dismissOverlay">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-6 w-full max-w-sm text-center transform transition-all animate-fade-in-up">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full mb-4" :class="overlayType === 'success' ? 'bg-green-100 dark:bg-green-900/50' : 'bg-red-100 dark:bg-red-900/50'">
|
||||||
|
<svg v-if="overlayType === 'success'" class="h-10 w-10 text-green-600 dark:text-green-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="h-10 w-10 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
{{ overlayType === 'success' ? 'Success' : 'An Error Occurred' }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6 px-4">
|
||||||
|
{{ overlayMessage }}
|
||||||
|
</p>
|
||||||
|
<button @click="dismissOverlay" class="w-full text-white font-bold py-3 px-4 rounded-xl transition-transform transform hover:scale-105 shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500': overlayType === 'success',
|
||||||
|
'bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500': overlayType === 'error'
|
||||||
|
}">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { CheckCircleIcon, ClockIcon, CameraIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import { Html5Qrcode } from 'html5-qrcode'
|
||||||
|
import { apiFetch } from '@/api.js'
|
||||||
|
import { workerCache } from '@/utils/workerCache.js'
|
||||||
|
// Removed Capacitor Geolocation for web migration
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
let html5QrCode = null
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const isClockedIn = ref(false)
|
||||||
|
const isScannerActive = ref(false)
|
||||||
|
const workerName = ref('')
|
||||||
|
const username = ref('')
|
||||||
|
|
||||||
|
// Unified overlay refs
|
||||||
|
const showOverlay = ref(false)
|
||||||
|
const overlayMessage = ref('')
|
||||||
|
const overlayType = ref('error') // 'success' or 'error'
|
||||||
|
|
||||||
|
let userId = sessionStorage.getItem('userId')
|
||||||
|
|
||||||
|
const clockStatusText = computed(() => t(isClockedIn.value ? 'statusClockedIn' : 'statusClockedOut'));
|
||||||
|
const scanActionText = computed(() => t(isClockedIn.value ? 'scanToClockOut' : 'scanToClockIn'));
|
||||||
|
|
||||||
|
const getLocalizedErrorMessage = (err) => {
|
||||||
|
if (err.code && (err.code >= 1 && err.code <= 3)) {
|
||||||
|
return t('unableToRetrieveLocation', { message: err.message });
|
||||||
|
}
|
||||||
|
const keyString = err.message || '';
|
||||||
|
if (keyString.includes('|')) {
|
||||||
|
const parts = keyString.split('|');
|
||||||
|
const actualKey = parts[0];
|
||||||
|
const distanceValue = parts[1];
|
||||||
|
if (t(actualKey) !== actualKey) {
|
||||||
|
return t(actualKey, { distance: distanceValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t(keyString) !== keyString) {
|
||||||
|
return t(keyString);
|
||||||
|
}
|
||||||
|
return t('error.default');
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerOverlay = (message, type = 'error') => {
|
||||||
|
overlayMessage.value = message;
|
||||||
|
overlayType.value = type;
|
||||||
|
showOverlay.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissOverlay = () => {
|
||||||
|
showOverlay.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWorkerDetails = async () => {
|
||||||
|
// Check if worker data is cached
|
||||||
|
const cachedData = workerCache.getWorkerData(userId);
|
||||||
|
if (cachedData) {
|
||||||
|
workerName.value = cachedData.full_name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if worker name is already in session storage (fallback)
|
||||||
|
const storedWorkerName = sessionStorage.getItem('workerName');
|
||||||
|
if (storedWorkerName) {
|
||||||
|
workerName.value = storedWorkerName;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch(`/api/workers/${userId}`)
|
||||||
|
if (data) {
|
||||||
|
workerName.value = data.full_name
|
||||||
|
// Cache the worker data
|
||||||
|
workerCache.storeWorkerData(userId, data);
|
||||||
|
// Also store in session storage for compatibility
|
||||||
|
sessionStorage.setItem('workerName', data.full_name);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
triggerOverlay(getLocalizedErrorMessage(err), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCurrentStatus = async () => {
|
||||||
|
try {
|
||||||
|
const lastEvent = await apiFetch(`/api/worker/status/${userId}`)
|
||||||
|
if (lastEvent) {
|
||||||
|
const isCurrentlyClockedIn = lastEvent.eventType === 'clock_in'
|
||||||
|
isClockedIn.value = isCurrentlyClockedIn;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
triggerOverlay(getLocalizedErrorMessage(err), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
|
||||||
|
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/clock', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId, eventType, qrCodeValue, latitude, longitude }),
|
||||||
|
});
|
||||||
|
const newClockStatus = !isClockedIn.value
|
||||||
|
isClockedIn.value = newClockStatus
|
||||||
|
triggerOverlay(t(newClockStatus ? 'successClockIn' : 'successClockOut'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
triggerOverlay(getLocalizedErrorMessage(err), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('user-forced-clock-out', handleForcedClockOut)
|
||||||
|
if (!userId) {
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get username from session storage
|
||||||
|
const storedUsername = sessionStorage.getItem('username');
|
||||||
|
if (storedUsername) {
|
||||||
|
username.value = storedUsername;
|
||||||
|
} else {
|
||||||
|
// Fallback to placeholder if not found
|
||||||
|
username.value = 'username'
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchWorkerDetails()
|
||||||
|
fetchCurrentStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('user-forced-clock-out', handleForcedClockOut)
|
||||||
|
if (html5QrCode && html5QrCode.isScanning) stopScanner()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleForcedClockOut = () => { isClockedIn.value = false }
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
if (showOverlay.value) showOverlay.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startScanner = () => {
|
||||||
|
isScannerActive.value = true
|
||||||
|
clearMessages()
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
html5QrCode = new Html5Qrcode('qr-reader')
|
||||||
|
const config = { fps: 10, qrbox: { width: 250, height: 250 } }
|
||||||
|
html5QrCode.start({ facingMode: 'environment' }, config, onScanSuccess, onScanFailure)
|
||||||
|
} catch {
|
||||||
|
triggerOverlay(t('unableToStartCamera'), 'error');
|
||||||
|
isScannerActive.value = false
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopScanner = () => {
|
||||||
|
if (html5QrCode && html5QrCode.isScanning) {
|
||||||
|
html5QrCode.stop().catch((err) => console.error('Failed to stop scanner cleanly.', err))
|
||||||
|
}
|
||||||
|
isScannerActive.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
clearMessages()
|
||||||
|
if (!html5QrCode) html5QrCode = new Html5Qrcode('qr-reader', false)
|
||||||
|
html5QrCode.scanFile(file, true).then(onScanSuccess).catch(onScanFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScanSuccess = async (decodedText) => {
|
||||||
|
stopScanner()
|
||||||
|
try {
|
||||||
|
// Use browser geolocation for web
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
triggerOverlay(t('geolocationNotSupported'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
sendClockEvent(decodedText, position.coords.latitude, position.coords.longitude);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
triggerOverlay(t('unableToRetrieveLocation', { message: error.message }), 'error');
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
triggerOverlay(t('unableToRetrieveLocation', { message: error.message }), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScanFailure = () => {
|
||||||
|
// Intentionally left blank to avoid "QR not detected" messages on every frame.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation method
|
||||||
|
const navigateTo = (path) => {
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Simple animation for the overlay content */
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* All styles are now handled by Tailwind CSS classes in the template. */
|
||||||
|
</style>
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="max-w-md mx-auto px-4 py-8 flex flex-col gap-6">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-800 dark:text-white text-center">{{ workerName }}</h1>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-6 p-6 rounded-lg shadow-md text-white"
|
|
||||||
:class="isClockedIn ? 'bg-green-500' : 'bg-red-500'">
|
|
||||||
<div class="text-4xl">
|
|
||||||
<span v-if="isClockedIn">✔️</span>
|
|
||||||
<span v-else>🙏</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm opacity-90">{{ $t('yourStatus') }}</p>
|
|
||||||
<h2 class="text-2xl font-bold">{{ clockStatus }}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<p v-if="successMessage" class="bg-green-100 text-green-700 p-3 rounded-md text-center mb-4">
|
|
||||||
{{ successMessage }}
|
|
||||||
</p>
|
|
||||||
<p v-if="errorMessage" class="bg-red-100 text-red-700 p-3 rounded-md text-center mb-4">
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="!isScannerActive" class="flex flex-col gap-4">
|
|
||||||
<button @click="startScanner"
|
|
||||||
class="w-full py-4 text-xl flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md transition-colors duration-200">
|
|
||||||
<span>📷</span>
|
|
||||||
<span>{{ $t('scanToClock', { action: $t(isClockedIn ? 'out' : 'in') }) }}</span>
|
|
||||||
</button>
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" @change="handleFileUpload" hidden />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="qr-reader-container" v-show="isScannerActive"
|
|
||||||
class="fixed inset-0 bg-gray-900 bg-opacity-70 flex flex-col items-center justify-center z-50 p-4">
|
|
||||||
<div id="qr-reader" class="w-full max-w-sm bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl"></div>
|
|
||||||
<button @click="stopScanner"
|
|
||||||
class="mt-4 bg-red-600 hover:bg-red-700 text-white font-semibold px-6 py-3 rounded-md transition-colors duration-200">
|
|
||||||
{{ $t('cancel') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<router-link to="/worker/history"
|
|
||||||
class="text-center text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline px-4 py-2 rounded-md transition-colors duration-200">
|
|
||||||
{{ $t('viewMyClockHistory') }} →
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<router-link to="/worker/change-password"
|
|
||||||
class="text-center text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline px-4 py-2 rounded-md transition-colors duration-200">
|
|
||||||
{{ $t('changeMyPassword') }} →
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { Html5Qrcode } from 'html5-qrcode'
|
|
||||||
import { apiFetch } from '@/api.js'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
let html5QrCode = null
|
|
||||||
const fileInput = ref(null)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const isClockedIn = ref(false)
|
|
||||||
const isScannerActive = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const successMessage = ref('')
|
|
||||||
const workerName = ref('')
|
|
||||||
|
|
||||||
const userId = sessionStorage.getItem('userId')
|
|
||||||
|
|
||||||
const clockStatus = computed(() => (isClockedIn.value ? t('clockedIn') : t('clockedOut')))
|
|
||||||
|
|
||||||
const fetchWorkerDetails = async () => {
|
|
||||||
try {
|
|
||||||
const data = await apiFetch(`/api/workers/${userId}`)
|
|
||||||
if (data) {
|
|
||||||
workerName.value = data.full_name
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
errorMessage.value = t('couldNotLoadWorkerInfo') + `: ${err.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchCurrentStatus = async () => {
|
|
||||||
try {
|
|
||||||
const lastEvent = await apiFetch(`/api/worker/status/${userId}`)
|
|
||||||
if (lastEvent) {
|
|
||||||
isClockedIn.value = lastEvent.eventType === 'clock_in'
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
errorMessage.value = t('couldNotVerifyStatus') + `: ${err.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
|
|
||||||
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
|
|
||||||
try {
|
|
||||||
const data = await apiFetch('/api/clock', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
userId: userId,
|
|
||||||
eventType,
|
|
||||||
qrCodeValue,
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
isClockedIn.value = !isClockedIn.value
|
|
||||||
successMessage.value = t('successfullyClocked', { action: t(eventType) }) + ` ${data.location || t('site')}.`
|
|
||||||
} catch (err) {
|
|
||||||
errorMessage.value = t('errorOccurred') + `: ${err.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!userId) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetchWorkerDetails()
|
|
||||||
fetchCurrentStatus()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (html5QrCode && html5QrCode.isScanning) {
|
|
||||||
stopScanner()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const clearMessages = () => {
|
|
||||||
errorMessage.value = ''
|
|
||||||
successMessage.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const startScanner = () => {
|
|
||||||
isScannerActive.value = true
|
|
||||||
clearMessages()
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
html5QrCode = new Html5Qrcode('qr-reader')
|
|
||||||
const config = { fps: 10, qrbox: { width: 250, height: 250 } }
|
|
||||||
html5QrCode.start({ facingMode: 'environment' }, config, onScanSuccess, onScanFailure)
|
|
||||||
} catch {
|
|
||||||
errorMessage.value = t('unableToStartCamera')
|
|
||||||
isScannerActive.value = false
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopScanner = () => {
|
|
||||||
if (html5QrCode && html5QrCode.isScanning) {
|
|
||||||
html5QrCode.stop().catch((err) => console.error('Failed to stop scanner cleanly.', err))
|
|
||||||
}
|
|
||||||
isScannerActive.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileUpload = (event) => {
|
|
||||||
const file = event.target.files[0]
|
|
||||||
if (!file) return
|
|
||||||
clearMessages()
|
|
||||||
if (!html5QrCode) {
|
|
||||||
html5QrCode = new Html5Qrcode('qr-reader', false)
|
|
||||||
}
|
|
||||||
html5QrCode
|
|
||||||
.scanFile(file, true)
|
|
||||||
.then(onScanSuccess)
|
|
||||||
.catch(() => {
|
|
||||||
onScanFailure(t('tryAgain'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onScanSuccess = (decodedText) => {
|
|
||||||
successMessage.value = t('qrDetectedGettingLocation')
|
|
||||||
stopScanner()
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
errorMessage.value = t('geolocationNotSupported')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(position) => sendClockEvent(decodedText, position.coords.latitude, position.coords.longitude),
|
|
||||||
(geoError) =>
|
|
||||||
(errorMessage.value = t('unableToRetrieveLocation', { message: geoError.message })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onScanFailure = () => {
|
|
||||||
errorMessage.value = t('qrNotDetectedTryAgain')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* All styles are now handled by Tailwind CSS classes in the template. */
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
|
||||||
|
<!-- Back Button -->
|
||||||
|
<div class="fixed bottom-4 right-4 z-50">
|
||||||
|
<button
|
||||||
|
@click="goBack"
|
||||||
|
class="bg-white dark:bg-gray-800 shadow-lg rounded-full p-3 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
aria-label="Return to Dashboard"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="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 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 dark:bg-gray-800 rounded-2xl shadow-lg p-5 flex items-center space-x-4">
|
||||||
|
<div class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||||
|
:class="event.event_type === 'clock_in' ? 'bg-green-100 dark:bg-green-900/50' : 'bg-red-100 dark:bg-red-900/50'">
|
||||||
|
<component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon"
|
||||||
|
:class="['w-8 h-8', event.event_type === 'clock_in' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400']" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<div class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ $t(event.event_type) }}</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">{{ event.qrCodeUsedName }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="font-medium text-gray-800 dark:text-gray-200">{{ new Date(event.timestamp).toLocaleDateString() }}</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ new Date(event.timestamp).toLocaleTimeString() }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ChartBarIcon, ArrowDownCircleIcon, ArrowUpCircleIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import { apiFetch } from '@/api.js'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const clockHistory = ref([])
|
||||||
|
const userId = sessionStorage.getItem('userId')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!userId) {
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await apiFetch(`/api/worker/clock-history/${userId}`)
|
||||||
|
if (data) {
|
||||||
|
clockHistory.value = data.filter(event => event.event_type !== 'failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(t('clockHistoryFetchFail'), error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* All styles are now handled by Tailwind CSS classes in the template. */
|
||||||
|
</style>
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="max-w-3xl mx-auto px-4 py-8 bg-white dark:bg-gray-800 rounded-lg shadow">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-4">{{ $t('myClockHistory') }}</h2>
|
|
||||||
<router-link to="/worker/dashboard" class="text-blue-600 hover:text-blue-800 font-medium mb-6 inline-block">← {{
|
|
||||||
$t('backToDashboard') }}</router-link>
|
|
||||||
|
|
||||||
<div v-if="!clockHistory.length" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $t('noClockHistory') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div v-for="event in clockHistory" :key="event.id" class="bg-gray-50 dark:bg-gray-700 rounded-lg shadow-sm p-4">
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<div class="inline-block px-3 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
|
|
||||||
:class="{
|
|
||||||
'bg-green-500': event.event_type === 'clock_in',
|
|
||||||
'bg-red-500': event.event_type === 'clock_out',
|
|
||||||
}">
|
|
||||||
{{ $t(event.event_type) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-gray-800 dark:text-white text-sm font-medium">
|
|
||||||
{{ new Date(event.timestamp).toLocaleString() }}
|
|
||||||
</div>
|
|
||||||
<div class="text-gray-600 dark:text-gray-300 text-sm">
|
|
||||||
{{ event.qrCodeUsedName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { apiFetch } from '@/api.js'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter()
|
|
||||||
const clockHistory = ref([])
|
|
||||||
const userId = sessionStorage.getItem('userId')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!userId) {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await apiFetch(`/api/worker/clock-history/${userId}`)
|
|
||||||
if (data) {
|
|
||||||
clockHistory.value = data.filter(event => event.event_type !== 'failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(t('clockHistoryFetchFail'), error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* All styles are now handled by Tailwind CSS classes in the template. */
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
|
||||||
|
<!-- Return Button (same position as settings button in dashboard) -->
|
||||||
|
<div class="fixed bottom-4 right-4 z-50">
|
||||||
|
<button @click="goBack"
|
||||||
|
class="bg-white dark:bg-gray-800 shadow-lg rounded-full p-3 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
aria-label="Return to Dashboard">
|
||||||
|
<svg class="w-6 h-6 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable Main Content -->
|
||||||
|
<main class="px-4 py-4 space-y-6 mt-2 mb-8">
|
||||||
|
<!-- Profile Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center">
|
||||||
|
<UserIcon class="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">{{ workerName }}</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">{{ username }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Menu -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
|
||||||
|
<!-- Clock History -->
|
||||||
|
<router-link to="/worker/history"
|
||||||
|
class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||||
|
<ChartBarIcon class="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Change Password -->
|
||||||
|
<router-link to="/worker/change-password"
|
||||||
|
class="flex items-center p-5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="w-12 h-12 bg-orange-100 dark:bg-orange-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||||
|
<LockClosedIcon class="w-8 h-8 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<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" />
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<!-- Language Selection -->
|
||||||
|
<div class="flex items-center p-5">
|
||||||
|
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||||
|
<LanguageIcon class="w-8 h-8 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100 mb-2">{{ $t('language') }}</h3>
|
||||||
|
<select v-model="currentLang" @change="changeLang"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
<option value="en">{{ $t('english') }}</option>
|
||||||
|
<option value="ms">{{ $t('malay') }}</option>
|
||||||
|
<option value="tm">{{ $t('tamil') }}</option>
|
||||||
|
<option value="bd">{{ $t('bengali') }}</option>
|
||||||
|
<option value="my">{{ $t('burmese') }}</option>
|
||||||
|
<option value="np">{{ $t('nepali') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Guide (NEW) -->
|
||||||
|
<router-link to="/worker/manual-guide"
|
||||||
|
class="flex items-center p-5 border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||||
|
<BookOpenIcon class="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">{{ $t('manualGuide') }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('viewUserManual') }}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App Information -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6">
|
||||||
|
<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('web') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logout Button -->
|
||||||
|
<button @click="logout"
|
||||||
|
class="w-full flex items-center justify-center p-5 bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||||
|
<div class="w-12 h-12 bg-red-100 dark:bg-red-900/50 rounded-xl flex items-center justify-center mr-5">
|
||||||
|
<ArrowRightOnRectangleIcon class="w-8 h-8 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow text-left">
|
||||||
|
<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-500">{{ $t('signOutOfAccount') }}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ChartBarIcon, LockClosedIcon, LanguageIcon, ArrowRightOnRectangleIcon, ChevronRightIcon, UserIcon, BookOpenIcon } from '@heroicons/vue/24/outline'
|
||||||
|
// Removed authService dependency for web migration
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const currentLang = ref(locale.value)
|
||||||
|
const workerName = ref('')
|
||||||
|
const workerId = ref('')
|
||||||
|
const username = ref('')
|
||||||
|
|
||||||
|
const changeLang = () => {
|
||||||
|
locale.value = currentLang.value
|
||||||
|
localStorage.setItem('lang', currentLang.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
// Simple logout for web - just clear session
|
||||||
|
sessionStorage.removeItem('userId')
|
||||||
|
sessionStorage.removeItem('userRole')
|
||||||
|
sessionStorage.removeItem('token')
|
||||||
|
sessionStorage.removeItem('username')
|
||||||
|
router.push('/')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
sessionStorage.clear()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedLang = localStorage.getItem('lang')
|
||||||
|
if (savedLang) {
|
||||||
|
currentLang.value = savedLang
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get worker info from session storage
|
||||||
|
const userId = sessionStorage.getItem('userId')
|
||||||
|
if (userId) {
|
||||||
|
workerId.value = userId
|
||||||
|
// Get worker name from session storage
|
||||||
|
const storedWorkerName = sessionStorage.getItem('workerName');
|
||||||
|
if (storedWorkerName) {
|
||||||
|
workerName.value = storedWorkerName;
|
||||||
|
} else {
|
||||||
|
// Fallback to placeholder if not found
|
||||||
|
workerName.value = 'Worker'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get username from session storage
|
||||||
|
const storedUsername = sessionStorage.getItem('username');
|
||||||
|
if (storedUsername) {
|
||||||
|
username.value = storedUsername;
|
||||||
|
} else {
|
||||||
|
// Fallback to placeholder if not found
|
||||||
|
username.value = 'username'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
import fs from 'fs'
|
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|||||||
Reference in New Issue
Block a user