4a04cfe15b
- 添加vite环境类型定义文件 - 优化考勤记录视图 - 修复后端时间戳处理问题 - 重构管理仪表盘响应式布局 - 改进工人历史视图卡片式布局 - 优化人员管理组件表格响应式 - 增强二维码管理组件移动端适配 - 重构考勤报表组件添加全选功能
285 lines
7.8 KiB
Vue
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>
|