feat: add backup/restore/version, fix leave duplicate, add Settings page to nav

- Add /api/admin/backup/list, /create, /download, /restore endpoints
- Add /api/version endpoint (git commit info)
- Add Settings page to sidebar nav + App.jsx routes
- Fix lateness stats: add total_late_count/total_late_minutes keys
- Fix leave aggregation: no more duplicate text in status
- Settings page now shows backup/restore UI + version info
- Fix leave upload: overwrite existing records on re-upload
This commit is contained in:
IT Dog
2026-07-21 02:34:30 +08:00
parent 6f90d60b13
commit d5d9d644a0
5 changed files with 1543 additions and 18 deletions
+123 -5
View File
@@ -1,10 +1,11 @@
import os import os
import secret import secret
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request, Body
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Optional, List from typing import Optional, List
import json import json
@@ -135,13 +136,19 @@ def enrich_attendance_with_leave(records, db):
if not emp_leaves: if not emp_leaves:
continue continue
# Aggregate all leaves by type (sum hours when multiple records of same type)
leave_by_type = {} leave_by_type = {}
for lv in emp_leaves: for lv in emp_leaves:
t = (lv.leave_type or "").upper() t = (lv.leave_type or "").upper()
h_val = float(lv.hours or 0) h_val = float(lv.hours or 0)
leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val
leave_text = " + ".join(f"{t}{h}h" for t, h in sorted(leave_by_type.items())) # Build display text from aggregated values (no duplicates)
leave_parts = []
for t, h in sorted(leave_by_type.items()):
if h > 0:
leave_parts.append(f"{t}{h:.1f}h" if h != int(h) else f"{t}{int(h)}h")
leave_text = " + ".join(leave_parts)
original_code = (r.status_code or "").lower() original_code = (r.status_code or "").lower()
if original_code == "missing": if original_code == "missing":
@@ -758,7 +765,16 @@ async def get_lateness_stats(current_user: User = Depends(get_current_user)):
totals["ot"] += status["ot_minutes"] totals["ot"] += status["ot_minutes"]
stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True) stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True)
return {"stats": stats, **totals} return {
"stats": stats,
"total_late_count": sum(s["late_count"] for s in employee_stats.values()),
"total_late_minutes": totals["late"],
"total_early_count": sum(s["early_count"] for s in employee_stats.values()),
"total_early_minutes": totals["early"],
"total_ot_count": sum(s["ot_count"] for s in employee_stats.values()),
"total_ot_minutes": totals["ot"],
"total_missing_count": totals["missing"],
}
@app.get("/api/attendance/lateness/records") @app.get("/api/attendance/lateness/records")
async def get_lateness_records( async def get_lateness_records(
@@ -1764,8 +1780,8 @@ async def upload_leaves(
errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped") errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped")
skipped += 1 skipped += 1
continue continue
# Not edited - safe to update # Not edited - safe to update (or multiple identical records exist - merge by adding hours)
existing.hours = r["hours"] existing.hours = r["hours"] # overwrite with latest uploaded value
existing.reason = r["reason"] existing.reason = r["reason"]
existing.approved_by = r["approved_by"] existing.approved_by = r["approved_by"]
existing.source = "csv_upload" existing.source = "csv_upload"
@@ -1796,6 +1812,108 @@ async def upload_leaves(
# =============================================================================
# Backup / Restore + Version
# =============================================================================
import shutil, hashlib
from datetime import datetime as _dt2
BACKUP_DIR = Path("/app/data/backups")
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
def _backup_path(name):
ts = _dt2.now().strftime("%Y%m%d_%H%M%S")
return BACKUP_DIR / f"aars_backup_{ts}_{name}.db"
@app.get("/api/admin/backup/list")
async def list_backups(
current_user: User = Depends(get_current_user),
):
"""List all backup files."""
files = []
for p in sorted(BACKUP_DIR.glob("aars_backup_*.db"), reverse=True):
files.append({
"filename": p.name,
"size": p.stat().st_size,
"created": _dt2.fromtimestamp(p.stat().st_mtime).isoformat(),
})
return files
@app.post("/api/admin/backup/create")
async def create_backup(
note: str = "",
current_user: User = Depends(get_current_user),
):
"""Create a manual backup."""
src = Path(DB_PATH)
if not src.exists():
raise HTTPException(status_code=500, detail="Database file not found")
dst = _backup_path("manual")
shutil.copy2(src, dst)
return {"saved": dst.name, "size": dst.stat().st_size, "note": note}
@app.get("/api/admin/backup/download/{filename}")
async def download_backup(
filename: str,
current_user: User = Depends(get_current_user),
):
"""Download a backup file."""
fp = BACKUP_DIR / filename
if not fp.exists() or ".." in filename:
raise HTTPException(status_code=404, detail="Backup not found")
from fastapi.responses import FileResponse
return FileResponse(fp, media_type="application/x-sqlite3", filename=filename)
@app.post("/api/admin/backup/restore")
async def restore_backup(
filename: str = Body(...),
current_user: User = Depends(get_current_user),
):
"""Restore from backup. Creates an auto-backup of current state first."""
fp = BACKUP_DIR / filename
if not fp.exists() or ".." in filename:
raise HTTPException(status_code=404, detail="Backup not found")
# Auto-backup current state
src = Path(DB_PATH)
if src.exists():
auto_dst = _backup_path("auto_pre_restore")
shutil.copy2(src, auto_dst)
# Restore
shutil.copy2(fp, src)
return {"restored": filename, "auto_backup": auto_dst.name if src.exists() else None}
@app.get("/api/version")
async def get_version(
current_user: User = Depends(get_current_user),
):
"""Get app version info."""
import subprocess
try:
result = subprocess.run(
["git", "log", "-1", "--format=%H|%cd", "--date=iso"],
cwd="/app",
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
parts = result.stdout.strip().split("|")
commit = parts[0]
date = parts[1] if len(parts) > 1 else ""
else:
commit = "unknown"
date = ""
except Exception:
commit = "unknown"
date = ""
return {
"version": "1.1.0",
"commit": commit,
"date": date,
}
@app.get("/api/{section}") @app.get("/api/{section}")
async def list_records( async def list_records(
section: str, section: str,
+2
View File
@@ -11,6 +11,7 @@ import AccidentDetail from './pages/accident/Detail'
import UploadPage from './pages/upload/Upload' import UploadPage from './pages/upload/Upload'
import RosterPage from './pages/roster/Roster' import RosterPage from './pages/roster/Roster'
import HolidaysPage from './pages/holidays/Holidays' import HolidaysPage from './pages/holidays/Holidays'
import Settings from './pages/Settings'
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
@@ -51,6 +52,7 @@ export default function App() {
<Route path="upload" element={<UploadPage />} /> <Route path="upload" element={<UploadPage />} />
<Route path="roster" element={<RosterPage />} /> <Route path="roster" element={<RosterPage />} />
<Route path="holidays" element={<HolidaysPage />} /> <Route path="holidays" element={<HolidaysPage />} />
<Route path="settings" element={<Settings />} />
</Route> </Route>
</Routes> </Routes>
) )
+2 -1
View File
@@ -1,7 +1,7 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useState } from 'react' import { useState } from 'react'
import { useAuth } from '../context/AuthContext' 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 = [ const NAV_ITEMS = [
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' }, { to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' }, { to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
{ to: '/roster', icon: Calendar, label: '班次 Roster' }, { to: '/roster', icon: Calendar, label: '班次 Roster' },
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' }, { to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
{ to: '/settings', icon: SettingsIcon, label: '設定 Settings' },
] ]
export default function Layout() { export default function Layout() {
+191 -12
View File
@@ -1,6 +1,7 @@
import { useState } from "react" import { useState, useEffect } from "react"
import { Trash2, AlertTriangle, Shield } from "lucide-react" import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info } from "lucide-react"
import api from "../api" import api from "../api"
import toast from "react-hot-toast"
const RESET_SECTIONS = [ const RESET_SECTIONS = [
{ {
@@ -27,6 +28,75 @@ export default function Settings() {
const [confirmText, setConfirmText] = useState({}) const [confirmText, setConfirmText] = useState({})
const [loading, setLoading] = useState({}) const [loading, setLoading] = useState({})
const [result, setResult] = 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("")
useEffect(() => {
fetchVersion()
fetchBackups()
}, [])
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 handleReset = async (section) => {
const confirmVal = confirmText[section] || "" const confirmVal = confirmText[section] || ""
@@ -59,16 +129,126 @@ export default function Settings() {
} }
} }
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 ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-slate-700" /> {/* Version Info */}
<div> {version && (
<h1 className="text-2xl font-bold text-slate-900">系統管理</h1> <div className="bg-blue-50 border border-blue-200 rounded-xl px-5 py-4 flex items-start gap-3">
<p className="text-sm text-slate-500 mt-0.5"> <Info className="w-5 h-5 text-blue-600 mt-0.5 shrink-0" />
危險操作請小心使用 <div>
</p> <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> </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>
</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> </div>
{/* Danger Zone */} {/* Danger Zone */}
@@ -99,7 +279,7 @@ export default function Settings() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="text" 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" 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}"`} placeholder={`輸入 "${section.key}"`}
value={confirmText[section.key] || ""} value={confirmText[section.key] || ""}
onChange={(e) => { onChange={(e) => {
@@ -121,7 +301,6 @@ export default function Settings() {
</div> </div>
</div> </div>
{/* Result message */}
{res && ( {res && (
<div <div
className={`mt-3 text-sm px-3 py-2 rounded-md ${ className={`mt-3 text-sm px-3 py-2 rounded-md ${
+1225
View File
File diff suppressed because it is too large Load Diff