12 Commits

Author SHA1 Message Date
IT狗 03bf4ef422 Make RECALC button btn-primary (blue) for visibility 2026-07-21 11:28:27 +08:00
IT狗 e7c29a0a57 Add RECALC button to Roster page to refresh data 2026-07-21 11:17:33 +08:00
IT狗 0b58a1c05a Fix calculate_attendance_status missing return result, move chart imports to top 2026-07-21 11:03:21 +08:00
IT狗 02b4a49e8c Add /api/attendance/records and /api/accident/records endpoints for frontend 2026-07-21 00:15:29 +08:00
IT狗 766817b9be Restore list_records and get_section_info endpoints after route reorder 2026-07-21 00:02:59 +08:00
IT狗 4114e03f05 Fix route ordering: specific endpoints before generic /{section}/* 2026-07-21 00:00:31 +08:00
IT狗 5b4a39924e Add missing dashboard endpoints: /api/dashboard/summary, /api/attendance/stats, /api/accident/dashboard/*
Frontend files: export HTML buttons on Dashboard + List pages
2026-07-20 23:55:39 +08:00
IT狗 6f845e2cb6 Fix import path: use database not app.database 2026-07-20 23:47:14 +08:00
IT狗 e1c6537f38 Fix HTML report auth: accept ?token= query param for new-window download 2026-07-20 23:45:40 +08:00
IT狗 535d10a885 Add HTML5 report export for Attendance and Accident
- Backend: /api/report/{section}/html endpoint generates self-contained HTML5 report
- Frontend: Export buttons added to Dashboard, Attendance List, Accident List
- Report features: embedded data, 3 interactive Chart.js charts, stats cards,
  sortable/filterable table with color-coded rows, popup detail on click,
  click chart segment to auto-filter table, print support
- Template: report_template.html with responsive design, Chinese labels
2026-07-20 23:21:12 +08:00
IT狗 eafc1c33fc Add full AARS system - Attendance & Accident Record System
- FastAPI backend with SQLite database
- React + TailwindCSS frontend
- JWT authentication
- Excel upload/download for attendance and accident records
- Shift roster management with S7a/S7b split
- Lateness/OT calculation based on shift times
- Roster info API endpoints
- Export API with token query parameter support
- Docker compose deployment
2026-07-16 03:16:33 +08:00
admin 7f34b1fa09 Add SC Medical Group Org Chart Editor
- D3.js based org chart editor
- Multi-select drag with Cmd/Ctrl+Click
- Undo/Redo support (30 steps)
- Version management (save/load)
- Custom links, angle links, free draw links
- Export PNG/SVG with transparent background
- Word-wrap for long titles
2026-07-16 03:14:50 +08:00
37 changed files with 3189 additions and 12588 deletions
+2 -20
View File
@@ -1,23 +1,5 @@
# Runtime data
data/
logs/
*.db
*.sqlite
*.sqlite3
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyc
.venv/
venv/
# Node
frontend/node_modules/
frontend/dist/
# Backups
*.bak
*.bak*
main.py.bak*
node_modules/
.env
-327
View File
@@ -127,330 +127,3 @@ docker compose up -d
**Q: Excel 导入失败?**
确保 Excel 文件第一行是表头,且日期格式为 `YYYY-MM-DD`
## 📊 Attendance System
### Data Source
- **Primary**: SQLite table `attendance_records` (SQLAlchemy ORM)
- **Legacy**: Excel file at `/app/data/attendance.xlsx` (still saved on upload for export compatibility)
### DB Schema (`attendance_records`)
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | auto |
| `employee_name` | VARCHAR(255) | index |
| `company`, `department` | VARCHAR(100) | nullable |
| `date` | DATE | index |
| `weekday` | VARCHAR(20) | e.g. "Friday" |
| `check_in`, `check_out` | DATETIME | nullable |
| `shift_code` | VARCHAR(20) | e.g. "S7a" |
| `status_code` | VARCHAR(20) | `normal` / `late` / `early` / `late_early` / `late_ot` / `early_ot` / `ot` / `missing` / `abnormal` |
| `status_text` | VARCHAR(100) | Chinese e.g. 「遲到46分 / OT60分」 |
| `late_minutes`, `early_minutes`, `ot_minutes` | INTEGER | computed |
| `expected_in`, `expected_out`, `actual_in`, `actual_out` | VARCHAR(10) | "HH:MM" string |
| `is_manually_edited` | BOOLEAN | **preserves manual edits from import overwrite** |
| `raw_data` | JSON | source row |
| `shift_id` | INTEGER FK | → `shifts.id` |
UNIQUE constraint on `(employee_name, date)` — one record per staff per day.
### Shifts Table (`shifts`)
- 11 rows pre-loaded (S7a, S8a, ...)
- Columns: `shift_code` + per-day (`mon_start`/`mon_end` ... `sun_start`/`sun_end`), all `HH:MM` strings
- Method: `Shift.get_schedule(weekday)``(start_str, end_str)` or `(None, None)` on day off
### Status Calculation (`calculate_attendance_status` in `main.py`)
| Status | Rule |
|--------|------|
| `missing` | No check-in or no check-out |
| `abnormal` | check_in == check_out (exact same second) |
| `late` | check_in > expected start (late_minutes = round(diff)) |
| `early` | check_out < expected end (early_minutes = round(diff)) |
| `ot` | check_out > expected end (ot_minutes = round(diff)) |
| `late_early`, `late_ot`, `early_ot` | combined codes |
Notes:
- `int()` was buggy → now uses `round()` (off-by-one fix)
- `18:00:00` exact default check (NOT `18:00:03` which is real swipe)
### API Endpoints (current, attendance scope)
| Method | Path | Source | Notes |
|--------|------|--------|-------|
| GET | `/api/dashboard/summary` | **SQL** | Returns `total_records`, `*_count`, `staff_count`, `by_status`, `by_department` (frontend-compatible fields) |
| GET | `/api/attendance/stats` | **SQL (alias)** | Per-employee: count + minutes + avg + max + consecutive_missing + last_attendance_date + attendance_rate |
| GET | `/api/attendance/records` | **SQL (alias)** | Supports `staff`, `date_from`, `date_to`, `status`, `search`, `sort_by`, `sort_order`, `page`, `per_page` |
| GET | `/api/attendance/{id}` | SQL | Single record by ID |
| PUT | `/api/attendance/{id}` | SQL | Update + mark `is_manually_edited=true` (protected from import) |
| POST | `/api/upload/attendance` | Excel + SQL | Save file + import to DB (skips records where `is_manually_edited=true`) |
| GET | `/api/export/attendance/excel` | **SQL → xlsxwriter** | On-the-fly 16-col xlsx with status colors |
### Excel Export Columns (16)
| Col | Name | Notes |
|-----|------|-------|
| A | Staff Name | |
| B | Company | |
| C | Department | |
| D | Date | ISO `YYYY-MM-DD` |
| E | Weekday | |
| F | Shift | |
| G | Expected In | |
| H | Expected Out | |
| I | Actual In | |
| J | Actual Out | |
| **K** | **Status Code** | **color-coded per status** |
| L | Status Text | Chinese |
| M | Late Min | |
| N | Early Min | |
| O | OT Min | |
| P | Manual Edit | "Yes" if manually edited |
### Status Colors (xlsxwriter)
| Status | BG | Font |
|--------|------|------|
| Header | `#DBEAFE` (light blue) | `#1E3A8A` (dark blue, bold) — frozen top row |
| normal | `#D1FAE5` (green) | `#065F46` |
| late / late_early | `#FEF3C7` (amber) | `#92400E` |
| late_ot | `#FED7AA` (orange) | `#7C2D12` |
| early | `#DBEAFE` (blue) | `#1E40AF` |
| early_ot | `#E0E7FF` (indigo) | `#1E40AF` |
| ot | `#EDE9FE` (purple) | `#5B21B6` |
| abnormal | `#FEE2E2` (red) | `#7F1D1D` |
| missing | `#E5E7EB` (gray) | `#374151` |
### Frontend Components
- **Dashboard** (`pages/Dashboard.jsx`):
- 8 stats cards (total / normal / late / early / OT / missing / abnormal / staff)
- 4 leaderboards (Late / OT / Missing / Attendance Rate)
- 5 date presets (今日 / 本週 / 上週 / 本月 / 清除)
- Per-staff detail row: avg / max / consecutive_missing / last_attendance_date / attendance_rate
- **Attendance List** (`pages/attendance/List.jsx`):
- Table with filters (status, date range, staff, search)
- Pagination + sort
- "Dashboard" back-link button (`to="/"`, NOT `to="/dashboard"`)
- **Attendance Detail** (`pages/attendance/Detail.jsx`):
- Per-record view + edit
### Manual Edit Flow
1. User edits record via UI (Detail page) → `PUT /api/attendance/{id}` → marks `is_manually_edited=true`
2. Next upload of Excel → import loop checks `is_manually_edited` → skips this record (preserves manual change)
### Known Data Quirks
- `expected_in` / `expected_out` stored as strings ("11:00") for direct UI display
- `actual_in` / `actual_out` same — strings, NOT datetimes
- `Shift.get_schedule()` returns strings — caller must `datetime.strptime(start_str, "%H:%M").time()` before passing to `calculate_attendance_status`
### Files
- Backend: `/root/aars/backend/main.py` (~2100 lines), `models.py`, `database.py`, `schemas.py`, `auth.py`
- DB file: `/root/aars/data/aars.db` (SQLite, bind-mounted into container)
- Frontend: `/root/aars/frontend/src/pages/Dashboard.jsx`, `pages/attendance/List.jsx`, `pages/attendance/Detail.jsx`
- Excel data: `/root/aars/data/attendance.xlsx` (legacy), `roster.xlsx`, `accident.xlsx`
### Computed Backend Data Caveats
- **Status counts are dynamic** — computed from `attendance_records` table at query time
- **No materialized views** — query heavy if data grows beyond ~100k records
- **Roster changes affect historical data** — if you update `Shift.mon_start` for S7a, all past records with `shift_code="S7a"` are NOT auto-recalculated (run recompute manually if needed)
## 🐛 Debug Logging
Structured JSON logs written to `/root/aars/logs/` (bind-mounted into container at `/app/logs/`). Designed for production debugging — NOT compliance/audit.
### Files
| File | Contents | Retention |
|------|----------|-----------|
| `app-YYYY-MM-DD.jsonl` | General INFO+ events | 7 days |
| `error-YYYY-MM-DD.jsonl` | Server + client errors (with stack) | 30 days |
| `access-YYYY-MM-DD.jsonl` | One entry per HTTP request | 7 days |
Files auto-rotate daily via in-process `DailyJSONLHandler`. Old files deleted by `/root/aars/scripts/logs-cleanup.sh` (cron at 03:00 recommended).
### Log Schema
Every entry is single-line JSON:
```json
{
"ts": "2026-07-17T08:30:00.123Z",
"level": "info",
"service": "aars-backend",
"env": "production",
"request_id": "abc123-...",
"route": "/api/dashboard/summary",
"method": "GET",
"status": 200,
"duration_ms": 114,
"msg": "human readable summary",
"error": { "name": "...", "message": "...", "stack": "..." }
}
```
- `ts`: ISO 8601 UTC
- `level`: `debug` / `info` / `warn` / `error`
- `service`: `aars-backend` (frontend errors tagged with `"source": "client"` in `context`)
- `env`: hardcoded `production`
- `request_id`: from `X-Request-ID` header (auto-generated if missing, validated if present)
- Sensitive fields auto-redacted (see below)
### Sensitive Redaction
Backend redactor scrubs these substrings from any log payload (dicts/strings walked recursively):
- `password`, `passwd`, `pwd`, `pass`
- `token`, `access_token`, `refresh_token`, `id_token`, `jwt`, `bearer`
- `api_key`, `apikey`, `secret`, `client_secret`
- `authorization`, `cookie`, `set-cookie`, `session`
- `database_url`, `db_url`, `connection_string`
JWT regex: `eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+``[REDACTED:JWT]`
**Verification**:
```bash
grep -l password /root/aars/logs/*.jsonl # should return nothing
grep admin123 /root/aars/logs/*.jsonl # should return nothing
```
### Reproducing an Error (workflow for ITdog)
1. User reports an issue with `request_id` (visible in browser DevTools → Network → Response Headers → `X-Request-ID`)
2. SSH to VPS:
```bash
ssh root@187.127.116.15
```
3. Search by request_id across all 3 files:
```bash
grep '"request_id":"paste-id-here"' /root/aars/logs/{app,error,access}-2026-07-17.jsonl | jq .
```
4. Search by route / status / message:
```bash
grep '"route":"/api/dashboard/summary"' /root/aars/logs/error-*.jsonl | jq .
grep '"status":500' /root/aars/logs/access-*.jsonl | jq .
grep 'login failed' /root/aars/logs/app-*.jsonl | jq .
```
5. Live tail for new errors:
```bash
tail -f /root/aars/logs/error-*.jsonl | jq .
```
### Live Tailing
```bash
tail -f /root/aars/logs/app-2026-07-17.jsonl | jq . # app events
tail -f /root/aars/logs/access-2026-07-17.jsonl | jq . # HTTP requests
tail -f /root/aars/logs/error-2026-07-17.jsonl | jq . # errors
```
(Requires `jq` — `apt install -y jq` on VPS if not present.)
### npm Scripts (from frontend dir)
```bash
cd /root/aars/frontend
npm run logs:tail # tail app log
npm run logs:errors # tail error log
npm run logs:access # tail access log
npm run logs:search -- '"route":"/api/dashboard"' # search all 3 files
npm run logs:clean # manually trigger retention cleanup
```
### Triggering a Test Client Error
From browser console:
```js
import('/src/lib/logger.js').then(m => m.logger.error('manual test', { stack: 'fake stack' }))
```
Or via curl (no auth required):
```bash
curl -X POST https://excel.donton.cloud/api/client-logs \
-H "Content-Type: application/json" \
-H "X-Request-ID: my-debug-test" \
-d '{"level":"error","msg":"manual curl test","stack":"foo","page_url":"https://excel.donton.cloud/dashboard","app_version":"1.0.0"}'
```
Then verify:
```bash
grep "my-debug-test" /root/aars/logs/error-*.jsonl | jq .
```
### Manual Retention Cleanup
```bash
npm run logs:clean
# or directly
bash /root/aars/scripts/logs-cleanup.sh
```
To schedule daily, add to crontab on the host:
```cron
0 3 * * * /root/aars/scripts/logs-cleanup.sh >> /var/log/aars-cleanup.log 2>&1
```
### Architecture
**Backend** (FastAPI stdlib only, no new deps):
- `backend/logging_config.py`:
- `JSONFormatter` — formats `LogRecord` → single-line JSON
- `DailyJSONLHandler` — in-process daily file rotation (not stdlib `TimedRotatingFileHandler` which has awkward filename semantics)
- `_scrub(obj)` — recursive dict walker + JWT regex
- `ContextVar` `request_id_var` + `user_id_var` for cross-function context
- Console handler with `ContextFilter` for human-readable stdout output
- `backend/middleware.py`:
- `RequestIDMiddleware` — generate uuid4 if `X-Request-ID` missing, validate if present (`[A-Za-z0-9_-]{1,128}` regex)
- `AccessLogMiddleware` — log every request with route/method/status/duration_ms
- `unhandled_exception_handler` — catch-all 500 with stack trace
**Frontend** (vanilla JS, no extra deps):
- `frontend/src/lib/logger.js`:
- Buffer (50 entries max), flush every 10s + on `pagehide` + on visibility hidden
- `navigator.sendBeacon` (preferred) → `fetch` fallback
- Captures `console.error`, `unhandledrejection`, ErrorBoundary errors
- Redacts same keys as backend (client-side filter)
- `frontend/src/api.js`:
- Axios interceptor — generate `X-Request-ID` per request, capture from response
- `frontend/src/components/ErrorBoundary.jsx`:
- React error boundary that posts to `/api/client-logs`
- `frontend/src/main.jsx`:
- Wraps app in ErrorBoundary, installs global error handlers
**Server endpoint**:
- `POST /api/client-logs` — no auth, validates via `ClientLogEntry` Pydantic schema, writes to error log with `source: client` tag
### Excluded (by design)
- ❌ Rate limiting on `/api/client-logs` — debug use, no DDoS concern
- ❌ PII pattern scrubbing (emails, HKID) — DB has no such data
- ❌ Lint / type / unit tests — user said no testing around
- ❌ Compliance / audit logging — no requirements
- ❌ Centralized log aggregation (ELK, Datadog) — VPS-only, SSH access suffices
### Files
| Path | Purpose |
|------|---------|
| `/root/aars/backend/logging_config.py` | Formatter + handler + redactor |
| `/root/aars/backend/middleware.py` | RequestID + AccessLog + exception handler |
| `/root/aars/scripts/logs-cleanup.sh` | Retention cron (run daily) |
| `/root/aars/logs/app-*.jsonl` | App events (7d) |
| `/root/aars/logs/error-*.jsonl` | Errors + stack traces (30d) |
| `/root/aars/logs/access-*.jsonl` | HTTP access log (7d) |
| `/root/aars/frontend/src/lib/logger.js` | Client logger |
| `/root/aars/frontend/src/api.js` | Axios + X-Request-ID interceptor |
| `/root/aars/frontend/src/components/ErrorBoundary.jsx` | React error boundary |
### Known Issues
1. **`jq` not pre-installed on VPS** — `apt install -y jq` required for pretty-printed logs
2. **Crontab for `logs-cleanup.sh` not auto-installed** — manually add `0 3 * * *` cron entry on host
3. **Container healthcheck always reports unhealthy** — uses `/api/auth/me` (returns 403 without auth). Cosmetic only, doesn't affect functionality.
-1
View File
@@ -1 +0,0 @@
logs/
-251
View File
@@ -1,251 +0,0 @@
"""
Structured JSON logging for AARS backend.
Writes newline-delimited JSON to:
/app/logs/app-YYYY-MM-DD.jsonl - INFO+ (general)
/app/logs/error-YYYY-MM-DD.jsonl - ERROR only
/app/logs/access-YYYY-MM-DD.jsonl - HTTP access log
Features:
- ISO 8601 UTC timestamps
- X-Request-ID context propagation via ContextVar
- Automatic secret redaction (passwords, tokens, JWTs, etc.)
- Daily rotation via TimedRotatingFileHandler
"""
import json
import logging
import re
import sys
from contextvars import ContextVar
from datetime import datetime, timezone
from logging.handlers import TimedRotatingFileHandler
from typing import Any
# ============ Request context ============
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
user_id_var: ContextVar[int] = ContextVar("user_id", default=0)
def set_request_id(rid: str) -> None:
request_id_var.set(rid)
def get_request_id() -> str:
return request_id_var.get()
def set_user_id(uid: int) -> None:
user_id_var.set(uid)
# ============ Redactor ============
REDACT_KEYS = {
"password", "passwd", "pwd", "pass",
"token", "access_token", "refresh_token", "id_token",
"jwt", "bearer", "authorization",
"api_key", "apikey", "secret", "client_secret",
"cookie", "set-cookie", "session",
"database_url", "db_url", "connection_string",
"x-api-key", "x-auth-token",
}
REDACT_PATTERNS = [
(re.compile(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"), "[REDACTED:JWT]"),
(re.compile(r"(?i)(bearer\s+)[A-Za-z0-9\-_.]+"), r"\1[REDACTED]"),
]
REDACTED = "[REDACTED]"
def _scrub_str(s: str) -> str:
for pat, repl in REDACT_PATTERNS:
s = pat.sub(repl, s)
return s
def _scrub(obj: Any) -> Any:
"""Recursively redact secrets from dict/list/str."""
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if isinstance(k, str) and k.lower() in REDACT_KEYS:
out[k] = REDACTED
else:
out[k] = _scrub(v)
return out
if isinstance(obj, list):
return [_scrub(v) for v in obj]
if isinstance(obj, str):
return _scrub_str(obj)
return obj
# ============ JSON Formatter ============
class JSONFormatter(logging.Formatter):
SERVICE = "aars-backend"
ENV = "production"
def format(self, record: logging.LogRecord) -> str:
# Standard fields
payload = {
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
"level": record.levelname.lower(),
"service": self.SERVICE,
"env": self.ENV,
"request_id": request_id_var.get(),
"msg": record.getMessage(),
}
# Optional fields from extra=
for key in ("route", "method", "status", "duration_ms", "user_id",
"page_url", "app_version", "browser", "client_ip"):
v = getattr(record, key, None)
if v is not None:
payload[key] = v
# Exception info
if record.exc_info:
exc_type, exc_val, exc_tb = record.exc_info
payload["error"] = {
"name": exc_type.__name__ if exc_type else "Exception",
"message": str(exc_val) if exc_val else "",
"stack": self.formatException(record.exc_info),
}
# Custom extras (e.g. extra={"context": {...}})
if hasattr(record, "context") and record.context:
payload["context"] = _scrub(record.context)
# Scrub msg + all string fields
payload = _scrub(payload)
try:
return json.dumps(payload, ensure_ascii=False, default=str)
except (TypeError, ValueError):
payload["msg"] = str(payload.get("msg", ""))[:500]
return json.dumps(payload, ensure_ascii=False, default=str)
# ============ Setup ============
LOG_DIR = "/app/logs"
APP_RETENTION = 7 # days
ERROR_RETENTION = 30
class DailyJSONLHandler(logging.Handler):
"""Writes one line per record to ``<base>-YYYY-MM-DD.jsonl``.
Switches file automatically when the local date changes.
Honors a maximum retention by deleting old files on rollover.
"""
def __init__(self, base_name: str, retention_days: int, level: int = logging.INFO):
super().__init__(level=level)
self.base_name = base_name # e.g. "app"
self.retention_days = retention_days
self._date_str = None
self._stream = None
self.setFormatter(JSONFormatter())
def _current_path(self) -> str:
import os
return os.path.join(LOG_DIR, f"{self.base_name}-{self._date_str}.jsonl")
def _open(self) -> None:
import os
os.makedirs(LOG_DIR, exist_ok=True)
path = self._current_path()
self._stream = open(path, "a", encoding="utf-8")
self._cleanup_old()
def _cleanup_old(self) -> None:
import os
import time
cutoff = time.time() - (self.retention_days * 86400)
prefix = f"{self.base_name}-"
try:
for fname in os.listdir(LOG_DIR):
if not (fname.startswith(prefix) and fname.endswith(".jsonl")):
continue
fpath = os.path.join(LOG_DIR, fname)
if os.path.getmtime(fpath) < cutoff:
os.remove(fpath)
except OSError:
pass
def _maybe_rollover(self) -> None:
import os
today = datetime.now().strftime("%Y-%m-%d")
if self._date_str != today:
if self._stream:
self._stream.close()
self._stream = None
self._date_str = today
self._open()
def emit(self, record: logging.LogRecord) -> None:
try:
self._maybe_rollover()
if self._stream:
msg = self.format(record)
self._stream.write(msg + "\n")
self._stream.flush()
except Exception:
self.handleError(record)
def close(self) -> None:
if self._stream:
self._stream.close()
self._stream = None
super().close()
def setup_logging() -> None:
"""Configure root logger. Call once at app startup."""
root = logging.getLogger()
root.setLevel(logging.INFO)
# Clear handlers (uvicorn re-imports)
for h in list(root.handlers):
root.removeHandler(h)
# App logger — INFO+
app_handler = DailyJSONLHandler("app", APP_RETENTION, level=logging.INFO)
root.addHandler(app_handler)
# Error logger — ERROR only
error_handler = DailyJSONLHandler("error", ERROR_RETENTION, level=logging.ERROR)
root.addHandler(error_handler)
# Access logger (separate logger, not root)
access_logger = logging.getLogger("access")
access_logger.setLevel(logging.INFO)
access_logger.propagate = False
access_handler = DailyJSONLHandler("access", APP_RETENTION, level=logging.INFO)
access_logger.addHandler(access_handler)
# Console handler — pretty for dev/visible logs
# Use a custom filter to inject request_id from ContextVar
class _ContextFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id_var.get()
record.user_id = user_id_var.get()
return True
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s [%(request_id)s] %(name)s: %(message)s"
))
console.addFilter(_ContextFilter())
root.addHandler(console)
# Silence noisy libs
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger(__name__).info("logging initialized", extra={"context": {"log_dir": LOG_DIR}})
def get_access_logger() -> logging.Logger:
return logging.getLogger("access")
+626 -3225
View File
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
"""
Middleware for AARS backend:
- RequestIDMiddleware: generate/accept X-Request-ID
- AccessLogMiddleware: log every HTTP request
- error_handler: catch unhandled exceptions, log with stack trace
"""
import logging
import re
import time
import traceback
import uuid
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from logging_config import (
get_access_logger,
set_request_id,
set_user_id,
)
logger = logging.getLogger(__name__)
REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
def _normalize_request_id(raw: str | None) -> str:
if raw and REQUEST_ID_PATTERN.match(raw):
return raw
return str(uuid.uuid4())
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Generate or accept X-Request-ID and echo in response headers."""
async def dispatch(self, request: Request, call_next):
rid = _normalize_request_id(request.headers.get("X-Request-ID"))
set_request_id(rid)
request.state.request_id = rid
response = await call_next(request)
response.headers["X-Request-ID"] = rid
return response
class AccessLogMiddleware(BaseHTTPMiddleware):
"""Log every HTTP request with route, method, status, duration."""
async def dispatch(self, request: Request, call_next):
start = time.perf_counter()
rid = getattr(request.state, "request_id", "-")
status = 500 # default for exception path
try:
response = await call_next(request)
status = response.status_code
return response
finally:
duration_ms = int((time.perf_counter() - start) * 1000)
access = get_access_logger()
access.info(
"%s %s %d",
request.method,
request.url.path,
status,
extra={
"route": request.url.path,
"method": request.method,
"status": status,
"duration_ms": duration_ms,
},
)
async def unhandled_exception_handler(request: Request, exc: Exception):
"""Catch-all for unhandled exceptions. Log with stack + return 500 JSON."""
rid = getattr(request.state, "request_id", "-")
logger.error(
"unhandled exception: %s %s",
request.method,
request.url.path,
exc_info=exc,
extra={
"route": request.url.path,
"method": request.method,
"status": 500,
"context": {
"exception_type": type(exc).__name__,
"query_params": dict(request.query_params),
},
},
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "request_id": rid},
headers={"X-Request-ID": rid},
)
-96
View File
@@ -1,96 +0,0 @@
"""Phase 2 Migration: Holidays + Leave Records
- Create holidays + leave_records tables
- Import HK public holidays 2025-2027 (51 records)
- Insert demo leave records
Run: docker exec aars-aars-1 python3 /app/migrate_phase2.py
"""
import sys
sys.path.insert(0, "/app")
from database import SessionLocal, engine, Base
from models import Holiday, LeaveRecord
from parsers import get_all_holidays
from datetime import date, datetime
print("=" * 60)
print("Phase 2 Migration: Holidays + Leave Records")
print("=" * 60)
# Step 1: Create tables (idempotent — Base.metadata.create_all skips existing)
print("\n[1/3] Creating tables...")
Base.metadata.create_all(engine)
print(" ✓ holidays + leave_records tables ready")
# Step 2: Import holidays
print("\n[2/3] Importing HK public holidays 2025-2027...")
db = SessionLocal()
added = 0
updated = 0
for d, name_zh, name_en, is_mandatory in get_all_holidays():
existing = db.query(Holiday).filter(Holiday.date == d).first()
if existing:
# Update if changed
if (existing.name != name_zh or existing.name_en != name_en
or existing.is_mandatory != is_mandatory):
existing.name = name_zh
existing.name_en = name_en
existing.is_mandatory = is_mandatory
existing.source = "gov_hk"
existing.updated_at = datetime.utcnow()
updated += 1
else:
db.add(Holiday(
date=d,
name=name_zh,
name_en=name_en,
region="HK",
is_mandatory=is_mandatory,
source="gov_hk",
))
added += 1
db.commit()
print(f" ✓ Added {added} new holidays, updated {updated}")
print(f" ✓ Total holidays in DB: {db.query(Holiday).count()}")
# Step 3: Demo leave records
print("\n[3/3] Inserting demo leave records...")
demo_leaves = [
# Jay Lam 2026-07-08: SL 2h (有返工但中間請病假)
("Jay Lam", "2026-07-08", "SL", 2.0, "睇醫生"),
# Jay Lam 2026-07-10: CL 4h (上午補鐘)
("Jay Lam", "2026-07-10", "CL", 4.0, "補鐘"),
# Cindy Cheung 2026-07-09: AL 8h (全日年假冇打卡)
("Cindy Cheung", "2026-07-09", "AL", 8.0, "Family trip"),
# May Chan 2026-07-07: SL 4h (下午走咗)
("May Chan", "2026-07-07", "SL", 4.0, "感冒"),
]
added_leave = 0
for name, d_str, lt, hrs, reason in demo_leaves:
d = datetime.strptime(d_str, "%Y-%m-%d").date()
existing = db.query(LeaveRecord).filter(
LeaveRecord.employee_name == name,
LeaveRecord.date == d,
LeaveRecord.leave_type == lt,
).first()
if not existing:
db.add(LeaveRecord(
employee_name=name,
date=d,
leave_type=lt,
hours=hrs,
reason=reason,
source="manual",
))
added_leave += 1
db.commit()
print(f" ✓ Added {added_leave} demo leave records")
print(f" ✓ Total leave records in DB: {db.query(LeaveRecord).count()}")
db.close()
print("\n" + "=" * 60)
print("Migration complete ✓")
print("=" * 60)
+55 -209
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, Date, JSON, ForeignKey, UniqueConstraint, Numeric
from sqlalchemy import Column, Integer, String, Text, Float, Boolean, DateTime, Date, Time, JSON, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
@@ -6,243 +6,89 @@ from datetime import datetime, timezone
def now_hkt():
"""Return current time in HKT timezone."""
return datetime.now(timezone.utc)
# ============ EXISTING MODELS (kept) ============
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
password_hash = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
role = Column(String(50), default="user")
role = Column(String(50), default="user") # admin, user
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
deleted_at = Column(DateTime, nullable=True)
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"
class Attendance(Base):
__tablename__ = "attendance"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(String(100), index=True, nullable=False)
employee_name = Column(String(255), nullable=False)
department = Column(String(100), index=True)
date = Column(Date, nullable=False)
time = Column(String(10), nullable=True)
location = Column(String(255), nullable=False)
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)
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
long_description = Column(Text, nullable=True) # Excel col 9
incident_photos = Column(Text, nullable=True) # Excel col 10 (URL)
needs_escalation = Column(String(20), nullable=True) # Excel col 12
incident_score = Column(Integer, nullable=True) # Excel col 14
submitted_at = Column(DateTime, nullable=True) # Excel col 1 (timestamp)
excel_row = Column(Integer, nullable=True) # original Excel row (1-based)
check_in = Column(Time, nullable=True)
check_out = Column(Time, nullable=True)
overtime_hours = Column(Float, default=0)
leave_type = Column(String(50), nullable=True) # annual, sick, unpaid, other
leave_hours = Column(Float, default=0)
status = Column(String(50), nullable=False) # present, absent, late, leave
remarks = Column(Text, nullable=True)
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)
deleted_at = Column(DateTime, nullable=True) # Soft delete
# ============ NEW MODELS ============
class Shift(Base):
"""班次定義"""
__tablename__ = "shifts"
id = Column(Integer, primary_key=True, index=True)
shift_code = Column(String(20), unique=True, nullable=False, index=True)
description = Column(String(100), nullable=True)
# 每天的上班時間 (HH:MM format as String)
mon_start = Column(String(5), nullable=True)
mon_end = Column(String(5), nullable=True)
tue_start = Column(String(5), nullable=True)
tue_end = Column(String(5), nullable=True)
wed_start = Column(String(5), nullable=True)
wed_end = Column(String(5), nullable=True)
thu_start = Column(String(5), nullable=True)
thu_end = Column(String(5), nullable=True)
fri_start = Column(String(5), nullable=True)
fri_end = Column(String(5), nullable=True)
sat_start = Column(String(5), nullable=True)
sat_end = Column(String(5), nullable=True)
sun_start = Column(String(5), nullable=True)
sun_end = Column(String(5), nullable=True)
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
def get_schedule(self, weekday: str):
"""取得指定星期幾的上班時間 (start, end)"""
day_map = {
'monday': ('mon_start', 'mon_end'),
'tuesday': ('tue_start', 'tue_end'),
'wednesday': ('wed_start', 'wed_end'),
'thursday': ('thu_start', 'thu_end'),
'friday': ('fri_start', 'fri_end'),
'saturday': ('sat_start', 'sat_end'),
'sunday': ('sun_start', 'sun_end'),
# 中文
'星期一': ('mon_start', 'mon_end'),
'星期二': ('tue_start', 'tue_end'),
'星期三': ('wed_start', 'wed_end'),
'星期四': ('thu_start', 'thu_end'),
'星期五': ('fri_start', 'fri_end'),
'星期六': ('sat_start', 'sat_end'),
'星期日': ('sun_start', 'sun_end'),
'星期天': ('sun_start', 'sun_end'),
}
key = weekday.lower().strip()
if key not in day_map:
return None, None
start_attr, end_attr = day_map[key]
start = getattr(self, start_attr, None)
end = getattr(self, end_attr, None)
# 休息標記
if start in ('-', '休息', None, ''):
return None, None
if end in ('-', '休息', None, ''):
return None, None
return start, end
class AttendanceRecord(Base):
"""出勤記錄"""
__tablename__ = "attendance_records"
__table_args__ = (
UniqueConstraint('employee_name', 'date', name='uix_employee_date'),
)
id = Column(Integer, primary_key=True, index=True)
# 員工信息
employee_name = Column(String(255), nullable=False, index=True)
company = Column(String(100), nullable=True)
department = Column(String(100), nullable=True)
# 日期
date = Column(Date, nullable=False, index=True)
weekday = Column(String(20), nullable=True)
# 打卡時間
check_in = Column(DateTime, nullable=True)
check_out = Column(DateTime, nullable=True)
# 班次
shift_code = Column(String(20), nullable=True)
# 計算結果
status_code = Column(String(20), nullable=False, default='normal')
status_text = Column(String(100), nullable=True)
late_minutes = Column(Integer, default=0)
early_minutes = Column(Integer, default=0)
ot_minutes = Column(Integer, default=0)
# Expected times
expected_in = Column(String(10), nullable=True)
expected_out = Column(String(10), nullable=True)
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)
# Foreign key
shift_id = Column(Integer, ForeignKey("shifts.id"), nullable=True)
shift = relationship("Shift", foreign_keys=[shift_id])
class Holiday(Base):
"""公眾假期 / Public Holidays
class Accident(Base):
__tablename__ = "accidents"
Source: HK Gov (https://www.gov.hk/en/about/abouthk/holiday/{year}.htm)
Auto-imported by /api/holidays/refresh endpoint, manually editable.
"""
__tablename__ = "holidays"
id = Column(Integer, primary_key=True, index=True)
date = Column(Date, nullable=False, unique=True, index=True)
name = Column(String(100), nullable=False) # 中文 e.g. "元旦"
name_en = Column(String(200), nullable=True) # English e.g. "The first day of January"
region = Column(String(20), nullable=True, default="HK") # "HK" / "CN"
is_mandatory = Column(Boolean, default=True) # 勞工假 vs 公假 (Sunday replacement)
source = Column(String(50), nullable=True) # "gov_hk_1823" / "manual"
notes = Column(Text, nullable=True)
# 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)
date = Column(Date, nullable=False)
time = Column(Time, nullable=True)
location = Column(String(255), nullable=False)
employee_name = Column(String(255), nullable=False)
employee_id = Column(String(100))
department = Column(String(100))
description = Column(Text, nullable=False)
severity = Column(String(50), nullable=False) # minor, moderate, serious, fatal
medical_report = Column(Text, nullable=True)
action_taken = Column(Text, nullable=True)
responsible_person = Column(String(255), nullable=True)
custom_fields = Column(JSON, default={})
created_by = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
deleted_at = Column(DateTime, nullable=True) # Soft delete
class LeaveRecord(Base):
"""員工請假 / Employee Leave Records
class AttendanceCustomField(Base):
__tablename__ = "attendance_custom_fields"
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)
employee_id = Column(String(100), nullable=True) # future, currently NULL
date = Column(Date, nullable=False, index=True)
leave_type = Column(String(20), nullable=False) # "SL" / "CL" / "AL"
hours = Column(Numeric(4, 2), nullable=False) # 0.5 ~ 8.0
reason = Column(Text, nullable=True)
approved_by = Column(String(100), nullable=True)
source = Column(String(50), nullable=True) # "csv_upload" / "manual"
notes = Column(Text, nullable=True)
# 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_by = Column(Integer, ForeignKey("users.id"), nullable=True)
field_name = Column(String(100), nullable=False)
field_type = Column(String(50), nullable=False) # text, number, date, select
field_key = Column(String(100), unique=True, nullable=False)
required = Column(Boolean, default=False)
options = Column(JSON, default=[]) # For select type
order = Column(Integer, default=0)
created_at = Column(DateTime, default=now_hkt)
class AccidentCustomField(Base):
__tablename__ = "accident_custom_fields"
id = Column(Integer, primary_key=True, index=True)
field_name = Column(String(100), nullable=False)
field_type = Column(String(50), nullable=False) # text, number, date, select
field_key = Column(String(100), unique=True, nullable=False)
required = Column(Boolean, default=False)
options = Column(JSON, default=[]) # For select type
order = Column(Integer, default=0)
created_at = Column(DateTime, default=now_hkt)
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
-80
View File
@@ -1,80 +0,0 @@
"""HK Public Holidays data
Source: https://www.gov.hk/en/about/abouthk/holiday/{year}.htm
Last verified: 2026-07-19
Format: (date ISO, name 中文, name_en English, is_mandatory)
"""
from datetime import date
# HK General Holidays 2025-2027 (gov.hk official)
# is_mandatory = True means statutory labour holiday (法定假日)
# False means general holiday only (銀行/學校等)
HK_HOLIDAYS = [
# ============ 2025 ============
(date(2025, 1, 1), "元旦", "The first day of January", True),
(date(2025, 1, 29), "農曆年初一", "Lunar New Year's Day", True),
(date(2025, 1, 30), "農曆年初二", "The second day of Lunar New Year", True),
(date(2025, 1, 31), "農曆年初三", "The third day of Lunar New Year", True),
(date(2025, 4, 4), "清明節", "Ching Ming Festival", True),
(date(2025, 4, 18), "耶穌受難日", "Good Friday", False),
(date(2025, 4, 19), "耶穌受難日翌日", "The day following Good Friday", False),
(date(2025, 4, 21), "復活節星期一", "Easter Monday", False),
(date(2025, 5, 1), "勞動節", "Labour Day", True),
(date(2025, 5, 5), "佛誕", "The Birthday of the Buddha", True),
(date(2025, 5, 31), "端午節", "Tuen Ng Festival", True),
(date(2025, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
(date(2025, 10, 1), "國慶日", "National Day", True),
(date(2025, 10, 7), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
(date(2025, 10, 29), "重陽節", "Chung Yeung Festival", True),
(date(2025, 12, 25), "聖誕節", "Christmas Day", True),
(date(2025, 12, 26), "聖誕節翌日", "The first weekday after Christmas Day", False),
# ============ 2026 ============
(date(2026, 1, 1), "元旦", "The first day of January", True),
(date(2026, 2, 17), "農曆年初一", "Lunar New Year's Day", True),
(date(2026, 2, 18), "農曆年初二", "The second day of Lunar New Year", True),
(date(2026, 2, 19), "農曆年初三", "The third day of Lunar New Year", True),
(date(2026, 4, 3), "耶穌受難日", "Good Friday", False),
(date(2026, 4, 4), "耶穌受難日翌日", "The day following Good Friday", False),
(date(2026, 4, 6), "清明節翌日", "The day following Ching Ming Festival", True),
(date(2026, 4, 7), "復活節星期一翌日", "The day following Easter Monday", False),
(date(2026, 5, 1), "勞動節", "Labour Day", True),
(date(2026, 5, 25), "佛誕翌日", "The day following the Birthday of the Buddha", True),
(date(2026, 6, 19), "端午節", "Tuen Ng Festival", True),
(date(2026, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
(date(2026, 9, 26), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
(date(2026, 10, 1), "國慶日", "National Day", True),
(date(2026, 10, 19), "重陽節翌日", "The day following Chung Yeung Festival", True),
(date(2026, 12, 25), "聖誕節", "Christmas Day", True),
(date(2026, 12, 26), "聖誕節翌日", "The first weekday after Christmas Day", False),
# ============ 2027 ============
(date(2027, 1, 1), "元旦", "The first day of January", True),
(date(2027, 2, 6), "農曆年初一", "Lunar New Year's Day", True),
(date(2027, 2, 8), "農曆年初三", "The third day of Lunar New Year", True),
(date(2027, 2, 9), "農曆年初四", "The fourth day of Lunar New Year", True),
(date(2027, 3, 26), "耶穌受難日", "Good Friday", False),
(date(2027, 3, 27), "耶穌受難日翌日", "The day following Good Friday", False),
(date(2027, 3, 29), "復活節星期一", "Easter Monday", False),
(date(2027, 4, 5), "清明節", "Ching Ming Festival", True),
(date(2027, 5, 1), "勞動節", "Labour Day", True),
(date(2027, 5, 13), "佛誕", "The Birthday of the Buddha", True),
(date(2027, 6, 9), "端午節", "Tuen Ng Festival", True),
(date(2027, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
(date(2027, 9, 16), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
(date(2027, 10, 1), "國慶日", "National Day", True),
(date(2027, 10, 8), "重陽節", "Chung Yeung Festival", True),
(date(2027, 12, 25), "聖誕節", "Christmas Day", True),
(date(2027, 12, 27), "聖誕節翌日", "The first weekday after Christmas Day", False),
]
GOV_HK_URL_TEMPLATE = "https://www.gov.hk/en/about/abouthk/holiday/{year}.htm"
def get_holidays_for_year(year: int):
"""Return list of (date, name_zh, name_en, is_mandatory) for given year."""
return [h for h in HK_HOLIDAYS if h[0].year == year]
def get_all_holidays():
"""Return full HK_HOLIDAYS list."""
return list(HK_HOLIDAYS)
-30
View File
@@ -1,30 +0,0 @@
#!/bin/bash
# AARS full deploy: build image + restart container + sync static to nginx root
# Usage: bash /root/aars/deploy.sh
set -e
cd /root/aars
echo "=========================================="
echo "AARS Deploy — $(date)"
echo "=========================================="
echo ""
echo "[1/3] Building Docker image..."
docker compose build aars
echo ""
echo "[2/3] Restarting container..."
docker compose up -d aars
sleep 5
echo ""
echo "[3/3] Syncing static files to nginx root..."
bash /root/aars/deploy_static.sh
echo ""
echo "=========================================="
echo "Deploy complete ✓"
echo "Reload https://excel.donton.cloud/"
echo "=========================================="
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
# Sync /app/static/ (backend build output) to /var/www/excel.donton.cloud/ (nginx root)
# Usage: bash /root/aars/deploy_static.sh
set -e
CONTAINER=$(docker ps -qf name=aars-aars-1)
if [ -z "$CONTAINER" ]; then
echo "ERROR: aars-aars-1 container not running"
exit 1
fi
WEB_ROOT=/var/www/excel.donton.cloud
echo "Syncing static files from container $CONTAINER -> $WEB_ROOT ..."
# Ensure dirs exist
mkdir -p "$WEB_ROOT/assets"
# Copy index.html
docker cp "$CONTAINER:/app/static/index.html" "$WEB_ROOT/index.html"
echo " ✓ index.html"
# Copy assets
docker cp "$CONTAINER:/app/static/assets/." "$WEB_ROOT/assets/"
asset_count=$(ls "$WEB_ROOT/assets/" | wc -l)
echo " ✓ assets/ ($asset_count files)"
# Show what is currently referenced
current_bundle=$(grep -oE "/assets/index-[A-Za-z0-9_-]+\.js" "$WEB_ROOT/index.html" | head -1)
echo " → Currently serving: $current_bundle"
# Optional: cleanup old bundles (keep only last 5 js + 3 css)
cd "$WEB_ROOT/assets"
ls -t index-*.js 2>/dev/null | tail -n +6 | xargs -r rm
ls -t index-*.css 2>/dev/null | tail -n +4 | xargs -r rm
remaining=$(ls "$WEB_ROOT/assets/" | wc -l)
echo " ✓ Cleanup done, $remaining files remaining"
echo "Done. Reload https://excel.donton.cloud/"
-1
View File
@@ -9,7 +9,6 @@ services:
- "18775:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
environment:
- TZ=Asia/Hong_Kong
- PYTHONUNBUFFERED=1
-1
View File
@@ -1 +0,0 @@
logs/
-3
View File
@@ -7,10 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
<title>AARS - Attendance & Accident Record System</title>
</head>
<body>
-3380
View File
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -1,17 +1,12 @@
{
"name": "aars-frontend",
"private": true,
"version": "1.3.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"logs:tail": "tail -f /root/aars/logs/app-*.jsonl | sed -u 's/\\\\n/\\\\n/g' | jq -c . 2>/dev/null || tail -f /root/aars/logs/app-*.jsonl",
"logs:errors": "tail -f /root/aars/logs/error-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/error-*.jsonl",
"logs:access": "tail -f /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/access-*.jsonl",
"logs:search": "grep -hE -- \"$@\" /root/aars/logs/app-*.jsonl /root/aars/logs/error-*.jsonl /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || true",
"logs:clean": "bash /root/aars/scripts/logs-cleanup.sh"
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
@@ -31,4 +26,4 @@
"tailwindcss": "^3.4.10",
"vite": "^5.4.6"
}
}
}
+1 -7
View File
@@ -5,13 +5,10 @@ import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import AttendanceList from './pages/attendance/List'
import AttendanceDetail from './pages/attendance/Detail'
import AccidentDashboard from './pages/accident/Dashboard'
import AccidentList from './pages/accident/List'
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()
@@ -46,13 +43,10 @@ export default function App() {
<Route index element={<Dashboard />} />
<Route path="attendance" element={<AttendanceList />} />
<Route path="attendance/:id" element={<AttendanceDetail />} />
<Route path="accident" element={<AccidentDashboard />} />
<Route path="accident/list" element={<AccidentList />} />
<Route path="accident" element={<AccidentList />} />
<Route path="accident/:id" element={<AccidentDetail />} />
<Route path="upload" element={<UploadPage />} />
<Route path="roster" element={<RosterPage />} />
<Route path="holidays" element={<HolidaysPage />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
)
+4 -25
View File
@@ -1,50 +1,29 @@
import axios from 'axios'
import { setCurrentRequestId } from './lib/logger.js'
const api = axios.create({
baseURL: '',
timeout: 30000,
})
// Generate a request id per outgoing call (for cross-correlation)
function newRequestId() {
// RFC4122-ish: use crypto if available
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
return 'req-' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
// Request interceptor: auth + X-Request-ID
// Request interceptor to add auth token
api.interceptors.request.use(
config => {
const token = localStorage.getItem('aars_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
config.headers['X-Request-ID'] = newRequestId()
return config
},
error => Promise.reject(error)
)
// Response interceptor: capture server's X-Request-ID for log correlation + 401 handling
// Response interceptor for error handling
api.interceptors.response.use(
response => {
const rid = response.headers['x-request-id']
if (rid) setCurrentRequestId(rid)
return response
},
response => response,
error => {
const rid = error.response?.headers?.['x-request-id']
if (rid) setCurrentRequestId(rid)
if (error.response?.status === 401) {
localStorage.removeItem('aars_token')
// Avoid loop if already on /login
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
window.location.href = '/login'
}
return Promise.reject(error)
}
-73
View File
@@ -1,73 +0,0 @@
import React from 'react'
import { logger } from '../lib/logger.js'
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
logger.error('React component error', {
stack: (error?.stack || '') + '\n\nComponent stack:\n' + (errorInfo?.componentStack || ''),
})
}
render() {
if (this.state.hasError) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
fontFamily: 'sans-serif',
background: '#f8fafc',
color: '#0f172a',
}}>
<div style={{ maxWidth: 600, textAlign: 'center' }}>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>
頁面發生錯誤 / Something went wrong
</h1>
<p style={{ color: '#64748b', marginBottom: '1.5rem' }}>
已經將錯誤自動回報俾 IT狗 reload 或者返回上一頁
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: '0.5rem 1rem',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
}}
>
Reload Page
</button>
{this.state.error?.message && (
<pre style={{
marginTop: '1.5rem',
padding: '1rem',
background: '#f1f5f9',
borderRadius: '0.375rem',
fontSize: '0.75rem',
textAlign: 'left',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{this.state.error.message}
</pre>
)}
</div>
</div>
)
}
return this.props.children
}
}
+64 -132
View File
@@ -1,44 +1,22 @@
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, Settings as SettingsIcon } from 'lucide-react'
const NAV_ITEMS = [
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/attendance', icon: Users, label: '出勤 Attendance' },
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
{ to: '/settings', icon: SettingsIcon, label: '設定 Settings' },
]
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar } from 'lucide-react'
export default function Layout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [mobileOpen, setMobileOpen] = useState(false)
const handleLogout = () => {
logout()
navigate('/login')
}
const closeMobile = () => setMobileOpen(false)
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
<header className="bg-white border-b border-slate-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14 sm:h-16">
{/* Mobile hamburger (left side) */}
<button
onClick={() => setMobileOpen(true)}
className="md:hidden p-2 -ml-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md"
aria-label="Open menu"
>
<Menu size={20} />
</button>
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center gap-8">
<div className="flex items-center gap-2">
@@ -47,35 +25,71 @@ export default function Layout() {
</div>
<span className="text-xl font-bold text-slate-900">AARS</span>
</div>
{/* Desktop nav */}
{/* Navigation */}
<nav className="hidden md:flex items-center gap-1">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
<NavLink
to="/"
end
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<LayoutDashboard size={18} />
Dashboard
</NavLink>
<NavLink
to="/attendance"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Users size={18} />
出勤 Attendance
</NavLink>
<NavLink
to="/accident"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<AlertTriangle size={18} />
意外 Accident
</NavLink>
<NavLink
to="/roster"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Calendar size={18} />
班次 Roster
</NavLink>
</nav>
</div>
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-4">
<div className="flex items-center gap-4">
<NavLink
to="/upload"
className={({ isActive }) =>
`hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
@@ -85,9 +99,9 @@ export default function Layout() {
<Upload size={16} />
<span className="hidden sm:inline">上傳</span>
</NavLink>
<div className="hidden sm:flex items-center gap-3 pl-4 border-l border-slate-200">
<div className="text-right hidden md:block">
<div className="flex items-center gap-3 pl-4 border-l border-slate-200">
<div className="text-right hidden sm:block">
<div className="text-sm font-medium text-slate-900">{user?.name}</div>
<div className="text-xs text-slate-500">{user?.role}</div>
</div>
@@ -99,95 +113,13 @@ export default function Layout() {
<LogOut size={18} />
</button>
</div>
{/* Mobile logout button (always visible) */}
<button
onClick={handleLogout}
className="sm:hidden p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
title="Logout"
>
<LogOut size={18} />
</button>
</div>
</div>
</div>
</header>
{/* Mobile drawer overlay */}
{mobileOpen && (
<div
className="fixed inset-0 bg-black/50 z-50 md:hidden"
onClick={closeMobile}
/>
)}
{/* Mobile drawer panel */}
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-200 ease-in-out md:hidden ${
mobileOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between px-4 h-14 border-b border-slate-200">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">A</span>
</div>
<span className="text-lg font-bold text-slate-900">AARS</span>
</div>
<button
onClick={closeMobile}
className="p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 rounded-md"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<nav className="p-3 space-y-1">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={closeMobile}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
<NavLink
to="/upload"
onClick={closeMobile}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Upload size={18} />
上傳
</NavLink>
</nav>
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200">
<div className="flex items-center gap-3">
<div className="text-sm">
<div className="font-medium text-slate-900">{user?.name}</div>
<div className="text-xs text-slate-500">{user?.role}</div>
</div>
</div>
</div>
</div>
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</main>
</div>
-102
View File
@@ -1,102 +0,0 @@
import { CheckCircle, AlertCircle, CalendarX, Palmtree, Briefcase, Coffee } from 'lucide-react'
/**
* Reusable status badge for attendance records.
* 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 (fallback signal + suffix source)
* - size: 'sm' | 'md' (default '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'
const badge = (icon, label, colorCls) => (
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
{icon}
{label}
</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())
}
}
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'))
}
// 休息 (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'))
}
-138
View File
@@ -1,138 +0,0 @@
/**
* Frontend logger for AARS.
*
* - Captures console.error, unhandledrejection, ErrorBoundary errors
* - Redacts secrets client-side before sending
* - Buffers, flushes via navigator.sendBeacon on pagehide
* - Posts to /api/client-logs (server-side correlation via X-Request-ID)
*/
const ENDPOINT = '/api/client-logs'
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '1.0.0')
const FLUSH_INTERVAL_MS = 10_000
const MAX_BUFFER = 50
// ============ Redactor ============
const REDACT_KEYS = new Set([
'password', 'passwd', 'pwd', 'pass',
'token', 'access_token', 'refresh_token', 'id_token',
'jwt', 'bearer', 'authorization',
'api_key', 'apikey', 'secret', 'client_secret',
'cookie', 'set-cookie', 'session',
])
function redactString(s) {
if (typeof s !== 'string') return s
// JWT-like
s = s.replace(/eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/g, '[REDACTED:JWT]')
// Bearer tokens
s = s.replace(/(bearer\s+)[A-Za-z0-9\-_.]+/gi, '$1[REDACTED]')
return s
}
function redact(obj) {
if (obj == null) return obj
if (typeof obj === 'string') return redactString(obj)
if (Array.isArray(obj)) return obj.map(redact)
if (typeof obj === 'object') {
const out = {}
for (const k of Object.keys(obj)) {
if (REDACT_KEYS.has(k.toLowerCase())) {
out[k] = '[REDACTED]'
} else {
out[k] = redact(obj[k])
}
}
return out
}
return obj
}
// ============ Buffer ============
const buffer = []
let flushTimer = null
function push(level, msg, extras = {}) {
const entry = {
level,
msg: redactString(String(msg).slice(0, 2000)),
stack: extras.stack ? redactString(String(extras.stack).slice(0, 8000)) : undefined,
page_url: typeof window !== 'undefined' ? window.location.href : undefined,
app_version: APP_VERSION,
request_id: getCurrentRequestId(),
browser: typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 500) : undefined,
ts: new Date().toISOString(),
}
// Drop undefined fields
Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k])
buffer.push(entry)
if (buffer.length >= MAX_BUFFER) flush()
}
function flush() {
if (!buffer.length) return
const entries = buffer.splice(0, buffer.length)
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
try {
const blob = new Blob([JSON.stringify(entries)], { type: 'application/json' })
navigator.sendBeacon(ENDPOINT, blob)
return
} catch (_) { /* fall through */ }
}
// Fallback: fetch (best-effort)
if (typeof fetch !== 'undefined') {
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entries),
keepalive: true,
}).catch(() => { /* swallow */ })
}
}
// ============ Current request_id ============
let currentRequestId = null
export function setCurrentRequestId(rid) { currentRequestId = rid }
function getCurrentRequestId() { return currentRequestId }
// ============ Public API ============
export const logger = {
debug: (msg, ex) => push('debug', msg, ex),
info: (msg, ex) => push('info', msg, ex),
warn: (msg, ex) => push('warn', msg, ex),
error: (msg, ex) => push('error', msg, ex),
flush,
}
// ============ Global error hooks ============
export function installGlobalHandlers() {
if (typeof window === 'undefined') return
// Capture uncaught errors
window.addEventListener('error', (event) => {
logger.error('Uncaught error', {
stack: event.error?.stack || `${event.message} at ${event.filename}:${event.lineno}:${event.colno}`,
})
})
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason
const msg = reason?.message || String(reason)
const stack = reason?.stack || `Unhandled rejection: ${msg}`
logger.error('Unhandled promise rejection', { stack })
})
// Flush on page hide
window.addEventListener('pagehide', () => flush())
window.addEventListener('beforeunload', () => flush())
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush()
})
// Periodic flush
if (flushTimer) clearInterval(flushTimer)
flushTimer = setInterval(flush, FLUSH_INTERVAL_MS)
}
export { redact }
+6 -13
View File
@@ -5,21 +5,14 @@ import './index.css'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { AuthProvider } from './context/AuthContext.jsx'
import ErrorBoundary from './components/ErrorBoundary.jsx'
import { installGlobalHandlers } from './lib/logger.js'
// Install global error handlers before mounting
installGlobalHandlers()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster position="bottom-right" />
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster position="bottom-right" />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)
+326 -617
View File
@@ -1,646 +1,355 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../api'
import toast from 'react-hot-toast'
import { Users, AlertTriangle, Clock, CheckCircle, XCircle, Calendar, TrendingUp, ArrowRight, Award, AlertCircle, Clock3, Download, FileSpreadsheet, FileBarChart } from 'lucide-react'
import { Users, AlertTriangle, FileSpreadsheet, RefreshCw, TrendingUp, MapPin, Clock, AlertCircle, FileCode } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
const COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
export default function Dashboard() {
const [summary, setSummary] = useState(null)
const [stats, setStats] = useState(null)
// Attendance state
const [attInfo, setAttInfo] = useState(null)
const [attDeptData, setAttDeptData] = useState([])
const [attStatusData, setAttStatusData] = useState([])
const [latenessData, setLatenessData] = useState(null)
// Accident state
const [accInfo, setAccInfo] = useState(null)
const [accSeverityData, setAccSeverityData] = useState([])
const [accLocationData, setAccLocationData] = useState([])
const [accMonthlyData, setAccMonthlyData] = useState([])
const [loading, setLoading] = useState(true)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [exporting, setExporting] = useState(false)
const pieRef = useRef(null)
const barRef = useRef(null)
const pieChart = useRef(null)
const barChart = useRef(null)
const dashboardRef = useRef(null)
useEffect(() => {
fetchData()
}, [dateFrom, dateTo])
useEffect(() => {
return () => {
if (pieChart.current) pieChart.current.destroy()
if (barChart.current) barChart.current.destroy()
}
}, [])
useEffect(() => {
if (summary && stats && !loading) {
renderCharts()
}
}, [summary, stats, loading])
const fetchData = async () => {
setLoading(true)
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const [sumRes, statRes] = await Promise.all([
api.get('/api/dashboard/summary', { params }),
api.get('/api/attendance/stats', { params }).catch(() => ({ data: null }))
const [attInfoRes, accInfoRes, attDataRes, accDataRes, lateRes] = await Promise.all([
api.get('/api/attendance/info'),
api.get('/api/accident/info'),
api.get('/api/attendance?page=1&page_size=1000'),
api.get('/api/accident?page=1&page_size=500'),
api.get('/api/attendance/lateness').catch(() => ({ data: null }))
])
setSummary(sumRes.data)
setStats(statRes.data)
setAttInfo(attInfoRes.data)
setAccInfo(accInfoRes.data)
setLatenessData(lateRes.data)
// Attendance - department
if (attDataRes.data.data && attDataRes.data.data.length > 0) {
const deptCount = {}
attDataRes.data.data.forEach(row => {
const deptKey = Object.keys(row).find(k =>
k.toLowerCase().includes('company') || k.includes('Company')
)
const dept = deptKey ? String(row[deptKey] || 'Unknown') : 'Unknown'
deptCount[dept] = (deptCount[dept] || 0) + 1
})
setAttDeptData(Object.entries(deptCount)
.map(([name, value]) => ({ name: name.slice(0, 15), value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6))
// Status - from DURATION or other fields
const statusCount = {}
attDataRes.data.data.forEach(row => {
const durKey = Object.keys(row).find(k =>
k.toLowerCase().includes('duration') || k.includes('DURATION')
)
if (durKey && row[durKey]) {
const dur = String(row[durKey])
if (dur.includes('hours')) {
statusCount['正常工作'] = (statusCount['正常工作'] || 0) + 1
} else {
statusCount['其他'] = (statusCount['其他'] || 0) + 1
}
}
})
setAttStatusData(Object.entries(statusCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value))
}
// Accident - severity, location, monthly
if (accDataRes.data.data && accDataRes.data.data.length > 0) {
const sevCount = {}
const locCount = {}
const dateCount = {}
accDataRes.data.data.forEach(row => {
const sevKey = Object.keys(row).find(k =>
k.toLowerCase().includes('severity') || k.includes('緊急') || k.includes('程度')
)
if (sevKey) {
const sev = String(row[sevKey] || 'Unknown')
sevCount[sev] = (sevCount[sev] || 0) + 1
}
const locKey = Object.keys(row).find(k =>
k.toLowerCase().includes('location') || k.includes('部門') || k.includes('地點')
)
if (locKey) {
const loc = String(row[locKey] || 'Unknown')
locCount[loc] = (locCount[loc] || 0) + 1
}
const dateKey = Object.keys(row).find(k =>
k.toLowerCase().includes('date') || k.includes('日期') || k.includes('時間戳')
)
if (dateKey && row[dateKey]) {
const dateStr = String(row[dateKey]).slice(0, 7)
dateCount[dateStr] = (dateCount[dateStr] || 0) + 1
}
})
setAccSeverityData(Object.entries(sevCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value))
setAccLocationData(Object.entries(locCount)
.map(([name, value]) => ({ name: name.slice(0, 12), value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6))
setAccMonthlyData(Object.entries(dateCount)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => a.name.localeCompare(b.name)))
}
} catch (err) {
toast.error('載入失敗')
console.error(err)
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
const renderCharts = () => {
if (!summary) return
if (pieChart.current) pieChart.current.destroy()
if (barChart.current) barChart.current.destroy()
const Chart = window.Chart
if (!Chart) return
const pieCtx = pieRef.current?.getContext('2d')
if (pieCtx) {
const statusData = [
{ label: '正常', value: summary.normal_count || 0, color: '#10B981' },
{ label: '遲到', value: summary.late_count || 0, color: '#F59E0B' },
{ label: '早退', value: summary.early_count || 0, color: '#3B82F6' },
{ label: 'OT', value: summary.ot_count || 0, color: '#8B5CF6' },
{ label: '異常', value: summary.abnormal_count || 0, color: '#EF4444' },
{ label: '缺勤', value: summary.missing_count || 0, color: '#6B7280' },
].filter(s => s.value > 0)
if (statusData.length > 0) {
pieChart.current = new Chart(pieCtx, {
type: 'doughnut',
data: {
labels: statusData.map(s => s.label),
datasets: [{ data: statusData.map(s => s.value), backgroundColor: statusData.map(s => s.color), borderWidth: 2, borderColor: '#fff' }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { padding: 16, font: { size: 12 } } }, title: { display: true, text: '出勤狀態分佈', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } }
}
})
}
}
const barCtx = barRef.current?.getContext('2d')
if (barCtx && stats?.stats?.length > 0) {
const staffData = stats.stats.slice(0, 8)
barChart.current = new Chart(barCtx, {
type: 'bar',
data: {
labels: staffData.map(s => s.name),
datasets: [{ label: '出勤記錄', data: staffData.map(s => s.total_records || s.total || 0), backgroundColor: '#2563EB', borderRadius: 4 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, title: { display: true, text: '員工出勤記錄數', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
}
})
}
}
// Export PDF - capture dashboard as image then make PDF
const exportPDF = async () => {
if (!dashboardRef.current) { toast.error('找不到 Dashboard'); return }
setExporting(true)
try {
if (typeof window.jspdf === 'undefined') throw new Error('jspdf 未載入')
if (typeof window.Chart === 'undefined') throw new Error('Chart.js 未載入')
const { jsPDF } = window.jspdf
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
const PW = pdf.internal.pageSize.getWidth() // 297mm
const PH = pdf.internal.pageSize.getHeight() // 210mm
const M = 12
// ─── Title ───
pdf.setFontSize(18); pdf.setTextColor(15,23,42)
pdf.text('Attendance Report', M, 14)
const range = (dateFrom || dateTo) ? `${dateFrom||'開始'} ~ ${dateTo||'今日'}` : '全部'
const now = new Date().toLocaleString('en-US',{hour12:false,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})
pdf.setFontSize(9); pdf.setTextColor(100,116,139)
pdf.text(`Date Range: ${range} | Generated: ${now}`, M, 20)
// ─── Summary Cards ───
const cards = summary ? [
{ label: 'Total', value: summary.total_records, color: [37,99,235] },
{ label: 'Normal', value: summary.normal_count, color: [16,185,129] },
{ label: 'Late', value: summary.late_count, color: [245,158,11] },
{ label: 'OT', value: summary.ot_count, color: [139,92,246] },
{ label: 'Abnormal', value: summary.abnormal_count, color: [239,68,68] },
{ label: 'Missing', value: summary.missing_count, color: [107,114,128] },
] : []
const cardW = (PW - M*2) / cards.length - 3
cards.forEach((card, i) => {
const cx = M + i*(cardW+3)
pdf.setFillColor(...card.color)
pdf.roundedRect(cx, 22, cardW, 16, 2, 2, 'F')
pdf.setFontSize(14); pdf.setTextColor(255,255,255)
pdf.text(String(card.value ?? 0), cx+cardW/2, 30, {align:'center'})
pdf.setFontSize(7); pdf.setTextColor(255,255,255)
pdf.text(card.label, cx+cardW/2, 34, {align:'center'})
})
// ─── Charts ───
const chartTop = 44
const chartH = 60
const chartW = (PW - M*2 - 8) / 2 // two charts side by side
// Helper: draw doughnut / pie chart
const drawPieChart = (cx, cy, r, data, colors, pdf) => {
const total = data.reduce((a,b)=>a+b, 0)
if (total === 0) return
let angle = -Math.PI/2
data.forEach((val, i) => {
const slice = (val/total) * 2*Math.PI
pdf.setFillColor(...colors[i])
pdf.setDrawColor(255,255,255)
pdf.setLineWidth(0.3)
const x1 = cx + r * Math.cos(angle)
const y1 = cy + r * Math.sin(angle)
const x2 = cx + r * Math.cos(angle + slice)
const y2 = cy + r * Math.sin(angle + slice)
if (val === total) {
pdf.setFillColor(...colors[i])
pdf.circle(cx, cy, r, 'F')
} else {
const pts = [[cx, cy], [x1, y1], [x2, y2]]
pdf.triangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1], 'F')
}
angle += slice
})
// inner circle (donut hole)
pdf.setFillColor(255,255,255)
pdf.circle(cx, cy, r*0.5, 'F')
}
// Helper: draw bar chart
const drawBarChart = (bx, by, bw, bh, labels, values, barColor, pdf) => {
const max = Math.max(...values, 1)
const barW = bw / labels.length * 0.6
const gap = bw / labels.length * 0.4
labels.forEach((lbl, i) => {
const barH = (values[i]/max) * bh
const x = bx + i*(barW+gap) + gap/2
const y = by + bh - barH
pdf.setFillColor(...barColor)
pdf.roundedRect(x, y, barW, barH, 1, 1, 'F')
// value label
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
pdf.text(String(values[i]), x+barW/2, by+bh+3.5, {align:'center'})
// x label (truncated)
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
pdf.text(lbl.length > 6 ? lbl.substring(0,5)+'.' : lbl, x+barW/2, by+bh+6.5, {align:'center'})
})
}
// ── Pie: 出勤狀態分佈 ──
if (summary) {
const pieCX = M + chartW/2
const pieCY = chartTop + chartH/2
const pieR = chartH/2 - 4
const pieData = [
summary.normal_count || 0,
summary.late_count || 0,
summary.early_count || 0,
summary.ot_count || 0,
summary.abnormal_count || 0,
summary.missing_count || 0,
]
const pieColors = [[16,185,129],[245,158,11],[59,130,246],[139,92,246],[239,68,68],[107,114,128]]
const pieLabels = ['Normal','Late','Early','OT','Abnormal','Missing']
const pieLegendX = M
const pieLegendY = chartTop + chartH + 5
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
pdf.text('Attendance Status Distribution', M, chartTop - 2)
// Draw pie
drawPieChart(pieCX, pieCY, pieR, pieData, pieColors, pdf)
// Legend
let lx = M
pieLabels.forEach((lbl, i) => {
if (pieData[i] === 0) return
pdf.setFillColor(...pieColors[i])
pdf.rect(lx, pieLegendY, 4, 4, 'F')
pdf.setFontSize(7.5); pdf.setTextColor(60,70,90)
pdf.text(`${lbl} ${pieData[i]}`, lx+5, pieLegendY+3.5)
lx += 28
})
}
// ── Bar: 員工出勤記錄 ──
if (stats?.stats?.length > 0) {
const barX = M + chartW + 8
const barLabels = stats.stats.slice(0,8).map(s=>s.name)
const barValues = stats.stats.slice(0,8).map(s=>s.total_records || s.total || 0)
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
pdf.text('Staff Attendance Records', barX, chartTop - 2)
// Axis
const axisX = barX + 2
const axisY = chartTop + chartH - 2
const axisW = chartW - 4
const axisH = chartH - 8
pdf.setDrawColor(200,210,220)
pdf.setLineWidth(0.3)
pdf.line(axisX, axisY, axisX, axisY - axisH) // y axis
pdf.line(axisX, axisY, axisX + axisW, axisY) // x axis
drawBarChart(axisX, axisY - axisH, axisW, axisH, barLabels, barValues, [37,99,235], pdf)
}
// ─── Leaderboards ───
if (stats?.stats?.length > 0) {
const staff = stats.stats
const lbTop = chartTop + chartH + 14
const lateRank = [...staff].sort((a,b)=>(b.late_count||0)-(a.late_count||0)).slice(0,5)
const otRank = [...staff].sort((a,b)=>(b.ot_count||0)-(a.ot_count||0)).slice(0,5)
const missRank = [...staff].sort((a,b)=>(b.missing_count||0)-(a.missing_count||0)).slice(0,5)
const rateRank = [...staff].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).slice(0,5)
const lbW = (PW - M*2) / 4 - 4
const lbData = [
{ title: 'Late Rank', data: lateRank, key: 'late_count', sub: 'late_minutes', color: [245,158,11] },
{ title: 'OT Rank', data: otRank, key: 'ot_count', sub: 'ot_minutes', color: [139,92,246] },
{ title: 'Missing Rank', data: missRank, key: 'missing_count', sub: null, color: [107,114,128] },
{ title: 'Attendance Rate', data: rateRank, key: 'rate', sub: null, color: [16,185,129] },
]
lbData.forEach((lb, li) => {
const lx = M + li*(lbW+4)
// Box
pdf.setFillColor(...lb.color)
pdf.roundedRect(lx, lbTop, lbW, 6, 2, 2, 'F')
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
const lines = lb.title.split('\n')
lines.forEach((l, li2) => pdf.text(l, lx+lbW/2, lbTop+3+li2*3, {align:'center'}))
// Items
lb.data.forEach((item, di) => {
const iy = lbTop + 10 + di*5
const medal = ['🥇','🥈','🥉'][di] || ` ${di+1}.`
const sub = lb.sub && item[lb.sub] ? ` (${item[lb.sub]}min)` : ''
const val = `${item[lb.key]}${lb.key==='rate'?'%':''}${sub}`
pdf.setFontSize(7.5); pdf.setTextColor(30,41,59)
pdf.text(`${medal} ${item.name} ${val}`, lx+2, iy)
})
})
}
// ─── Staff Table ───
if (stats?.stats?.length > 0) {
const tblTop = PW > 250 ? (chartTop + chartH + 14 + 35) : (chartTop + chartH + 14 + 30)
pdf.setFontSize(11); pdf.setTextColor(15,23,42)
pdf.text('Staff Summary', M, tblTop - 1)
const staff = stats.stats
const colW = [46, 18, 16, 20, 16, 20, 16, 16]
const cols = ['Name','Total','Late','LateMin','OT','OTMin','Abn','Miss']
let xPos = [M]
for (let ci=0; ci<colW.length-1; ci++) xPos.push(xPos[ci]+colW[ci])
const rowH = 6
// Header
pdf.setFillColor(50,60,80)
pdf.rect(M, tblTop, PW-M*2, rowH, 'F')
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
cols.forEach((h,ci) => pdf.text(h, xPos[ci]+colW[ci]/2, tblTop+4.5, {align:'center'}))
// Rows
staff.forEach((s, ri) => {
const ry = tblTop + rowH*(ri+1)
if (ri%2===0) { pdf.setFillColor(248,250,252); pdf.rect(M, ry, PW-M*2, rowH, 'F') }
pdf.setFontSize(8); pdf.setTextColor(30,41,59)
const vals = [s.name, String(s.total_records || s.total || 0), String(s.late_count||0), String(s.late_minutes||'-'), String(s.ot_count||0), String(s.ot_minutes||'-'), String(s.abnormal_count||0), String(s.missing_count||0)]
vals.forEach((v,ci) => pdf.text(v, xPos[ci]+colW[ci]/2, ry+4.5, {align:'center'}))
})
}
pdf.save(`attendance_report_${dateFrom||'all'}_${dateTo||''}.pdf`.replace(/-/g,''))
toast.success('✅ PDF 已下載')
} catch (err) {
console.error('PDF error:', err)
toast.error('PDF 導出失敗: ' + err.message)
} finally {
setExporting(false)
}
}
const exportExcel = async (full) => {
setExporting(true)
try {
const token = localStorage.getItem('aars_token')
const params = full ? {} : (dateFrom ? { date_from: dateFrom, date_to: dateTo || new Date().toISOString().slice(0, 10) } : {})
const url = `/api/export/attendance/excel${Object.keys(params).length ? '?' + new URLSearchParams(params).toString() : ''}`
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!resp.ok) throw new Error('Export failed')
const blob = await resp.blob()
const filename = full
? `attendance_full.xlsx`
: `attendance_${dateFrom || 'start'}_${dateTo || 'today'}.xlsx`.replace(/-/g, '')
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
toast.success('✅ Excel 已下載')
} catch (err) {
console.error('Excel export error:', err)
toast.error('Excel 導出失敗')
} finally {
setExporting(false)
}
}
const byStaff = stats?.stats || []
const lateRank = [...byStaff].sort((a, b) => (b.late_count || 0) - (a.late_count || 0))
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)
const statCards = summary ? [
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
{ label: '正常', value: summary.normal_count, icon: CheckCircle, color: 'bg-green-50 text-green-600' },
{ label: '遲到', value: summary.late_count, icon: Clock, color: 'bg-orange-50 text-orange-600' },
{ label: '早退', value: summary.early_count, icon: Clock3, color: 'bg-blue-50 text-blue-600' },
{ label: 'OT', value: summary.ot_count, icon: TrendingUp, color: 'bg-purple-50 text-purple-600' },
{ label: '異常', value: summary.abnormal_count, icon: AlertTriangle, color: 'bg-red-50 text-red-600' },
{ label: '缺勤', value: summary.missing_count, icon: XCircle, color: 'bg-gray-50 text-gray-600' },
{ label: '員工', value: summary.staff_count, icon: Users, color: 'bg-teal-50 text-teal-600' },
] : []
const today = new Date().toISOString().slice(0, 10)
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
// Date preset helpers
const fmt = (d) => d.toISOString().slice(0, 10)
const weekRange = (offsetWeeks) => {
// Monday = start of week
const now = new Date()
const dow = now.getDay() // Sun=0, Mon=1, ... Sat=6
const daysFromMonday = (dow + 6) % 7 // Mon=0, Tue=1, ..., Sun=6
const start = new Date(now)
start.setDate(now.getDate() - daysFromMonday + offsetWeeks * 7)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [fmt(start), fmt(end)]
}
// Map rankKey to Option 1 detail fields exposed by backend
const detailFieldsMap = {
late_count: [
{ key: 'late_minutes', label: '分鐘', unit: '分' },
{ key: 'late_avg_minutes', label: '平均', unit: '分' },
{ key: 'late_max_minutes', label: '最長', unit: '分' },
],
early_count: [
{ key: 'early_minutes', label: '分鐘', unit: '分' },
{ key: 'early_avg_minutes', label: '平均', unit: '分' },
{ key: 'early_max_minutes', label: '最長', unit: '分' },
],
ot_count: [
{ key: 'ot_minutes', label: '分鐘', unit: '分' },
{ key: 'ot_avg_minutes', label: '平均', unit: '分' },
{ key: 'ot_max_minutes', label: '最長', unit: '分' },
],
missing_count: [
{ key: 'consecutive_missing', label: '連續', unit: '次' },
{ key: 'attendance_rate', label: '出勤率', unit: '%', suffix: true },
{ key: 'last_attendance_date', label: '最後', isDate: true },
],
rate: [
{ key: 'total_records', label: '記錄', unit: '條' },
{ key: 'missing_count', label: '缺勤', unit: '次' },
],
}
const RankCard = ({ title, icon: Icon, iconColor, data, rankKey, subKey, unit, colorFn }) => {
const details = detailFieldsMap[rankKey] || []
const items = data.filter(d => d[rankKey] > 0 || rankKey === 'rate').slice(0, 6)
return (
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Icon size={16} style={{ color: iconColor }} /> {title}
</h3>
<div className="space-y-1">
{items.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
) : items.map((s, i) => (
<div key={s.name} className="py-1.5 border-b border-slate-100 last:border-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
i === 0 ? 'bg-yellow-100 text-yellow-700' : i === 1 ? 'bg-slate-100 text-slate-500' : i === 2 ? 'bg-orange-50 text-orange-400' : 'bg-slate-50 text-slate-400'
}`}>{i + 1}</span>
<span className="text-sm text-slate-700">{s.name}</span>
</div>
<div className="text-right">
<span className="text-sm font-bold" style={colorFn ? { color: colorFn(s) } : undefined}>
{rankKey === 'rate' ? `${s[rankKey]}%` : s[rankKey]}
</span>
{subKey && (s[subKey] || 0) > 0 && (
<span className="text-xs text-slate-400 ml-1">({s[subKey]}{unit || '次'})</span>
)}
</div>
</div>
{/* Option 1 detail row */}
{details.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 ml-7 text-[11px] text-slate-500">
{details.map(d => {
const v = s[d.key]
let display
if (v === null || v === undefined || v === 0 && d.key !== 'attendance_rate') {
// Skip empty/zero for most fields, but show 0% rate
if (!(d.key === 'attendance_rate')) return null
display = `0${d.unit}`
} else if (d.isDate) {
display = `${d.label} ${v}`
} else if (d.suffix) {
// e.g. attendance_rate = 70 (already %)
display = `${d.label} ${v}${d.unit}`
} else {
display = `${d.label} ${v}${d.unit}`
}
// For attendance_rate, only show if rate exists
if (d.key === 'attendance_rate' && (v === null || v === undefined)) return null
// For last_attendance_date, only show if exists
if (d.isDate && !v) return null
// For consecutive_missing, only show if > 0 (otherwise skip)
if (d.key === 'consecutive_missing' && (v || 0) === 0) return null
return <span key={d.key}>{display}</span>
})}
</div>
)}
</div>
))}
</div>
</div>
)
}
return (
<div className="space-y-6" ref={dashboardRef}>
{/* Header */}
const SectionCard = ({ title, value, subtitle, icon: Icon, color, link }) => (
<Link to={link} className={`card p-4 hover:shadow-md transition-shadow border-l-4 ${color}`}>
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">Dashboard</h1>
<div className="flex items-center gap-2">
{/* Export Buttons */}
<div className="flex items-center gap-1 border border-slate-300 rounded-md overflow-hidden">
<button
onClick={exportPDF}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
title="導出 PDF"
>
<FileBarChart size={14} /> PDF
</button>
<div className="w-px h-5 bg-slate-300" />
<button
onClick={() => exportExcel(false)}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
title="導出 Excel (跟時間範圍)"
>
<FileSpreadsheet size={14} /> Excel
</button>
<div className="w-px h-5 bg-slate-300" />
<button
onClick={() => exportExcel(true)}
disabled={exporting}
className="flex items-center gap-1 px-3 py-2 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50 font-medium"
title="導出 Excel (全部)"
>
<Download size={14} /> 全部
</button>
</div>
<Link to="/attendance" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<ArrowRight size={16} /> 出勤記錄
</Link>
<div>
<div className="text-xs font-medium text-slate-500">{title}</div>
<div className="text-xl font-bold text-slate-900 mt-1">{value ?? '-'}</div>
{subtitle && <div className="text-xs text-slate-400 mt-0.5">{subtitle}</div>}
</div>
<Icon size={20} className={color.replace('border-l-', 'text-')} />
</div>
</Link>
)
{/* Date Filter */}
<div className="card p-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">日期範圍:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
<span className="text-slate-400">-</span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={() => { setDateFrom(today); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">今日</button>
<button onClick={() => {
const [w0, w1] = weekRange(0)
setDateFrom(w0); setDateTo(w1)
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本週</button>
<button onClick={() => {
const [w0, w1] = weekRange(-1)
setDateFrom(w0); setDateTo(w1)
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">上週</button>
<button onClick={() => { setDateFrom(monthStart); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本月</button>
<button onClick={() => { setDateFrom(''); setDateTo('') }} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-2 border border-slate-300 rounded">清除</button>
</div>
</div>
{(dateFrom || dateTo) && (
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
)}
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">載入中...</div>
) : summary ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map(({ label, value, icon: Icon, color }) => (
<div key={label} className="card p-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-lg ${color}`}><Icon size={20} /></div>
<div>
<div className="text-2xl font-bold text-slate-900">{value ?? 0}</div>
<div className="text-xs text-slate-500">{label}</div>
</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card p-4">
<div style={{ height: '260px', position: 'relative' }}><canvas ref={pieRef}></canvas></div>
</div>
<div className="card p-4">
<div style={{ height: '260px', position: 'relative' }}><canvas ref={barRef}></canvas></div>
</div>
</div>
{byStaff.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<RankCard title="遲到排行榜" icon={Clock} iconColor="#F59E0B" data={lateRank} rankKey="late_count" subKey="late_minutes" unit="分" colorFn={s => s.late_count > 0 ? '#F59E0B' : '#10B981'} />
<RankCard title="OT排行榜" icon={TrendingUp} iconColor="#8B5CF6" data={otRank} rankKey="ot_count" subKey="ot_minutes" unit="分" colorFn={s => s.ot_count > 0 ? '#8B5CF6' : '#10B981'} />
<RankCard title="缺勤排行榜" icon={XCircle} iconColor="#6B7280" data={missingRank} rankKey="missing_count" colorFn={s => s.missing_count > 0 ? '#EF4444' : '#10B981'} />
<RankCard title="出勤率排行榜" icon={Award} iconColor="#10B981" data={attendanceRate} rankKey="rate" colorFn={s => s.rate >= 90 ? '#10B981' : s.rate >= 70 ? '#F59E0B' : '#EF4444'} />
</div>
)}
{stats?.stats?.length > 0 && (
<div className="card p-4">
<h2 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Users size={16} /> 員工出勤概覽
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600">員工</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">出勤</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">正常</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲到</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲分鐘</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT分鐘</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">異常</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">缺勤</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{byStaff.map((s) => (
<tr key={s.name} className="hover:bg-slate-50">
<td className="px-3 py-2 font-medium text-slate-700">{s.name}</td>
<td className="px-3 py-2 text-right text-slate-600">{s.total_records ?? s.total ?? 0}</td>
<td className="px-3 py-2 text-right text-green-600">{(s.total_records ?? s.total ?? 0) - (s.late_count || 0) - (s.abnormal_count || 0)}</td>
<td className="px-3 py-2 text-right text-orange-600">{s.late_count || 0}</td>
<td className="px-3 py-2 text-right text-orange-400">{s.late_minutes || '-'}</td>
<td className="px-3 py-2 text-right text-purple-600">{s.ot_count || 0}</td>
<td className="px-3 py-2 text-right text-purple-400">{s.ot_minutes || '-'}</td>
<td className="px-3 py-2 text-right text-red-600">{s.abnormal_count || 0}</td>
<td className="px-3 py-2 text-right text-gray-500">{s.missing_count || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
const SmallBarChart = ({ data, color }) => (
<div className="h-32">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ left: 5, right: 5 }}>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis dataKey="name" type="category" width={80} tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }} />
<Bar dataKey="value" radius={[0, 3, 3, 0]}>
{data.map((_, index) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-12 text-slate-400">暫無數據</div>
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無數據</div>
)}
</div>
)
const TrendChart = ({ data }) => (
<div className="h-40">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ left: 5, right: 15 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }} />
<Line type="monotone" dataKey="value" stroke="#ef4444" strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無數據</div>
)}
</div>
)
// Lateness chart data
const latenessChartData = latenessData?.stats?.slice(0, 8).map(s => ({
name: String(s.name).slice(0, 10),
value: s.late_minutes,
count: s.late_count
})) || []
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">Dashboard</h1>
<button onClick={fetchData} className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-md">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
刷新
</button>
</div>
{/* ========== ATTENDANCE SECTION ========== */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Users size={20} className="text-blue-600" />
出勤 Attendance
</h2>
<Link to="/attendance" className="text-sm text-blue-600 hover:text-blue-700">查看全部 </Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<SectionCard title="出勤記錄" value={attInfo?.rows || 0} icon={Users} color="border-l-blue-500" link="/attendance" />
<SectionCard title="出勤部門" value={attDeptData.length} subtitle="個部門" icon={MapPin} color="border-l-cyan-500" link="/attendance" />
<SectionCard
title="遲到人次"
value={latenessData?.total_late_count || 0}
icon={AlertCircle}
color="border-l-orange-500"
link="/attendance"
/>
<SectionCard
title="遲到總分鐘"
value={latenessData?.total_late_minutes || 0}
subtitle="分鐘"
icon={Clock}
color="border-l-red-500"
link="/attendance"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card p-4 border-l-4 border-l-blue-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">部門分佈</h3>
<SmallBarChart data={attDeptData} color="blue" />
</div>
<div className="card p-4 border-l-4 border-l-blue-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">出勤狀態</h3>
<SmallBarChart data={attStatusData} color="blue" />
</div>
{/* Lateness Chart */}
<div className="card p-4 border-l-4 border-l-orange-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">員工遲到分鐘</h3>
<div className="h-32">
{latenessChartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={latenessChartData} layout="vertical" margin={{ left: 5, right: 5 }}>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis dataKey="name" type="category" width={60} tick={{ fontSize: 9 }} />
<Tooltip
contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }}
formatter={(value, name, props) => [`${value}分鐘 (${props.payload.count}次)`, '遲到']}
/>
<Bar dataKey="value" radius={[0, 3, 3, 0]} fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無遲到數據</div>
)}
</div>
</div>
</div>
{/* Top late employees table */}
{latenessData?.stats?.length > 0 && (
<div className="card p-4 mt-4">
<h3 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
<AlertCircle size={16} className="text-orange-500" />
遲到排行榜
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-500">員工</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">班次</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">遲到次數</th>
<th className="text-center py-2 px-3 font-medium text-slate-500">總分鐘</th>
</tr>
</thead>
<tbody>
{latenessData.stats.slice(0, 5).map((stat, idx) => (
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-2 px-3 font-medium">{stat.name}</td>
<td className="text-center py-2 px-3">{stat.shift}</td>
<td className="text-center py-2 px-3 text-orange-600 font-medium">{stat.late_count} </td>
<td className="text-center py-2 px-3 text-red-600 font-medium">{stat.late_minutes} 分鐘</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* ========== ACCIDENT SECTION ========== */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<AlertTriangle size={20} className="text-red-600" />
意外 Accident
</h2>
<Link to="/accident" className="text-sm text-red-600 hover:text-red-700">查看全部 </Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<SectionCard title="意外記錄" value={accInfo?.rows || 0} icon={AlertTriangle} color="border-l-red-500" link="/accident" />
<SectionCard title="出事地點" value={accLocationData.length} subtitle="個地點" icon={MapPin} color="border-l-orange-500" link="/accident" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card p-4 border-l-4 border-l-red-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">緊急程度</h3>
<SmallBarChart data={accSeverityData} color="red" />
</div>
<div className="card p-4 border-l-4 border-l-orange-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">出事地點</h3>
<SmallBarChart data={accLocationData} color="orange" />
</div>
<div className="card p-4 border-l-4 border-l-red-500">
<h3 className="text-sm font-semibold text-slate-900 mb-3">月度趨勢</h3>
<TrendChart data={accMonthlyData} />
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<button onClick={() => handleExportHTML('attendance')} className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
<FileCode size={16} />
Attendance HTML Report
</button>
<button onClick={() => handleExportHTML('accident')} className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">
<FileCode size={16} />
Accident HTML Report
</button>
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-md hover:bg-primary-700">
<FileSpreadsheet size={16} />
上傳 Excel
</Link>
<Link to="/roster" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<Clock size={16} />
班次管理
</Link>
</div>
</div>
)
}
-242
View File
@@ -1,242 +0,0 @@
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>
)
}
-344
View File
@@ -1,344 +0,0 @@
import { useState, useEffect } from "react"
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info } 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("")
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 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>
{/* 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>
)
}
-291
View File
@@ -1,291 +0,0 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { Link } from 'react-router-dom'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, LineChart, Line } from 'recharts'
import { AlertTriangle, MapPin, Users, FileText, Calendar, TrendingUp, XCircle, CheckCircle2, Download, Search, ChevronUp, ChevronDown, Filter, X, Activity } from 'lucide-react'
import api from '../../api'
import toast from 'react-hot-toast'
const SEVERITY_COLORS = {
'1': { bg: '#D1FAE5', fg: '#065F46', label: '低 Low' },
'2': { bg: '#DBEAFE', fg: '#1E40AF', label: '中 Medium' },
'3': { bg: '#FEF3C7', fg: '#92400E', label: '高 High' },
'4': { bg: '#FED7AA', fg: '#9A3412', label: '嚴重 Severe' },
'5': { bg: '#FEE2E2', fg: '#7F1D1D', label: '極嚴重 Critical' },
}
export default function AccidentDashboard() {
const [summary, setSummary] = useState(null)
const [stats, setStats] = useState(null)
const [records, setRecords] = useState([])
const [loading, setLoading] = useState(true)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const dashboardRef = useRef(null)
const fmt = (d) => d.toISOString().slice(0, 10)
const weekRange = (offsetWeeks) => {
const now = new Date()
const dow = now.getDay()
const daysFromMonday = (dow + 6) % 7
const start = new Date(now)
start.setDate(now.getDate() - daysFromMonday + offsetWeeks * 7)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [fmt(start), fmt(end)]
}
const today = new Date().toISOString().slice(0, 10)
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
useEffect(() => {
fetchData()
}, [dateFrom, dateTo])
const fetchData = async () => {
setLoading(true)
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const [sumRes, statRes, recRes] = await Promise.all([
api.get('/api/accident/dashboard/summary', { params }),
api.get('/api/accident/dashboard/stats', { params }),
api.get('/api/accident/records', { params: { ...params, per_page: 1000 } }).catch(() => ({ data: { records: [] } })),
])
setSummary(sumRes.data)
setStats(statRes.data)
setRecords(recRes.data.records || [])
} catch (err) {
toast.error('載入失敗')
console.error(err)
} finally {
setLoading(false)
}
}
const handleExportExcel = async () => {
try {
const params = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const response = await api.get('/api/accident/export/excel', { params, responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
let suffix = ''
if (dateFrom && dateTo) suffix = `_${dateFrom}_${dateTo}`
else if (dateFrom) suffix = `_from_${dateFrom}`
else if (dateTo) suffix = `_to_${dateTo}`
link.setAttribute('download', `accident${suffix}.xlsx`)
document.body.appendChild(link)
link.click()
link.remove()
toast.success('Excel 已下載')
} catch (err) {
toast.error('匯出失敗')
}
}
// Build donut chart data (severity distribution)
const severityData = useMemo(() => {
if (!summary?.severity_count) return []
return Object.entries(summary.severity_count)
.filter(([_, count]) => count > 0)
.map(([sev, count]) => ({
name: SEVERITY_COLORS[sev]?.label || `Sev ${sev}`,
value: count,
severity: sev,
color: SEVERITY_COLORS[sev]?.fg || '#6B7280',
}))
}, [summary])
// Build line chart (monthly trend)
const monthlyTrend = useMemo(() => {
const byMonth = {}
for (const r of records) {
if (!r.date) continue
const ym = r.date.substring(0, 7)
byMonth[ym] = (byMonth[ym] || 0) + 1
}
return Object.entries(byMonth)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, count]) => ({ month, count }))
}, [records])
if (loading && !summary) {
return <div className="p-12 text-center text-slate-500">載入中</div>
}
return (
<div className="space-y-6" ref={dashboardRef}>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">意外事故 Dashboard</h1>
<div className="flex items-center gap-2">
<Link
to="/accident/list"
className="flex items-center gap-2 px-3 py-1.5 border border-slate-300 text-slate-700 rounded-md hover:bg-slate-50 text-sm"
>
<FileText size={16} />
列表
</Link>
<button
onClick={handleExportExcel}
className="flex items-center gap-2 px-3 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
<Download size={16} />
匯出 Excel
</button>
</div>
</div>
{/* Date Range Filter */}
<div className="card p-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">日期範圍:</span>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
<span className="text-slate-400">-</span>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={() => { setDateFrom(today); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">今日</button>
<button onClick={() => { const [w0, w1] = weekRange(0); setDateFrom(w0); setDateTo(w1) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本週</button>
<button onClick={() => { const [w0, w1] = weekRange(-1); setDateFrom(w0); setDateTo(w1) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">上週</button>
<button onClick={() => { setDateFrom(monthStart); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本月</button>
<button onClick={() => { setDateFrom(''); setDateTo('') }} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-2 border border-slate-300 rounded">清除</button>
</div>
</div>
{(dateFrom || dateTo) && (
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
)}
</div>
{/* Stats Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon={AlertTriangle} color="bg-red-50 text-red-600" label="總記錄 Total" value={summary.total_records || 0} />
<StatCard icon={Calendar} color="bg-blue-50 text-blue-600" label="本月" value={summary.this_month || 0} />
<StatCard icon={MapPin} color="bg-purple-50 text-purple-600" label="地點 Locations" value={summary.unique_locations || 0} />
<StatCard icon={TrendingUp} color="bg-green-50 text-green-600" label="日均" value={summary.avg_per_day || 0} />
{Object.entries(summary.severity_count || {}).map(([sev, count]) => (
<StatCard
key={sev}
icon={Activity}
color=""
label={`Severity ${sev}`}
value={count}
overrideColor={SEVERITY_COLORS[sev]?.fg}
overrideBg={SEVERITY_COLORS[sev]?.bg}
/>
))}
</div>
)}
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Severity Distribution */}
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">嚴重程度分佈</h3>
{severityData.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-12">暫無數據</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={severityData}
dataKey="value"
nameKey="name"
cx="50%" cy="50%"
outerRadius={80}
label={(entry) => `${entry.name}: ${entry.value}`}
>
{severityData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
{/* Monthly Trend */}
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">月度趨勢</h3>
{monthlyTrend.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-12">暫無數據</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="count" stroke="#2563EB" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Leaderboards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<RankCard title="地點 (Top 5)" icon={MapPin} data={stats.by_location?.slice(0, 5) || []} field="count" />
<RankCard title="員工 (Top 5)" icon={Users} data={stats.by_employee?.slice(0, 5) || []} field="count" />
<RankCard title="部門 (Top 5)" icon={FileText} data={stats.by_department?.slice(0, 5) || []} field="count" />
<RankCard title="負責人 (Top 5)" icon={CheckCircle2} data={stats.by_responsible?.slice(0, 5) || []} field="count" />
</div>
)}
</div>
)
}
function StatCard({ icon: Icon, color, label, value, overrideColor, overrideBg }) {
return (
<div
className="card p-4"
style={overrideBg ? { backgroundColor: overrideBg } : undefined}
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 mb-1">{label}</p>
<p className="text-2xl font-bold" style={overrideColor ? { color: overrideColor } : undefined}>{value}</p>
</div>
{Icon && (
<div className={`p-2 rounded-lg ${color}`}>
<Icon size={20} />
</div>
)}
</div>
</div>
)
}
function RankCard({ title, icon: Icon, data, field }) {
return (
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Icon size={16} /> {title}
</h3>
<div className="space-y-1">
{data.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
) : data.map((s, i) => (
<div key={s.name} className="py-1.5 border-b border-slate-100 last:border-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${i === 0 ? 'bg-yellow-100 text-yellow-700' : i === 1 ? 'bg-slate-100 text-slate-500' : i === 2 ? 'bg-orange-50 text-orange-400' : 'bg-slate-50 text-slate-400'}`}>{i + 1}</span>
<span className="text-sm text-slate-700 truncate max-w-[160px]" title={s.name}>{s.name}</span>
</div>
<div className="text-right">
<span className="text-sm font-bold text-slate-700">{s[field]}</span>
<span className="text-xs text-slate-400 ml-2">avg {s.avg_severity || 0}</span>
</div>
</div>
</div>
))}
</div>
</div>
)
}
+45 -86
View File
@@ -2,67 +2,45 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { ArrowLeft, FileText, AlertTriangle } from 'lucide-react'
const SEVERITY_COLORS = {
'1': { bg: '#D1FAE5', fg: '#065F46', label: '低 Low' },
'2': { bg: '#DBEAFE', fg: '#1E40AF', label: '中 Medium' },
'3': { bg: '#FEF3C7', fg: '#92400E', label: '高 High' },
'4': { bg: '#FED7AA', fg: '#9A3412', label: '嚴重 Severe' },
'5': { bg: '#FEE2E2', fg: '#7F1D1D', label: '極嚴重 Critical' },
}
const FIELDS = [
{ key: 'date', label: '日期 Date' },
{ key: 'time', label: '時間 Time' },
{ key: 'severity', label: '嚴重程度 Severity', badge: true },
{ key: 'location', label: '地點 Location' },
{ key: 'employee_name', label: '員工/報告人 Employee' },
{ key: 'department', label: '部門 Department' },
{ key: 'description', label: '事件描述 Description' },
{ key: 'medical_report', label: '醫療報告 Medical Report' },
{ key: 'action_taken', label: '處理情況 Action Taken' },
{ key: 'responsible_person', label: '負責人 Responsible Person' },
{ key: 'created_at', label: '建立時間 Created At' },
]
import { ArrowLeft, FileText } from 'lucide-react'
export default function AccidentDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [record, setRecord] = useState(null)
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const numericId = parseInt(id, 10)
if (isNaN(numericId) || numericId <= 0) {
navigate('/accident/list', { replace: true })
return
}
fetchRecord(numericId)
fetchRecord()
}, [id])
const fetchRecord = async (recordId) => {
const fetchRecord = async () => {
setLoading(true)
setError(null)
try {
const { data } = await api.get(`/api/accident/${recordId}`)
setRecord(data)
// Get all records to find the one with this row id
const { data } = await api.get(`/api/accident?page=1&page_size=1000`)
if (data.data && data.data.length > 0) {
// Extract headers from first record
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
// Find the record with matching _row
const found = data.data.find(r => r._row === parseInt(id))
if (found) {
setRecord(found)
} else {
setError('Record not found')
}
} else {
setError('No data available')
}
} catch (err) {
console.error('Error fetching record:', err)
const detail = err.response?.data?.detail
// FastAPI 422 returns detail as array of validation errors — stringify safely
let msg = 'Failed to load record'
if (typeof detail === 'string') {
msg = detail
} else if (Array.isArray(detail)) {
msg = `Invalid request: ${detail.map(d => d.msg || JSON.stringify(d)).join('; ')}`
} else if (detail && typeof detail === 'object') {
msg = JSON.stringify(detail)
} else if (err.response?.status === 404) {
msg = 'Record not found'
}
setError(msg)
setError('Failed to load record')
} finally {
setLoading(false)
}
@@ -81,7 +59,7 @@ export default function AccidentDetail() {
<div className="space-y-4">
<div className="flex items-center gap-4">
<Link
to="/accident/list"
to="/accident"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
@@ -89,10 +67,9 @@ export default function AccidentDetail() {
</Link>
</div>
<div className="card p-8 text-center">
<AlertTriangle size={48} className="mx-auto text-red-400 mb-2" />
<div className="text-red-500 mb-2">{error}</div>
<div className="text-red-500">{error}</div>
<button
onClick={() => navigate('/accident/list')}
onClick={() => navigate('/accident')}
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
>
返回列表
@@ -102,67 +79,49 @@ export default function AccidentDetail() {
)
}
const sev = String(record.severity || '')
const sevColor = SEVERITY_COLORS[sev] || { bg: '#F3F4F6', fg: '#374151', label: sev || '-' }
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4 sm:mb-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/accident/list"
to="/accident"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回
</Link>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900">
<h1 className="text-2xl font-bold text-slate-900">
意外記錄 #{id}
</h1>
</div>
<span
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold"
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
>
Severity {sev} · {sevColor.label}
</span>
</div>
<div className="card overflow-hidden">
{/* Record Details */}
<div className="card">
<div className="p-4 border-b border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<FileText size={24} className="text-slate-400" />
<div>
<div className="font-medium text-slate-900">記錄詳情</div>
<div className="text-sm text-slate-500">AARS 意外事故記錄</div>
<div className="text-sm text-slate-500">Excel 原始數據</div>
</div>
</div>
</div>
<div className="divide-y divide-slate-100">
{FIELDS.map(({ key, label, badge }) => {
const val = record[key]
const display = val !== null && val !== undefined && val !== '' ? String(val) : '-'
return (
<div key={key} className="grid grid-cols-1 sm:grid-cols-12">
<div className="sm:col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{label}
</div>
<div className="sm:col-span-9 px-4 py-3 text-sm text-slate-900 break-words">
{badge && sev !== '' ? (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold"
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
>
{sev} · {sevColor.label}
</span>
) : (
display
)}
</div>
{headers.map(key => (
<div key={key} className="grid grid-cols-12">
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{key}
</div>
)
})}
<div className="col-span-9 px-4 py-3 text-sm text-slate-900">
{record[key] !== null && record[key] !== undefined
? String(record[key])
: '-'}
</div>
</div>
))}
</div>
</div>
</div>
+55 -127
View File
@@ -1,105 +1,79 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, Settings2, RotateCcw } from 'lucide-react'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, FileCode } from 'lucide-react'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
export default function AccidentList() {
const navigate = useNavigate()
const [records, setRecords] = useState([])
const [excelHeaders, setExcelHeaders] = useState({}) // sql_col -> Excel chinese header
const [columnOrder, setColumnOrder] = useState([]) // ordered SQL cols (Excel order)
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState('date')
const [sortDir, setSortDir] = useState('desc')
const [sortKey, setSortKey] = useState(null)
const [sortDir, setSortDir] = useState('asc')
const [filterKey, setFilterKey] = useState('')
const [filterValue, setFilterValue] = useState('')
// Column visibility — localStorage persisted
const STORAGE_KEY = 'aars.accident.visibleColumns.v1'
const [visibleCols, setVisibleCols] = useState(() => {
try {
const s = localStorage.getItem(STORAGE_KEY)
if (s) return JSON.parse(s)
} catch {}
return null // null = show all
})
const [showColumnSettings, setShowColumnSettings] = useState(false)
useEffect(() => {
fetchRecords()
}, [])
useEffect(() => {
if (visibleCols !== null) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleCols))
}
}, [visibleCols])
const fetchRecords = async () => {
setLoading(true)
try {
const { data } = await api.get('/api/accident/records?per_page=1000')
setRecords(data.records || [])
const eh = data.excel_headers || {}
const co = data.column_order || []
setExcelHeaders(eh)
setColumnOrder(co)
// Default sort
if (data.records && data.records.length > 0 && !sortKey) {
setSortKey('date')
setSortDir('desc')
const { data } = await api.get('/api/accident?page=1&page_size=1000')
setRecords(data.data || [])
if (data.data && data.data.length > 0) {
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
}
} catch (err) {
toast.error('Failed to load records')
console.error(err)
} finally {
setLoading(false)
}
}
// The set of columns to display: filtered by visibleCols (if not null)
const displayedCols = useMemo(() => {
let cols = columnOrder.filter(c => c !== 'id')
if (visibleCols !== null) {
cols = cols.filter(c => visibleCols[c])
}
return cols
}, [columnOrder, visibleCols])
// Helper: get display name for a SQL column (uses Excel header, trims trailing spaces)
const displayName = (col) => {
const h = excelHeaders[col]
return (h ? String(h).trim() : col) || col
}
// Filtered and sorted data
const processedData = useMemo(() => {
let result = [...records]
// Filter
if (filterKey && filterValue) {
result = result.filter(r => {
const val = String(r[filterKey] || '').toLowerCase()
return val.includes(filterValue.toLowerCase())
})
}
// Search
if (search) {
const searchLower = search.toLowerCase()
result = result.filter(r =>
Object.values(r).some(v =>
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) && aVal !== '' && bVal !== '') {
if (!isNaN(aNum) && !isNaN(bNum)) {
cmp = aNum - bNum
} else {
cmp = String(aVal).localeCompare(String(bVal))
@@ -107,6 +81,7 @@ export default function AccidentList() {
return sortDir === 'asc' ? cmp : -cmp
})
}
return result
}, [records, search, sortKey, sortDir, filterKey, filterValue])
@@ -134,35 +109,18 @@ export default function AccidentList() {
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
const toggleColumn = (col) => {
const next = visibleCols ? { ...visibleCols } : {}
// Initialize all columns to true first
columnOrder.forEach(c => { if (next[c] === undefined) next[c] = true })
next[col] = !next[col]
setVisibleCols(next)
}
const resetColumns = () => {
setVisibleCols(null)
localStorage.removeItem(STORAGE_KEY)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-lg sm:text-xl font-bold text-slate-900">意外記錄 Accident</h1>
<h1 className="text-xl font-bold text-slate-900">意外記錄 Accident</h1>
<div className="flex gap-2">
<button
onClick={() => setShowColumnSettings(!showColumnSettings)}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-md border ${
showColumnSettings
? 'bg-slate-100 border-slate-400 text-slate-800'
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
}`}
onClick={() => handleExportHTML('accident')}
className="flex items-center gap-2 px-4 py-2 bg-slate-700 text-white text-sm rounded-md hover:bg-slate-800"
>
<Settings2 size={16} />
欄位 ({displayedCols.length}/{columnOrder.filter(c => c !== 'id').length})
<FileCode size={16} />
匯出 HTML Report
</button>
<Link
to="/upload"
@@ -173,44 +131,10 @@ export default function AccidentList() {
</div>
</div>
{/* Column Settings Panel */}
{showColumnSettings && (
<div className="card p-4 border-l-4 border-primary-500">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-slate-700">
選擇顯示嘅欄位 (來源: Excel)
</div>
<button
onClick={resetColumns}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-800"
>
<RotateCcw size={12} />
重設全部顯示
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{columnOrder.filter(c => c !== 'id').map(col => {
const isVisible = visibleCols === null || (visibleCols[col] !== false)
return (
<label key={col} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-slate-50 px-2 py-1 rounded">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggleColumn(col)}
className="rounded"
/>
<span className="text-slate-700">{displayName(col)}</span>
<span className="text-slate-400 text-[10px]">({col})</span>
</label>
)
})}
</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
@@ -221,6 +145,8 @@ export default function AccidentList() {
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
/>
</div>
{/* Column Filter */}
<div className="flex items-center gap-2">
<Filter size={16} className="text-slate-400" />
<select
@@ -229,10 +155,11 @@ export default function AccidentList() {
className="text-sm border border-slate-300 rounded-md px-2 py-2"
>
<option value="">篩選欄位</option>
{displayedCols.map(h => (
<option key={h} value={h}>{displayName(h)}</option>
{headers.slice(0, 6).map(h => (
<option key={h} value={h}>{h}</option>
))}
</select>
{filterKey && (
<>
<input
@@ -248,6 +175,8 @@ export default function AccidentList() {
</>
)}
</div>
{/* Result count */}
<div className="text-sm text-slate-500 py-2">
{processedData.length} 筆記錄
</div>
@@ -261,15 +190,14 @@ export default function AccidentList() {
<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>
{displayedCols.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 whitespace-nowrap"
{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)}
title={excelHeaders[h] || h}
>
<div className="flex items-center gap-1">
{displayName(h)}
{h}
{getSortIcon(h)}
</div>
</th>
@@ -280,32 +208,32 @@ export default function AccidentList() {
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
載入中...
</td>
</tr>
) : processedData.length === 0 ? (
<tr>
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
processedData.map((record) => (
<tr
key={record.id}
<tr
key={record._row}
className="hover:bg-slate-50 cursor-pointer"
onClick={() => navigate(`/accident/${record.id}`)}
onClick={() => window.location.href = `/accident/${record._row}`}
>
<td className="px-3 py-2 text-xs text-slate-400">{record.id}</td>
{displayedCols.map(h => (
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate" title={record[h]}>
{record[h] !== null && record[h] !== undefined && record[h] !== '' ? String(record[h]) : '-'}
<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-right" onClick={(e) => e.stopPropagation()}>
<Link
to={`/accident/${record.id}`}
to={`/accident/${record._row}`}
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
>
<Eye size={16} />
+61 -160
View File
@@ -2,16 +2,14 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { ArrowLeft, Save, X } from 'lucide-react'
import { ArrowLeft, FileText } from 'lucide-react'
export default function AttendanceDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [record, setRecord] = useState(null)
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editMode, setEditMode] = useState(false)
const [formData, setFormData] = useState({})
const [error, setError] = useState(null)
useEffect(() => {
@@ -22,111 +20,32 @@ export default function AttendanceDetail() {
setLoading(true)
setError(null)
try {
// Fetch all records and find by id
const { data } = await api.get('/api/attendance/records?per_page=1000')
const found = (data.records || []).find(r => r.id === parseInt(id))
if (found) {
setRecord(found)
setFormData({
employee_name: found.employee_name || '',
company: found.company || '',
date: found.date || '',
weekday: found.weekday || '',
shift_code: found.shift_code || '',
status_code: found.status_code || '',
status_text: found.status_text || '',
late_minutes: found.late_minutes || 0,
early_minutes: found.early_minutes || 0,
ot_minutes: found.ot_minutes || 0,
expected_in: found.expected_in || '',
expected_out: found.expected_out || '',
actual_in: found.actual_in || '',
actual_out: found.actual_out || '',
is_manually_edited: found.is_manually_edited || false,
})
// Get all records to find the one with this row id
const { data } = await api.get(`/api/attendance?page=1&page_size=1000`)
if (data.data && data.data.length > 0) {
// Extract headers from first record
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
// Find the record with matching _row
const found = data.data.find(r => r._row === parseInt(id))
if (found) {
setRecord(found)
} else {
setError('Record not found')
}
} else {
setError('找不到記錄')
setError('No data available')
}
} catch (err) {
console.error('Error:', err)
setError('載入失敗')
console.error('Error fetching record:', err)
setError('Failed to load record')
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
try {
await api.put(`/api/attendance/${id}`, formData)
toast.success('保存成功')
setEditMode(false)
fetchRecord()
} catch (err) {
toast.error('保存失敗')
} finally {
setSaving(false)
}
}
const handleCancel = () => {
setEditMode(false)
if (record) {
setFormData({
employee_name: record.employee_name || '',
company: record.company || '',
date: record.date || '',
weekday: record.weekday || '',
shift_code: record.shift_code || '',
status_code: record.status_code || '',
status_text: record.status_text || '',
late_minutes: record.late_minutes || 0,
early_minutes: record.early_minutes || 0,
ot_minutes: record.ot_minutes || 0,
expected_in: record.expected_in || '',
expected_out: record.expected_out || '',
actual_in: record.actual_in || '',
actual_out: record.actual_out || '',
})
}
}
const getStatusBadge = (code, text) => {
const badges = {
normal: 'bg-green-100 text-green-700',
late: 'bg-orange-100 text-orange-700',
early: 'bg-blue-100 text-blue-700',
ot: 'bg-purple-100 text-purple-700',
abnormal: 'bg-red-100 text-red-700',
missing: 'bg-gray-200 text-gray-600',
late_early: 'bg-orange-100 text-orange-700',
late_ot: 'bg-orange-100 text-orange-700',
early_ot: 'bg-blue-100 text-blue-700',
}
return (
<span className={`px-3 py-1 rounded-full text-xs font-medium ${badges[code] || 'bg-gray-100 text-gray-600'}`}>
{text || code}
</span>
)
}
const displayFields = [
{ key: 'employee_name', label: '員工姓名' },
{ key: 'company', label: '公司' },
{ key: 'date', label: '日期' },
{ key: 'weekday', label: '星期' },
{ key: 'shift_code', label: '班次' },
{ key: 'expected_in', label: '應上班時間' },
{ key: 'expected_out', label: '應下班時間' },
{ key: 'actual_in', label: '實際上班' },
{ key: 'actual_out', label: '實際下班' },
{ key: 'late_minutes', label: '遲到分鐘' },
{ key: 'early_minutes', label: '早退分鐘' },
{ key: 'ot_minutes', label: 'OT分鐘' },
{ key: 'status_code', label: '狀態代碼' },
{ key: 'status_text', label: '狀態' },
]
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -138,10 +57,24 @@ export default function AttendanceDetail() {
if (error) {
return (
<div className="space-y-4">
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
<ArrowLeft size={20} /> 返回列表
</Link>
<div className="card p-8 text-center text-red-500">{error}</div>
<div className="flex items-center gap-4">
<Link
to="/attendance"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回列表
</Link>
</div>
<div className="card p-8 text-center">
<div className="text-red-500">{error}</div>
<button
onClick={() => navigate('/attendance')}
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
>
返回列表
</button>
</div>
</div>
)
}
@@ -151,73 +84,41 @@ export default function AttendanceDetail() {
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
<ArrowLeft size={20} /> 返回
<Link
to="/attendance"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={20} />
返回
</Link>
<h1 className="text-xl font-bold text-slate-900">
出勤記錄 - {record?.employee_name}
<h1 className="text-2xl font-bold text-slate-900">
出勤記錄 #{id}
</h1>
</div>
<div className="flex items-center gap-3">
{record && getStatusBadge(record.status_code, record.status_text)}
{!editMode ? (
<button
onClick={() => setEditMode(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
編輯
</button>
) : (
<>
<button
onClick={handleCancel}
className="flex items-center gap-2 px-4 py-2 border border-slate-300 text-slate-700 text-sm rounded-md hover:bg-slate-50"
>
<X size={16} /> 取消
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
>
<Save size={16} /> {saving ? '保存中...' : '保存'}
</button>
</>
)}
</div>
</div>
{/* Manual edit warning */}
{record?.is_manually_edited && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3 text-sm text-yellow-800">
此記錄曾被手動修改之後重新導入 Excel 不會覆蓋此記錄
</div>
)}
{/* Record Details */}
<div className="card">
<div className="p-4 border-b border-slate-200 bg-slate-50">
<h2 className="font-semibold text-slate-700">記錄詳情</h2>
<div className="flex items-center gap-3">
<FileText size={24} className="text-slate-400" />
<div>
<div className="font-medium text-slate-900">記錄詳情</div>
<div className="text-sm text-slate-500">Excel 原始數據</div>
</div>
</div>
</div>
<div className="divide-y divide-slate-100">
{displayFields.map(({ key, label }) => (
<div key={key} className="grid grid-cols-12 items-center">
{headers.map(key => (
<div key={key} className="grid grid-cols-12">
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
{label}
{key}
</div>
<div className="col-span-9 px-4 py-3">
{editMode && ['status_code', 'late_minutes', 'early_minutes', 'ot_minutes', 'actual_in', 'actual_out', 'shift_code'].includes(key) ? (
<input
type="text"
value={formData[key]}
onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-slate-900">
{record?.[key] !== null && record?.[key] !== undefined ? String(record[key]) : '-'}
</span>
)}
<div className="col-span-9 px-4 py-3 text-sm text-slate-900">
{record[key] !== null && record[key] !== undefined
? String(record[key])
: '-'}
</div>
</div>
))}
+223 -230
View File
@@ -2,226 +2,231 @@ import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api'
import toast from 'react-hot-toast'
import { Search, ChevronUp, ChevronDown, Filter, X, Users, Calendar } from 'lucide-react'
import StatusBadge from '../../components/StatusBadge'
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock, FileCode } from 'lucide-react'
const handleExportHTML = (section) => {
const token = localStorage.getItem('aars_token')
if (token) {
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
}
}
export default function AttendanceList() {
const [records, setRecords] = useState([])
const [total, setTotal] = useState(0)
const [headers, setHeaders] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sortBy, setSortBy] = useState('date')
const [sortOrder, setSortOrder] = useState('desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [staffFilter, setStaffFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const [statusOptions, setStatusOptions] = useState([])
const perPage = 50
const [staffList, setStaffList] = useState([])
const [sortKey, setSortKey] = useState(null)
const [sortDir, setSortDir] = useState('asc')
const [filterKey, setFilterKey] = useState('')
const [filterValue, setFilterValue] = useState('')
const [latenessRecords, setLatenessRecords] = useState([])
useEffect(() => {
fetchRecords()
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) }
}
fetchLateness()
}, [])
const fetchRecords = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
page,
per_page: perPage,
sort_by: sortBy,
sort_order: sortOrder,
})
if (search) params.append('search', search)
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_text', statusFilter)
const { data } = await api.get(`/api/attendance/records?${params}`)
setRecords(data.records || [])
setTotal(data.total || 0)
// Extract unique staff names
const names = [...new Set((data.records || []).map(r => r.employee_name).filter(Boolean))]
setStaffList(names.sort())
const { data } = await api.get('/api/attendance?page=1&page_size=1000')
setRecords(data.data || [])
if (data.data && data.data.length > 0) {
const keys = Object.keys(data.data[0]).filter(k => k !== '_row')
setHeaders(keys)
}
} catch (err) {
toast.error('載入失敗')
console.error(err)
toast.error('Failed to load records')
} finally {
setLoading(false)
}
}
const handleSearch = (e) => {
e.preventDefault()
setPage(1)
fetchRecords()
const fetchLateness = async () => {
try {
const { data } = await api.get('/api/attendance/lateness/records')
setLatenessRecords(data || [])
} catch (err) {
console.error('Failed to load lateness data:', err)
}
}
const handleSort = (key) => {
if (sortBy === key) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(key)
setSortOrder('asc')
// Create a map of late records for quick lookup
const latenessMap = useMemo(() => {
const map = {}
latenessRecords.forEach(rec => {
const key = `${rec.staff}-${rec.date}`
map[key] = rec
})
return map
}, [latenessRecords])
// Filtered and sorted data
const processedData = useMemo(() => {
let result = [...records]
// Filter
if (filterKey && filterValue) {
result = result.filter(r => {
const val = String(r[filterKey] || '').toLowerCase()
return val.includes(filterValue.toLowerCase())
})
}
setPage(1)
// Search
if (search) {
const searchLower = search.toLowerCase()
result = result.filter(r =>
Object.values(r).some(v =>
v && String(v).toLowerCase().includes(searchLower)
)
)
}
// Sort
if (sortKey) {
result.sort((a, b) => {
const aVal = a[sortKey] || ''
const bVal = b[sortKey] || ''
const aNum = Number(aVal)
const bNum = Number(bVal)
let cmp = 0
if (!isNaN(aNum) && !isNaN(bNum)) {
cmp = aNum - bNum
} else {
cmp = String(aVal).localeCompare(String(bVal))
}
return sortDir === 'asc' ? cmp : -cmp
})
}
return result
}, [records, search, sortKey, sortDir, filterKey, filterValue])
const handleSort = (key) => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
}
}
const handleFilter = (key) => {
setFilterKey(key)
setFilterValue('')
}
const clearFilter = () => {
setFilterKey('')
setFilterValue('')
}
const getSortIcon = (key) => {
if (sortBy !== key) return null
return sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
if (sortKey !== key) return null
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
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} />
// Get lateness info for a record
const getLatenessInfo = (record) => {
// Find staff name and date columns
const staffKey = Object.keys(record).find(k =>
k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工')
)
const dateKey = Object.keys(record).find(k =>
k.toLowerCase().includes('date') || k.toLowerCase().includes('日期')
)
if (!staffKey || !dateKey) return null
const staff = record[staffKey]
const date = record[dateKey]
if (!staff || !date) return null
const key = `${staff}-${String(date).slice(0, 10)}`
return latenessMap[key]
}
const getRowClass = (code) => {
// 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'
}
const totalPages = Math.ceil(total / perPage)
const columns = [
{ key: 'employee_name', label: '員工' },
{ key: 'date', label: '日期' },
{ key: 'weekday', label: '星期' },
{ key: 'shift_code', label: '班次' },
{ key: 'actual_in', label: '上班' },
{ key: 'actual_out', label: '下班' },
{ key: 'expected_in', label: '應上班' },
{ key: 'expected_out', label: '應下班' },
]
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-lg sm:text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
<h1 className="text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
<div className="flex gap-2">
<Link to="/" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
<Calendar size={16} /> Dashboard
</Link>
<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">
<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-4 space-y-3">
{/* Search row */}
<form onSubmit={handleSearch} className="flex flex-wrap gap-3 items-end">
<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="搜尋員工姓名..."
placeholder="搜尋所有欄位..."
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
/>
</div>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
搜尋
</button>
</form>
{/* Filter row */}
<div className="flex flex-wrap gap-3 items-end">
{/* Column Filter */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">日期:</span>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
placeholder="由"
/>
<span className="text-slate-400">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
placeholder="至"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">員工:</span>
<Filter size={16} className="text-slate-400" />
<select
value={staffFilter}
onChange={(e) => { setStaffFilter(e.target.value); setPage(1) }}
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
value={filterKey}
onChange={(e) => handleFilter(e.target.value)}
className="text-sm border border-slate-300 rounded-md px-2 py-2"
>
<option value="">全部</option>
{staffList.map(s => (
<option key={s} value={s}>{s}</option>
<option value="">篩選欄位</option>
{headers.slice(0, 6).map(h => (
<option key={h} value={h}>{h}</option>
))}
</select>
{filterKey && (
<>
<input
type="text"
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
placeholder="輸入篩選值..."
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
/>
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
<X size={16} />
</button>
</>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">狀態:</span>
<select
value={statusFilter}
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>
{statusOptions.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
{/* Result count */}
<div className="text-sm text-slate-500 py-2">
{processedData.length} 筆記錄
{latenessRecords.length > 0 && (
<span className="ml-2 text-orange-600">
{latenessRecords.length} 筆遲到
</span>
)}
</div>
<button
onClick={() => {
setDateFrom(''); setDateTo(''); setStaffFilter(''); setStatusFilter(''); setSearch(''); setPage(1)
// Force refetch in case useEffect timing misses the state change.
setTimeout(() => fetchRecords(), 0)
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs text-slate-500 hover:text-slate-700 border border-slate-300 rounded-md"
>
<X size={14} /> 清除篩選
</button>
</div>
{/* Stats bar */}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>{total} 筆記錄</span>
{staffFilter && <span> 員工: {staffFilter}</span>}
{statusFilter && <span> 狀態: {statusFilter}</span>}
{(dateFrom || dateTo) && <span> {dateFrom || '...'} {dateTo || '...'}</span>}
</div>
</div>
@@ -231,93 +236,81 @@ export default function AttendanceList() {
<table className="w-full text-sm">
<thead className="bg-slate-100 border-b border-slate-200">
<tr>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 w-12">#</th>
{columns.map(col => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
<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">
{col.label}
{getSortIcon(col.key)}
{h}
{getSortIcon(h)}
</div>
</th>
))}
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-600">狀態</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-600 w-16">操作</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={10} className="px-3 py-8 text-center text-slate-400">
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
載入中...
</td>
</tr>
) : records.length === 0 ? (
) : processedData.length === 0 ? (
<tr>
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
沒有記錄
</td>
</tr>
) : (
records.map((record, idx) => (
<tr
key={record.id || idx}
className={getRowClass(record.status_code)}
>
<td className="px-3 py-2.5 text-xs text-slate-400">{(page - 1) * perPage + idx + 1}</td>
<td className="px-3 py-2.5 text-slate-700 font-medium">{record.employee_name || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.date || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.weekday || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.shift_code || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.actual_in || '-'}</td>
<td className="px-3 py-2.5 text-slate-600">{record.actual_out || '-'}</td>
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_in || '-'}</td>
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_out || '-'}</td>
<td className="px-3 py-2.5 text-center">
{getStatusBadge(record)}
</td>
<td className="px-3 py-2.5 text-right">
<Link
to={`/attendance/${record.id}`}
className="p-1 text-slate-400 hover:text-blue-600 inline-block"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</Link>
</td>
</tr>
))
processedData.map((record) => {
const lateInfo = getLatenessInfo(record)
const isLate = lateInfo && lateInfo.minutes_late > 0
return (
<tr
key={record._row}
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
onClick={() => window.location.href = `/attendance/${record._row}`}
>
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
{headers.slice(0, 6).map(h => (
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
</td>
))}
<td className="px-3 py-2 text-center">
{isLate ? (
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}>
<AlertCircle size={14} />
<span className="text-xs font-medium">{lateInfo.minutes_late}</span>
</div>
) : (
<div className="flex items-center justify-center gap-1 text-green-600">
<Clock size={14} />
<span className="text-xs">正常</span>
</div>
)}
</td>
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
<Link
to={`/attendance/${record._row}`}
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
>
<Eye size={16} />
</Link>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<span className="text-xs text-slate-500">
{page} / {totalPages} {total} 筆記錄
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
>
上一頁
</button>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 text-xs border border-slate-300 rounded-md disabled:opacity-50 hover:bg-slate-100"
>
下一頁
</button>
</div>
</div>
)}
</div>
</div>
)
-986
View File
@@ -1,986 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import api from '../../api'
import toast from 'react-hot-toast'
import {
Plus, Edit2, Trash2, RefreshCw, Upload, Download, Calendar as CalIcon,
Users, X, ChevronDown, ChevronUp, Filter,
} from 'lucide-react'
import StatusBadge from '../../components/StatusBadge'
const LEAVE_TYPES = [
{ value: 'SL', label: '病假 SL', color: 'pink' },
{ value: 'CL', label: '補鐘 CL', color: 'indigo' },
{ value: 'AL', label: '年假 AL', color: 'emerald' },
]
const CURRENT_YEAR = new Date().getFullYear()
const YEAR_OPTIONS = [CURRENT_YEAR - 1, CURRENT_YEAR, CURRENT_YEAR + 1, CURRENT_YEAR + 2]
export default function HolidaysPage() {
const [tab, setTab] = useState('holidays')
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">假期 Holidays</h1>
<p className="text-sm text-slate-500 mt-1">
管理公眾假期 + 員工請假 (SL 病假 / CL 補鐘 / AL 年假)
</p>
</div>
</div>
{/* Tabs */}
<div className="border-b border-slate-200">
<nav className="flex gap-4">
<button
onClick={() => setTab('holidays')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'holidays'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<CalIcon size={16} className="inline mr-1.5" />
公眾假期
</button>
<button
onClick={() => setTab('leaves')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'leaves'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<Users size={16} className="inline mr-1.5" />
員工請假
</button>
</nav>
</div>
{tab === 'holidays' ? <HolidaysTab /> : <LeavesTab />}
</div>
)
}
// ============ Tab 1: 公眾假期 ============
function HolidaysTab() {
const [year, setYear] = useState(CURRENT_YEAR)
const [holidays, setHolidays] = useState([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(null) // Holiday object or null
const [showCreate, setShowCreate] = useState(false)
useEffect(() => {
load()
}, [year])
async function load() {
setLoading(true)
try {
const { data } = await api.get(`/api/holidays?year=${year}`)
setHolidays(data)
} catch (err) {
toast.error(err.response?.data?.detail || '載入失敗')
} finally {
setLoading(false)
}
}
async function handleRefresh() {
if (!confirm(`從 gov.hk 重新抓取 ${year} 年假期?\n(手動編輯嘅假期唔會被覆寫)`)) return
try {
const { data } = await api.post(`/api/holidays/refresh?years=${year}`)
toast.success(`${year} 已同步:新增 ${data.added} / 更新 ${data.updated} / 跳過 ${data.skipped}`)
load()
} catch (err) {
toast.error(err.response?.data?.detail || '同步失敗')
}
}
async function handleDelete(h) {
if (!confirm(`確定要刪除 ${h.date}${h.name}」?`)) return
try {
await api.delete(`/api/holidays/${h.id}`)
toast.success('已刪除')
load()
} catch (err) {
toast.error(err.response?.data?.detail || '刪除失敗')
}
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">年份:</label>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
>
{YEAR_OPTIONS.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div className="flex-1" />
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
>
<Plus size={14} /> 加公假
</button>
<button
onClick={handleRefresh}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<RefreshCw size={14} /> GovHK 同步
</button>
</div>
{/* Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-400">載入中...</div>
) : holidays.length === 0 ? (
<div className="p-8 text-center text-slate-400">{year} 年冇假期資料</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">名稱 ()</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">Name (EN)</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">備註</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{holidays.map(h => (
<tr key={h.id} className="hover:bg-slate-50">
<td className="px-4 py-2 font-mono text-slate-700">{h.date}</td>
<td className="px-4 py-2 font-medium text-slate-900">{h.name}</td>
<td className="px-4 py-2 text-slate-600">{h.name_en || '-'}</td>
<td className="px-4 py-2">
{h.is_mandatory ? (
<span className="inline-block px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700">法定</span>
) : (
<span className="inline-block px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600">銀行/學校</span>
)}
</td>
<td className="px-4 py-2 text-xs text-slate-500">{h.source || '-'}</td>
<td className="px-4 py-2 text-slate-500 text-xs">{h.notes || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => setEditing(h)}
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="編輯"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(h)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
title="刪除"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="text-xs text-slate-500 text-center">
{holidays.length} 個公眾假期 · 來源<a className="text-primary-600 underline" href="https://www.gov.hk/en/about/abouthk/holiday/" target="_blank" rel="noreferrer">gov.hk</a>
</div>
{(showCreate || editing) && (
<HolidayModal
holiday={editing}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
)}
</div>
)
}
// ============ Tab 2: 員工請假 ============
function LeavesTab() {
const [leaves, setLeaves] = useState([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [filterType, setFilterType] = useState('')
const [filterEmp, setFilterEmp] = useState('')
const [filterFrom, setFilterFrom] = useState('')
const [filterTo, setFilterTo] = useState('')
const fileInputRef = useRef(null)
useEffect(() => {
load()
}, [])
async function load() {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterType) params.set('leave_type', filterType)
if (filterEmp) params.set('employee_name', filterEmp)
if (filterFrom) params.set('date_from', filterFrom)
if (filterTo) params.set('date_to', filterTo)
const { data } = await api.get(`/api/leave?${params}`)
setLeaves(data)
} catch (err) {
toast.error(err.response?.data?.detail || '載入失敗')
} finally {
setLoading(false)
}
}
async function handleDelete(lv) {
if (!confirm(`確定要刪除 ${lv.employee_name} ${lv.date} ${lv.leave_type}${lv.hours}h`)) return
try {
await api.delete(`/api/leave/${lv.id}`)
toast.success('已刪除')
load()
} catch (err) {
toast.error(err.response?.data?.detail || '刪除失敗')
}
}
async function handleUpload(e) {
const f = e.target.files?.[0]
if (!f) return
const fd = new FormData()
fd.append('file', f)
try {
const { data } = await api.post('/api/leave/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const errMsg = data.errors?.length ? `\n錯誤 (前 ${data.errors.length} 條):\n${data.errors.join('\n')}` : ''
toast.success(`✓ 新增 ${data.added} 條 / 跳過 ${data.skipped}${errMsg}`, { duration: 6000 })
load()
} catch (err) {
toast.error(err.response?.data?.detail || '上傳失敗')
} finally {
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
function downloadTemplate() {
const csv = 'employee_name,date,leave_type,hours,reason,approved_by\nJay Lam,2026-07-15,SL,2.0,睇醫生,\nJay Lam,2026-07-20,AL,8.0,Family trip,Manager A\n'
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'leave_template.csv'
a.click()
URL.revokeObjectURL(url)
}
// 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">
{/* Filters */}
<div className="bg-white border border-slate-200 rounded-lg p-4 flex flex-wrap items-center gap-3">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
>
<option value="">所有類型</option>
{LEAVE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input
type="date"
value={filterFrom}
onChange={(e) => setFilterFrom(e.target.value)}
placeholder="由"
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<input
type="date"
value={filterTo}
onChange={(e) => setFilterTo(e.target.value)}
placeholder="至"
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<button
onClick={load}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-200"
>
<Filter size={14} /> 套用
</button>
<button
onClick={() => { setFilterType(''); setFilterEmp(''); setFilterFrom(''); setFilterTo(''); setTimeout(load, 0) }}
className="inline-flex items-center gap-1 px-2 py-1.5 text-slate-500 hover:text-slate-700 text-sm"
>
<X size={14} />
</button>
<div className="flex-1" />
<button
onClick={downloadTemplate}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<Download size={14} /> CSV 範本
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 text-slate-700 rounded-md text-sm font-medium hover:bg-slate-50"
>
<Upload size={14} /> 上傳 CSV/Excel
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
onChange={handleUpload}
className="hidden"
/>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
>
<Plus size={14} /> 加請假
</button>
</div>
{/* Table */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
{loading ? (
<div className="p-8 text-center text-slate-400">載入中...</div>
) : leaves.length === 0 ? (
<div className="p-8 text-center text-slate-400">冇請假記錄</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">員工</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">日期</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">類型</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">時數</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">原因</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">審批人</th>
<th className="px-4 py-2.5 text-left font-medium text-slate-700">來源</th>
<th className="px-4 py-2.5 text-right font-medium text-slate-700">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{leaves.map(lv => (
<tr key={lv.id} className="hover:bg-slate-50">
<td className="px-4 py-2 font-medium text-slate-900">{lv.employee_name}</td>
<td className="px-4 py-2 font-mono text-slate-700">{lv.date}</td>
<td className="px-4 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
lv.leave_type === 'SL' ? 'bg-pink-100 text-pink-700' :
lv.leave_type === 'CL' ? 'bg-indigo-100 text-indigo-700' :
'bg-emerald-100 text-emerald-700'
}`}>
{LEAVE_TYPES.find(t => t.value === lv.leave_type)?.label || lv.leave_type}
</span>
</td>
<td className="px-4 py-2 text-right tabular-nums">{lv.hours}h</td>
<td className="px-4 py-2 text-slate-600 text-xs">{lv.reason || '-'}</td>
<td className="px-4 py-2 text-slate-600 text-xs">{lv.approved_by || '-'}</td>
<td className="px-4 py-2 text-xs text-slate-500">{lv.source || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => setEditing(lv)}
className="p-1 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="編輯"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(lv)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded ml-1"
title="刪除"
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="text-xs text-slate-500 text-center">
{leaves.length} 條請假記錄
</div>
{(showCreate || editing) && (
<LeaveModal
leave={editing}
employees={employees}
onClose={() => { setShowCreate(false); setEditing(null) }}
onSaved={() => { setShowCreate(false); setEditing(null); load() }}
/>
)}
</div>
)
}
// ============ Holiday Modal ============
function HolidayModal({ holiday, onClose, onSaved }) {
const isEdit = !!holiday
const [date, setDate] = useState(holiday?.date || '')
const [name, setName] = useState(holiday?.name || '')
const [nameEn, setNameEn] = useState(holiday?.name_en || '')
const [isMandatory, setIsMandatory] = useState(holiday?.is_mandatory ?? true)
const [notes, setNotes] = useState(holiday?.notes || '')
const [saving, setSaving] = useState(false)
async function handleSave() {
if (!date || !name) {
toast.error('日期同名稱係必填')
return
}
setSaving(true)
try {
if (isEdit) {
await api.put(`/api/holidays/${holiday.id}`, {
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
})
toast.success('已更新')
} else {
await api.post('/api/holidays', {
date, name, name_en: nameEn, is_mandatory: isMandatory, notes,
})
toast.success('已新增')
}
onSaved()
} catch (err) {
toast.error(err.response?.data?.detail || '儲存失敗')
} finally {
setSaving(false)
}
}
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">{isEdit ? '編輯假期' : '加公假'}</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>
<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>
<input
type="text"
value={name}
onChange={(e) => setName(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">Name (English)</label>
<input
type="text"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="The first day of January"
className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mandatory"
checked={isMandatory}
onChange={(e) => setIsMandatory(e.target.checked)}
className="rounded"
/>
<label htmlFor="mandatory" className="text-sm text-slate-700">
法定假日 (勞工假)
</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">備註</label>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
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>
</div>
</div>
)
}
// ============ 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')
const [hours, setHours] = useState(leave?.hours ?? 8)
const [reason, setReason] = useState(leave?.reason || '')
const [approvedBy, setApprovedBy] = useState(leave?.approved_by || '')
const [saving, setSaving] = useState(false)
// 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
}
const h = Number(hours)
if (isNaN(h) || h < 0 || h > 24) {
toast.error('時數必須 0-24')
return
}
setSaving(true)
try {
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 || '儲存失敗')
} finally {
setSaving(false)
}
}
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-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">
<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-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="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>
<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>
{/* 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 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 -1
View File
@@ -84,7 +84,7 @@ export default function RosterPage() {
return (
<div className="p-6">
<h1 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-6">班次管理 Roster</h1>
<h1 className="text-2xl font-bold mb-6">班次管理 Roster</h1>
{/* Upload Section */}
<div className="card p-6 mb-6">
+1 -1
View File
@@ -125,7 +125,7 @@ export default function UploadPage() {
{SECTIONS.map(s => (
<button
key={s.key}
onClick={() => setSection(s.key)}
onClick={() => { setSection(s.key); setInfo(null); loadSectionInfo() }}
className={`px-4 py-2 rounded-md font-medium transition-colors ${
section === s.key
? s.color === 'red' ? 'bg-red-600 text-white' : 'bg-blue-600 text-white'
-1225
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
# Daily log cleanup for AARS. Keep app/access for 7 days, error for 30 days.
# Add to crontab: 0 3 * * * /root/aars/scripts/logs-cleanup.sh
set -e
LOG_DIR="/root/aars/logs"
APP_DAYS=7
ERROR_DAYS=30
if [ ! -d "$LOG_DIR" ]; then
echo "$(date): $LOG_DIR not found, nothing to clean"
exit 0
fi
DELETED_APP=$(find "$LOG_DIR" -maxdepth 1 -name 'app-*.jsonl' -mtime +$APP_DAYS -delete -print | wc -l)
DELETED_ACCESS=$(find "$LOG_DIR" -maxdepth 1 -name 'access-*.jsonl' -mtime +$APP_DAYS -delete -print | wc -l)
DELETED_ERROR=$(find "$LOG_DIR" -maxdepth 1 -name 'error-*.jsonl' -mtime +$ERROR_DAYS -delete -print | wc -l)
echo "$(date): cleanup done — deleted app=$DELETED_APP access=$DELETED_ACCESS error=$DELETED_ERROR"