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:
+227
-14
@@ -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.' })
|
||||
|
||||
Generated
+1368
-579
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 *));
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
|
||||
+8
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
<template>
|
||||
<div class="manager-dashboard">
|
||||
<div class="tabs">
|
||||
<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="{ active: activeTab === 'personnel' }"
|
||||
class="tab-button"
|
||||
: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"
|
||||
: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"
|
||||
: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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user