feat: 添加密码修改功能并集成Tailwind CSS

refactor: 重构UI组件使用Tailwind CSS
feat(router): 添加密码修改路由
feat(views): 实现密码修改页面
feat(api): 添加密码修改API端点
style: 移除旧CSS文件并配置Tailwind
chore: 添加Tailwind CSS相关依赖
This commit is contained in:
sudomarcma
2025-06-26 17:16:57 +08:00
parent 5d3c618722
commit 0676d64af3
18 changed files with 3069 additions and 2135 deletions
+227 -14
View File
@@ -186,41 +186,254 @@ async function startServer() {
}
})
// Manager: GET All Workers with Search and Pagination
app.put('/api/worker/change-password', authenticateJWT, async (req, res) => {
try {
const { userId } = req.user // Get user ID from JWT
const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) {
return res.status(400).json({ message: 'Current password and new password are required.' })
}
if (newPassword.length < 6) {
return res.status(400).json({ message: 'New password must be at least 6 characters long.' })
}
// Get user's current password hash
const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId])
if (rows.length === 0) {
return res.status(404).json({ message: 'User not found.' })
}
const user = rows[0]
// Verify current password
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash)
if (!passwordMatch) {
return res.status(401).json({ message: 'Incorrect current password.' })
}
// Hash new password
const saltRounds = 10
const newHashedPassword = await bcrypt.hash(newPassword, saltRounds)
// Update password in DB
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [
newHashedPassword,
userId,
])
res.json({ message: 'Password updated successfully.' })
} catch (error) {
console.error('Change password error:', error)
res.status(500).json({ message: 'Database error during password change.' })
}
})
// Manager: PUT (Update) a Worker's Password
app.put('/api/managers/workers/:workerId/password', authenticateJWT, async (req, res) => {
try {
// Ensure the user performing the action is a manager
if (req.user.role !== 'manager') {
return res
.status(403)
.json({ message: 'Forbidden: You do not have permission to perform this action.' })
}
const { workerId } = req.params
const { newPassword } = req.body
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ message: 'Password must be at least 6 characters long.' })
}
const saltRounds = 10
const hashedPassword = await bcrypt.hash(newPassword, saltRounds)
const [result] = await db.execute(
"UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'worker'",
[hashedPassword, workerId],
)
if (result.affectedRows === 0) {
return res
.status(404)
.json({ message: 'Worker not found or you cannot change the password for this user.' })
}
res.status(200).json({ message: 'Password updated successfully.' })
} catch (error) {
console.error('Update password error:', error)
res.status(500).json({ message: 'Database error while updating password.' })
}
})
// GET all tags
app.get('/api/managers/tags', authenticateJWT, async (req, res) => {
try {
const [tags] = await db.execute('SELECT * FROM tags ORDER BY tag_name ASC')
res.json(tags)
} catch (error) {
console.error('Get tags error:', error)
res.status(500).json({ message: 'Database error fetching tags.' })
}
})
// POST a new tag
app.post('/api/managers/tags', authenticateJWT, async (req, res) => {
try {
const { tag_name } = req.body
if (!tag_name) {
return res.status(400).json({ message: 'Tag name is required.' })
}
const [result] = await db.execute('INSERT INTO tags (tag_name) VALUES (?)', [tag_name])
res.status(201).json({ id: result.insertId, tag_name })
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'This tag already exists.' })
}
console.error('Add tag error:', error)
res.status(500).json({ message: 'Database error adding tag.' })
}
})
// POST to assign a tag to a worker
app.post('/api/managers/workers/:workerId/tags', authenticateJWT, async (req, res) => {
try {
const { workerId } = req.params
const { tagId } = req.body // Expects a single tag ID
if (!tagId) {
return res.status(400).json({ message: 'Tag ID is required.' })
}
// INSERT IGNORE prevents errors if the tag is already assigned to the worker
await db.query('INSERT IGNORE INTO worker_tags (worker_id, tag_id) VALUES (?, ?)', [
workerId,
tagId,
])
res.status(200).json({ message: 'Tag assigned successfully.' })
} catch (error) {
console.error('Assign tag error:', error)
res.status(500).json({ message: 'Database error assigning tag.' })
}
})
// DELETE to remove a tag from a worker
app.delete('/api/managers/workers/:workerId/tags/:tagId', authenticateJWT, async (req, res) => {
try {
const { workerId, tagId } = req.params
await db.query('DELETE FROM worker_tags WHERE worker_id = ? AND tag_id = ?', [
workerId,
tagId,
])
res.status(204).send() // 204 No Content for successful deletion
} catch (error) {
console.error('Remove tag error:', error)
res.status(500).json({ message: 'Database error removing tag.' })
}
})
// Find this endpoint in your server.js and replace it with the code below.
// Manager: GET All Workers (FIXED for older MySQL versions)
app.get('/api/managers/workers', authenticateJWT, async (req, res) => {
try {
const { search = '', page = 1, limit = 20 } = req.query
const { search = '', page = 1, limit = 20, tags = '' } = req.query
const offset = (parseInt(page) - 1) * parseInt(limit)
const searchTerm = `%${search}%`
const [workers] = await db.execute(
`SELECT id, username, full_name, created_at FROM workers WHERE role = 'worker' AND (full_name LIKE ? OR username LIKE ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[searchTerm, searchTerm, parseInt(limit), offset],
)
const [[{ totalCount }]] = await db.execute(
`SELECT COUNT(*) as totalCount FROM workers WHERE role = 'worker' AND (full_name LIKE ? OR username LIKE ?)`,
[searchTerm, searchTerm],
)
const tagIds = tags
.split(',')
.filter((id) => id)
.map(Number)
const hasTagFilter = tagIds.length > 0
// Base queries
let baseQuery = `
SELECT
w.id, w.username, w.full_name, w.created_at,
(SELECT GROUP_CONCAT(t.tag_name SEPARATOR ', ')
FROM worker_tags wt_sub
JOIN tags t ON wt_sub.tag_id = t.id
WHERE wt_sub.worker_id = w.id) as tags
FROM workers w
`
let countQuery = `SELECT COUNT(DISTINCT w.id) as totalCount FROM workers w`
// Parameters for the queries
const params = []
const countParams = []
// Join with worker_tags if filtering
if (hasTagFilter) {
const joinClause = ` JOIN worker_tags wt ON w.id = wt.worker_id`
baseQuery += joinClause
countQuery += joinClause
}
// Common WHERE clause
const whereClause = ` WHERE w.role = 'worker' AND (w.full_name LIKE ? OR w.username LIKE ?)`
baseQuery += whereClause
countQuery += whereClause
params.push(searchTerm, searchTerm)
countParams.push(searchTerm, searchTerm)
// Add tag filtering logic
if (hasTagFilter) {
const tagPlaceholders = tagIds.map(() => '?').join(',')
const tagFilterClause = ` AND wt.tag_id IN (${tagPlaceholders})`
baseQuery += tagFilterClause
countQuery += tagFilterClause
// Add the tag IDs to the parameters individually
params.push(...tagIds)
countParams.push(...tagIds)
// --- FIX END ---
}
// Grouping and pagination for the main query
if (hasTagFilter) {
baseQuery += ` GROUP BY w.id HAVING COUNT(DISTINCT wt.tag_id) = ?`
params.push(tagIds.length)
}
baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?`
params.push(parseInt(limit), offset)
// Execute queries
const [workers] = await db.execute(baseQuery, params)
const [[{ totalCount }]] = await db.execute(countQuery, countParams)
res.json({ workers, totalCount })
} catch (error) {
// This is the error you are seeing
console.error('Get workers error:', error)
res.status(500).json({ message: 'Database error fetching workers.' })
}
})
// Manager: POST (Add new) Worker
app.post('/api/managers/workers', authenticateJWT, async (req, res) => {
try {
const { username, password, fullName } = req.body
const { username, password, fullName, role = 'worker' } = req.body
if (!username || !password || !fullName) {
return res.status(400).json({ message: 'Username, password, and full name are required.' })
}
if (!['worker', 'manager'].includes(role)) {
return res.status(400).json({ message: 'Invalid role specified.' })
}
const saltRounds = 10
const hashedPassword = await bcrypt.hash(password, saltRounds)
const [result] = await db.execute(
"INSERT INTO workers (username, password_hash, full_name, role) VALUES (?, ?, ?, 'worker')",
[username, hashedPassword, fullName],
'INSERT INTO workers (username, password_hash, full_name, role) VALUES (?, ?, ?, ?)',
[username, hashedPassword, fullName, role], // Pass role to query
)
res.status(201).json({ id: result.insertId, username, fullName })
res.status(201).json({ id: result.insertId, username, fullName, role })
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'Username already exists.' })
+1368 -579
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@capacitor/cli": "^7.4.0",
"@capacitor/core": "^7.4.0",
"@primeuix/themes": "^1.1.2",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
@@ -24,6 +25,7 @@
"json2csv": "^6.0.0-alpha.2",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.14.1",
"primevue": "^4.3.5",
"qrcode": "^1.5.4",
"uuid": "^11.1.0",
"vue": "^3.5.13",
@@ -31,13 +33,17 @@
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@tailwindcss/vite": "^4.1.10",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.21",
"concurrently": "^9.1.2",
"eslint": "^9.22.0",
"eslint-plugin-vue": "~10.0.0",
"globals": "^16.0.0",
"postcss": "^8.5.6",
"prettier": "3.5.3",
"tailwindcss": "^4.1.10",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2"
}
+24 -49
View File
@@ -1,15 +1,30 @@
<template>
<div class="app-container">
<header class="app-header">
<h1>Clock-In/Out System</h1>
<div class="header-actions">
<button v-if="isLoggedIn" @click="logout" class="button-secondary">Logout</button>
<button @click="toggleTheme" class="theme-toggle" title="Toggle Theme">
{{ isDarkMode ? '☀️' : '🌙' }}
<div
class="min-h-screen bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors duration-300"
>
<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"
>
<h1 class="text-xl sm:text-2xl font-bold">Clock-In/Out System</h1>
<div class="flex items-center gap-4">
<button
v-if="isLoggedIn"
@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"
>
Logout
</button>
<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"
title="Toggle Theme"
>
<span v-if="isDarkMode"></span>
<span v-else>🌙</span>
</button>
</div>
</header>
<main>
<main class="p-4 sm:p-8">
<RouterView />
</main>
</div>
@@ -23,10 +38,8 @@ const isDarkMode = ref(false)
const router = useRouter()
const route = useRoute()
// --- ALIGNMENT CHANGE ---
const isLoggedIn = ref(!!sessionStorage.getItem('userId'))
// Watch for route changes to update login status
watch(
() => route.path,
() => {
@@ -63,43 +76,5 @@ onMounted(() => {
</script>
<style scoped>
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
/* Other styles remain the same */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: var(--c-bg-secondary);
border-bottom: 1px solid var(--c-border);
box-shadow: var(--shadow-sm);
}
.app-header h1 {
font-size: 1.5rem;
margin: 0;
}
.theme-toggle {
background: var(--c-bg-tertiary);
color: var(--c-text-primary);
font-size: 1.25rem;
padding: 8px;
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
main {
padding: 1rem;
}
@media (min-width: 768px) {
main {
padding: 2rem;
}
}
/* All styles are now handled by Tailwind CSS classes in the template. */
</style>
-86
View File
@@ -1,86 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+2 -128
View File
@@ -1,128 +1,2 @@
:root {
--font-sans:
'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji';
/* Light Theme */
--c-bg-primary: #f0f2f5;
--c-bg-secondary: #ffffff;
--c-bg-tertiary: #e4e6eb;
--c-text-primary: #050505;
--c-text-secondary: #65676b;
--c-border: #ced0d4;
--c-primary: #0866ff;
--c-primary-text: #ffffff;
--c-success: #31a24c;
--c-danger: #e41e3f;
--c-secondary: #05cece;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--radius: 8px;
}
.dark {
/* Dark Theme */
--c-bg-primary: #18191a;
--c-bg-secondary: #242526;
--c-bg-tertiary: #3a3b3c;
--c-text-primary: #e4e6eb;
--c-text-secondary: #b0b3b8;
--c-border: #3e4042;
--c-primary: #2374e1;
--c-success: #45bd62;
--c-danger: #f02849;
}
/* Global Styles */
body {
margin: 0;
font-family: var(--font-sans);
background-color: var(--c-bg-primary);
color: var(--c-text-primary);
transition:
background-color 0.2s ease,
color 0.2s ease;
}
#app {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button {
font-family: var(--font-sans);
font-weight: 600;
border-radius: 6px;
border: none;
padding: 10px 16px;
cursor: pointer;
transition:
background-color 0.2s ease,
transform 0.1s ease;
}
button:active {
transform: scale(0.98);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
background-color: var(--c-bg-secondary);
}
th,
td {
border: 1px solid var(--c-border);
padding: 12px 15px;
text-align: left;
}
th {
background-color: var(--c-bg-tertiary);
}
.card {
background: var(--c-bg-secondary);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow-sm);
}
.form-input {
padding: 10px;
border: 1px solid var(--c-border);
border-radius: 6px;
background-color: var(--c-bg-secondary);
color: var(--c-text-primary);
font-size: 1rem;
}
.button-primary {
background-color: var(--c-primary);
color: var(--c-primary-text);
}
.button-primary:disabled {
background-color: var(--c-bg-tertiary);
color: var(--c-text-secondary);
cursor: not-allowed;
}
.button-secondary {
background-color: var(--c-bg-tertiary);
color: var(--c-text-primary);
}
.button-danger {
background-color: transparent;
border: 1px solid var(--c-danger);
color: var(--c-danger);
}
.button-danger:hover {
background-color: var(--c-danger);
color: var(--c-primary-text);
}
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
+317 -437
View File
@@ -1,26 +1,31 @@
<template>
<div class="attendance-reporting-layout">
<div class="selection-panel">
<section class="card">
<h2 class="panel-header">1. Select Workers</h2>
<div class="selection-controls">
<div class="search-box">
<div class="flex flex-col lg:flex-row gap-8 attendance-reporting-layout">
<div class="selection-panel w-full lg:w-1/2">
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 h-full flex flex-col">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">1. Select Workers</h2>
<div class="selection-controls flex flex-col sm:flex-row gap-4 sm:gap-2 mb-4">
<div class="search-box relative flex-grow">
<input
type="text"
v-model="searchQuery"
placeholder="Search for a worker..."
class="form-input"
class="form-input 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"
@keydown.enter.prevent="handleSearch"
@keydown.down.prevent="navigateResults(1)"
@keydown.up.prevent="navigateResults(-1)"
@keydown.esc.prevent="clearSearch"
/>
<div v-if="searchResults.length > 0" class="search-results-list">
<div
v-if="searchResults.length > 0"
class="search-results-list absolute top-full left-0 right-0 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 border-t-0 rounded-b-md shadow-lg z-10 max-h-60 overflow-y-auto"
>
<ul>
<li
v-for="(worker, index) in searchResults"
:key="worker.id"
:class="{ highlighted: index === highlightedIndex }"
:class="{ 'bg-blue-100 dark:bg-blue-900': index === highlightedIndex }"
class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 border-b border-gray-100 dark:border-gray-700 last:border-b-0 text-gray-900 dark:text-white"
@click="selectWorker(worker)"
@mouseenter="highlightedIndex = index"
>
@@ -29,105 +34,199 @@
</ul>
</div>
</div>
<button @click="handleSelectAll" class="button-secondary select-all-btn">
<button
@click="handleSelectAll"
class="button-secondary 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 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto"
>
Select All
</button>
</div>
<div class="selected-workers-list">
<h4>Selected for Report ({{ selectedWorkers.length }})</h4>
<ul v-if="isSelectAllActive">
<li>
<div class="tag-selection mb-6">
<div class="form-group flex flex-col gap-2">
<label for="tag-select" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Add all workers from a tag</label
>
<div class="selection-controls flex flex-col sm:flex-row gap-4 sm:gap-2 items-end">
<select
id="tag-select"
v-model="selectedTagId"
class="form-input 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"
>
<option :value="null" disabled>-- Choose a tag --</option>
<option v-for="tag in allTags" :key="tag.id" :value="tag.id">
{{ tag.tag_name }}
</option>
</select>
<button
@click="addWorkersByTag"
:disabled="!selectedTagId"
class="button-secondary 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 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto"
>
Add by Tag
</button>
</div>
</div>
</div>
<div class="selected-workers-list flex-grow">
<h4 class="text-lg font-semibold mb-3 text-gray-800 dark:text-white">
Selected for Report ({{ selectedWorkers.length }})
</h4>
<ul v-if="isSelectAllActive" class="space-y-2">
<li
class="flex justify-between items-center bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-4 py-2 text-gray-700 dark:text-gray-200"
>
<span>All Workers ({{ selectedWorkers.length }}) Selected</span>
<button @click="clearAllSelection" class="remove-btn">×</button>
<button
@click="clearAllSelection"
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-xl leading-none"
>
&times;
</button>
</li>
</ul>
<ul v-else-if="selectedWorkers.length > 0">
<li v-for="worker in selectedWorkers" :key="worker.id">
<ul v-else-if="selectedWorkers.length > 0" class="flex flex-wrap gap-2">
<li
v-for="worker in selectedWorkers"
:key="worker.id"
class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-1 text-gray-700 dark:text-gray-200 text-sm font-medium"
>
<span>{{ worker.full_name }}</span>
<button @click="removeWorker(worker.id)" class="remove-btn">×</button>
<button
@click="removeWorker(worker.id)"
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-base leading-none"
>
&times;
</button>
</li>
</ul>
<p v-else class="empty-state">No workers selected.</p>
<p v-else class="empty-state text-gray-500 dark:text-gray-400 italic mt-4">
No workers selected.
</p>
</div>
</section>
</div>
<div class="results-panel">
<section class="card">
<h2 class="panel-header">2. Report Settings</h2>
<div class="filters">
<div class="form-group">
<label for="start-date">Start Date</label>
<input type="date" id="start-date" v-model="filters.startDate" class="form-input" />
<div class="results-panel w-full lg:w-1/2">
<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">2. Report Settings</h2>
<div
class="filters flex flex-col sm:flex-row gap-4 border-b border-gray-200 dark:border-gray-700 pb-6 mb-6"
>
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Start Date</label
>
<input
type="date"
id="start-date"
v-model="filters.startDate"
class="form-input 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="form-group">
<label for="end-date">End Date</label>
<input type="date" id="end-date" v-model="filters.endDate" class="form-input" />
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>End Date</label
>
<input
type="date"
id="end-date"
v-model="filters.endDate"
class="form-input 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>
<div class="overtime-settings-section" v-if="selectedWorkers.length > 0">
<div class="worker-salaries">
<h4>Monthly Salary (RM)</h4>
<p class="subtitle">Applied to all selected workers.</p>
<div class="form-group">
<div
class="overtime-settings-section flex flex-col gap-5"
v-if="selectedWorkers.length > 0"
>
<div class="worker-salaries border-b border-gray-200 dark:border-gray-700 pb-5">
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">
Monthly Salary (RM)
</h4>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
Applied to all selected workers.
</p>
<div class="form-group flex flex-col gap-2">
<input
id="monthly-salary"
type="number"
v-model.number="monthlySalary"
class="form-input"
class="form-input 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="e.g., 3000"
/>
</div>
</div>
<div class="factor-settings">
<h4>OT Factors</h4>
<div class="factor-input">
<div class="form-group">
<p class="subtitle">Weekend Factor</p>
<div class="factor-settings border-b border-gray-200 dark:border-gray-700 pb-5">
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">OT Factors</h4>
<div class="factor-input flex flex-col sm:flex-row gap-4">
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Weekend Factor</p>
<input
id="rest-day-factor"
type="number"
v-model.number="overtimeSettings.restDayFactor"
class="form-input"
class="form-input 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="form-group">
<p class="subtitle">Holiday Factor</p>
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Holiday Factor</p>
<input
id="holiday-factor"
type="number"
v-model.number="overtimeSettings.publicHolidayFactor"
class="form-input"
class="form-input 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>
</div>
<div class="holiday-picker">
<h5>Select Public Holidays</h5>
<div class="calendar">
<div class="calendar-header">
<button @click="changeMonth(-1)"></button>
<h5 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">
Select Public Holidays
</h5>
<div
class="calendar border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-w-sm mx-auto"
>
<div
class="calendar-header flex justify-between items-center font-semibold mb-4 text-gray-800 dark:text-white"
>
<button
@click="changeMonth(-1)"
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150"
>
&lt;
</button>
<span>{{ calendarGrid.monthName }} {{ calendarGrid.year }}</span>
<button @click="changeMonth(1)"></button>
<button
@click="changeMonth(1)"
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150"
>
&gt;
</button>
</div>
<div class="calendar-weekdays">
<div
class="calendar-weekdays grid grid-cols-7 text-center text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700 pb-2 mb-2"
>
<div v-for="day in calendarGrid.weekdayLabels" :key="day" class="weekday">
{{ day }}
</div>
</div>
<div class="calendar-days">
<div class="calendar-days grid grid-cols-7 text-center">
<div
v-for="(day, index) in calendarGrid.grid"
:key="index"
class="day-cell"
class="day-cell h-9 w-9 flex items-center justify-center rounded-full cursor-pointer transition-colors duration-100"
:class="{
padding: day.type === 'padding',
holiday: day.isHoliday,
'text-gray-400 dark:text-gray-500 cursor-default': day.type === 'padding',
'bg-blue-600 text-white font-bold': day.isHoliday,
'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-white':
day.type === 'day' && !day.isHoliday,
}"
@click="day.type === 'day' && toggleHoliday(day.dateString)"
>
@@ -138,75 +237,131 @@
</div>
</div>
<div class="action-buttons">
<div class="action-buttons mt-8 flex justify-end">
<button
@click="generateReport"
:disabled="!canGenerate"
style="background-color: var(--c-success)"
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-6 py-3 rounded-md w-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
Generate Attendance & OT Report
</button>
</div>
</section>
<section class="card" style="margin-top: 2rem" v-if="reportGenerated">
<div class="results-display">
<div v-if="overtimeReport" class="overtime-report">
<div class="report-header">
<h4>Overtime Pay Summary</h4>
<button
@click="exportOtSummaryCsv"
:disabled="!overtimeReport"
class="button-primary"
>
Export OT Summary (CSV)
</button>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Worker</th>
<th>Total Hours Worked</th>
<th>Total OT Pay (RM)</th>
</tr>
</thead>
<tbody>
<tr v-for="(report, name) in overtimeReport" :key="name">
<td>{{ name }}</td>
<td>{{ report.totalHours.toFixed(2) }}</td>
<td>{{ report.totalOtPay.toFixed(2) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="raw-logs" v-if="reportData.length > 0">
<h4 style="margin-top: 2rem">Raw Attendance Data</h4>
<div
v-for="(group, workerName) in groupedReportData"
:key="workerName"
class="worker-group"
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8" v-if="reportGenerated">
<div v-if="overtimeReport" class="overtime-report mb-8">
<div
class="report-header flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4"
>
<h4 class="text-xl font-semibold text-gray-800 dark:text-white mb-2 sm:mb-0">
Overtime Pay Summary
</h4>
<button
@click="exportOtSummaryCsv"
:disabled="!overtimeReport"
class="button-primary bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
<h5 class="worker-group-header">{{ workerName }}</h5>
<table>
<thead>
<tr>
<th>Event</th>
<th>Timestamp</th>
<th>Location</th>
Export OT Summary (CSV)
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-[400px] 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"
>
Worker
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Total Hours Worked
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Total OT Pay (RM)
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="(report, name) in overtimeReport"
:key="name"
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">{{ name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ report.totalHours.toFixed(2) }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ report.totalOtPay.toFixed(2) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="raw-logs" v-if="reportData.length > 0">
<h4 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">
Raw Attendance Data
</h4>
<div
v-for="(group, workerName) in groupedReportData"
:key="workerName"
class="worker-group mb-6 last:mb-0"
>
<h5
class="worker-group-header bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md mb-3"
>
{{ workerName }}
</h5>
<div class="overflow-x-auto">
<table class="min-w-[500px] 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"
>
Event
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Timestamp
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Location
</th>
</tr>
</thead>
<tbody>
<tr v-for="record in group" :key="record.id">
<td>
<span class="event-type" :class="record.event_type">{{
record.event_type.replace('_', ' ')
}}</span>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="record in group"
:key="record.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
<td class="px-4 py-3">
<span
class="inline-block px-3 py-1 rounded-md text-xs font-semibold uppercase text-white"
:class="{
'bg-green-500': record.event_type === 'clock_in',
'bg-red-500': record.event_type === 'clock_out',
}"
>
{{ record.event_type.replace('_', ' ') }}
</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(record.timestamp).toLocaleString() }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ record.qrCodeUsedName }}
</td>
<td>{{ new Date(record.timestamp).toLocaleString() }}</td>
<td>{{ record.qrCodeUsedName }}</td>
</tr>
</tbody>
</table>
@@ -214,8 +369,11 @@
</div>
</div>
</section>
<section class="card" style="margin-top: 2rem; text-align: center" v-if="loadingReport">
<p>Loading Report...</p>
<section
class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8 text-center"
v-if="loadingReport"
>
<p class="text-lg font-medium text-gray-700 dark:text-gray-200">Loading Report...</p>
</section>
</div>
</div>
@@ -223,7 +381,6 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
// CORRECT: Import the apiFetch wrapper
import { apiFetch } from '@/api.js'
// --- STATE ---
@@ -235,6 +392,10 @@ const isSelectAllActive = ref(false)
const selectedWorkers = ref([])
const filters = ref({ startDate: '', endDate: '' })
// NEW: State for tag selection
const allTags = ref([])
const selectedTagId = ref(null)
const loadingReport = ref(false)
const reportGenerated = ref(false)
const reportData = ref([])
@@ -306,6 +467,46 @@ const calendarGrid = computed(() => {
}
})
// --- METHODS ---
const fetchInitialData = async () => {
// Setup default dates
const today = new Date()
filters.value.endDate = today.toISOString().split('T')[0]
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(today.getDate() - 7)
filters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
calendarDate.value = new Date(filters.value.startDate + 'T00:00:00')
// NEW: Fetch all available tags
try {
allTags.value = await apiFetch('/api/managers/tags')
} catch (err) {
console.error('Failed to fetch tags', err)
}
}
const addWorkersByTag = async () => {
if (!selectedTagId.value) return
try {
// Use the API to get all workers with the selected tag
const data = await apiFetch(`/api/managers/workers?tags=${selectedTagId.value}&limit=1000`)
// Add the fetched workers to the selection list, avoiding duplicates
data.workers.forEach((worker) => {
if (!selectedWorkers.value.some((sw) => sw.id === worker.id)) {
selectedWorkers.value.push(worker)
}
})
// Reset the dropdown
selectedTagId.value = null
} catch (err) {
console.error('Failed to fetch workers by tag', err)
alert('Could not load workers for the selected tag.')
}
}
const handleSearch = async () => {
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
selectWorker(searchResults.value[highlightedIndex.value])
@@ -320,7 +521,6 @@ const handleSearch = async () => {
const fetchWorkers = async (selectAll = false) => {
try {
// CORRECT: Use apiFetch and get data directly
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&limit=1000`)
if (selectAll) {
data.workers.forEach((worker) => {
@@ -350,6 +550,7 @@ const navigateResults = (direction) => {
highlightedIndex.value = newIndex
}
}
const handleSelectAll = async () => {
await fetchWorkers(true)
isSelectAllActive.value = true
@@ -387,11 +588,9 @@ const generateReport = async () => {
const url = `/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
try {
// CORRECT: Use apiFetch to get records directly
const fetchedRecords = await apiFetch(url)
reportData.value = fetchedRecords
// OT CALCULATION LOGIC REMAINS THE SAME
const otResults = {}
const hourlyRate = monthlySalary.value / 26 / 8
@@ -437,7 +636,6 @@ const generateReport = async () => {
}
}
// CSV EXPORT LOGIC REMAINS THE SAME
const exportOtSummaryCsv = () => {
if (reportData.value.length === 0) return
@@ -529,7 +727,6 @@ const exportOtSummaryCsv = () => {
document.body.removeChild(link)
}
// CALENDAR METHODS REMAIN THE SAME
const changeMonth = (offset) => {
const newDate = new Date(calendarDate.value)
newDate.setMonth(newDate.getMonth() + offset)
@@ -546,327 +743,10 @@ const toggleHoliday = (dateString) => {
}
onMounted(() => {
const today = new Date()
filters.value.endDate = today.toISOString().split('T')[0]
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(today.getDate() - 7)
filters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
calendarDate.value = new Date(filters.value.startDate + 'T00:00:00')
fetchInitialData()
})
</script>
<style scoped>
/* STYLES REMAIN UNCHANGED */
@media (max-width: 768px) {
.attendance-reporting-layout {
flex-direction: column;
}
.selection-controls {
flex-direction: column;
align-items: stretch; /* Make items take full width */
}
.card {
width: 100%;
height: auto;
min-height: 300px;
margin-bottom: 1rem;
box-sizing: border-box;
}
.form-input,
button {
font-size: 16px; /* Prevent zooming on focus */
padding: 12px;
}
.remove-btn {
width: 30px;
height: 30px;
}
table {
font-size: 14px;
}
.factor-input {
display: flex;
flex-direction: column;
}
.filters {
flex-direction: column;
align-items: stretch;
}
/* Target the holiday picker section */
.holiday-picker {
margin-top: 2rem;
max-width: 100%; /* Ensure the container doesn't overflow */
overflow-x: hidden; /* Prevent horizontal scroll within this section */
}
/* Force the calendar itself to fit the screen */
.calendar {
/* This ensures the calendar itself shrinks to fit the screen */
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
}
@media (min-width: 769px) {
.selected-workers-list ul {
display: flex; /* 1. Turn the list container into a flexbox */
flex-wrap: wrap; /* 2. Allow items to wrap to the next line */
gap: 0.75rem; /* 3. Add spacing between the tags */
}
.selected-workers-list li {
margin-bottom: 0;
}
.factor-input {
display: flex;
flex-direction: row;
}
}
.selection-controls {
display: flex;
gap: 0.75rem;
align-items: center;
}
.search-box {
position: relative;
}
.search-results-list {
position: absolute;
top: 100%; /* Places the list directly below the input */
left: 0;
right: 0;
background-color: var(--c-bg-secondary);
border: 1px solid var(--c-border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
z-index: 10; /* Ensures the list appears on top of other content */
max-height: 250px;
overflow-y: auto;
box-shadow: var(--shadow-md);
}
.search-results-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.search-results-list li {
padding: 12px 15px;
cursor: pointer;
border-bottom: 1px solid var(--c-border);
}
.search-results-list li:last-child {
border-bottom: none;
}
.search-results-list li.highlighted,
.search-results-list li:hover {
background-color: var(--c-bg-tertiary);
color: var(--c-text-primary);
}
.select-all-btn {
/* Prevents the button from shrinking */
flex-shrink: 0;
padding: 10px 16px; /* Match form-input padding from main.css */
}
/*
* STYLES FOR THE SELECTED WORKERS LIST
*/
.selected-workers-list ul {
list-style: none;
padding: 0;
margin-top: 1rem;
}
.selected-workers-list li {
/* Base styles for each worker "tag" */
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background-color: var(--c-bg-tertiary);
border-radius: var(--radius);
border: 1px solid var(--c-border);
gap: 1rem; /* Space between name and remove button */
/* Spacing for the default (mobile) vertical layout */
margin-bottom: 0.5rem;
}
.remove-btn {
/* Making the remove button a bit softer */
background: none;
border: none;
font-size: 1.2rem;
color: var(--c-text-secondary);
cursor: pointer;
line-height: 1;
}
.remove-btn:hover {
color: var(--c-danger);
}
.card {
width: 100%;
height: auto;
min-height: 300px;
margin-bottom: 1rem;
box-sizing: border-box;
}
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch; /* Smooth scrolling for iOS */
}
/* Optional: Prevent table from collapsing on small screens */
.table-wrapper table {
min-width: 650px;
}
.filters {
display: flex;
gap: 1rem;
border-bottom: 1px solid var(--c-border);
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
.overtime-settings-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.settings-header {
margin: 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--c-border-light);
}
.worker-salaries h5,
.factor-input h5,
.holiday-picker h5 {
margin-top: 0;
}
.form-group {
margin-top: 1rem;
}
.subtitle {
font-size: 0.9rem;
color: var(--c-text-secondary);
margin-top: -0.5rem;
margin-bottom: 0.75rem;
}
.action-buttons {
margin-top: 1.5rem;
display: flex;
justify-content: flex-end;
}
.action-buttons button {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
}
/* --- Calendar --- */
.calendar {
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: 1rem;
max-width: 400px;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
margin-bottom: 1rem;
}
.calendar-header button {
background: none;
border: 1px solid var(--c-border);
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
/* Add the following lines to center the text */
display: flex; /* Use flexbox */
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically */
font-size: 1.2rem; /* Adjust font size if needed for better centering visually */
line-height: 1; /* Reset line-height to prevent extra spacing */
}
.calendar-weekdays,
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
}
.weekday {
font-weight: 500;
color: var(--c-text-secondary);
padding-bottom: 0.5rem;
}
.day-cell {
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
}
.day-cell:not(.padding):hover {
background-color: var(--c-bg-secondary);
}
.day-cell.holiday {
background-color: var(--c-primary);
color: var(--c-primary-text);
font-weight: bold;
}
.day-cell.padding {
cursor: default;
}
/* --- Results --- */
.results-display .report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.results-display h4 {
margin: 0;
border-bottom: none;
padding-bottom: 0;
}
.overtime-report table {
margin-bottom: 2rem;
}
.worker-group {
margin-top: 1.5rem;
}
.worker-group-header {
padding: 0.5rem 1rem;
background-color: var(--c-bg-primary);
border-radius: 6px;
}
.event-type {
padding: 4px 8px;
border-radius: 6px;
color: var(--c-primary-text);
font-size: 0.85rem;
text-transform: capitalize;
}
.event-type.clock_in {
background-color: var(--c-success);
}
.event-type.clock_out {
background-color: var(--c-danger);
}
/* No custom styles needed anymore, Tailwind handles everything */
</style>
+599 -182
View File
@@ -1,141 +1,498 @@
<template>
<div class="personnel-container">
<!-- Add Worker Section -->
<section class="card">
<h2 class="card-header">Add New Worker</h2>
<div class="add-worker-form">
<div class="form-group">
<label for="fullName">Full Name</label>
<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">Add New User</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-end">
<div class="flex flex-col gap-2">
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Full Name</label
>
<input
type="text"
id="fullName"
v-model="newWorker.fullName"
class="form-input"
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="e.g., John Smith"
/>
</div>
<div class="form-group">
<label for="username">Username</label>
<div class="flex flex-col gap-2">
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Username</label
>
<input
type="text"
id="username"
v-model="newWorker.username"
class="form-input"
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="e.g., jsmith"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="flex flex-col gap-2">
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Password</label
>
<input
type="password"
id="password"
v-model="newWorker.password"
class="form-input"
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="e.g., 123456"
/>
</div>
<button @click="addWorker" :disabled="!isFormValid || loading" class="button-primary">
{{ loading ? 'Adding...' : 'Add Worker' }}
</button>
<div class="flex flex-col justify-end">
<label class="flex items-center text-sm mb-2 cursor-pointer">
<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">As Manager</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"
>
{{ loading ? 'Adding...' : 'Add User' }}
</button>
</div>
</div>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
<p v-if="errorMessage" class="text-red-500 text-sm mt-4">{{ errorMessage }}</p>
</section>
<!-- Roster Section -->
<section class="card">
<h2 class="card-header">Worker Roster</h2>
<div class="roster-controls">
<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">Manage Tags</h2>
<div class="flex flex-col sm:flex-row items-end gap-4 mb-4">
<div class="flex flex-col gap-2 flex-grow w-full sm:w-auto">
<label for="new-tag" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Create New Tag</label
>
<input
type="text"
id="new-tag"
v-model="newTagName"
@keyup.enter="createTag"
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="e.g., Team A, Senior, Project X"
/>
</div>
<button
@click="createTag"
:disabled="!newTagName"
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 disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
>
Create Tag
</button>
</div>
<div class="flex flex-wrap gap-2 min-h-[2rem]">
<span
v-for="tag in allTags"
:key="tag.id"
class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-3 py-1 rounded-full text-sm font-medium"
>
{{ tag.tag_name }}
</span>
</div>
</section>
<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">Worker Roster</h2>
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:gap-0 sm:items-center justify-between">
<input
type="text"
v-model="searchQuery"
placeholder="Search by name or username..."
class="form-input search-input"
@keyup.enter="fetchWorkers(1)"
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 flex-wrap items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 mr-1"
>Filter by Tag:</span
>
<button
v-for="tag in allTags"
:key="tag.id"
@click="toggleTagFilter(tag.id)"
:class="{
'bg-blue-600 text-white': selectedTagIds.includes(tag.id),
'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-white hover:bg-gray-300 dark:hover:bg-gray-600':
!selectedTagIds.includes(tag.id),
}"
class="px-3 py-1 rounded-full text-sm font-medium transition-colors duration-200"
>
{{ tag.tag_name }}
</button>
<button
v-if="selectedTagIds.length > 0"
@click="clearTagFilter"
class="text-blue-600 hover:text-blue-800 text-sm font-medium ml-2"
>
Clear Filter
</button>
</div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Full Name</th>
<th>Username</th>
<th>Date Joined</th>
<th class="actions-header">Actions</th>
<div class="overflow-x-auto">
<table class="min-w-[700px] 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="w-12 px-2 py-3 text-center">
<input
type="checkbox"
@change="toggleSelectAll"
:checked="isAllSelected"
class="form-checkbox h-4 w-4 text-blue-600 rounded"
/>
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Full Name
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Username
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Tags
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Date Joined
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && workers.length === 0">
<td colspan="4" class="loading-state">Loading workers...</td>
</tr>
<tr v-if="!loading && workers.length === 0">
<td colspan="4" class="empty-state">No workers found.</td>
</tr>
<tr v-for="worker in workers" :key="worker.id">
<td>{{ worker.full_name }}</td>
<td>{{ worker.username }}</td>
<td>{{ new Date(worker.created_at).toLocaleDateString() }}</td>
<td class="actions-cell">
<button @click="viewRecords(worker.id)" class="button-secondary">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="worker in workers"
:key="worker.id"
:class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
<td class="px-2 py-3 text-center">
<input
type="checkbox"
:checked="isWorkerSelected(worker.id)"
@change="toggleWorkerSelection(worker.id)"
class="form-checkbox h-4 w-4 text-blue-600 rounded"
/>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
<td class="px-4 py-3">
<template v-if="worker.tags">
<span
v-for="tag in worker.tags.split(', ')"
:key="tag"
class="bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 px-2 py-1 rounded-full text-xs font-medium mr-1 mb-1 inline-block"
>
{{ tag }}
</span>
</template>
<span v-else class="text-gray-500 dark:text-gray-400">N/A</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(worker.created_at).toLocaleDateString() }}
</td>
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
<button
@click="openTagEditor(worker)"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200"
>
Edit Tags
</button>
<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"
>
Password
</button>
<button
@click="viewRecords(worker.id)"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200"
>
View Records
</button>
<button @click="deleteWorker(worker.id)" class="button-danger">Delete</button>
<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"
>
Delete
</button>
</td>
</tr>
<tr v-if="workers.length === 0">
<td colspan="6" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ loading ? 'Loading workers...' : 'No workers found.' }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination-controls" v-if="totalPages > 1">
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1">Previous</button>
<span>Page {{ currentPage }} of {{ totalPages }}</span>
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages">
<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"
>
Previous
</button>
<span class="text-gray-700 dark:text-gray-200"
>Page <span class="font-semibold">{{ currentPage }}</span> of
<span class="font-semibold">{{ totalPages }}</span></span
>
<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"
>
Next
</button>
</div>
</section>
<div
v-if="isEditingTags"
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 transform transition-all scale-100 opacity-100"
>
<h3 class="text-2xl font-bold mb-6 text-gray-800 dark:text-white">{{ editorTitle }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<label
v-for="tag in allTags"
:key="tag.id"
class="flex items-center gap-2 cursor-pointer text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 p-2 rounded-md transition-colors duration-150"
>
<input
type="checkbox"
:checked="isTagAppliedToSelection(tag.id)"
@change="toggleTagForSelection(tag.id)"
class="form-checkbox h-4 w-4 text-blue-600 rounded"
/>
<span class="text-base">{{ tag.tag_name }}</span>
</label>
<p
v-if="allTags.length === 0"
class="col-span-2 text-center text-gray-500 dark:text-gray-400"
>
No tags available. Create some in "Manage Tags" section.
</p>
</div>
<button
@click="closeTagEditor"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-5 py-2 rounded-md mt-8 w-full transition-colors duration-200"
>
Done
</button>
</div>
</div>
<div
v-if="isPasswordModalVisible"
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">
<h3 class="text-2xl font-bold mb-2 text-gray-800 dark:text-white">Change Password</h3>
<p v-if="editingWorkerPassword" class="mb-6 text-gray-600 dark:text-gray-300">
For user: <span class="font-semibold">{{ editingWorkerPassword.full_name }}</span>
</p>
<form @submit.prevent="updateWorkerPassword">
<div class="flex flex-col gap-4">
<div>
<label
for="newPassword"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>New Password</label
>
<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>
<label
for="confirmNewPassword"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Confirm New Password</label
>
<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>
<p v-if="passwordErrorMessage" class="text-red-500 text-sm -mt-2">
{{ passwordErrorMessage }}
</p>
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm -mt-2">
{{ passwordSuccessMessage }}
</p>
</div>
<div class="flex justify-end gap-4 mt-8">
<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"
>
Cancel
</button>
<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"
>
{{ passwordLoading ? 'Saving...' : 'Save Password' }}
</button>
</div>
</form>
</div>
</div>
<div
v-if="selectedWorkerIds.length > 0"
class="fixed bottom-0 left-0 w-full bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg py-4 px-8 flex flex-col sm:flex-row justify-center items-center gap-4 sm:gap-6 z-40"
>
<span class="font-medium text-gray-800 dark:text-white"
>{{ selectedWorkerIds.length }} worker(s) selected</span
>
<button
@click="openBulkTagEditor"
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 duration-200 w-full sm:w-auto"
>
Bulk Edit Tags
</button>
<button
@click="selectedWorkerIds = []"
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto"
>
Clear Selection
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { apiFetch } from '@/api.js'
// --- STATE ---
const router = useRouter()
const workers = ref([])
const loading = ref(false)
const errorMessage = ref('')
// Form State
const newWorker = ref({ fullName: '', username: '', password: '' })
// Search & Pagination State
const isManager = ref(false)
const allTags = ref([])
const newTagName = ref('')
const editingWorker = ref(null)
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(20) // Or whatever default you prefer
const pageSize = ref(20)
const totalWorkers = ref(0)
const selectedTagIds = ref([])
const isEditingTags = ref(false)
const selectedWorkerIds = ref([])
// NEW: State for the password change modal
const isPasswordModalVisible = ref(false)
const editingWorkerPassword = ref(null)
const newPassword = ref('')
const confirmNewPassword = ref('')
const passwordErrorMessage = ref('')
const passwordSuccessMessage = ref('')
const passwordLoading = ref(false)
// --- COMPUTED ---
// ... existing computed properties ...
const isFormValid = computed(
() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password,
)
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value))
// --- METHODS ---
let searchDebounce = null
watch(searchQuery, () => {
clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => {
fetchWorkers(1) // Reset to page 1 on new search
}, 500) // Debounce search for 500ms
const isAllSelected = computed(() => {
return workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length
})
const editorTitle = computed(() => {
if (editingWorker.value) {
return `Edit Tags for ${editingWorker.value.full_name}`
}
if (selectedWorkerIds.value.length > 0) {
return `Editing Tags for ${selectedWorkerIds.value.length} Workers`
}
return 'Edit Tags'
})
// --- WATCHERS ---
// ... existing watchers ...
let debounceTimer = null
const debouncedFetch = () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
fetchWorkers(1)
}, 500)
}
watch(searchQuery, debouncedFetch)
watch(
selectedTagIds,
() => {
fetchWorkers(1)
},
{ deep: true },
)
watch(currentPage, () => {
selectedWorkerIds.value = []
})
// --- METHODS ---
// ... existing methods ...
const fetchInitialData = async () => {
loading.value = true
try {
const [workersData, tagsData] = await Promise.all([
apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=1&limit=${pageSize.value}`),
apiFetch('/api/managers/tags'),
])
workers.value = workersData.workers
totalWorkers.value = workersData.totalCount
allTags.value = tagsData
} catch (err) {
errorMessage.value = 'Failed to load page data.'
console.error(err)
} finally {
loading.value = false
}
}
const fetchWorkers = async (page = currentPage.value) => {
loading.value = true
selectedWorkerIds.value = []
try {
const data = await apiFetch(
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`,
)
const tagsQueryParam =
selectedTagIds.value.length > 0 ? `&tags=${selectedTagIds.value.join(',')}` : ''
const url = `/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}${tagsQueryParam}`
const data = await apiFetch(url)
workers.value = data.workers
totalWorkers.value = data.totalCount
currentPage.value = page
@@ -158,14 +515,19 @@ const addWorker = async () => {
loading.value = true
errorMessage.value = ''
try {
const data = await apiFetch('/api/managers/workers', {
const payload = {
...newWorker.value,
role: isManager.value ? 'manager' : 'worker',
}
await apiFetch('/api/managers/workers', {
method: 'POST',
body: JSON.stringify(newWorker.value),
body: JSON.stringify(payload),
})
await fetchWorkers(1) // Refresh list to the first page
newWorker.value = { fullName: '', username: '', password: '' } // Clear form
await fetchWorkers(1)
newWorker.value = { fullName: '', username: '', password: '' }
isManager.value = false
} catch (err) {
errorMessage.value = 'An error occurred while adding the worker.'
errorMessage.value = err.message || 'An error occurred while adding the user.'
console.error(err)
} finally {
loading.value = false
@@ -175,10 +537,7 @@ const addWorker = async () => {
const deleteWorker = async (id) => {
if (!confirm('Are you sure you want to delete this worker account?')) return
try {
await apiFetch(`/api/managers/workers/${id}`, {
method: 'DELETE',
})
// If the deleted worker was the last on the page, go to the previous page
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' })
if (workers.value.length === 1 && currentPage.value > 1) {
await fetchWorkers(currentPage.value - 1)
} else {
@@ -190,118 +549,176 @@ const deleteWorker = async (id) => {
}
}
const viewRecords = (workerIds) => {
console.log(
`[DEBUG] 1. fetchWorkerDetails called with ID: '${workerIds}' (Type: ${typeof workerIds})`,
)
router.push(`/manager/attendance/${workerIds}`)
const viewRecords = (workerId) => {
router.push(`/manager/attendance/${workerId}`)
}
const createTag = async () => {
if (!newTagName.value) return
try {
const newTag = await apiFetch('/api/managers/tags', {
method: 'POST',
body: JSON.stringify({ tag_name: newTagName.value }),
})
allTags.value.push(newTag)
newTagName.value = ''
} catch (err) {
alert(err.message)
}
}
const openTagEditor = (worker) => {
editingWorker.value = worker
isEditingTags.value = true
}
const openBulkTagEditor = () => {
editingWorker.value = null
isEditingTags.value = true
}
const closeTagEditor = () => {
isEditingTags.value = false
editingWorker.value = null
selectedWorkerIds.value = []
fetchWorkers(currentPage.value)
}
const isTagAppliedToSelection = (tagId) => {
const tagName = allTags.value.find((t) => t.id === tagId)?.tag_name
if (!tagName) return false
const targetWorkers = editingWorker.value
? [editingWorker.value]
: workers.value.filter((w) => selectedWorkerIds.value.includes(w.id))
if (targetWorkers.length === 0) return false
return targetWorkers.every((worker) => worker.tags && worker.tags.split(', ').includes(tagName))
}
const toggleTagForSelection = async (tagId) => {
const workersToUpdate = editingWorker.value
? [editingWorker.value]
: workers.value.filter((w) => selectedWorkerIds.value.includes(w.id))
if (workersToUpdate.length === 0) return
const isAdding = !isTagAppliedToSelection(tagId)
try {
for (const worker of workersToUpdate) {
if (isAdding) {
await apiFetch(`/api/managers/workers/${worker.id}/tags`, {
method: 'POST',
body: JSON.stringify({ tagId: tagId }),
})
} else {
await apiFetch(`/api/managers/workers/${worker.id}/tags/${tagId}`, { method: 'DELETE' })
}
}
const tagName = allTags.value.find((t) => t.id === tagId)?.tag_name
if (tagName) {
workersToUpdate.forEach((worker) => {
const tags = worker.tags ? worker.tags.split(', ').filter((t) => t) : []
if (isAdding) {
if (!tags.includes(tagName)) tags.push(tagName)
} else {
const index = tags.indexOf(tagName)
if (index > -1) tags.splice(index, 1)
}
worker.tags = tags.join(', ')
})
}
} catch (err) {
alert('Failed to update tags. Please try again.')
console.error(err)
}
}
// NEW: Methods for password change modal
const openPasswordModal = (worker) => {
editingWorkerPassword.value = worker
isPasswordModalVisible.value = true
}
const closePasswordModal = () => {
isPasswordModalVisible.value = false
editingWorkerPassword.value = null
newPassword.value = ''
confirmNewPassword.value = ''
passwordErrorMessage.value = ''
passwordSuccessMessage.value = ''
passwordLoading.value = false
}
const updateWorkerPassword = async () => {
passwordErrorMessage.value = ''
passwordSuccessMessage.value = ''
if (newPassword.value !== confirmNewPassword.value) {
passwordErrorMessage.value = 'Passwords do not match.'
return
}
if (newPassword.value.length < 6) {
passwordErrorMessage.value = 'Password must be at least 6 characters long.'
return
}
if (!editingWorkerPassword.value) return
passwordLoading.value = true
try {
await apiFetch(`/api/managers/workers/${editingWorkerPassword.value.id}/password`, {
method: 'PUT',
body: JSON.stringify({ newPassword: newPassword.value }),
})
passwordSuccessMessage.value = 'Password updated successfully!'
setTimeout(closePasswordModal, 2000) // Close modal after 2 seconds
} catch (err) {
passwordErrorMessage.value = err.message || 'Failed to update password.'
console.error(err)
} finally {
passwordLoading.value = false
}
}
const isWorkerSelected = (workerId) => {
return selectedWorkerIds.value.includes(workerId)
}
const toggleWorkerSelection = (workerId) => {
const index = selectedWorkerIds.value.indexOf(workerId)
if (index === -1) {
selectedWorkerIds.value.push(workerId)
} else {
selectedWorkerIds.value.splice(index, 1)
}
}
const toggleSelectAll = (event) => {
if (event.target.checked) {
selectedWorkerIds.value = workers.value.map((w) => w.id)
} else {
selectedWorkerIds.value = []
}
}
const toggleTagFilter = (tagId) => {
const index = selectedTagIds.value.indexOf(tagId)
if (index === -1) {
selectedTagIds.value = [...selectedTagIds.value, tagId]
} else {
selectedTagIds.value = selectedTagIds.value.filter((id) => id !== tagId)
}
}
const clearTagFilter = () => {
selectedTagIds.value = []
}
onMounted(() => {
const userRole = sessionStorage.getItem('userRole')
if (userRole !== 'manager') {
router.push('/')
return
}
fetchWorkers()
fetchInitialData()
})
</script>
<style scoped>
.personnel-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.card-header {
margin-top: 0;
margin-bottom: 1.5rem;
}
.add-worker-form {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto;
gap: 1rem;
align-items: flex-end;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.9rem;
font-weight: 500;
}
.error-message {
color: var(--c-danger);
margin-top: 1rem;
}
.roster-controls {
margin-bottom: 1.5rem;
}
.search-input {
width: 100%;
max-width: 400px;
}
.actions-header {
text-align: right;
}
.actions-cell {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.empty-state,
.loading-state {
text-align: center;
padding: 2rem;
color: var(--c-text-secondary);
}
.pagination-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--c-border);
}
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
min-width: 600px; /* Force horizontal scroll on mobile */
}
/* For tablet-sized screens */
@media (max-width: 992px) {
.add-worker-form {
grid-template-columns: 1fr 1fr; /* 2-column layout for the form */
}
.add-worker-form button {
grid-column: span 2; /* Make button span full width */
}
}
/* For mobile-sized screens */
@media (max-width: 768px) {
.add-worker-form {
grid-template-columns: 1fr; /* 1-column layout for the form */
}
.add-worker-form button {
grid-column: span 1; /* Reset span */
}
.actions-cell {
flex-wrap: wrap; /* Allow action buttons to wrap onto the next line */
}
.pagination-controls {
flex-direction: column; /* Stack pagination controls */
gap: 0.75rem;
}
}
/* No custom styles needed anymore, Tailwind handles everything */
</style>
+88 -134
View File
@@ -1,65 +1,119 @@
<template>
<div class="qr-management-container">
<section class="card">
<h2 class="card-header">Create New QR Code</h2>
<div class="qr-add-form">
<div class="form-group">
<label for="qr-name">QR Code Name</label>
<div class="flex flex-col gap-8">
<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">Create New QR Code</h2>
<div class="flex flex-col sm:flex-row items-end gap-4 mb-6">
<div class="flex flex-col gap-2 flex-grow w-full">
<label for="qr-name" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>QR Code Name</label
>
<input
type="text"
id="qr-name"
v-model="newQrName"
placeholder="e.g., 'West Gate Entrance'"
class="form-input"
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"
@keyup.enter="addQrCode"
/>
</div>
<button @click="addQrCode" :disabled="!newQrName" class="button-primary">Create</button>
<button
@click="addQrCode"
:disabled="!newQrName"
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 w-full sm:w-auto"
>
Create
</button>
</div>
<div v-if="newlyGeneratedQr" class="new-qr-display">
<div class="new-qr-header">
<h3>New Code Created!</h3>
<p>Save this image or use the ID below. This will disappear on refresh.</p>
<div
v-if="newlyGeneratedQr"
class="text-center p-8 mt-6 bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg"
>
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">New Code Created!</h3>
<p class="text-sm text-gray-600 dark:text-gray-300">
Save this image or use the ID below. This will disappear on refresh.
</p>
</div>
<div class="new-qr-id">
<div
class="inline-block mb-4 font-mono bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm"
>
<span>ID: {{ newlyGeneratedQr.id }}</span>
</div>
<canvas ref="newQrCanvas"></canvas>
<canvas
ref="newQrCanvas"
class="max-w-full h-auto border-4 border-gray-200 dark:border-gray-600 rounded-lg mx-auto"
></canvas>
</div>
</section>
<section class="card">
<h2 class="card-header">Existing QR Codes</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th class="actions-header">Actions</th>
<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">Existing QR Codes</h2>
<div class="overflow-x-auto">
<table class="min-w-[600px] 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"
>
Name
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Status
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="qr in qrCodes" :key="qr.id">
<td>{{ qr.name }}</td>
<td>
<span class="status-badge" :class="qr.is_active ? 'active' : 'inactive'">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="qr in qrCodes"
:key="qr.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">{{ qr.name }}</td>
<td class="px-4 py-3">
<span
class="inline-block px-3 py-1 rounded-full text-xs font-semibold uppercase"
:class="
qr.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-200'
"
>
{{ qr.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="actions-cell">
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
<button
@click="downloadQrCode(qr)"
class="button-secondary"
class="flex items-center gap-1 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200"
title="Download QR Code"
>
<span></span> Download
<span class="text-base"></span> Download
</button>
<button @click="toggleQrStatus(qr)" class="button-secondary">
<button
@click="toggleQrStatus(qr)"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200"
>
{{ qr.is_active ? 'Deactivate' : 'Activate' }}
</button>
<button @click="deleteQrCode(qr.id)" class="button-danger">Delete</button>
<button
@click="deleteQrCode(qr.id)"
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200"
>
Delete
</button>
</td>
</tr>
<tr v-if="qrCodes.length === 0">
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
No QR codes found. Create one above!
</td>
</tr>
</tbody>
@@ -183,105 +237,5 @@ const downloadQrCode = async (qr) => {
</script>
<style scoped>
/* STYLES REMAIN UNCHANGED */
.qr-management-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.card-header {
margin-top: 0;
margin-bottom: 1.5rem;
}
.qr-add-form {
display: flex;
align-items: flex-end;
gap: 1rem;
}
.qr-add-form .form-group {
flex-grow: 1;
}
.new-qr-display {
text-align: center;
padding: 2rem;
margin-top: 1.5rem;
background: var(--c-bg-primary);
border-radius: var(--radius);
border: 1px dashed var(--c-border);
}
.new-qr-header h3 {
margin: 0 0 0.5rem 0;
}
.new-qr-header p {
margin: 0 0 1.5rem 0;
color: var(--c-text-secondary);
}
.new-qr-id {
margin-bottom: 1rem;
font-family: monospace;
background: var(--c-bg-tertiary);
padding: 0.5rem 1rem;
border-radius: 6px;
display: inline-block;
}
.new-qr-display canvas {
max-width: 100%;
height: auto;
border: 6px solid var(--c-bg-secondary);
border-radius: var(--radius);
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.active {
background-color: #45bd6233;
color: var(--c-success);
}
.status-badge.inactive {
background-color: #8a8d9133;
color: var(--c-text-secondary);
}
.actions-header {
text-align: right;
}
.actions-cell {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.button-secondary {
display: flex;
align-items: center;
gap: 0.5rem;
}
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
min-width: 500px; /* Force horizontal scroll */
}
.actions-cell {
flex-wrap: wrap; /* Allow buttons to wrap on smaller screens */
}
@media (max-width: 768px) {
.qr-add-form {
flex-direction: column; /* Stack form elements vertically */
align-items: stretch;
}
.actions-header {
text-align: left; /* Align header with wrapped buttons */
}
.actions-cell {
justify-content: flex-start;
}
}
/* No custom styles needed anymore, Tailwind handles everything */
</style>
+1
View File
@@ -1,6 +1,7 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
+8 -1
View File
@@ -3,7 +3,8 @@ import LoginView from '../views/LoginView.vue'
import WorkerDashboardView from '../views/WorkerDashboardView.vue'
import ManagerDashboardView from '../views/ManagerDashboardView.vue'
import WorkerHistoryView from '../views/WorkerHistoryView.vue'
import AttendanceRecordView from '../views/AttendanceRecordView.vue' // <-- Import new view
import AttendanceRecordView from '../views/AttendanceRecordView.vue'
import ChangePasswordView from '../views/ChangePasswordView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -21,6 +22,12 @@ const router = createRouter({
component: WorkerHistoryView,
meta: { requiresAuth: true, role: 'worker' },
},
{
path: '/worker/change-password',
name: 'worker-change-password',
component: ChangePasswordView,
meta: { requiresAuth: true, role: 'worker' },
},
{
path: '/manager/dashboard',
name: 'manager-dashboard',
+126 -183
View File
@@ -1,90 +1,161 @@
<template>
<div class="attendance-container">
<div class="card">
<div class="header">
<router-link to="/manager/dashboard" class="back-link"> Back to Dashboard</router-link>
<h2 class="card-header">Attendance Log for {{ workerName }}</h2>
<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="mb-6">
<router-link to="/manager/dashboard" class="text-blue-600 hover:text-blue-800 font-medium"
> Back to Dashboard</router-link
>
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mt-2">
Attendance Log for {{ workerName }}
</h2>
</div>
<div class="manual-entry-card">
<h3 class="manual-entry-header">Add Manual Clock-Out</h3>
<p class="manual-entry-desc">
<div
class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6"
>
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-1">
Add Manual Clock-Out
</h3>
<p class="text-sm text-blue-700 dark:text-blue-300 mb-4">
Use this form if the worker forgot to clock out. The last event must be a clock-in.
</p>
<div class="manual-entry-form">
<div class="form-group">
<label for="manual-timestamp">Clock-Out Time</label>
<div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="flex flex-col gap-2 flex-grow w-full">
<label
for="manual-timestamp"
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Clock-Out Time</label
>
<input
type="datetime-local"
id="manual-timestamp"
v-model="manualClockOut.timestamp"
class="form-input"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div class="form-group" style="flex-grow: 1">
<label for="manual-notes">Reason (e.g., "Forgot to clock out")</label>
<div class="flex flex-col gap-2 flex-grow w-full">
<label for="manual-notes" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Reason (e.g., "Forgot to clock out")</label
>
<input
type="text"
id="manual-notes"
v-model="manualClockOut.notes"
class="form-input"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Enter a brief note"
/>
</div>
<button @click="addManualClockOut" class="button-primary">Add Record</button>
<button
@click="addManualClockOut"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"
>
Add Record
</button>
</div>
</div>
<div class="filters">
<div class="form-group">
<label for="start-date">Start Date</label>
<input type="date" id="start-date" v-model="filters.startDate" class="form-input" />
<div
class="flex flex-col sm:flex-row gap-4 items-end mb-6 pt-4 border-t border-gray-200 dark:border-gray-700"
>
<div class="flex flex-col gap-2 w-full sm:w-auto">
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Start Date</label
>
<input
type="date"
id="start-date"
v-model="filters.startDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div class="form-group">
<label for="end-date">End Date</label>
<input type="date" id="end-date" v-model="filters.endDate" class="form-input" />
<div class="flex flex-col gap-2 w-full sm:w-auto">
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>End Date</label
>
<input
type="date"
id="end-date"
v-model="filters.endDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<button @click="fetchRecords" class="button-primary">Filter Records</button>
<button
@click="fetchRecords"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"
>
Filter Records
</button>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>Event</th>
<th>Timestamp</th>
<th>Location Name</th>
<th>Coordinates</th>
<th>Notes</th>
<div class="overflow-x-auto">
<table class="min-w-[650px] 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"
>
Event
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Timestamp
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Location Name
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Coordinates
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Notes
</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-if="!records.length">
<td colspan="5" style="text-align: center; padding: 2rem">
<td colspan="5" class="text-center py-8 text-gray-500 dark:text-gray-400">
No records found for this period.
</td>
</tr>
<tr v-for="record in records" :key="record.id">
<td>
<span class="event-type" :class="record.event_type">{{
record.event_type.replace('_', ' ')
}}</span>
<tr
v-for="record in records"
:key="record.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
<td class="px-4 py-3">
<span
class="inline-block px-2 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
:class="{
'bg-green-500': record.event_type === 'clock_in',
'bg-red-500': record.event_type === 'clock_out',
}"
>{{ record.event_type.replace('_', ' ') }}</span
>
</td>
<td>{{ new Date(record.timestamp).toLocaleString() }}</td>
<td>{{ record.qrCodeUsedName }}</td>
<td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(record.timestamp).toLocaleString() }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.qrCodeUsedName }}</td>
<td class="px-4 py-3">
<a
v-if="record.latitude && record.longitude"
:href="`https://www.google.com/maps?q=${record.latitude},${record.longitude}`"
:href="`https://maps.google.com/?q=$${record.latitude},${record.longitude}`"
target="_blank"
rel="noopener noreferrer"
class="map-link"
class="text-blue-600 hover:text-blue-800 underline font-medium"
>
Show on map
</a>
<span v-else>N/A</span>
<span v-else class="text-gray-500 dark:text-gray-400">N/A</span>
</td>
<td>{{ record.notes || 'N/A' }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || 'N/A' }}</td>
</tr>
</tbody>
</table>
@@ -96,33 +167,30 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { apiFetch } from '@/api.js'
const route = useRoute()
const records = ref([])
const workerName = ref('')
const workerId = route.params.workerId
import { apiFetch } from '@/api.js'
// Get the current date and time in the required format for datetime-local input
const toLocalISOString = (date) => {
const tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
return localISOTime
}
// New state for manual clock-out form
const manualClockOut = ref({
timestamp: toLocalISOString(new Date()),
notes: '',
})
console.log('API Base URL from .env:', import.meta.env.VITE_API_BASE_URL)
// Set default date range for filters to the past 7 days
const today = new Date()
const sevenDaysAgo = new Date(today)
sevenDaysAgo.setDate(today.getDate() - 7)
const setStartDay = new Date(today)
setStartDay.setDate(today.getDate() - 60)
const filters = ref({
startDate: sevenDaysAgo.toISOString().split('T')[0],
startDate: setStartDay.toISOString().split('T')[0],
endDate: today.toISOString().split('T')[0],
})
@@ -133,7 +201,6 @@ const fetchRecords = async () => {
}
try {
// Correct: 'data' is the JSON array returned directly from apiFetch.
const data = await apiFetch(url)
if (data && Array.isArray(data)) {
@@ -146,7 +213,7 @@ const fetchRecords = async () => {
}
} catch (err) {
console.error('Failed to fetch attendance records:', err)
alert(err.message) // The error thrown by apiFetch will be caught here.
alert(err.message)
records.value = []
}
}
@@ -162,8 +229,6 @@ const addManualClockOut = async () => {
}
try {
// FIX: Removed "const resData =" as the variable is not used.
// We just need to wait for the API call to complete successfully.
await apiFetch('/api/managers/add-record', {
method: 'POST',
headers: {
@@ -177,11 +242,10 @@ const addManualClockOut = async () => {
}),
})
// If the line above completes without an error, the call was successful.
alert('Manual clock-out recorded successfully!')
manualClockOut.value.notes = ''
manualClockOut.value.timestamp = toLocalISOString(new Date())
fetchRecords() // Refresh the records list
fetchRecords()
} catch (err) {
console.error('Failed to submit manual clock-out:', err)
alert(`An error occurred: ${err.message}`)
@@ -194,126 +258,5 @@ onMounted(() => {
</script>
<style scoped>
.attendance-container {
max-width: 1000px;
margin: auto;
}
.header {
margin-bottom: 1.5rem;
}
.back-link {
color: var(--c-primary);
text-decoration: none;
font-weight: 500;
}
.card-header {
margin-top: 0.5rem;
}
/* New styles for manual entry card */
.manual-entry-card {
background-color: var(--c-bg-secondary); /* Use theme variable */
border: 1px solid var(--c-border); /* Use theme variable */
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
}
.manual-entry-header {
margin-top: 0;
margin-bottom: 0.25rem;
color: var(--c-text-primary); /* Ensure text color adapts */
}
.manual-entry-desc {
font-size: 0.9rem;
color: var(--c-text-secondary); /* Ensure text color adapts */
margin-top: 0;
margin-bottom: 1rem;
}
.manual-entry-form {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.filters {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--c-border); /* Use theme variable */
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.event-type {
padding: 4px 8px;
border-radius: 6px;
color: var(--c-primary-text);
font-size: 0.85rem;
text-transform: capitalize;
white-space: nowrap; /* Keep on one line */
}
.event-type.clock_in {
background-color: var(--c-success);
}
.event-type.clock_out {
background-color: var(--c-danger);
}
/* Style for the new map link */
.map-link {
color: var(--c-primary);
text-decoration: underline;
font-weight: 500;
}
.map-link:hover {
color: var(--c-primary-dark);
}
/* Added responsive table container */
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch; /* For smoother scrolling on iOS */
}
/* Responsive styles */
@media (max-width: 768px) {
.attendance-container {
max-width: 100%;
padding: 0 1rem; /* Add horizontal padding for smaller screens */
}
.manual-entry-form,
.filters {
flex-direction: column; /* Stack items vertically */
align-items: stretch; /* Stretch items to full width */
}
.manual-entry-form .form-group,
.filters .form-group {
width: 100%; /* Make form groups take full width */
}
.manual-entry-form button,
.filters button {
width: 100%; /* Make buttons take full width */
margin-top: 0.5rem; /* Add some space above buttons when stacked */
}
/* Adjust table font size for smaller screens if needed */
table {
font-size: 0.8rem; /* Slightly smaller font for table on small screens */
}
th,
td {
padding: 6px 8px; /* Reduce padding for table cells further */
}
.event-type {
font-size: 0.75rem; /* Slightly smaller font for event type tag */
padding: 3px 6px; /* Adjust padding for smaller font */
}
}
/* All styles are now handled by Tailwind CSS classes in the template. */
</style>
+124
View File
@@ -0,0 +1,124 @@
<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">
Change Password
</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"
>
{{ successMessage }}
</div>
<div v-if="errorMessage" class="bg-red-100 text-red-700 p-3 rounded-md text-center mb-4">
{{ 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"
>Current Password</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"
>New Password</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"
>Confirm New Password</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 ? 'Updating...' : 'Update Password' }}
</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"
>
Back to Dashboard
</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 = 'New passwords do not match.'
return
}
if (passwords.value.newPassword.length < 6) {
errorMessage.value = 'New password must be at least 6 characters long.'
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 =
'Password updated successfully! You can now use your new password to log in.'
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
} catch (err) {
errorMessage.value = err.message || 'An error occurred.'
} finally {
loading.value = false
}
}
</script>
+40 -57
View File
@@ -1,21 +1,47 @@
<template>
<div class="login-wrapper">
<div class="login-card card">
<h2>Login</h2>
<div class="flex justify-center items-center min-h-[70vh] p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 w-full max-w-md">
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-6 text-center">Login</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" v-model="username" class="form-input" required />
<div class="mb-5">
<label
for="username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>Username</label
>
<input
type="text"
id="username"
v-model="username"
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
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" v-model="password" class="form-input" required />
<div class="mb-5">
<label
for="password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>Password</label
>
<input
type="password"
id="password"
v-model="password"
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
/>
</div>
<p class="info">Hint: worker/password or manager/password</p>
<button type="submit" class="button-primary login-button" :disabled="loading">
<p class="text-xs text-gray-500 dark:text-gray-400 text-center mb-6">
Hint: worker/password or manager/password
</p>
<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"
:disabled="loading"
>
{{ loading ? 'Logging in...' : 'Login' }}
</button>
<p v-if="error" class="error-message">{{ error }}</p>
<p v-if="error" class="text-red-600 text-center mt-4">{{ error }}</p>
</form>
</div>
</div>
@@ -44,7 +70,6 @@ const handleLogin = async () => {
const data = await response.json()
if (response.ok) {
// Store token and user info in session storage
sessionStorage.setItem('token', data.token)
try {
const decodedToken = JSON.parse(atob(data.token.split('.')[1]))
@@ -57,7 +82,7 @@ const handleLogin = async () => {
router.push('/manager/dashboard')
}
} catch {
error.value = 'Invalid token received from server.'
error.value = 'Invalid tokrouteren received from server.'
}
} else {
error.value = data.message
@@ -71,47 +96,5 @@ const handleLogin = async () => {
</script>
<style scoped>
/* Styles remain the same */
.login-wrapper {
display: flex;
justify-content: center;
align-items: center;
min-height: 70vh;
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-card h2 {
text-align: center;
margin-top: 0;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-input {
width: 100%;
box-sizing: border-box;
}
.info {
font-size: 0.8rem;
color: var(--c-text-secondary);
text-align: center;
margin: -0.5rem 0 1.5rem 0;
}
.login-button {
width: 100%;
padding: 12px;
font-size: 1.1rem;
}
.error-message {
color: var(--c-danger);
text-align: center;
margin-top: 1rem;
}
/* All styles are now handled by Tailwind CSS classes in the template. */
</style>
+41 -62
View File
@@ -1,24 +1,38 @@
<template>
<div class="manager-dashboard">
<div class="tabs">
<button
@click="activeTab = 'personnel'"
:class="{ active: activeTab === 'personnel' }"
class="tab-button"
<div class="max-w-full mx-auto px-4 py-4">
<div
class="flex gap-1 border-b border-gray-200 dark:border-gray-700 mb-4 overflow-x-auto pb-2 sm:pb-0 scrollbar-hide"
>
<button
@click="activeTab = 'personnel'"
:class="{
'text-blue-600 border-blue-600': activeTab === 'personnel',
'text-gray-600 dark:text-gray-300 border-transparent hover:text-gray-800 dark:hover:text-white':
activeTab !== 'personnel',
}"
class="flex-shrink-0 px-4 py-3 text-sm font-semibold border-b-2 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Personnel
</button>
<button
@click="activeTab = 'attendance'"
:class="{ active: activeTab === 'attendance' }"
class="tab-button"
<button
@click="activeTab = 'attendance'"
:class="{
'text-blue-600 border-blue-600': activeTab === 'attendance',
'text-gray-600 dark:text-gray-300 border-transparent hover:text-gray-800 dark:hover:text-white':
activeTab !== 'attendance',
}"
class="flex-shrink-0 px-4 py-3 text-sm font-semibold border-b-2 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Attendance
</button>
<button
@click="activeTab = 'qr'"
:class="{ active: activeTab === 'qr' }"
class="tab-button"
<button
@click="activeTab = 'qr'"
:class="{
'text-blue-600 border-blue-600': activeTab === 'qr',
'text-gray-600 dark:text-gray-300 border-transparent hover:text-gray-800 dark:hover:text-white':
activeTab !== 'qr',
}"
class="flex-shrink-0 px-4 py-3 text-sm font-semibold border-b-2 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
QR Codes
</button>
@@ -31,59 +45,24 @@
</div>
</template>
<style scoped>
.manager-dashboard {
max-width: 100%;
margin: 0;
padding: 1rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--c-border);
margin-bottom: 1rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.tab-button {
padding: 0.75rem 1rem;
min-width: 80px;
white-space: nowrap;
background-color: transparent;
border: none;
border-bottom: 3px solid transparent;
color: var(--c-text-secondary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.tab-button.active {
color: var(--c-primary);
border-bottom-color: var(--c-primary);
}
@media (max-width: 768px) {
.tabs {
gap: 0;
}
.tab-button {
padding: 0.75rem 0.5rem;
font-size: 0.8rem;
min-width: 70px;
}
}
</style>
<script setup>
import { ref } from 'vue'
import AttendanceReporting from '@/components/AttendanceReporting.vue'
import QrCodeManagement from '@/components/QrCodeManagement.vue'
import PersonnelManagement from '@/components/PersonnelManagement.vue'
console.log('API Base URL from .env:', import.meta.env.VITE_API_BASE_URL)
const activeTab = ref('personnel')
</script>
<style scoped>
/* All styles are now handled by Tailwind CSS classes in the template. */
/* Hide scrollbar for a cleaner look, while keeping it functional */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>
+60 -140
View File
@@ -1,36 +1,76 @@
<template>
<div class="worker-dashboard">
<h1>{{ workerName }}</h1>
<div class="status-card" :class="isClockedIn ? 'clocked-in' : 'clocked-out'">
<div class="status-icon">
<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 class="status-text">
<p>Your Status</p>
<h2>{{ clockStatus }}</h2>
<div>
<p class="text-sm opacity-90">Your Status</p>
<h2 class="text-2xl font-bold">{{ clockStatus }}</h2>
</div>
</div>
<div class="action-panel card">
<p v-if="successMessage" class="message success">{{ successMessage }}</p>
<p v-if="errorMessage" class="message error">{{ errorMessage }}</p>
<div v-if="!isScannerActive" class="action-buttons">
<button @click="startScanner" class="action-button">
<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>Scan to Clock {{ isClockedIn ? 'Out' : 'In' }}</span>
</button>
<button @click="triggerFileUpload" class="action-button secondary">
<button
@click="triggerFileUpload"
class="w-full py-4 text-xl flex items-center justify-center gap-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold rounded-md transition-colors duration-200"
>
<span>📁</span>
<span>Upload QR Image</span>
</button>
<input ref="fileInput" type="file" accept="image/*" @change="handleFileUpload" hidden />
</div>
</div>
<div id="qr-reader-container" v-show="isScannerActive">
<div id="qr-reader"></div>
<button @click="stopScanner" class="stop-button">Cancel</button>
<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"
>
Cancel
</button>
</div>
<router-link to="/worker/history" class="history-link">View My Clock History </router-link>
<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"
>View My Clock History </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"
>Change My Password </router-link
>
</div>
</template>
@@ -49,17 +89,13 @@ const isScannerActive = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const workerName = ref('')
console.log('API Base URL from .env:', import.meta.env.VITE_API_BASE_URL)
// Get userId from session storage. Redirect if not found.
const userId = sessionStorage.getItem('userId')
const clockStatus = computed(() => (isClockedIn.value ? 'Clocked In' : 'Clocked Out'))
//fetch worker name
const fetchWorkerDetails = async () => {
try {
// CORRECT: 'data' is the JSON object returned directly by apiFetch
const data = await apiFetch(`/api/workers/${userId}`)
if (data) {
workerName.value = data.full_name
@@ -71,7 +107,6 @@ const fetchWorkerDetails = async () => {
const fetchCurrentStatus = async () => {
try {
// CORRECT: 'lastEvent' is the JSON object
const lastEvent = await apiFetch(`/api/worker/status/${userId}`)
if (lastEvent) {
isClockedIn.value = lastEvent.eventType === 'clock_in'
@@ -84,7 +119,6 @@ const fetchCurrentStatus = async () => {
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
try {
// CORRECT: 'data' is the JSON response from the server
const data = await apiFetch('/api/clock', {
method: 'POST',
body: JSON.stringify({
@@ -96,23 +130,20 @@ const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
}),
})
// If the call succeeds, the code proceeds here. If it fails, the catch block is executed.
isClockedIn.value = !isClockedIn.value
successMessage.value = `Successfully clocked ${eventType.replace('_', ' ')} at ${
data.location || 'site' // Assuming the response might contain location info
data.location || 'site'
}.`
} catch (err) {
// The error message from apiFetch or the server is caught here
errorMessage.value = `Error: ${err.message}`
}
}
onMounted(() => {
if (!userId) {
router.push('/') // Redirect to login if no userId
router.push('/')
return
}
// Fetch worker details and current status when component mounts
fetchWorkerDetails()
fetchCurrentStatus()
})
@@ -130,7 +161,6 @@ const clearMessages = () => {
const startScanner = () => {
isScannerActive.value = true
clearMessages()
// A small delay helps ensure the DOM is ready for the QR reader to attach
setTimeout(() => {
try {
html5QrCode = new Html5Qrcode('qr-reader')
@@ -154,8 +184,6 @@ const handleFileUpload = (event) => {
const file = event.target.files[0]
if (!file) return
clearMessages()
// No need to set isScannerActive to true for file uploads unless you want the UI overlay
// html5QrCode does not need to be attached to the DOM for file scanning
if (!html5QrCode) {
html5QrCode = new Html5Qrcode('qr-reader', false)
}
@@ -181,117 +209,9 @@ const onScanSuccess = (decodedText) => {
}
const onScanFailure = (message = 'Could not detect a QR code. Please try again.') => {
errorMessage.value = message
stopScanner()
}
</script>
<style scoped>
/* STYLES REMAIN UNCHANGED */
.worker-dashboard {
max-width: 500px;
margin: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.status-card {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.5rem;
border-radius: var(--radius);
color: var(--c-primary-text);
box-shadow: var(--shadow-md);
}
.status-card.clocked-in {
background-color: var(--c-success);
}
.status-card.clocked-out {
background-color: var(--c-secondary);
}
.status-card .status-icon {
font-size: 2.5rem;
}
.status-card .status-text p {
margin: 0;
opacity: 0.9;
}
.status-card .status-text h2 {
margin: 0;
font-size: 1.75rem;
}
.action-panel {
padding: 1.5rem;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
}
.action-button {
width: 100%;
padding: 1rem;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
background-color: var(--c-primary);
color: var(--c-primary-text);
}
.action-button.secondary {
background-color: var(--c-bg-tertiary);
color: var(--c-text-primary);
}
.message {
padding: 1rem;
border-radius: 6px;
text-align: center;
margin-bottom: 1rem;
}
.message.success {
background-color: #45bd6233;
color: var(--c-success);
}
.message.error {
background-color: #f0284933;
color: var(--c-danger);
}
#qr-reader-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
}
#qr-reader {
width: 90%;
max-width: 400px;
background: white;
border-radius: var(--radius);
overflow: hidden;
}
.stop-button {
margin-top: 1rem;
background-color: var(--c-danger);
color: var(--c-primary-text);
padding: 0.75rem 2rem;
}
.history-link {
text-align: center;
color: var(--c-text-secondary);
text-decoration: none;
font-weight: 500;
padding: 0.5rem;
}
.history-link:hover {
text-decoration: underline;
}
/* All styles are now handled by Tailwind CSS classes in the template. */
</style>
+35 -78
View File
@@ -1,18 +1,38 @@
<template>
<div class="history-container card">
<h2 class="card-header">My Clock History</h2>
<router-link to="/worker/dashboard" class="back-link"> Back to Dashboard</router-link>
<div v-if="!clockHistory.length" class="no-data">You have no clocking history.</div>
<div v-for="event in clockHistory" :key="event.id" class="clock-card">
<div class="card-content">
<div class="event-type" :class="event.event_type">
{{ event.event_type.replace('_', ' ') }}
</div>
<div class="timestamp">
{{ new Date(event.timestamp).toLocaleString() }}
</div>
<div class="location">
{{ event.qrCodeUsedName }}
<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">My Clock History</h2>
<router-link
to="/worker/dashboard"
class="text-blue-600 hover:text-blue-800 font-medium mb-6 inline-block"
> Back to Dashboard</router-link
>
<div v-if="!clockHistory.length" class="text-center py-8 text-gray-500 dark:text-gray-400">
You have no clocking history.
</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',
}"
>
{{ event.event_type.replace('_', ' ') }}
</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>
@@ -22,7 +42,6 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
// CORRECT: Import the centralized apiFetch function
import { apiFetch } from '@/api.js'
const router = useRouter()
@@ -35,78 +54,16 @@ onMounted(async () => {
return
}
try {
// CORRECT: Use apiFetch to automatically handle headers and response parsing
const data = await apiFetch(`/api/worker/clock-history/${userId}`)
if (data) {
clockHistory.value = data
}
} catch (error) {
// The error from apiFetch is caught here
console.error('Failed to fetch clock history:', error)
}
})
</script>
<style scoped>
/* STYLES REMAIN UNCHANGED */
.history-container {
max-width: 800px;
margin: auto;
}
.card-header {
margin-top: 2;
}
.back-link {
color: var(--c-primary);
text-decoration: none;
font-weight: 500;
margin-bottom: 1.5rem;
display: inline-block;
}
.no-data {
text-align: center;
padding: 2rem;
color: var(--c-text-secondary);
}
.event-type {
padding: 4px 8px;
border-radius: 6px;
color: var(--c-primary-text);
font-size: 0.85rem;
text-transform: capitalize;
white-space: nowrap; /* Prevent line breaks */
display: inline-block;
}
.event-type.clock_in {
background-color: var(--c-success);
}
.event-type.clock_out {
background-color: var(--c-secondary);
}
/* Card styles */
.clock-card {
background-color: var(--c-background-secondary);
border-radius: 8px;
box-shadow: var(--shadow-md);
padding: 16px;
margin-bottom: 16px;
}
.card-content {
display: flex;
align-items: center;
}
.timestamp,
.location {
margin-left: 12px;
font-size: 0.9rem;
}
/* Responsive styles */
@media (max-width: 600px) {
.history-container {
max-width: 100%;
padding: 0 1rem;
}
}
/* All styles are now handled by Tailwind CSS classes in the template. */
</style>
+3 -5
View File
@@ -2,18 +2,16 @@ import { fileURLToPath, URL } from 'node:url'
import fs from 'fs'
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {