feat(考勤管理): 新增考勤记录查看、人员管理和报表生成功能
添加考勤记录查看页面,支持按日期筛选和展示员工考勤数据 实现人员管理组件,包含添加员工、搜索分页和删除功能 新增考勤报表生成组件,支持多员工筛选和导出CSV
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="attendance-reporting-layout">
|
||||
<!-- Left Panel: Worker Selection -->
|
||||
<div class="selection-panel">
|
||||
<section class="card">
|
||||
<h3 class="panel-header">1. Select Workers</h3>
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search by name..."
|
||||
class="form-input"
|
||||
@keydown.enter.prevent="handleSearch"
|
||||
@keydown.down.prevent="navigateResults(1)"
|
||||
@keydown.esc.prevent="clearSearch"
|
||||
/>
|
||||
<div v-if="searchResults.length > 0" class="search-results-list">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(worker, index) in searchResults"
|
||||
:key="worker.id"
|
||||
:class="{ highlighted: index === highlightedIndex }"
|
||||
@click="selectWorker(worker)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
>
|
||||
{{ worker.full_name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selected-workers-list">
|
||||
<h4>Selected for Report ({{ selectedWorkers.length }})</h4>
|
||||
<ul v-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>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Filters & Results -->
|
||||
<div class="results-panel">
|
||||
<section class="card">
|
||||
<h3 class="panel-header">2. Set Filters & Generate</h3>
|
||||
<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>
|
||||
<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>
|
||||
<div class="action-buttons">
|
||||
<button @click="generateReport" :disabled="!canGenerate">Generate Report</button>
|
||||
<button @click="exportReport" :disabled="!canGenerate" class="button-secondary">
|
||||
Export (CSV)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="results-display">
|
||||
<h4>Report Results</h4>
|
||||
<div v-if="loadingReport" class="loading-state">Loading...</div>
|
||||
<div v-else-if="reportData.length > 0">
|
||||
<div
|
||||
v-for="(group, workerName) in groupedReportData"
|
||||
:key="workerName"
|
||||
class="worker-group"
|
||||
>
|
||||
<h5 class="worker-group-header">{{ workerName }}</h5>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Timestamp</th>
|
||||
<th>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>
|
||||
</td>
|
||||
<td>{{ new Date(record.timestamp).toLocaleString() }}</td>
|
||||
<td>{{ record.qrCodeUsedName }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty-state">Generate a report to see results.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
// --- STATE ---
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const highlightedIndex = ref(-1)
|
||||
|
||||
const selectedWorkers = ref([])
|
||||
const filters = ref({ startDate: '', endDate: '' })
|
||||
|
||||
const loadingReport = ref(false)
|
||||
const reportData = ref([])
|
||||
|
||||
// --- COMPUTED ---
|
||||
const canGenerate = computed(
|
||||
() => selectedWorkers.value.length > 0 && filters.value.startDate && filters.value.endDate,
|
||||
)
|
||||
|
||||
const groupedReportData = computed(() => {
|
||||
return reportData.value.reduce((groups, record) => {
|
||||
const key = record.full_name
|
||||
if (!groups[key]) {
|
||||
groups[key] = []
|
||||
}
|
||||
groups[key].push(record)
|
||||
return groups
|
||||
}, {})
|
||||
})
|
||||
|
||||
// --- METHODS ---
|
||||
const handleSearch = () => {
|
||||
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
|
||||
selectWorker(searchResults.value[highlightedIndex.value])
|
||||
} else {
|
||||
fetchWorkers()
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWorkers = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://localhost:3000/api/managers/workers?search=${searchQuery.value}&limit=10`,
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
searchResults.value = data.workers
|
||||
highlightedIndex.value = data.workers.length > 0 ? 0 : -1
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to search workers', err)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
|
||||
const navigateResults = (direction) => {
|
||||
if (searchResults.value.length === 0) return
|
||||
const newIndex = highlightedIndex.value + direction
|
||||
if (newIndex >= 0 && newIndex < searchResults.value.length) {
|
||||
highlightedIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
|
||||
const selectWorker = (worker) => {
|
||||
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
|
||||
selectedWorkers.value.push(worker)
|
||||
}
|
||||
clearSearch()
|
||||
}
|
||||
|
||||
const removeWorker = (workerId) => {
|
||||
selectedWorkers.value = selectedWorkers.value.filter((w) => w.id !== workerId)
|
||||
}
|
||||
|
||||
const generateReport = async () => {
|
||||
if (!canGenerate.value) return
|
||||
loadingReport.value = true
|
||||
reportData.value = []
|
||||
|
||||
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}`
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
reportData.value = await res.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to generate report', err)
|
||||
} finally {
|
||||
loadingReport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exportReport = () => {
|
||||
if (!canGenerate.value) return
|
||||
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}&format=csv`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const today = new Date()
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(today.getDate() - 7)
|
||||
filters.value.endDate = today.toISOString().split('T')[0]
|
||||
filters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attendance-reporting-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.panel-header {
|
||||
margin-top: 0;
|
||||
}
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
.search-results-list {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
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;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.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;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.selected-workers-list li {
|
||||
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;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.action-buttons {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.results-display h4 {
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
.worker-group {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,169 +0,0 @@
|
||||
<template>
|
||||
<div class="report-container">
|
||||
<section class="card">
|
||||
<h2 class="card-header">Hours Report</h2>
|
||||
<div class="filters">
|
||||
<div class="form-group">
|
||||
<label for="start-date">Start Date</label>
|
||||
<input type="date" id="start-date" v-model="reportFilters.startDate" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end-date">End Date</label>
|
||||
<input type="date" id="end-date" v-model="reportFilters.endDate" class="form-input" />
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button @click="fetchHoursReport" :disabled="loadingReport" class="button-primary">
|
||||
{{ loadingReport ? 'Loading...' : 'Generate Report' }}
|
||||
</button>
|
||||
<button
|
||||
@click="exportReportAsCsv"
|
||||
:disabled="!reportData.length"
|
||||
class="button-secondary"
|
||||
>
|
||||
Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingReport" class="loading-placeholder">Loading report data...</div>
|
||||
|
||||
<div v-if="!loadingReport && reportData.length > 0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Total Hours</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData" :key="item.userId">
|
||||
<td>{{ item.fullName }}</td>
|
||||
<td>{{ item.totalHours }}</td>
|
||||
<td>
|
||||
<span v-if="item.hasIncomplete" class="status-badge incomplete"> Incomplete </span>
|
||||
<span v-else class="status-badge complete"> Complete </span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="summary-total">
|
||||
<strong>Total Collective Hours: {{ collectiveHours.toFixed(2) }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!loadingReport && !reportData.length && hasGeneratedReport" class="no-data-message">
|
||||
No data found for the selected period.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const reportFilters = ref({ startDate: '', endDate: '' })
|
||||
const reportData = ref([])
|
||||
const loadingReport = ref(false)
|
||||
const hasGeneratedReport = ref(false)
|
||||
|
||||
const collectiveHours = computed(() => {
|
||||
return reportData.value.reduce((total, item) => total + item.totalHours, 0)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const today = new Date()
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(today.getDate() - 7)
|
||||
reportFilters.value.endDate = today.toISOString().split('T')[0]
|
||||
reportFilters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
const fetchHoursReport = async () => {
|
||||
if (!reportFilters.value.startDate || !reportFilters.value.endDate) {
|
||||
alert('Please select both a start and end date.')
|
||||
return
|
||||
}
|
||||
loadingReport.value = true
|
||||
hasGeneratedReport.value = true
|
||||
reportData.value = []
|
||||
const url = `http://localhost:3000/api/managers/hours-report?startDate=${reportFilters.value.startDate}&endDate=${reportFilters.value.endDate}`
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
reportData.value = await res.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch report:', err)
|
||||
} finally {
|
||||
loadingReport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exportReportAsCsv = () => {
|
||||
const url = `http://localhost:3000/api/managers/hours-report?startDate=${reportFilters.value.startDate}&endDate=${reportFilters.value.endDate}&format=csv`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background-color: var(--c-bg-primary);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form-group label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-badge.incomplete {
|
||||
background-color: #f0a10033;
|
||||
color: #d18c00;
|
||||
}
|
||||
.dark .status-badge.incomplete {
|
||||
color: #f0a100;
|
||||
}
|
||||
.status-badge.complete {
|
||||
background-color: #45bd6233;
|
||||
color: var(--c-success);
|
||||
}
|
||||
.summary-total {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--c-border);
|
||||
font-size: 1.2rem;
|
||||
text-align: right;
|
||||
}
|
||||
.loading-placeholder,
|
||||
.no-data-message {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
id="fullName"
|
||||
v-model="newWorker.fullName"
|
||||
class="form-input"
|
||||
placeholder="e.g., John Smith"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
v-model="newWorker.username"
|
||||
class="form-input"
|
||||
placeholder="e.g., jsmith"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
v-model="newWorker.password"
|
||||
class="form-input"
|
||||
placeholder="Create a temporary password"
|
||||
/>
|
||||
</div>
|
||||
<button @click="addWorker" :disabled="!isFormValid || loading" class="button-primary">
|
||||
{{ loading ? 'Adding...' : 'Add Worker' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Roster Section -->
|
||||
<section class="card">
|
||||
<h2 class="card-header">Worker Roster</h2>
|
||||
<div class="roster-controls">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search by name or username..."
|
||||
class="form-input search-input"
|
||||
@keyup.enter="fetchWorkers(1)"
|
||||
/>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Full Name</th>
|
||||
<th>Username</th>
|
||||
<th>Date Joined</th>
|
||||
<th class="actions-header">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">View Records</button>
|
||||
<button @click="deleteWorker(worker.id)" class="button-danger">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
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 searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20) // Or whatever default you prefer
|
||||
const totalWorkers = ref(0)
|
||||
|
||||
// --- COMPUTED ---
|
||||
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 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}`,
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
workers.value = data.workers
|
||||
totalWorkers.value = data.totalCount
|
||||
currentPage.value = page
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = 'Failed to fetch workers.'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const changePage = (page) => {
|
||||
if (page > 0 && page <= totalPages.value) {
|
||||
fetchWorkers(page)
|
||||
}
|
||||
}
|
||||
|
||||
const addWorker = async () => {
|
||||
if (!isFormValid.value) return
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const res = await fetch('http://localhost:3000/api/managers/workers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newWorker.value),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
await fetchWorkers(1) // Refresh list to the first page
|
||||
newWorker.value = { fullName: '', username: '', password: '' } // Clear form
|
||||
} else {
|
||||
errorMessage.value = data.message
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = 'An error occurred while adding the worker.'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
// If the deleted worker was the last on the page, go to the previous page
|
||||
if (workers.value.length === 1 && currentPage.value > 1) {
|
||||
await fetchWorkers(currentPage.value - 1)
|
||||
} else {
|
||||
await fetchWorkers(currentPage.value)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = 'Failed to delete worker.'
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const viewRecords = (workerIds) => {
|
||||
console.log(
|
||||
`[DEBUG] 1. fetchWorkerDetails called with ID: '${workerIds}' (Type: ${typeof workerIds})`,
|
||||
)
|
||||
router.push(`/manager/attendance/${workerIds}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const userRole = sessionStorage.getItem('userRole')
|
||||
if (userRole !== 'manager') {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
fetchWorkers()
|
||||
})
|
||||
</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);
|
||||
}
|
||||
</style>
|
||||
@@ -43,16 +43,18 @@
|
||||
<tr v-for="qr in qrCodes" :key="qr.id">
|
||||
<td>{{ qr.name }}</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="qr.isActive ? 'active' : 'inactive'">
|
||||
{{ qr.isActive ? 'Active' : 'Inactive' }}
|
||||
<!-- FIX #1: Use qr.is_active instead of qr.isActive -->
|
||||
<span class="status-badge" :class="qr.is_active ? 'active' : 'inactive'">
|
||||
{{ qr.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<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 -->
|
||||
<button @click="toggleQrStatus(qr)" class="button-secondary">
|
||||
{{ qr.isActive ? 'Deactivate' : 'Activate' }}
|
||||
{{ qr.is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
<button @click="deleteQrCode(qr.id)" class="button-danger">Delete</button>
|
||||
</td>
|
||||
@@ -65,14 +67,21 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const router = useRouter()
|
||||
const qrCodes = ref([])
|
||||
const newQrName = ref('')
|
||||
const newlyGeneratedQr = ref(null)
|
||||
const newQrCanvas = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
const userRole = sessionStorage.getItem('userRole')
|
||||
if (userRole !== 'manager') {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
fetchQrCodes()
|
||||
})
|
||||
|
||||
@@ -118,12 +127,16 @@ const toggleQrStatus = async (qr) => {
|
||||
const res = await fetch(`http://localhost:3000/api/managers/qr-codes/${qr.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: !qr.isActive }),
|
||||
// Send the opposite of the current status
|
||||
body: JSON.stringify({ isActive: !qr.is_active }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updatedQr = await res.json()
|
||||
const index = qrCodes.value.findIndex((q) => q.id === updatedQr.id)
|
||||
if (index !== -1) qrCodes.value[index] = updatedQr
|
||||
// FIX #2: Instead of replacing the object, just update the property.
|
||||
// This preserves the 'name' and other properties of the object.
|
||||
const index = qrCodes.value.findIndex((q) => q.id === qr.id)
|
||||
if (index !== -1) {
|
||||
qrCodes.value[index].is_active = !qrCodes.value[index].is_active
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update QR status:', err)
|
||||
@@ -168,6 +181,7 @@ const downloadQrCode = async (qr) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Styles remain the same */
|
||||
.qr-management-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -244,3 +258,6 @@ const downloadQrCode = async (qr) => {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</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
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<script setup>
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<div class="worker-management-container">
|
||||
<section class="card worker-list-card">
|
||||
<h2 class="card-header">All Workers</h2>
|
||||
<ul class="worker-list">
|
||||
<li
|
||||
v-for="worker in workers"
|
||||
:key="worker.id"
|
||||
@click="selectWorker(worker)"
|
||||
:class="{ active: selectedWorker?.id === worker.id }"
|
||||
class="worker-list-item"
|
||||
>
|
||||
{{ worker.fullName }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedWorker" class="card worker-details-card">
|
||||
<h2 class="card-header">Details for {{ selectedWorker.fullName }}</h2>
|
||||
<div v-if="loadingDetails">Loading details...</div>
|
||||
<div v-else>
|
||||
<h3>Total Hours</h3>
|
||||
<p>{{ totalHours.toFixed(2) }} hours worked (all time)</p>
|
||||
<h3>Clock History (Latest First)</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Timestamp</th>
|
||||
<th>QR Code Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in clockHistory" :key="event.id">
|
||||
<td>{{ event.eventType }}</td>
|
||||
<td>{{ new Date(event.timestamp).toLocaleString() }}</td>
|
||||
<td>{{ event.qrCodeUsedName }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<p v-else class="prompt-text">Select a worker from the list to view their details.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const workers = ref([])
|
||||
const selectedWorker = ref(null)
|
||||
const clockHistory = ref([])
|
||||
const totalHours = ref(0)
|
||||
const loadingDetails = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('http://localhost:3000/api/managers/users')
|
||||
workers.value = await res.json()
|
||||
})
|
||||
|
||||
const selectWorker = async (worker) => {
|
||||
selectedWorker.value = worker
|
||||
loadingDetails.value = true
|
||||
|
||||
// Fetch both history and total hours in parallel
|
||||
const [historyRes, hoursRes] = await Promise.all([
|
||||
fetch(`http://localhost:3000/api/users/${worker.id}/clock-history`),
|
||||
fetch(`http://localhost:3000/api/managers/hours-report?userId=${worker.id}`),
|
||||
])
|
||||
|
||||
if (historyRes.ok) clockHistory.value = await historyRes.json()
|
||||
if (hoursRes.ok) {
|
||||
const hoursData = await hoursRes.json()
|
||||
totalHours.value = hoursData[0]?.totalHours || 0
|
||||
}
|
||||
|
||||
loadingDetails.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.worker-management-container {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.worker-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.worker-list-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.worker-list-item:hover {
|
||||
background-color: var(--c-bg-primary);
|
||||
}
|
||||
.worker-list-item.active {
|
||||
background-color: var(--c-primary);
|
||||
color: var(--c-primary-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.card-header {
|
||||
margin-top: 0;
|
||||
}
|
||||
.prompt-text {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user