Initial commit: AARS backend + frontend + holiday/leave phase 1+2
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Save, X } from 'lucide-react'
|
||||
|
||||
export default function AttendanceDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [record, setRecord] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [formData, setFormData] = useState({})
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecord()
|
||||
}, [id])
|
||||
|
||||
const fetchRecord = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Fetch all records and find by id
|
||||
const { data } = await api.get('/api/attendance/records?per_page=1000')
|
||||
const found = (data.records || []).find(r => r.id === parseInt(id))
|
||||
if (found) {
|
||||
setRecord(found)
|
||||
setFormData({
|
||||
employee_name: found.employee_name || '',
|
||||
company: found.company || '',
|
||||
date: found.date || '',
|
||||
weekday: found.weekday || '',
|
||||
shift_code: found.shift_code || '',
|
||||
status_code: found.status_code || '',
|
||||
status_text: found.status_text || '',
|
||||
late_minutes: found.late_minutes || 0,
|
||||
early_minutes: found.early_minutes || 0,
|
||||
ot_minutes: found.ot_minutes || 0,
|
||||
expected_in: found.expected_in || '',
|
||||
expected_out: found.expected_out || '',
|
||||
actual_in: found.actual_in || '',
|
||||
actual_out: found.actual_out || '',
|
||||
is_manually_edited: found.is_manually_edited || false,
|
||||
})
|
||||
} else {
|
||||
setError('找不到記錄')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error:', err)
|
||||
setError('載入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.put(`/api/attendance/${id}`, formData)
|
||||
toast.success('保存成功')
|
||||
setEditMode(false)
|
||||
fetchRecord()
|
||||
} catch (err) {
|
||||
toast.error('保存失敗')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditMode(false)
|
||||
if (record) {
|
||||
setFormData({
|
||||
employee_name: record.employee_name || '',
|
||||
company: record.company || '',
|
||||
date: record.date || '',
|
||||
weekday: record.weekday || '',
|
||||
shift_code: record.shift_code || '',
|
||||
status_code: record.status_code || '',
|
||||
status_text: record.status_text || '',
|
||||
late_minutes: record.late_minutes || 0,
|
||||
early_minutes: record.early_minutes || 0,
|
||||
ot_minutes: record.ot_minutes || 0,
|
||||
expected_in: record.expected_in || '',
|
||||
expected_out: record.expected_out || '',
|
||||
actual_in: record.actual_in || '',
|
||||
actual_out: record.actual_out || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (code, text) => {
|
||||
const badges = {
|
||||
normal: 'bg-green-100 text-green-700',
|
||||
late: 'bg-orange-100 text-orange-700',
|
||||
early: 'bg-blue-100 text-blue-700',
|
||||
ot: 'bg-purple-100 text-purple-700',
|
||||
abnormal: 'bg-red-100 text-red-700',
|
||||
missing: 'bg-gray-200 text-gray-600',
|
||||
late_early: 'bg-orange-100 text-orange-700',
|
||||
late_ot: 'bg-orange-100 text-orange-700',
|
||||
early_ot: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${badges[code] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{text || code}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const displayFields = [
|
||||
{ key: 'employee_name', label: '員工姓名' },
|
||||
{ key: 'company', label: '公司' },
|
||||
{ key: 'date', label: '日期' },
|
||||
{ key: 'weekday', label: '星期' },
|
||||
{ key: 'shift_code', label: '班次' },
|
||||
{ key: 'expected_in', label: '應上班時間' },
|
||||
{ key: 'expected_out', label: '應下班時間' },
|
||||
{ key: 'actual_in', label: '實際上班' },
|
||||
{ key: 'actual_out', label: '實際下班' },
|
||||
{ key: 'late_minutes', label: '遲到分鐘' },
|
||||
{ key: 'early_minutes', label: '早退分鐘' },
|
||||
{ key: 'ot_minutes', label: 'OT分鐘' },
|
||||
{ key: 'status_code', label: '狀態代碼' },
|
||||
{ key: 'status_text', label: '狀態' },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-slate-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
|
||||
<ArrowLeft size={20} /> 返回列表
|
||||
</Link>
|
||||
<div className="card p-8 text-center text-red-500">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
|
||||
<ArrowLeft size={20} /> 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-slate-900">
|
||||
出勤記錄 - {record?.employee_name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{record && getStatusBadge(record.status_code, record.status_text)}
|
||||
{!editMode ? (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-300 text-slate-700 text-sm rounded-md hover:bg-slate-50"
|
||||
>
|
||||
<X size={16} /> 取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} /> {saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual edit warning */}
|
||||
{record?.is_manually_edited && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3 text-sm text-yellow-800">
|
||||
⚠️ 此記錄曾被手動修改,之後重新導入 Excel 不會覆蓋此記錄
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record Details */}
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-slate-200 bg-slate-50">
|
||||
<h2 className="font-semibold text-slate-700">記錄詳情</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{displayFields.map(({ key, label }) => (
|
||||
<div key={key} className="grid grid-cols-12 items-center">
|
||||
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
|
||||
{label}
|
||||
</div>
|
||||
<div className="col-span-9 px-4 py-3">
|
||||
{editMode && ['status_code', 'late_minutes', 'early_minutes', 'ot_minutes', 'actual_in', 'actual_out', 'shift_code'].includes(key) ? (
|
||||
<input
|
||||
type="text"
|
||||
value={formData[key]}
|
||||
onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-slate-900">
|
||||
{record?.[key] !== null && record?.[key] !== undefined ? String(record[key]) : '-'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user