diff --git a/backend/report_template.html b/backend/report_template.html new file mode 100644 index 0000000..de03d87 --- /dev/null +++ b/backend/report_template.html @@ -0,0 +1,366 @@ + + + + + +{{TITLE}} Report + + + + + + + + + + +
+

{{REPORT_TITLE}}

+

Generated: {{GENERATED_DATE}} | {{TOTAL_RECORDS}} 筆記錄

+
+ + +
+ + +
+
+

📊 圖表 1

+
+
+
+

📊 圖表 2

+
+
+
+

📊 圖表 3

+
+
+
+ + +
+ + + + + + + +
+ + +
+
+ + + +
+
+
+ + + + + + + + + diff --git a/frontend/src/pages/List.jsx b/frontend/src/pages/List.jsx new file mode 100644 index 0000000..8bab9ea --- /dev/null +++ b/frontend/src/pages/List.jsx @@ -0,0 +1,317 @@ +import { useState, useEffect, useMemo } from 'react' +import { Link } from 'react-router-dom' +import api from '../../api' +import toast from 'react-hot-toast' +import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock, FileCode } from 'lucide-react' + +const handleExportHTML = (section) => { + const token = localStorage.getItem('aars_token') + if (token) { + window.open(`/api/report/${section}/html?token=${token}`, '_blank') + } +} + +export default function AttendanceList() { + const [records, setRecords] = useState([]) + const [headers, setHeaders] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [sortKey, setSortKey] = useState(null) + const [sortDir, setSortDir] = useState('asc') + const [filterKey, setFilterKey] = useState('') + const [filterValue, setFilterValue] = useState('') + const [latenessRecords, setLatenessRecords] = useState([]) + + useEffect(() => { + fetchRecords() + fetchLateness() + }, []) + + const fetchRecords = async () => { + setLoading(true) + try { + const { data } = await api.get('/api/attendance?page=1&page_size=1000') + setRecords(data.data || []) + + if (data.data && data.data.length > 0) { + const keys = Object.keys(data.data[0]).filter(k => k !== '_row') + setHeaders(keys) + } + } catch (err) { + toast.error('Failed to load records') + } finally { + setLoading(false) + } + } + + const fetchLateness = async () => { + try { + const { data } = await api.get('/api/attendance/lateness/records') + setLatenessRecords(data || []) + } catch (err) { + console.error('Failed to load lateness data:', err) + } + } + + // Create a map of late records for quick lookup + const latenessMap = useMemo(() => { + const map = {} + latenessRecords.forEach(rec => { + const key = `${rec.staff}-${rec.date}` + map[key] = rec + }) + return map + }, [latenessRecords]) + + // Filtered and sorted data + const processedData = useMemo(() => { + let result = [...records] + + // Filter + if (filterKey && filterValue) { + result = result.filter(r => { + const val = String(r[filterKey] || '').toLowerCase() + return val.includes(filterValue.toLowerCase()) + }) + } + + // Search + if (search) { + const searchLower = search.toLowerCase() + result = result.filter(r => + Object.values(r).some(v => + v && String(v).toLowerCase().includes(searchLower) + ) + ) + } + + // Sort + if (sortKey) { + result.sort((a, b) => { + const aVal = a[sortKey] || '' + const bVal = b[sortKey] || '' + const aNum = Number(aVal) + const bNum = Number(bVal) + + let cmp = 0 + if (!isNaN(aNum) && !isNaN(bNum)) { + cmp = aNum - bNum + } else { + cmp = String(aVal).localeCompare(String(bVal)) + } + return sortDir === 'asc' ? cmp : -cmp + }) + } + + return result + }, [records, search, sortKey, sortDir, filterKey, filterValue]) + + const handleSort = (key) => { + if (sortKey === key) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc') + } else { + setSortKey(key) + setSortDir('asc') + } + } + + const handleFilter = (key) => { + setFilterKey(key) + setFilterValue('') + } + + const clearFilter = () => { + setFilterKey('') + setFilterValue('') + } + + const getSortIcon = (key) => { + if (sortKey !== key) return null + return sortDir === 'asc' ? : + } + + // Get lateness info for a record + const getLatenessInfo = (record) => { + // Find staff name and date columns + const staffKey = Object.keys(record).find(k => + k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工') + ) + const dateKey = Object.keys(record).find(k => + k.toLowerCase().includes('date') || k.toLowerCase().includes('日期') + ) + + if (!staffKey || !dateKey) return null + + const staff = record[staffKey] + const date = record[dateKey] + + if (!staff || !date) return null + + const key = `${staff}-${String(date).slice(0, 10)}` + return latenessMap[key] + } + + return ( +
+ {/* Header */} +
+

出勤記錄 Attendance

+
+ + + 上傳 Excel + +
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="搜尋所有欄位..." + className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md" + /> +
+ + {/* Column Filter */} +
+ + + + {filterKey && ( + <> + setFilterValue(e.target.value)} + placeholder="輸入篩選值..." + className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32" + /> + + + )} +
+ + {/* Result count */} +
+ {processedData.length} 筆記錄 + {latenessRecords.length > 0 && ( + + • {latenessRecords.length} 筆遲到 + + )} +
+
+
+ + {/* Table */} +
+
+ + + + + {headers.slice(0, 6).map(h => ( + + ))} + + + + + + {loading ? ( + + + + ) : processedData.length === 0 ? ( + + + + ) : ( + processedData.map((record) => { + const lateInfo = getLatenessInfo(record) + const isLate = lateInfo && lateInfo.minutes_late > 0 + + return ( + window.location.href = `/attendance/${record._row}`} + > + + {headers.slice(0, 6).map(h => ( + + ))} + + + + ) + }) + )} + +
# handleSort(h)} + > +
+ {h} + {getSortIcon(h)} +
+
狀態操作
+ 載入中... +
+ 沒有記錄 +
{record._row} + {record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'} + + {isLate ? ( +
+ + {lateInfo.minutes_late}分 +
+ ) : ( +
+ + 正常 +
+ )} +
e.stopPropagation()}> + + + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx new file mode 100644 index 0000000..02d3cc3 --- /dev/null +++ b/frontend/src/pages/Settings.jsx @@ -0,0 +1,143 @@ +import { useState } from "react" +import { Trash2, AlertTriangle, Shield } from "lucide-react" +import api from "../api" + +const RESET_SECTIONS = [ + { + key: "attendance", + label: "出勤 Attendance", + description: "刪除所有出勤記錄(attendance_records 全表清空)", + color: "red", + }, + { + key: "accident", + label: "意外 Accident", + description: "刪除所有意外記錄(accidents 全表清空)", + color: "orange", + }, + { + key: "holidays", + label: "假期 Holidays + Leave", + description: "刪除所有公眾假期 + 員工請假記錄", + color: "amber", + }, +] + +export default function Settings() { + const [confirmText, setConfirmText] = useState({}) + const [loading, setLoading] = useState({}) + const [result, setResult] = useState({}) + + const handleReset = async (section) => { + const confirmVal = confirmText[section] || "" + if (confirmVal !== section) { + setResult((prev) => ({ ...prev, [section]: { error: `請輸入 "${section}" 以確認刪除` } })) + return + } + + setLoading((prev) => ({ ...prev, [section]: true })) + setResult((prev) => ({ ...prev, [section]: null })) + + try { + const res = await api.post(`/api/admin/reset/${section}`, { confirm: section }) + setResult((prev) => ({ + ...prev, + [section]: { + success: `已刪除 ${res.data.deleted} 條記錄`, + }, + })) + setConfirmText((prev) => ({ ...prev, [section]: "" })) + } catch (err) { + setResult((prev) => ({ + ...prev, + [section]: { + error: err.response?.data?.detail || err.message || "刪除失敗", + }, + })) + } finally { + setLoading((prev) => ({ ...prev, [section]: false })) + } + } + + return ( +
+
+ +
+

系統管理

+

+ 危險操作,請小心使用 +

+
+
+ + {/* Danger Zone */} +
+
+
+ +

Danger Zone — 資料庫重置

+
+

+ 以下操作將永久刪除資料,無法復原。請確認後再執行。 +

+
+ +
+ {RESET_SECTIONS.map((section) => { + const res = result[section.key] + const isLoading = loading[section.key] + + return ( +
+
+
+

{section.label}

+

{section.description}

+
+ +
+ { + setConfirmText((prev) => ({ ...prev, [section.key]: e.target.value })) + setResult((prev) => ({ ...prev, [section.key]: null })) + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleReset(section.key) + }} + /> + +
+
+ + {/* Result message */} + {res && ( +
+ {res.error ? `❌ ${res.error}` : `✅ ${res.success}`} +
+ )} +
+ ) + })} +
+
+
+ ) +}