Add Settings page, List page, report template
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user