Initial commit: AARS backend + frontend + holiday/leave phase 1+2
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
# Runtime data
|
||||
data/
|
||||
logs/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Backups
|
||||
*.bak
|
||||
*.bak*
|
||||
main.py.bak*
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install Node.js for frontend build + build deps for bcrypt
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first (for caching)
|
||||
COPY backend/requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy backend code
|
||||
COPY backend/ .
|
||||
|
||||
# Copy frontend source
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Build frontend
|
||||
RUN cd frontend && npm install && npm run build
|
||||
|
||||
# Move built frontend to static folder
|
||||
RUN mkdir -p /app/static && mv frontend/dist/* /app/static/
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,456 @@
|
||||
# AARS - Attendance & Accident Record System
|
||||
|
||||
出勤及意外記錄系統
|
||||
|
||||
## 🚀 快速開始
|
||||
|
||||
### 方式一:Docker 部署(推薦)
|
||||
|
||||
```bash
|
||||
# 复制到 VPS
|
||||
scp -r ~/aars root@187.127.116.15:/root/
|
||||
|
||||
# SSH 到 VPS
|
||||
ssh root@187.127.116.15
|
||||
|
||||
# 進入目录并运行
|
||||
cd /root/aars
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
访问:`http://187.127.116.15:18775`
|
||||
|
||||
### 方式二:本地开发
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python test_backend.py # 测试
|
||||
uvicorn main:app --reload --port 8000
|
||||
|
||||
# Frontend (另一个terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🔐 默认账户
|
||||
|
||||
- **Email:** admin@aars.hk
|
||||
- **Password:** admin123
|
||||
|
||||
⚠️ 首次登录后请立即修改密码!
|
||||
|
||||
## 📋 功能
|
||||
|
||||
### 出勤 Attendance
|
||||
- ✅ 列表视图 (Filter/Sort/分页)
|
||||
- ✅ 新增/编辑/删除记录
|
||||
- ✅ Excel 批量导入(自动去重)
|
||||
- ✅ 导出 Excel / PDF
|
||||
- ✅ 自定义字段
|
||||
|
||||
### 意外 Accident
|
||||
- ✅ 列表视图 (Filter/Sort/分页)
|
||||
- ✅ 新增/编辑/删除记录
|
||||
- ✅ Excel 批量导入(自动去重)
|
||||
- ✅ 导出 Excel / PDF
|
||||
- ✅ 自定义字段
|
||||
|
||||
### Dashboard
|
||||
- 📊 统计卡片(总计/今日/本月)
|
||||
- 📈 趋势图表
|
||||
- ⚠️ 最近意外列表
|
||||
|
||||
## 🏗️ 技术栈
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | React 18 + Vite + TailwindCSS |
|
||||
| Backend | FastAPI + SQLAlchemy |
|
||||
| Database | SQLite |
|
||||
| Charts | Recharts |
|
||||
| Excel | openpyxl + xlsxwriter |
|
||||
| PDF | ReportLab |
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
aars/
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI app
|
||||
│ ├── models.py # Database models
|
||||
│ ├── schemas.py # Pydantic schemas
|
||||
│ ├── auth.py # JWT auth
|
||||
│ ├── database.py # SQLite setup
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # React pages
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ └── context/ # Auth context
|
||||
│ └── ...
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── SPEC.md
|
||||
```
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
### 端口
|
||||
- 默认: `18775`
|
||||
- 修改: 编辑 `docker-compose.yml`
|
||||
|
||||
### JWT Secret
|
||||
- 开发: 自动生成
|
||||
- 生产: 修改 `backend/secret.py`
|
||||
|
||||
## 📝 API 文档
|
||||
|
||||
启动后访问:`http://localhost:8000/docs` (Swagger UI)
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
**Q: Docker build 失败?**
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Q: 无法登录?**
|
||||
检查浏览器 console 的 CORS 错误,或确认 JWT secret 未变更。
|
||||
|
||||
**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,377 @@
|
||||
# AARS - Attendance & Accident Record System
|
||||
|
||||
## 1. Concept & Vision
|
||||
|
||||
一個為中小企設計嘅出勤同意外記錄系統。介面乾淨、專業、易用,帶有香港本地特色(繁體中文、HKT時區)。兩大一頁式 dashboard,展示即時數據,所有記錄都可以 filter、sort、export。
|
||||
|
||||
目標係取代 Excel 試算表,做一個可以多人協作、數據唔會重複、report 一click 生成嘅系統。
|
||||
|
||||
---
|
||||
|
||||
## 2. Design Language
|
||||
|
||||
**Aesthetic:** 淺色專業商務風,白底配淺灰,帶有 System Blue 強調色。似 classic Notion / Linear 嗰種乾淨感覺,但再 warm 少少。
|
||||
|
||||
**Colors (TailwindCSS custom):**
|
||||
- Primary: `#2563EB` (blue-600)
|
||||
- Primary Dark: `#1D4ED8` (blue-700)
|
||||
- Accent: `#06B6D4` (cyan-500)
|
||||
- Background: `#F8FAFC` (slate-50)
|
||||
- Card: `#FFFFFF`
|
||||
- Border: `#E2E8F0` (slate-200)
|
||||
- Text Primary: `#0F172A` (slate-900)
|
||||
- Text Secondary: `#64748B` (slate-500)
|
||||
- Success: `#10B981` (emerald-500)
|
||||
- Warning: `#F59E0B` (amber-500)
|
||||
- Danger: `#EF4444` (red-500)
|
||||
|
||||
**Typography:**
|
||||
- Font: Inter (Google Fonts) + Noto Sans TC (繁體中文)
|
||||
- Headings: 600-700 weight
|
||||
- Body: 400-500 weight
|
||||
|
||||
**Spatial System:**
|
||||
- Base unit: 4px
|
||||
- Card padding: 24px
|
||||
- Section gap: 32px
|
||||
- Border radius: 8px (cards), 6px (buttons), 4px (inputs)
|
||||
|
||||
**Motion:**
|
||||
- Page transitions: fade 150ms
|
||||
- Hover states: 200ms ease
|
||||
- Loading: pulse animation
|
||||
- No bouncy/playful animations - keep it professional
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout & Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Header: Logo + Nav (Attendance | Accident) + User │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Page Content: │
|
||||
│ ┌─ Dashboard ─┐ ┌─ List View ─┐ ┌─ Detail ─┐ │
|
||||
│ │ Stats cards │ │ Table + Fn │ │ Edit form│ │
|
||||
│ │ Charts │ │ Filter/Sort │ │ History │ │
|
||||
│ └────────────┘ └─────────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Pages:**
|
||||
1. **Login** (`/login`) - Email + password,JWT auth
|
||||
2. **Dashboard** (`/`) - Stats cards + recent activity + quick actions
|
||||
3. **Attendance List** (`/attendance`) - Table with all records
|
||||
4. **Attendance Detail** (`/attendance/:id`) - View/Edit single record
|
||||
5. **Accident List** (`/accident`) - Table with all records
|
||||
6. **Accident Detail** (`/accident/:id`) - View/Edit single record
|
||||
7. **Import** (`/import`) - Excel upload + mapping + preview
|
||||
|
||||
**Responsive:** Desktop-first,但 table 可以 horizontal scroll 喺 mobile
|
||||
|
||||
---
|
||||
|
||||
## 4. Features & Interactions
|
||||
|
||||
### 4.1 Authentication
|
||||
- Login with email + password
|
||||
- JWT token stored in localStorage
|
||||
- Token expiry: 24 hours
|
||||
- Auto-redirect to login if expired
|
||||
|
||||
### 4.2 Dashboard
|
||||
**Attendance Section:**
|
||||
- Total records count
|
||||
- Today attendance count
|
||||
- This month attendance count
|
||||
- Records trend chart (last 7 days / 30 days)
|
||||
|
||||
**Accident Section:**
|
||||
- Total records count
|
||||
- This month accidents count
|
||||
- Unresolved accidents count
|
||||
- Recent accidents list (last 5)
|
||||
|
||||
### 4.3 List View
|
||||
- Paginated table (20 per page)
|
||||
- Columns: ID, Date, Employee Name, Department, Status, Actions
|
||||
- **Filter:** By date range, department, status, search text
|
||||
- **Sort:** Click column header to sort ASC/DESC
|
||||
- **Actions:** View, Edit, Delete (soft delete)
|
||||
- **Bulk Actions:** Select multiple → Delete / Export selected
|
||||
- **Export:** PDF (single/all selected) / Excel (all filtered results)
|
||||
|
||||
### 4.4 Detail / Edit View
|
||||
- Form with all fields
|
||||
- Read-only view mode vs Edit mode
|
||||
- **History tab:** Show who created/modified when
|
||||
- **Save:** Validates + saves to SQLite
|
||||
- **Cancel:** Returns to list without saving
|
||||
|
||||
### 4.5 Excel Import
|
||||
- Upload Excel file (.xlsx, .xls)
|
||||
- Auto-detect columns from first row
|
||||
- **Field mapping:** Map Excel columns to system fields
|
||||
- **Duplicate detection:** Check by unique key (Employee ID + Date for attendance, Date + Location for accident)
|
||||
- **Preview:** Show first 10 rows with validation status
|
||||
- **Options:**
|
||||
- "Update existing" → skip duplicates or update
|
||||
- "Create new only" → skip duplicates
|
||||
- **Import button:** Bulk insert/update
|
||||
- **Result:** Show success count, skipped (duplicates), errors
|
||||
|
||||
### 4.6 Field Management
|
||||
- Default fields per section (pre-defined)
|
||||
- **Add custom field:** Name, type (text/number/date/select), required
|
||||
- Custom fields stored in JSON column
|
||||
- Can be used in list view, detail view, export
|
||||
|
||||
### 4.7 Export
|
||||
- **Excel:** All filtered results, includes all fields
|
||||
- **PDF:** Formatted report with header, table, pagination
|
||||
- Export respects current filter/sort state
|
||||
|
||||
---
|
||||
|
||||
## 5. Component Inventory
|
||||
|
||||
### 5.1 Header
|
||||
- Logo (left): "AARS" text logo
|
||||
- Nav tabs (center): "出勤 Attendance" | "意外 Accident"
|
||||
- Active tab: blue underline
|
||||
- User menu (right): Username + dropdown (Profile, Logout)
|
||||
|
||||
### 5.2 Stats Card
|
||||
- Icon (colored circle bg) + metric value (large) + label (small)
|
||||
- Hover: subtle shadow lift
|
||||
|
||||
### 5.3 Data Table
|
||||
- Header row: grey bg, bold text
|
||||
- Sortable columns: sort icon, click to toggle
|
||||
- Row hover: light blue bg
|
||||
- Checkbox column for bulk select
|
||||
- Action buttons: View (blue), Edit (gray), Delete (red)
|
||||
- Empty state: "No records found" + illustration
|
||||
|
||||
### 5.4 Filter Bar
|
||||
- Date range picker (two inputs)
|
||||
- Dropdown for department/status
|
||||
- Text search input
|
||||
- "Clear filters" button
|
||||
- "Export" button
|
||||
|
||||
### 5.5 Form
|
||||
- Label + input pairs, stacked
|
||||
- Required indicator: red asterisk
|
||||
- Input states: default, focus (blue ring), error (red border + message)
|
||||
- Select dropdowns, date pickers
|
||||
- Submit button (blue), Cancel button (gray outline)
|
||||
|
||||
### 5.6 Modal
|
||||
- Centered overlay, white card
|
||||
- Header + body + footer
|
||||
- Close X button top right
|
||||
- Backdrop click to close
|
||||
|
||||
### 5.7 Toast Notifications
|
||||
- Success (green), Error (red), Info (blue)
|
||||
- Auto-dismiss after 3s
|
||||
- Bottom right corner
|
||||
|
||||
### 5.8 Import Wizard
|
||||
- Step 1: Upload Excel
|
||||
- Step 2: Map columns
|
||||
- Step 3: Preview + validate
|
||||
- Step 4: Confirm + import
|
||||
- Progress indicator at top
|
||||
|
||||
---
|
||||
|
||||
## 6. Technical Approach
|
||||
|
||||
### 6.1 Backend (FastAPI)
|
||||
```
|
||||
backend/
|
||||
├── main.py # FastAPI app entry
|
||||
├── database.py # SQLite + SQLAlchemy
|
||||
├── models.py # SQLAlchemy models
|
||||
├── schemas.py # Pydantic schemas
|
||||
├── auth.py # JWT auth
|
||||
├── routers/
|
||||
│ ├── attendance.py
|
||||
│ ├── accident.py
|
||||
│ ├── import.py
|
||||
│ └── export.py
|
||||
└── utils/
|
||||
├── excel.py # openpyxl helpers
|
||||
└── pdf.py # reportlab helpers
|
||||
```
|
||||
|
||||
**API Endpoints:**
|
||||
```
|
||||
POST /api/auth/login
|
||||
GET /api/auth/me
|
||||
|
||||
GET /api/attendance # List with filter/sort/paginate
|
||||
POST /api/attendance # Create
|
||||
GET /api/attendance/:id # Get one
|
||||
PUT /api/attendance/:id # Update
|
||||
DELETE /api/attendance/:id # Soft delete
|
||||
|
||||
GET /api/accident # List with filter/sort/paginate
|
||||
POST /api/accident # Create
|
||||
GET /api/accident/:id # Get one
|
||||
PUT /api/accident/:id # Update
|
||||
DELETE /api/accident/:id # Soft delete
|
||||
|
||||
POST /api/import/attendance # Excel import
|
||||
POST /api/import/accident # Excel import
|
||||
|
||||
GET /api/export/attendance/excel
|
||||
GET /api/export/attendance/pdf
|
||||
GET /api/export/accident/excel
|
||||
GET //api/export/accident/pdf
|
||||
|
||||
GET /api/dashboard/attendance # Stats
|
||||
GET /api/dashboard/accident # Stats
|
||||
|
||||
GET /api/fields/attendance # Custom fields
|
||||
POST /api/fields/attendance
|
||||
GET /api/fields/accident
|
||||
POST /api/fields/accident
|
||||
```
|
||||
|
||||
### 6.2 Database Schema
|
||||
|
||||
**Users:**
|
||||
```sql
|
||||
id, email, password_hash, name, role, created_at, updated_at, is_active
|
||||
```
|
||||
|
||||
**Attendance:**
|
||||
```sql
|
||||
id, employee_id, employee_name, department, date, check_in, check_out,
|
||||
overtime_hours, leave_type, leave_hours, status, remarks,
|
||||
custom_fields (JSON), created_by, created_at, updated_at, deleted_at
|
||||
```
|
||||
|
||||
**Accident:**
|
||||
```sql
|
||||
id, date, time, location, employee_name, employee_id, department,
|
||||
description, severity, medical_report, action_taken, responsible_person,
|
||||
custom_fields (JSON), created_by, created_at, updated_at, deleted_at
|
||||
```
|
||||
|
||||
**AttendanceCustomFields / AccidentCustomFields:**
|
||||
```sql
|
||||
id, field_name, field_type, required, options, order, created_at
|
||||
```
|
||||
|
||||
### 6.3 Frontend (React + Vite)
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── App.jsx
|
||||
│ ├── main.jsx
|
||||
│ ├── api.js # Axios API calls
|
||||
│ ├── context/
|
||||
│ │ └── AuthContext.jsx
|
||||
│ ├── pages/
|
||||
│ │ ├── Login.jsx
|
||||
│ │ ├── Dashboard.jsx
|
||||
│ │ ├── attendance/
|
||||
│ │ │ ├── List.jsx
|
||||
│ │ │ └── Detail.jsx
|
||||
│ │ ├── accident/
|
||||
│ │ │ ├── List.jsx
|
||||
│ │ │ └── Detail.jsx
|
||||
│ │ └── import/
|
||||
│ │ └── Import.jsx
|
||||
│ └── components/
|
||||
│ ├── Layout.jsx
|
||||
│ ├── DataTable.jsx
|
||||
│ ├── FilterBar.jsx
|
||||
│ ├── StatsCard.jsx
|
||||
│ ├── Modal.jsx
|
||||
│ └── Toast.jsx
|
||||
├── index.html
|
||||
├── tailwind.config.js
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
### 6.4 Docker Setup
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
aars:
|
||||
build: .
|
||||
ports:
|
||||
- "18775:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- TIMEZONE=Asia/Hong_Kong
|
||||
- SECRET_KEY=<generated>
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 6.5 Key Libraries
|
||||
- **Backend:** fastapi, uvicorn, sqlalchemy, pydantic, python-jose, passlib, openpyxl, reportlab
|
||||
- **Frontend:** react, react-router-dom, axios, tailwindcss, recharts (charts), react-hot-toast
|
||||
|
||||
---
|
||||
|
||||
## 7. Default Fields
|
||||
|
||||
### Attendance Default Fields
|
||||
| Field | Type | Required |
|
||||
|-------|------|----------|
|
||||
| employee_id | text | Yes |
|
||||
| employee_name | text | Yes |
|
||||
| department | select | Yes |
|
||||
| date | date | Yes |
|
||||
| check_in | time | No |
|
||||
| check_out | time | No |
|
||||
| overtime_hours | number | No |
|
||||
| leave_type | select | No |
|
||||
| leave_hours | number | No |
|
||||
| status | select | Yes |
|
||||
| remarks | text | No |
|
||||
|
||||
### Accident Default Fields
|
||||
| Field | Type | Required |
|
||||
|-------|------|----------|
|
||||
| date | date | Yes |
|
||||
| time | time | Yes |
|
||||
| location | text | Yes |
|
||||
| employee_name | text | Yes |
|
||||
| employee_id | text | Yes |
|
||||
| department | select | Yes |
|
||||
| description | text | Yes |
|
||||
| severity | select | Yes |
|
||||
| medical_report | text | No |
|
||||
| action_taken | text | No |
|
||||
| responsible_person | text | No |
|
||||
|
||||
---
|
||||
|
||||
## 8. Security
|
||||
|
||||
- Passwords hashed with bcrypt
|
||||
- JWT with 24h expiry
|
||||
- CORS configured for known origins only
|
||||
- Input sanitization on all endpoints
|
||||
- Soft delete (never hard delete user data)
|
||||
- Rate limiting on login endpoint
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of Scope (v1)
|
||||
|
||||
- User management (create/edit users) - only admin can do this via direct DB
|
||||
- Multi-company support
|
||||
- Recurring attendance (scheduled check-in reminders)
|
||||
- Document/photo upload for accidents
|
||||
- Email notifications
|
||||
- Mobile responsive redesign
|
||||
- API for third-party integrations
|
||||
@@ -0,0 +1 @@
|
||||
logs/
|
||||
@@ -0,0 +1,72 @@
|
||||
from fastapi import Depends, HTTPException, status, Query
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError, jwt
|
||||
from database import get_db, SECRET_KEY, ALGORITHM
|
||||
from models import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Validate JWT token and return current user."""
|
||||
token = credentials.credentials
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id_str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Ensure current user is an admin."""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_user_from_query(
|
||||
token: str = Query(...),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Validate JWT token from query parameter and return current user."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id_str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
user_id = int(user_id_str)
|
||||
except (JWTError, ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, Float, Boolean, DateTime, Date, Time, JSON
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from passlib.context import CryptContext
|
||||
from jose import jwt
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
import secret
|
||||
|
||||
# Database setup
|
||||
DATABASE_URL = "sqlite:///./data/aars.db"
|
||||
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")
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
expire = datetime.now(timezone.utc).timestamp() + (ACCESS_TOKEN_EXPIRE_HOURS * 3600)
|
||||
to_encode = data.copy()
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database with tables and default admin user."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check if admin exists
|
||||
from models import User
|
||||
admin = db.query(User).filter(User.email == "admin@aars.hk").first()
|
||||
if not admin:
|
||||
# Create default admin
|
||||
admin = User(
|
||||
email="admin@aars.hk",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
name="系統管理員",
|
||||
role="admin",
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print("✅ Default admin user created: admin@aars.hk / admin123")
|
||||
else:
|
||||
print("✅ Admin user already exists")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -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")
|
||||
+3318
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)
|
||||
@@ -0,0 +1,215 @@
|
||||
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
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def now_hkt():
|
||||
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")
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class Accident(Base):
|
||||
__tablename__ = "accidents"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(Date, nullable=False)
|
||||
time = Column(String(10), nullable=True)
|
||||
location = Column(String(255), nullable=False)
|
||||
employee_name = Column(String(255), nullable=False)
|
||||
employee_id = Column(String(100))
|
||||
department = Column(String(100))
|
||||
description = Column(Text, nullable=False)
|
||||
severity = Column(String(50), nullable=False)
|
||||
medical_report = Column(Text, nullable=True)
|
||||
action_taken = Column(Text, nullable=True)
|
||||
responsible_person = Column(String(255), nullable=True)
|
||||
|
||||
# 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"))
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
# ============ NEW MODELS ============
|
||||
|
||||
class Shift(Base):
|
||||
"""班次定義"""
|
||||
__tablename__ = "shifts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shift_code = Column(String(20), unique=True, nullable=False, index=True)
|
||||
description = Column(String(100), nullable=True)
|
||||
|
||||
# 每天的上班時間 (HH:MM format as String)
|
||||
mon_start = Column(String(5), nullable=True)
|
||||
mon_end = Column(String(5), nullable=True)
|
||||
tue_start = Column(String(5), nullable=True)
|
||||
tue_end = Column(String(5), nullable=True)
|
||||
wed_start = Column(String(5), nullable=True)
|
||||
wed_end = Column(String(5), nullable=True)
|
||||
thu_start = Column(String(5), nullable=True)
|
||||
thu_end = Column(String(5), nullable=True)
|
||||
fri_start = Column(String(5), nullable=True)
|
||||
fri_end = Column(String(5), nullable=True)
|
||||
sat_start = Column(String(5), nullable=True)
|
||||
sat_end = Column(String(5), nullable=True)
|
||||
sun_start = Column(String(5), nullable=True)
|
||||
sun_end = Column(String(5), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
|
||||
def get_schedule(self, weekday: str):
|
||||
"""取得指定星期幾的上班時間 (start, end)"""
|
||||
day_map = {
|
||||
'monday': ('mon_start', 'mon_end'),
|
||||
'tuesday': ('tue_start', 'tue_end'),
|
||||
'wednesday': ('wed_start', 'wed_end'),
|
||||
'thursday': ('thu_start', 'thu_end'),
|
||||
'friday': ('fri_start', 'fri_end'),
|
||||
'saturday': ('sat_start', 'sat_end'),
|
||||
'sunday': ('sun_start', 'sun_end'),
|
||||
# 中文
|
||||
'星期一': ('mon_start', 'mon_end'),
|
||||
'星期二': ('tue_start', 'tue_end'),
|
||||
'星期三': ('wed_start', 'wed_end'),
|
||||
'星期四': ('thu_start', 'thu_end'),
|
||||
'星期五': ('fri_start', 'fri_end'),
|
||||
'星期六': ('sat_start', 'sat_end'),
|
||||
'星期日': ('sun_start', 'sun_end'),
|
||||
'星期天': ('sun_start', 'sun_end'),
|
||||
}
|
||||
key = weekday.lower().strip()
|
||||
if key not in day_map:
|
||||
return None, None
|
||||
start_attr, end_attr = day_map[key]
|
||||
start = getattr(self, start_attr, None)
|
||||
end = getattr(self, end_attr, None)
|
||||
# 休息標記
|
||||
if start in ('-', '休息', None, ''):
|
||||
return None, None
|
||||
if end in ('-', '休息', None, ''):
|
||||
return None, None
|
||||
return start, end
|
||||
|
||||
|
||||
class AttendanceRecord(Base):
|
||||
"""出勤記錄"""
|
||||
__tablename__ = "attendance_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('employee_name', 'date', name='uix_employee_date'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 員工信息
|
||||
employee_name = Column(String(255), nullable=False, index=True)
|
||||
company = Column(String(100), nullable=True)
|
||||
department = Column(String(100), nullable=True)
|
||||
|
||||
# 日期
|
||||
date = Column(Date, nullable=False, index=True)
|
||||
weekday = Column(String(20), nullable=True)
|
||||
|
||||
# 打卡時間
|
||||
check_in = Column(DateTime, nullable=True)
|
||||
check_out = Column(DateTime, nullable=True)
|
||||
|
||||
# 班次
|
||||
shift_code = Column(String(20), nullable=True)
|
||||
|
||||
# 計算結果
|
||||
status_code = Column(String(20), nullable=False, default='normal')
|
||||
status_text = Column(String(100), nullable=True)
|
||||
late_minutes = Column(Integer, default=0)
|
||||
early_minutes = Column(Integer, default=0)
|
||||
ot_minutes = Column(Integer, default=0)
|
||||
|
||||
# Expected times
|
||||
expected_in = Column(String(10), nullable=True)
|
||||
expected_out = Column(String(10), nullable=True)
|
||||
actual_in = Column(String(10), nullable=True)
|
||||
actual_out = Column(String(10), nullable=True)
|
||||
|
||||
# 人手修改標記
|
||||
is_manually_edited = Column(Boolean, default=False)
|
||||
|
||||
# 原始 Excel 數據
|
||||
raw_data = Column(JSON, default={})
|
||||
|
||||
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)
|
||||
|
||||
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.
|
||||
"""
|
||||
__tablename__ = "leave_records"
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
sqlalchemy==2.0.35
|
||||
pydantic==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.12
|
||||
openpyxl==3.1.5
|
||||
reportlab==4.2.5
|
||||
xlsxwriter==3.2.0
|
||||
aiofiles==24.1.0
|
||||
@@ -0,0 +1,187 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import date, time, datetime
|
||||
|
||||
|
||||
# ============ Auth ============
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Attendance ============
|
||||
class AttendanceBase(BaseModel):
|
||||
employee_id: str
|
||||
employee_name: str
|
||||
department: Optional[str] = None
|
||||
date: date
|
||||
check_in: Optional[time] = None
|
||||
check_out: Optional[time] = None
|
||||
overtime_hours: float = 0
|
||||
leave_type: Optional[str] = None
|
||||
leave_hours: float = 0
|
||||
status: str
|
||||
remarks: Optional[str] = None
|
||||
custom_fields: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class AttendanceCreate(AttendanceBase):
|
||||
pass
|
||||
|
||||
|
||||
class AttendanceUpdate(BaseModel):
|
||||
employee_id: Optional[str] = None
|
||||
employee_name: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
date: Optional[date] = None
|
||||
check_in: Optional[time] = None
|
||||
check_out: Optional[time] = None
|
||||
overtime_hours: Optional[float] = None
|
||||
leave_type: Optional[str] = None
|
||||
leave_hours: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
remarks: Optional[str] = None
|
||||
custom_fields: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AttendanceResponse(AttendanceBase):
|
||||
id: int
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Accident ============
|
||||
class AccidentBase(BaseModel):
|
||||
date: date
|
||||
time: Optional[time] = None
|
||||
location: str
|
||||
employee_name: str
|
||||
employee_id: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
description: str
|
||||
severity: str
|
||||
medical_report: Optional[str] = None
|
||||
action_taken: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
custom_fields: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class AccidentCreate(AccidentBase):
|
||||
pass
|
||||
|
||||
|
||||
class AccidentUpdate(BaseModel):
|
||||
date: Optional[date] = None
|
||||
time: Optional[time] = None
|
||||
location: Optional[str] = None
|
||||
employee_name: Optional[str] = None
|
||||
employee_id: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
medical_report: Optional[str] = None
|
||||
action_taken: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
custom_fields: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AccidentResponse(AccidentBase):
|
||||
id: int
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Custom Fields ============
|
||||
class CustomFieldBase(BaseModel):
|
||||
field_name: str
|
||||
field_type: str
|
||||
field_key: str
|
||||
required: bool = False
|
||||
options: List[str] = []
|
||||
order: int = 0
|
||||
|
||||
|
||||
class CustomFieldCreate(CustomFieldBase):
|
||||
pass
|
||||
|
||||
|
||||
class CustomFieldResponse(CustomFieldBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Dashboard ============
|
||||
class AttendanceStats(BaseModel):
|
||||
total: int
|
||||
today: int
|
||||
this_month: int
|
||||
by_status: Dict[str, int]
|
||||
by_department: Dict[str, int]
|
||||
trend: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class AccidentStats(BaseModel):
|
||||
total: int
|
||||
this_month: int
|
||||
unresolved: int
|
||||
by_severity: Dict[str, int]
|
||||
recent: List[AccidentResponse]
|
||||
|
||||
|
||||
# ============ Import ============
|
||||
class ImportRequest(BaseModel):
|
||||
section: str # "attendance" or "accident"
|
||||
update_existing: bool = True
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
created: int
|
||||
updated: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
# ============ Pagination / Filter ============
|
||||
class FilterParams(BaseModel):
|
||||
page: int = 1
|
||||
per_page: int = 20
|
||||
sort_by: Optional[str] = None
|
||||
sort_order: Optional[str] = "asc" # asc or desc
|
||||
search: Optional[str] = None
|
||||
date_from: Optional[date] = None
|
||||
date_to: Optional[date] = None
|
||||
department: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
items: List[Any]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
total_pages: int
|
||||
@@ -0,0 +1,2 @@
|
||||
# AARS JWT Secret - CHANGE THIS IN PRODUCTION!
|
||||
JWT_SECRET = "aars-jwt-secret-2026-prod-do-not-share"
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick test script for AARS backend - run locally without Docker"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
# Generate secret if missing
|
||||
if not os.path.exists("secret.py"):
|
||||
with open("secret.py", "w") as f:
|
||||
f.write(f"JWT_SECRET = 'dev-secret-{os.urandom(16).hex()}'\n")
|
||||
|
||||
# Import and run
|
||||
from database import init_db, SessionLocal
|
||||
from models import User
|
||||
|
||||
print("🧪 Testing AARS Backend...")
|
||||
print()
|
||||
|
||||
# Test 1: Database init
|
||||
print("1️⃣ Testing database initialization...")
|
||||
try:
|
||||
init_db()
|
||||
print(" ✅ Database initialized")
|
||||
except Exception as e:
|
||||
print(f" ❌ Database init failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Check admin user
|
||||
print("2️⃣ Testing admin user...")
|
||||
try:
|
||||
db = SessionLocal()
|
||||
admin = db.query(User).filter(User.email == "admin@aars.hk").first()
|
||||
if admin:
|
||||
print(f" ✅ Admin exists: {admin.email}")
|
||||
else:
|
||||
print(" ⚠️ Admin not found")
|
||||
db.close()
|
||||
except Exception as e:
|
||||
print(f" ❌ Query failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 3: Test password hashing
|
||||
print("3️⃣ Testing password hashing...")
|
||||
try:
|
||||
from database import get_password_hash, verify_password
|
||||
pw_hash = get_password_hash("test123")
|
||||
assert verify_password("test123", pw_hash)
|
||||
print(" ✅ Password hashing works")
|
||||
except Exception as e:
|
||||
print(f" ❌ Password hashing failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("🎉 All basic tests passed!")
|
||||
print()
|
||||
print("To run the backend:")
|
||||
print(" cd backend")
|
||||
print(" pip install -r requirements.txt")
|
||||
print(" uvicorn main:app --reload --port 8000")
|
||||
print()
|
||||
print("Then visit: http://localhost:8000")
|
||||
print("API docs: http://localhost:8000/docs")
|
||||
@@ -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/"
|
||||
@@ -0,0 +1,22 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
aars:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "18775:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- TZ=Asia/Hong_Kong
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/auth/me"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1 @@
|
||||
logs/
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3380
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "aars-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"logs:tail": "tail -f /root/aars/logs/app-*.jsonl | sed -u 's/\\\\n/\\\\n/g' | jq -c . 2>/dev/null || tail -f /root/aars/logs/app-*.jsonl",
|
||||
"logs:errors": "tail -f /root/aars/logs/error-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/error-*.jsonl",
|
||||
"logs:access": "tail -f /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || tail -f /root/aars/logs/access-*.jsonl",
|
||||
"logs:search": "grep -hE -- \"$@\" /root/aars/logs/app-*.jsonl /root/aars/logs/error-*.jsonl /root/aars/logs/access-*.jsonl | jq -c . 2>/dev/null || true",
|
||||
"logs:clean": "bash /root/aars/scripts/logs-cleanup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"axios": "^1.7.7",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"recharts": "^2.12.7",
|
||||
"lucide-react": "^0.441.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.45",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './context/AuthContext'
|
||||
import Layout from './components/Layout'
|
||||
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'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-pulse text-slate-500">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="attendance" element={<AttendanceList />} />
|
||||
<Route path="attendance/:id" element={<AttendanceDetail />} />
|
||||
<Route path="accident" element={<AccidentDashboard />} />
|
||||
<Route path="accident/list" element={<AccidentList />} />
|
||||
<Route path="accident/:id" element={<AccidentDetail />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="roster" element={<RosterPage />} />
|
||||
<Route path="holidays" element={<HolidaysPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import axios from 'axios'
|
||||
import { setCurrentRequestId } from './lib/logger.js'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Generate a request id per outgoing call (for cross-correlation)
|
||||
function newRequestId() {
|
||||
// RFC4122-ish: use crypto if available
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'req-' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
// Request interceptor: auth + X-Request-ID
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
config.headers['X-Request-ID'] = newRequestId()
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor: capture server's X-Request-ID for log correlation + 401 handling
|
||||
api.interceptors.response.use(
|
||||
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')
|
||||
// Avoid loop if already on /login
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree } from 'lucide-react'
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const closeMobile = () => setMobileOpen(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-14 sm:h-16">
|
||||
{/* Mobile hamburger (left side) */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="md:hidden p-2 -ml-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-8">
|
||||
<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-xl font-bold text-slate-900">AARS</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<NavLink
|
||||
to="/upload"
|
||||
className={({ isActive }) =>
|
||||
`hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Upload size={16} />
|
||||
<span className="hidden sm:inline">上傳</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-3 pl-4 border-l border-slate-200">
|
||||
<div className="text-right hidden md:block">
|
||||
<div className="text-sm font-medium text-slate-900">{user?.name}</div>
|
||||
<div className="text-xs text-slate-500">{user?.role}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile logout button (always visible) */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="sm:hidden p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 md:hidden"
|
||||
onClick={closeMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer panel */}
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-200 ease-in-out md:hidden ${
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 h-14 border-b border-slate-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">A</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900">AARS</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMobile}
|
||||
className="p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 rounded-md"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="p-3 space-y-1">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={closeMobile}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink
|
||||
to="/upload"
|
||||
onClick={closeMobile}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Upload size={18} />
|
||||
上傳
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-slate-900">{user?.name}</div>
|
||||
<div className="text-xs text-slate-500">{user?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { CheckCircle, AlertCircle, Clock, X, AlertTriangle, Briefcase, Stethoscope, Timer, PartyPopper } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Reusable status badge for attendance records.
|
||||
* Handles all status codes including enriched leave types:
|
||||
* normal / late / late_early / late_ot / early / early_ot / ot / abnormal / missing
|
||||
* holiday / al / sl / cl / mixed_leave
|
||||
*
|
||||
* Props:
|
||||
* - code: status_code string
|
||||
* - text: status_text string (already enriched)
|
||||
* - 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'
|
||||
|
||||
// Split text into main + leave parts (after ' + ' separator)
|
||||
const parts = text.split(/\s*\+\s*/).filter(Boolean)
|
||||
const mainText = parts[0] || ''
|
||||
const leaveParts = parts.slice(1)
|
||||
|
||||
const badge = (icon, label, colorCls) => (
|
||||
<span className={`inline-flex items-center gap-1 ${sizeCls} rounded-full font-medium ${colorCls}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
// ============ Attendance status badges ============
|
||||
if (code === 'normal') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<CheckCircle size={12} />, mainText || '正常', 'bg-green-100 text-green-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'late' || code === 'late_early' || code === 'late_ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<AlertCircle size={12} />, mainText || code, 'bg-orange-100 text-orange-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'early' || code === 'early_ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<Clock size={12} />, mainText || code, 'bg-blue-100 text-blue-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'ot') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<Clock size={12} />, mainText || '加班', 'bg-purple-100 text-purple-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'abnormal') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<AlertTriangle size={12} />, mainText || '⚠️異常', 'bg-red-100 text-red-700')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
+ {lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (code === 'missing') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{badge(<X size={12} />, '缺勤', 'bg-gray-200 text-gray-600')}
|
||||
{leaveParts.map((lp, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600">
|
||||
{lp}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Enriched leave status badges ============
|
||||
if (code === 'holiday') {
|
||||
return badge(<PartyPopper size={12} />, mainText, 'bg-amber-100 text-amber-700')
|
||||
}
|
||||
if (code === 'al') {
|
||||
return badge(<Briefcase size={12} />, mainText || '年假', 'bg-emerald-100 text-emerald-700')
|
||||
}
|
||||
if (code === 'sl') {
|
||||
return badge(<Stethoscope size={12} />, mainText || '病假', 'bg-pink-100 text-pink-700')
|
||||
}
|
||||
if (code === 'cl') {
|
||||
return badge(<Timer size={12} />, mainText || '補鐘', 'bg-indigo-100 text-indigo-700')
|
||||
}
|
||||
if (code === 'mixed_leave') {
|
||||
return badge(<Timer size={12} />, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700')
|
||||
}
|
||||
|
||||
// fallback
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs text-slate-500 bg-slate-100`}>{text || code || '-'}</span>
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import api from '../api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
if (token) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
fetchUser()
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/auth/me')
|
||||
setUser(data)
|
||||
} catch (err) {
|
||||
localStorage.removeItem('aars_token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (email, password) => {
|
||||
const { data } = await api.post('/api/auth/login', { email, password })
|
||||
localStorage.setItem('aars_token', data.access_token)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${data.access_token}`
|
||||
await fetchUser()
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('aars_token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Noto Sans TC', system-ui, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2 rounded-md font-medium
|
||||
hover:bg-primary-700 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-slate-700 px-4 py-2 rounded-md font-medium
|
||||
border border-slate-300 hover:bg-slate-50 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white px-4 py-2 rounded-md font-medium
|
||||
hover:bg-red-700 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 border border-slate-300 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500
|
||||
placeholder:text-slate-400;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg border border-slate-200 shadow-sm;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { AuthProvider } from './context/AuthContext.jsx'
|
||||
import ErrorBoundary from './components/ErrorBoundary.jsx'
|
||||
import { installGlobalHandlers } from './lib/logger.js'
|
||||
|
||||
// Install global error handlers before mounting
|
||||
installGlobalHandlers()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster position="bottom-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,646 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Users, AlertTriangle, Clock, CheckCircle, XCircle, Calendar, TrendingUp, ArrowRight, Award, AlertCircle, Clock3, Download, FileSpreadsheet, FileBarChart } from 'lucide-react'
|
||||
|
||||
export default function Dashboard() {
|
||||
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 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 }))
|
||||
])
|
||||
setSummary(sumRes.data)
|
||||
setStats(statRes.data)
|
||||
} catch (err) {
|
||||
toast.error('載入失敗')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
if (!summary) return
|
||||
if (pieChart.current) pieChart.current.destroy()
|
||||
if (barChart.current) barChart.current.destroy()
|
||||
|
||||
const Chart = window.Chart
|
||||
if (!Chart) return
|
||||
|
||||
const pieCtx = pieRef.current?.getContext('2d')
|
||||
if (pieCtx) {
|
||||
const statusData = [
|
||||
{ label: '正常', value: summary.normal_count || 0, color: '#10B981' },
|
||||
{ label: '遲到', value: summary.late_count || 0, color: '#F59E0B' },
|
||||
{ label: '早退', value: summary.early_count || 0, color: '#3B82F6' },
|
||||
{ label: 'OT', value: summary.ot_count || 0, color: '#8B5CF6' },
|
||||
{ label: '異常', value: summary.abnormal_count || 0, color: '#EF4444' },
|
||||
{ label: '缺勤', value: summary.missing_count || 0, color: '#6B7280' },
|
||||
].filter(s => s.value > 0)
|
||||
|
||||
if (statusData.length > 0) {
|
||||
pieChart.current = new Chart(pieCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: statusData.map(s => s.label),
|
||||
datasets: [{ data: statusData.map(s => s.value), backgroundColor: statusData.map(s => s.color), borderWidth: 2, borderColor: '#fff' }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom', labels: { padding: 16, font: { size: 12 } } }, title: { display: true, text: '出勤狀態分佈', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const barCtx = barRef.current?.getContext('2d')
|
||||
if (barCtx && stats?.stats?.length > 0) {
|
||||
const staffData = stats.stats.slice(0, 8)
|
||||
barChart.current = new Chart(barCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: staffData.map(s => s.name),
|
||||
datasets: [{ label: '出勤記錄', data: staffData.map(s => s.total_records || s.total || 0), backgroundColor: '#2563EB', borderRadius: 4 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, title: { display: true, text: '員工出勤記錄數', font: { size: 14, weight: '600' }, padding: { bottom: 12 } } },
|
||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Export PDF - capture dashboard as image then make PDF
|
||||
const exportPDF = async () => {
|
||||
if (!dashboardRef.current) { toast.error('找不到 Dashboard'); return }
|
||||
setExporting(true)
|
||||
try {
|
||||
if (typeof window.jspdf === 'undefined') throw new Error('jspdf 未載入')
|
||||
if (typeof window.Chart === 'undefined') throw new Error('Chart.js 未載入')
|
||||
|
||||
const { jsPDF } = window.jspdf
|
||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
||||
const PW = pdf.internal.pageSize.getWidth() // 297mm
|
||||
const PH = pdf.internal.pageSize.getHeight() // 210mm
|
||||
const M = 12
|
||||
|
||||
// ─── Title ───
|
||||
pdf.setFontSize(18); pdf.setTextColor(15,23,42)
|
||||
pdf.text('Attendance Report', M, 14)
|
||||
const range = (dateFrom || dateTo) ? `${dateFrom||'開始'} ~ ${dateTo||'今日'}` : '全部'
|
||||
const now = new Date().toLocaleString('en-US',{hour12:false,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})
|
||||
pdf.setFontSize(9); pdf.setTextColor(100,116,139)
|
||||
pdf.text(`Date Range: ${range} | Generated: ${now}`, M, 20)
|
||||
|
||||
// ─── Summary Cards ───
|
||||
const cards = summary ? [
|
||||
{ label: 'Total', value: summary.total_records, color: [37,99,235] },
|
||||
{ label: 'Normal', value: summary.normal_count, color: [16,185,129] },
|
||||
{ label: 'Late', value: summary.late_count, color: [245,158,11] },
|
||||
{ label: 'OT', value: summary.ot_count, color: [139,92,246] },
|
||||
{ label: 'Abnormal', value: summary.abnormal_count, color: [239,68,68] },
|
||||
{ label: 'Missing', value: summary.missing_count, color: [107,114,128] },
|
||||
] : []
|
||||
|
||||
const cardW = (PW - M*2) / cards.length - 3
|
||||
cards.forEach((card, i) => {
|
||||
const cx = M + i*(cardW+3)
|
||||
pdf.setFillColor(...card.color)
|
||||
pdf.roundedRect(cx, 22, cardW, 16, 2, 2, 'F')
|
||||
pdf.setFontSize(14); pdf.setTextColor(255,255,255)
|
||||
pdf.text(String(card.value ?? 0), cx+cardW/2, 30, {align:'center'})
|
||||
pdf.setFontSize(7); pdf.setTextColor(255,255,255)
|
||||
pdf.text(card.label, cx+cardW/2, 34, {align:'center'})
|
||||
})
|
||||
|
||||
// ─── Charts ───
|
||||
const chartTop = 44
|
||||
const chartH = 60
|
||||
const chartW = (PW - M*2 - 8) / 2 // two charts side by side
|
||||
|
||||
// Helper: draw doughnut / pie chart
|
||||
const drawPieChart = (cx, cy, r, data, colors, pdf) => {
|
||||
const total = data.reduce((a,b)=>a+b, 0)
|
||||
if (total === 0) return
|
||||
let angle = -Math.PI/2
|
||||
data.forEach((val, i) => {
|
||||
const slice = (val/total) * 2*Math.PI
|
||||
pdf.setFillColor(...colors[i])
|
||||
pdf.setDrawColor(255,255,255)
|
||||
pdf.setLineWidth(0.3)
|
||||
const x1 = cx + r * Math.cos(angle)
|
||||
const y1 = cy + r * Math.sin(angle)
|
||||
const x2 = cx + r * Math.cos(angle + slice)
|
||||
const y2 = cy + r * Math.sin(angle + slice)
|
||||
if (val === total) {
|
||||
pdf.setFillColor(...colors[i])
|
||||
pdf.circle(cx, cy, r, 'F')
|
||||
} else {
|
||||
const pts = [[cx, cy], [x1, y1], [x2, y2]]
|
||||
pdf.triangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1], 'F')
|
||||
}
|
||||
angle += slice
|
||||
})
|
||||
// inner circle (donut hole)
|
||||
pdf.setFillColor(255,255,255)
|
||||
pdf.circle(cx, cy, r*0.5, 'F')
|
||||
}
|
||||
|
||||
// Helper: draw bar chart
|
||||
const drawBarChart = (bx, by, bw, bh, labels, values, barColor, pdf) => {
|
||||
const max = Math.max(...values, 1)
|
||||
const barW = bw / labels.length * 0.6
|
||||
const gap = bw / labels.length * 0.4
|
||||
labels.forEach((lbl, i) => {
|
||||
const barH = (values[i]/max) * bh
|
||||
const x = bx + i*(barW+gap) + gap/2
|
||||
const y = by + bh - barH
|
||||
pdf.setFillColor(...barColor)
|
||||
pdf.roundedRect(x, y, barW, barH, 1, 1, 'F')
|
||||
// value label
|
||||
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
|
||||
pdf.text(String(values[i]), x+barW/2, by+bh+3.5, {align:'center'})
|
||||
// x label (truncated)
|
||||
pdf.setFontSize(6); pdf.setTextColor(100,116,139)
|
||||
pdf.text(lbl.length > 6 ? lbl.substring(0,5)+'.' : lbl, x+barW/2, by+bh+6.5, {align:'center'})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Pie: 出勤狀態分佈 ──
|
||||
if (summary) {
|
||||
const pieCX = M + chartW/2
|
||||
const pieCY = chartTop + chartH/2
|
||||
const pieR = chartH/2 - 4
|
||||
const pieData = [
|
||||
summary.normal_count || 0,
|
||||
summary.late_count || 0,
|
||||
summary.early_count || 0,
|
||||
summary.ot_count || 0,
|
||||
summary.abnormal_count || 0,
|
||||
summary.missing_count || 0,
|
||||
]
|
||||
const pieColors = [[16,185,129],[245,158,11],[59,130,246],[139,92,246],[239,68,68],[107,114,128]]
|
||||
const pieLabels = ['Normal','Late','Early','OT','Abnormal','Missing']
|
||||
const pieLegendX = M
|
||||
const pieLegendY = chartTop + chartH + 5
|
||||
|
||||
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
|
||||
pdf.text('Attendance Status Distribution', M, chartTop - 2)
|
||||
|
||||
// Draw pie
|
||||
drawPieChart(pieCX, pieCY, pieR, pieData, pieColors, pdf)
|
||||
|
||||
// Legend
|
||||
let lx = M
|
||||
pieLabels.forEach((lbl, i) => {
|
||||
if (pieData[i] === 0) return
|
||||
pdf.setFillColor(...pieColors[i])
|
||||
pdf.rect(lx, pieLegendY, 4, 4, 'F')
|
||||
pdf.setFontSize(7.5); pdf.setTextColor(60,70,90)
|
||||
pdf.text(`${lbl} ${pieData[i]}`, lx+5, pieLegendY+3.5)
|
||||
lx += 28
|
||||
})
|
||||
}
|
||||
|
||||
// ── Bar: 員工出勤記錄 ──
|
||||
if (stats?.stats?.length > 0) {
|
||||
const barX = M + chartW + 8
|
||||
const barLabels = stats.stats.slice(0,8).map(s=>s.name)
|
||||
const barValues = stats.stats.slice(0,8).map(s=>s.total_records || s.total || 0)
|
||||
|
||||
pdf.setFontSize(10); pdf.setTextColor(15,23,42)
|
||||
pdf.text('Staff Attendance Records', barX, chartTop - 2)
|
||||
|
||||
// Axis
|
||||
const axisX = barX + 2
|
||||
const axisY = chartTop + chartH - 2
|
||||
const axisW = chartW - 4
|
||||
const axisH = chartH - 8
|
||||
pdf.setDrawColor(200,210,220)
|
||||
pdf.setLineWidth(0.3)
|
||||
pdf.line(axisX, axisY, axisX, axisY - axisH) // y axis
|
||||
pdf.line(axisX, axisY, axisX + axisW, axisY) // x axis
|
||||
|
||||
drawBarChart(axisX, axisY - axisH, axisW, axisH, barLabels, barValues, [37,99,235], pdf)
|
||||
}
|
||||
|
||||
// ─── Leaderboards ───
|
||||
if (stats?.stats?.length > 0) {
|
||||
const staff = stats.stats
|
||||
const lbTop = chartTop + chartH + 14
|
||||
const lateRank = [...staff].sort((a,b)=>(b.late_count||0)-(a.late_count||0)).slice(0,5)
|
||||
const otRank = [...staff].sort((a,b)=>(b.ot_count||0)-(a.ot_count||0)).slice(0,5)
|
||||
const missRank = [...staff].sort((a,b)=>(b.missing_count||0)-(a.missing_count||0)).slice(0,5)
|
||||
const rateRank = [...staff].map(s=>({...s,rate:(s.total_records||s.total||0)>0?Math.round((((s.total_records||s.total||0)-(s.late_count||0)-(s.abnormal_count||0))/(s.total_records||s.total||0))*100):0})).sort((a,b)=>b.rate-a.rate).slice(0,5)
|
||||
|
||||
const lbW = (PW - M*2) / 4 - 4
|
||||
const lbData = [
|
||||
{ title: 'Late Rank', data: lateRank, key: 'late_count', sub: 'late_minutes', color: [245,158,11] },
|
||||
{ title: 'OT Rank', data: otRank, key: 'ot_count', sub: 'ot_minutes', color: [139,92,246] },
|
||||
{ title: 'Missing Rank', data: missRank, key: 'missing_count', sub: null, color: [107,114,128] },
|
||||
{ title: 'Attendance Rate', data: rateRank, key: 'rate', sub: null, color: [16,185,129] },
|
||||
]
|
||||
|
||||
lbData.forEach((lb, li) => {
|
||||
const lx = M + li*(lbW+4)
|
||||
// Box
|
||||
pdf.setFillColor(...lb.color)
|
||||
pdf.roundedRect(lx, lbTop, lbW, 6, 2, 2, 'F')
|
||||
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
|
||||
const lines = lb.title.split('\n')
|
||||
lines.forEach((l, li2) => pdf.text(l, lx+lbW/2, lbTop+3+li2*3, {align:'center'}))
|
||||
// Items
|
||||
lb.data.forEach((item, di) => {
|
||||
const iy = lbTop + 10 + di*5
|
||||
const medal = ['🥇','🥈','🥉'][di] || ` ${di+1}.`
|
||||
const sub = lb.sub && item[lb.sub] ? ` (${item[lb.sub]}min)` : ''
|
||||
const val = `${item[lb.key]}${lb.key==='rate'?'%':''}${sub}`
|
||||
pdf.setFontSize(7.5); pdf.setTextColor(30,41,59)
|
||||
pdf.text(`${medal} ${item.name} ${val}`, lx+2, iy)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Staff Table ───
|
||||
if (stats?.stats?.length > 0) {
|
||||
const tblTop = PW > 250 ? (chartTop + chartH + 14 + 35) : (chartTop + chartH + 14 + 30)
|
||||
pdf.setFontSize(11); pdf.setTextColor(15,23,42)
|
||||
pdf.text('Staff Summary', M, tblTop - 1)
|
||||
|
||||
const staff = stats.stats
|
||||
const colW = [46, 18, 16, 20, 16, 20, 16, 16]
|
||||
const cols = ['Name','Total','Late','LateMin','OT','OTMin','Abn','Miss']
|
||||
let xPos = [M]
|
||||
for (let ci=0; ci<colW.length-1; ci++) xPos.push(xPos[ci]+colW[ci])
|
||||
const rowH = 6
|
||||
|
||||
// Header
|
||||
pdf.setFillColor(50,60,80)
|
||||
pdf.rect(M, tblTop, PW-M*2, rowH, 'F')
|
||||
pdf.setFontSize(8); pdf.setTextColor(255,255,255)
|
||||
cols.forEach((h,ci) => pdf.text(h, xPos[ci]+colW[ci]/2, tblTop+4.5, {align:'center'}))
|
||||
|
||||
// Rows
|
||||
staff.forEach((s, ri) => {
|
||||
const ry = tblTop + rowH*(ri+1)
|
||||
if (ri%2===0) { pdf.setFillColor(248,250,252); pdf.rect(M, ry, PW-M*2, rowH, 'F') }
|
||||
pdf.setFontSize(8); pdf.setTextColor(30,41,59)
|
||||
const vals = [s.name, String(s.total_records || s.total || 0), String(s.late_count||0), String(s.late_minutes||'-'), String(s.ot_count||0), String(s.ot_minutes||'-'), String(s.abnormal_count||0), String(s.missing_count||0)]
|
||||
vals.forEach((v,ci) => pdf.text(v, xPos[ci]+colW[ci]/2, ry+4.5, {align:'center'}))
|
||||
})
|
||||
}
|
||||
|
||||
pdf.save(`attendance_report_${dateFrom||'all'}_${dateTo||''}.pdf`.replace(/-/g,''))
|
||||
toast.success('✅ PDF 已下載')
|
||||
} catch (err) {
|
||||
console.error('PDF error:', err)
|
||||
toast.error('PDF 導出失敗: ' + err.message)
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportExcel = async (full) => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
const params = full ? {} : (dateFrom ? { date_from: dateFrom, date_to: dateTo || new Date().toISOString().slice(0, 10) } : {})
|
||||
const url = `/api/export/attendance/excel${Object.keys(params).length ? '?' + new URLSearchParams(params).toString() : ''}`
|
||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
if (!resp.ok) throw new Error('Export failed')
|
||||
const blob = await resp.blob()
|
||||
const filename = full
|
||||
? `attendance_full.xlsx`
|
||||
: `attendance_${dateFrom || 'start'}_${dateTo || 'today'}.xlsx`.replace(/-/g, '')
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
toast.success('✅ Excel 已下載')
|
||||
} catch (err) {
|
||||
console.error('Excel export error:', err)
|
||||
toast.error('Excel 導出失敗')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const byStaff = stats?.stats || []
|
||||
const lateRank = [...byStaff].sort((a, b) => (b.late_count || 0) - (a.late_count || 0))
|
||||
const otRank = [...byStaff].sort((a, b) => (b.ot_count || 0) - (a.ot_count || 0))
|
||||
const abnormalRank = [...byStaff].sort((a, b) => (b.abnormal_count || 0) - (a.abnormal_count || 0))
|
||||
const missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0))
|
||||
const attendanceRate = [...byStaff].map(s => ({
|
||||
...s,
|
||||
rate: (s.total_records || s.total || 0) > 0 ? Math.round(((((s.total_records || s.total || 0) - (s.late_count || 0) - (s.abnormal_count || 0))) / (s.total_records || s.total || 0)) * 100) : 0
|
||||
})).sort((a, b) => b.rate - a.rate)
|
||||
|
||||
const statCards = summary ? [
|
||||
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
|
||||
{ label: '正常', value: summary.normal_count, icon: CheckCircle, color: 'bg-green-50 text-green-600' },
|
||||
{ label: '遲到', value: summary.late_count, icon: Clock, color: 'bg-orange-50 text-orange-600' },
|
||||
{ label: '早退', value: summary.early_count, icon: Clock3, color: 'bg-blue-50 text-blue-600' },
|
||||
{ label: 'OT', value: summary.ot_count, icon: TrendingUp, color: 'bg-purple-50 text-purple-600' },
|
||||
{ label: '異常', value: summary.abnormal_count, icon: AlertTriangle, color: 'bg-red-50 text-red-600' },
|
||||
{ label: '缺勤', value: summary.missing_count, icon: XCircle, color: 'bg-gray-50 text-gray-600' },
|
||||
{ label: '員工', value: summary.staff_count, icon: Users, color: 'bg-teal-50 text-teal-600' },
|
||||
] : []
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
|
||||
|
||||
// Date preset helpers
|
||||
const fmt = (d) => d.toISOString().slice(0, 10)
|
||||
const weekRange = (offsetWeeks) => {
|
||||
// Monday = start of week
|
||||
const now = new Date()
|
||||
const dow = now.getDay() // Sun=0, Mon=1, ... Sat=6
|
||||
const daysFromMonday = (dow + 6) % 7 // Mon=0, Tue=1, ..., Sun=6
|
||||
const start = new Date(now)
|
||||
start.setDate(now.getDate() - daysFromMonday + offsetWeeks * 7)
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + 6)
|
||||
return [fmt(start), fmt(end)]
|
||||
}
|
||||
|
||||
// Map rankKey to Option 1 detail fields exposed by backend
|
||||
const detailFieldsMap = {
|
||||
late_count: [
|
||||
{ key: 'late_minutes', label: '分鐘', unit: '分' },
|
||||
{ key: 'late_avg_minutes', label: '平均', unit: '分' },
|
||||
{ key: 'late_max_minutes', label: '最長', unit: '分' },
|
||||
],
|
||||
early_count: [
|
||||
{ key: 'early_minutes', label: '分鐘', unit: '分' },
|
||||
{ key: 'early_avg_minutes', label: '平均', unit: '分' },
|
||||
{ key: 'early_max_minutes', label: '最長', unit: '分' },
|
||||
],
|
||||
ot_count: [
|
||||
{ key: 'ot_minutes', label: '分鐘', unit: '分' },
|
||||
{ key: 'ot_avg_minutes', label: '平均', unit: '分' },
|
||||
{ key: 'ot_max_minutes', label: '最長', unit: '分' },
|
||||
],
|
||||
missing_count: [
|
||||
{ key: 'consecutive_missing', label: '連續', unit: '次' },
|
||||
{ key: 'attendance_rate', label: '出勤率', unit: '%', suffix: true },
|
||||
{ key: 'last_attendance_date', label: '最後', isDate: true },
|
||||
],
|
||||
rate: [
|
||||
{ key: 'total_records', label: '記錄', unit: '條' },
|
||||
{ key: 'missing_count', label: '缺勤', unit: '次' },
|
||||
],
|
||||
}
|
||||
|
||||
const RankCard = ({ title, icon: Icon, iconColor, data, rankKey, subKey, unit, colorFn }) => {
|
||||
const details = detailFieldsMap[rankKey] || []
|
||||
const items = data.filter(d => d[rankKey] > 0 || rankKey === 'rate').slice(0, 6)
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<Icon size={16} style={{ color: iconColor }} /> {title}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-4">暫無數據</p>
|
||||
) : items.map((s, i) => (
|
||||
<div key={s.name} className="py-1.5 border-b border-slate-100 last:border-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
i === 0 ? 'bg-yellow-100 text-yellow-700' : i === 1 ? 'bg-slate-100 text-slate-500' : i === 2 ? 'bg-orange-50 text-orange-400' : 'bg-slate-50 text-slate-400'
|
||||
}`}>{i + 1}</span>
|
||||
<span className="text-sm text-slate-700">{s.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-bold" style={colorFn ? { color: colorFn(s) } : undefined}>
|
||||
{rankKey === 'rate' ? `${s[rankKey]}%` : s[rankKey]}
|
||||
</span>
|
||||
{subKey && (s[subKey] || 0) > 0 && (
|
||||
<span className="text-xs text-slate-400 ml-1">({s[subKey]}{unit || '次'})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Option 1 detail row */}
|
||||
{details.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 ml-7 text-[11px] text-slate-500">
|
||||
{details.map(d => {
|
||||
const v = s[d.key]
|
||||
let display
|
||||
if (v === null || v === undefined || v === 0 && d.key !== 'attendance_rate') {
|
||||
// Skip empty/zero for most fields, but show 0% rate
|
||||
if (!(d.key === 'attendance_rate')) return null
|
||||
display = `0${d.unit}`
|
||||
} else if (d.isDate) {
|
||||
display = `${d.label} ${v}`
|
||||
} else if (d.suffix) {
|
||||
// e.g. attendance_rate = 70 (already %)
|
||||
display = `${d.label} ${v}${d.unit}`
|
||||
} else {
|
||||
display = `${d.label} ${v}${d.unit}`
|
||||
}
|
||||
// For attendance_rate, only show if rate exists
|
||||
if (d.key === 'attendance_rate' && (v === null || v === undefined)) return null
|
||||
// For last_attendance_date, only show if exists
|
||||
if (d.isDate && !v) return null
|
||||
// For consecutive_missing, only show if > 0 (otherwise skip)
|
||||
if (d.key === 'consecutive_missing' && (v || 0) === 0) return null
|
||||
return <span key={d.key}>{display}</span>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" ref={dashboardRef}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-slate-900">Dashboard</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Buttons */}
|
||||
<div className="flex items-center gap-1 border border-slate-300 rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={exportPDF}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||
title="導出 PDF"
|
||||
>
|
||||
<FileBarChart size={14} /> PDF
|
||||
</button>
|
||||
<div className="w-px h-5 bg-slate-300" />
|
||||
<button
|
||||
onClick={() => exportExcel(false)}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||
title="導出 Excel (跟時間範圍)"
|
||||
>
|
||||
<FileSpreadsheet size={14} /> Excel
|
||||
</button>
|
||||
<div className="w-px h-5 bg-slate-300" />
|
||||
<button
|
||||
onClick={() => exportExcel(true)}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50 font-medium"
|
||||
title="導出 Excel (全部)"
|
||||
>
|
||||
<Download size={14} /> 全部
|
||||
</button>
|
||||
</div>
|
||||
<Link to="/attendance" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
|
||||
<ArrowRight size={16} /> 出勤記錄
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Filter */}
|
||||
<div className="card p-4">
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">日期範圍:</span>
|
||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
|
||||
<span className="text-slate-400">-</span>
|
||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" />
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button onClick={() => { setDateFrom(today); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">今日</button>
|
||||
<button onClick={() => {
|
||||
const [w0, w1] = weekRange(0)
|
||||
setDateFrom(w0); setDateTo(w1)
|
||||
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本週</button>
|
||||
<button onClick={() => {
|
||||
const [w0, w1] = weekRange(-1)
|
||||
setDateFrom(w0); setDateTo(w1)
|
||||
}} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">上週</button>
|
||||
<button onClick={() => { setDateFrom(monthStart); setDateTo(today) }} className="text-xs px-3 py-2 border border-slate-300 rounded-md hover:bg-slate-50">本月</button>
|
||||
<button onClick={() => { setDateFrom(''); setDateTo('') }} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-2 border border-slate-300 rounded">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
{(dateFrom || dateTo) && (
|
||||
<p className="text-xs text-slate-400 mt-2">顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">載入中...</div>
|
||||
) : summary ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{statCards.map(({ label, value, icon: Icon, color }) => (
|
||||
<div key={label} className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2.5 rounded-lg ${color}`}><Icon size={20} /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{value ?? 0}</div>
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card p-4">
|
||||
<div style={{ height: '260px', position: 'relative' }}><canvas ref={pieRef}></canvas></div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div style={{ height: '260px', position: 'relative' }}><canvas ref={barRef}></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{byStaff.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<RankCard title="遲到排行榜" icon={Clock} iconColor="#F59E0B" data={lateRank} rankKey="late_count" subKey="late_minutes" unit="分" colorFn={s => s.late_count > 0 ? '#F59E0B' : '#10B981'} />
|
||||
<RankCard title="OT排行榜" icon={TrendingUp} iconColor="#8B5CF6" data={otRank} rankKey="ot_count" subKey="ot_minutes" unit="分" colorFn={s => s.ot_count > 0 ? '#8B5CF6' : '#10B981'} />
|
||||
<RankCard title="缺勤排行榜" icon={XCircle} iconColor="#6B7280" data={missingRank} rankKey="missing_count" colorFn={s => s.missing_count > 0 ? '#EF4444' : '#10B981'} />
|
||||
<RankCard title="出勤率排行榜" icon={Award} iconColor="#10B981" data={attendanceRate} rankKey="rate" colorFn={s => s.rate >= 90 ? '#10B981' : s.rate >= 70 ? '#F59E0B' : '#EF4444'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.stats?.length > 0 && (
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<Users size={16} /> 員工出勤概覽
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600">員工</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">出勤</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">正常</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲到</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">遲分鐘</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">OT分鐘</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">異常</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600">缺勤</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{byStaff.map((s) => (
|
||||
<tr key={s.name} className="hover:bg-slate-50">
|
||||
<td className="px-3 py-2 font-medium text-slate-700">{s.name}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">{s.total_records ?? s.total ?? 0}</td>
|
||||
<td className="px-3 py-2 text-right text-green-600">{(s.total_records ?? s.total ?? 0) - (s.late_count || 0) - (s.abnormal_count || 0)}</td>
|
||||
<td className="px-3 py-2 text-right text-orange-600">{s.late_count || 0}</td>
|
||||
<td className="px-3 py-2 text-right text-orange-400">{s.late_minutes || '-'}</td>
|
||||
<td className="px-3 py-2 text-right text-purple-600">{s.ot_count || 0}</td>
|
||||
<td className="px-3 py-2 text-right text-purple-400">{s.ot_minutes || '-'}</td>
|
||||
<td className="px-3 py-2 text-right text-red-600">{s.abnormal_count || 0}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500">{s.missing_count || 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400">暫無數據</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
toast.success('登入成功!')
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '登入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white font-bold text-2xl">A</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">AARS</h1>
|
||||
<p className="text-slate-500 mt-1">Attendance & Accident Record System</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<div className="card p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
電郵 Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="admin@aars.hk"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
密碼 Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '登入中...' : '登入'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-slate-500">
|
||||
<p>預設帳戶:admin@aars.hk / admin123</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, FileText, AlertTriangle } from 'lucide-react'
|
||||
|
||||
const SEVERITY_COLORS = {
|
||||
'1': { bg: '#D1FAE5', fg: '#065F46', label: '低 Low' },
|
||||
'2': { bg: '#DBEAFE', fg: '#1E40AF', label: '中 Medium' },
|
||||
'3': { bg: '#FEF3C7', fg: '#92400E', label: '高 High' },
|
||||
'4': { bg: '#FED7AA', fg: '#9A3412', label: '嚴重 Severe' },
|
||||
'5': { bg: '#FEE2E2', fg: '#7F1D1D', label: '極嚴重 Critical' },
|
||||
}
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'date', label: '日期 Date' },
|
||||
{ key: 'time', label: '時間 Time' },
|
||||
{ key: 'severity', label: '嚴重程度 Severity', badge: true },
|
||||
{ key: 'location', label: '地點 Location' },
|
||||
{ key: 'employee_name', label: '員工/報告人 Employee' },
|
||||
{ key: 'department', label: '部門 Department' },
|
||||
{ key: 'description', label: '事件描述 Description' },
|
||||
{ key: 'medical_report', label: '醫療報告 Medical Report' },
|
||||
{ key: 'action_taken', label: '處理情況 Action Taken' },
|
||||
{ key: 'responsible_person', label: '負責人 Responsible Person' },
|
||||
{ key: 'created_at', label: '建立時間 Created At' },
|
||||
]
|
||||
|
||||
export default function AccidentDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [record, setRecord] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const numericId = parseInt(id, 10)
|
||||
if (isNaN(numericId) || numericId <= 0) {
|
||||
navigate('/accident/list', { replace: true })
|
||||
return
|
||||
}
|
||||
fetchRecord(numericId)
|
||||
}, [id])
|
||||
|
||||
const fetchRecord = async (recordId) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await api.get(`/api/accident/${recordId}`)
|
||||
setRecord(data)
|
||||
} catch (err) {
|
||||
console.error('Error fetching record:', err)
|
||||
const detail = err.response?.data?.detail
|
||||
// FastAPI 422 returns detail as array of validation errors — stringify safely
|
||||
let msg = 'Failed to load record'
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail
|
||||
} else if (Array.isArray(detail)) {
|
||||
msg = `Invalid request: ${detail.map(d => d.msg || JSON.stringify(d)).join('; ')}`
|
||||
} else if (detail && typeof detail === 'object') {
|
||||
msg = JSON.stringify(detail)
|
||||
} else if (err.response?.status === 404) {
|
||||
msg = 'Record not found'
|
||||
}
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-slate-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/accident/list"
|
||||
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">
|
||||
<AlertTriangle size={48} className="mx-auto text-red-400 mb-2" />
|
||||
<div className="text-red-500 mb-2">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/accident/list')}
|
||||
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sev = String(record.severity || '')
|
||||
const sevColor = SEVERITY_COLORS[sev] || { bg: '#F3F4F6', fg: '#374151', label: sev || '-' }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4 sm:mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/accident/list"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</Link>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-900">
|
||||
意外記錄 #{id}
|
||||
</h1>
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold"
|
||||
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
|
||||
>
|
||||
Severity {sev} · {sevColor.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText size={24} className="text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">記錄詳情</div>
|
||||
<div className="text-sm text-slate-500">AARS 意外事故記錄</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{FIELDS.map(({ key, label, badge }) => {
|
||||
const val = record[key]
|
||||
const display = val !== null && val !== undefined && val !== '' ? String(val) : '-'
|
||||
return (
|
||||
<div key={key} className="grid grid-cols-1 sm:grid-cols-12">
|
||||
<div className="sm:col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
|
||||
{label}
|
||||
</div>
|
||||
<div className="sm:col-span-9 px-4 py-3 text-sm text-slate-900 break-words">
|
||||
{badge && sev !== '' ? (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold"
|
||||
style={{ backgroundColor: sevColor.bg, color: sevColor.fg }}
|
||||
>
|
||||
{sev} · {sevColor.label}
|
||||
</span>
|
||||
) : (
|
||||
display
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Search, Eye, ChevronUp, ChevronDown, Filter, X, Settings2, RotateCcw } from 'lucide-react'
|
||||
|
||||
export default function AccidentList() {
|
||||
const navigate = useNavigate()
|
||||
const [records, setRecords] = useState([])
|
||||
const [excelHeaders, setExcelHeaders] = useState({}) // sql_col -> Excel chinese header
|
||||
const [columnOrder, setColumnOrder] = useState([]) // ordered SQL cols (Excel order)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
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/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]
|
||||
if (filterKey && filterValue) {
|
||||
result = result.filter(r => {
|
||||
const val = String(r[filterKey] || '').toLowerCase()
|
||||
return val.includes(filterValue.toLowerCase())
|
||||
})
|
||||
}
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
result = result.filter(r =>
|
||||
Object.values(r).some(v =>
|
||||
v && String(v).toLowerCase().includes(searchLower)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (sortKey) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[sortKey] || ''
|
||||
const bVal = b[sortKey] || ''
|
||||
const aNum = Number(aVal)
|
||||
const bNum = Number(bVal)
|
||||
let cmp = 0
|
||||
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== '' && bVal !== '') {
|
||||
cmp = aNum - bNum
|
||||
} else {
|
||||
cmp = String(aVal).localeCompare(String(bVal))
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [records, search, sortKey, sortDir, filterKey, filterValue])
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilter = (key) => {
|
||||
setFilterKey(key)
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const clearFilter = () => {
|
||||
setFilterKey('')
|
||||
setFilterValue('')
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortKey !== key) return null
|
||||
return sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
const toggleColumn = (col) => {
|
||||
const next = visibleCols ? { ...visibleCols } : {}
|
||||
// Initialize all columns to true first
|
||||
columnOrder.forEach(c => { if (next[c] === undefined) next[c] = true })
|
||||
next[col] = !next[col]
|
||||
setVisibleCols(next)
|
||||
}
|
||||
|
||||
const resetColumns = () => {
|
||||
setVisibleCols(null)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg sm:text-xl font-bold text-slate-900">意外記錄 Accident</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowColumnSettings(!showColumnSettings)}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-md border ${
|
||||
showColumnSettings
|
||||
? 'bg-slate-100 border-slate-400 text-slate-800'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Settings2 size={16} />
|
||||
欄位 ({displayedCols.length}/{columnOrder.filter(c => c !== 'id').length})
|
||||
</button>
|
||||
<Link
|
||||
to="/upload"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
|
||||
>
|
||||
上傳 Excel
|
||||
</Link>
|
||||
</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">
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={(e) => handleFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">篩選欄位</option>
|
||||
{displayedCols.map(h => (
|
||||
<option key={h} value={h}>{displayName(h)}</option>
|
||||
))}
|
||||
</select>
|
||||
{filterKey && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
placeholder="輸入篩選值..."
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
|
||||
/>
|
||||
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
</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>
|
||||
{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">
|
||||
{displayName(h)}
|
||||
{getSortIcon(h)}
|
||||
</div>
|
||||
</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={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : processedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={displayedCols.length + 2} className="px-3 py-6 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => (
|
||||
<tr
|
||||
key={record.id}
|
||||
className="hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => navigate(`/accident/${record.id}`)}
|
||||
>
|
||||
<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.id}`}
|
||||
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,228 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Save, X } from 'lucide-react'
|
||||
|
||||
export default function AttendanceDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [record, setRecord] = useState(null)
|
||||
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(() => {
|
||||
fetchRecord()
|
||||
}, [id])
|
||||
|
||||
const fetchRecord = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Fetch all records and find by id
|
||||
const { data } = await api.get('/api/attendance/records?per_page=1000')
|
||||
const found = (data.records || []).find(r => r.id === parseInt(id))
|
||||
if (found) {
|
||||
setRecord(found)
|
||||
setFormData({
|
||||
employee_name: found.employee_name || '',
|
||||
company: found.company || '',
|
||||
date: found.date || '',
|
||||
weekday: found.weekday || '',
|
||||
shift_code: found.shift_code || '',
|
||||
status_code: found.status_code || '',
|
||||
status_text: found.status_text || '',
|
||||
late_minutes: found.late_minutes || 0,
|
||||
early_minutes: found.early_minutes || 0,
|
||||
ot_minutes: found.ot_minutes || 0,
|
||||
expected_in: found.expected_in || '',
|
||||
expected_out: found.expected_out || '',
|
||||
actual_in: found.actual_in || '',
|
||||
actual_out: found.actual_out || '',
|
||||
is_manually_edited: found.is_manually_edited || false,
|
||||
})
|
||||
} else {
|
||||
setError('找不到記錄')
|
||||
}
|
||||
} catch (err) {
|
||||
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">
|
||||
<div className="text-slate-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link to="/attendance" className="flex items-center gap-2 text-slate-600 hover:text-slate-900">
|
||||
<ArrowLeft size={20} /> 返回列表
|
||||
</Link>
|
||||
<div className="card p-8 text-center text-red-500">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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>
|
||||
<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">
|
||||
<h2 className="font-semibold text-slate-700">記錄詳情</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{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">
|
||||
{label}
|
||||
</div>
|
||||
<div className="col-span-9 px-4 py-3">
|
||||
{editMode && ['status_code', 'late_minutes', 'early_minutes', 'ot_minutes', 'actual_in', 'actual_out', 'shift_code'].includes(key) ? (
|
||||
<input
|
||||
type="text"
|
||||
value={formData[key]}
|
||||
onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-slate-900">
|
||||
{record?.[key] !== null && record?.[key] !== undefined ? String(record[key]) : '-'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Search, ChevronUp, ChevronDown, Filter, X, Users, Calendar } from 'lucide-react'
|
||||
import StatusBadge from '../../components/StatusBadge'
|
||||
|
||||
export default function AttendanceList() {
|
||||
const [records, setRecords] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortBy, setSortBy] = useState('date')
|
||||
const [sortOrder, setSortOrder] = useState('desc')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [staffFilter, setStaffFilter] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const perPage = 50
|
||||
|
||||
const [staffList, setStaffList] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecords()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, sortBy, sortOrder, dateFrom, dateTo, staffFilter, statusFilter, search])
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page,
|
||||
per_page: perPage,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
if (search) params.append('search', search)
|
||||
if (dateFrom) params.append('date_from', dateFrom)
|
||||
if (dateTo) params.append('date_to', dateTo)
|
||||
if (staffFilter) params.append('staff', staffFilter)
|
||||
if (statusFilter) params.append('status', 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('載入失敗')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
fetchRecords()
|
||||
}
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(key)
|
||||
setSortOrder('asc')
|
||||
}
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const getSortIcon = (key) => {
|
||||
if (sortBy !== key) return null
|
||||
return sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
const getStatusBadge = (record) => {
|
||||
return <StatusBadge code={record.status_code} text={record.status_text} />
|
||||
}
|
||||
|
||||
const getRowClass = (code) => {
|
||||
if (code === 'abnormal') return 'bg-red-50 hover:bg-red-100'
|
||||
if (code === 'missing') return 'bg-gray-100 hover:bg-gray-200'
|
||||
if (code === 'late' || code === 'late_early' || code === 'late_ot') return 'bg-orange-50 hover:bg-orange-100'
|
||||
if (code === 'early' || code === 'early_ot') return 'bg-blue-50 hover:bg-blue-100'
|
||||
if (code === 'ot') return 'bg-purple-50 hover:bg-purple-100'
|
||||
return 'hover:bg-slate-50'
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / perPage)
|
||||
|
||||
const columns = [
|
||||
{ key: 'employee_name', label: '員工' },
|
||||
{ key: 'date', label: '日期' },
|
||||
{ key: 'weekday', label: '星期' },
|
||||
{ key: 'shift_code', label: '班次' },
|
||||
{ key: 'actual_in', label: '上班' },
|
||||
{ key: 'actual_out', label: '下班' },
|
||||
{ key: 'expected_in', label: '應上班' },
|
||||
{ key: 'expected_out', label: '應下班' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg sm:text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
|
||||
<Calendar size={16} /> Dashboard
|
||||
</Link>
|
||||
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700">
|
||||
上傳 Excel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-4 space-y-3">
|
||||
{/* Search row */}
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap gap-3 items-end">
|
||||
<div className="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>
|
||||
<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">
|
||||
<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={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>
|
||||
{staffList.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">狀態:</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="late">遲到</option>
|
||||
<option value="early">早退</option>
|
||||
<option value="ot">OT</option>
|
||||
<option value="abnormal">異常</option>
|
||||
<option value="missing">缺勤</option>
|
||||
</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>
|
||||
|
||||
{/* 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.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">
|
||||
{col.label}
|
||||
{getSortIcon(col.key)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-600">狀態</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : records.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-3 py-8 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
records.map((record, idx) => (
|
||||
<tr
|
||||
key={record.id || idx}
|
||||
className={getRowClass(record.status_code)}
|
||||
>
|
||||
<td className="px-3 py-2.5 text-xs text-slate-400">{(page - 1) * perPage + idx + 1}</td>
|
||||
<td className="px-3 py-2.5 text-slate-700 font-medium">{record.employee_name || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.date || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.weekday || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.shift_code || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.actual_in || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{record.actual_out || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_in || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-slate-400 text-xs">{record.expected_out || '-'}</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
{getStatusBadge(record)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<Link
|
||||
to={`/attendance/${record.id}`}
|
||||
className="p-1 text-slate-400 hover:text-blue-600 inline-block"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</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,713 @@
|
||||
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)
|
||||
}
|
||||
|
||||
const uniqueEmps = [...new Set(leaves.map(l => l.employee_name))].sort()
|
||||
|
||||
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={uniqueEmps}
|
||||
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 ============
|
||||
function LeaveModal({ leave, employees, onClose, onSaved }) {
|
||||
const isEdit = !!leave
|
||||
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)
|
||||
|
||||
async function handleSave() {
|
||||
if (!employeeName || !date || !leaveType) {
|
||||
toast.error('員工 / 日期 / 類型 係必填')
|
||||
return
|
||||
}
|
||||
if (hours < 0 || hours > 24) {
|
||||
toast.error('時數必須 0-24')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.put(`/api/leave/${leave.id}`, {
|
||||
employee_name: employeeName, date, leave_type: leaveType,
|
||||
hours: Number(hours), reason, approved_by: approvedBy,
|
||||
})
|
||||
toast.success('已更新')
|
||||
} else {
|
||||
await api.post('/api/leave', {
|
||||
employee_name: employeeName, date, leave_type: leaveType,
|
||||
hours: Number(hours), reason, approved_by: approvedBy,
|
||||
})
|
||||
toast.success('已新增')
|
||||
}
|
||||
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>
|
||||
{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={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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle, XCircle, ArrowRight, Download } from 'lucide-react'
|
||||
|
||||
const ATTENDANCE_COLUMNS = ['date', 'employee_name', 'employee_id', 'department', 'check_in', 'check_out', 'status']
|
||||
const ACCIDENT_COLUMNS = ['date', 'time', 'location', 'employee_name', 'employee_id', 'department', 'description', 'severity', 'medical_report', 'action_taken', 'responsible_person']
|
||||
|
||||
function downloadTemplate(section) {
|
||||
const XLSX = window.XLSX
|
||||
if (!XLSX) return toast.error('XLSX library not loaded')
|
||||
|
||||
const columns = section === 'attendance' ? ATTENDANCE_COLUMNS : ACCIDENT_COLUMNS
|
||||
const data = [columns, ['2026-01-01', '張三', 'A001', '工程部', '09:00', '18:00', 'present']]
|
||||
const ws = XLSX.utils.aoa_to_sheet(data)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, section)
|
||||
XLSX.writeFile(wb, `${section}_template.xlsx`)
|
||||
}
|
||||
|
||||
export default function ImportPage() {
|
||||
const [step, setStep] = useState(1)
|
||||
const [section, setSection] = useState('attendance')
|
||||
const [file, setFile] = useState(null)
|
||||
const [headers, setHeaders] = useState([])
|
||||
const [preview, setPreview] = useState([])
|
||||
const [mapping, setMapping] = useState({})
|
||||
const [updateExisting, setUpdateExisting] = useState(true)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const fileInputRef = useRef()
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.name.match(/\.(xlsx|xls)$/i)) {
|
||||
toast.error('請上傳 Excel 文件 (.xlsx, .xls)')
|
||||
return
|
||||
}
|
||||
|
||||
setFile(file)
|
||||
|
||||
// Read and parse headers
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const data = new Uint8Array(event.target.result)
|
||||
const XLSX = window.XLSX
|
||||
const wb = XLSX.read(data, { type: 'array' })
|
||||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||
const range = XLSX.utils.decode_range(ws['!ref'])
|
||||
|
||||
// Get headers
|
||||
const headersRow = []
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const addr = XLSX.utils.encode_cell({ r: 0, c: C })
|
||||
headersRow.push(ws[addr]?.v || `Column ${C + 1}`)
|
||||
}
|
||||
setHeaders(headersRow)
|
||||
|
||||
// Get preview (first 5 rows)
|
||||
const previewRows = []
|
||||
for (let R = 1; R <= Math.min(5, range.e.r); ++R) {
|
||||
const row = []
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const addr = XLSX.utils.encode_cell({ r: R, c: C })
|
||||
row.push(ws[addr]?.v || '')
|
||||
}
|
||||
previewRows.push(row)
|
||||
}
|
||||
setPreview(previewRows)
|
||||
|
||||
// Auto-map using Excel column names directly
|
||||
const autoMapping = {}
|
||||
headersRow.forEach((header, idx) => {
|
||||
if (header && header.trim()) {
|
||||
autoMapping[idx] = header.trim()
|
||||
}
|
||||
})
|
||||
|
||||
setMapping(autoMapping)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
toast.error('讀取 Excel 文件失敗')
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
const handleMappingChange = (colIndex, field) => {
|
||||
setMapping(prev => ({ ...prev, [colIndex]: field }))
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('update_existing', updateExisting.toString())
|
||||
|
||||
const { data } = await api.post(`/api/import/${section}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
setResult(data)
|
||||
setStep(3)
|
||||
toast.success('Import completed!')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || 'Import failed')
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Excel Import</h1>
|
||||
<p className="text-slate-500 mt-1">上傳 Excel 文件批量導入數據</p>
|
||||
</div>
|
||||
|
||||
{/* Section selector */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-slate-700">選擇類別:</span>
|
||||
<button
|
||||
onClick={() => setSection('attendance')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
section === 'attendance'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
出勤 Attendance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSection('accident')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
section === 'accident'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
意外 Accident
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadTemplate(section)}
|
||||
className="ml-auto flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md font-medium hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download size={16} />
|
||||
下載範本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress steps */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-medium ${
|
||||
step >= s ? 'bg-primary-600 text-white' : 'bg-slate-200 text-slate-500'
|
||||
}`}>
|
||||
{s}
|
||||
</div>
|
||||
<span className={`text-sm ${step >= s ? 'text-primary-600' : 'text-slate-400'}`}>
|
||||
{s === 1 ? '上傳檔案' : s === 2 ? '欄位映射' : '完成'}
|
||||
</span>
|
||||
{s < 3 && <ArrowRight size={16} className="text-slate-300 mx-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
{step === 1 && (
|
||||
<div className="card p-12">
|
||||
<div
|
||||
className="border-2 border-dashed border-slate-300 rounded-lg p-12 text-center hover:border-primary-400 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={48} className="mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-lg font-medium text-slate-700">點擊上傳 Excel 文件</p>
|
||||
<p className="text-sm text-slate-500 mt-2">支援 .xlsx, .xls 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Mapping & Preview */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
{/* File info */}
|
||||
<div className="card p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{file?.name}</div>
|
||||
<div className="text-sm text-slate-500">{preview.length + 1} rows</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setStep(1); setFile(null); setHeaders([]); setPreview([]); setMapping({}) }}
|
||||
className="text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
重新上傳
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mapping */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="font-medium text-slate-900">欄位映射 Field Mapping</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">將 Excel 欄位映射到系統欄位</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{headers.map((header, idx) => (
|
||||
<div key={idx} className="grid grid-cols-2 gap-4 items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-700">{header}</div>
|
||||
<div className="text-xs text-slate-400">Column {idx + 1}</div>
|
||||
</div>
|
||||
<select
|
||||
value={mapping[idx] || ''}
|
||||
onChange={(e) => handleMappingChange(idx, e.target.value)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">-- 不映射 --</option>
|
||||
{section === 'attendance' ? (
|
||||
<>
|
||||
<option value="employee_id">員工編號 Employee ID</option>
|
||||
<option value="employee_name">員工姓名 Employee Name</option>
|
||||
<option value="department">部門 Department</option>
|
||||
<option value="date">日期 Date</option>
|
||||
<option value="check_in">上班時間 Check In</option>
|
||||
<option value="check_out">下班時間 Check Out</option>
|
||||
<option value="status">狀態 Status</option>
|
||||
<option value="overtime_hours">加班時數 OT Hours</option>
|
||||
<option value="leave_type">請假類型 Leave Type</option>
|
||||
<option value="remarks">備註 Remarks</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="date">日期 Date</option>
|
||||
<option value="time">時間 Time</option>
|
||||
<option value="location">地點 Location</option>
|
||||
<option value="employee_name">員工姓名 Employee Name</option>
|
||||
<option value="employee_id">員工編號 Employee ID</option>
|
||||
<option value="department">部門 Department</option>
|
||||
<option value="description">描述 Description</option>
|
||||
<option value="severity">嚴重程度 Severity</option>
|
||||
<option value="medical_report">醫療報告 Medical Report</option>
|
||||
<option value="action_taken">行動 Action Taken</option>
|
||||
<option value="responsible_person">負責人 Responsible Person</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="font-medium text-slate-900">預覽 Preview</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} className="px-4 py-2 text-left text-xs font-medium text-slate-600">
|
||||
{h}
|
||||
{mapping[i] && (
|
||||
<span className="ml-2 text-primary-600">→ {mapping[i]}</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{preview.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-4 py-2 text-slate-700">
|
||||
{String(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="card p-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={updateExisting}
|
||||
onChange={(e) => setUpdateExisting(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">更新已存在的記錄</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
如果數據已存在,則更新而非跳過(以 Employee ID + Date 判斷重複)
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Import button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || Object.keys(mapping).length === 0}
|
||||
className="btn-primary px-8 py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{importing ? '導入中...' : '開始導入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Result */}
|
||||
{step === 3 && result && (
|
||||
<div className="card p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle size={32} className="text-green-600" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">導入完成!</h2>
|
||||
<p className="text-slate-500 mb-8">已完成數據導入,以下是結果摘要</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-green-600">{result.created}</div>
|
||||
<div className="text-sm text-green-700">已建立</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-blue-600">{result.updated}</div>
|
||||
<div className="text-sm text-blue-700">已更新</div>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-amber-600">{result.skipped}</div>
|
||||
<div className="text-sm text-amber-700">已跳過</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="text-left card p-4 mb-8 border border-red-200">
|
||||
<div className="flex items-center gap-2 text-red-600 font-medium mb-2">
|
||||
<AlertCircle size={18} />
|
||||
錯誤 ({result.errors.length})
|
||||
</div>
|
||||
<ul className="text-sm text-red-600 space-y-1">
|
||||
{result.errors.slice(0, 10).map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => { setStep(1); setFile(null); setHeaders([]); setPreview([]); setMapping({}); setResult(null) }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
繼續導入
|
||||
</button>
|
||||
<Link
|
||||
to={section === 'attendance' ? '/attendance' : '/accident'}
|
||||
className="btn-primary"
|
||||
>
|
||||
查看列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Upload, CheckCircle, Trash2, Clock, Users } from 'lucide-react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function RosterPage() {
|
||||
const [rosterData, setRosterData] = useState({ headers: [], rows: [] })
|
||||
const [rosterInfo, setRosterInfo] = useState({ uploaded: false })
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadRosterData = async () => {
|
||||
try {
|
||||
const [infoRes, dataRes] = await Promise.all([
|
||||
api.get('/api/roster/info'),
|
||||
api.get('/api/roster/data').catch(() => ({ data: { headers: [], rows: [] } }))
|
||||
])
|
||||
setRosterInfo(infoRes.data)
|
||||
setRosterData(dataRes.data || { headers: [], rows: [] })
|
||||
} catch (err) {
|
||||
console.error('Load error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRosterData()
|
||||
}, [])
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
await api.post('/api/roster/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
toast.success('上傳成功!')
|
||||
setFile(null)
|
||||
loadRosterData()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '上傳失敗')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('確定要刪除班次表嗎?')) return
|
||||
try {
|
||||
await api.delete('/api/roster')
|
||||
toast.success('已刪除')
|
||||
setRosterData({ headers: [], rows: [] })
|
||||
setRosterInfo({ uploaded: false })
|
||||
} catch (err) {
|
||||
toast.error('刪除失敗')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center">載入中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Upload size={20} />
|
||||
上傳班次表
|
||||
</h2>
|
||||
|
||||
{!rosterInfo.uploaded ? (
|
||||
<form onSubmit={handleUpload} className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
選擇班次表 Excel 檔案
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={!file || uploading} className="btn-primary">
|
||||
{uploading ? '上傳中...' : '上傳'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="text-green-500" size={24} />
|
||||
<div>
|
||||
<p className="font-medium">已上傳班次表</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{rosterInfo.rows} 個班次,{rosterInfo.columns} 欄
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleDelete} className="btn-danger">
|
||||
<Trash2 size={16} className="inline mr-1" />
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Roster Table */}
|
||||
{rosterData.headers.length > 0 && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock size={20} />
|
||||
班次表
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 border-b border-slate-200">
|
||||
<tr>
|
||||
{rosterData.headers.map((header, idx) => (
|
||||
<th key={idx} className="px-3 py-2 text-left text-xs font-semibold text-slate-600">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rosterData.rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="hover:bg-slate-50">
|
||||
{rosterData.headers.map((header, colIdx) => (
|
||||
<td key={colIdx} className="px-3 py-2 text-slate-700">
|
||||
{row[colIdx] !== null && row[colIdx] !== undefined
|
||||
? String(row[colIdx])
|
||||
: '-'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
{!rosterInfo.uploaded && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">如何使用</h2>
|
||||
<ol className="list-decimal list-inside space-y-2 text-slate-600">
|
||||
<li>上傳包含班次資料的 Excel 檔案</li>
|
||||
<li>Excel 需要有「班次」同「描述」欄</li>
|
||||
<li>上傳後可以睇到所有班次</li>
|
||||
<li>喺 Attendance Excel 入面加「班次」欄,填入員工所屬班次代碼</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Upload, FileSpreadsheet, CheckCircle, AlertCircle, Download, Trash2 } from 'lucide-react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'accident', label: '意外 Accident', color: 'red' },
|
||||
{ key: 'attendance', label: '出勤 Attendance', color: 'blue' },
|
||||
]
|
||||
|
||||
export default function UploadPage() {
|
||||
const [section, setSection] = useState('accident')
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [info, setInfo] = useState(null)
|
||||
const fileInputRef = useRef()
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
const currentSection = SECTIONS.find(s => s.key === section)
|
||||
|
||||
// Load current file info when section changes
|
||||
useEffect(() => {
|
||||
loadSectionInfo()
|
||||
}, [section])
|
||||
|
||||
async function loadSectionInfo() {
|
||||
try {
|
||||
const { data } = await api.get(`/api/${section}/info`)
|
||||
setInfo(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!confirm(`確定要刪除 ${section} 的所有數據嗎?這個操作無法撤銷!`)) return
|
||||
try {
|
||||
await api.delete(`/api/upload/${section}`)
|
||||
toast.success(`${section} 數據已清除`)
|
||||
setInfo({ uploaded: false, rows: 0, columns: 0, headers: [] })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '清除失敗')
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(e) {
|
||||
const f = e.target.files[0]
|
||||
if (f) {
|
||||
setFile(f)
|
||||
setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setResult(null)
|
||||
setUploadProgress(0)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
// Simulate progress
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(p => Math.min(p + 10, 90))
|
||||
}, 200)
|
||||
|
||||
const { data } = await api.post(`/api/upload/${section}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total))
|
||||
}
|
||||
})
|
||||
|
||||
clearInterval(progressInterval)
|
||||
setUploadProgress(100)
|
||||
|
||||
setResult(data)
|
||||
toast.success(`${section === 'accident' ? '意外' : '出勤'} 數據上傳成功!`)
|
||||
setFile(null)
|
||||
loadSectionInfo()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '上傳失敗')
|
||||
setUploadProgress(0)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelUpload() {
|
||||
// Cancel is handled by just resetting the state
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
setFile(null)
|
||||
toast.success('已取消上傳')
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
try {
|
||||
const { data: tokenData } = await api.post('/api/auth/login', {
|
||||
email: 'admin@aars.hk',
|
||||
password: 'Admin@1234'
|
||||
})
|
||||
|
||||
const token = tokenData.access_token
|
||||
window.location.href = `https://excel.donton.cloud/api/export/${section}/excel?token=${token}`
|
||||
} catch (err) {
|
||||
toast.error('Download failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">上傳 Excel</h1>
|
||||
<p className="text-slate-500 mt-1">直接上傳 Excel 檔案作為數據源</p>
|
||||
</div>
|
||||
|
||||
{/* Section selector */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-slate-700">選擇類別:</span>
|
||||
{SECTIONS.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
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'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current file info */}
|
||||
{info && (
|
||||
<div className="card p-4 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{section === 'accident' ? '意外' : '出勤'} 數據:
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
{info.uploaded
|
||||
? `${info.rows} 行, ${info.columns} 列`
|
||||
: '尚未上傳'}
|
||||
</div>
|
||||
{info.headers && info.headers.length > 0 && (
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
欄位:{info.headers.slice(0, 5).join(', ')}
|
||||
{info.headers.length > 5 && '...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{info.uploaded && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
<Download size={16} />
|
||||
下載
|
||||
</button>
|
||||
)}
|
||||
{info.uploaded && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<div className="card p-8">
|
||||
<div
|
||||
className="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:border-primary-400 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={48} className="mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-lg font-medium text-slate-700">
|
||||
{file ? file.name : '點擊選擇 Excel 檔案'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-2">支援 .xlsx, .xls 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium">{file.name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCancelUpload}
|
||||
disabled={!uploading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-md hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? '上傳中...' : '上傳'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{uploading && uploadProgress > 0 && (
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="card p-4 bg-green-50 border border-green-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium text-green-900">{result.message}</div>
|
||||
<div className="text-sm text-green-700">
|
||||
{result.rows} 行數據已就緒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="card p-4 bg-amber-50 border border-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium text-amber-900">注意事項</div>
|
||||
<ul className="text-sm text-amber-800 mt-2 space-y-1">
|
||||
<li>• 上傳的 Excel 將直接作為數據源</li>
|
||||
<li>• 第一行必須是欄位名稱</li>
|
||||
<li>• 之後每行是一條記錄</li>
|
||||
<li>• 現有數據會被新上傳的文件替換</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'Noto Sans TC', 'system-ui', 'sans-serif'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
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,52 @@
|
||||
#!/bin/bash
|
||||
# AARS Setup Script
|
||||
# Run this on VPS to setup the project
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== AARS Setup Script ==="
|
||||
echo ""
|
||||
|
||||
# Generate JWT secret if not exists
|
||||
if [ ! -f backend/secret.py ] || grep -q "change-me" backend/secret.py; then
|
||||
JWT_SECRET="aars-jwt-$(date +%s)-$(head -c 32 /dev/urandom | base64)"
|
||||
echo "JWT_SECRET=$JWT_SECRET" > backend/secret.py
|
||||
echo "✅ Generated JWT secret"
|
||||
else
|
||||
echo "✅ JWT secret already exists"
|
||||
fi
|
||||
|
||||
# Create data directory
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
|
||||
# Build Docker image
|
||||
echo ""
|
||||
echo "=== Building Docker image ==="
|
||||
docker compose build
|
||||
|
||||
echo ""
|
||||
echo "=== Starting container ==="
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "=== Waiting for container to be healthy ==="
|
||||
sleep 5
|
||||
|
||||
# Check health
|
||||
for i in {1..10}; do
|
||||
if curl -sf http://localhost:18775/api/auth/me > /dev/null 2>&1; then
|
||||
echo "✅ Container is healthy!"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Waiting for container... ($i/10)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete! ==="
|
||||
echo ""
|
||||
echo "📍 AARS URL: http://187.127.116.15:18775"
|
||||
echo "👤 Default login: admin@aars.hk / admin123"
|
||||
echo ""
|
||||
echo "⚠️ Please change the default password after first login!"
|
||||
Reference in New Issue
Block a user