feat: 重构前端界面并优化API集成

- 添加vite环境类型定义文件
- 优化考勤记录视图
- 修复后端时间戳处理问题
- 重构管理仪表盘响应式布局
- 改进工人历史视图卡片式布局
- 优化人员管理组件表格响应式
- 增强二维码管理组件移动端适配
- 重构考勤报表组件添加全选功能
This commit is contained in:
sudomarcma
2025-06-17 17:09:04 +08:00
parent 6b2b95ce8b
commit 4a04cfe15b
14 changed files with 614 additions and 312 deletions
+6 -6
View File
@@ -18,12 +18,12 @@ Create a .env file in the project root with your database configuration:
```
DBHOST=your_database_host
DBUSER=your_database_user
DBPASSWORD=your_database_password
DBNAME=your_database_name
DBPORT=your_database_port
DB_HOST=your_database_host
DB_USER=your_database_user
DB_PASSWORD=your_database_password
DB_NAME=your_database_name
DB_PORT=your_database_port
VITE_API_BASE_URL=your_api_base_url
```
### Development
+10 -7
View File
@@ -14,11 +14,11 @@ async function startServer() {
// --- Database Connection ---
const db = mysql.createPool({
host: process.env.DBHOST,
user: process.env.DBUSER,
password: process.env.DBPASSWORD,
database: process.env.DBNAME,
port: process.env.DBPORT,
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
@@ -135,7 +135,7 @@ async function startServer() {
const { userId } = req.params
// MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries
const [rows] = await db.execute(
`SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName, cr.latitude, cr.longitude, cr.notes FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC`,
`SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC`,
[userId],
)
res.json(rows)
@@ -227,10 +227,13 @@ async function startServer() {
const status = eventType === 'clock_in' ? 'in' : 'out'
return res.status(409).json({ message: `Worker is already clocked ${status}.` })
}
// --- THIS IS THE FIX ---
// Sanitize the timestamp from "YYYY-MM-DDTHH:mm" to "YYYY-MM-DD HH:mm"
const sanitizedTimestamp = timestamp.replace('T', ' ')
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)',
[workerId, eventType, timestamp, notes],
[workerId, eventType, sanitizedTimestamp, notes],
)
res.status(201).json({ message: 'Manual record added successfully.' })
+7 -4
View File
@@ -1,9 +1,12 @@
<!DOCTYPE html>
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Vite App</title>
</head>
<body>
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"backend": "node ./backend/server.js",
"dev:all": "concurrently \"npm run dev\" \"npm run backend\"",
"build": "vite build",
+20
View File
@@ -0,0 +1,20 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
export async function apiFetch(endpoint, options = {}) {
const defaultHeaders = {
'ngrok-skip-browser-warning': 'true',
'Content-Type': 'application/json',
...options.headers,
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: defaultHeaders,
})
if (!response.ok) {
throw new Error(`API call failed with status: ${response.status}`)
}
return response.json()
}
+240 -88
View File
@@ -1,16 +1,18 @@
<template>
<div class="attendance-reporting-layout">
<div class="selection-panel">
<section class="card" style="height: 70vh; display: flex; flex-direction: column">
<h3 class="panel-header">1. Select Workers</h3>
<section class="card">
<h2 class="panel-header">1. Select Workers</h2>
<div class="selection-controls">
<div class="search-box">
<input
type="text"
v-model="searchQuery"
placeholder="Search by name..."
placeholder="Search for a worker..."
class="form-input"
@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">
@@ -27,17 +29,27 @@
</ul>
</div>
</div>
<div
class="selected-workers-list"
style="flex-grow: 1; display: flex; flex-direction: column; overflow: hidden"
>
<button @click="handleSelectAll" class="button-secondary select-all-btn">
Select All
</button>
</div>
<div class="selected-workers-list">
<h4>Selected for Report ({{ selectedWorkers.length }})</h4>
<ul v-if="selectedWorkers.length > 0" style="flex-grow: 1; overflow-y: auto">
<ul v-if="isSelectAllActive">
<li>
<span>All Workers ({{ selectedWorkers.length }}) Selected</span>
<button @click="clearAllSelection" class="remove-btn">×</button>
</li>
</ul>
<ul v-else-if="selectedWorkers.length > 0">
<li v-for="worker in selectedWorkers" :key="worker.id">
<span>{{ worker.full_name }}</span>
<button @click="removeWorker(worker.id)" class="remove-btn">×</button>
</li>
</ul>
<p v-else class="empty-state">No workers selected.</p>
</div>
</section>
@@ -45,7 +57,7 @@
<div class="results-panel">
<section class="card">
<h3 class="panel-header">2. Report Settings</h3>
<h2 class="panel-header">2. Report Settings</h2>
<div class="filters">
<div class="form-group">
<label for="start-date">Start Date</label>
@@ -58,10 +70,9 @@
</div>
<div class="overtime-settings-section" v-if="selectedWorkers.length > 0">
<h4 class="settings-header">Overtime Settings</h4>
<div class="worker-salaries">
<h5>Monthly Salary (RM)</h5>
<p class="subtitle">Enter the monthly salary to be applied to all selected workers.</p>
<h4>Monthly Salary (RM)</h4>
<p class="subtitle">Applied to all selected workers.</p>
<div class="form-group">
<input
id="monthly-salary"
@@ -72,11 +83,11 @@
/>
</div>
</div>
<div class="factors-and-holidays">
<div class="factor-settings">
<h4>OT Factors</h4>
<div class="factor-input">
<h5>OT Factors</h5>
<div class="form-group">
<label for="rest-day-factor">Rest Day OT Factor</label>
<p class="subtitle">Weekend Factor</p>
<input
id="rest-day-factor"
type="number"
@@ -85,7 +96,7 @@
/>
</div>
<div class="form-group">
<label for="holiday-factor">Holiday Day OT Factor</label>
<p class="subtitle">Holiday Factor</p>
<input
id="holiday-factor"
type="number"
@@ -94,6 +105,8 @@
/>
</div>
</div>
</div>
<div class="holiday-picker">
<h5>Select Public Holidays</h5>
<div class="calendar">
@@ -124,10 +137,13 @@
</div>
</div>
</div>
</div>
<div class="action-buttons">
<button @click="generateReport" :disabled="!canGenerate">
<button
@click="generateReport"
:disabled="!canGenerate"
style="background-color: var(--c-success)"
>
Generate Attendance & OT Report
</button>
</div>
@@ -146,7 +162,7 @@
Export OT Summary (CSV)
</button>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
@@ -164,6 +180,7 @@
</tbody>
</table>
</div>
</div>
<div class="raw-logs" v-if="reportData.length > 0">
<h4 style="margin-top: 2rem">Raw Attendance Data</h4>
@@ -211,6 +228,7 @@ import { ref, onMounted, computed } from 'vue'
const searchQuery = ref('')
const searchResults = ref([])
const highlightedIndex = ref(-1)
const isSelectAllActive = ref(false)
const selectedWorkers = ref([])
const filters = ref({ startDate: '', endDate: '' })
@@ -286,25 +304,44 @@ const calendarGrid = computed(() => {
}
})
// --- METHODS ---
const handleSearch = () => {
const handleSearch = async () => {
// SCENARIO 1: User presses Enter on a highlighted item in the list
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
selectWorker(searchResults.value[highlightedIndex.value])
} else {
fetchWorkers()
}
// SCENARIO 2: User presses Enter in an empty search box
else if (searchQuery.value === '') {
await handleSelectAll()
searchResults.value = []
highlightedIndex.value = -1
}
// SCENARIO 3: User types a query and presses Enter for the first time
else {
// This fetches the list of workers so you can navigate it
await fetchWorkers()
}
}
const fetchWorkers = async () => {
const fetchWorkers = async (selectAll = false) => {
// ... this function's internal logic remains the same
try {
const res = await fetch(
`http://localhost:3000/api/managers/workers?search=${searchQuery.value}&limit=10`,
`${import.meta.env.VITE_API_BASE_URL}/api/managers/workers?search=${searchQuery.value}&limit=1000`,
)
if (res.ok) {
const data = await res.json()
if (selectAll) {
data.workers.forEach((worker) => {
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
selectedWorkers.value.push(worker)
}
})
} else {
searchResults.value = data.workers
// Set highlighted index to the first item, or -1 if no results
highlightedIndex.value = data.workers.length > 0 ? 0 : -1
}
}
} catch (err) {
console.error('Failed to search workers', err)
}
@@ -323,16 +360,29 @@ const navigateResults = (direction) => {
highlightedIndex.value = newIndex
}
}
const handleSelectAll = async () => {
await fetchWorkers(true) // This function already fetches and selects all workers
isSelectAllActive.value = true // Activate the summary view
}
// vvv ADD THIS NEW METHOD vvv
const clearAllSelection = () => {
selectedWorkers.value = []
isSelectAllActive.value = false
}
const selectWorker = (worker) => {
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
selectedWorkers.value.push(worker)
}
clearSearch()
isSelectAllActive.value = false // Turn off "Select All" mode
clearSearch() // Clear the search box and results after selection
}
// vvv MODIFY THIS METHOD vvv
const removeWorker = (workerId) => {
selectedWorkers.value = selectedWorkers.value.filter((w) => w.id !== workerId)
isSelectAllActive.value = false // Deactivate summary view when removing individually
}
const generateReport = async () => {
@@ -346,7 +396,7 @@ const generateReport = async () => {
overtimeReport.value = null
const workerIds = selectedWorkers.value.map((w) => w.id).join(',')
const url = `http://localhost:3000/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
const url = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
try {
const res = await fetch(url)
@@ -526,76 +576,180 @@ onMounted(() => {
</script>
<style scoped>
/* --- Existing Styles --- */
.attendance-reporting-layout {
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
align-items: flex-start;
/* Add these media queries */
@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;
}
}
.panel-header {
margin-top: 0;
@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;
width: 100%;
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-radius: var(--radius);
margin-top: 0.5rem;
z-index: 10;
max-height: 200px;
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;
margin: 0;
padding: 0.5rem;
}
.search-results-list li {
padding: 0.75rem 1rem;
cursor: pointer;
border-radius: 6px;
}
.search-results-list li.highlighted {
background-color: var(--c-primary);
color: var(--c-primary-text);
}
.selected-workers-list {
margin-top: 1.5rem;
}
.selected-workers-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: 0.75rem;
border-radius: 6px;
}
.selected-workers-list li:nth-child(odd) {
background-color: var(--c-bg-primary);
}
.remove-btn {
background: none;
border: none;
color: var(--c-danger);
font-size: 1.5rem;
cursor: pointer;
}
.empty-state {
color: var(--c-text-secondary);
text-align: center;
padding: 1rem;
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;
}
/* --- Refactored & New Styles --- */
.filters {
display: flex;
gap: 1rem;
@@ -606,7 +760,7 @@ onMounted(() => {
.overtime-settings-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: 0.5rem;
}
.settings-header {
margin: 0;
@@ -617,25 +771,17 @@ onMounted(() => {
.factor-input h5,
.holiday-picker h5 {
margin-top: 0;
margin-bottom: 0.75rem;
}
.form-group {
margin-top: 1rem;
}
.subtitle {
font-size: 0.9rem;
color: var(--c-text-secondary);
margin-top: -0.5rem;
margin-bottom: 0.75rem;
}
.factors-and-holidays {
display: grid;
grid-template-columns: 200px 1fr;
gap: 2rem;
align-items: flex-start;
}
.factor-input {
display: flex;
flex-direction: column;
gap: 1rem;
}
.action-buttons {
margin-top: 1.5rem;
@@ -669,6 +815,12 @@ onMounted(() => {
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 {
+43 -4
View File
@@ -53,6 +53,7 @@
@keyup.enter="fetchWorkers(1)"
/>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
@@ -74,12 +75,15 @@
<td>{{ worker.username }}</td>
<td>{{ new Date(worker.created_at).toLocaleDateString() }}</td>
<td class="actions-cell">
<button @click="viewRecords(worker.id)" class="button-secondary">View Records</button>
<button @click="viewRecords(worker.id)" class="button-secondary">
View Records
</button>
<button @click="deleteWorker(worker.id)" class="button-danger">Delete</button>
</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>
@@ -128,7 +132,7 @@ const fetchWorkers = async (page = currentPage.value) => {
loading.value = true
try {
const res = await fetch(
`http://localhost:3000/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`,
`${import.meta.env.VITE_API_BASE_URL}/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`,
)
if (res.ok) {
const data = await res.json()
@@ -155,7 +159,7 @@ const addWorker = async () => {
loading.value = true
errorMessage.value = ''
try {
const res = await fetch('http://localhost:3000/api/managers/workers', {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/workers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newWorker.value),
@@ -178,7 +182,7 @@ const addWorker = async () => {
const deleteWorker = async (id) => {
if (!confirm('Are you sure you want to delete this worker account?')) return
try {
const res = await fetch(`http://localhost:3000/api/managers/workers/${id}`, {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/workers/${id}`, {
method: 'DELETE',
})
if (res.ok) {
@@ -274,4 +278,39 @@ onMounted(() => {
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;
}
}
</style>
+36 -8
View File
@@ -31,6 +31,7 @@
<section class="card">
<h2 class="card-header">Existing QR Codes</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
@@ -49,7 +50,11 @@
</span>
</td>
<td class="actions-cell">
<button @click="downloadQrCode(qr)" class="button-secondary" title="Download QR Code">
<button
@click="downloadQrCode(qr)"
class="button-secondary"
title="Download QR Code"
>
<span></span> Download
</button>
<!-- FIX #1: Use qr.is_active instead of qr.isActive -->
@@ -61,6 +66,7 @@
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
@@ -87,7 +93,7 @@ onMounted(() => {
const fetchQrCodes = async () => {
try {
const res = await fetch('http://localhost:3000/api/managers/qr-codes')
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/qr-codes`)
qrCodes.value = await res.json()
} catch (err) {
console.error('Failed to fetch QR codes:', err)
@@ -97,7 +103,7 @@ const fetchQrCodes = async () => {
const addQrCode = async () => {
if (!newQrName.value) return
try {
const res = await fetch('http://localhost:3000/api/managers/qr-codes', {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/qr-codes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newQrName.value }),
@@ -124,7 +130,7 @@ const addQrCode = async () => {
const toggleQrStatus = async (qr) => {
try {
const res = await fetch(`http://localhost:3000/api/managers/qr-codes/${qr.id}`, {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/qr-codes/${qr.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
// Send the opposite of the current status
@@ -148,7 +154,7 @@ const deleteQrCode = async (id) => {
return
}
try {
const res = await fetch(`http://localhost:3000/api/managers/qr-codes/${id}`, {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/qr-codes/${id}`, {
method: 'DELETE',
})
if (res.ok) {
@@ -257,7 +263,29 @@ const downloadQrCode = async (qr) => {
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;
}
}
</style>
``` These targeted fixes should resolve the issues completely. The component will now correctly
display the status from the database on initial load and will properly update the state when you
activate or deactivate a QR co
+6 -4
View File
@@ -112,7 +112,7 @@ 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)
@@ -127,7 +127,9 @@ const fetchRecords = async () => {
// Ensure we have a worker name, fetch if not
if (!workerName.value) {
try {
const workerRes = await fetch(`http://localhost:3000/api/managers/worker/${workerId}`)
const workerRes = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/managers/worker/${workerId}`,
)
if (workerRes.ok) {
const workerData = await workerRes.json()
workerName.value = workerData.full_name
@@ -139,7 +141,7 @@ const fetchRecords = async () => {
}
// Fetch attendance records
let url = `http://localhost:3000/api/managers/attendance-records?workerIds=${workerId}`
let url = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records?workerIds=${workerId}`
if (filters.value.startDate && filters.value.endDate) {
url += `&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
}
@@ -169,7 +171,7 @@ const addManualClockOut = async () => {
}
try {
const res = await fetch('http://localhost:3000/api/managers/add-record', {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/add-record`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+1 -1
View File
@@ -35,7 +35,7 @@ const handleLogin = async () => {
loading.value = true
error.value = ''
try {
const response = await fetch('http://localhost:3000/api/auth/login', {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
+66 -34
View File
@@ -1,14 +1,26 @@
<template>
<div class="manager-dashboard">
<div class="tabs">
<button @click="activeTab = 'personnel'" :class="{ active: activeTab === 'personnel' }">
Personnel Management
<button
@click="activeTab = 'personnel'"
:class="{ active: activeTab === 'personnel' }"
class="tab-button"
>
Personnel
</button>
<button @click="activeTab = 'attendance'" :class="{ active: activeTab === 'attendance' }">
Attendance & Reporting
<button
@click="activeTab = 'attendance'"
:class="{ active: activeTab === 'attendance' }"
class="tab-button"
>
Attendance
</button>
<button @click="activeTab = 'qr'" :class="{ active: activeTab === 'qr' }">
QR Code Management
<button
@click="activeTab = 'qr'"
:class="{ active: activeTab === 'qr' }"
class="tab-button"
>
QR Codes
</button>
</div>
<div class="tab-content">
@@ -19,39 +31,59 @@
</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>
.manager-dashboard {
max-width: 100%;
margin: auto;
}
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--c-border);
margin-bottom: 2rem;
}
.tabs button {
padding: 1rem 1.5rem;
background-color: transparent;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
color: var(--c-text-secondary);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.tabs button.active {
color: var(--c-primary);
border-bottom-color: var(--c-primary);
}
</style>
+4 -3
View File
@@ -48,6 +48,7 @@ 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')
@@ -57,7 +58,7 @@ const clockStatus = computed(() => (isClockedIn.value ? 'Clocked In' : 'Clocked
//fetch worker name
const fetchWorkerDetails = async () => {
try {
const response = await fetch(`http://localhost:3000/api/workers/${userId}`)
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/workers/${userId}`)
if (!response.ok) return
const data = await response.json()
workerName.value = data.full_name
@@ -68,7 +69,7 @@ const fetchWorkerDetails = async () => {
const fetchCurrentStatus = async () => {
try {
const response = await fetch(`http://localhost:3000/api/worker/status/${userId}`)
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/worker/status/${userId}`)
if (!response.ok) return
const lastEvent = await response.json()
isClockedIn.value = lastEvent.eventType === 'clock_in'
@@ -96,7 +97,7 @@ onBeforeUnmount(() => {
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
try {
const response = await fetch('http://localhost:3000/api/clock', {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/clock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+46 -34
View File
@@ -2,37 +2,20 @@
<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>
<table>
<thead>
<tr>
<th>Event</th>
<th>Timestamp</th>
<th>Location Name</th>
<th>Coordinates</th>
</tr>
</thead>
<tbody>
<tr v-if="!clockHistory.length">
<td colspan="4" class="no-data">You have no clocking history.</td>
</tr>
<tr v-for="event in clockHistory" :key="event.id">
<td>
<span class="event-type" :class="event.event_type">{{
event.event_type.replace('_', ' ')
}}</span>
</td>
<td>{{ new Date(event.timestamp).toLocaleString() }}</td>
<td>{{ event.qrCodeUsedName }}</td>
<td>
{{
event.latitude
? `${Number(event.latitude).toFixed(4)}, ${Number(event.longitude).toFixed(4)}`
: 'N/A'
}}
</td>
</tr>
</tbody>
</table>
<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>
</div>
</div>
</div>
</template>
@@ -52,7 +35,8 @@ onMounted(async () => {
return
}
try {
const response = await fetch(`http://localhost:3000/api/worker/clock-history/${userId}`)
const response = await fetch(`
${import.meta.env.VITE_API_BASE_URL}/api/worker/clock-history/${userId}`)
if (response.ok) {
clockHistory.value = await response.json()
}
@@ -68,7 +52,7 @@ onMounted(async () => {
margin: auto;
}
.card-header {
margin-top: 0;
margin-top: 2;
}
.back-link {
color: var(--c-primary);
@@ -88,11 +72,39 @@ onMounted(async () => {
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-danger);
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;
}
}
</style>
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}