Compare commits
8 Commits
new_edi_branch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c58565af77 | |||
| 66d29fa4b6 | |||
| c98b16dbd7 | |||
| d51c16399c | |||
| 99f80a25d0 | |||
| 9f65883534 | |||
| 2d7ddbb96a | |||
| 2e7de997ff |
+224
-33
@@ -70,6 +70,38 @@ export default function () {
|
|||||||
const d = new Date(y, m - 1, dd, 12, 0, 0, 0) // noon avoids DST edges
|
const d = new Date(y, m - 1, dd, 12, 0, 0, 0) // noon avoids DST edges
|
||||||
return ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'][d.getDay()]
|
return ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'][d.getDay()]
|
||||||
}
|
}
|
||||||
|
// --- bool helper (handles BIT(1) returning Buffer) ---
|
||||||
|
const toBool = (v) =>
|
||||||
|
typeof v === 'number' ? v === 1 : Buffer.isBuffer(v) ? v[0] === 1 : Boolean(v)
|
||||||
|
|
||||||
|
// ---- Lunch / totals helpers ----
|
||||||
|
const minutesBetween = (a, b) => Math.max(0, (b - a) / 60000)
|
||||||
|
|
||||||
|
const overlapMinutes = (aStart, aEnd, bStart, bEnd) => {
|
||||||
|
const start = Math.max(aStart.getTime(), bStart.getTime())
|
||||||
|
const end = Math.min(aEnd.getTime(), bEnd.getTime())
|
||||||
|
return Math.max(0, (end - start) / 60000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measures total "gap between sessions" that overlaps lunch window (11:00–15:00), capped to policyMin.
|
||||||
|
const calcLunchGapMinutes = (sessions, ymd, TZ, policyMin) => {
|
||||||
|
if (!sessions || sessions.length < 2) return 0
|
||||||
|
|
||||||
|
const lunchStart = parseNaiveAsTZ(`${ymd} 11:00:00`, TZ)
|
||||||
|
const lunchEnd = parseNaiveAsTZ(`${ymd} 15:00:00`, TZ)
|
||||||
|
|
||||||
|
const s = sessions.slice().sort((x, y) => x.start - y.start)
|
||||||
|
|
||||||
|
let total = 0
|
||||||
|
for (let i = 0; i < s.length - 1; i++) {
|
||||||
|
const gapStart = s[i].end
|
||||||
|
const gapEnd = s[i + 1].start
|
||||||
|
if (gapEnd <= gapStart) continue
|
||||||
|
total += overlapMinutes(gapStart, gapEnd, lunchStart, lunchEnd)
|
||||||
|
if (total >= policyMin) return policyMin
|
||||||
|
}
|
||||||
|
return Math.min(total, policyMin)
|
||||||
|
}
|
||||||
|
|
||||||
// Middleware to authenticate and authorize managers
|
// Middleware to authenticate and authorize managers
|
||||||
const authenticateJWT = (req, res, next) => {
|
const authenticateJWT = (req, res, next) => {
|
||||||
@@ -98,7 +130,7 @@ export default function () {
|
|||||||
managerId,
|
managerId,
|
||||||
])
|
])
|
||||||
|
|
||||||
if (rows.length === 0 || !rows[0][requiredPermission]) {
|
if (rows.length === 0 || !toBool(rows[0][requiredPermission])) {
|
||||||
return res.status(403).json({ message: 'Forbidden: Insufficient permissions.' })
|
return res.status(403).json({ message: 'Forbidden: Insufficient permissions.' })
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
@@ -196,12 +228,10 @@ export default function () {
|
|||||||
res.json(rows)
|
res.json(rows)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed records summary error:', error)
|
console.error('Failed records summary error:', error)
|
||||||
res
|
res.status(500).json({
|
||||||
.status(500)
|
message: 'Database error fetching failed records summary.',
|
||||||
.json({
|
details: error.message,
|
||||||
message: 'Database error fetching failed records summary.',
|
})
|
||||||
details: error.message,
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
db.release()
|
db.release()
|
||||||
}
|
}
|
||||||
@@ -232,12 +262,10 @@ export default function () {
|
|||||||
res.json(rows)
|
res.json(rows)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed records details error:', error)
|
console.error('Failed records details error:', error)
|
||||||
res
|
res.status(500).json({
|
||||||
.status(500)
|
message: 'Database error fetching failed records details.',
|
||||||
.json({
|
details: error.message,
|
||||||
message: 'Database error fetching failed records details.',
|
})
|
||||||
details: error.message,
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
db.release()
|
db.release()
|
||||||
}
|
}
|
||||||
@@ -356,8 +384,10 @@ export default function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx'
|
const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx'
|
||||||
|
const wantTxt = String(req.query.format || '').toLowerCase() === 'txt'
|
||||||
|
|
||||||
let workerIdClause = ''
|
let workerIdClause = ''
|
||||||
|
let departmentClause = ''
|
||||||
const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`]
|
const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`]
|
||||||
|
|
||||||
if (workerIds) {
|
if (workerIds) {
|
||||||
@@ -370,6 +400,12 @@ export default function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { department } = req.query
|
||||||
|
if (department) {
|
||||||
|
departmentClause = ` AND LOWER(w.department) = LOWER(?)`
|
||||||
|
params.push(department)
|
||||||
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
cr.worker_id,
|
cr.worker_id,
|
||||||
@@ -382,8 +418,8 @@ export default function () {
|
|||||||
FROM clock_records cr
|
FROM clock_records cr
|
||||||
JOIN workers w ON cr.worker_id = w.id
|
JOIN workers w ON cr.worker_id = w.id
|
||||||
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
|
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
|
||||||
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}
|
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}${departmentClause}
|
||||||
AND cr.event_type IN ('clock_in','clock_out')
|
AND cr.event_type IN ('clock_in','clock_out')
|
||||||
ORDER BY cr.worker_id, cr.timestamp ASC
|
ORDER BY cr.worker_id, cr.timestamp ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -414,7 +450,7 @@ export default function () {
|
|||||||
|
|
||||||
// ---- Build rows: one per successful [clock_in, clock_out] session ----
|
// ---- Build rows: one per successful [clock_in, clock_out] session ----
|
||||||
const csvData = []
|
const csvData = []
|
||||||
const byWorkerForXlsx = new Map() // key = "username||full_name||department" → daily rows
|
const byWorkerForXlsx = new Map() // key = "username||full_name||department" → session rows
|
||||||
|
|
||||||
for (const workerId in workByDay) {
|
for (const workerId in workByDay) {
|
||||||
const w = workByDay[workerId]
|
const w = workByDay[workerId]
|
||||||
@@ -427,6 +463,9 @@ export default function () {
|
|||||||
let open = null
|
let open = null
|
||||||
let openQr = 'Manual Entry'
|
let openQr = 'Manual Entry'
|
||||||
|
|
||||||
|
const sessions = []
|
||||||
|
const dayRows = []
|
||||||
|
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.type === 'clock_in' && open == null) {
|
if (e.type === 'clock_in' && open == null) {
|
||||||
open = e.time
|
open = e.time
|
||||||
@@ -435,6 +474,8 @@ export default function () {
|
|||||||
const start = open
|
const start = open
|
||||||
const end = e.time
|
const end = e.time
|
||||||
|
|
||||||
|
sessions.push({ start, end })
|
||||||
|
|
||||||
const dailyRow = {
|
const dailyRow = {
|
||||||
username: w.username,
|
username: w.username,
|
||||||
full_name: w.full_name,
|
full_name: w.full_name,
|
||||||
@@ -444,24 +485,91 @@ export default function () {
|
|||||||
clock_out: hmInTZ(end, TZ),
|
clock_out: hmInTZ(end, TZ),
|
||||||
work_hours: ((end - start) / 3600000).toFixed(2),
|
work_hours: ((end - start) / 3600000).toFixed(2),
|
||||||
qr_code_name: openQr,
|
qr_code_name: openQr,
|
||||||
|
daily_total: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
csvData.push(dailyRow)
|
csvData.push(dailyRow)
|
||||||
perWorkerRows.push(dailyRow)
|
perWorkerRows.push(dailyRow)
|
||||||
|
dayRows.push(dailyRow)
|
||||||
|
|
||||||
// close the session
|
// close the session
|
||||||
open = null
|
open = null
|
||||||
openQr = 'Manual Entry'
|
openQr = 'Manual Entry'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Daily total: worked - missing lunch (policy 60min) ----
|
||||||
|
if (dayRows.length) {
|
||||||
|
const unpaidLunchMin = 60
|
||||||
|
|
||||||
|
const workedMin = sessions.reduce((sum, s) => sum + minutesBetween(s.start, s.end), 0)
|
||||||
|
const lunchGapMin = calcLunchGapMinutes(sessions, day, TZ, unpaidLunchMin)
|
||||||
|
|
||||||
|
const missingLunchMin = Math.max(0, unpaidLunchMin - lunchGapMin)
|
||||||
|
const paidMin = Math.max(0, workedMin - missingLunchMin)
|
||||||
|
|
||||||
|
dayRows[dayRows.length - 1].daily_total = (paidMin / 60).toFixed(2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows)
|
byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows)
|
||||||
}
|
}
|
||||||
// ===== XLSX branch: grouped header + per-day summary columns =====
|
|
||||||
|
// ===== TXT branch =====
|
||||||
|
if (wantTxt) {
|
||||||
|
const lines = []
|
||||||
|
for (const wId in workByDay) {
|
||||||
|
const w = workByDay[wId]
|
||||||
|
const seen = new Set()
|
||||||
|
for (const day of Object.keys(w.days).sort()) {
|
||||||
|
const events = w.days[day].slice().sort((a, b) => a.time - b.time)
|
||||||
|
for (const e of events) {
|
||||||
|
const code = e.type === 'clock_in' ? '1' : '0'
|
||||||
|
const date = ymdInTZ(e.time, TZ)
|
||||||
|
const timeStr = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: TZ,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(e.time)
|
||||||
|
const line = `${w.username};${code};${date};${timeStr};${w.full_name}`
|
||||||
|
if (!seen.has(line)) {
|
||||||
|
seen.add(line)
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.set('X-Export-TZ', TZ)
|
||||||
|
res
|
||||||
|
.header('Content-Type', 'text/plain')
|
||||||
|
.attachment(`attendance_${startDate}_to_${endDate}.txt`)
|
||||||
|
.send(lines.join('\n'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== XLSX branch: grouped header + per-day summary sheet =====
|
||||||
if (wantXlsx) {
|
if (wantXlsx) {
|
||||||
const wb = new ExcelJS.Workbook()
|
const wb = new ExcelJS.Workbook()
|
||||||
const ws = wb.addWorksheet('Attendance')
|
const ws = wb.addWorksheet('Attendance')
|
||||||
|
const wsSum = wb.addWorksheet('Summary')
|
||||||
|
|
||||||
|
wsSum.columns = [
|
||||||
|
{ header: 'Username', key: 'username', width: 16 },
|
||||||
|
{ header: 'Full Name', key: 'full_name', width: 24 },
|
||||||
|
{ header: 'Department', key: 'department', width: 18 },
|
||||||
|
{ header: 'Days', key: 'days', width: 8 },
|
||||||
|
{ header: 'Worked Hours', key: 'worked_hours', width: 14 },
|
||||||
|
{ header: 'Paid Hours', key: 'paid_hours', width: 14 },
|
||||||
|
{ header: 'Avg Paid/Day', key: 'avg_paid', width: 14 },
|
||||||
|
]
|
||||||
|
wsSum.getRow(1).font = { bold: true }
|
||||||
|
wsSum.views = [{ state: 'frozen', ySplit: 1 }]
|
||||||
|
wsSum.autoFilter = { from: 'A1', to: 'G1' }
|
||||||
|
wsSum.getColumn('worked_hours').numFmt = '0.00'
|
||||||
|
wsSum.getColumn('paid_hours').numFmt = '0.00'
|
||||||
|
wsSum.getColumn('avg_paid').numFmt = '0.00'
|
||||||
|
|
||||||
ws.columns = [
|
ws.columns = [
|
||||||
{ header: 'Date', key: 'date', width: 12 },
|
{ header: 'Date', key: 'date', width: 12 },
|
||||||
@@ -470,7 +578,12 @@ export default function () {
|
|||||||
{ header: 'Clock Out', key: 'clock_out', width: 10 },
|
{ header: 'Clock Out', key: 'clock_out', width: 10 },
|
||||||
{ header: 'Work Hours', key: 'work_hours', width: 12 },
|
{ header: 'Work Hours', key: 'work_hours', width: 12 },
|
||||||
{ header: 'QR Code', key: 'qr_code_name', width: 24 },
|
{ header: 'QR Code', key: 'qr_code_name', width: 24 },
|
||||||
|
{ header: 'Daily Total', key: 'daily_total', width: 12 },
|
||||||
]
|
]
|
||||||
|
ws.getColumn('work_hours').numFmt = '0.00'
|
||||||
|
ws.getColumn('daily_total').numFmt = '0.00'
|
||||||
|
ws.getColumn('work_hours').alignment = { horizontal: 'right' }
|
||||||
|
ws.getColumn('daily_total').alignment = { horizontal: 'right' }
|
||||||
|
|
||||||
for (const [key, rowsForWorker] of byWorkerForXlsx.entries()) {
|
for (const [key, rowsForWorker] of byWorkerForXlsx.entries()) {
|
||||||
const [username, full_name, dept] = key.split('||')
|
const [username, full_name, dept] = key.split('||')
|
||||||
@@ -479,7 +592,7 @@ export default function () {
|
|||||||
|
|
||||||
// Bold merged group header: "username full_name Dept: X"
|
// Bold merged group header: "username full_name Dept: X"
|
||||||
const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1
|
const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1
|
||||||
ws.mergeCells(`A${titleRowIdx}:F${titleRowIdx}`)
|
ws.mergeCells(`A${titleRowIdx}:G${titleRowIdx}`)
|
||||||
const titleCell = ws.getCell(`A${titleRowIdx}`)
|
const titleCell = ws.getCell(`A${titleRowIdx}`)
|
||||||
titleCell.value = dept
|
titleCell.value = dept
|
||||||
? `${username} ${full_name} Dept: ${dept}`
|
? `${username} ${full_name} Dept: ${dept}`
|
||||||
@@ -495,17 +608,63 @@ export default function () {
|
|||||||
clock_out: 'Clock Out',
|
clock_out: 'Clock Out',
|
||||||
work_hours: 'Work Hours',
|
work_hours: 'Work Hours',
|
||||||
qr_code_name: 'QR Code',
|
qr_code_name: 'QR Code',
|
||||||
|
daily_total: 'Daily Total',
|
||||||
})
|
})
|
||||||
hdr.font = { bold: true }
|
hdr.font = { bold: true }
|
||||||
|
|
||||||
// Detail rows (one per day)
|
// Detail rows (one per day)
|
||||||
for (const r of rowsForWorker) {
|
for (const r of rowsForWorker) {
|
||||||
ws.addRow(r)
|
ws.addRow({
|
||||||
|
...r,
|
||||||
|
work_hours: r.work_hours === '' ? null : Number(r.work_hours),
|
||||||
|
daily_total: r.daily_total === '' ? null : Number(r.daily_total),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPaid = rowsForWorker.reduce((sum, r) => {
|
||||||
|
const v = Number(r.daily_total)
|
||||||
|
return sum + (Number.isFinite(v) ? v : 0)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const totalWorked = rowsForWorker.reduce((sum, r) => {
|
||||||
|
const v = Number(r.work_hours)
|
||||||
|
return sum + (Number.isFinite(v) ? v : 0)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const daysCount = rowsForWorker.reduce((set, r) => {
|
||||||
|
if (r.daily_total) set.add(r.date)
|
||||||
|
return set
|
||||||
|
}, new Set()).size
|
||||||
|
|
||||||
|
wsSum.addRow({
|
||||||
|
username,
|
||||||
|
full_name,
|
||||||
|
department: dept || '',
|
||||||
|
days: daysCount,
|
||||||
|
worked_hours: totalWorked,
|
||||||
|
paid_hours: totalPaid,
|
||||||
|
avg_paid: daysCount ? totalPaid / daysCount : 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalRow = ws.addRow({
|
||||||
|
date: '',
|
||||||
|
day: '',
|
||||||
|
clock_in: '',
|
||||||
|
clock_out: 'TOTAL',
|
||||||
|
work_hours: '',
|
||||||
|
qr_code_name: '',
|
||||||
|
daily_total: totalPaid,
|
||||||
|
})
|
||||||
|
totalRow.font = { bold: true }
|
||||||
|
totalRow.eachCell((cell) => {
|
||||||
|
cell.border = { top: { style: 'thin' } }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.eachRow((row) => {
|
ws.eachRow((row) => {
|
||||||
row.alignment = { vertical: 'middle' }
|
row.eachCell((cell) => {
|
||||||
|
cell.alignment = { ...(cell.alignment || {}), vertical: 'middle' }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const buf = await wb.xlsx.writeBuffer()
|
const buf = await wb.xlsx.writeBuffer()
|
||||||
@@ -521,7 +680,7 @@ export default function () {
|
|||||||
return res.send(Buffer.from(buf))
|
return res.send(Buffer.from(buf))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== CSV fallback: one row per day; include identity columns =====
|
// ===== CSV fallback =====
|
||||||
const json2csvParser = new Parser({
|
const json2csvParser = new Parser({
|
||||||
fields: [
|
fields: [
|
||||||
'username',
|
'username',
|
||||||
@@ -532,6 +691,7 @@ export default function () {
|
|||||||
'clock_out',
|
'clock_out',
|
||||||
'work_hours',
|
'work_hours',
|
||||||
'qr_code_name',
|
'qr_code_name',
|
||||||
|
'daily_total',
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const csv = json2csvParser.parse(csvData)
|
const csv = json2csvParser.parse(csvData)
|
||||||
@@ -624,11 +784,11 @@ export default function () {
|
|||||||
if (requesterId !== targetId) {
|
if (requesterId !== targetId) {
|
||||||
// If not, check if they have permission to manage permissions
|
// If not, check if they have permission to manage permissions
|
||||||
const [permissionRows] = await db.execute(
|
const [permissionRows] = await db.execute(
|
||||||
'SELECT can_manage_permissions FROM manager_permissions WHERE manager_id = ?',
|
'SELECT manager_permissions FROM manager_permissions WHERE manager_id = ?',
|
||||||
[requesterId],
|
[requesterId],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (permissionRows.length === 0 || !permissionRows[0].can_manage_permissions) {
|
if (permissionRows.length === 0 || !toBool(permissionRows[0].manager_permissions)) {
|
||||||
return res
|
return res
|
||||||
.status(403)
|
.status(403)
|
||||||
.json({ message: "Forbidden: Insufficient permissions to view others' permissions." })
|
.json({ message: "Forbidden: Insufficient permissions to view others' permissions." })
|
||||||
@@ -655,7 +815,7 @@ export default function () {
|
|||||||
// Convert buffer values to booleans
|
// Convert buffer values to booleans
|
||||||
const permissions = Object.entries(rows[0]).reduce((acc, [key, value]) => {
|
const permissions = Object.entries(rows[0]).reduce((acc, [key, value]) => {
|
||||||
if (key !== 'manager_id') {
|
if (key !== 'manager_id') {
|
||||||
acc[key] = Boolean(value)
|
acc[key] = toBool(value)
|
||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
@@ -666,6 +826,8 @@ export default function () {
|
|||||||
res
|
res
|
||||||
.status(500)
|
.status(500)
|
||||||
.json({ message: 'Database error fetching manager permissions.', details: error.message })
|
.json({ message: 'Database error fetching manager permissions.', details: error.message })
|
||||||
|
} finally {
|
||||||
|
db.release()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -724,9 +886,16 @@ export default function () {
|
|||||||
let whereClauses = ["w.role = 'worker'", "w.status != 'deleted'"] // Filter out soft-deleted workers
|
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 ?`)
|
||||||
params.push(searchTerm, searchTerm)
|
params.push(searchTerm)
|
||||||
countParams.push(searchTerm, searchTerm)
|
countParams.push(searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { department } = req.query
|
||||||
|
if (department) {
|
||||||
|
whereClauses.push(`LOWER(w.department) = LOWER(?)`)
|
||||||
|
params.push(department)
|
||||||
|
countParams.push(department)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (whereClauses.length > 0) {
|
if (whereClauses.length > 0) {
|
||||||
@@ -1154,12 +1323,10 @@ export default function () {
|
|||||||
res.status(200).json({ message: 'Device registration cleared and/or status updated.' })
|
res.status(200).json({ message: 'Device registration cleared and/or status updated.' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Reset device/update status error:', error)
|
console.error('Reset device/update status error:', error)
|
||||||
res
|
res.status(500).json({
|
||||||
.status(500)
|
message: 'Database error resetting device or updating status.',
|
||||||
.json({
|
details: error.message,
|
||||||
message: 'Database error resetting device or updating status.',
|
})
|
||||||
details: error.message,
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
db.release()
|
db.release()
|
||||||
}
|
}
|
||||||
@@ -1353,6 +1520,30 @@ export default function () {
|
|||||||
db.release()
|
db.release()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// GET distinct departments for filter tabs
|
||||||
|
router.get('/departments', checkPermission('view_all'), async (req, res) => {
|
||||||
|
const db = await getConnection()
|
||||||
|
try {
|
||||||
|
const [rows] = await db.execute(`
|
||||||
|
SELECT DISTINCT department
|
||||||
|
FROM workers
|
||||||
|
WHERE role = 'worker'
|
||||||
|
AND status != 'deleted'
|
||||||
|
AND department IS NOT NULL
|
||||||
|
AND department != ''
|
||||||
|
ORDER BY department ASC
|
||||||
|
`)
|
||||||
|
const departments = rows.map((r) => r.department)
|
||||||
|
res.json(departments)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get departments error:', error)
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: 'Database error fetching departments.', details: error.message })
|
||||||
|
} finally {
|
||||||
|
db.release()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@ const db = mysql.createPool({
|
|||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
port: process.env.DB_PORT,
|
port: process.env.DB_PORT,
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 50,
|
||||||
queueLimit: 0,
|
queueLimit: 100,
|
||||||
// timezone: '+08:00',
|
// timezone: '+08:00',
|
||||||
dateStrings: true,
|
dateStrings: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getConnection } from './pool.js'
|
||||||
|
// import mysql from 'mysql2/promise'
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// export const db = mysql.createPool({
|
||||||
|
// host: '47.254.195.2',
|
||||||
|
// user: 'nilai_clock_indo',
|
||||||
|
// password: '5jHy8ZsfeEjPAhYS',
|
||||||
|
// database: 'nilai_clock_indo',
|
||||||
|
// port: 3306,
|
||||||
|
// waitForConnections: true,
|
||||||
|
// connectionLimit: 10,
|
||||||
|
// queueLimit: 0,
|
||||||
|
// dateStrings: true,
|
||||||
|
// // 不在这里设置timezone,因为我们用查询设置
|
||||||
|
// });
|
||||||
|
|
||||||
|
const conn = await getConnection()
|
||||||
|
|
||||||
|
const [rows] = await conn.execute('select * from clock_records where id= 6654');
|
||||||
|
console.log(rows);
|
||||||
@@ -51,32 +51,59 @@
|
|||||||
|
|
||||||
<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-end justify-between">
|
|
||||||
<div class="flex-grow">
|
<div class="mb-6 flex items-end gap-4">
|
||||||
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')"
|
<div class="flex-1 min-w-0">
|
||||||
|
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByName')"
|
||||||
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" />
|
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>
|
||||||
|
<div class="shrink-0 flex flex-col gap-2">
|
||||||
<div class="flex items-end gap-4">
|
<label for="department-filter" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||||
<div class="flex flex-col gap-2">
|
$t('departmentFilter') }}</label>
|
||||||
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
<div class="relative">
|
||||||
$t('startDate') }}</label>
|
<select
|
||||||
<input type="date" id="export-start-date" v-model="exportFilters.startDate"
|
id="department-filter"
|
||||||
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" />
|
v-model="selectedDepartment"
|
||||||
</div>
|
class="appearance-none border border-gray-300 dark:border-gray-600 rounded-md pl-3 pr-10 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white min-w-[180px] w-full"
|
||||||
<div class="flex flex-col gap-2">
|
>
|
||||||
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
|
<option value="">{{ $t('allDepartments') }}</option>
|
||||||
}}</label>
|
<option v-for="dept in departments" :key="dept" :value="dept">
|
||||||
<input type="date" id="export-end-date" v-model="exportFilters.endDate"
|
{{ dept }}
|
||||||
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" />
|
</option>
|
||||||
</div>
|
</select>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 flex flex-col gap-2">
|
||||||
|
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||||
|
$t('startDate') }}</label>
|
||||||
|
<input type="date" id="export-start-date" v-model="exportFilters.startDate"
|
||||||
|
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 flex flex-col gap-2">
|
||||||
|
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
|
||||||
|
}}</label>
|
||||||
|
<input type="date" id="export-end-date" v-model="exportFilters.endDate"
|
||||||
|
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
<button @click="exportWorkHours"
|
<button @click="exportWorkHours"
|
||||||
:disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading"
|
: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">
|
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>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<button @click="exportTxt"
|
||||||
|
:disabled="!exportFilters.startDate || !exportFilters.endDate || txtExportLoading"
|
||||||
|
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">
|
||||||
|
{{ txtExportLoading ? $t('exporting') : 'Export TXT' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-[700px] w-full text-left">
|
<table class="min-w-[700px] w-full text-left">
|
||||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
@@ -415,6 +442,7 @@ const viewRecords = (workerId) => {
|
|||||||
workers: workers.value,
|
workers: workers.value,
|
||||||
selectedWorkerIds: selectedWorkerIds.value,
|
selectedWorkerIds: selectedWorkerIds.value,
|
||||||
exportFilters: exportFilters.value,
|
exportFilters: exportFilters.value,
|
||||||
|
selectedDepartment: selectedDepartment.value,
|
||||||
};
|
};
|
||||||
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
|
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
|
||||||
|
|
||||||
@@ -450,8 +478,11 @@ const confirmMessage = ref('');
|
|||||||
const isConfirmModalVisible = ref(false);
|
const isConfirmModalVisible = ref(false);
|
||||||
const exportFilters = ref({ startDate: '', endDate: '' });
|
const exportFilters = ref({ startDate: '', endDate: '' });
|
||||||
const exportLoading = ref(false);
|
const exportLoading = ref(false);
|
||||||
|
const txtExportLoading = ref(false);
|
||||||
const showClearDeviceConfirm = ref(false);
|
const showClearDeviceConfirm = ref(false);
|
||||||
const showDeleteConfirm = ref(false);
|
const showDeleteConfirm = ref(false);
|
||||||
|
const departments = ref([]);
|
||||||
|
const selectedDepartment = ref('');
|
||||||
|
|
||||||
// --- COMPUTED ---
|
// --- COMPUTED ---
|
||||||
const isFormValid = computed(
|
const isFormValid = computed(
|
||||||
@@ -467,6 +498,10 @@ const isAllSelected = computed(
|
|||||||
|
|
||||||
// --- WATCHERS ---
|
// --- WATCHERS ---
|
||||||
watch(searchQuery, () => fetchWorkers(1));
|
watch(searchQuery, () => fetchWorkers(1));
|
||||||
|
watch(selectedDepartment, () => {
|
||||||
|
currentPage.value = 1;
|
||||||
|
fetchWorkers(1);
|
||||||
|
});
|
||||||
watch(currentPage, (newPage) => {
|
watch(currentPage, (newPage) => {
|
||||||
selectedWorkerIds.value = [];
|
selectedWorkerIds.value = [];
|
||||||
jumpToPageInput.value = newPage;
|
jumpToPageInput.value = newPage;
|
||||||
@@ -476,9 +511,11 @@ watch(currentPage, (newPage) => {
|
|||||||
const fetchWorkers = async (page = currentPage.value) => {
|
const fetchWorkers = async (page = currentPage.value) => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch(
|
let url = `/api/managers/workers?search=${encodeURIComponent(searchQuery.value)}&page=${page}&limit=${pageSize.value}`;
|
||||||
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`
|
if (selectedDepartment.value) {
|
||||||
);
|
url += `&department=${encodeURIComponent(selectedDepartment.value)}`;
|
||||||
|
}
|
||||||
|
const data = await apiFetch(url);
|
||||||
workers.value = data.workers;
|
workers.value = data.workers;
|
||||||
totalWorkers.value = data.totalCount;
|
totalWorkers.value = data.totalCount;
|
||||||
|
|
||||||
@@ -496,6 +533,14 @@ const fetchWorkers = async (page = currentPage.value) => {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const fetchDepartments = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/api/managers/departments');
|
||||||
|
departments.value = data;
|
||||||
|
} catch (_err) {
|
||||||
|
console.error('Failed to fetch departments');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const changePage = (page) => {
|
const changePage = (page) => {
|
||||||
if (page > 0 && page <= totalPages.value) {
|
if (page > 0 && page <= totalPages.value) {
|
||||||
@@ -693,9 +738,14 @@ const exportWorkHours = async () => {
|
|||||||
const { startDate, endDate } = exportFilters.value;
|
const { startDate, endDate } = exportFilters.value;
|
||||||
const workerIds = selectedWorkerIds.value.join(',');
|
const workerIds = selectedWorkerIds.value.join(',');
|
||||||
|
|
||||||
|
let exportUrl = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`;
|
||||||
|
if (selectedDepartment.value) {
|
||||||
|
exportUrl += `&department=${encodeURIComponent(selectedDepartment.value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`,
|
exportUrl,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${sessionStorage.getItem('token')}`,
|
Authorization: `Bearer ${sessionStorage.getItem('token')}`,
|
||||||
@@ -720,7 +770,41 @@ const exportWorkHours = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportTxt = async () => {
|
||||||
|
const toast = useToast();
|
||||||
|
txtExportLoading.value = true;
|
||||||
|
const { startDate, endDate } = exportFilters.value;
|
||||||
|
const workerIds = selectedWorkerIds.value.join(',');
|
||||||
|
let exportUrl = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=txt&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`;
|
||||||
|
if (selectedDepartment.value) {
|
||||||
|
exportUrl += `&department=${encodeURIComponent(selectedDepartment.value)}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(exportUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${sessionStorage.getItem('token')}`,
|
||||||
|
'X-User-Timezone': getUserTimezone(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Network response was not ok.');
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `attendance_${startDate}_to_${endDate}.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (_err) {
|
||||||
|
toast.showToast('Export TXT failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
txtExportLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
fetchDepartments();
|
||||||
const savedSearchState = sessionStorage.getItem('personnelSearchState');
|
const savedSearchState = sessionStorage.getItem('personnelSearchState');
|
||||||
if (savedSearchState) {
|
if (savedSearchState) {
|
||||||
try {
|
try {
|
||||||
@@ -732,6 +816,7 @@ onMounted(() => {
|
|||||||
workers.value = searchState.workers || [];
|
workers.value = searchState.workers || [];
|
||||||
selectedWorkerIds.value = searchState.selectedWorkerIds || [];
|
selectedWorkerIds.value = searchState.selectedWorkerIds || [];
|
||||||
exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' };
|
exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' };
|
||||||
|
selectedDepartment.value = searchState.selectedDepartment || '';
|
||||||
sessionStorage.removeItem('personnelSearchState');
|
sessionStorage.removeItem('personnelSearchState');
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
fetchWorkers();
|
fetchWorkers();
|
||||||
|
|||||||
@@ -115,6 +115,7 @@
|
|||||||
"chooseTag": "-- Choose a tag --",
|
"chooseTag": "-- Choose a tag --",
|
||||||
"addByTag": "Add by Tag",
|
"addByTag": "Add by Tag",
|
||||||
"selectedForReport": "Selected for Report ({count})",
|
"selectedForReport": "Selected for Report ({count})",
|
||||||
|
"all": "All",
|
||||||
"allWorkersSelected": "All Workers ({count}) Selected",
|
"allWorkersSelected": "All Workers ({count}) Selected",
|
||||||
"noWorkersSelected": "No workers selected.",
|
"noWorkersSelected": "No workers selected.",
|
||||||
"reportSettings": "2. Report Settings",
|
"reportSettings": "2. Report Settings",
|
||||||
@@ -139,6 +140,9 @@
|
|||||||
"exportAll": "Export All",
|
"exportAll": "Export All",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
|
|
||||||
|
"filterByDepartment": "Filter by Department",
|
||||||
|
"departmentFilter": "Departments",
|
||||||
|
"allDepartments": "All Departments",
|
||||||
"addNewUser": "Add New User",
|
"addNewUser": "Add New User",
|
||||||
"fullName": "Full Name",
|
"fullName": "Full Name",
|
||||||
"department": "Department",
|
"department": "Department",
|
||||||
@@ -159,6 +163,7 @@
|
|||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"workerRoster": "Employee List",
|
"workerRoster": "Employee List",
|
||||||
"searchByNameOrUsername": "Search by name/username",
|
"searchByNameOrUsername": "Search by name/username",
|
||||||
|
"searchByName": "Search by Name",
|
||||||
"searchByNameOrDepartment": "Search by name/department",
|
"searchByNameOrDepartment": "Search by name/department",
|
||||||
"filterByTag": "Filter by tag",
|
"filterByTag": "Filter by tag",
|
||||||
"clearFilter": "Clear filter",
|
"clearFilter": "Clear filter",
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"chooseTag": "-- Pilih tag --",
|
"chooseTag": "-- Pilih tag --",
|
||||||
"addByTag": "Tambah melalui Tag",
|
"addByTag": "Tambah melalui Tag",
|
||||||
"selectedForReport": "Dipilih untuk Laporan ({count})",
|
"selectedForReport": "Dipilih untuk Laporan ({count})",
|
||||||
|
"all": "Semua",
|
||||||
"allWorkersSelected": "Semua Pekerja ({count}) Dipilih",
|
"allWorkersSelected": "Semua Pekerja ({count}) Dipilih",
|
||||||
"noWorkersSelected": "Tiada pekerja dipilih.",
|
"noWorkersSelected": "Tiada pekerja dipilih.",
|
||||||
"reportSettings": "2. Tetapan Laporan",
|
"reportSettings": "2. Tetapan Laporan",
|
||||||
@@ -139,6 +140,9 @@
|
|||||||
"reportGenerationError": "Ralat semasa menjana laporan.",
|
"reportGenerationError": "Ralat semasa menjana laporan.",
|
||||||
"exportAll": "Eksport Semua",
|
"exportAll": "Eksport Semua",
|
||||||
"export": "Eksport",
|
"export": "Eksport",
|
||||||
|
"filterByDepartment": "Tapis mengikut Jabatan",
|
||||||
|
"departmentFilter": "Jabatan:",
|
||||||
|
"allDepartments": "Semua Jabatan",
|
||||||
"addNewUser": "Tambah Pengguna Baru",
|
"addNewUser": "Tambah Pengguna Baru",
|
||||||
"fullName": "Nama Penuh",
|
"fullName": "Nama Penuh",
|
||||||
"department": "Jabatan",
|
"department": "Jabatan",
|
||||||
@@ -158,6 +162,7 @@
|
|||||||
"createTag": "Cipta Tag",
|
"createTag": "Cipta Tag",
|
||||||
"tags": "Tag",
|
"tags": "Tag",
|
||||||
"workerRoster": "Deftar Pekerja",
|
"workerRoster": "Deftar Pekerja",
|
||||||
|
"searchByName": "Cari mengikut Nama",
|
||||||
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
|
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
|
||||||
"searchByNameOrDepartment": " Cari nama atau jabatan",
|
"searchByNameOrDepartment": " Cari nama atau jabatan",
|
||||||
"filterByTag": "Tapis mengikut tag",
|
"filterByTag": "Tapis mengikut tag",
|
||||||
|
|||||||
+4
-2
@@ -97,7 +97,8 @@
|
|||||||
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
|
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
|
||||||
"addByTag": "டேக் மூலம் சேர்க்கவும்",
|
"addByTag": "டேக் மூலம் சேர்க்கவும்",
|
||||||
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
|
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
|
||||||
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
|
"all": "அனைத்தும்",
|
||||||
|
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
|
||||||
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
|
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
|
||||||
"reportSettings": "2. அறிக்கை அமைப்புகள்",
|
"reportSettings": "2. அறிக்கை அமைப்புகள்",
|
||||||
"setting": "அமைப்பு",
|
"setting": "அமைப்பு",
|
||||||
@@ -133,7 +134,8 @@
|
|||||||
"createTag": "டேக் உருவாக்கவும்",
|
"createTag": "டேக் உருவாக்கவும்",
|
||||||
"tags": "டேக்குகள்",
|
"tags": "டேக்குகள்",
|
||||||
"workerRoster": "பணியாளர் பட்டியல்",
|
"workerRoster": "பணியாளர் பட்டியல்",
|
||||||
"searchByNameOrUsername": "பெயர் அல்லது பயனர் பெயர் மூலம் தேடவும்",
|
"searchByName": "பெயரால் தேடு",
|
||||||
|
"searchByNameOrUsername": "பெயர் அல்லது பயனர்பெயரால் தேடு",
|
||||||
"filterByTag": "டேக் மூலம் வடிகட்டவும்",
|
"filterByTag": "டேக் மூலம் வடிகட்டவும்",
|
||||||
"clearFilter": "வடிகட்டியைத் துடைக்கவும்",
|
"clearFilter": "வடிகட்டியைத் துடைக்கவும்",
|
||||||
"dateJoined": "சேர்ந்த தேதி",
|
"dateJoined": "சேர்ந்த தேதி",
|
||||||
|
|||||||
Reference in New Issue
Block a user