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

285 lines
7.8 KiB
Vue

<template>
<div class="attendance-container">
<div class="card">
<div class="header">
<router-link to="/manager/dashboard" class="back-link"> Back to Dashboard</router-link>
<h2 class="card-header">Attendance Log for {{ workerName }}</h2>
</div>
<div class="manual-entry-card">
<h3 class="manual-entry-header">Add Manual Clock-Out</h3>
<p class="manual-entry-desc">
Use this form if the worker forgot to clock out. The last event must be a clock-in.
</p>
<div class="manual-entry-form">
<div class="form-group">
<label for="manual-timestamp">Clock-Out Time</label>
<input
type="datetime-local"
id="manual-timestamp"
v-model="manualClockOut.timestamp"
class="form-input"
/>
</div>
<div class="form-group" style="flex-grow: 1">
<label for="manual-notes">Reason (e.g., "Forgot to clock out")</label>
<input
type="text"
id="manual-notes"
v-model="manualClockOut.notes"
class="form-input"
placeholder="Enter a brief note"
/>
</div>
<button @click="addManualClockOut" class="button-primary">Add Record</button>
</div>
</div>
<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>
<button @click="fetchRecords" class="button-primary">Filter Records</button>
</div>
<table>
<thead>
<tr>
<th>Event</th>
<th>Timestamp</th>
<th>Location Name</th>
<th>Coordinates</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr v-if="!records.length">
<td colspan="5" style="text-align: center; padding: 2rem">
No records found for this period.
</td>
</tr>
<tr v-for="record in records" :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>
<td>
<a
v-if="record.latitude && record.longitude"
:href="`https://www.google.com/maps?q=${record.latitude},${record.longitude}`"
target="_blank"
rel="noopener noreferrer"
class="map-link"
>
Show on map
</a>
<span v-else>N/A</span>
</td>
<td>{{ record.notes || 'N/A' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const records = ref([])
const workerName = ref('')
const workerId = route.params.workerId
// Get the current date and time in the required format for datetime-local input
const toLocalISOString = (date) => {
const tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
return localISOTime
}
// New state for manual clock-out form
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)
sevenDaysAgo.setDate(today.getDate() - 7)
const filters = ref({
startDate: sevenDaysAgo.toISOString().split('T')[0],
endDate: today.toISOString().split('T')[0],
})
const fetchRecords = async () => {
// Ensure we have a worker name, fetch if not
if (!workerName.value) {
try {
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
}
} catch (err) {
console.error('Failed to fetch worker name:', err)
workerName.value = `Worker #${workerId}` // Fallback name
}
}
// Fetch attendance records
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}`
}
try {
const res = await fetch(url)
if (res.ok) {
records.value = await res.json()
// If the worker name hasn't been set yet and records are found, set it.
if (!workerName.value && records.value.length > 0) {
workerName.value = records.value[0].full_name
}
}
} catch (err) {
console.error('Failed to fetch attendance records:', err)
}
}
// New method to add a manual clock-out record
const addManualClockOut = async () => {
if (!manualClockOut.value.timestamp) {
alert('Please select a timestamp for the clock-out.')
return
}
if (!manualClockOut.value.notes) {
alert('Please provide a reason/note for the manual entry.')
return
}
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/add-record`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workerId: workerId,
eventType: 'clock_out',
timestamp: manualClockOut.value.timestamp,
notes: manualClockOut.value.notes,
}),
})
const data = await res.json()
if (res.ok) {
alert('Manual clock-out recorded successfully!')
// Reset form and refresh the records
manualClockOut.value.notes = ''
manualClockOut.value.timestamp = toLocalISOString(new Date())
fetchRecords()
} else {
alert(`Failed to add record: ${data.message}`)
}
} catch (err) {
console.error('Failed to submit manual clock-out:', err)
alert('An error occurred while submitting the record.')
}
}
onMounted(() => {
fetchRecords()
})
</script>
<style scoped>
.attendance-container {
max-width: 1000px;
margin: auto;
}
.header {
margin-bottom: 1.5rem;
}
.back-link {
color: var(--c-primary);
text-decoration: none;
font-weight: 500;
}
.card-header {
margin-top: 0.5rem;
}
/* New styles for manual entry card */
.manual-entry-card {
background-color: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
}
.manual-entry-header {
margin-top: 0;
margin-bottom: 0.25rem;
}
.manual-entry-desc {
font-size: 0.9rem;
color: #666;
margin-top: 0;
margin-bottom: 1rem;
}
.manual-entry-form {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.filters {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.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 for the new map link */
.map-link {
color: var(--c-primary);
text-decoration: underline;
font-weight: 500;
}
.map-link:hover {
color: var(--c-primary-dark);
}
</style>