Initial commit: AARS backend + frontend + holiday/leave phase 1+2

This commit is contained in:
IT Dog
2026-07-19 20:10:15 +08:00
commit bbc0048c24
49 changed files with 13375 additions and 0 deletions
+23
View File
@@ -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
View File
@@ -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"]
+456
View File
@@ -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.
+377
View File
@@ -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 + passwordJWT 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
+1
View File
@@ -0,0 +1 @@
logs/
+72
View File
@@ -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
+72
View File
@@ -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()
+251
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -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},
)
+96
View File
@@ -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)
+215
View File
@@ -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)
+80
View File
@@ -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)
+11
View File
@@ -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
+187
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# AARS JWT Secret - CHANGE THIS IN PRODUCTION!
JWT_SECRET = "aars-jwt-secret-2026-prod-do-not-share"
+65
View File
@@ -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")
Executable
+30
View File
@@ -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 "=========================================="
+39
View File
@@ -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/"
+22
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
logs/
+20
View File
@@ -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>
+3380
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+57
View File
@@ -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>
)
}
+53
View File
@@ -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
+73
View File
@@ -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
}
}
+194
View File
@@ -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>
)
}
+124
View File
@@ -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>
}
+52
View File
@@ -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)
+39
View File
@@ -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;
}
}
+138
View File
@@ -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 }
+25
View File
@@ -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>,
)
+646
View File
@@ -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>
)
}
+87
View File
@@ -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>
)
}
+291
View File
@@ -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>
)
}
+170
View File
@@ -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>
)
}
+323
View File
@@ -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>
)
}
+228
View File
@@ -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>
)
}
+309
View File
@@ -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>
)
}
+713
View File
@@ -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>
)
}
+394
View File
@@ -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>
)
}
+169
View File
@@ -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>
)
}
+278
View File
@@ -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>
)
}
+29
View File
@@ -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: [],
}
+16
View File
@@ -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,
}
}
}
})
+19
View File
@@ -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"
+52
View File
@@ -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!"