Compare commits
49 Commits
8e0d73c37e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| acbc767247 | |||
| f6712d9d0e | |||
| 262c44f6a1 | |||
| 3c855d521b | |||
| 5596ab2cb5 | |||
| a7dfd208d2 | |||
| a16e243da1 | |||
| 4a01b53541 | |||
| 404329d3fa | |||
| 811b9991c9 | |||
| f12a239e26 | |||
| fcc3246d8e | |||
| 3ba6617009 | |||
| 4bd6d0e7c8 | |||
| 7702fe4ae5 | |||
| d302fa9beb | |||
| 4b967f7920 | |||
| 8b378ec4c6 | |||
| 7dd53e0a5f | |||
| 33a5e3b2ad | |||
| 4c9db01213 | |||
| ef57d7cd59 | |||
| f41dad6127 | |||
| 6f2df815f5 | |||
| c1b1d6f35b | |||
| e531cea8a4 | |||
| 2d64d3f0b6 | |||
| 08ba4cbca9 | |||
| c07f40b28f | |||
| 2d9b73df9d | |||
| 9980f8d80a | |||
| 1e7d58486b | |||
| cca088261b | |||
| adc9e6451b | |||
| a34905661b | |||
| 89c14c7a72 | |||
| 1a60c71032 | |||
| d5d9d644a0 | |||
| 6f90d60b13 | |||
| 95814350c9 | |||
| 975791b9bd | |||
| 0d34b51918 | |||
| aca1c55072 | |||
| 4bcda0a907 | |||
| c0076a125c | |||
| 6c70b81df5 | |||
| 25ebf6da66 | |||
| 0cd8717320 | |||
| 347a862cae |
+14
-1
@@ -15,7 +15,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto")
|
||||
# bcrypt is the primary scheme (cross-version stable, OpenSSL-native).
|
||||
# sha256_crypt is kept as deprecated fallback so legacy hashes still
|
||||
# verify; on first successful login the password is re-hashed to bcrypt
|
||||
# via verify_and_update().
|
||||
pwd_context = CryptContext(schemes=["bcrypt", "sha256_crypt"], deprecated=["sha256_crypt"])
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
|
||||
@@ -34,6 +38,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, str | None]:
|
||||
"""Verify password and return new hash if rehash is needed.
|
||||
|
||||
Returns (ok, new_hash_or_none). Caller is responsible for committing
|
||||
the new hash to the DB when new_hash is not None.
|
||||
"""
|
||||
return pwd_context.verify_and_update(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
+611
-96
File diff suppressed because it is too large
Load Diff
+29
-4
@@ -25,19 +25,29 @@ class User(Base):
|
||||
|
||||
|
||||
class Accident(Base):
|
||||
"""意外記錄 Accident Reports
|
||||
|
||||
IMPORTANT — Column naming convention (2026-07-19 cleanup):
|
||||
- submitter_email: The Google Form submitter's email (NOT the injured person)
|
||||
- injured_person: Name of the staff who got hurt (was: responsible_person)
|
||||
- injured_person_id: Employee ID (was: employee_id)
|
||||
|
||||
This naming makes it clear these are different concepts, preventing
|
||||
accidental joins with attendance_records.employee_name (which IS a real name).
|
||||
"""
|
||||
__tablename__ = "accidents"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(Date, nullable=False)
|
||||
time = Column(String(10), nullable=True)
|
||||
location = Column(String(255), nullable=False)
|
||||
employee_name = Column(String(255), nullable=False)
|
||||
employee_id = Column(String(100))
|
||||
submitter_email = Column(String(255), nullable=False)
|
||||
injured_person_id = Column(String(100))
|
||||
department = Column(String(100))
|
||||
description = Column(Text, nullable=False)
|
||||
severity = Column(String(50), nullable=False)
|
||||
medical_report = Column(Text, nullable=True)
|
||||
action_taken = Column(Text, nullable=True)
|
||||
responsible_person = Column(String(255), nullable=True)
|
||||
injured_person = Column(String(255), nullable=True)
|
||||
|
||||
# Excel-derived (Google Form) extra columns (full map) -- 2026-07-18
|
||||
patient_or_staff = Column(Text, nullable=True) # Excel col 8
|
||||
@@ -50,6 +60,11 @@ class Accident(Base):
|
||||
|
||||
custom_fields = Column(JSON, default={})
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
|
||||
# Manual edit tracking - upload skips records with non-null last_edited_at
|
||||
last_edited_at = Column(DateTime, nullable=True)
|
||||
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
@@ -156,12 +171,16 @@ class AttendanceRecord(Base):
|
||||
actual_in = Column(String(10), nullable=True)
|
||||
actual_out = Column(String(10), nullable=True)
|
||||
|
||||
# 人手修改標記
|
||||
# 人手修改標記 (deprecated - use last_edited_at instead)
|
||||
is_manually_edited = Column(Boolean, default=False)
|
||||
|
||||
# 原始 Excel 數據
|
||||
raw_data = Column(JSON, default={})
|
||||
|
||||
# Manual edit tracking - upload skips records with non-null last_edited_at
|
||||
last_edited_at = Column(DateTime, nullable=True)
|
||||
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
|
||||
@@ -200,8 +219,14 @@ class LeaveRecord(Base):
|
||||
|
||||
Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0).
|
||||
HR uploads via CSV/Excel; manually editable.
|
||||
|
||||
Unique constraint on (employee_name, date, leave_type) ensures one record
|
||||
per employee per day per leave type. Re-import of same record upserts.
|
||||
"""
|
||||
__tablename__ = "leave_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_name", "date", "leave_type", name="uix_emp_date_type"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
employee_name = Column(String(255), nullable=False, index=True)
|
||||
|
||||
@@ -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>
|
||||
@@ -3,7 +3,8 @@ uvicorn[standard]==0.30.6
|
||||
sqlalchemy==2.0.35
|
||||
pydantic==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib==1.7.4
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.12
|
||||
openpyxl==3.1.5
|
||||
reportlab==4.2.5
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aars-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aars-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"lucide-react": "^0.441.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aars-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -11,6 +11,7 @@ import AccidentDetail from './pages/accident/Detail'
|
||||
import UploadPage from './pages/upload/Upload'
|
||||
import RosterPage from './pages/roster/Roster'
|
||||
import HolidaysPage from './pages/holidays/Holidays'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -51,6 +52,7 @@ export default function App() {
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="roster" element={<RosterPage />} />
|
||||
<Route path="holidays" element={<HolidaysPage />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree } from 'lucide-react'
|
||||
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree, Settings as SettingsIcon } from 'lucide-react'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
|
||||
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
|
||||
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
|
||||
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
|
||||
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
|
||||
{ to: '/settings', icon: SettingsIcon, label: '設定 Settings' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper } from 'lucide-react'
|
||||
import { CheckCircle, AlertCircle, CalendarX, Palmtree, Briefcase, Coffee } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Reusable status badge for attendance records.
|
||||
* Handles all status codes including enriched leave types:
|
||||
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing
|
||||
* holiday / al / sl / cl / mixed_leave
|
||||
* Simplified to BASIC status categories — no detailed breakdowns.
|
||||
*
|
||||
* Status mapping (driven by backend status_code / status_text):
|
||||
* - missing → 缺勤 (grey)
|
||||
* - holiday → 公假 (indigo)
|
||||
* - al / sl / cl / mixed_leave → 請假 (violet)
|
||||
* - normal → 正常 (green)
|
||||
* - everything else (late/early/ot/abnormal/late_early/...) → 異常 (orange)
|
||||
*
|
||||
* If status_text carries a leave/holiday suffix (e.g. '+ SL2h', '🎉香港…'),
|
||||
* a small secondary chip is rendered next to the main badge so the list page
|
||||
* shows the underlying leave/holiday detail without expanding the row.
|
||||
*
|
||||
* Props:
|
||||
* - code: status_code string
|
||||
* - text: status_text string (already enriched)
|
||||
* - text: status_text string (fallback signal + suffix source)
|
||||
* - size: 'sm' | 'md' (default 'sm')
|
||||
*/
|
||||
export default function StatusBadge({ code, text = '', size = 'sm' }) {
|
||||
export default function StatusBadge({ code = '', text = '', size = 'sm' }) {
|
||||
const sizeCls = size === 'md'
|
||||
? 'px-3 py-1 text-sm'
|
||||
: 'px-2 py-0.5 text-xs'
|
||||
|
||||
// Split text into main + leave parts (after ' + ' separator)
|
||||
const parts = text.split(/\s*\+\s*/).filter(Boolean)
|
||||
const mainText = parts[0] || ''
|
||||
const leaveParts = parts.slice(1)
|
||||
|
||||
const badge = (icon, label, colorCls) => (
|
||||
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
|
||||
{icon}
|
||||
@@ -28,97 +32,71 @@ export default function StatusBadge({ code, text = '', size = 'sm' }) {
|
||||
</span>
|
||||
)
|
||||
|
||||
// ============ Attendance status badges ============
|
||||
if (code === 'normal') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'late' || code === 'late_early' || code === 'late_ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<AlertCircle size={12} />, mainText || code, 'bg-orange-100 text-orange-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'early' || code === 'early_ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<Clock size={12} />, mainText || code, 'bg-blue-100 text-blue-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<Clock size={12} />, mainText || '加班', 'bg-purple-100 text-purple-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'abnormal') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<AlertTriangle size={12} />, mainText || '⚠️異常', 'bg-red-100 text-red-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'missing') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<X size={12} />, '缺勤', 'bg-gray-200 text-gray-600')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
{lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
// Extract leave suffix from status_text (holiday suffix dropped — the main
|
||||
// badge already says 公假, so repeating the long name like
|
||||
// '香港特別行政區成立紀念日' clutters the row).
|
||||
// '異常-遲到48分 + SL2h' → ['SL2h']
|
||||
// 'OT3分 + AL4h + SL3h' → ['AL4h', 'SL3h']
|
||||
// '正常 + CL4h' → ['CL4h']
|
||||
const t = String(text || '')
|
||||
const noteParts = []
|
||||
const split = t.split(' + ').map(s => s.trim())
|
||||
if (split.length > 1) {
|
||||
for (let i = 1; i < split.length; i++) {
|
||||
noteParts.push(split[i].replace(/^\(|\)$/g, '').trim())
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Enriched leave status badges ============
|
||||
if (code === 'holiday') {
|
||||
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700')
|
||||
}
|
||||
if (code === 'al') {
|
||||
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700')
|
||||
}
|
||||
if (code === 'sl') {
|
||||
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700')
|
||||
}
|
||||
if (code === 'cl') {
|
||||
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700')
|
||||
}
|
||||
if (code === 'mixed_leave') {
|
||||
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700')
|
||||
const noteChips = noteParts.filter(Boolean).map((part, idx) => {
|
||||
const cls = 'bg-violet-50 text-violet-600 border border-violet-200'
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`inline-flex items-center ${sizeCls} rounded-full font-normal ${cls}`}
|
||||
title={part}
|
||||
>
|
||||
+{part}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
const wrap = (content) => (
|
||||
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||
{content}
|
||||
{noteChips}
|
||||
</span>
|
||||
)
|
||||
|
||||
const c = String(code || '').toLowerCase()
|
||||
|
||||
// 缺勤 (missing)
|
||||
if (c === 'missing' || t === '缺勤') {
|
||||
return wrap(badge(<CalendarX size={12} />, '缺勤', 'bg-gray-200 text-gray-700'))
|
||||
}
|
||||
|
||||
// fallback
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span>
|
||||
// 休息 (rest day — roster says not scheduled, employee didn't clock in)
|
||||
if (c === 'rest' || t === '休息') {
|
||||
return wrap(badge(<Coffee size={12} />, '休息', 'bg-slate-100 text-slate-600'))
|
||||
}
|
||||
|
||||
// 公假 (holiday)
|
||||
if (c === 'holiday' || t.includes('公假') || t.includes('🏖️')) {
|
||||
return wrap(badge(<Palmtree size={12} />, '公假', 'bg-indigo-100 text-indigo-700'))
|
||||
}
|
||||
|
||||
// 請假 (any leave type)
|
||||
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)
|
||||
|| t.includes('年假') || t.includes('病假') || t.includes('補鐘')
|
||||
|| t.includes('混合假')) {
|
||||
return wrap(badge(<Briefcase size={12} />, '請假', 'bg-violet-100 text-violet-700'))
|
||||
}
|
||||
|
||||
// 正常 (only when status_code is 'normal' — text suffix like '+ CL4h' is allowed,
|
||||
// we still want to flag it as 正常 first since the employee did clock in/out normally)
|
||||
if (c === 'normal') {
|
||||
return wrap(badge(<CheckCircle size={12} />, '正常', 'bg-green-100 text-green-700'))
|
||||
}
|
||||
|
||||
// 異常 (late/early/ot/abnormal/late_early/late_ot/early_ot/...)
|
||||
return wrap(badge(<AlertCircle size={12} />, '異常', 'bg-orange-100 text-orange-700'))
|
||||
}
|
||||
@@ -368,10 +368,12 @@ export default function Dashboard() {
|
||||
const otRank = [...byStaff].sort((a, b) => (b.ot_count || 0) - (a.ot_count || 0))
|
||||
const abnormalRank = [...byStaff].sort((a, b) => (b.abnormal_count || 0) - (a.abnormal_count || 0))
|
||||
const missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0))
|
||||
const attendanceRate = [...byStaff].map(s => ({
|
||||
...s,
|
||||
rate: (s.total_records || s.total || 0) > 0 ? Math.round(((((s.total_records || s.total || 0) - (s.late_count || 0) - (s.abnormal_count || 0))) / (s.total_records || s.total || 0)) * 100) : 0
|
||||
})).sort((a, b) => b.rate - a.rate)
|
||||
// Use backend's pre-computed attendance_rate = (total - missing) / total * 100
|
||||
// (handles missing records correctly; a record can still be late/early/ot
|
||||
// and count as attended)
|
||||
const attendanceRate = [...byStaff]
|
||||
.map(s => ({ ...s, rate: s.attendance_rate ?? 0 }))
|
||||
.sort((a, b) => b.rate - a.rate)
|
||||
|
||||
const statCards = summary ? [
|
||||
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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, FileCode } from 'lucide-react'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
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 [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
}, [statusFilter])
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = { page: 1, per_page: 1000 }
|
||||
if (statusFilter) params.status_text = statusFilter
|
||||
const { data } = await api.get('/api/attendance/records', { params })
|
||||
setRecords(data.records || [])
|
||||
|
||||
if (data.records && data.records.length > 0) {
|
||||
const keys = Object.keys(data.records[0]).filter(k => k !== '_row')
|
||||
setHeaders(keys)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to load records')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filtered and sorted data (client-side: search + sort only; status handled by backend)
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...records]
|
||||
|
||||
// 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])
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortKey !== key) return null
|
||||
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Status Filter (basic categories — server-side) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">全部狀態</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="abnormal">異常</option>
|
||||
<option value="missing">缺勤</option>
|
||||
<option value="holiday">公假</option>
|
||||
<option value="leave">請假</option>
|
||||
</select>
|
||||
|
||||
{statusFilter && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"
|
||||
title="清除篩選"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
{statusFilter && (
|
||||
<span className="ml-2 text-slate-400">• 已篩選:{statusFilter}</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) => (
|
||||
<tr
|
||||
key={record._row}
|
||||
className="hover:bg-slate-50 cursor-pointer"
|
||||
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">
|
||||
<StatusBadge
|
||||
code={record.status_code}
|
||||
text={record.status_text}
|
||||
/>
|
||||
</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,517 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info, KeyRound, User as UserIcon } from "lucide-react"
|
||||
import api from "../api"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
const RESET_SECTIONS = [
|
||||
{
|
||||
key: "attendance",
|
||||
label: "出勤 Attendance",
|
||||
description: "刪除所有出勤記錄(attendance_records 全表清空)",
|
||||
color: "red",
|
||||
},
|
||||
{
|
||||
key: "accident",
|
||||
label: "意外 Accident",
|
||||
description: "刪除所有意外記錄(accidents 全表清空)",
|
||||
color: "orange",
|
||||
},
|
||||
{
|
||||
key: "holidays",
|
||||
label: "假期 Holidays",
|
||||
description: "刪除所有公眾假期記錄",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
key: "leaves",
|
||||
label: "請假 Leaves",
|
||||
description: "刪除所有員工請假記錄",
|
||||
color: "lime",
|
||||
},
|
||||
]
|
||||
|
||||
export default function Settings() {
|
||||
const [confirmText, setConfirmText] = useState({})
|
||||
const [loading, setLoading] = useState({})
|
||||
const [result, setResult] = useState({})
|
||||
const [version, setVersion] = useState(null)
|
||||
const [backups, setBackups] = useState([])
|
||||
const [loadingBackups, setLoadingBackups] = useState(false)
|
||||
const [creatingBackup, setCreatingBackup] = useState(false)
|
||||
const [restoring, setRestoring] = useState(null)
|
||||
const [restoreConfirm, setRestoreConfirm] = useState("")
|
||||
|
||||
// User management (admin only)
|
||||
const [users, setUsers] = useState([])
|
||||
const [loadingUsers, setLoadingUsers] = useState(false)
|
||||
const [resetTarget, setResetTarget] = useState(null)
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [resetError, setResetError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersion()
|
||||
fetchBackups()
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoadingUsers(true)
|
||||
try {
|
||||
const { data } = await api.get("/api/auth/users")
|
||||
setUsers(data || [])
|
||||
} catch (e) {
|
||||
// 403 if not admin — leave list empty
|
||||
setUsers([])
|
||||
} finally {
|
||||
setLoadingUsers(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openResetDialog = (u) => {
|
||||
setResetTarget(u)
|
||||
setNewPassword("")
|
||||
setResetError(null)
|
||||
}
|
||||
|
||||
const closeResetDialog = () => {
|
||||
if (resetting) return
|
||||
setResetTarget(null)
|
||||
setNewPassword("")
|
||||
setResetError(null)
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!resetTarget || !newPassword) return
|
||||
if (newPassword.length < 6) {
|
||||
setResetError("密碼至少需要 6 個字元")
|
||||
return
|
||||
}
|
||||
setResetting(true)
|
||||
setResetError(null)
|
||||
try {
|
||||
await api.post("/api/auth/reset-password", {
|
||||
email: resetTarget.email,
|
||||
new_password: newPassword,
|
||||
})
|
||||
toast.success(`已重設 ${resetTarget.email} 嘅密碼`)
|
||||
closeResetDialog()
|
||||
} catch (e) {
|
||||
setResetError(e.response?.data?.detail || e.message)
|
||||
} finally {
|
||||
setResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/api/version")
|
||||
setVersion(data)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBackups = async () => {
|
||||
setLoadingBackups(true)
|
||||
try {
|
||||
const { data } = await api.get("/api/admin/backup/list")
|
||||
setBackups(data || [])
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch backups:", e)
|
||||
} finally {
|
||||
setLoadingBackups(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setCreatingBackup(true)
|
||||
try {
|
||||
const { data } = await api.post("/api/admin/backup/create")
|
||||
toast.success(`Backup created: ${data.saved}`)
|
||||
fetchBackups()
|
||||
} catch (e) {
|
||||
toast.error("Backup failed: " + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setCreatingBackup(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (filename) => {
|
||||
if (restoreConfirm !== filename) {
|
||||
toast.error("Please type the filename to confirm restore")
|
||||
return
|
||||
}
|
||||
setRestoring(filename)
|
||||
try {
|
||||
const { data } = await api.post("/api/admin/backup/restore", { filename })
|
||||
toast.success(`Restored from ${filename}. Auto-backup: ${data.auto_backup}`)
|
||||
setRestoreConfirm("")
|
||||
fetchBackups()
|
||||
} catch (e) {
|
||||
toast.error("Restore failed: " + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (filename) => {
|
||||
const token = localStorage.getItem("aars_token")
|
||||
if (!token) return
|
||||
window.open(`/api/admin/backup/download/${filename}?token=${token}`, "_blank")
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return "—"
|
||||
if (bytes < 1024) return bytes + " B"
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
||||
return (bytes / 1024 / 1024).toFixed(1) + " MB"
|
||||
}
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return "—"
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString("zh-HK", { timeZone: "Asia/Hong_Kong" })
|
||||
} catch { return iso }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
|
||||
{/* Version Info */}
|
||||
{version && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-5 py-4 flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-blue-900">
|
||||
AARS v{version.version}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700 mt-0.5">
|
||||
Commit: <code className="bg-blue-100 px-1 rounded">{version.commit?.slice(0, 8)}</code>
|
||||
{version.date && <> · {new Date(version.date).toLocaleString("zh-HK", {timeZone:"Asia/Hong_Kong"})}</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup / Restore */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-slate-600" />
|
||||
<h2 className="text-lg font-semibold text-slate-800">💾 資料庫 Backup / Restore</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={creatingBackup}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${creatingBackup ? "animate-spin" : ""}`} />
|
||||
{creatingBackup ? "建立中..." : "建立 Backup"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">手動建立快照,或從過往備份還原</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{loadingBackups ? (
|
||||
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中...</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="px-5 py-6 text-sm text-slate-400 text-center">尚無備份記錄</div>
|
||||
) : (
|
||||
backups.map((b) => (
|
||||
<div key={b.filename} className="px-5 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-800 truncate">{b.filename}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{formatDate(b.created)} · {formatSize(b.size)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleDownload(b.filename)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md transition-colors"
|
||||
title="Download backup"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> 下載
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(b.filename === restoreConfirm ? "" : b.filename)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-600 hover:text-amber-700 hover:bg-amber-50 rounded-md transition-colors"
|
||||
title="Restore from this backup"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" /> 還原
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`確定要刪除 ${b.filename}?`)) return
|
||||
try {
|
||||
await api.delete(`/api/admin/backup/${b.filename}`)
|
||||
toast.success(`已刪除 ${b.filename}`)
|
||||
fetchBackups()
|
||||
} catch (e) {
|
||||
toast.error("刪除失敗: " + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-500 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Delete backup"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> 刪除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore confirmation */}
|
||||
{restoreConfirm && (
|
||||
<div className="px-5 py-4 bg-amber-50 border-t border-amber-100">
|
||||
<p className="text-sm text-amber-800 mb-2">
|
||||
⚠️ 還原將覆蓋當前資料庫。輸入 <code className="font-mono bg-amber-100 px-1 rounded">{restoreConfirm}</code> 確認:
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 text-sm border border-amber-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder={"輸入檔案名稱確認"}
|
||||
value={restoreConfirm}
|
||||
onChange={(e) => setRestoreConfirm(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && restoreConfirm === restoreConfirm) handleRestore(restoreConfirm) }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRestore(restoreConfirm)}
|
||||
disabled={restoreConfirm !== restoreConfirm || restoring}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 rounded-md transition-colors"
|
||||
>
|
||||
{restoring ? "還原中..." : "確認還原"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRestoreConfirm("")}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Management — admin only */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5 text-slate-600" />
|
||||
<h2 className="text-lg font-semibold text-slate-800">使用者管理 User Management</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
重設使用者密碼。Admin 限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{loadingUsers && (
|
||||
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中…</div>
|
||||
)}
|
||||
{!loadingUsers && users.length === 0 && (
|
||||
<div className="px-5 py-6 text-sm text-slate-400 text-center">
|
||||
沒有使用者(或你唔係 admin)
|
||||
</div>
|
||||
)}
|
||||
{!loadingUsers && users.map((u) => (
|
||||
<div key={u.id} className="px-5 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
|
||||
<UserIcon className="w-4 h-4 text-slate-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-slate-800 truncate">
|
||||
{u.name || u.email}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate">
|
||||
{u.email}
|
||||
<span className={`ml-2 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
||||
u.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
{!u.is_active && (
|
||||
<span className="ml-1 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-100 text-red-700">
|
||||
inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openResetDialog(u)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 hover:bg-slate-50 hover:border-slate-400 rounded-md transition-colors"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
重設密碼
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{resetTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" onClick={closeResetDialog}>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h3 className="text-lg font-semibold text-slate-900">重設密碼</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
為 <span className="font-mono text-slate-700">{resetTarget.email}</span> 設定新密碼
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-5 space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
新密碼
|
||||
<input
|
||||
type="text"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value)
|
||||
setResetError(null)
|
||||
}}
|
||||
placeholder="至少 6 個字元"
|
||||
className="mt-1.5 block w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleResetPassword()
|
||||
if (e.key === 'Escape') closeResetDialog()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{resetError && (
|
||||
<div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||||
❌ {resetError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={closeResetDialog}
|
||||
disabled={resetting}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetPassword}
|
||||
disabled={resetting || !newPassword}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
>
|
||||
{resetting ? "重設中…" : "確認重設"}
|
||||
</button>
|
||||
</div>
|
||||
</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-36 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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -17,15 +17,24 @@ export default function AttendanceList() {
|
||||
const [staffFilter, setStaffFilter] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusOptions, setStatusOptions] = useState([])
|
||||
const perPage = 50
|
||||
|
||||
const [staffList, setStaffList] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
fetchStatusTexts()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
|
||||
|
||||
const fetchStatusTexts = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/attendance/status_texts')
|
||||
setStatusOptions(data || [])
|
||||
} catch (e) { console.error('Failed to fetch status texts', e) }
|
||||
}
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -39,7 +48,7 @@ export default function AttendanceList() {
|
||||
if (dateFrom) params.append('date_from', dateFrom)
|
||||
if (dateTo) params.append('date_to', dateTo)
|
||||
if (staffFilter) params.append('staff', staffFilter)
|
||||
if (statusFilter) params.append('status', statusFilter)
|
||||
if (statusFilter) params.append('status_text', statusFilter)
|
||||
|
||||
const { data } = await api.get(`/api/attendance/records?${params}`)
|
||||
setRecords(data.records || [])
|
||||
@@ -78,15 +87,21 @@ export default function AttendanceList() {
|
||||
}
|
||||
|
||||
const getStatusBadge = (record) => {
|
||||
// StatusBadge now drives from status_code + status_text only (basic 5 categories).
|
||||
return <StatusBadge code={record.status_code} text={record.status_text} />
|
||||
}
|
||||
|
||||
const getRowClass = (code) => {
|
||||
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
|
||||
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
|
||||
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
|
||||
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
|
||||
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
|
||||
// Match the basic-status grouping so highlights line up with the badge.
|
||||
const c = String(code || '').toLowerCase()
|
||||
if (c === 'abnormal' || c === 'late' || c === 'early' || c === 'ot'
|
||||
|| c === 'late_early' || c === 'late_ot' || c === 'early_ot') {
|
||||
return 'bg-orange-50 hover:bg-orange-100'
|
||||
}
|
||||
if (c === 'missing') return 'bg-gray-100 hover:bg-gray-200'
|
||||
if (c === 'rest') return 'bg-slate-50 hover:bg-slate-100'
|
||||
if (c === 'holiday') return 'bg-indigo-50 hover:bg-indigo-100'
|
||||
if (['al', 'sl', 'cl', 'mixed_leave'].includes(c)) return 'bg-violet-50 hover:bg-violet-100'
|
||||
return 'hover:bg-slate-50'
|
||||
}
|
||||
|
||||
@@ -176,16 +191,16 @@ export default function AttendanceList() {
|
||||
<span className="text-xs text-slate-500">狀態:</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="late">遲到</option>
|
||||
<option value="early">早退</option>
|
||||
<option value="ot">OT</option>
|
||||
<option value="abnormal">異常</option>
|
||||
<option value="missing">缺勤</option>
|
||||
{statusOptions.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -284,7 +284,13 @@ function LeavesTab() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const uniqueEmps = [...new Set(leaves.map(l => l.employee_name))].sort()
|
||||
// Fetch employee list from /api/employees/list (UNION of attendance + leave + accident)
|
||||
const [employees, setEmployees] = useState([])
|
||||
useEffect(() => {
|
||||
api.get('/api/employees/list')
|
||||
.then(({ data }) => setEmployees(data.employees || []))
|
||||
.catch(() => setEmployees([]))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -420,7 +426,7 @@ function LeavesTab() {
|
||||
{(showCreate || editing) && (
|
||||
<LeaveModal
|
||||
leave={editing}
|
||||
employees={uniqueEmps}
|
||||
employees={employees}
|
||||
onClose={() => { setShowCreate(false); setEditing(null) }}
|
||||
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
|
||||
/>
|
||||
@@ -543,9 +549,11 @@ function HolidayModal({ holiday, onClose, onSaved }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Leave Modal ============
|
||||
// ============ Leave Modal (multi-row create, single edit) ============
|
||||
function LeaveModal({ leave, employees, onClose, onSaved }) {
|
||||
const isEdit = !!leave
|
||||
|
||||
// Edit-mode state
|
||||
const [employeeName, setEmployeeName] = useState(leave?.employee_name || '')
|
||||
const [date, setDate] = useState(leave?.date || '')
|
||||
const [leaveType, setLeaveType] = useState(leave?.leave_type || 'SL')
|
||||
@@ -554,30 +562,30 @@ function LeaveModal({ leave, employees, onClose, onSaved }) {
|
||||
const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSave() {
|
||||
// Create-mode: shared employee + date, plus a list of leave-type rows
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createDate, setCreateDate] = useState('')
|
||||
const [rows, setRows] = useState([{ leave_type: 'SL', hours: 2, reason: '' }])
|
||||
const [createApprovedBy, setCreateApprovedBy] = useState('')
|
||||
const [results, setResults] = useState(null) // null | {added, skipped, failed}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!employeeName || !date || !leaveType) {
|
||||
toast.error('員工 / 日期 / 類型 係必填')
|
||||
return
|
||||
}
|
||||
if (hours < 0 || hours > 24) {
|
||||
const h = Number(hours)
|
||||
if (isNaN(h) || h < 0 || h > 24) {
|
||||
toast.error('時數必須 0-24')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.put(`/api/leave/${leave.id}`, {
|
||||
employee_name: employeeName, date, leave_type: leaveType,
|
||||
hours: Number(hours), reason, approved_by: approvedBy,
|
||||
})
|
||||
toast.success('已更新')
|
||||
} else {
|
||||
await api.post('/api/leave', {
|
||||
employee_name: employeeName, date, leave_type: leaveType,
|
||||
hours: Number(hours), reason, approved_by: approvedBy,
|
||||
})
|
||||
toast.success('已新增')
|
||||
}
|
||||
await api.put(`/api/leave/${leave.id}`, {
|
||||
employee_name: employeeName, date, leave_type: leaveType,
|
||||
hours: h, reason, approved_by: approvedBy,
|
||||
})
|
||||
toast.success('已更新')
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '儲存失敗')
|
||||
@@ -586,126 +594,391 @@ function LeaveModal({ leave, employees, onClose, onSaved }) {
|
||||
}
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
// Suggest an unused type so user doesn't have to pick
|
||||
const used = new Set(rows.map(r => r.leave_type))
|
||||
const next = ['SL', 'CL', 'AL'].find(t => !used.has(t)) || 'SL'
|
||||
setRows([...rows, { leave_type: next, hours: 1, reason: '' }])
|
||||
}
|
||||
function removeRow(idx) {
|
||||
setRows(rows.filter((_, i) => i !== idx))
|
||||
}
|
||||
function updateRow(idx, field, value) {
|
||||
setRows(rows.map((r, i) => i === idx ? { ...r, [field]: value } : r))
|
||||
}
|
||||
|
||||
async function handleSaveCreate() {
|
||||
if (!createName || !createDate) {
|
||||
toast.error('員工 / 日期 係必填')
|
||||
return
|
||||
}
|
||||
const valid = rows.filter(r => r.leave_type && Number(r.hours) > 0 && Number(r.hours) <= 24)
|
||||
if (valid.length === 0) {
|
||||
toast.error('至少要有一條有效 row (類型 + 時數 > 0)')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setResults(null)
|
||||
let added = 0
|
||||
const skipped = []
|
||||
const failed = []
|
||||
for (const row of valid) {
|
||||
try {
|
||||
await api.post('/api/leave', {
|
||||
employee_name: createName,
|
||||
date: createDate,
|
||||
leave_type: row.leave_type,
|
||||
hours: Number(row.hours),
|
||||
reason: row.reason,
|
||||
approved_by: createApprovedBy,
|
||||
})
|
||||
added += 1
|
||||
} catch (err) {
|
||||
const status = err.response?.status
|
||||
const detail = err.response?.data?.detail || err.message
|
||||
// SQLAlchemy UNIQUE violation → 撞到 existing
|
||||
if (status === 409 || (detail && (detail.includes('UNIQUE') || detail.includes('exists')))) {
|
||||
skipped.push({ row, reason: '撞到 existing record' })
|
||||
} else {
|
||||
failed.push({ row, reason: detail })
|
||||
}
|
||||
}
|
||||
}
|
||||
setResults({ added, skipped, failed, total: valid.length })
|
||||
setSaving(false)
|
||||
if (added > 0) {
|
||||
// refresh parent + show summary
|
||||
const msg = `✓ 加咗 ${added} 條` +
|
||||
(skipped.length ? ` · skip ${skipped.length}` : '') +
|
||||
(failed.length ? ` · 失敗 ${failed.length}` : '')
|
||||
toast.success(msg)
|
||||
// Auto-close after 1.5s if no failures
|
||||
if (failed.length === 0) {
|
||||
setTimeout(() => onSaved(), 1200)
|
||||
}
|
||||
} else if (failed.length === 0) {
|
||||
toast('冇加到 (全部撞到 existing)', { icon: 'ℹ️' })
|
||||
} else {
|
||||
toast.error(`${failed.length} 條失敗`)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== EDIT MODE ==============
|
||||
if (isEdit) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">編輯請假</h3>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
|
||||
{employees && employees.length > 0 ? (
|
||||
<select
|
||||
value={employeeName}
|
||||
onChange={(e) => setEmployeeName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">-- 揀員工 --</option>
|
||||
{employees.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
{!employees.includes(employeeName) && employeeName && (
|
||||
<option value={employeeName}>{employeeName} (新)</option>
|
||||
)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={employeeName}
|
||||
onChange={(e) => setEmployeeName(e.target.value)}
|
||||
placeholder="員工姓名"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">類型 <span className="text-red-500">*</span></label>
|
||||
<div className="flex gap-2">
|
||||
{LEAVE_TYPES.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setLeaveType(t.value)}
|
||||
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium border transition-colors ${
|
||||
leaveType === t.value
|
||||
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
|
||||
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
|
||||
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
|
||||
: 'bg-white border-slate-300 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">時數 (0-24) <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="24"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{[1, 2, 4, 8].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setHours(h)}
|
||||
className="px-2 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded"
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">原因</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="睇醫生 / Family trip / 補鐘"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
|
||||
<input
|
||||
type="text"
|
||||
value={approvedBy}
|
||||
onChange={(e) => setApprovedBy(e.target.value)}
|
||||
placeholder="Manager A"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '儲存中...' : '更新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============== CREATE MODE (multi-row) ==============
|
||||
const totalHours = rows.reduce((s, r) => s + (Number(r.hours) || 0), 0)
|
||||
const validCount = rows.filter(r => r.leave_type && Number(r.hours) > 0).length
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-5 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">{isEdit ? '編輯請假' : '加請假'}</h3>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">加請假 (一次過加多個類型)</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">SL 病假 / CL 補鐘 / AL 年假 — 可以同日多條</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:text-slate-700 rounded">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
|
||||
{employees.length > 0 ? (
|
||||
<select
|
||||
value={employeeName}
|
||||
onChange={(e) => setEmployeeName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">-- 揀員工 --</option>
|
||||
{employees.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
{!employees.includes(employeeName) && employeeName && (
|
||||
<option value={employeeName}>{employeeName} (新)</option>
|
||||
)}
|
||||
</select>
|
||||
) : (
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Shared: employee + date + approver */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">員工 <span className="text-red-500">*</span></label>
|
||||
{employees && employees.length > 0 ? (
|
||||
<select
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">-- 揀員工 --</option>
|
||||
{employees.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
{!employees.includes(createName) && createName && (
|
||||
<option value={createName}>{createName} (新)</option>
|
||||
)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="員工姓名"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={employeeName}
|
||||
onChange={(e) => setEmployeeName(e.target.value)}
|
||||
placeholder="員工姓名"
|
||||
type="date"
|
||||
value={createDate}
|
||||
onChange={(e) => setCreateDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createApprovedBy}
|
||||
onChange={(e) => setCreateApprovedBy(e.target.value)}
|
||||
placeholder="Manager A"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">日期 <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">類型 <span className="text-red-500">*</span></label>
|
||||
<div className="flex gap-2">
|
||||
{LEAVE_TYPES.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setLeaveType(t.value)}
|
||||
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium border transition-colors ${
|
||||
leaveType === t.value
|
||||
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
|
||||
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
|
||||
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
|
||||
: 'bg-white border-slate-300 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
請假項目 <span className="text-red-500">*</span>
|
||||
<span className="ml-2 text-xs text-slate-400">(同日可以加 SL/CL/AL 不同類型)</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 rounded"
|
||||
>
|
||||
<Plus size={12} /> 加多一條
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 bg-slate-50 rounded-md border border-slate-200">
|
||||
{/* Type buttons (vertical) */}
|
||||
<div className="flex flex-col gap-1 shrink-0">
|
||||
{LEAVE_TYPES.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => updateRow(idx, 'leave_type', t.value)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium border min-w-[3rem] ${
|
||||
row.leave_type === t.value
|
||||
? t.color === 'pink' ? 'bg-pink-100 border-pink-400 text-pink-700'
|
||||
: t.color === 'indigo' ? 'bg-indigo-100 border-indigo-400 text-indigo-700'
|
||||
: 'bg-emerald-100 border-emerald-400 text-emerald-700'
|
||||
: 'bg-white border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{t.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Hours */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="24"
|
||||
value={row.hours}
|
||||
onChange={(e) => updateRow(idx, 'hours', e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-slate-300 rounded-md text-sm"
|
||||
placeholder="時數"
|
||||
/>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{[1, 2, 4, 8].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => updateRow(idx, 'hours', h)}
|
||||
className="px-1.5 py-0.5 text-xs bg-white border border-slate-200 hover:bg-slate-100 rounded"
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Reason */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={row.reason}
|
||||
onChange={(e) => updateRow(idx, 'reason', e.target.value)}
|
||||
placeholder="原因 (optional)"
|
||||
className="w-full px-2 py-1.5 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
{/* Remove */}
|
||||
{rows.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(idx)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded shrink-0"
|
||||
title="移除"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">時數 (0-24) <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="24"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{[1, 2, 4, 8].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setHours(h)}
|
||||
className="px-2 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded"
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Result summary */}
|
||||
{results && (
|
||||
<div className="text-xs bg-slate-50 border border-slate-200 rounded-md p-2">
|
||||
<div>加咗 <b className="text-emerald-700">{results.added}</b> / Skip <b className="text-amber-700">{results.skipped.length}</b> / 失敗 <b className="text-red-700">{results.failed.length}</b> / 總共 {results.total}</div>
|
||||
{(results.skipped.length > 0 || results.failed.length > 0) && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-slate-600">詳細</summary>
|
||||
<div className="mt-1 text-slate-500 space-y-0.5">
|
||||
{results.skipped.map((s, i) => (
|
||||
<div key={i}>• Skip {s.row.leave_type} {s.row.hours}h {s.row.reason}: {s.reason}</div>
|
||||
))}
|
||||
{results.failed.map((f, i) => (
|
||||
<div key={i}>• 失敗 {f.row.leave_type} {f.row.hours}h: {f.reason}</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">原因</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="睇醫生 / Family trip / 補鐘"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">審批人</label>
|
||||
<input
|
||||
type="text"
|
||||
value={approvedBy}
|
||||
onChange={(e) => setApprovedBy(e.target.value)}
|
||||
placeholder="Manager A"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-slate-200 flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '儲存中...' : (isEdit ? '更新' : '新增')}
|
||||
</button>
|
||||
<div className="px-5 py-3 border-t border-slate-200 flex justify-between gap-2 bg-slate-50">
|
||||
<div className="text-xs text-slate-500 self-center">
|
||||
{validCount} 條有效 · 總時數 {totalHours}h
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-slate-600 hover:bg-slate-100 rounded-md text-sm">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveCreate}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '儲存中...' : `加 ${validCount} 條`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Upload, CheckCircle, Trash2, Clock, Users } from 'lucide-react'
|
||||
import { Upload, CheckCircle, Trash2, Clock, Users, RefreshCw } from 'lucide-react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
@@ -63,6 +63,21 @@ export default function RosterPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecalc = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
api.get('/api/roster/info'),
|
||||
api.get('/api/roster/data').catch(() => ({ data: { headers: [], rows: [] } }))
|
||||
])
|
||||
toast.success('已重新計算')
|
||||
} catch {
|
||||
toast.error('重新計算失敗')
|
||||
} finally {
|
||||
loadRosterData()
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center">載入中...</div>
|
||||
}
|
||||
@@ -106,10 +121,16 @@ export default function RosterPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleDelete} className="btn-danger">
|
||||
<Trash2 size={16} className="inline mr-1" />
|
||||
刪除
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleRecalc} className="btn-primary">
|
||||
<RefreshCw size={16} className="inline mr-1" />
|
||||
RECALC
|
||||
</button>
|
||||
<button onClick={handleDelete} className="btn-danger">
|
||||
<Trash2 size={16} className="inline mr-1" />
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke test: /api/auth/users + /api/auth/reset-password."""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://localhost:8000"
|
||||
|
||||
def req(method, path, body=None, token=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = json.dumps(body).encode() if body else None
|
||||
r = urllib.request.Request(f"{BASE}{path}", data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=10) as resp:
|
||||
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode() or "{}")
|
||||
|
||||
# 1. Login
|
||||
code, body = req("POST", "/api/auth/login", {"email": "admin@aars.hk", "password": "admin123"})
|
||||
if code != 200:
|
||||
print(f"LOGIN FAILED: {code} {body}")
|
||||
sys.exit(1)
|
||||
token = body["access_token"]
|
||||
print(f"✅ login ok, token={token[:30]}...")
|
||||
|
||||
# 2. List users
|
||||
code, body = req("GET", "/api/auth/users", token=token)
|
||||
print(f"\n--- GET /api/auth/users (code={code}) ---")
|
||||
print(json.dumps(body, indent=2, ensure_ascii=False))
|
||||
|
||||
# 3. Reset password (no actual change, just smoke test)
|
||||
code, body = req("POST", "/api/auth/reset-password",
|
||||
{"email": "admin@aars.hk", "new_password": "admin123"},
|
||||
token=token)
|
||||
print(f"\n--- POST /api/auth/reset-password (code={code}) ---")
|
||||
print(json.dumps(body, indent=2, ensure_ascii=False))
|
||||
|
||||
# 4. Verify login still works
|
||||
code, body = req("POST", "/api/auth/login", {"email": "admin@aars.hk", "password": "admin123"})
|
||||
print(f"\n--- verify login (code={code}) ---")
|
||||
if code == 200:
|
||||
print("✅ login still works after reset")
|
||||
else:
|
||||
print(f"❌ login broken: {body}")
|
||||
Reference in New Issue
Block a user