feat: 重构前端界面并优化API集成
- 添加vite环境类型定义文件 - 优化考勤记录视图 - 修复后端时间戳处理问题 - 重构管理仪表盘响应式布局 - 改进工人历史视图卡片式布局 - 优化人员管理组件表格响应式 - 增强二维码管理组件移动端适配 - 重构考勤报表组件添加全选功能
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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 --- */
|
||||
/* Add these media queries */
|
||||
@media (max-width: 768px) {
|
||||
.attendance-reporting-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.panel-header {
|
||||
margin-top: 0;
|
||||
.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;
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user