Add Settings page, List page, report template

This commit is contained in:
IT Dog
2026-07-21 01:56:10 +08:00
parent 95814350c9
commit 6f90d60b13
3 changed files with 826 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}} Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', 'Noto Sans TC', sans-serif; background: #f8fafc; color: #0f172a; font-size: 13px; }
.report-header { background: linear-gradient(135deg, {{HEADER_COLOR}} 0%, {{HEADER_DARK}} 100%); color: white; padding: 24px 32px; }
.report-header h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.report-header p { opacity: 0.85; font-size: 13px; }
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 20px 32px; }
.stat-card { background: white; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border-left: 4px solid {{ACCENT_COLOR}}; }
.stat-card .label { font-size: 11px; color: #64748b; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 26px; font-weight: 700; color: #0f172a; margin-top: 4px; }
.stat-card .sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.charts-section { padding: 16px 32px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.chart-card { background: white; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.chart-card h3 { font-size: 13px; font-weight: 600; color: #334155; margin-bottom: 12px; border-bottom: 2px solid {{ACCENT_COLOR}}; padding-bottom: 6px; }
.chart-wrap { position: relative; height: 200px; }
.filter-bar { padding: 12px 32px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; background: white; border-bottom: 1px solid #e2e8f0; }
.filter-bar input { padding: 7px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; width: 200px; }
.filter-bar select { padding: 7px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; }
.filter-bar .badge { background: #f1f5f9; padding: 6px 12px; border-radius: 20px; font-size: 12px; color: #475569; }
.table-section { padding: 0 32px 32px; }
.table-wrap { background: white; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
thead { background: #f8fafc; }
thead th { padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0; cursor: pointer; user-select: none; white-space: nowrap; }
thead th:hover { background: #e2e8f0; }
thead th .sort-icon { margin-left: 4px; opacity: 0.4; font-size: 10px; }
thead th.sorted .sort-icon { opacity: 1; color: {{ACCENT_COLOR}}; }
tbody tr { border-bottom: 1px solid #f1f5f9; transition: background 0.15s; }
tbody tr:hover { background: #f8fafc; }
tbody td { padding: 9px 12px; font-size: 13px; color: #334155; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tbody tr.row-normal { border-left: 3px solid transparent; }
tbody tr.row-warning { border-left: 3px solid #f59e0b; }
tbody tr.row-danger { border-left: 3px solid #ef4444; }
tbody tr.row-success { border-left: 3px solid #10b981; }
.cell-normal { color: #334155; }
.cell-warning { color: #d97706; background: #fef3c7; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-danger { color: #dc2626; background: #fee2e2; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-success { color: #059669; background: #d1fae5; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
.cell-info { color: #2563eb; background: #dbeafe; padding: 2px 6px; border-radius: 4px; font-weight: 500; }
/* Popup */
.popup-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 9999; align-items: center; justify-content: center; }
.popup-overlay.active { display: flex; }
.popup { background: white; border-radius: 12px; width: 480px; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
.popup-header { padding: 16px 20px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; background: {{HEADER_COLOR}}; color: white; border-radius: 12px 12px 0 0; }
.popup-header h2 { font-size: 15px; font-weight: 600; }
.popup-close { background: rgba(255,255,255,0.2); border: none; color: white; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 16px; }
.popup-body { padding: 16px 20px; }
.popup-row { display: flex; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
.popup-row:last-child { border-bottom: none; }
.popup-label { font-weight: 600; color: #64748b; font-size: 12px; width: 130px; flex-shrink: 0; }
.popup-value { color: #0f172a; font-size: 13px; word-break: break-all; }
.pagination { padding: 12px 32px; display: flex; gap: 6px; align-items: center; justify-content: space-between; background: white; border-top: 1px solid #e2e8f0; }
.pagination-info { font-size: 12px; color: #64748b; }
.pagination buttons { display: flex; gap: 4px; }
.pagination button { padding: 6px 12px; border: 1px solid #cbd5e1; background: white; border-radius: 6px; cursor: pointer; font-size: 12px; }
.pagination button:hover { background: #f1f5f9; }
.pagination button.active { background: {{ACCENT_COLOR}}; color: white; border-color: {{ACCENT_COLOR}}; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.footer { text-align: center; padding: 16px; color: #94a3b8; font-size: 11px; }
</style>
</head>
<body>
<!-- Popup -->
<div class="popup-overlay" id="popup">
<div class="popup">
<div class="popup-header">
<h2 id="popup-title">詳細資料</h2>
<button class="popup-close" onclick="closePopup()">×</button>
</div>
<div class="popup-body" id="popup-body"></div>
</div>
</div>
<!-- Header -->
<div class="report-header">
<h1>{{REPORT_TITLE}}</h1>
<p>Generated: {{GENERATED_DATE}} | {{TOTAL_RECORDS}} 筆記錄</p>
</div>
<!-- Stats -->
<div class="stats-row" id="stats-row"></div>
<!-- Charts -->
<div class="charts-section">
<div class="chart-card">
<h3>📊 圖表 1</h3>
<div class="chart-wrap"><canvas id="chart1"></canvas></div>
</div>
<div class="chart-card">
<h3>📊 圖表 2</h3>
<div class="chart-wrap"><canvas id="chart2"></canvas></div>
</div>
<div class="chart-card">
<h3>📊 圖表 3</h3>
<div class="chart-wrap"><canvas id="chart3"></canvas></div>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<input type="text" id="searchInput" placeholder="🔍 搜尋所有欄位..." oninput="applyFilters()">
<select id="filterCol" onchange="applyFilters()"><option value="">全部欄位</option>{{COLUMN_OPTIONS}}</select>
<select id="filterOp" onchange="applyFilters()"><option value="contains">包含</option><option value="equals">等於</option><option value="gt">大於</option><option value="lt">小於</option></select>
<input type="text" id="filterVal" placeholder="篩選值..." oninput="applyFilters()">
<span class="badge" id="resultCount"></span>
<button onclick="clearFilters()" style="margin-left:auto;padding:6px 12px;border:1px solid #cbd5e1;background:white;border-radius:6px;cursor:pointer;font-size:12px;">清除篩選</button>
<button onclick="window.print()" style="padding:6px 14px;background:{{ACCENT_COLOR}};color:white;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;">🖨️ 列印</button>
</div>
<!-- Table -->
<div class="table-section">
<div class="table-wrap">
<table>
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="pagination">
<div class="pagination-info" id="pageInfo"></div>
<div id="pageButtons"></div>
</div>
<div class="footer">
AARS Report System | {{GENERATED_DATE}} | Page 1 of 1
</div>
<script>
const DATA = {{EMBEDDED_DATA}};
const COLS = {{COLUMN_DEFS}};
const CHARTS = {{CHART_CONFIG}};
const ACCENT = "{{ACCENT_COLOR}}";
const PAGE_SIZE = 50;
let currentPage = 1;
let filteredData = [];
let sortKey = null;
let sortDir = "asc";
function init() {
filteredData = [...DATA];
renderStats();
renderCharts();
applyFilters();
}
function renderStats() {
const row = document.getElementById("stats-row");
row.innerHTML = CHARTS.stats.map(s => `
<div class="stat-card">
<div class="label">${s.label}</div>
<div class="value">${s.value}</div>
${s.sub ? `<div class="sub">${s.sub}</div>` : ""}
</div>
`).join("");
}
function renderCharts() {
CHARTS.charts.forEach((cfg, i) => {
const ctx = document.getElementById(`chart${i+1}`);
if (!ctx) return;
new Chart(ctx, {
type: cfg.type,
data: cfg.data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { font: { size: 11 }, boxWidth: 12, padding: 8 } },
tooltip: { callbacks: cfg.tooltipCallbacks || {} }
},
onClick: (e, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
const label = cfg.data.labels[idx];
// Auto-filter table when clicking chart segment
const col = cfg.filterColumn;
if (col) {
document.getElementById("filterCol").value = col;
document.getElementById("filterVal").value = label;
applyFilters();
}
}
}
}
});
});
}
function getCellClass(val, col) {
if (val === null || val === undefined || val === "") return "cell-normal";
const v = String(val).toLowerCase();
if (["missing", "缺勤", "no show"].some(t => v.includes(t))) return "cell-danger";
if (["late", "遲到", "遲"].some(t => v.includes(t))) return "cell-warning";
if (["early", "早退"].some(t => v.includes(t))) return "cell-warning";
if (["normal", "正常", "present", "準時"].some(t => v.includes(t))) return "cell-success";
if (["ot", "overtime", "超時"].some(t => v.includes(t))) return "cell-info";
if (["high", "red", "danger", "緊急", "嚴重"].some(t => v.includes(t))) return "cell-danger";
if (["medium", "medium", "中度", "中等"].some(t => v.includes(t))) return "cell-warning";
if (["low", "green", "輕微", "輕度"].some(t => v.includes(t))) return "cell-success";
return "cell-normal";
}
function getRowClass(row) {
const statusKeys = Object.keys(row).find(k => /status|狀態|status_text/i.test(k));
if (statusKeys) {
const v = String(row[statusKeys] || "").toLowerCase();
if (["missing", "缺勤", "no show", "late", "遲到", "early", "早退"].some(t => v.includes(t))) return "row-warning";
if (["normal", "正常", "present"].some(t => v.includes(t))) return "row-normal";
}
const sevKeys = Object.keys(row).find(k => /severity|緊急|程度/i.test(k));
if (sevKeys) {
const v = String(row[sevKeys] || "").toLowerCase();
if (["high", "red", "danger", "緊急", "嚴重", "死亡"].some(t => v.includes(t))) return "row-danger";
if (["medium", "中度", "中等"].some(t => v.includes(t))) return "row-warning";
if (["low", "green", "輕微", "輕度", "輕傷"].some(t => v.includes(t))) return "row-success";
}
return "row-normal";
}
function applyFilters() {
const search = document.getElementById("searchInput").value.toLowerCase();
const col = document.getElementById("filterCol").value;
const op = document.getElementById("filterOp").value;
const val = document.getElementById("filterVal").value.toLowerCase();
filteredData = DATA.filter(row => {
if (search) {
const hit = Object.values(row).some(v => v !== null && String(v).toLowerCase().includes(search));
if (!hit) return false;
}
if (col && val) {
const cellVal = String(row[col] || "").toLowerCase();
if (op === "contains") { if (!cellVal.includes(val)) return false; }
else if (op === "equals") { if (cellVal !== val) return false; }
else if (op === "gt") { if (!(parseFloat(row[col]) > parseFloat(val))) return false; }
else if (op === "lt") { if (!(parseFloat(row[col]) < parseFloat(val))) return false; }
}
return true;
});
if (sortKey) sortFiltered();
currentPage = 1;
renderTable();
document.getElementById("resultCount").textContent = `${filteredData.length} 筆記錄`;
}
function clearFilters() {
document.getElementById("searchInput").value = "";
document.getElementById("filterCol").value = "";
document.getElementById("filterOp").value = "contains";
document.getElementById("filterVal").value = "";
applyFilters();
}
function sortFiltered() {
filteredData.sort((a, b) => {
let aVal = a[sortKey] || "";
let bVal = b[sortKey] || "";
const aNum = parseFloat(aVal);
const bNum = parseFloat(bVal);
let cmp;
if (!isNaN(aNum) && !isNaN(bNum)) cmp = aNum - bNum;
else cmp = String(aVal).localeCompare(String(bVal), "zh-Hant");
return sortDir === "asc" ? cmp : -cmp;
});
}
function handleSort(key) {
if (sortKey === key) sortDir = sortDir === "asc" ? "desc" : "asc";
else { sortKey = key; sortDir = "asc"; }
sortFiltered();
renderTable();
}
function renderTable() {
const thead = document.getElementById("thead");
const visibleCols = COLS.filter(c => !c.hide);
const displayCols = visibleCols.length > 6 ? visibleCols.slice(0, 6) : visibleCols;
thead.innerHTML = `<tr>${displayCols.map((c, i) => {
const icon = sortKey === c.key ? (sortDir === "asc" ? "▲" : "▼") : "▽";
const cls = sortKey === c.key ? "sorted" : "";
return `<th class="${cls}" onclick="handleSort('${c.key}')">${c.label || c.key}<span class="sort-icon">${icon}</span></th>`;
}).join("")}<th>操作</th></tr>`;
const totalPages = Math.max(1, Math.ceil(filteredData.length / PAGE_SIZE));
const start = (currentPage - 1) * PAGE_SIZE;
const pageRows = filteredData.slice(start, start + PAGE_SIZE);
document.getElementById("tbody").innerHTML = pageRows.map(row => {
const rowClass = getRowClass(row);
const cells = displayCols.map(c => {
const val = row[c.key];
const cls = getCellClass(val, c.key);
return `<td class="${cls}" title="${val ?? ""}">${val !== null && val !== undefined ? String(val) : "-"}</td>`;
}).join("");
return `<tr class="${rowClass}" ondblclick="showPopup(${start + filteredData.indexOf(row)})">${cells}<td><button onclick="showPopup(${start + filteredData.indexOf(row)})" style="background:{{ACCENT_COLOR}};color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px;">詳情</button></td></tr>`;
}).join("");
// pagination buttons
const pageInfo = document.getElementById("pageInfo");
pageInfo.textContent = `${currentPage} / ${totalPages} 頁,共 ${filteredData.length} 筆記錄`;
const btns = document.getElementById("pageButtons");
btns.innerHTML = `<button ${currentPage===1?"disabled":""} onclick="goPage(${currentPage-1})">上一頁</button>`;
for (let p = Math.max(1, currentPage-2); p <= Math.min(totalPages, currentPage+3); p++) {
btns.innerHTML += `<button class="${p===currentPage?"active":""}" onclick="goPage(${p})">${p}</button>`;
}
btns.innerHTML += `<button ${currentPage===totalPages?"disabled":""} onclick="goPage(${currentPage+1})">下一頁</button>`;
}
function goPage(p) {
const totalPages = Math.max(1, Math.ceil(filteredData.length / PAGE_SIZE));
if (p < 1 || p > totalPages) return;
currentPage = p;
renderTable();
}
function showPopup(idx) {
const row = filteredData[idx];
if (!row) return;
const visibleCols = COLS.filter(c => !c.hide);
let html = visibleCols.map(c => `<div class="popup-row"><div class="popup-label">${c.label || c.key}</div><div class="popup-value">${row[c.key] !== null && row[c.key] !== undefined ? String(row[c.key]) : "-"}</div></div>`).join("");
document.getElementById("popup-body").innerHTML = html;
document.getElementById("popup-title").textContent = "詳細資料 #" + idx;
document.getElementById("popup").classList.add("active");
}
function closePopup() {
document.getElementById("popup").classList.remove("active");
}
document.getElementById("popup").addEventListener("click", function(e) {
if (e.target === this) closePopup();
});
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") closePopup();
});
init();
</script>
</body>
</html>
+317
View File
@@ -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' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
// 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 (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
<div className="flex gap-2">
<button
onClick={() => handleExportHTML('attendance')}
className="flex items-center gap-2 px-4 py-2 bg-slate-700 text-white text-sm rounded-md hover:bg-slate-800"
>
<FileCode size={16} />
匯出 HTML Report
</button>
<Link
to="/upload"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
上傳 Excel
</Link>
</div>
</div>
{/* Filters */}
<div className="card p-3">
<div className="flex flex-wrap gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜尋所有欄位..."
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
/>
</div>
{/* Column Filter */}
<div className="flex items-center gap-2">
<Filter size={16} className="text-slate-400" />
<select
value={filterKey}
onChange={(e) => handleFilter(e.target.value)}
className="text-sm border border-slate-300 rounded-md px-2 py-2"
>
<option value="">篩選欄位</option>
{headers.slice(0, 6).map(h => (
<option key={h} value={h}>{h}</option>
))}
</select>
{filterKey && (
<>
<input
type="text"
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
placeholder="輸入篩選值..."
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
/>
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
<X size={16} />
</button>
</>
)}
</div>
{/* Result count */}
<div className="text-sm text-slate-500 py-2">
{processedData.length} 筆記錄
{latenessRecords.length > 0 && (
<span className="ml-2 text-orange-600">
{latenessRecords.length} 筆遲到
</span>
)}
</div>
</div>
</div>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-100 border-b border-slate-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600 w-12">#</th>
{headers.slice(0, 6).map(h => (
<th
key={h}
className="px-3 py-2 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
onClick={() => handleSort(h)}
>
<div className="flex items-center gap-1">
{h}
{getSortIcon(h)}
</div>
</th>
))}
<th className="px-3 py-2 text-center text-xs font-semibold text-slate-600 w-20">狀態</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
載入中...
</td>
</tr>
) : processedData.length === 0 ? (
<tr>
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
processedData.map((record) => {
const lateInfo = getLatenessInfo(record)
const isLate = lateInfo && lateInfo.minutes_late > 0
return (
<tr
key={record._row}
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
onClick={() => window.location.href = `/attendance/${record._row}`}
>
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
{headers.slice(0, 6).map(h => (
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
</td>
))}
<td className="px-3 py-2 text-center">
{isLate ? (
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}>
<AlertCircle size={14} />
<span className="text-xs font-medium">{lateInfo.minutes_late}</span>
</div>
) : (
<div className="flex items-center justify-center gap-1 text-green-600">
<Clock size={14} />
<span className="text-xs">正常</span>
</div>
)}
</td>
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link
to={`/attendance/${record._row}`}
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
>
<Eye size={16} />
</Link>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+143
View File
@@ -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 (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-slate-700" />
<div>
<h1 className="text-2xl font-bold text-slate-900">系統管理</h1>
<p className="text-sm text-slate-500 mt-0.5">
危險操作請小心使用
</p>
</div>
</div>
{/* Danger Zone */}
<div className="bg-white border border-red-200 rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-red-100 bg-red-50/50">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<h2 className="text-lg font-semibold text-red-800">Danger Zone 資料庫重置</h2>
</div>
<p className="text-sm text-red-600 mt-1">
以下操作將永久刪除資料無法復原請確認後再執行
</p>
</div>
<div className="divide-y divide-slate-100">
{RESET_SECTIONS.map((section) => {
const res = result[section.key]
const isLoading = loading[section.key]
return (
<div key={section.key} className="px-5 py-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1">
<h3 className="font-semibold text-slate-900">{section.label}</h3>
<p className="text-sm text-slate-500 mt-0.5">{section.description}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
className="w-32 px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-slate-400"
placeholder={`輸入 "${section.key}"`}
value={confirmText[section.key] || ""}
onChange={(e) => {
setConfirmText((prev) => ({ ...prev, [section.key]: e.target.value }))
setResult((prev) => ({ ...prev, [section.key]: null }))
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleReset(section.key)
}}
/>
<button
onClick={() => handleReset(section.key)}
disabled={isLoading || (confirmText[section.key] || "") !== section.key}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
>
<Trash2 className="w-4 h-4" />
{isLoading ? "刪除中..." : "重置"}
</button>
</div>
</div>
{/* Result message */}
{res && (
<div
className={`mt-3 text-sm px-3 py-2 rounded-md ${
res.error
? "bg-red-50 text-red-700 border border-red-200"
: "bg-green-50 text-green-700 border border-green-200"
}`}
>
{res.error ? `${res.error}` : `${res.success}`}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}