feat(考勤管理): 新增考勤记录查看、人员管理和报表生成功能

添加考勤记录查看页面,支持按日期筛选和展示员工考勤数据
实现人员管理组件,包含添加员工、搜索分页和删除功能
新增考勤报表生成组件,支持多员工筛选和导出CSV
This commit is contained in:
sudomarcma
2025-06-13 18:24:58 +08:00
parent c76fda9180
commit cac82a2c36
20 changed files with 1346 additions and 834 deletions
+327
View File
@@ -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>
-44
View File
@@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>
-169
View File
@@ -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>
+277
View File
@@ -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>
+24 -7
View File
@@ -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
-94
View File
@@ -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>
Vues
<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>
-86
View File
@@ -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>
-115
View File
@@ -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>