Files
Nilai_Clock/src/components/PersonnelManagement.vue
T
sudomarcma 4a04cfe15b feat: 重构前端界面并优化API集成
- 添加vite环境类型定义文件
- 优化考勤记录视图
- 修复后端时间戳处理问题
- 重构管理仪表盘响应式布局
- 改进工人历史视图卡片式布局
- 优化人员管理组件表格响应式
- 增强二维码管理组件移动端适配
- 重构考勤报表组件添加全选功能
2025-06-17 17:09:04 +08:00

317 lines
8.3 KiB
Vue

<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="e.g., 123456"
/>
</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>
<div class="table-wrapper">
<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>
<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(
`${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()
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(`${import.meta.env.VITE_API_BASE_URL}/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(`${import.meta.env.VITE_API_BASE_URL}/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);
}
.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>