Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acbc767247 | |||
| f6712d9d0e | |||
| 262c44f6a1 | |||
| 3c855d521b | |||
| 5596ab2cb5 | |||
| a7dfd208d2 | |||
| a16e243da1 | |||
| 4a01b53541 | |||
| 404329d3fa | |||
| 811b9991c9 | |||
| f12a239e26 | |||
| fcc3246d8e | |||
| 3ba6617009 | |||
| 4bd6d0e7c8 | |||
| 7702fe4ae5 | |||
| d302fa9beb | |||
| 4b967f7920 | |||
| 8b378ec4c6 | |||
| 7dd53e0a5f | |||
| 33a5e3b2ad | |||
| 4c9db01213 | |||
| ef57d7cd59 | |||
| f41dad6127 | |||
| 6f2df815f5 | |||
| c1b1d6f35b | |||
| e531cea8a4 | |||
| 2d64d3f0b6 | |||
| 08ba4cbca9 | |||
| c07f40b28f | |||
| 2d9b73df9d | |||
| 9980f8d80a | |||
| 1e7d58486b | |||
| cca088261b | |||
| adc9e6451b | |||
| a34905661b | |||
| 89c14c7a72 | |||
| 1a60c71032 | |||
| d5d9d644a0 | |||
| 6f90d60b13 | |||
| 95814350c9 | |||
| 975791b9bd | |||
| 0d34b51918 | |||
| aca1c55072 | |||
| 4bcda0a907 | |||
| c0076a125c | |||
| 6c70b81df5 | |||
| 25ebf6da66 | |||
| 0cd8717320 | |||
| 347a862cae | |||
| 8e0d73c37e | |||
| bbc0048c24 |
+20
-2
@@ -1,5 +1,23 @@
|
||||
# Runtime data
|
||||
data/
|
||||
logs/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.pyc
|
||||
node_modules/
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Backups
|
||||
*.bak
|
||||
*.bak*
|
||||
main.py.bak*
|
||||
|
||||
@@ -127,3 +127,330 @@ 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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
logs/
|
||||
+14
-1
@@ -15,7 +15,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto")
|
||||
# bcrypt is the primary scheme (cross-version stable, OpenSSL-native).
|
||||
# sha256_crypt is kept as deprecated fallback so legacy hashes still
|
||||
# verify; on first successful login the password is re-hashed to bcrypt
|
||||
# via verify_and_update().
|
||||
pwd_context = CryptContext(schemes=["bcrypt", "sha256_crypt"], deprecated=["sha256_crypt"])
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
|
||||
@@ -34,6 +38,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, str | None]:
|
||||
"""Verify password and return new hash if rehash is needed.
|
||||
|
||||
Returns (ok, new_hash_or_none). Caller is responsible for committing
|
||||
the new hash to the DB when new_hash is not None.
|
||||
"""
|
||||
return pwd_context.verify_and_update(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
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")
|
||||
+3319
-629
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
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},
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""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)
|
||||
+205
-51
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Float, Boolean, DateTime, Date, Time, JSON, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, Date, JSON, ForeignKey, UniqueConstraint, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
@@ -6,89 +6,243 @@ 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") # admin, user
|
||||
role = Column(String(50), default="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 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)
|
||||
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"))
|
||||
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 Accident(Base):
|
||||
"""意外記錄 Accident Reports
|
||||
|
||||
IMPORTANT — Column naming convention (2026-07-19 cleanup):
|
||||
- submitter_email: The Google Form submitter's email (NOT the injured person)
|
||||
- injured_person: Name of the staff who got hurt (was: responsible_person)
|
||||
- injured_person_id: Employee ID (was: employee_id)
|
||||
|
||||
This naming makes it clear these are different concepts, preventing
|
||||
accidental joins with attendance_records.employee_name (which IS a real name).
|
||||
"""
|
||||
__tablename__ = "accidents"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(Date, nullable=False)
|
||||
time = Column(Time, nullable=True)
|
||||
time = Column(String(10), nullable=True)
|
||||
location = Column(String(255), nullable=False)
|
||||
employee_name = Column(String(255), nullable=False)
|
||||
employee_id = Column(String(100))
|
||||
submitter_email = Column(String(255), nullable=False)
|
||||
injured_person_id = Column(String(100))
|
||||
department = Column(String(100))
|
||||
description = Column(Text, nullable=False)
|
||||
severity = Column(String(50), nullable=False) # minor, moderate, serious, fatal
|
||||
severity = Column(String(50), nullable=False)
|
||||
medical_report = Column(Text, nullable=True)
|
||||
action_taken = Column(Text, nullable=True)
|
||||
responsible_person = Column(String(255), nullable=True)
|
||||
injured_person = Column(String(255), nullable=True)
|
||||
|
||||
# Excel-derived (Google Form) extra columns (full map) -- 2026-07-18
|
||||
patient_or_staff = Column(Text, nullable=True) # Excel col 8
|
||||
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)
|
||||
|
||||
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) # Soft delete
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class AttendanceCustomField(Base):
|
||||
__tablename__ = "attendance_custom_fields"
|
||||
|
||||
# ============ NEW MODELS ============
|
||||
|
||||
class Shift(Base):
|
||||
"""班次定義"""
|
||||
__tablename__ = "shifts"
|
||||
|
||||
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)
|
||||
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 AccidentCustomField(Base):
|
||||
__tablename__ = "accident_custom_fields"
|
||||
|
||||
class AttendanceRecord(Base):
|
||||
"""出勤記錄"""
|
||||
__tablename__ = "attendance_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('employee_name', 'date', name='uix_employee_date'),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# 員工信息
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
|
||||
|
||||
class LeaveRecord(Base):
|
||||
"""員工請假 / Employee Leave Records
|
||||
|
||||
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)
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""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)
|
||||
@@ -3,7 +3,8 @@ uvicorn[standard]==0.30.6
|
||||
sqlalchemy==2.0.35
|
||||
pydantic==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib==1.7.4
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.12
|
||||
openpyxl==3.1.5
|
||||
reportlab==4.2.5
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/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 "=========================================="
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/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/"
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- "18775:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- TZ=Asia/Hong_Kong
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
logs/
|
||||
@@ -7,7 +7,10 @@
|
||||
<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>
|
||||
|
||||
Generated
+3380
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"name": "aars-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
@@ -26,4 +31,4 @@
|
||||
"tailwindcss": "^3.4.10",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,13 @@ 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()
|
||||
@@ -43,10 +46,13 @@ export default function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="attendance" element={<AttendanceList />} />
|
||||
<Route path="attendance/:id" element={<AttendanceDetail />} />
|
||||
<Route path="accident" element={<AccidentList />} />
|
||||
<Route path="accident" element={<AccidentDashboard />} />
|
||||
<Route path="accident/list" 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>
|
||||
)
|
||||
|
||||
+25
-4
@@ -1,29 +1,50 @@
|
||||
import axios from 'axios'
|
||||
import { setCurrentRequestId } from './lib/logger.js'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Request interceptor to add auth token
|
||||
// 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
|
||||
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 for error handling
|
||||
// Response interceptor: capture server's X-Request-ID for log correlation + 401 handling
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
response => {
|
||||
const rid = response.headers['x-request-id']
|
||||
if (rid) setCurrentRequestId(rid)
|
||||
return response
|
||||
},
|
||||
error => {
|
||||
const rid = error.response?.headers?.['x-request-id']
|
||||
if (rid) setCurrentRequestId(rid)
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('aars_token')
|
||||
window.location.href = '/login'
|
||||
// Avoid loop if already on /login
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,44 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar } from 'lucide-react'
|
||||
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree, Settings as SettingsIcon } from 'lucide-react'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
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-50">
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<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>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -25,71 +47,35 @@ export default function Layout() {
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">AARS</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
<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_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>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<NavLink
|
||||
to="/upload"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
`hidden sm: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'
|
||||
@@ -99,9 +85,9 @@ export default function Layout() {
|
||||
<Upload size={16} />
|
||||
<span className="hidden sm:inline">上傳</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-slate-200">
|
||||
<div className="text-right hidden sm:block">
|
||||
|
||||
<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="text-sm font-medium text-slate-900">{user?.name}</div>
|
||||
<div className="text-xs text-slate-500">{user?.role}</div>
|
||||
</div>
|
||||
@@ -113,13 +99,95 @@ 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-8">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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'))
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 }
|
||||
+13
-6
@@ -5,14 +5,21 @@ 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>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster position="bottom-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster position="bottom-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
+608
-315
@@ -1,355 +1,648 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api'
|
||||
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']
|
||||
import toast from 'react-hot-toast'
|
||||
import { Users, AlertTriangle, Clock, CheckCircle, XCircle, Calendar, TrendingUp, ArrowRight, Award, AlertCircle, Clock3, Download, FileSpreadsheet, FileBarChart } from 'lucide-react'
|
||||
|
||||
export default function Dashboard() {
|
||||
// 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 [summary, setSummary] = useState(null)
|
||||
const [stats, setStats] = useState(null)
|
||||
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 [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 }))
|
||||
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 }))
|
||||
])
|
||||
|
||||
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)))
|
||||
}
|
||||
setSummary(sumRes.data)
|
||||
setStats(statRes.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
toast.error('載入失敗')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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>}
|
||||
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))
|
||||
// Use backend's pre-computed attendance_rate = (total - missing) / total * 100
|
||||
// (handles missing records correctly; a record can still be late/early/ot
|
||||
// and count as attended)
|
||||
const attendanceRate = [...byStaff]
|
||||
.map(s => ({ ...s, rate: s.attendance_rate ?? 0 }))
|
||||
.sort((a, b) => b.rate - a.rate)
|
||||
|
||||
const statCards = summary ? [
|
||||
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
|
||||
{ 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>
|
||||
<Icon size={20} className={color.replace('border-l-', 'text-')} />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
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="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">
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
{/* ========== 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" />
|
||||
{/* 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="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 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>
|
||||
|
||||
{/* 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>
|
||||
{(dateFrom || dateTo) && (
|
||||
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
|
||||
)}
|
||||
</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" />
|
||||
{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="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 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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400">暫無數據</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, FileCode } from 'lucide-react'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
const handleExportHTML = (section) => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
if (token) {
|
||||
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
export default function AttendanceList() {
|
||||
const [records, setRecords] = useState([])
|
||||
const [headers, setHeaders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState(null)
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
}, [statusFilter])
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = { page: 1, per_page: 1000 }
|
||||
if (statusFilter) params.status_text = statusFilter
|
||||
const { data } = await api.get('/api/attendance/records', { params })
|
||||
setRecords(data.records || [])
|
||||
|
||||
if (data.records && data.records.length > 0) {
|
||||
const keys = Object.keys(data.records[0]).filter(k => k !== '_row')
|
||||
setHeaders(keys)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to load records')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filtered and sorted data (client-side: search + sort only; status handled by backend)
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...records]
|
||||
|
||||
// Search
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
result = result.filter(r =>
|
||||
Object.values(r).some(v =>
|
||||
v && String(v).toLowerCase().includes(searchLower)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortKey) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[sortKey] || ''
|
||||
const bVal = b[sortKey] || ''
|
||||
const aNum = Number(aVal)
|
||||
const bNum = Number(bVal)
|
||||
|
||||
let cmp = 0
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
cmp = aNum - bNum
|
||||
} else {
|
||||
cmp = String(aVal).localeCompare(String(bVal))
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [records, search, sortKey, sortDir])
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortKey !== key) return null
|
||||
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleExportHTML('attendance')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-700 text-white text-sm rounded-md hover:bg-slate-800"
|
||||
>
|
||||
<FileCode size={16} />
|
||||
匯出 HTML Report
|
||||
</button>
|
||||
<Link
|
||||
to="/upload"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
上傳 Excel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜尋所有欄位..."
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter (basic categories — server-side) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">全部狀態</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="abnormal">異常</option>
|
||||
<option value="missing">缺勤</option>
|
||||
<option value="holiday">公假</option>
|
||||
<option value="leave">請假</option>
|
||||
</select>
|
||||
|
||||
{statusFilter && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"
|
||||
title="清除篩選"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
{statusFilter && (
|
||||
<span className="ml-2 text-slate-400">• 已篩選:{statusFilter}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600 w-12">#</th>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
|
||||
onClick={() => handleSort(h)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{h}
|
||||
{getSortIcon(h)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-slate-600 w-20">狀態</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : processedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => (
|
||||
<tr
|
||||
key={record._row}
|
||||
className="hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => window.location.href = `/attendance/${record._row}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
|
||||
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-center">
|
||||
<StatusBadge
|
||||
code={record.status_code}
|
||||
text={record.status_text}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
to={`/attendance/${record._row}`}
|
||||
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info, KeyRound, User as UserIcon } from "lucide-react"
|
||||
import api from "../api"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
const RESET_SECTIONS = [
|
||||
{
|
||||
key: "attendance",
|
||||
label: "出勤 Attendance",
|
||||
description: "刪除所有出勤記錄(attendance_records 全表清空)",
|
||||
color: "red",
|
||||
},
|
||||
{
|
||||
key: "accident",
|
||||
label: "意外 Accident",
|
||||
description: "刪除所有意外記錄(accidents 全表清空)",
|
||||
color: "orange",
|
||||
},
|
||||
{
|
||||
key: "holidays",
|
||||
label: "假期 Holidays",
|
||||
description: "刪除所有公眾假期記錄",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
key: "leaves",
|
||||
label: "請假 Leaves",
|
||||
description: "刪除所有員工請假記錄",
|
||||
color: "lime",
|
||||
},
|
||||
]
|
||||
|
||||
export default function Settings() {
|
||||
const [confirmText, setConfirmText] = useState({})
|
||||
const [loading, setLoading] = useState({})
|
||||
const [result, setResult] = useState({})
|
||||
const [version, setVersion] = useState(null)
|
||||
const [backups, setBackups] = useState([])
|
||||
const [loadingBackups, setLoadingBackups] = useState(false)
|
||||
const [creatingBackup, setCreatingBackup] = useState(false)
|
||||
const [restoring, setRestoring] = useState(null)
|
||||
const [restoreConfirm, setRestoreConfirm] = useState("")
|
||||
|
||||
// User management (admin only)
|
||||
const [users, setUsers] = useState([])
|
||||
const [loadingUsers, setLoadingUsers] = useState(false)
|
||||
const [resetTarget, setResetTarget] = useState(null)
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [resetError, setResetError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersion()
|
||||
fetchBackups()
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoadingUsers(true)
|
||||
try {
|
||||
const { data } = await api.get("/api/auth/users")
|
||||
setUsers(data || [])
|
||||
} catch (e) {
|
||||
// 403 if not admin — leave list empty
|
||||
setUsers([])
|
||||
} finally {
|
||||
setLoadingUsers(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openResetDialog = (u) => {
|
||||
setResetTarget(u)
|
||||
setNewPassword("")
|
||||
setResetError(null)
|
||||
}
|
||||
|
||||
const closeResetDialog = () => {
|
||||
if (resetting) return
|
||||
setResetTarget(null)
|
||||
setNewPassword("")
|
||||
setResetError(null)
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!resetTarget || !newPassword) return
|
||||
if (newPassword.length < 6) {
|
||||
setResetError("密碼至少需要 6 個字元")
|
||||
return
|
||||
}
|
||||
setResetting(true)
|
||||
setResetError(null)
|
||||
try {
|
||||
await api.post("/api/auth/reset-password", {
|
||||
email: resetTarget.email,
|
||||
new_password: newPassword,
|
||||
})
|
||||
toast.success(`已重設 ${resetTarget.email} 嘅密碼`)
|
||||
closeResetDialog()
|
||||
} catch (e) {
|
||||
setResetError(e.response?.data?.detail || e.message)
|
||||
} finally {
|
||||
setResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/api/version")
|
||||
setVersion(data)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBackups = async () => {
|
||||
setLoadingBackups(true)
|
||||
try {
|
||||
const { data } = await api.get("/api/admin/backup/list")
|
||||
setBackups(data || [])
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch backups:", e)
|
||||
} finally {
|
||||
setLoadingBackups(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setCreatingBackup(true)
|
||||
try {
|
||||
const { data } = await api.post("/api/admin/backup/create")
|
||||
toast.success(`Backup created: ${data.saved}`)
|
||||
fetchBackups()
|
||||
} catch (e) {
|
||||
toast.error("Backup failed: " + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setCreatingBackup(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (filename) => {
|
||||
if (restoreConfirm !== filename) {
|
||||
toast.error("Please type the filename to confirm restore")
|
||||
return
|
||||
}
|
||||
setRestoring(filename)
|
||||
try {
|
||||
const { data } = await api.post("/api/admin/backup/restore", { filename })
|
||||
toast.success(`Restored from ${filename}. Auto-backup: ${data.auto_backup}`)
|
||||
setRestoreConfirm("")
|
||||
fetchBackups()
|
||||
} catch (e) {
|
||||
toast.error("Restore failed: " + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (filename) => {
|
||||
const token = localStorage.getItem("aars_token")
|
||||
if (!token) return
|
||||
window.open(`/api/admin/backup/download/${filename}?token=${token}`, "_blank")
|
||||
}
|
||||
|
||||
const handleReset = async (section) => {
|
||||
const confirmVal = confirmText[section] || ""
|
||||
if (confirmVal !== section) {
|
||||
setResult((prev) => ({ ...prev, [section]: { error: `請輸入 "${section}" 以確認刪除` } }))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading((prev) => ({ ...prev, [section]: true }))
|
||||
setResult((prev) => ({ ...prev, [section]: null }))
|
||||
|
||||
try {
|
||||
const res = await api.post(`/api/admin/reset/${section}`, { confirm: section })
|
||||
setResult((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
success: `已刪除 ${res.data.deleted} 條記錄`,
|
||||
},
|
||||
}))
|
||||
setConfirmText((prev) => ({ ...prev, [section]: "" }))
|
||||
} catch (err) {
|
||||
setResult((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
error: err.response?.data?.detail || err.message || "刪除失敗",
|
||||
},
|
||||
}))
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, [section]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return "—"
|
||||
if (bytes < 1024) return bytes + " B"
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
||||
return (bytes / 1024 / 1024).toFixed(1) + " MB"
|
||||
}
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return "—"
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString("zh-HK", { timeZone: "Asia/Hong_Kong" })
|
||||
} catch { return iso }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
|
||||
{/* Version Info */}
|
||||
{version && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-5 py-4 flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-blue-900">
|
||||
AARS v{version.version}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700 mt-0.5">
|
||||
Commit: <code className="bg-blue-100 px-1 rounded">{version.commit?.slice(0, 8)}</code>
|
||||
{version.date && <> · {new Date(version.date).toLocaleString("zh-HK", {timeZone:"Asia/Hong_Kong"})}</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup / Restore */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-slate-600" />
|
||||
<h2 className="text-lg font-semibold text-slate-800">💾 資料庫 Backup / Restore</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={creatingBackup}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${creatingBackup ? "animate-spin" : ""}`} />
|
||||
{creatingBackup ? "建立中..." : "建立 Backup"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">手動建立快照,或從過往備份還原</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{loadingBackups ? (
|
||||
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中...</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="px-5 py-6 text-sm text-slate-400 text-center">尚無備份記錄</div>
|
||||
) : (
|
||||
backups.map((b) => (
|
||||
<div key={b.filename} className="px-5 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-800 truncate">{b.filename}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{formatDate(b.created)} · {formatSize(b.size)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleDownload(b.filename)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md transition-colors"
|
||||
title="Download backup"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> 下載
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(b.filename === restoreConfirm ? "" : b.filename)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-600 hover:text-amber-700 hover:bg-amber-50 rounded-md transition-colors"
|
||||
title="Restore from this backup"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" /> 還原
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`確定要刪除 ${b.filename}?`)) return
|
||||
try {
|
||||
await api.delete(`/api/admin/backup/${b.filename}`)
|
||||
toast.success(`已刪除 ${b.filename}`)
|
||||
fetchBackups()
|
||||
} catch (e) {
|
||||
toast.error("刪除失敗: " + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-500 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Delete backup"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> 刪除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore confirmation */}
|
||||
{restoreConfirm && (
|
||||
<div className="px-5 py-4 bg-amber-50 border-t border-amber-100">
|
||||
<p className="text-sm text-amber-800 mb-2">
|
||||
⚠️ 還原將覆蓋當前資料庫。輸入 <code className="font-mono bg-amber-100 px-1 rounded">{restoreConfirm}</code> 確認:
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 text-sm border border-amber-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder={"輸入檔案名稱確認"}
|
||||
value={restoreConfirm}
|
||||
onChange={(e) => setRestoreConfirm(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && restoreConfirm === restoreConfirm) handleRestore(restoreConfirm) }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRestore(restoreConfirm)}
|
||||
disabled={restoreConfirm !== restoreConfirm || restoring}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 rounded-md transition-colors"
|
||||
>
|
||||
{restoring ? "還原中..." : "確認還原"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRestoreConfirm("")}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Management — admin only */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5 text-slate-600" />
|
||||
<h2 className="text-lg font-semibold text-slate-800">使用者管理 User Management</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
重設使用者密碼。Admin 限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{loadingUsers && (
|
||||
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中…</div>
|
||||
)}
|
||||
{!loadingUsers && users.length === 0 && (
|
||||
<div className="px-5 py-6 text-sm text-slate-400 text-center">
|
||||
沒有使用者(或你唔係 admin)
|
||||
</div>
|
||||
)}
|
||||
{!loadingUsers && users.map((u) => (
|
||||
<div key={u.id} className="px-5 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
|
||||
<UserIcon className="w-4 h-4 text-slate-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-slate-800 truncate">
|
||||
{u.name || u.email}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate">
|
||||
{u.email}
|
||||
<span className={`ml-2 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
||||
u.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
{!u.is_active && (
|
||||
<span className="ml-1 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-100 text-red-700">
|
||||
inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openResetDialog(u)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 hover:bg-slate-50 hover:border-slate-400 rounded-md transition-colors"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
重設密碼
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{resetTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" onClick={closeResetDialog}>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h3 className="text-lg font-semibold text-slate-900">重設密碼</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
為 <span className="font-mono text-slate-700">{resetTarget.email}</span> 設定新密碼
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-5 space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
新密碼
|
||||
<input
|
||||
type="text"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value)
|
||||
setResetError(null)
|
||||
}}
|
||||
placeholder="至少 6 個字元"
|
||||
className="mt-1.5 block w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleResetPassword()
|
||||
if (e.key === 'Escape') closeResetDialog()
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{resetError && (
|
||||
<div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||||
❌ {resetError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={closeResetDialog}
|
||||
disabled={resetting}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetPassword}
|
||||
disabled={resetting || !newPassword}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
>
|
||||
{resetting ? "重設中…" : "確認重設"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-white border border-red-200 rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-red-100 bg-red-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<h2 className="text-lg font-semibold text-red-800">Danger Zone — 資料庫重置</h2>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
以下操作將永久刪除資料,無法復原。請確認後再執行。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{RESET_SECTIONS.map((section) => {
|
||||
const res = result[section.key]
|
||||
const isLoading = loading[section.key]
|
||||
|
||||
return (
|
||||
<div key={section.key} className="px-5 py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900">{section.label}</h3>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{section.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
className="w-36 px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-slate-400"
|
||||
placeholder={`輸入 "${section.key}"`}
|
||||
value={confirmText[section.key] || ""}
|
||||
onChange={(e) => {
|
||||
setConfirmText((prev) => ({ ...prev, [section.key]: e.target.value }))
|
||||
setResult((prev) => ({ ...prev, [section.key]: null }))
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleReset(section.key)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleReset(section.key)}
|
||||
disabled={isLoading || (confirmText[section.key] || "") !== section.key}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{isLoading ? "刪除中..." : "重置"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{res && (
|
||||
<div
|
||||
className={`mt-3 text-sm px-3 py-2 rounded-md ${
|
||||
res.error
|
||||
? "bg-red-50 text-red-700 border border-red-200"
|
||||
: "bg-green-50 text-green-700 border border-green-200"
|
||||
}`}
|
||||
>
|
||||
{res.error ? `❌ ${res.error}` : `✅ ${res.success}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -2,45 +2,67 @@ 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 } from 'lucide-react'
|
||||
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' },
|
||||
]
|
||||
|
||||
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(() => {
|
||||
fetchRecord()
|
||||
const numericId = parseInt(id, 10)
|
||||
if (isNaN(numericId) || numericId <= 0) {
|
||||
navigate('/accident/list', { replace: true })
|
||||
return
|
||||
}
|
||||
fetchRecord(numericId)
|
||||
}, [id])
|
||||
|
||||
const fetchRecord = async () => {
|
||||
const fetchRecord = async (recordId) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// 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')
|
||||
}
|
||||
const { data } = await api.get(`/api/accident/${recordId}`)
|
||||
setRecord(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching record:', err)
|
||||
setError('Failed to load record')
|
||||
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)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -59,7 +81,7 @@ export default function AccidentDetail() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/accident"
|
||||
to="/accident/list"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
@@ -67,9 +89,10 @@ export default function AccidentDetail() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card p-8 text-center">
|
||||
<div className="text-red-500">{error}</div>
|
||||
<AlertTriangle size={48} className="mx-auto text-red-400 mb-2" />
|
||||
<div className="text-red-500 mb-2">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/accident')}
|
||||
onClick={() => navigate('/accident/list')}
|
||||
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
返回列表
|
||||
@@ -79,49 +102,67 @@ 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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4 sm:mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/accident"
|
||||
to="/accident/list"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
<h1 className="text-xl sm: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>
|
||||
|
||||
{/* Record Details */}
|
||||
<div className="card">
|
||||
<div className="card overflow-hidden">
|
||||
<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">Excel 原始數據</div>
|
||||
<div className="text-sm text-slate-500">AARS 意外事故記錄</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{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}
|
||||
{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>
|
||||
</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>
|
||||
|
||||
@@ -1,79 +1,105 @@
|
||||
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, FileCode } from 'lucide-react'
|
||||
|
||||
const handleExportHTML = (section) => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
if (token) {
|
||||
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
|
||||
}
|
||||
}
|
||||
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, Settings2, RotateCcw } from 'lucide-react'
|
||||
|
||||
export default function AccidentList() {
|
||||
const navigate = useNavigate()
|
||||
const [records, setRecords] = useState([])
|
||||
const [headers, setHeaders] = useState([])
|
||||
const [excelHeaders, setExcelHeaders] = useState({}) // sql_col -> Excel chinese header
|
||||
const [columnOrder, setColumnOrder] = useState([]) // ordered SQL cols (Excel order)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState(null)
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
const [sortKey, setSortKey] = useState('date')
|
||||
const [sortDir, setSortDir] = useState('desc')
|
||||
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?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)
|
||||
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')
|
||||
}
|
||||
} 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)) {
|
||||
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== '' && bVal !== '') {
|
||||
cmp = aNum - bNum
|
||||
} else {
|
||||
cmp = String(aVal).localeCompare(String(bVal))
|
||||
@@ -81,7 +107,6 @@ export default function AccidentList() {
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [records, search, sortKey, sortDir, filterKey, filterValue])
|
||||
|
||||
@@ -109,18 +134,35 @@ 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-xl font-bold text-slate-900">意外記錄 Accident</h1>
|
||||
<h1 className="text-lg sm:text-xl font-bold text-slate-900">意外記錄 Accident</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<FileCode size={16} />
|
||||
匯出 HTML Report
|
||||
<Settings2 size={16} />
|
||||
欄位 ({displayedCols.length}/{columnOrder.filter(c => c !== 'id').length})
|
||||
</button>
|
||||
<Link
|
||||
to="/upload"
|
||||
@@ -131,10 +173,44 @@ 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
|
||||
@@ -145,8 +221,6 @@ 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
|
||||
@@ -155,11 +229,10 @@ export default function AccidentList() {
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">篩選欄位</option>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<option key={h} value={h}>{h}</option>
|
||||
{displayedCols.map(h => (
|
||||
<option key={h} value={h}>{displayName(h)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{filterKey && (
|
||||
<>
|
||||
<input
|
||||
@@ -175,8 +248,6 @@ export default function AccidentList() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
</div>
|
||||
@@ -190,14 +261,15 @@ 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>
|
||||
{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"
|
||||
{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"
|
||||
onClick={() => handleSort(h)}
|
||||
title={excelHeaders[h] || h}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{h}
|
||||
{displayName(h)}
|
||||
{getSortIcon(h)}
|
||||
</div>
|
||||
</th>
|
||||
@@ -208,32 +280,32 @@ export default function AccidentList() {
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
|
||||
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : processedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
|
||||
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => (
|
||||
<tr
|
||||
key={record._row}
|
||||
<tr
|
||||
key={record.id}
|
||||
className="hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => window.location.href = `/accident/${record._row}`}
|
||||
onClick={() => navigate(`/accident/${record.id}`)}
|
||||
>
|
||||
<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 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>
|
||||
))}
|
||||
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
to={`/accident/${record._row}`}
|
||||
to={`/accident/${record.id}`}
|
||||
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||||
>
|
||||
<Eye size={16} />
|
||||
|
||||
@@ -2,14 +2,16 @@ 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 } from 'lucide-react'
|
||||
import { ArrowLeft, Save, X } 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(() => {
|
||||
@@ -20,32 +22,111 @@ export default function AttendanceDetail() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// 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')
|
||||
}
|
||||
// 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,
|
||||
})
|
||||
} else {
|
||||
setError('No data available')
|
||||
setError('找不到記錄')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching record:', err)
|
||||
setError('Failed to load record')
|
||||
console.error('Error:', err)
|
||||
setError('載入失敗')
|
||||
} 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">
|
||||
@@ -57,24 +138,10 @@ export default function AttendanceDetail() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -84,41 +151,73 @@ 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-2xl font-bold text-slate-900">
|
||||
出勤記錄 #{id}
|
||||
<h1 className="text-xl font-bold text-slate-900">
|
||||
出勤記錄 - {record?.employee_name}
|
||||
</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">
|
||||
<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>
|
||||
<h2 className="font-semibold text-slate-700">記錄詳情</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{headers.map(key => (
|
||||
<div key={key} className="grid grid-cols-12">
|
||||
{displayFields.map(({ key, label }) => (
|
||||
<div key={key} className="grid grid-cols-12 items-center">
|
||||
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
|
||||
{key}
|
||||
{label}
|
||||
</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 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>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,231 +2,226 @@ import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock, FileCode } from 'lucide-react'
|
||||
|
||||
const handleExportHTML = (section) => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
if (token) {
|
||||
window.open(`/api/report/${section}/html?token=${token}`, '_blank')
|
||||
}
|
||||
}
|
||||
import { Search, ChevronUp, ChevronDown, Filter, X, Users, Calendar } from 'lucide-react'
|
||||
import StatusBadge from '../../components/StatusBadge'
|
||||
|
||||
export default function AttendanceList() {
|
||||
const [records, setRecords] = useState([])
|
||||
const [headers, setHeaders] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState(null)
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
const [filterKey, setFilterKey] = useState('')
|
||||
const [filterValue, setFilterValue] = useState('')
|
||||
const [latenessRecords, setLatenessRecords] = useState([])
|
||||
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([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
fetchLateness()
|
||||
}, [])
|
||||
fetchStatusTexts()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
|
||||
|
||||
const fetchStatusTexts = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/attendance/status_texts')
|
||||
setStatusOptions(data || [])
|
||||
} catch (e) { console.error('Failed to fetch status texts', e) }
|
||||
}
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
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)
|
||||
}
|
||||
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())
|
||||
} catch (err) {
|
||||
toast.error('Failed to load records')
|
||||
toast.error('載入失敗')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLateness = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/attendance/lateness/records')
|
||||
setLatenessRecords(data || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load lateness data:', err)
|
||||
}
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
fetchRecords()
|
||||
}
|
||||
|
||||
// Create a map of late records for quick lookup
|
||||
const latenessMap = useMemo(() => {
|
||||
const map = {}
|
||||
latenessRecords.forEach(rec => {
|
||||
const key = `${rec.staff}-${rec.date}`
|
||||
map[key] = rec
|
||||
})
|
||||
return map
|
||||
}, [latenessRecords])
|
||||
|
||||
// Filtered and sorted data
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...records]
|
||||
|
||||
// Filter
|
||||
if (filterKey && filterValue) {
|
||||
result = result.filter(r => {
|
||||
const val = String(r[filterKey] || '').toLowerCase()
|
||||
return val.includes(filterValue.toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
// Search
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
result = result.filter(r =>
|
||||
Object.values(r).some(v =>
|
||||
v && String(v).toLowerCase().includes(searchLower)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortKey) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[sortKey] || ''
|
||||
const bVal = b[sortKey] || ''
|
||||
const aNum = Number(aVal)
|
||||
const bNum = Number(bVal)
|
||||
|
||||
let cmp = 0
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
cmp = aNum - bNum
|
||||
} else {
|
||||
cmp = String(aVal).localeCompare(String(bVal))
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [records, search, sortKey, sortDir, filterKey, filterValue])
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
setSortBy(key)
|
||||
setSortOrder('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilter = (key) => {
|
||||
setFilterKey(key)
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const clearFilter = () => {
|
||||
setFilterKey('')
|
||||
setFilterValue('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortKey !== key) return null
|
||||
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
if (sortBy !== key) return null
|
||||
return sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
// Get lateness info for a record
|
||||
const getLatenessInfo = (record) => {
|
||||
// Find staff name and date columns
|
||||
const staffKey = Object.keys(record).find(k =>
|
||||
k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工')
|
||||
)
|
||||
const dateKey = Object.keys(record).find(k =>
|
||||
k.toLowerCase().includes('date') || k.toLowerCase().includes('日期')
|
||||
)
|
||||
|
||||
if (!staffKey || !dateKey) return null
|
||||
|
||||
const staff = record[staffKey]
|
||||
const date = record[dateKey]
|
||||
|
||||
if (!staff || !date) return null
|
||||
|
||||
const key = `${staff}-${String(date).slice(0, 10)}`
|
||||
return latenessMap[key]
|
||||
const getStatusBadge = (record) => {
|
||||
// StatusBadge now drives from status_code + status_text only (basic 5 categories).
|
||||
return <StatusBadge code={record.status_code} text={record.status_text} />
|
||||
}
|
||||
|
||||
const getRowClass = (code) => {
|
||||
// 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-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
|
||||
<h1 className="text-lg sm: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"
|
||||
>
|
||||
<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">
|
||||
上傳 Excel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Search */}
|
||||
<div className="card p-4 space-y-3">
|
||||
{/* Search row */}
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap gap-3 items-end">
|
||||
<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>
|
||||
|
||||
{/* Column Filter */}
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<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>
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={(e) => handleFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
value={staffFilter}
|
||||
onChange={(e) => { setStaffFilter(e.target.value); setPage(1) }}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<option value="">篩選欄位</option>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<option key={h} value={h}>{h}</option>
|
||||
<option value="">全部</option>
|
||||
{staffList.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{filterKey && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
placeholder="輸入篩選值..."
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
|
||||
/>
|
||||
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
{latenessRecords.length > 0 && (
|
||||
<span className="ml-2 text-orange-600">
|
||||
• {latenessRecords.length} 筆遲到
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div 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>
|
||||
</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>
|
||||
|
||||
@@ -236,81 +231,93 @@ 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 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)}
|
||||
<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"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{h}
|
||||
{getSortIcon(h)}
|
||||
{col.label}
|
||||
{getSortIcon(col.key)}
|
||||
</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>
|
||||
<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>
|
||||
</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 colSpan={10} className="px-3 py-8 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : processedData.length === 0 ? (
|
||||
) : records.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
|
||||
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => {
|
||||
const lateInfo = getLatenessInfo(record)
|
||||
const isLate = lateInfo && lateInfo.minutes_late > 0
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={record._row}
|
||||
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
|
||||
onClick={() => window.location.href = `/attendance/${record._row}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
|
||||
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isLate ? (
|
||||
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}>
|
||||
<AlertCircle size={14} />
|
||||
<span className="text-xs font-medium">{lateInfo.minutes_late}分</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1 text-green-600">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs">正常</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
to={`/attendance/${record._row}`}
|
||||
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
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>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,986 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export default function RosterPage() {
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">班次管理 Roster</h1>
|
||||
<h1 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-6">班次管理 Roster</h1>
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="card p-6 mb-6">
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function UploadPage() {
|
||||
{SECTIONS.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => { setSection(s.key); setInfo(null); loadSectionInfo() }}
|
||||
onClick={() => setSection(s.key)}
|
||||
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'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke test: /api/auth/users + /api/auth/reset-password."""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://localhost:8000"
|
||||
|
||||
def req(method, path, body=None, token=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = json.dumps(body).encode() if body else None
|
||||
r = urllib.request.Request(f"{BASE}{path}", data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=10) as resp:
|
||||
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode() or "{}")
|
||||
|
||||
# 1. Login
|
||||
code, body = req("POST", "/api/auth/login", {"email": "admin@aars.hk", "password": "admin123"})
|
||||
if code != 200:
|
||||
print(f"LOGIN FAILED: {code} {body}")
|
||||
sys.exit(1)
|
||||
token = body["access_token"]
|
||||
print(f"✅ login ok, token={token[:30]}...")
|
||||
|
||||
# 2. List users
|
||||
code, body = req("GET", "/api/auth/users", token=token)
|
||||
print(f"\n--- GET /api/auth/users (code={code}) ---")
|
||||
print(json.dumps(body, indent=2, ensure_ascii=False))
|
||||
|
||||
# 3. Reset password (no actual change, just smoke test)
|
||||
code, body = req("POST", "/api/auth/reset-password",
|
||||
{"email": "admin@aars.hk", "new_password": "admin123"},
|
||||
token=token)
|
||||
print(f"\n--- POST /api/auth/reset-password (code={code}) ---")
|
||||
print(json.dumps(body, indent=2, ensure_ascii=False))
|
||||
|
||||
# 4. Verify login still works
|
||||
code, body = req("POST", "/api/auth/login", {"email": "admin@aars.hk", "password": "admin123"})
|
||||
print(f"\n--- verify login (code={code}) ---")
|
||||
if code == 200:
|
||||
print("✅ login still works after reset")
|
||||
else:
|
||||
print(f"❌ login broken: {body}")
|
||||
Reference in New Issue
Block a user