From bbc0048c24d16815fd4692f4d6aa0c9663f8d270 Mon Sep 17 00:00:00 2001 From: IT Dog Date: Sun, 19 Jul 2026 20:10:15 +0800 Subject: [PATCH] Initial commit: AARS backend + frontend + holiday/leave phase 1+2 --- .gitignore | 23 + Dockerfile | 40 + README.md | 456 +++ SPEC.md | 377 +++ backend/.gitignore | 1 + backend/auth.py | 72 + backend/database.py | 72 + backend/logging_config.py | 251 ++ backend/main.py | 3318 ++++++++++++++++++++ backend/middleware.py | 98 + backend/migrate_phase2.py | 96 + backend/models.py | 215 ++ backend/parsers/__init__.py | 80 + backend/requirements.txt | 11 + backend/schemas.py | 187 ++ backend/secret.py | 2 + backend/test_backend.py | 65 + deploy.sh | 30 + deploy_static.sh | 39 + docker-compose.yml | 22 + frontend/.gitignore | 1 + frontend/index.html | 20 + frontend/package-lock.json | 3380 +++++++++++++++++++++ frontend/package.json | 34 + frontend/postcss.config.js | 6 + frontend/src/App.jsx | 57 + frontend/src/api.js | 53 + frontend/src/components/ErrorBoundary.jsx | 73 + frontend/src/components/Layout.jsx | 194 ++ frontend/src/components/StatusBadge.jsx | 124 + frontend/src/context/AuthContext.jsx | 52 + frontend/src/index.css | 39 + frontend/src/lib/logger.js | 138 + frontend/src/main.jsx | 25 + frontend/src/pages/Dashboard.jsx | 646 ++++ frontend/src/pages/Login.jsx | 87 + frontend/src/pages/accident/Dashboard.jsx | 291 ++ frontend/src/pages/accident/Detail.jsx | 170 ++ frontend/src/pages/accident/List.jsx | 323 ++ frontend/src/pages/attendance/Detail.jsx | 228 ++ frontend/src/pages/attendance/List.jsx | 309 ++ frontend/src/pages/holidays/Holidays.jsx | 713 +++++ frontend/src/pages/import/Import.jsx | 394 +++ frontend/src/pages/roster/Roster.jsx | 169 ++ frontend/src/pages/upload/Upload.jsx | 278 ++ frontend/tailwind.config.js | 29 + frontend/vite.config.js | 16 + scripts/logs-cleanup.sh | 19 + setup.sh | 52 + 49 files changed, 13375 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 backend/.gitignore create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/logging_config.py create mode 100644 backend/main.py create mode 100644 backend/middleware.py create mode 100644 backend/migrate_phase2.py create mode 100644 backend/models.py create mode 100644 backend/parsers/__init__.py create mode 100644 backend/requirements.txt create mode 100644 backend/schemas.py create mode 100644 backend/secret.py create mode 100644 backend/test_backend.py create mode 100755 deploy.sh create mode 100755 deploy_static.sh create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/ErrorBoundary.jsx create mode 100644 frontend/src/components/Layout.jsx create mode 100644 frontend/src/components/StatusBadge.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/logger.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/Dashboard.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/accident/Dashboard.jsx create mode 100644 frontend/src/pages/accident/Detail.jsx create mode 100644 frontend/src/pages/accident/List.jsx create mode 100644 frontend/src/pages/attendance/Detail.jsx create mode 100644 frontend/src/pages/attendance/List.jsx create mode 100644 frontend/src/pages/holidays/Holidays.jsx create mode 100644 frontend/src/pages/import/Import.jsx create mode 100644 frontend/src/pages/roster/Roster.jsx create mode 100644 frontend/src/pages/upload/Upload.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100755 scripts/logs-cleanup.sh create mode 100644 setup.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbfc672 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7d6d300 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b354143 --- /dev/null +++ b/README.md @@ -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. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..0b015e8 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,377 @@ +# AARS - Attendance & Accident Record System + +## 1. Concept & Vision + +一個為中小企設計嘅出勤同意外記錄系統。介面乾淨、專業、易用,帶有香港本地特色(繁體中文、HKT時區)。兩大一頁式 dashboard,展示即時數據,所有記錄都可以 filter、sort、export。 + +目標係取代 Excel 試算表,做一個可以多人協作、數據唔會重複、report 一click 生成嘅系統。 + +--- + +## 2. Design Language + +**Aesthetic:** 淺色專業商務風,白底配淺灰,帶有 System Blue 強調色。似 classic Notion / Linear 嗰種乾淨感覺,但再 warm 少少。 + +**Colors (TailwindCSS custom):** +- Primary: `#2563EB` (blue-600) +- Primary Dark: `#1D4ED8` (blue-700) +- Accent: `#06B6D4` (cyan-500) +- Background: `#F8FAFC` (slate-50) +- Card: `#FFFFFF` +- Border: `#E2E8F0` (slate-200) +- Text Primary: `#0F172A` (slate-900) +- Text Secondary: `#64748B` (slate-500) +- Success: `#10B981` (emerald-500) +- Warning: `#F59E0B` (amber-500) +- Danger: `#EF4444` (red-500) + +**Typography:** +- Font: Inter (Google Fonts) + Noto Sans TC (繁體中文) +- Headings: 600-700 weight +- Body: 400-500 weight + +**Spatial System:** +- Base unit: 4px +- Card padding: 24px +- Section gap: 32px +- Border radius: 8px (cards), 6px (buttons), 4px (inputs) + +**Motion:** +- Page transitions: fade 150ms +- Hover states: 200ms ease +- Loading: pulse animation +- No bouncy/playful animations - keep it professional + +--- + +## 3. Layout & Structure + +``` +┌─────────────────────────────────────────────────────┐ +│ Header: Logo + Nav (Attendance | Accident) + User │ +├─────────────────────────────────────────────────────┤ +│ Page Content: │ +│ ┌─ Dashboard ─┐ ┌─ List View ─┐ ┌─ Detail ─┐ │ +│ │ Stats cards │ │ Table + Fn │ │ Edit form│ │ +│ │ Charts │ │ Filter/Sort │ │ History │ │ +│ └────────────┘ └─────────────┘ └─────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**Pages:** +1. **Login** (`/login`) - Email + password,JWT auth +2. **Dashboard** (`/`) - Stats cards + recent activity + quick actions +3. **Attendance List** (`/attendance`) - Table with all records +4. **Attendance Detail** (`/attendance/:id`) - View/Edit single record +5. **Accident List** (`/accident`) - Table with all records +6. **Accident Detail** (`/accident/:id`) - View/Edit single record +7. **Import** (`/import`) - Excel upload + mapping + preview + +**Responsive:** Desktop-first,但 table 可以 horizontal scroll 喺 mobile + +--- + +## 4. Features & Interactions + +### 4.1 Authentication +- Login with email + password +- JWT token stored in localStorage +- Token expiry: 24 hours +- Auto-redirect to login if expired + +### 4.2 Dashboard +**Attendance Section:** +- Total records count +- Today attendance count +- This month attendance count +- Records trend chart (last 7 days / 30 days) + +**Accident Section:** +- Total records count +- This month accidents count +- Unresolved accidents count +- Recent accidents list (last 5) + +### 4.3 List View +- Paginated table (20 per page) +- Columns: ID, Date, Employee Name, Department, Status, Actions +- **Filter:** By date range, department, status, search text +- **Sort:** Click column header to sort ASC/DESC +- **Actions:** View, Edit, Delete (soft delete) +- **Bulk Actions:** Select multiple → Delete / Export selected +- **Export:** PDF (single/all selected) / Excel (all filtered results) + +### 4.4 Detail / Edit View +- Form with all fields +- Read-only view mode vs Edit mode +- **History tab:** Show who created/modified when +- **Save:** Validates + saves to SQLite +- **Cancel:** Returns to list without saving + +### 4.5 Excel Import +- Upload Excel file (.xlsx, .xls) +- Auto-detect columns from first row +- **Field mapping:** Map Excel columns to system fields +- **Duplicate detection:** Check by unique key (Employee ID + Date for attendance, Date + Location for accident) +- **Preview:** Show first 10 rows with validation status +- **Options:** + - "Update existing" → skip duplicates or update + - "Create new only" → skip duplicates +- **Import button:** Bulk insert/update +- **Result:** Show success count, skipped (duplicates), errors + +### 4.6 Field Management +- Default fields per section (pre-defined) +- **Add custom field:** Name, type (text/number/date/select), required +- Custom fields stored in JSON column +- Can be used in list view, detail view, export + +### 4.7 Export +- **Excel:** All filtered results, includes all fields +- **PDF:** Formatted report with header, table, pagination +- Export respects current filter/sort state + +--- + +## 5. Component Inventory + +### 5.1 Header +- Logo (left): "AARS" text logo +- Nav tabs (center): "出勤 Attendance" | "意外 Accident" +- Active tab: blue underline +- User menu (right): Username + dropdown (Profile, Logout) + +### 5.2 Stats Card +- Icon (colored circle bg) + metric value (large) + label (small) +- Hover: subtle shadow lift + +### 5.3 Data Table +- Header row: grey bg, bold text +- Sortable columns: sort icon, click to toggle +- Row hover: light blue bg +- Checkbox column for bulk select +- Action buttons: View (blue), Edit (gray), Delete (red) +- Empty state: "No records found" + illustration + +### 5.4 Filter Bar +- Date range picker (two inputs) +- Dropdown for department/status +- Text search input +- "Clear filters" button +- "Export" button + +### 5.5 Form +- Label + input pairs, stacked +- Required indicator: red asterisk +- Input states: default, focus (blue ring), error (red border + message) +- Select dropdowns, date pickers +- Submit button (blue), Cancel button (gray outline) + +### 5.6 Modal +- Centered overlay, white card +- Header + body + footer +- Close X button top right +- Backdrop click to close + +### 5.7 Toast Notifications +- Success (green), Error (red), Info (blue) +- Auto-dismiss after 3s +- Bottom right corner + +### 5.8 Import Wizard +- Step 1: Upload Excel +- Step 2: Map columns +- Step 3: Preview + validate +- Step 4: Confirm + import +- Progress indicator at top + +--- + +## 6. Technical Approach + +### 6.1 Backend (FastAPI) +``` +backend/ +├── main.py # FastAPI app entry +├── database.py # SQLite + SQLAlchemy +├── models.py # SQLAlchemy models +├── schemas.py # Pydantic schemas +├── auth.py # JWT auth +├── routers/ +│ ├── attendance.py +│ ├── accident.py +│ ├── import.py +│ └── export.py +└── utils/ + ├── excel.py # openpyxl helpers + └── pdf.py # reportlab helpers +``` + +**API Endpoints:** +``` +POST /api/auth/login +GET /api/auth/me + +GET /api/attendance # List with filter/sort/paginate +POST /api/attendance # Create +GET /api/attendance/:id # Get one +PUT /api/attendance/:id # Update +DELETE /api/attendance/:id # Soft delete + +GET /api/accident # List with filter/sort/paginate +POST /api/accident # Create +GET /api/accident/:id # Get one +PUT /api/accident/:id # Update +DELETE /api/accident/:id # Soft delete + +POST /api/import/attendance # Excel import +POST /api/import/accident # Excel import + +GET /api/export/attendance/excel +GET /api/export/attendance/pdf +GET /api/export/accident/excel +GET //api/export/accident/pdf + +GET /api/dashboard/attendance # Stats +GET /api/dashboard/accident # Stats + +GET /api/fields/attendance # Custom fields +POST /api/fields/attendance +GET /api/fields/accident +POST /api/fields/accident +``` + +### 6.2 Database Schema + +**Users:** +```sql +id, email, password_hash, name, role, created_at, updated_at, is_active +``` + +**Attendance:** +```sql +id, employee_id, employee_name, department, date, check_in, check_out, +overtime_hours, leave_type, leave_hours, status, remarks, +custom_fields (JSON), created_by, created_at, updated_at, deleted_at +``` + +**Accident:** +```sql +id, date, time, location, employee_name, employee_id, department, +description, severity, medical_report, action_taken, responsible_person, +custom_fields (JSON), created_by, created_at, updated_at, deleted_at +``` + +**AttendanceCustomFields / AccidentCustomFields:** +```sql +id, field_name, field_type, required, options, order, created_at +``` + +### 6.3 Frontend (React + Vite) +``` +frontend/ +├── src/ +│ ├── App.jsx +│ ├── main.jsx +│ ├── api.js # Axios API calls +│ ├── context/ +│ │ └── AuthContext.jsx +│ ├── pages/ +│ │ ├── Login.jsx +│ │ ├── Dashboard.jsx +│ │ ├── attendance/ +│ │ │ ├── List.jsx +│ │ │ └── Detail.jsx +│ │ ├── accident/ +│ │ │ ├── List.jsx +│ │ │ └── Detail.jsx +│ │ └── import/ +│ │ └── Import.jsx +│ └── components/ +│ ├── Layout.jsx +│ ├── DataTable.jsx +│ ├── FilterBar.jsx +│ ├── StatsCard.jsx +│ ├── Modal.jsx +│ └── Toast.jsx +├── index.html +├── tailwind.config.js +└── vite.config.js +``` + +### 6.4 Docker Setup +```yaml +# docker-compose.yml +services: + aars: + build: . + ports: + - "18775:8000" + volumes: + - ./data:/app/data + environment: + - TIMEZONE=Asia/Hong_Kong + - SECRET_KEY= + 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 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..333c1e9 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +logs/ diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..12c5403 --- /dev/null +++ b/backend/auth.py @@ -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 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..5ec9af3 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/logging_config.py b/backend/logging_config.py new file mode 100644 index 0000000..dba1848 --- /dev/null +++ b/backend/logging_config.py @@ -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 ``-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") diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d60debd --- /dev/null +++ b/backend/main.py @@ -0,0 +1,3318 @@ +import os +import secret +from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, RedirectResponse +from sqlalchemy.orm import Session +from datetime import date, datetime, timedelta +from typing import Optional, List +import json + +from database import get_db, create_access_token, verify_password, init_db, Base, engine +from models import User, AttendanceRecord, Shift, Accident, Holiday, LeaveRecord +from sqlalchemy import func as sqlfunc +from schemas import * +from auth import get_current_user, get_current_admin, get_current_user_from_query +from pydantic import BaseModel, Field + + +# ============ Pydantic Schemas (Holidays + Leave) ============ +class HolidayCreate(BaseModel): + date: date + name: str + name_en: Optional[str] = None + region: Optional[str] = "HK" + is_mandatory: Optional[bool] = True + notes: Optional[str] = None + + +class HolidayUpdate(BaseModel): + date: Optional[date] = None + name: Optional[str] = None + name_en: Optional[str] = None + region: Optional[str] = None + is_mandatory: Optional[bool] = None + notes: Optional[str] = None + + +class HolidayOut(BaseModel): + id: int + date: date + name: str + name_en: Optional[str] = None + region: Optional[str] = None + is_mandatory: Optional[bool] = None + source: Optional[str] = None + notes: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class LeaveCreate(BaseModel): + employee_name: str + employee_id: Optional[str] = None + date: date + leave_type: str + hours: float + reason: Optional[str] = None + approved_by: Optional[str] = None + notes: Optional[str] = None + + +class LeaveUpdate(BaseModel): + employee_name: Optional[str] = None + employee_id: Optional[str] = None + date: Optional[date] = None + leave_type: Optional[str] = None + hours: Optional[float] = None + reason: Optional[str] = None + approved_by: Optional[str] = None + notes: Optional[str] = None + + +class LeaveOut(BaseModel): + id: int + employee_name: str + employee_id: Optional[str] = None + date: date + leave_type: str + hours: float + reason: Optional[str] = None + approved_by: Optional[str] = None + source: Optional[str] = None + notes: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +def enrich_attendance_with_leave(records, db): + """Apply holiday + leave override to attendance records (in-place).""" + from collections import defaultdict as _dd + + if not records: + return records + + dates = [r.date for r in records if r.date] + if not dates: + return records + min_date, max_date = min(dates), max(dates) + + holidays = db.query(Holiday).filter( + Holiday.date >= min_date, Holiday.date <= max_date + ).all() + holidays_by_date = {h.date: h for h in holidays} + + emp_names = list({r.employee_name for r in records if r.employee_name}) + leaves = [] + if emp_names: + leaves = db.query(LeaveRecord).filter( + LeaveRecord.date >= min_date, LeaveRecord.date <= max_date, + LeaveRecord.employee_name.in_(emp_names), + ).all() + leave_by_emp_date = _dd(lambda: _dd(list)) + for lv in leaves: + leave_by_emp_date[lv.employee_name][lv.date].append(lv) + + for r in records: + if not r.date: + continue + + if r.date in holidays_by_date: + h = holidays_by_date[r.date] + r.status_code = "holiday" + r.status_text = f"\U0001F3D6\uFE0F{h.name}" + continue + + emp_leaves = leave_by_emp_date.get(r.employee_name, {}).get(r.date, []) + if not emp_leaves: + continue + + leave_by_type = {} + for lv in emp_leaves: + t = (lv.leave_type or "").upper() + h_val = float(lv.hours or 0) + leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val + + leave_text = " + ".join(f"{t}{h}h" for t, h in sorted(leave_by_type.items())) + + original_code = (r.status_code or "").lower() + if original_code == "missing": + types = sorted(leave_by_type.keys()) + if len(types) == 1: + r.status_code = types[0].lower() + else: + r.status_code = "mixed_leave" + r.status_text = leave_text + else: + base = r.status_text or "正常" + r.status_text = f"{base} + {leave_text}" + + return records + + +import logging +from logging_config import setup_logging, set_request_id, set_user_id +from middleware import ( + RequestIDMiddleware, + AccessLogMiddleware, + unhandled_exception_handler, +) + +setup_logging() +logger = logging.getLogger(__name__) + +app = FastAPI(title="AARS API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Structured logging middleware +app.add_middleware(AccessLogMiddleware) +app.add_middleware(RequestIDMiddleware) + +# Catch-all exception handler (after middleware) +app.add_exception_handler(Exception, unhandled_exception_handler) + +# Data directory for storing uploaded Excel files +DATA_DIR = "/app/data" +os.makedirs(DATA_DIR, exist_ok=True) + +@app.on_event("startup") +async def startup(): + init_db() + logger.info("AARS backend started") + +# ============ Auth ============ +@app.post("/api/auth/login", response_model=TokenResponse) +async def login(form_data: LoginRequest, db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == form_data.email).first() + if not user or not verify_password(form_data.password, user.password_hash): + logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "invalid_credentials"}}) + raise HTTPException(status_code=401, detail="Invalid email or password") + set_user_id(user.id) + access_token = create_access_token({"sub": str(user.id)}) + logger.info("login success", extra={"context": {"user_id": user.id, "email": form_data.email}}) + return TokenResponse(access_token=access_token) + +@app.get("/api/auth/me", response_model=UserResponse) +async def get_me(current_user: User = Depends(get_current_user)): + return current_user + +# ============ Excel File Storage ============ +def get_excel_path(section: str) -> str: + """Get path to the stored Excel file for a section""" + return os.path.join(DATA_DIR, f"{section}.xlsx") + +def read_excel_file(section: str): + """Read Excel file and return headers and rows""" + import openpyxl + path = get_excel_path(section) + if not os.path.exists(path): + return None, [] + + wb = openpyxl.load_workbook(path, data_only=True) + ws = wb.active + + headers = [cell.value for cell in ws[1]] + rows = list(ws.iter_rows(min_row=2, values_only=True)) + + return headers, rows + +# ============ Upload Excel (Replace entire file) ============ +@app.delete("/api/upload/{section}") +async def delete_uploaded_file( + section: str, + current_user: User = Depends(get_current_user) +): + """Delete the stored Excel file for a section""" + if section not in ["attendance", "accident"]: + raise HTTPException(status_code=400, detail="Invalid section") + + path = get_excel_path(section) + if os.path.exists(path): + os.remove(path) + + return {"message": f"{section} file deleted"} + +@app.post("/api/upload/{section}") +async def upload_excel( + section: str, + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Upload Excel file — saves file AND imports rows into SQL table.""" + if section not in ["attendance", "accident"]: + raise HTTPException(status_code=400, detail="Invalid section") + + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + # Save the file (keep for legacy compatibility + export) + path = get_excel_path(section) + contents = await file.read() + with open(path, "wb") as f: + f.write(contents) + + # Read rows back + headers, rows = read_excel_file(section) + if headers is None: + return { + "message": f"{section} file uploaded (no rows parsed)", + "filename": file.filename, "rows": 0, "columns": 0, "headers": [], + } + + if section == "attendance": + imported = await _import_attendance_to_db(db, headers, rows, current_user.id) + logger.info("attendance import to DB", + extra={"context": {"user_id": current_user.id, + "imported": imported["imported"], + "updated": imported["updated"], + "skipped": imported["skipped"]}}) + return { + "message": f"attendance uploaded + imported", + "filename": file.filename, + "rows": len(rows), + "columns": len(headers), + "headers": headers, + "db_import": imported, + } + + if section == "accident": + imported = await _import_accident_to_db(db, headers, rows, current_user.id) + logger.info("accident import to DB", + extra={"context": {"user_id": current_user.id, + "imported": imported["imported"], + "updated": imported["updated"], + "errors": imported["errors"]}}) + return { + "message": f"accident uploaded + imported", + "filename": file.filename, + "rows": len(rows), + "columns": len(headers), + "headers": headers, + "db_import": imported, + } + + return { + "message": f"{section} file uploaded", + "filename": file.filename, + "rows": len(rows), + "columns": len(headers), + "headers": headers, + } + + +async def _import_attendance_to_db(db: Session, headers, rows, user_id: int) -> dict: + """Parse Excel rows and upsert into attendance_records table. + + Skips records with is_manually_edited=True (manual edits are preserved). + Returns counts: imported (new), updated (existing overwritten), skipped. + """ + # Find column indices from header + col = {} + for i, h in enumerate(headers): + if not h: + continue + hs = str(h).strip().lower() + if hs in ("company",): + col["company"] = i + elif "staff" in hs or "員工" in str(h) or "name" in hs: + col["staff"] = i + elif "dept" in hs or "部門" in str(h): + col["department"] = i + elif hs in ("date", "日期") or "date" in hs: + col["date"] = i + elif "week" in hs or "星期" in str(h): + col["weekday"] = i + elif "check" in hs and "in" in hs or "actual" in hs and "in" in hs or "上班" in str(h) or "實際" in str(h) and "上班" in str(h): + col["check_in"] = i + elif "check" in hs and "out" in hs or "actual" in hs and "out" in hs or "下班" in str(h) or "實際" in str(h) and "下班" in str(h): + col["check_out"] = i + elif "班次" in str(h) or "shift" in hs: + col["shift"] = i + + imported = updated = skipped = errors = 0 + for row in rows: + try: + def cell(k, default=None): + idx = col.get(k) + if idx is None or idx >= len(row): + return default + return row[idx] + + staff = cell("staff") + date_val = cell("date") + if not staff or not date_val: + errors += 1 + continue + + # Parse date + if hasattr(date_val, "date"): + d = date_val.date() if isinstance(date_val, datetime) else date_val + elif isinstance(date_val, date): + d = date_val + elif isinstance(date_val, str): + d = None + for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"): + try: + d = datetime.strptime(date_val, fmt).date() + break + except ValueError: + continue + if d is None: + errors += 1 + continue + else: + errors += 1 + continue + + # Find existing + existing = db.query(AttendanceRecord).filter( + AttendanceRecord.employee_name == staff, + AttendanceRecord.date == d, + ).first() + + if existing and existing.is_manually_edited: + skipped += 1 + continue + + # Parse times + def parse_time(v, fallback_date): + if not v: + return None + if isinstance(v, datetime): + return v + if isinstance(v, date): + return None + if isinstance(v, str): + for fmt in ("%H:%M:%S", "%H:%M"): + try: + return datetime.strptime(v, fmt).time() + except ValueError: + continue + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", + "%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M"): + try: + return datetime.strptime(v, fmt) + except ValueError: + continue + return None + + ci_raw = cell("check_in") + co_raw = cell("check_out") + ci_dt = parse_time(ci_raw, d) + co_dt = parse_time(co_raw, d) + + # Combine date + time + def to_dt(t, base_date): + if t is None: + return None + if isinstance(t, datetime): + return t + if isinstance(t, time): + return datetime.combine(base_date, t) + return None + + ci_full = to_dt(ci_dt, d) + co_full = to_dt(co_dt, d) + + shift_code = cell("shift") + + # Calculate status using existing helper + shift_start = None + shift_end = None + if shift_code: + shift_obj = db.query(Shift).filter(Shift.shift_code == shift_code).first() + if shift_obj: + weekday_en = cell("weekday") or d.strftime("%A") + start_str, end_str = shift_obj.get_schedule(weekday_en) + try: + if start_str and end_str: + shift_start = datetime.strptime(start_str, "%H:%M").time() + shift_end = datetime.strptime(end_str, "%H:%M").time() + except (ValueError, TypeError): + pass + + status = calculate_attendance_status(ci_full, co_full, shift_start, shift_end) + + record = existing or AttendanceRecord( + employee_name=staff, date=d, + ) + record.company = cell("company") + record.department = cell("department") + record.date = d + record.weekday = cell("weekday") + record.check_in = ci_full + record.check_out = co_full + record.shift_code = shift_code + record.status_code = status["status_code"] + record.status_text = status["status_text"] + record.late_minutes = status["late_minutes"] + record.early_minutes = status["early_minutes"] + record.ot_minutes = status["ot_minutes"] + record.expected_in = status["expected_in"] + record.expected_out = status["expected_out"] + record.actual_in = status["actual_in"] + record.actual_out = status["actual_out"] + record.raw_data = {"source_row": list(row)} + + if existing: + updated += 1 + else: + db.add(record) + imported += 1 + except Exception as e: + errors += 1 + logger.error("attendance import row failed", + extra={"context": {"error": str(e)[:200], "row": list(row)[:8]}}) + + db.commit() + return {"imported": imported, "updated": updated, "skipped": skipped, "errors": errors} + + +# ============ Attendance Calculation ============ +def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]: + """Get the expected start and end time for a shift on a given weekday. + Returns (start_time, end_time) tuple. + """ + roster_path = get_excel_path("roster") + if not os.path.exists(roster_path): + return None + + import openpyxl + wb = openpyxl.load_workbook(roster_path, data_only=True) + ws = wb.active + + headers = [cell.value for cell in ws[1]] + + # Find column indices + shift_col = None + for i, h in enumerate(headers): + if h == '班次': + shift_col = i + break + + if shift_col is None: + return None + + # Weekday mapping + weekday_map = { + 'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三', + 'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', + 'Sunday': '星期日', '星期一': '星期一', '星期二': '星期二', + '星期三': '星期三', '星期四': '星期四', '星期五': '星期五', + '星期六': '星期六', '星期日': '星期日' + } + + day_col_map = { + '星期一': None, '星期二': None, '星期三': None, + '星期四': None, '星期五': None, '星期六': None, '星期日': None + } + + for i, h in enumerate(headers): + if h in day_col_map: + day_col_map[h] = i + + target_day = weekday_map.get(weekday, weekday) + + # Find the shift row + for row in ws.iter_rows(min_row=2, values_only=True): + if row[shift_col] == shift_code: + day_col = day_col_map.get(target_day) + if day_col is not None: + schedule = row[day_col] + if schedule and schedule != '-' and schedule != '休息': + # Parse "HH:MM-HH:MM" format + try: + parts = str(schedule).split('-') + if len(parts) == 2: + start_str = parts[0].strip() + end_str = parts[1].strip() + start_time = datetime.strptime(start_str, '%H:%M').time() + end_time = datetime.strptime(end_str, '%H:%M').time() + return (start_time, end_time) + except: + pass + + return None + +def parse_datetime(time_val) -> Optional[datetime]: + """Parse datetime from various formats""" + if not time_val: + return None + if isinstance(time_val, datetime): + return time_val + if isinstance(time_val, str): + for fmt in ['%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%H:%M:%S', '%H:%M']: + try: + return datetime.strptime(time_val, fmt) + except: + continue + return None + +def calculate_attendance_status(check_in, check_out, shift_start, shift_end): + """Calculate attendance status: late_minutes, early_minutes, ot_minutes, is_missing + + Returns: { + "is_missing": bool, + "late_minutes": int, + "early_minutes": int, + "ot_minutes": int, + "status_code": str, # e.g. "late_early", "early_ot", "normal" + "status_text": str, + "expected_in": str, + "expected_out": str, + "actual_in": str, + "actual_out": str + } + """ + result = { + "is_missing": False, + "late_minutes": 0, + "early_minutes": 0, + "ot_minutes": 0, + "status_code": "normal", + "status_text": "正常", + "expected_in": shift_start.strftime("%H:%M") if shift_start else "-", + "expected_out": shift_end.strftime("%H:%M") if shift_end else "-", + "actual_in": "-", + "actual_out": "-" + } + + # Check for missing punch + if not check_in or not check_out: + result["is_missing"] = True + result["status_code"] = "missing" + result["status_text"] = "缺勤" + if check_in: + result["actual_in"] = check_in.strftime("%H:%M") if isinstance(check_in, datetime) else str(check_in) + if check_out: + result["actual_out"] = check_out.strftime("%H:%M") if isinstance(check_out, datetime) else str(check_out) + return result + + # Parse check-in/out times + check_in_dt = parse_datetime(check_in) + check_out_dt = parse_datetime(check_out) + + if not check_in_dt or not check_out_dt: + result["is_missing"] = True + result["status_code"] = "missing" + result["status_text"] = "缺勤" + return result + + result["actual_in"] = check_in_dt.strftime("%H:%M") + result["actual_out"] = check_out_dt.strftime("%H:%M") + + # Skip if invalid times (Excel default "0" = 18:00:00 exactly) + # Only treat second-precise 18:00:00 as the default, not 18:00:03 (real swipe) + if (check_in_dt.hour == 18 and check_in_dt.minute == 0 and check_in_dt.second == 0 + and check_out_dt.hour == 18 and check_out_dt.minute == 0 and check_out_dt.second == 0): + result["is_missing"] = True + result["status_code"] = "missing" + result["status_text"] = "缺勤" + return result + + # Detect same check-in/out (exact same second = forgotten swipe) + if check_in_dt and check_out_dt: + if check_in_dt == check_out_dt: + result["is_missing"] = True + result["status_code"] = "abnormal" + result["status_text"] = "⚠️異常" + return result + + if not shift_start or not shift_end: + # No roster data, can't calculate + return result + + # Calculate late minutes + check_in_time = check_in_dt.time() + if check_in_time > shift_start: + diff = (datetime.combine(check_in_dt.date(), check_in_time) - + datetime.combine(check_in_dt.date(), shift_start)).total_seconds() / 60 + result["late_minutes"] = round(diff) + + # Calculate early minutes (leaving before scheduled end) + check_out_time = check_out_dt.time() + if shift_end > shift_start: + # Handle overnight shifts + if shift_end < shift_start: + # Assume next day + expected_end_dt = datetime.combine(check_out_dt.date() + timedelta(days=1), shift_end) + else: + expected_end_dt = datetime.combine(check_out_dt.date(), shift_end) + + if check_out_dt < expected_end_dt: + diff = (expected_end_dt - check_out_dt).total_seconds() / 60 + result["early_minutes"] = round(diff) + + # Calculate OT minutes (leaving after scheduled end) + if check_out_dt > expected_end_dt: + diff = (check_out_dt - expected_end_dt).total_seconds() / 60 + result["ot_minutes"] = round(diff) + + # Determine status code and text + status_parts = [] + if result["late_minutes"] > 0: + status_parts.append(f"遲到{result['late_minutes']}分") + if result["early_minutes"] > 0: + status_parts.append(f"早退{result['early_minutes']}分") + if result["ot_minutes"] > 0: + status_parts.append(f"OT{result['ot_minutes']}分") + + if result["late_minutes"] > 0 and result["early_minutes"] > 0: + result["status_code"] = "late_early" + elif result["late_minutes"] > 0 and result["ot_minutes"] > 0: + result["status_code"] = "late_ot" + elif result["early_minutes"] > 0 and result["ot_minutes"] > 0: + result["status_code"] = "early_ot" + elif result["late_minutes"] > 0: + result["status_code"] = "late" + elif result["early_minutes"] > 0: + result["status_code"] = "early" + elif result["ot_minutes"] > 0: + result["status_code"] = "ot" + else: + result["status_code"] = "normal" + + result["status_text"] = " / ".join(status_parts) if status_parts else "正常" + return result + + +@app.get("/api/attendance/lateness") +async def get_lateness_stats(current_user: User = Depends(get_current_user)): + """Calculate full attendance statistics: late, early, OT""" + headers, rows = read_excel_file("attendance") + if headers is None or not rows: + return {"stats": [], "total_late": 0, "total_early": 0, "total_ot": 0, "total_missing": 0} + + staff_col = None; date_col = None; week_col = None; checkin_col = None; checkout_col = None; shift_col = None + + for i, h in enumerate(headers): + h_lower = str(h).lower() if h else '' + if 'staff' in h_lower or '員工' in h_lower or 'name' in h_lower: + staff_col = i + elif 'date' in h_lower or '日期' in h_lower: + date_col = i + elif 'week' in h_lower or '星期' in h_lower: + week_col = i + elif 'check' in h_lower and 'in' in h_lower or '上班' in h_lower: + checkin_col = i + elif 'check' in h_lower and 'out' in h_lower or '下班' in h_lower: + checkout_col = i + elif '班次' in h_lower: + shift_col = i + + if staff_col is None or checkin_col is None or shift_col is None: + return {"error": "Missing required columns", "headers": headers} + + employee_stats = {} + totals = {"late": 0, "early": 0, "ot": 0, "missing": 0} + + for row in rows: + staff = row[staff_col]; check_in = row[checkin_col] + check_out = row[checkout_col] if checkout_col is not None else None + shift_code = row[shift_col]; week_day = row[week_col] if week_col is not None else None + + if not staff or not shift_code: + continue + + shift_times = get_shift_schedule_full(shift_code, week_day or '') + shift_start = shift_times[0] if shift_times else None + shift_end = shift_times[1] if shift_times else None + + status = calculate_attendance_status(check_in, check_out, shift_start, shift_end) + + staff_key = str(staff) + if staff_key not in employee_stats: + employee_stats[staff_key] = {"name": staff, "shift": shift_code, "late_count": 0, "late_minutes": 0, "early_count": 0, "early_minutes": 0, "ot_count": 0, "ot_minutes": 0, "missing_count": 0} + + if status["is_missing"]: + employee_stats[staff_key]["missing_count"] += 1 + totals["missing"] += 1 + else: + if status["late_minutes"] > 0: + employee_stats[staff_key]["late_count"] += 1 + employee_stats[staff_key]["late_minutes"] += status["late_minutes"] + totals["late"] += status["late_minutes"] + if status["early_minutes"] > 0: + employee_stats[staff_key]["early_count"] += 1 + employee_stats[staff_key]["early_minutes"] += status["early_minutes"] + totals["early"] += status["early_minutes"] + if status["ot_minutes"] > 0: + employee_stats[staff_key]["ot_count"] += 1 + employee_stats[staff_key]["ot_minutes"] += status["ot_minutes"] + totals["ot"] += status["ot_minutes"] + + stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True) + return {"stats": stats, **totals} + +@app.get("/api/attendance/lateness/records") +async def get_lateness_records( + staff: Optional[str] = None, + current_user: User = Depends(get_current_user) +): + """Get detailed attendance status records""" + headers, rows = read_excel_file("attendance") + if headers is None or not rows: + return [] + + staff_col = None; date_col = None; week_col = None; checkin_col = None; checkout_col = None; shift_col = None + + for i, h in enumerate(headers): + h_lower = str(h).lower() if h else '' + if 'staff' in h_lower or '員工' in h_lower or 'name' in h_lower: + staff_col = i + elif 'date' in h_lower or '日期' in h_lower: + date_col = i + elif 'week' in h_lower or '星期' in h_lower: + week_col = i + elif 'check' in h_lower and 'in' in h_lower or '上班' in h_lower: + checkin_col = i + elif 'check' in h_lower and 'out' in h_lower or '下班' in h_lower: + checkout_col = i + elif '班次' in h_lower: + shift_col = i + + records = [] + for row in rows: + staff_val = row[staff_col] if staff_col is not None else None + check_in = row[checkin_col] if checkin_col is not None else None + check_out = row[checkout_col] if checkout_col is not None else None + shift_code = row[shift_col] if shift_col is not None else None + week_day = row[week_col] if week_col is not None else None + date_val = row[date_col] if date_col is not None else None + + if staff_val and shift_code: + shift_times = get_shift_schedule_full(shift_code, week_day or '') + shift_start = shift_times[0] if shift_times else None + shift_end = shift_times[1] if shift_times else None + + status = calculate_attendance_status(check_in, check_out, shift_start, shift_end) + + record = { + "staff": staff_val, "date": str(date_val)[:10] if date_val else None, + "weekday": week_day, "shift": shift_code, + "status_code": status["status_code"], "status_text": status["status_text"], + "expected_in": status["expected_in"], "expected_out": status["expected_out"], + "actual_in": status["actual_in"], "actual_out": status["actual_out"], + "late_minutes": status["late_minutes"], "early_minutes": status["early_minutes"], "ot_minutes": status["ot_minutes"] + } + + if staff is None or record["staff"] == staff: + records.append(record) + + return records + +@app.delete("/api/roster") +async def delete_roster(current_user: User = Depends(get_current_user)): + """Delete the stored roster file""" + path = get_excel_path("roster") + if os.path.exists(path): + os.remove(path) + return {"message": "Roster file deleted"} + +@app.get("/api/roster/info") +async def get_roster_info(current_user: User = Depends(get_current_user)): + """Get info about stored roster Excel file""" + path = get_excel_path("roster") + if not os.path.exists(path): + return {"uploaded": False, "rows": 0, "columns": 0, "headers": []} + + headers, rows = read_excel_file("roster") + return { + "uploaded": True, + "rows": len(rows) if rows else 0, + "columns": len(headers) if headers else 0, + "headers": headers if headers else [] + } + +@app.post("/api/roster/upload") +async def upload_roster( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user) +): + """Upload Roster Excel file""" + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + path = get_excel_path("roster") + contents = await file.read() + + with open(path, "wb") as f: + f.write(contents) + + headers, rows = read_excel_file("roster") + + return { + "message": "Roster file uploaded successfully", + "filename": file.filename, + "rows": len(rows) if rows else 0, + "columns": len(headers) if headers else 0, + "headers": headers if headers else [] + } + +@app.get("/api/roster/data") +async def get_roster_data(current_user: User = Depends(get_current_user)): + """Get full roster Excel data""" + headers, rows = read_excel_file("roster") + if headers is None: + return {"headers": [], "rows": []} + return {"headers": headers, "rows": rows} + +@app.get("/api/roster/shifts") +async def get_shifts(current_user: User = Depends(get_current_user)): + """Get all shifts from roster file""" + headers, rows = read_excel_file("roster") + if headers is None: + return [] + + # Find columns + shift_col = None + desc_col = None + for i, h in enumerate(headers): + if h and str(h).lower() in ['班次', 'shift', 'shift code']: + shift_col = i + elif h and str(h).lower() in ['描述', 'description', 'desc']: + desc_col = i + + shifts = [] + for row in rows: + shift_code = row[shift_col] if shift_col is not None else None + if shift_code: + shifts.append({ + "code": shift_code, + "description": row[desc_col] if desc_col is not None else "" + }) + + return shifts + +@app.get("/api/roster/assignments") +async def get_assignments(current_user: User = Depends(get_current_user)): + """Get employee-shift assignments""" + # Read from attendance data + headers, rows = read_excel_file("attendance") + if headers is None: + return {} + + # Find 班次 column + shift_col = None + emp_col = None + for i, h in enumerate(headers): + if h and '班次' in str(h): + shift_col = i + elif h and any(kw in str(h).lower() for kw in ['員工', 'employee', 'name', '員工名稱']): + emp_col = i + + assignments = {} + if shift_col is not None: + for row in rows: + emp = row[emp_col] if emp_col is not None and emp_col < len(row) else None + shift = row[shift_col] if shift_col is not None and shift_col < len(row) else None + if emp and shift: + assignments[str(emp)] = str(shift) + + return assignments + +@app.post("/api/roster/assign") +async def assign_shift( + employee: str = Form(...), + shift: str = Form(...), + current_user: User = Depends(get_current_user) +): + """Assign employee to shift""" + assignments_path = os.path.join(DATA_DIR, "shift_assignments.json") + + # Load existing + if os.path.exists(assignments_path): + with open(assignments_path, "r") as f: + assignments = json.load(f) + else: + assignments = {} + + assignments[employee] = shift + + return RedirectResponse(url="/") +@app.get("/api/{section}/info") +async def get_section_info(section: str): + """Get info about stored Excel file""" + if section not in ["attendance", "accident"]: + raise HTTPException(status_code=400, detail="Invalid section") + + headers, rows = read_excel_file(section) + if headers is None: + return {"uploaded": False, "rows": 0, "columns": 0, "headers": []} + + return { + "uploaded": True, + "rows": len(rows), + "columns": len(headers), + "headers": headers + } + +# ============ List Records (from Excel) ============ + +# ============ Frontend Compat Aliases ============ +# Inserted BEFORE wildcard routes to win FastAPI route matching. +@app.get("/api/dashboard/summary") +async def dashboard_summary( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Read from attendance_records table (SQL). Returns frontend-expected field names.""" + from datetime import date as _date, datetime as _dt + q = db.query(AttendanceRecord) + if date_from: + try: + q = q.filter(AttendanceRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(AttendanceRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + rows = q.all() + today = _date.today() + month_start = today.replace(day=1) + + # Frontend expects total_records + count-by-status fields + by_status = {} + by_department = {} + staff_set = set() + for r in rows: + sc = r.status_code or "unknown" + by_status[sc] = by_status.get(sc, 0) + 1 + if r.department: + by_department[r.department] = by_department.get(r.department, 0) + 1 + if r.employee_name: + staff_set.add(r.employee_name) + + return { + # Backend / dashboard canonical fields + "total": len(rows), + "today": sum(1 for r in rows if r.date == today), + "this_month": sum(1 for r in rows if r.date and r.date >= month_start), + "by_status": by_status, + "by_department": by_department, + "trend": [], + # Frontend-expected fields (alias for legacy UI) + "total_records": len(rows), + "normal_count": by_status.get("normal", 0), + "late_count": sum(v for k, v in by_status.items() if "late" in k), + "early_count": sum(v for k, v in by_status.items() if "early" in k), + "ot_count": sum(v for k, v in by_status.items() if "ot" in k), + "missing_count": by_status.get("missing", 0), + "abnormal_count": by_status.get("abnormal", 0), + "staff_count": len(staff_set), + } + +@app.get("/api/attendance/stats") +async def attendance_stats( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Read aggregated stats from attendance_records (SQL). + + Returns Option 1 detail per employee: + - late_count, late_minutes + - late_avg_minutes, late_max_minutes ← new + - early_count, early_minutes + - early_avg_minutes, early_max_minutes ← new + - ot_count, ot_minutes + - ot_avg_minutes, ot_max_minutes ← new + - missing_count + - last_attendance_date ← new + - consecutive_missing ← new (current streak of missing) + - attendance_rate ← new (% non-missing) + """ + from datetime import datetime as _dt, date as _date, timedelta as _td + q = db.query(AttendanceRecord) + if date_from: + try: + q = q.filter(AttendanceRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(AttendanceRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + rows = q.order_by(AttendanceRecord.date.asc()).all() + + # Group rows by employee (preserve chronological order) + by_emp = {} + for r in rows: + if not r.employee_name: + continue + by_emp.setdefault(r.employee_name, []).append(r) + + employee_stats = [] + totals = {"late": 0, "early": 0, "ot": 0, "missing": 0} + + for name, emp_rows in by_emp.items(): + late_count = late_minutes = 0 + late_min_list = [] + early_count = early_minutes = 0 + early_min_list = [] + ot_count = ot_minutes = 0 + ot_min_list = [] + missing_count = 0 + last_date = None + + for r in emp_rows: + sc = (r.status_code or "").lower() + if "late" in sc: + late_count += 1 + lm = r.late_minutes or 0 + late_minutes += lm + late_min_list.append(lm) + totals["late"] += lm + if "early" in sc: + early_count += 1 + em = r.early_minutes or 0 + early_minutes += em + early_min_list.append(em) + totals["early"] += em + if "ot" in sc: + ot_count += 1 + om = r.ot_minutes or 0 + ot_minutes += om + ot_min_list.append(om) + totals["ot"] += om + if sc == "missing": + missing_count += 1 + totals["missing"] += 1 + if r.date and (last_date is None or r.date > last_date): + last_date = r.date + + # Consecutive missing streak (current run ending at last row) + consecutive_missing = 0 + for r in reversed(emp_rows): + if (r.status_code or "").lower() == "missing": + consecutive_missing += 1 + else: + break + + total_records = len(emp_rows) + attendance_rate = ( + round((total_records - missing_count) / total_records * 100) + if total_records > 0 else 0 + ) + + employee_stats.append({ + "name": name, + "shift": emp_rows[-1].shift_code or "-", + # Late + "late_count": late_count, + "late_minutes": late_minutes, + "late_avg_minutes": round(late_minutes / late_count) if late_count > 0 else 0, + "late_max_minutes": max(late_min_list) if late_min_list else 0, + # Early + "early_count": early_count, + "early_minutes": early_minutes, + "early_avg_minutes": round(early_minutes / early_count) if early_count > 0 else 0, + "early_max_minutes": max(early_min_list) if early_min_list else 0, + # OT + "ot_count": ot_count, + "ot_minutes": ot_minutes, + "ot_avg_minutes": round(ot_minutes / ot_count) if ot_count > 0 else 0, + "ot_max_minutes": max(ot_min_list) if ot_min_list else 0, + # Missing + "missing_count": missing_count, + "consecutive_missing": consecutive_missing, + "last_attendance_date": last_date.isoformat() if last_date else None, + # Overall + "total_records": total_records, + "attendance_rate": attendance_rate, + }) + + employee_stats.sort(key=lambda x: x["late_minutes"], reverse=True) + return {"stats": employee_stats, **totals} + +@app.get("/api/attendance/records") +async def attendance_records( + page: int = 1, + per_page: int = 50, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "desc", + search: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + staff: Optional[str] = None, + status: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Paginated records from attendance_records (SQL).""" + from datetime import datetime as _dt + from sqlalchemy import or_, and_ + + q = db.query(AttendanceRecord) + if date_from: + try: + q = q.filter(AttendanceRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(AttendanceRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + if staff: + q = q.filter(AttendanceRecord.employee_name == staff) + if status: + q = q.filter(AttendanceRecord.status_code == status) + if search: + like = f"%{search}%" + q = q.filter(or_( + AttendanceRecord.employee_name.like(like), + AttendanceRecord.department.like(like), + AttendanceRecord.shift_code.like(like), + )) + + # sort + sortable = { + "date": AttendanceRecord.date, + "employee_name": AttendanceRecord.employee_name, + "weekday": AttendanceRecord.weekday, + "shift_code": AttendanceRecord.shift_code, + "status_code": AttendanceRecord.status_code, + } + col = sortable.get(sort_by, AttendanceRecord.date) + q = q.order_by(col.desc() if sort_order == "desc" else col.asc()) + + total = q.count() + rows = q.offset((page - 1) * per_page).limit(per_page).all() + enrich_attendance_with_leave(rows, db) + + def to_dict(r): + return { + "id": r.id, + "staff": r.employee_name, + "employee_name": r.employee_name, + "company": r.company, + "department": r.department, + "date": r.date.isoformat() if r.date else None, + "weekday": r.weekday, + "shift": r.shift_code, + "shift_code": r.shift_code, + "status_code": r.status_code, + "status_text": r.status_text, + "expected_in": r.expected_in, + "expected_out": r.expected_out, + "actual_in": r.actual_in, + "actual_out": r.actual_out, + "late_minutes": r.late_minutes or 0, + "early_minutes": r.early_minutes or 0, + "ot_minutes": r.ot_minutes or 0, + "is_manually_edited": bool(r.is_manually_edited), + } + + return { + "records": [to_dict(r) for r in rows], + "total": total, + "page": page, + "per_page": per_page, + } + +@app.get("/api/attendance/{record_id}") +async def get_attendance_record( + record_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get single attendance record by id from DB.""" + r = db.query(AttendanceRecord).filter(AttendanceRecord.id == record_id).first() + if not r: + raise HTTPException(status_code=404, detail="Record not found") + return { + "id": r.id, + "employee_name": r.employee_name, + "company": r.company, + "department": r.department, + "date": r.date.isoformat() if r.date else None, + "weekday": r.weekday, + "shift_code": r.shift_code, + "check_in": r.check_in.isoformat() if r.check_in else None, + "check_out": r.check_out.isoformat() if r.check_out else None, + "status_code": r.status_code, + "status_text": r.status_text, + "late_minutes": r.late_minutes or 0, + "early_minutes": r.early_minutes or 0, + "ot_minutes": r.ot_minutes or 0, + "expected_in": r.expected_in, + "expected_out": r.expected_out, + "actual_in": r.actual_in, + "actual_out": r.actual_out, + "is_manually_edited": bool(r.is_manually_edited), + } + + +@app.put("/api/attendance/{record_id}") +async def update_attendance_record( + record_id: int, + payload: dict, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update attendance record. Marks is_manually_edited = True so re-import won't overwrite.""" + from datetime import datetime as _dt + r = db.query(AttendanceRecord).filter(AttendanceRecord.id == record_id).first() + if not r: + raise HTTPException(status_code=404, detail="Record not found") + editable = {"status_code", "status_text", "late_minutes", "early_minutes", + "ot_minutes", "actual_in", "actual_out", "shift_code", "department"} + for k, v in payload.items(): + if k in editable: + setattr(r, k, v) + r.is_manually_edited = True + db.commit() + logger.info("attendance record updated", extra={"context": {"record_id": record_id, "user_id": current_user.id}}) + return {"message": "ok", "id": record_id} + + +# ============ Holidays CRUD ============ +@app.get("/api/holidays", response_model=List[HolidayOut]) +async def list_holidays( + year: Optional[int] = None, + region: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all holidays, optionally filtered by year / region.""" + q = db.query(Holiday) + if year: + q = q.filter(sqlfunc.extract("year", Holiday.date) == year) + if region: + q = q.filter(Holiday.region == region) + return q.order_by(Holiday.date.asc()).all() + + +@app.get("/api/holidays/{holiday_id}", response_model=HolidayOut) +async def get_holiday( + holiday_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get single holiday by id.""" + h = db.query(Holiday).filter(Holiday.id == holiday_id).first() + if not h: + raise HTTPException(status_code=404, detail="Holiday not found") + return h + + +@app.post("/api/holidays", response_model=HolidayOut) +async def create_holiday( + body: HolidayCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin), +): + """Create a new holiday (admin only).""" + existing = db.query(Holiday).filter(Holiday.date == body.date).first() + if existing: + raise HTTPException(status_code=409, detail=f"Holiday already exists for {body.date}") + h = Holiday( + date=body.date, + name=body.name, + name_en=body.name_en, + region=body.region or "HK", + is_mandatory=body.is_mandatory if body.is_mandatory is not None else True, + source="manual", + notes=body.notes, + ) + db.add(h) + db.commit() + db.refresh(h) + logger.info("holiday created", extra={"context": {"id": h.id, "date": str(h.date)}}) + return h + + +@app.put("/api/holidays/{holiday_id}", response_model=HolidayOut) +async def update_holiday( + holiday_id: int, + body: HolidayUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin), +): + """Update an existing holiday (admin only).""" + h = db.query(Holiday).filter(Holiday.id == holiday_id).first() + if not h: + raise HTTPException(status_code=404, detail="Holiday not found") + update_data = body.dict(exclude_unset=True) + for k, v in update_data.items(): + setattr(h, k, v) + h.source = "manual_edit" + db.commit() + db.refresh(h) + return h + + +@app.delete("/api/holidays/{holiday_id}") +async def delete_holiday( + holiday_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin), +): + """Delete a holiday (admin only).""" + h = db.query(Holiday).filter(Holiday.id == holiday_id).first() + if not h: + raise HTTPException(status_code=404, detail="Holiday not found") + db.delete(h) + db.commit() + return {"deleted": holiday_id} + + +@app.post("/api/holidays/refresh") +async def refresh_holidays_from_gov_hk( + years: Optional[str] = None, # CSV: "2025,2026,2027" + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin), +): + """Refresh holidays from internal static list (sourced from gov.hk). + + Admin endpoint. Adds new dates, updates existing ones (only if source=gov_hk). + Manually edited holidays are NOT overwritten. + """ + from parsers import get_all_holidays + target_years = [int(y) for y in years.split(",")] if years else [2025, 2026, 2027] + all_holidays = [h for h in get_all_holidays() if h[0].year in target_years] + + added = 0 + updated = 0 + skipped = 0 + for d, name_zh, name_en, is_mandatory in all_holidays: + existing = db.query(Holiday).filter(Holiday.date == d).first() + if existing: + # Don't overwrite manual edits + if existing.source and existing.source.startswith("manual"): + skipped += 1 + continue + 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" + 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() + return { + "added": added, "updated": updated, "skipped": skipped, + "total_in_db": db.query(Holiday).count(), + "years": target_years, + } + + +# ============ Leave Records CRUD ============ +@app.get("/api/leave", response_model=List[LeaveOut]) +async def list_leaves( + employee_name: Optional[str] = None, + leave_type: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List leave records with optional filters.""" + from datetime import datetime as _dt + q = db.query(LeaveRecord) + if employee_name: + q = q.filter(LeaveRecord.employee_name == employee_name) + if leave_type: + q = q.filter(LeaveRecord.leave_type == leave_type.upper()) + if date_from: + try: + q = q.filter(LeaveRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(LeaveRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + return q.order_by(LeaveRecord.date.desc(), LeaveRecord.employee_name.asc()).all() + + +@app.post("/api/leave", response_model=LeaveOut) +async def create_leave( + body: LeaveCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a leave record.""" + body.leave_type = body.leave_type.upper() + if body.leave_type not in ("SL", "CL", "AL"): + raise HTTPException(status_code=400, detail="leave_type must be SL / CL / AL") + if body.hours < 0 or body.hours > 24: + raise HTTPException(status_code=400, detail="hours must be 0-24") + + lv = LeaveRecord( + employee_name=body.employee_name, + employee_id=body.employee_id, + date=body.date, + leave_type=body.leave_type, + hours=body.hours, + reason=body.reason, + approved_by=body.approved_by, + source="manual", + notes=body.notes, + created_by=current_user.id, + ) + db.add(lv) + db.commit() + db.refresh(lv) + return lv + + +@app.put("/api/leave/{leave_id}", response_model=LeaveOut) +async def update_leave( + leave_id: int, + body: LeaveUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update a leave record.""" + lv = db.query(LeaveRecord).filter(LeaveRecord.id == leave_id).first() + if not lv: + raise HTTPException(status_code=404, detail="Leave record not found") + update_data = body.dict(exclude_unset=True) + if "leave_type" in update_data and update_data["leave_type"]: + update_data["leave_type"] = update_data["leave_type"].upper() + if update_data["leave_type"] not in ("SL", "CL", "AL"): + raise HTTPException(status_code=400, detail="leave_type must be SL / CL / AL") + for k, v in update_data.items(): + setattr(lv, k, v) + db.commit() + db.refresh(lv) + return lv + + +@app.delete("/api/leave/{leave_id}") +async def delete_leave( + leave_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a leave record.""" + lv = db.query(LeaveRecord).filter(LeaveRecord.id == leave_id).first() + if not lv: + raise HTTPException(status_code=404, detail="Leave record not found") + db.delete(lv) + db.commit() + return {"deleted": leave_id} + + +@app.post("/api/leave/upload") +async def upload_leaves( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Bulk upload leave records from CSV or Excel. + + CSV columns: employee_name,date,leave_type,hours,reason,approved_by + Excel: same columns (row 1 = header) + """ + from io import BytesIO + content = await file.read() + fname = file.filename.lower() + + rows = [] + if fname.endswith(".csv"): + import csv, io as _io + reader = csv.DictReader(_io.StringIO(content.decode("utf-8-sig"))) + for row in reader: + rows.append({ + "employee_name": row.get("employee_name", "").strip(), + "date": row.get("date", "").strip(), + "leave_type": row.get("leave_type", "").strip().upper(), + "hours": float(row.get("hours", 0) or 0), + "reason": row.get("reason", "").strip() or None, + "approved_by": row.get("approved_by", "").strip() or None, + }) + elif fname.endswith((".xlsx", ".xls")): + import openpyxl + wb = openpyxl.load_workbook(BytesIO(content), data_only=True) + ws = wb.active + headers = [str(c.value or "").strip() for c in ws[1]] + col_idx = {h: i for i, h in enumerate(headers)} + for row in ws.iter_rows(min_row=2, values_only=True): + if not row or all(v is None for v in row): + continue + def getv(h): + v = row[col_idx[h]] if h in col_idx and col_idx[h] < len(row) else None + return v + rows.append({ + "employee_name": str(getv("employee_name") or "").strip(), + "date": str(getv("date") or "").strip(), + "leave_type": str(getv("leave_type") or "").strip().upper(), + "hours": float(getv("hours") or 0), + "reason": str(getv("reason") or "").strip() or None, + "approved_by": str(getv("approved_by") or "").strip() or None, + }) + else: + raise HTTPException(status_code=400, detail="File must be CSV or Excel") + + added = 0 + skipped = 0 + errors = [] + for i, r in enumerate(rows): + try: + if not r["employee_name"] or not r["date"] or not r["leave_type"]: + errors.append(f"Row {i+2}: missing required field") + skipped += 1 + continue + if r["leave_type"] not in ("SL", "CL", "AL"): + errors.append(f"Row {i+2}: invalid leave_type {r[chr(39)+'leave_type'+chr(39)]}") + skipped += 1 + continue + + from datetime import datetime as _dt + if isinstance(r["date"], str): + d = _dt.strptime(r["date"], "%Y-%m-%d").date() + else: + d = r["date"] + + existing = db.query(LeaveRecord).filter( + LeaveRecord.employee_name == r["employee_name"], + LeaveRecord.date == d, + LeaveRecord.leave_type == r["leave_type"], + ).first() + if existing: + skipped += 1 + continue + + db.add(LeaveRecord( + employee_name=r["employee_name"], + date=d, + leave_type=r["leave_type"], + hours=r["hours"], + reason=r["reason"], + approved_by=r["approved_by"], + source="csv_upload", + created_by=current_user.id, + )) + added += 1 + except Exception as e: + errors.append(f"Row {i+2}: {str(e)}") + skipped += 1 + + db.commit() + return { + "added": added, "skipped": skipped, + "errors": errors[:20], # First 20 errors only + "total_in_db": db.query(LeaveRecord).count(), + } + + + +@app.get("/api/{section}") +async def list_records( + section: str, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=1000), + current_user: User = Depends(get_current_user) +): + """List records from stored Excel file""" + if section not in ["attendance", "accident"]: + raise HTTPException(status_code=400, detail="Invalid section") + + headers, rows = read_excel_file(section) + if headers is None: + return {"total": 0, "page": page, "page_size": page_size, "data": []} + + # Pagination + start = (page - 1) * page_size + end = start + page_size + page_rows = rows[start:end] + + # Build response with row number as id + data = [] + for idx, row in enumerate(page_rows, start=start + 1): + record = {"_row": idx} # Row number (1-based, excluding header) + for i, header in enumerate(headers): + if header: + record[str(header)] = row[i] if i < len(row) else None + data.append(record) + + return { + "total": len(rows), + "page": page, + "page_size": page_size, + "data": data + } + +# ============ Dashboard (from Excel) ============ +@app.get("/api/dashboard/{section}") +async def dashboard(section: str, current_user: User = Depends(get_current_user)): + """Get dashboard stats from stored Excel file""" + if section not in ["attendance", "accident"]: + raise HTTPException(status_code=400, detail="Invalid section") + + headers, rows = read_excel_file(section) + if headers is None or not rows: + return {"total": 0, "this_month": 0, "by_status": {}, "by_department": {}, "trend": []} + + today = date.today() + month_start = date(today.year, today.month, 1) + + # Find date column (look for common date column names) + date_col_idx = None + for i, h in enumerate(headers): + if h and any(kw in str(h).lower() for kw in ['date', '日期', '時間']): + date_col_idx = i + break + + # Find department column + dept_col_idx = None + for i, h in enumerate(headers): + if h and any(kw in str(h).lower() for kw in ['dept', '部門', '部門名稱']): + dept_col_idx = i + break + + total = len(rows) + this_month = 0 + by_department = {} + trend = [] + + for row in rows: + # Count this month + if date_col_idx is not None and date_col_idx < len(row): + val = row[date_col_idx] + if val: + row_date = None + if isinstance(val, datetime): + row_date = val.date() + elif isinstance(val, date): + row_date = val + elif isinstance(val, str): + for fmt in ['%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y']: + try: + row_date = datetime.strptime(val, fmt).date() + break + except: + pass + if row_date and row_date >= month_start: + this_month += 1 + + # Count by department + if dept_col_idx is not None and dept_col_idx < len(row): + dept = str(row[dept_col_idx] or 'Unknown') + by_department[dept] = by_department.get(dept, 0) + 1 + + return { + "total": total, + "today": 0, + "this_month": this_month, + "by_status": {}, + "by_department": by_department, + "trend": [] + } + + +async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> dict: + """Parse Excel rows and upsert into accidents table. + + Excel (Google Form) → SQL column map (15 columns): + Col 1 時間戳記 -> submitted_at + Col 2 電子郵件地址 -> employee_name + Col 3 部門名稱 -> department + Col 4 當日事件摘要 -> description + Col 5 緊急程度 -> severity + Col 6 事件發生時間 -> time + Col 7 涉及部門 -> location + Col 8 涉及的患者或相關人員 -> patient_or_staff + Col 9 事件描述 -> long_description + Col 10 如有事件相關照片可提供-> incident_photos + Col 11 處理情況/已採取的行動-> action_taken + Col 12 需要管理層介入的事項 -> needs_escalation + Col 13 第 12 欄 -> (skipped) + Col 14 分數 -> incident_score + Col 15 事件發生日期 -> date + """ + from datetime import datetime, date, time as _time + + def _parse_dt(v): + if v is None: + return None + if isinstance(v, datetime): + return v + if isinstance(v, str): + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"): + try: + return datetime.strptime(v, fmt) + except ValueError: + continue + return None + + def _parse_date(v): + if v is None: + return None + if isinstance(v, datetime): + return v.date() + if isinstance(v, date): + return v + if isinstance(v, str): + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%m/%d/%Y"): + try: + return datetime.strptime(v, fmt).date() + except ValueError: + continue + return None + + def _parse_time(v): + if v is None: + return None + if isinstance(v, _time): + return v.strftime("%H:%M:%S") + if isinstance(v, datetime): + return v.strftime("%H:%M:%S") + if isinstance(v, str): + for fmt in ("%H:%M:%S", "%H:%M"): + try: + return datetime.strptime(v, fmt).strftime("%H:%M:%S") + except ValueError: + continue + return str(v) if v else None + + def _cell(row, idx): + if idx is None or idx >= len(row): + return None + return row[idx] + + imported = updated = skipped = errors = 0 + for row_idx, row in enumerate(rows, start=2): # row_idx is the Excel 1-based row number + try: + submitted_at = _parse_dt(_cell(row, 0)) + employee_name = _cell(row, 1) or "" + department = _cell(row, 2) + description = _cell(row, 3) or "" + severity = str(_cell(row, 4) or "") + time_str = _parse_time(_cell(row, 5)) + location = _cell(row, 6) or "" + patient_or_staff = _cell(row, 7) + long_description = _cell(row, 8) + incident_photos = _cell(row, 9) + action_taken = _cell(row, 10) + needs_escalation = _cell(row, 11) + incident_score = _cell(row, 13) + date_val = _parse_date(_cell(row, 14)) + if not date_val and submitted_at: + # Fallback: derive date from submitted timestamp (col 1) + date_val = submitted_at.date() + + # Skip rows missing critical identifiers + if not date_val or not employee_name: + errors += 1 + continue + + if incident_score is not None and incident_score != "": + try: + incident_score = int(incident_score) + except (TypeError, ValueError): + incident_score = None + + existing = db.query(Accident).filter(Accident.excel_row == row_idx).first() + if existing: + # Update fields from Excel + existing.submitted_at = submitted_at + existing.employee_name = employee_name + existing.department = department + existing.description = description + existing.severity = severity + existing.time = time_str + existing.location = location + existing.patient_or_staff = patient_or_staff + existing.long_description = long_description + existing.incident_photos = incident_photos + existing.action_taken = action_taken + existing.needs_escalation = str(needs_escalation) if needs_escalation is not None else None + existing.incident_score = incident_score + existing.date = date_val + updated += 1 + else: + rec = Accident( + submitted_at=submitted_at, + employee_name=employee_name, + department=department, + description=description, + severity=severity, + time=time_str, + location=location, + patient_or_staff=patient_or_staff, + long_description=long_description, + incident_photos=incident_photos, + action_taken=action_taken, + needs_escalation=str(needs_escalation) if needs_escalation is not None else None, + incident_score=incident_score, + date=date_val, + excel_row=row_idx, + created_by=user_id, + ) + db.add(rec) + imported += 1 + except Exception as e: + errors += 1 + logger.warning("accident row import error", + extra={"context": {"row": row_idx, "error": str(e)}}) + + db.commit() + return {"imported": imported, "updated": updated, "skipped": skipped, "errors": errors} + + +def _get_excel_headers_accident() -> dict: + """Read /app/data/accident.xlsx row 1 and return SQL col → Excel header map. + Cached after first call. + """ + if hasattr(_get_excel_headers_accident, "_cache"): + return _get_excel_headers_accident._cache + try: + import openpyxl + path = os.path.join(DATA_DIR, "accident.xlsx") + if not os.path.exists(path): + _get_excel_headers_accident._cache = {} + return {} + wb = openpyxl.load_workbook(path, data_only=True) + ws = wb.active + headers = [ws.cell(row=1, column=c).value for c in range(1, ws.max_column + 1)] + # Hardcoded column index → SQL field mapping (matches _import_accident_to_db) + idx_to_sql = { + 0: "submitted_at", + 1: "employee_name", + 2: "department", + 3: "description", + 4: "severity", + 5: "time", + 6: "location", + 7: "patient_or_staff", + 8: "long_description", + 9: "incident_photos", + 10: "action_taken", + 11: "needs_escalation", + 13: "incident_score", + 14: "date", + } + result = {} + for i, h in enumerate(headers): + if i in idx_to_sql and h is not None: + result[idx_to_sql[i]] = str(h) + _get_excel_headers_accident._cache = result + return result + except Exception as e: + logger.warning("read accident excel headers failed", extra={"context": {"error": str(e)}}) + _get_excel_headers_accident._cache = {} + return {} + + +def _get_excel_column_order_accident() -> list: + """Return SQL columns in Excel order (Col 1..15 mapped to SQL fields). + + Plus extra DB-only columns (responsible_person, medical_report) at the end + so they can be VLOOKUP'd from 個案查詢 sheet even though they are not in + the original Excel. + """ + return [ + "id", # virtual + "submitted_at", + "employee_name", + "department", + "description", + "severity", + "time", + "location", + "patient_or_staff", + "long_description", + "incident_photos", + "action_taken", + "needs_escalation", + "incident_score", + "date", + # DB-only (not in Excel) + "responsible_person", + "medical_report", + ] + +@app.get("/api/accident/dashboard/summary") +async def accident_dashboard_summary( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Accident dashboard summary stats.""" + from datetime import date as _date, datetime as _dt + q = db.query(Accident).filter(Accident.deleted_at.is_(None)) + if date_from: + try: + q = q.filter(Accident.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(Accident.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + rows = q.all() + + today = _date.today() + month_start = today.replace(day=1) + total = len(rows) + by_severity = {} + by_department = {} + by_location = {} + by_responsible = {} + this_month = 0 + earliest_date = None + latest_date = None + for r in rows: + sc = r.severity or "unknown" + by_severity[sc] = by_severity.get(sc, 0) + 1 + if r.department: + by_department[r.department] = by_department.get(r.department, 0) + 1 + if r.location: + by_location[r.location] = by_location.get(r.location, 0) + 1 + if r.responsible_person: + by_responsible[r.responsible_person] = by_responsible.get(r.responsible_person, 0) + 1 + if r.date and r.date >= month_start: + this_month += 1 + if r.date: + if earliest_date is None or r.date < earliest_date: + earliest_date = r.date + if latest_date is None or r.date > latest_date: + latest_date = r.date + + # Avg per day (over date span) + days_span = 1 + if earliest_date and latest_date: + days_span = max((latest_date - earliest_date).days + 1, 1) + avg_per_day = round(total / days_span, 2) if total > 0 else 0 + + return { + "total_records": total, + "this_month": this_month, + "today": sum(1 for r in rows if r.date == today), + "by_severity": by_severity, + "by_department": by_department, + "by_location": by_location, + "by_responsible": by_responsible, + "severity_count": { # frontend-expected + "1": by_severity.get("1", 0), + "2": by_severity.get("2", 0), + "3": by_severity.get("3", 0), + "4": by_severity.get("4", 0), + "5": by_severity.get("5", 0), + }, + "unique_locations": len(by_location), + "unique_departments": len(by_department), + "unique_responsible": len(by_responsible), + "avg_per_day": avg_per_day, + "earliest_date": earliest_date.isoformat() if earliest_date else None, + "latest_date": latest_date.isoformat() if latest_date else None, + } + + +@app.get("/api/accident/dashboard/stats") +async def accident_dashboard_stats( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Accident per-entity leaderboards (location, employee, department, responsible).""" + from datetime import datetime as _dt + q = db.query(Accident).filter(Accident.deleted_at.is_(None)) + if date_from: + try: + q = q.filter(Accident.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(Accident.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + rows = q.all() + + # Bucket by entity + by_location = {} + by_employee = {} + by_department = {} + by_responsible = {} + + for r in rows: + # Severity-weighted score (severity 5 = most critical) + try: + sev_weight = int(r.severity or 1) + except (ValueError, TypeError): + sev_weight = 1 + sev_weight = max(sev_weight, 1) + + loc = r.location or "未分類" + by_location.setdefault(loc, {"name": loc, "count": 0, "severity_sum": 0, "avg_severity": 0}) + by_location[loc]["count"] += 1 + by_location[loc]["severity_sum"] += sev_weight + + emp = r.employee_name or "未分類" + by_employee.setdefault(emp, {"name": emp, "count": 0, "severity_sum": 0, "avg_severity": 0}) + by_employee[emp]["count"] += 1 + by_employee[emp]["severity_sum"] += sev_weight + + dept = r.department or "未分類" + by_department.setdefault(dept, {"name": dept, "count": 0, "severity_sum": 0, "avg_severity": 0}) + by_department[dept]["count"] += 1 + by_department[dept]["severity_sum"] += sev_weight + + rp = r.responsible_person or "未分類" + by_responsible.setdefault(rp, {"name": rp, "count": 0, "severity_sum": 0, "avg_severity": 0}) + by_responsible[rp]["count"] += 1 + by_responsible[rp]["severity_sum"] += sev_weight + + def finalize(d): + out = [] + for v in d.values(): + v["avg_severity"] = round(v["severity_sum"] / v["count"], 2) if v["count"] > 0 else 0 + out.append(v) + return out + + return { + "by_location": sorted(finalize(by_location), key=lambda x: x["count"], reverse=True), + "by_employee": sorted(finalize(by_employee), key=lambda x: x["count"], reverse=True), + "by_department": sorted(finalize(by_department), key=lambda x: x["count"], reverse=True), + "by_responsible": sorted(finalize(by_responsible), key=lambda x: x["count"], reverse=True), + } + + +@app.get("/api/accident/records") +async def accident_records( + page: int = 1, + per_page: int = 50, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "desc", + search: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + severity: Optional[str] = None, + location: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Paginated accident records from SQL.""" + from datetime import datetime as _dt + from sqlalchemy import or_ + + q = db.query(Accident).filter(Accident.deleted_at.is_(None)) + if date_from: + try: + q = q.filter(Accident.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(Accident.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + if severity: + q = q.filter(Accident.severity == severity) + if location: + q = q.filter(Accident.location == location) + if search: + like = f"%{search}%" + q = q.filter(or_( + Accident.employee_name.like(like), + Accident.location.like(like), + Accident.description.like(like), + Accident.department.like(like), + )) + + sortable = { + "id": Accident.id, + "submitted_at": Accident.submitted_at, + "date": Accident.date, + "severity": Accident.severity, + "time": Accident.time, + "location": Accident.location, + "employee_name": Accident.employee_name, + "department": Accident.department, + "description": Accident.description, + "action_taken": Accident.action_taken, + "patient_or_staff": Accident.patient_or_staff, + "incident_score": Accident.incident_score, + "responsible_person": Accident.responsible_person, + } + col = sortable.get(sort_by, Accident.date) + q = q.order_by(col.desc() if sort_order == "desc" else col.asc()) + + total = q.count() + rows = q.offset((page - 1) * per_page).limit(per_page).all() + + def to_dict(r): + return { + "id": r.id, + "submitted_at": r.submitted_at.isoformat() if r.submitted_at else None, + "date": r.date.isoformat() if r.date else None, + "time": r.time, + "location": r.location, + "employee_name": r.employee_name, + "employee_id": r.employee_id, + "department": r.department, + "description": r.description, + "severity": r.severity, + "medical_report": r.medical_report, + "action_taken": r.action_taken, + "responsible_person": r.responsible_person, + "patient_or_staff": r.patient_or_staff, + "long_description": r.long_description, + "incident_photos": r.incident_photos, + "needs_escalation": r.needs_escalation, + "incident_score": r.incident_score, + "excel_row": r.excel_row, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + + excel_headers = _get_excel_headers_accident() + column_order = _get_excel_column_order_accident() + return { + "records": [to_dict(r) for r in rows], + "total": total, + "page": page, + "per_page": per_page, + "excel_headers": excel_headers, + "column_order": column_order, + } + + +@app.get("/api/accident/{record_id}") +async def get_accident_record( + record_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get a single accident record by ID.""" + r = db.query(Accident).filter(Accident.id == record_id, Accident.deleted_at.is_(None)).first() + if not r: + raise HTTPException(status_code=404, detail="Record not found") + excel_headers = _get_excel_headers_accident() + column_order = _get_excel_column_order_accident() + return { + "id": r.id, + "submitted_at": r.submitted_at.isoformat() if r.submitted_at else None, + "date": r.date.isoformat() if r.date else None, + "time": r.time, + "location": r.location, + "employee_name": r.employee_name, + "employee_id": r.employee_id, + "department": r.department, + "description": r.description, + "severity": r.severity, + "medical_report": r.medical_report, + "action_taken": r.action_taken, + "responsible_person": r.responsible_person, + "patient_or_staff": r.patient_or_staff, + "long_description": r.long_description, + "incident_photos": r.incident_photos, + "needs_escalation": r.needs_escalation, + "incident_score": r.incident_score, + "excel_row": r.excel_row, + "excel_headers": excel_headers, + "column_order": column_order, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + + +@app.get("/api/accident/export/excel") +async def accident_export_excel( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Generate multi-sheet accident xlsx (Phase 2: formula-driven, 3 sheets). + + Sheets: + - 總覽: KPIs, severity/dept distribution, case index + - 個案查詢: input case_no in C2; VLOOKUP shows detail (live, no macro needed) + + multi-case list + + 2-case side-by-side compare (input cells) + - 明細: full table, all Excel columns, conditional formatting on severity + """ + import xlsxwriter + import io + from datetime import datetime as _dt + from collections import Counter + + q = db.query(Accident).filter(Accident.deleted_at.is_(None)) + if date_from: + try: + q = q.filter(Accident.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(Accident.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + rows = q.order_by(Accident.date.desc()).all() + + excel_headers = _get_excel_headers_accident() + column_order = _get_excel_column_order_accident() + + # Map col index (1-based, for VLOOKUP) → SQL field key + # 明細 sheet will write records starting at row 2 (header row 1). + # Column order in 明細: id, submitted_at, employee_name, department, description, + # severity, time, location, patient_or_staff, long_description, incident_photos, + # action_taken, needs_escalation, incident_score, date + col_index_lookup = {col: i + 1 for i, col in enumerate(column_order)} + + # ============ Workbook + formats ============ + buf = io.BytesIO() + # force_full_calc_on_load + calc_on_load ensures Excel/Numbers recompute all + # formulas when opening the file (overrides cached 0 from xlsxwriter). + wb = xlsxwriter.Workbook( + buf, + {"in_memory": True, + "calc_on_load": True, + "calc_mode": "auto"}) + # Belt-and-suspenders: set workbook calc properties via set_calc_mode + wb.set_calc_mode("auto", calc_id=1) + + title_fmt = wb.add_format({"bold": True, "font_size": 16, "font_color": "#1E3A8A"}) + subtitle_fmt = wb.add_format({"italic": True, "font_size": 10, "font_color": "#6B7280"}) + section_fmt = wb.add_format({"bold": True, "font_size": 12, "font_color": "#1E3A8A", + "bottom": 2, "bottom_color": "#93C5FD"}) + kpi_label_fmt = wb.add_format({"bold": True, "font_size": 9, "font_color": "#6B7280", + "align": "left"}) + kpi_value_fmt = wb.add_format({"bold": True, "font_size": 22, "font_color": "#1E3A8A", + "align": "left"}) + header_fmt = wb.add_format({"font_color": "#FFFFFF", "bold": True, "align": "center", + "bg_color": "#1E40AF", "border": 1, "border_color": "#1E3A8A"}) + cell_fmt = wb.add_format({"align": "left", "valign": "top"}) + cell_wrap_fmt = wb.add_format({"text_wrap": True, "align": "left", "valign": "top"}) + detail_label_fmt = wb.add_format({"bold": True, "bg_color": "#F1F5F9", + "border": 1, "border_color": "#CBD5E1", + "valign": "top", "text_wrap": True, "align": "left", + "indent": 1}) + detail_value_fmt = wb.add_format({"border": 1, "border_color": "#CBD5E1", + "valign": "top", "text_wrap": True, "align": "left", + "indent": 1}) + detail_label_alt_fmt = wb.add_format({"bold": True, "bg_color": "#E2E8F0", + "border": 1, "border_color": "#CBD5E1", + "valign": "top", "text_wrap": True, "align": "left", + "indent": 1}) + + severity_fmts = {} + for code, font_c, bg_c in [ + ("1", "#065F46", "#D1FAE5"), ("2", "#1E40AF", "#DBEAFE"), + ("3", "#92400E", "#FEF3C7"), ("4", "#9A3412", "#FED7AA"), + ("5", "#7F1D1D", "#FEE2E2"), + ]: + severity_fmts[code] = wb.add_format({"font_color": font_c, "bg_color": bg_c, + "bold": True, "align": "center", "border": 1, + "border_color": "#E5E7EB"}) + severity_fmts["default"] = wb.add_format({"align": "center", "border": 1, + "border_color": "#E5E7EB"}) + + # ============ Sheet 1: 總覽 ============ + overview = wb.add_worksheet("總覽") + overview.set_tab_color("#1E40AF") + overview.hide_gridlines(2) + overview.set_column("A:A", 2) + overview.set_column("B:B", 30) + overview.set_column("C:C", 18) + overview.set_column("D:D", 30) + overview.set_column("E:E", 18) + overview.set_column("F:F", 30) + overview.set_column("G:G", 18) + + overview.write("B2", "意外記錄 總覽 Dashboard", title_fmt) + overview.write("B3", f"生成時間: {_dt.now().strftime('%Y-%m-%d %H:%M:%S')}", subtitle_fmt) + + if date_from or date_to: + range_str = f"{date_from or '起始'} → {date_to or '現在'}" + else: + range_str = "全部記錄" + overview.write("B4", f"篩選範圍: {range_str}", subtitle_fmt) + + overview.merge_range("B6:C6", "總記錄數", kpi_label_fmt) + overview.merge_range("B7:C7", len(rows), kpi_value_fmt) + overview.merge_range("D6:E6", "嚴重程度 5 (最高)", kpi_label_fmt) + sev5 = sum(1 for r in rows if str(r.severity or "") == "5") + overview.merge_range("D7:E7", sev5, kpi_value_fmt) + overview.merge_range("F6:G6", "最早事件日期", kpi_label_fmt) + earliest = min((r.date for r in rows if r.date), default=None) + overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt) + + overview.write("B10", "嚴重程度分佈", section_fmt) + overview.write("B11", "Severity", header_fmt) + overview.write("C11", "數量", header_fmt) + overview.write("D11", "百分比", header_fmt) + severity_codes = ["1", "2", "3", "4", "5"] + for i, code in enumerate(severity_codes): + overview.write(11 + i, 1, code, severity_fmts[code]) + count = sum(1 for r in rows if str(r.severity or "") == code) + overview.write(11 + i, 2, count, cell_fmt) + pct = (count / len(rows) * 100) if rows else 0 + overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt) + overview.write(11 + len(severity_codes), 1, "(空)", cell_fmt) + unk = sum(1 for r in rows if not str(r.severity or "")) + overview.write(11 + len(severity_codes), 2, unk, cell_fmt) + pct_unk = (unk / len(rows) * 100) if rows else 0 + overview.write(11 + len(severity_codes), 3, f"{pct_unk:.1f}%", cell_fmt) + + overview.write("F10", "部門事故數 (TOP 10)", section_fmt) + overview.write("F11", "部門", header_fmt) + overview.write("G11", "數量", header_fmt) + dept_counts = Counter((r.department or "(未分類)") for r in rows) + top_depts = dept_counts.most_common(10) + for i, (dept, count) in enumerate(top_depts): + overview.write(11 + i, 5, dept, cell_fmt) + overview.write(11 + i, 6, count, cell_fmt) + + nav_start_row = 80 + overview.write(f"B{nav_start_row}", "個案索引 (可 click → 跳去「個案查詢」對應 row)", section_fmt) + overview.write(f"B{nav_start_row + 1}", "#", header_fmt) + overview.write(f"C{nav_start_row + 1}", "日期", header_fmt) + overview.write(f"D{nav_start_row + 1}", "時間", header_fmt) + overview.write(f"E{nav_start_row + 1}", "部門", header_fmt) + overview.write(f"F{nav_start_row + 1}", "嚴重程度", header_fmt) + overview.write(f"G{nav_start_row + 1}", "→ 查個案", header_fmt) + link_fmt = wb.add_format({"font_color": "#2563EB", "underline": 1}) + for idx, r in enumerate(rows): + row_n = nav_start_row + 2 + idx + overview.write(row_n, 1, r.id, cell_fmt) + overview.write(row_n, 2, r.date.isoformat() if r.date else "", cell_fmt) + overview.write(row_n, 3, r.time or "", cell_fmt) + overview.write(row_n, 4, r.department or "", cell_fmt) + overview.write(row_n, 5, str(r.severity or ""), severity_fmts.get(str(r.severity or ""), severity_fmts["default"])) + # Use HYPERLINK formula. When clicked, jumps to sheet 個案查詢 cell A1. + # User types case_no in C5 of 個案查詢 sheet. + # xlsxwriter requires single quotes around sheet names containing non-ASCII. + # Use proper xlsxwriter hyperlink element (write_url with internal:Sheet!Cell). + # HYPERLINK formula would cache value 0 — write_url sets display text directly. + overview.write_url(row_n, 6, + f"internal:個案查詢!A1", + link_fmt, + string=f"#{r.id} → 查詢", + tip=f"跳到「個案查詢」輸入個案 ID {r.id}") + overview.set_row(row_n, 18) + + # ============ Charts block — 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============ + # 4 charts: Severity Pie, Severity Column, Department Bar TOP 10, Monthly Trend Line + # Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index. + + # --- Chart 1: Severity Pie (I6) --- + sev_pie = wb.add_chart({"type": "pie"}) + sev_pie.add_series({ + "name": "嚴重程度分佈", + # B12:B17 = severity codes + (空) + "categories": ["總覽", 11, 1, 16, 1], + "values": ["總覽", 11, 2, 16, 2], + "data_labels": {"percentage": True, "category": True, "position": "outside_end"}, + }) + sev_pie.set_title({"name": "嚴重程度分佈 (Pie)"}) + sev_pie.set_style(10) + sev_pie.set_size({"width": 480, "height": 320}) + sev_pie.set_legend({"position": "right"}) + overview.insert_chart("I6", sev_pie, {"x_offset": 5, "y_offset": 5}) + + # --- Chart 2: Department Bar TOP 10 (I22) --- + # Dept table starts at F12, up to 10 entries + header row. + # F12:F21 = department names; G12:G21 = counts + dept_bar = wb.add_chart({"type": "bar"}) + dept_bar.add_series({ + "name": "部門事故數", + "categories": ["總覽", 11, 5, 20, 5], + "values": ["總覽", 11, 6, 20, 6], + "fill": {"color": "#3B82F6"}, + "border": {"color": "#1E40AF"}, + "data_labels": {"value": True}, + }) + dept_bar.set_title({"name": "部門事故數 (TOP 10)"}) + dept_bar.set_x_axis({"name": "事故數"}) + dept_bar.set_y_axis({"name": "部門", "reverse": True}) + dept_bar.set_legend({"none": True}) + dept_bar.set_size({"width": 480, "height": 320}) + overview.insert_chart("I22", dept_bar, {"x_offset": 5, "y_offset": 5}) + + # --- Chart 3: Severity Column (I42) --- + sev_col_chart = wb.add_chart({"type": "column"}) + sev_col_chart.add_series({ + "name": "嚴重程度 數量", + "categories": ["總覽", 11, 1, 15, 1], # B12:B16 = severity 1-5 only (skip (空)) + "values": ["總覽", 11, 2, 15, 2], + "fill": {"color": "#EF4444"}, + "border": {"color": "#7F1D1D"}, + "data_labels": {"value": True}, + }) + sev_col_chart.set_title({"name": "嚴重程度 數量 (Column)"}) + sev_col_chart.set_x_axis({"name": "嚴重程度"}) + sev_col_chart.set_y_axis({"name": "事故數"}) + sev_col_chart.set_legend({"none": True}) + sev_col_chart.set_size({"width": 480, "height": 320}) + overview.insert_chart("I42", sev_col_chart, {"x_offset": 5, "y_offset": 5}) + + # --- Chart 4: Monthly Trend Line (I62) --- + # Build monthly data table inline below the case index (col B, starting at row 60). + # Header at row 60; data rows 61..72 for last 12 months. + trend_start_row = 60 # 0-indexed → Excel row 61 + overview.write(trend_start_row, 1, "月份", header_fmt) + overview.write(trend_start_row, 2, "事故數", header_fmt) + + # Compute last 12 months from data + from collections import OrderedDict + from datetime import date as _date + today = _dt.now().date() + months = [] + for offset in range(11, -1, -1): + y = today.year + m = today.month - offset + while m <= 0: + m += 12 + y -= 1 + months.append(f"{y}-{m:02d}") + + monthly_counts = OrderedDict((m, 0) for m in months) + for r in rows: + if r.date: + key = f"{r.date.year}-{r.date.month:02d}" + if key in monthly_counts: + monthly_counts[key] += 1 + + for i, (m, cnt) in enumerate(monthly_counts.items()): + overview.write(trend_start_row + 1 + i, 1, m, cell_fmt) + overview.write(trend_start_row + 1 + i, 2, cnt, cell_fmt) + + n_months = len(months) + trend_end_row = trend_start_row + n_months # exclusive + + line_chart = wb.add_chart({"type": "line"}) + line_chart.add_series({ + "name": "每月事故數", + "categories": ["總覽", trend_start_row + 1, 1, trend_end_row, 1], + "values": ["總覽", trend_start_row + 1, 2, trend_end_row, 2], + "line": {"color": "#10B981", "width": 2.25}, + "marker": {"type": "circle", "size": 6, + "fill": {"color": "#10B981"}, + "border": {"color": "#065F46"}}, + "data_labels": {"value": True}, + }) + line_chart.set_title({"name": "每月事故趨勢 (Last 12 months)"}) + line_chart.set_x_axis({"name": "月份"}) + line_chart.set_y_axis({"name": "事故數"}) + line_chart.set_legend({"none": True}) + line_chart.set_size({"width": 480, "height": 320}) + overview.insert_chart("I62", line_chart, {"x_offset": 5, "y_offset": 5}) + + # ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============ + # Build it now so that 個案查詢 formulas can resolve its range properly. + # However, xlsxwriter's defined_name allows forward reference; formula text is + # what matters at runtime — even if the sheet is added later, the formula string + # '明細!A:O' will be valid when Excel opens the file. + # We define 明細 here so that the formula range 明細!A2:O{max_row} resolves. + # But xlsxwriter requires sheets to be added in the order they're defined. + # Solution: build 個案查詢 first (it sets the VLOOKUP formula referencing 明細!A:O), + # then create 明細 LAST. + # Per xlsxwriter semantics, sheet order is the add_worksheet() call order. + # So: Sheet order on tab will be: 總覽 (already added), 個案查詢 (next), 明細 (last). + # The 明細 range in the formula will be accepted even if the sheet is created later. + + # Actually in xlsxwriter, all sheets are written to the workbook buffer; + # the order in the file is the order of add_worksheet() calls. + # The formula reference to '明細' works as long as the sheet exists by the time + # the workbook closes. We'll add 個案查詢 before 明細. + + # Define the detail fields and their source column indices in 明細 + detail_fields = [ + ("id", "#"), + ("submitted_at", "時間戳記"), + ("date", "事件發生日期"), + ("time", "事件發生時間"), + ("department", "部門名稱"), + ("employee_name", "電子郵件地址"), + ("severity", "緊急程度"), + ("location", "涉及部門"), + ("patient_or_staff", "涉及的患者或相關人員"), + ("long_description", "事件描述"), + ("incident_photos", "事件相關照片"), + ("description", "當日事件摘要"), + ("action_taken", "處理情況 / 已採取的行動"), + ("needs_escalation", "需要管理層介入"), + ("incident_score", "分數"), + ("responsible_person", "負責人"), + ("medical_report", "醫療報告"), + ] + + # ============ Sheet 2: 個案查詢 ============ + detail = wb.add_worksheet("個案查詢") + detail.set_tab_color("#F59E0B") + detail.hide_gridlines(2) + detail.set_column("A:A", 2) + detail.set_column("B:B", 24) # field labels + detail.set_column("C:C", 50) # values + + detail.write("B2", "個案查詢 Case Lookup", title_fmt) + detail.write("B3", "輸入個案 ID 即時顯示詳細資料 (Excel VLOOKUP 自動)", subtitle_fmt) + + # Input row + input_label_fmt = wb.add_format({"bold": True, "bg_color": "#FEF3C7", + "border": 2, "border_color": "#F59E0B", + "align": "left", "indent": 1}) + input_fmt = wb.add_format({"bold": True, "font_size": 14, "font_color": "#1E3A8A", + "bg_color": "#FFFBEB", "border": 2, "border_color": "#F59E0B", + "align": "center"}) + # Text-type variant for cells that need string input (like case_no id) + text_input_fmt = wb.add_format({"bold": True, "font_size": 14, "font_color": "#1E3A8A", + "bg_color": "#FFFBEB", "border": 2, "border_color": "#F59E0B", + "align": "center", "num_format": "@"}) + # B5 = label only (NOT merged with C5). Otherwise user-typed text goes to + # B5 (top-left of merge) instead of C5 where the formula references. + detail.write("B5", "輸入個案 ID:", input_label_fmt) + # C5 = case_no input cell (NOT merged) + detail.write_string("C5", "", text_input_fmt) + # Hint + detail.merge_range("B6:C6", + "提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。", + subtitle_fmt) + + # Detail rows: each pulls from 明細! via VLOOKUP keyed on id (col A) + detail_row_start = 8 + for i, (col_key, label) in enumerate(detail_fields): + row_n = detail_row_start + i + lf = detail_label_fmt if i % 2 == 0 else detail_label_alt_fmt + detail.write(row_n, 1, label, lf) + col_idx = col_index_lookup.get(col_key, 1) + # Use INDEX/MATCH for type-safe text lookup. C5 is text (id column is text). + formula = f'=IFERROR(INDEX(明細!$A:$O,MATCH($C$5,明細!$A:$A,0),{col_idx}),"(找不到個案)")' + detail.write_formula(row_n, 2, formula, detail_value_fmt) + detail.set_row(row_n, 22) + + # Multi-case list (rows under detail block) + list_start_row = detail_row_start + len(detail_fields) + 2 + detail.write(f"B{list_start_row}", "多個個案列表 (輸入 ID, 顯示詳情)", section_fmt) + + list_header_row = list_start_row + 1 + detail.write(list_header_row, 1, "個案 ID", header_fmt) + detail.write(list_header_row, 2, "日期", header_fmt) + detail.write(list_header_row, 3, "時間", header_fmt) + detail.write(list_header_row, 4, "部門", header_fmt) + detail.write(list_header_row, 5, "嚴重程度", header_fmt) + detail.write(list_header_row, 6, "員工/涉及人", header_fmt) + detail.write(list_header_row, 7, "事件摘要", header_fmt) + detail.set_column("D:H", 18) + + n_list_rows = 10 + for j in range(n_list_rows): + row_n = list_header_row + 1 + j + detail.write_string(row_n, 1, "", text_input_fmt) # text input cell for case_no + col_date = col_index_lookup.get("date", 14) + col_time = col_index_lookup.get("time", 7) + col_dept = col_index_lookup.get("department", 4) + col_sev = col_index_lookup.get("severity", 6) + col_emp = col_index_lookup.get("employee_name", 3) + col_desc = col_index_lookup.get("description", 5) + # Use INDIRECT or simply use cell reference for ID — but ID varies per row. + # Easier: each row's ID is in B; build formula with that. + # VLOOKUP needs lookup_value, table, col_index, FALSE. + # table 明細!$A:$O. + # For severity col we want to apply conditional formatting so we use formula without format. + # xlsxwriter 0-indexed; Excel cell row = row_n + 1 + # detail.write_string(row_n, 1, "") writes to Excel B + cell_id_ref = f"$B${row_n + 1}" + # INDEX/MATCH for text id lookup (consistent with detail block) + detail.write_formula(row_n, 2, + f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_date}),"")', + cell_fmt) + detail.write_formula(row_n, 3, + f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_time}),"")', + cell_fmt) + detail.write_formula(row_n, 4, + f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_dept}),"")', + cell_fmt) + detail.write_formula(row_n, 5, + f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_sev}),"")', + severity_fmts["default"]) + detail.write_formula(row_n, 6, + f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_emp}),"")', + cell_fmt) + detail.write_formula(row_n, 7, + f'=IFERROR(INDEX(明細!$A:$O,MATCH({cell_id_ref},明細!$A:$A,0),{col_desc}),"")', + cell_wrap_fmt) + detail.set_row(row_n, 28) + + # Conditional formatting on the severity column in the list (rows list_header_row+1 ..) + list_sev_first = list_header_row + 1 + list_sev_last = list_header_row + n_list_rows + for code, fmt in severity_fmts.items(): + if code == "default": + continue + # Match cells in column F (=index 5) that equal "1", "2", etc. + detail.conditional_format(list_sev_first, 5, list_sev_last, 5, { + "type": "cell", + "criteria": "equal to", + "value": f'"{code}"', + "format": severity_fmts[code], + }) + + # ============ Side-by-side compare (2 cases) ============ + compare_start_row = list_header_row + n_list_rows + 3 + detail.write(f"B{compare_start_row}", "兩個個案 並列比較 Side-by-Side", section_fmt) + detail.write(f"B{compare_start_row + 1}", + "輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。", + subtitle_fmt) + + cmp_label_row = compare_start_row + 2 + detail.write(cmp_label_row, 1, "個案 A ID:", input_label_fmt) + detail.write_string(cmp_label_row, 2, "", text_input_fmt) + detail.write(cmp_label_row, 4, "個案 B ID:", input_label_fmt) + detail.write_string(cmp_label_row, 5, "", text_input_fmt) + detail.set_column("F:F", 50) + + cmp_header_row = cmp_label_row + 2 + detail.write(cmp_header_row, 1, "欄位", header_fmt) + detail.write(cmp_header_row, 2, "個案 A 值", header_fmt) + detail.write(cmp_header_row, 3, "", header_fmt) + detail.write(cmp_header_row, 4, "", header_fmt) + detail.write(cmp_header_row, 5, "個案 B 值", header_fmt) + detail.set_column("D:D", 5) + detail.set_column("E:E", 5) + + cmp_data_start = cmp_header_row + 1 + for i, (col_key, label) in enumerate(detail_fields): + row_n = cmp_data_start + i + lf = detail_label_fmt if i % 2 == 0 else detail_label_alt_fmt + detail.write(row_n, 1, label, lf) + col_idx = col_index_lookup.get(col_key, 1) + # Case A: lookup at C. Use INDEX/MATCH for text id. + id_a = f"$C${cmp_label_row + 1}" + id_b = f"$F${cmp_label_row + 1}" + detail.write_formula(row_n, 2, + f'=IFERROR(INDEX(明細!$A:$O,MATCH({id_a},明細!$A:$A,0),{col_idx}),"")', + detail_value_fmt) + detail.write_formula(row_n, 5, + f'=IFERROR(INDEX(明細!$A:$O,MATCH({id_b},明細!$A:$A,0),{col_idx}),"")', + detail_value_fmt) + detail.set_row(row_n, 22) + + # Hint at top + detail.merge_range(f"B{cmp_data_start + len(detail_fields) + 2}:C{cmp_data_start + len(detail_fields) + 2}", + "💡 全部公式 (VLOOKUP),無需啟用巨集。Excel/Numbers 都正常運作。", + subtitle_fmt) + + # ============ Sheet 3: 明細 ============ + ws = wb.add_worksheet("明細") + ws.set_tab_color("#10B981") + ws.set_column("A:A", 8) + widths_by_col = { + "id": 8, "submitted_at": 18, "employee_name": 24, "department": 14, + "description": 38, "severity": 10, "time": 10, "location": 18, + "patient_or_staff": 28, "long_description": 60, "incident_photos": 30, + "action_taken": 38, "needs_escalation": 10, "incident_score": 8, "date": 12, + } + sev_col = None + for col_idx, col in enumerate(column_order): + header_text = (excel_headers.get(col) or col).strip() + ws.write(0, col_idx, header_text, header_fmt) + ws.set_column(col_idx, col_idx, widths_by_col.get(col, 16)) + ws.set_row(0, 24) + if col == "severity": + sev_col = col_idx + ws.freeze_panes(1, 0) + + for r_idx, r in enumerate(rows, start=1): + rec = { + "id": r.id, + "submitted_at": r.submitted_at.isoformat() if r.submitted_at else "", + "date": r.date.isoformat() if r.date else "", + "time": r.time or "", + "location": r.location or "", + "employee_name": r.employee_name or "", + "employee_id": r.employee_id or "", + "department": r.department or "", + "description": r.description or "", + "severity": str(r.severity or ""), + "medical_report": r.medical_report or "", + "action_taken": r.action_taken or "", + "responsible_person": r.responsible_person or "", + "patient_or_staff": r.patient_or_staff or "", + "long_description": r.long_description or "", + "incident_photos": r.incident_photos or "", + "needs_escalation": r.needs_escalation or "", + "incident_score": r.incident_score if r.incident_score is not None else "", + "excel_row": r.excel_row if r.excel_row is not None else "", + } + ws.set_row(r_idx, 30) + for c_idx, col in enumerate(column_order): + v = rec.get(col, "") + if col == "severity": + # Apply plain format; conditional formatting will color it later + ws.write(r_idx, c_idx, v, severity_fmts["default"]) + elif col in ("description", "long_description", "action_taken"): + ws.write(r_idx, c_idx, v, cell_wrap_fmt) + elif col == "id": + # IMPORTANT: Write id as TEXT so VLOOKUP matches text input from cell C5 + ws.write_string(r_idx, c_idx, str(v), cell_fmt) + else: + ws.write(r_idx, c_idx, v, cell_fmt) + + # Apply conditional formatting on severity column (after writing data) + if sev_col is not None and rows: + first_row = 1 + last_row = len(rows) + for code, fmt in severity_fmts.items(): + if code == "default": + continue + ws.conditional_format(first_row, sev_col, last_row, sev_col, { + "type": "cell", + "criteria": "equal to", + "value": f'"{code}"', + "format": fmt, + }) + + # Also add a filter (Excel auto-filter) on the header row for easy column filtering. + ws.autofilter(0, 0, len(rows), len(column_order) - 1) + + wb.close() + buf.seek(0) + + date_suffix = "" + if date_from and date_to: + date_suffix = f"_{date_from}_{date_to}" + elif date_from: + date_suffix = f"_from_{date_from}" + elif date_to: + date_suffix = f"_to_{date_to}" + filename = f"accident{date_suffix}.xlsx" + + logger.info("accident export to xlsx v3", + extra={"context": {"user_id": current_user.id, "rows": len(rows), + "date_from": date_from, "date_to": date_to}}) + + from fastapi.responses import StreamingResponse + return StreamingResponse( + buf, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +# ============ Record Detail (from Excel) ============ +@app.get("/api/{section}/{row_id}") +async def get_record( + section: str, + row_id: int, + current_user: User = Depends(get_current_user) +): + """Get a specific record by row number""" + if section not in ["attendance", "accident"]: + raise HTTPException(status_code=400, detail="Invalid section") + + headers, rows = read_excel_file(section) + if headers is None: + raise HTTPException(status_code=404, detail="No data file found") + + idx = row_id - 1 # Convert to 0-based index + if idx < 0 or idx >= len(rows): + raise HTTPException(status_code=404, detail="Record not found") + + row = rows[idx] + record = {"_row": row_id} + for i, header in enumerate(headers): + if header: + record[str(header)] = row[i] if i < len(row) else None + + return record + +# ============ Export ============ +@app.get("/api/export/{section}/excel") +async def export_excel( + request: Request, + section: str, + token: Optional[str] = Query(None), + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + """Export Excel file. + + For attendance: builds xlsx on-the-fly from attendance_records SQL table with + full computed columns (expected_in, expected_out, actual_in, actual_out, + status_code, status_text, late/early/ot minutes). + For accident: returns the stored Excel file (legacy). + """ + from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + from jose import JWTError, jwt + from database import SECRET_KEY, ALGORITHM + from datetime import datetime as _dt + + credentials_exception = HTTPException( + status_code=401, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + current_user = None + if token: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + current_user = db.query(User).filter(User.id == user_id, User.is_active == True).first() + except (JWTError, ValueError, TypeError): + pass + if not current_user: + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + bearer_token = auth_header[7:] + try: + payload = jwt.decode(bearer_token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + current_user = db.query(User).filter(User.id == user_id, User.is_active == True).first() + except (JWTError, ValueError, TypeError): + pass + if not current_user: + raise credentials_exception + + if section not in ["attendance", "accident"]: + raise HTTPException(status_code=400, detail="Invalid section") + + # Attendance: build xlsx from SQL table + if section == "attendance": + import xlsxwriter + import io + q = db.query(AttendanceRecord) + if date_from: + try: + q = q.filter(AttendanceRecord.date >= _dt.strptime(date_from, "%Y-%m-%d").date()) + except ValueError: + pass + if date_to: + try: + q = q.filter(AttendanceRecord.date <= _dt.strptime(date_to, "%Y-%m-%d").date()) + except ValueError: + pass + rows = q.order_by(AttendanceRecord.date.desc(), AttendanceRecord.employee_name.asc()).all() + enrich_attendance_with_leave(rows, db) + + buf = io.BytesIO() + wb = xlsxwriter.Workbook( + buf, + {"in_memory": True, + "calc_on_load": True, + "calc_mode": "auto"}) + wb.set_calc_mode("auto", calc_id=1) + # ============ Sheet 1: 總覽 (Dashboard) ============ + overview = wb.add_worksheet("總覽") + overview.set_tab_color("#1E40AF") + overview.hide_gridlines(2) + overview.set_column("A:A", 2) + overview.set_column("B:B", 28) + overview.set_column("C:C", 18) + overview.set_column("D:D", 30) + overview.set_column("E:E", 18) + overview.set_column("F:F", 26) + overview.set_column("G:G", 18) + + title_fmt = wb.add_format({"bold": True, "font_size": 16, "font_color": "#1E3A8A"}) + subtitle_fmt = wb.add_format({"italic": True, "font_size": 10, "font_color": "#6B7280"}) + section_fmt = wb.add_format({"bold": True, "font_size": 12, "font_color": "#1E3A8A", + "bottom": 2, "bottom_color": "#93C5FD"}) + kpi_label_fmt = wb.add_format({"bold": True, "font_size": 9, "font_color": "#6B7280", + "align": "left"}) + kpi_value_fmt = wb.add_format({"bold": True, "font_size": 22, "font_color": "#1E3A8A", + "align": "left"}) + header_fmt_ov = wb.add_format({"font_color": "#FFFFFF", "bold": True, "align": "center", + "bg_color": "#1E40AF", "border": 1, "border_color": "#1E3A8A"}) + cell_fmt_ov = wb.add_format({"align": "left", "valign": "top"}) + link_fmt_ov = wb.add_format({"font_color": "#2563EB", "underline": 1}) + + overview.write("B2", "出勤記錄 總覽 Dashboard", title_fmt) + overview.write("B3", f"生成時間: {_dt.now().strftime('%Y-%m-%d %H:%M:%S')}", subtitle_fmt) + + if date_from or date_to: + range_str = f"{date_from or '起始'} → {date_to or '現在'}" + else: + range_str = "全部記錄" + overview.write("B4", f"篩選範圍: {range_str}", subtitle_fmt) + + # Counters + n_normal = sum(1 for r in rows if (r.status_code or "").lower() == "normal") + n_late = sum(1 for r in rows if "late" in (r.status_code or "").lower() and "early" not in (r.status_code or "").lower()) + n_early = sum(1 for r in rows if "early" in (r.status_code or "").lower() and "late" not in (r.status_code or "").lower()) + n_ot = sum(1 for r in rows if "ot" in (r.status_code or "").lower()) + n_abnormal = sum(1 for r in rows if (r.status_code or "").lower() == "abnormal") + total_late_min = sum(r.late_minutes or 0 for r in rows) + total_ot_min = sum(r.ot_minutes or 0 for r in rows) + + overview.merge_range("B6:C6", "總記錄數", kpi_label_fmt) + overview.merge_range("B7:C7", len(rows), kpi_value_fmt) + overview.merge_range("D6:E6", "正常出勤", kpi_label_fmt) + overview.merge_range("D7:E7", n_normal, kpi_value_fmt) + overview.merge_range("F6:G6", "最早事件日期", kpi_label_fmt) + earliest = min((r.date for r in rows if r.date), default=None) + overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt) + + # Status distribution table (B10:D) + overview.write("B10", "出勤狀態分佈", section_fmt) + overview.write("B11", "Status", header_fmt_ov) + overview.write("C11", "數量", header_fmt_ov) + overview.write("D11", "百分比", header_fmt_ov) + status_codes_order = ["normal", "late", "late_early", "late_ot", "early", "early_ot", "ot", "abnormal", "missing", + "holiday", "al", "sl", "cl", "mixed_leave"] + status_labels = { + "normal": "正常", "late": "遲到", "late_early": "遲到+早走", + "late_ot": "遲到+加班", "early": "早走", "early_ot": "早走+加班", + "ot": "加班", "abnormal": "異常", "missing": "缺勤", + "holiday": "🏖️公假", "al": "年假 AL", "sl": "病假 SL", "cl": "補鐘 CL", + "mixed_leave": "混合假", + } + for i, code in enumerate(status_codes_order): + count = sum(1 for r in rows if (r.status_code or "").lower() == code) + overview.write(11 + i, 1, status_labels.get(code, code), cell_fmt_ov) + overview.write(11 + i, 2, count, cell_fmt_ov) + pct = (count / len(rows) * 100) if rows else 0 + overview.write(11 + i, 3, f"{pct:.1f}%", cell_fmt_ov) + unk = sum(1 for r in rows if not (r.status_code or "").strip()) + overview.write(11 + len(status_codes_order), 1, "(空)", cell_fmt_ov) + overview.write(11 + len(status_codes_order), 2, unk, cell_fmt_ov) + pct_unk = (unk / len(rows) * 100) if rows else 0 + overview.write(11 + len(status_codes_order), 3, f"{pct_unk:.1f}%", cell_fmt_ov) + + # Department TOP 10 table (F10:G) + overview.write("F10", "部門記錄數 (TOP 10)", section_fmt) + overview.write("F11", "部門", header_fmt_ov) + overview.write("G11", "數量", header_fmt_ov) + from collections import Counter + dept_counts = Counter((r.department or "(未分類)") for r in rows) + top_depts = dept_counts.most_common(10) + for i, (dept, count) in enumerate(top_depts): + overview.write(11 + i, 5, dept, cell_fmt_ov) + overview.write(11 + i, 6, count, cell_fmt_ov) + + # ============ Charts block (Sheet 1 總覽 右側 columns I-P) ============ + # Chart 1: Status Pie (I6) + status_pie = wb.add_chart({"type": "pie"}) + status_pie.add_series({ + "name": "出勤狀態分佈", + "categories": ["總覽", 11, 1, 11 + len(status_codes_order), 1], + "values": ["總覽", 11, 2, 11 + len(status_codes_order), 2], + "data_labels": {"percentage": True, "category": False, "position": "outside_end"}, + }) + status_pie.set_title({"name": "出勤狀態分佈 (Pie)"}) + status_pie.set_style(10) + status_pie.set_size({"width": 480, "height": 320}) + status_pie.set_legend({"position": "right"}) + overview.insert_chart("I6", status_pie, {"x_offset": 5, "y_offset": 5}) + + # Chart 2: Department Bar TOP 10 (I22) + dept_bar = wb.add_chart({"type": "bar"}) + dept_bar.add_series({ + "name": "部門記錄數", + "categories": ["總覽", 11, 5, 20, 5], + "values": ["總覽", 11, 6, 20, 6], + "fill": {"color": "#3B82F6"}, + "border": {"color": "#1E40AF"}, + "data_labels": {"value": True}, + }) + dept_bar.set_title({"name": "部門記錄數 (TOP 10)"}) + dept_bar.set_x_axis({"name": "記錄數"}) + dept_bar.set_y_axis({"name": "部門", "reverse": True}) + dept_bar.set_legend({"none": True}) + dept_bar.set_size({"width": 480, "height": 320}) + overview.insert_chart("I22", dept_bar, {"x_offset": 5, "y_offset": 5}) + + # Chart 3: Status Column (I42) + status_col_chart = wb.add_chart({"type": "column"}) + status_col_chart.add_series({ + "name": "出勤狀態 數量", + "categories": ["總覽", 11, 1, 11 + len(status_codes_order) - 1, 1], + "values": ["總覽", 11, 2, 11 + len(status_codes_order) - 1, 2], + "fill": {"color": "#10B981"}, + "border": {"color": "#065F46"}, + "data_labels": {"value": True}, + }) + status_col_chart.set_title({"name": "出勤狀態 數量 (Column)"}) + status_col_chart.set_x_axis({"name": "狀態"}) + status_col_chart.set_y_axis({"name": "記錄數"}) + status_col_chart.set_legend({"none": True}) + status_col_chart.set_size({"width": 480, "height": 320}) + overview.insert_chart("I42", status_col_chart, {"x_offset": 5, "y_offset": 5}) + + # Chart 4: Monthly Trend Line (I62) + trend_start_row = 60 + overview.write(trend_start_row, 1, "月份", header_fmt_ov) + overview.write(trend_start_row, 2, "記錄數", header_fmt_ov) + + from collections import OrderedDict + today = _dt.now().date() + months = [] + for offset in range(11, -1, -1): + y = today.year + m = today.month - offset + while m <= 0: + m += 12 + y -= 1 + months.append(f"{y}-{m:02d}") + + monthly_counts = OrderedDict((m, 0) for m in months) + for r in rows: + if r.date: + key = f"{r.date.year}-{r.date.month:02d}" + if key in monthly_counts: + monthly_counts[key] += 1 + + for i, (m, cnt) in enumerate(monthly_counts.items()): + overview.write(trend_start_row + 1 + i, 1, m, cell_fmt_ov) + overview.write(trend_start_row + 1 + i, 2, cnt, cell_fmt_ov) + + n_months = len(months) + trend_end_row = trend_start_row + n_months + + line_chart = wb.add_chart({"type": "line"}) + line_chart.add_series({ + "name": "每月記錄數", + "categories": ["總覽", trend_start_row + 1, 1, trend_end_row, 1], + "values": ["總覽", trend_start_row + 1, 2, trend_end_row, 2], + "line": {"color": "#10B981", "width": 2.25}, + "marker": {"type": "circle", "size": 6, + "fill": {"color": "#10B981"}, + "border": {"color": "#065F46"}}, + "data_labels": {"value": True}, + }) + line_chart.set_title({"name": "每月記錄趨勢 (Last 12 months)"}) + line_chart.set_x_axis({"name": "月份"}) + line_chart.set_y_axis({"name": "記錄數"}) + line_chart.set_legend({"none": True}) + line_chart.set_size({"width": 480, "height": 320}) + overview.insert_chart("I62", line_chart, {"x_offset": 5, "y_offset": 5}) + + # 員工索引 (row 80+) + nav_start_row = 80 + overview.write(f"B{nav_start_row}", "員工出勤索引 (可 click → 跳去「明細」對應 row)", section_fmt) + overview.write(f"B{nav_start_row + 1}", "姓名", header_fmt_ov) + overview.write(f"C{nav_start_row + 1}", "部門", header_fmt_ov) + overview.write(f"D{nav_start_row + 1}", "日期", header_fmt_ov) + overview.write(f"E{nav_start_row + 1}", "狀態", header_fmt_ov) + overview.write(f"F{nav_start_row + 1}", "遲到 min", header_fmt_ov) + overview.write(f"G{nav_start_row + 1}", "→ 查明細", header_fmt_ov) + for idx, r in enumerate(rows): + row_n = nav_start_row + 2 + idx + overview.write(row_n, 1, r.employee_name or "", cell_fmt_ov) + overview.write(row_n, 2, r.department or "", cell_fmt_ov) + overview.write(row_n, 3, r.date.isoformat() if r.date else "", cell_fmt_ov) + overview.write(row_n, 4, r.status_text or "", cell_fmt_ov) + overview.write(row_n, 5, r.late_minutes or 0, cell_fmt_ov) + detail_row = idx + 2 + overview.write_url(row_n, 6, + f"internal:明細!A{detail_row}", + link_fmt_ov, + string=f"#{idx + 1} → 查詢", + tip=f"跳到「明細」row {detail_row}") + overview.set_row(row_n, 18) + + # ============ Sheet 2: 明細 (renamed from "Attendance") ============ + ws = wb.add_worksheet("明細") + ws.set_tab_color("#10B981") + + # Per-status formats (font color + bg color) + status_fmts = { + "normal": wb.add_format({"font_color": "#065F46", "bg_color": "#D1FAE5", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}), + "late": wb.add_format({"font_color": "#92400E", "bg_color": "#FEF3C7", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}), + "late_early": wb.add_format({"font_color": "#92400E", "bg_color": "#FED7AA", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}), + "late_ot": wb.add_format({"font_color": "#7C2D12", "bg_color": "#FED7AA", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}), + "early": wb.add_format({"font_color": "#1E40AF", "bg_color": "#DBEAFE", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}), + "early_ot": wb.add_format({"font_color": "#1E40AF", "bg_color": "#E0E7FF", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}), + "ot": wb.add_format({"font_color": "#5B21B6", "bg_color": "#EDE9FE", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}), + "abnormal": wb.add_format({"font_color": "#7F1D1D", "bg_color": "#FEE2E2", "bold": True, "align": "center", "border": 1, "border_color": "#E5E7EB"}), + "missing": wb.add_format({"font_color": "#374151", "bg_color": "#E5E7EB", "bold": True, "align": "center", "border": 1, "border_color": "#D1D5DB"}), + "default": wb.add_format({"align": "center"}), + } + + # Header format + header_fmt = wb.add_format({ + "font_color": "#1E3A8A", + "bold": True, + "align": "center", + "bg_color": "#DBEAFE", + "border": 1, + "border_color": "#93C5FD", + }) + # Column widths + widths = [ + ("Staff Name", 18), ("Company", 12), ("Department", 14), + ("Date", 12), ("Weekday", 10), ("Shift", 10), + ("Expected In", 12), ("Expected Out", 12), + ("Actual In", 12), ("Actual Out", 12), + ("Status Code", 12), ("Status Text", 22), + ("Late Min", 10), ("Early Min", 10), ("OT Min", 10), + ("Manual Edit", 12), + ] + # Write headers + for col, (label, w) in enumerate(widths): + ws.write(0, col, label, header_fmt) + ws.set_column(col, col, w) + ws.freeze_panes(1, 0) # Freeze header row + # Write data + for r_idx, r in enumerate(rows, start=1): + sc = (r.status_code or "").lower() + fmt = status_fmts.get(sc, status_fmts["default"]) + ws.write(r_idx, 0, r.employee_name or "") + ws.write(r_idx, 1, r.company or "") + ws.write(r_idx, 2, r.department or "") + ws.write(r_idx, 3, r.date.isoformat() if r.date else "") + ws.write(r_idx, 4, r.weekday or "") + ws.write(r_idx, 5, r.shift_code or "") + ws.write(r_idx, 6, r.expected_in or "-") + ws.write(r_idx, 7, r.expected_out or "-") + ws.write(r_idx, 8, r.actual_in or "-") + ws.write(r_idx, 9, r.actual_out or "-") + ws.write(r_idx, 10, sc, fmt) + ws.write(r_idx, 11, r.status_text or "") + ws.write(r_idx, 12, r.late_minutes or 0) + ws.write(r_idx, 13, r.early_minutes or 0) + ws.write(r_idx, 14, r.ot_minutes or 0) + ws.write(r_idx, 15, "Yes" if r.is_manually_edited else "") + ws.set_row(r_idx, 20) + wb.close() + buf.seek(0) + + date_suffix = "" + if date_from and date_to: + date_suffix = f"_{date_from}_{date_to}" + elif date_from: + date_suffix = f"_from_{date_from}" + elif date_to: + date_suffix = f"_to_{date_to}" + filename = f"attendance{date_suffix}.xlsx" + + logger.info("attendance export to xlsx", + extra={"context": {"user_id": current_user.id, "rows": len(rows), + "date_from": date_from, "date_to": date_to}}) + + from fastapi.responses import StreamingResponse + return StreamingResponse( + buf, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + # Accident: legacy file + path = get_excel_path(section) + if not os.path.exists(path): + raise HTTPException(status_code=404, detail="No file found") + return FileResponse( + path, + filename=f"{section}_export.xlsx", + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={section}_export.xlsx"}, + ) + +# ============ Roster / Shift Management ============ + +from fastapi.staticfiles import StaticFiles +import os as _os + +# Serve built frontend (Vite output) +if _os.path.isdir("/app/static"): + app.mount("/assets", StaticFiles(directory="/app/static/assets"), name="assets") + +@app.get("/") +async def root(): + return RedirectResponse(url="/index.html") + + +@app.get("/index.html") +async def index_html(): + """Serve the built React app index.html""" + path = "/app/static/index.html" + if not _os.path.exists(path): + raise HTTPException(status_code=404, detail="Frontend not built") + return FileResponse(path, media_type="text/html") + + +@app.get("/favicon.ico") +async def favicon(): + p = "/app/static/favicon.ico" + if _os.path.exists(p): + return FileResponse(p) + raise HTTPException(status_code=404) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) + +# ============ Client Logs (frontend error reports) ============ +class ClientLogEntry(BaseModel): + level: str = Field(..., pattern="^(debug|info|warn|error)$") + msg: str = Field(..., max_length=2000) + stack: Optional[str] = Field(None, max_length=8000) + page_url: Optional[str] = Field(None, max_length=2000) + app_version: Optional[str] = Field(None, max_length=64) + request_id: Optional[str] = Field(None, max_length=128) + browser: Optional[str] = Field(None, max_length=500) + ts: Optional[str] = Field(None, max_length=64) + + +@app.post("/api/client-logs") +async def client_logs(entry: ClientLogEntry, request: Request): + """Receive error reports from frontend. No auth required.""" + level_map = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warn": logging.WARNING, + "error": logging.ERROR, + } + log_level = level_map.get(entry.level, logging.INFO) + + # Use server's request_id as authoritative if client sent one that looks fishy + server_rid = getattr(request.state, "request_id", None) + final_rid = entry.request_id or server_rid or "-" + + extra = { + "route": entry.page_url or "-", + "page_url": entry.page_url, + "app_version": entry.app_version, + "browser": entry.browser, + "client_ip": request.client.host if request.client else None, + "context": { + "source": "client", + "request_id_from_client": entry.request_id, + "server_request_id": server_rid, + "ts_from_client": entry.ts, + }, + } + + if entry.stack: + # Log as if exception + logger.log(log_level, "client error: %s", entry.msg, extra={ + **extra, + "context": { + **extra["context"], + "error": {"name": "ClientError", "message": entry.msg, "stack": entry.stack}, + }, + }) + else: + logger.log(log_level, "client: %s", entry.msg, extra=extra) + + return {"status": "ok", "request_id": server_rid} + + diff --git a/backend/middleware.py b/backend/middleware.py new file mode 100644 index 0000000..624b275 --- /dev/null +++ b/backend/middleware.py @@ -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}, + ) diff --git a/backend/migrate_phase2.py b/backend/migrate_phase2.py new file mode 100644 index 0000000..a434fec --- /dev/null +++ b/backend/migrate_phase2.py @@ -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) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..3f3f95c --- /dev/null +++ b/backend/models.py @@ -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) diff --git a/backend/parsers/__init__.py b/backend/parsers/__init__.py new file mode 100644 index 0000000..032e57f --- /dev/null +++ b/backend/parsers/__init__.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3ecddf2 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..c927c87 --- /dev/null +++ b/backend/schemas.py @@ -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 diff --git a/backend/secret.py b/backend/secret.py new file mode 100644 index 0000000..b029a0e --- /dev/null +++ b/backend/secret.py @@ -0,0 +1,2 @@ +# AARS JWT Secret - CHANGE THIS IN PRODUCTION! +JWT_SECRET = "aars-jwt-secret-2026-prod-do-not-share" diff --git a/backend/test_backend.py b/backend/test_backend.py new file mode 100644 index 0000000..665076a --- /dev/null +++ b/backend/test_backend.py @@ -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") diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..d40c2c7 --- /dev/null +++ b/deploy.sh @@ -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 "==========================================" diff --git a/deploy_static.sh b/deploy_static.sh new file mode 100755 index 0000000..5142539 --- /dev/null +++ b/deploy_static.sh @@ -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/" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2d9f1da --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..333c1e9 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +logs/ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..449eee6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + AARS - Attendance & Accident Record System + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..03a7df8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3380 @@ +{ + "name": "aars-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aars-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.7", + "lucide-react": "^0.441.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "react-router-dom": "^6.26.2", + "recharts": "^2.12.7" + }, + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.3", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.3.tgz", + "integrity": "sha512-bJRzflk8GgE4JX+iZNEwz9f9p460NCHnU7bd+CZ9vIjIlZuTkt6F3WSl2oNO8StZBFx17nLEsiQ6H2wcZiY7nA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001805", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.441.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.441.0.tgz", + "integrity": "sha512-0vfExYtvSDhkC2lqg0zYVW1Uu9GsI4knuV9GP9by5z0Xhc4Zi5RejTxfz9LsjRmCyWVzHCJvxGKZWcRyvQCWVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..08e8b67 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..357bc55 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+
Loading...
+
+ ) + } + + if (!user) { + return + } + + return children +} + +export default function App() { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..57be58e --- /dev/null +++ b/frontend/src/api.js @@ -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 diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..6449feb --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -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 ( +
+
+

+ ⚠️ 頁面發生錯誤 / Something went wrong +

+

+ 已經將錯誤自動回報俾 IT狗。請 reload 或者返回上一頁。 +

+ + {this.state.error?.message && ( +
+                {this.state.error.message}
+              
+ )} +
+
+ ) + } + return this.props.children + } +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..717fc08 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -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 ( +
+ {/* Header */} +
+
+
+ {/* Mobile hamburger (left side) */} + + + {/* Logo */} +
+
+
+ A +
+ AARS +
+ + {/* Desktop nav */} + +
+ + {/* Right side */} +
+ + `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' + }` + } + > + + 上傳 + + +
+
+
{user?.name}
+
{user?.role}
+
+ +
+ + {/* Mobile logout button (always visible) */} + +
+
+
+
+ + {/* Mobile drawer overlay */} + {mobileOpen && ( +
+ )} + + {/* Mobile drawer panel */} +
+
+
+
+ A +
+ AARS +
+ +
+ +
+
+
+
{user?.name}
+
{user?.role}
+
+
+
+
+ + {/* Main content */} +
+ +
+
+ ) +} diff --git a/frontend/src/components/StatusBadge.jsx b/frontend/src/components/StatusBadge.jsx new file mode 100644 index 0000000..238a16f --- /dev/null +++ b/frontend/src/components/StatusBadge.jsx @@ -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) => ( + + {icon} + {label} + + ) + + // ============ Attendance status badges ============ + if (code === 'normal') { + return ( + + {badge(, mainText || '正常', 'bg-green-100 text-green-700')} + {leaveParts.map((lp, i) => ( + + + {lp} + + ))} + + ) + } + if (code === 'late' || code === 'late_early' || code === 'late_ot') { + return ( + + {badge(, mainText || code, 'bg-orange-100 text-orange-700')} + {leaveParts.map((lp, i) => ( + + + {lp} + + ))} + + ) + } + if (code === 'early' || code === 'early_ot') { + return ( + + {badge(, mainText || code, 'bg-blue-100 text-blue-700')} + {leaveParts.map((lp, i) => ( + + + {lp} + + ))} + + ) + } + if (code === 'ot') { + return ( + + {badge(, mainText || '加班', 'bg-purple-100 text-purple-700')} + {leaveParts.map((lp, i) => ( + + + {lp} + + ))} + + ) + } + if (code === 'abnormal') { + return ( + + {badge(, mainText || '⚠️異常', 'bg-red-100 text-red-700')} + {leaveParts.map((lp, i) => ( + + + {lp} + + ))} + + ) + } + if (code === 'missing') { + return ( + + {badge(, '缺勤', 'bg-gray-200 text-gray-600')} + {leaveParts.map((lp, i) => ( + + {lp} + + ))} + + ) + } + + // ============ Enriched leave status badges ============ + if (code === 'holiday') { + return badge(, mainText, 'bg-amber-100 text-amber-700') + } + if (code === 'al') { + return badge(, mainText || '年假', 'bg-emerald-100 text-emerald-700') + } + if (code === 'sl') { + return badge(, mainText || '病假', 'bg-pink-100 text-pink-700') + } + if (code === 'cl') { + return badge(, mainText || '補鐘', 'bg-indigo-100 text-indigo-700') + } + if (code === 'mixed_leave') { + return badge(, mainText || '混合假', 'bg-fuchsia-100 text-fuchsia-700') + } + + // fallback + return {text || code || '-'} +} \ No newline at end of file diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..da4d527 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..2273792 --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/lib/logger.js b/frontend/src/lib/logger.js new file mode 100644 index 0000000..00bac2c --- /dev/null +++ b/frontend/src/lib/logger.js @@ -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 } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..8de18c3 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + + + + + + + + , +) diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..c006d79 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -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 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 ( +
+

+ {title} +

+
+ {items.length === 0 ? ( +

暫無數據

+ ) : items.map((s, i) => ( +
+
+
+ {i + 1} + {s.name} +
+
+ + {rankKey === 'rate' ? `${s[rankKey]}%` : s[rankKey]} + + {subKey && (s[subKey] || 0) > 0 && ( + ({s[subKey]}{unit || '次'}) + )} +
+
+ {/* Option 1 detail row */} + {details.length > 0 && ( +
+ {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 {display} + })} +
+ )} +
+ ))} +
+
+ ) + } + + return ( +
+ {/* Header */} +
+

Dashboard

+
+ {/* Export Buttons */} +
+ +
+ +
+ +
+ + 出勤記錄 + +
+
+ + {/* Date Filter */} +
+
+
+ 日期範圍: + setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" /> + - + setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" /> +
+
+ + + + + +
+
+ {(dateFrom || dateTo) && ( +

顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據

+ )} +
+ + {loading ? ( +
載入中...
+ ) : summary ? ( + <> +
+ {statCards.map(({ label, value, icon: Icon, color }) => ( +
+
+
+
+
{value ?? 0}
+
{label}
+
+
+
+ ))} +
+ +
+
+
+
+
+
+
+
+ + {byStaff.length > 0 && ( +
+ s.late_count > 0 ? '#F59E0B' : '#10B981'} /> + s.ot_count > 0 ? '#8B5CF6' : '#10B981'} /> + s.missing_count > 0 ? '#EF4444' : '#10B981'} /> + s.rate >= 90 ? '#10B981' : s.rate >= 70 ? '#F59E0B' : '#EF4444'} /> +
+ )} + + {stats?.stats?.length > 0 && ( +
+

+ 員工出勤概覽 +

+
+ + + + + + + + + + + + + + + + {byStaff.map((s) => ( + + + + + + + + + + + + ))} + +
員工出勤正常遲到遲分鐘OTOT分鐘異常缺勤
{s.name}{s.total_records ?? s.total ?? 0}{(s.total_records ?? s.total ?? 0) - (s.late_count || 0) - (s.abnormal_count || 0)}{s.late_count || 0}{s.late_minutes || '-'}{s.ot_count || 0}{s.ot_minutes || '-'}{s.abnormal_count || 0}{s.missing_count || 0}
+
+
+ )} + + ) : ( +
暫無數據
+ )} +
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..ab904af --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -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 ( +
+
+ {/* Logo */} +
+
+ A +
+

AARS

+

Attendance & Accident Record System

+
+ + {/* Login form */} +
+
+
+ + setEmail(e.target.value)} + className="input-field" + placeholder="admin@aars.hk" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="input-field" + placeholder="••••••••" + required + /> +
+ + +
+ +
+

預設帳戶:admin@aars.hk / admin123

+
+
+
+
+ ) +} diff --git a/frontend/src/pages/accident/Dashboard.jsx b/frontend/src/pages/accident/Dashboard.jsx new file mode 100644 index 0000000..aed612b --- /dev/null +++ b/frontend/src/pages/accident/Dashboard.jsx @@ -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
載入中…
+ } + + return ( +
+ {/* Header */} +
+

意外事故 Dashboard

+
+ + + 列表 + + +
+
+ + {/* Date Range Filter */} +
+
+
+ 日期範圍: + setDateFrom(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" /> + - + setDateTo(e.target.value)} className="text-sm border border-slate-300 rounded-md px-3 py-2" /> +
+
+ + + + + +
+
+ {(dateFrom || dateTo) && ( +

顯示 {dateFrom || '開始'} ~ {dateTo || today} 的數據

+ )} +
+ + {/* Stats Cards */} + {summary && ( +
+ + + + + {Object.entries(summary.severity_count || {}).map(([sev, count]) => ( + + ))} +
+ )} + + {/* Charts */} +
+ {/* Severity Distribution */} +
+

嚴重程度分佈

+ {severityData.length === 0 ? ( +

暫無數據

+ ) : ( + + + `${entry.name}: ${entry.value}`} + > + {severityData.map((entry, i) => ( + + ))} + + + + + )} +
+ + {/* Monthly Trend */} +
+

月度趨勢

+ {monthlyTrend.length === 0 ? ( +

暫無數據

+ ) : ( + + + + + + + + + + )} +
+
+ + {/* Leaderboards */} + {stats && ( +
+ + + + +
+ )} +
+ ) +} + +function StatCard({ icon: Icon, color, label, value, overrideColor, overrideBg }) { + return ( +
+
+
+

{label}

+

{value}

+
+ {Icon && ( +
+ +
+ )} +
+
+ ) +} + +function RankCard({ title, icon: Icon, data, field }) { + return ( +
+

+ {title} +

+
+ {data.length === 0 ? ( +

暫無數據

+ ) : data.map((s, i) => ( +
+
+
+ {i + 1} + {s.name} +
+
+ {s[field]} + avg {s.avg_severity || 0} +
+
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/accident/Detail.jsx b/frontend/src/pages/accident/Detail.jsx new file mode 100644 index 0000000..8c98db2 --- /dev/null +++ b/frontend/src/pages/accident/Detail.jsx @@ -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 ( +
+
載入中...
+
+ ) + } + + if (error) { + return ( +
+
+ + + 返回列表 + +
+
+ +
{error}
+ +
+
+ ) + } + + const sev = String(record.severity || '') + const sevColor = SEVERITY_COLORS[sev] || { bg: '#F3F4F6', fg: '#374151', label: sev || '-' } + + return ( +
+
+
+ + + 返回 + +

+ 意外記錄 #{id} +

+
+ + Severity {sev} · {sevColor.label} + +
+ +
+
+
+ +
+
記錄詳情
+
AARS 意外事故記錄
+
+
+
+ +
+ {FIELDS.map(({ key, label, badge }) => { + const val = record[key] + const display = val !== null && val !== undefined && val !== '' ? String(val) : '-' + return ( +
+
+ {label} +
+
+ {badge && sev !== '' ? ( + + {sev} · {sevColor.label} + + ) : ( + display + )} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/pages/accident/List.jsx b/frontend/src/pages/accident/List.jsx new file mode 100644 index 0000000..09544fa --- /dev/null +++ b/frontend/src/pages/accident/List.jsx @@ -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' ? : + } + + 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 ( +
+ {/* Header */} +
+

意外記錄 Accident

+
+ + + 上傳 Excel + +
+
+ + {/* Column Settings Panel */} + {showColumnSettings && ( +
+
+
+ 選擇顯示嘅欄位 (來源: Excel) +
+ +
+
+ {columnOrder.filter(c => c !== 'id').map(col => { + const isVisible = visibleCols === null || (visibleCols[col] !== false) + return ( + + ) + })} +
+
+ )} + + {/* Filters */} +
+
+
+ + setSearch(e.target.value)} + placeholder="搜尋所有欄位..." + className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md" + /> +
+
+ + + {filterKey && ( + <> + setFilterValue(e.target.value)} + placeholder="輸入篩選值..." + className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32" + /> + + + )} +
+
+ {processedData.length} 筆記錄 +
+
+
+ + {/* Table */} +
+
+ + + + + {displayedCols.map(h => ( + + ))} + + + + + {loading ? ( + + + + ) : processedData.length === 0 ? ( + + + + ) : ( + processedData.map((record) => ( + navigate(`/accident/${record.id}`)} + > + + {displayedCols.map(h => ( + + ))} + + + )) + )} + +
# handleSort(h)} + title={excelHeaders[h] || h} + > +
+ {displayName(h)} + {getSortIcon(h)} +
+
操作
+ 載入中... +
+ 沒有記錄 +
{record.id} + {record[h] !== null && record[h] !== undefined && record[h] !== '' ? String(record[h]) : '-'} + e.stopPropagation()}> + + + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/attendance/Detail.jsx b/frontend/src/pages/attendance/Detail.jsx new file mode 100644 index 0000000..3749edd --- /dev/null +++ b/frontend/src/pages/attendance/Detail.jsx @@ -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 ( + + {text || code} + + ) + } + + 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 ( +
+
載入中...
+
+ ) + } + + if (error) { + return ( +
+ + 返回列表 + +
{error}
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + 返回 + +

+ 出勤記錄 - {record?.employee_name} +

+
+
+ {record && getStatusBadge(record.status_code, record.status_text)} + {!editMode ? ( + + ) : ( + <> + + + + )} +
+
+ + {/* Manual edit warning */} + {record?.is_manually_edited && ( +
+ ⚠️ 此記錄曾被手動修改,之後重新導入 Excel 不會覆蓋此記錄 +
+ )} + + {/* Record Details */} +
+
+

記錄詳情

+
+
+ {displayFields.map(({ key, label }) => ( +
+
+ {label} +
+
+ {editMode && ['status_code', 'late_minutes', 'early_minutes', 'ot_minutes', 'actual_in', 'actual_out', 'shift_code'].includes(key) ? ( + 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" + /> + ) : ( + + {record?.[key] !== null && record?.[key] !== undefined ? String(record[key]) : '-'} + + )} +
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/attendance/List.jsx b/frontend/src/pages/attendance/List.jsx new file mode 100644 index 0000000..d74a9dc --- /dev/null +++ b/frontend/src/pages/attendance/List.jsx @@ -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' ? : + } + + const getStatusBadge = (record) => { + return + } + + 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 ( +
+ {/* Header */} +
+

出勤記錄 Attendance

+
+ + Dashboard + + + 上傳 Excel + +
+
+ + {/* Filters */} +
+ {/* Search row */} +
+
+ + setSearch(e.target.value)} + placeholder="搜尋員工姓名..." + className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md" + /> +
+ +
+ + {/* Filter row */} +
+
+ 日期: + { setDateFrom(e.target.value); setPage(1) }} + className="text-sm border border-slate-300 rounded-md px-2 py-1.5" + placeholder="由" + /> + - + { setDateTo(e.target.value); setPage(1) }} + className="text-sm border border-slate-300 rounded-md px-2 py-1.5" + placeholder="至" + /> +
+ +
+ 員工: + +
+ +
+ 狀態: + +
+ + +
+ + {/* Stats bar */} +
+ {total} 筆記錄 + {staffFilter && • 員工: {staffFilter}} + {statusFilter && • 狀態: {statusFilter}} + {(dateFrom || dateTo) && • {dateFrom || '...'} 至 {dateTo || '...'}} +
+
+ + {/* Table */} +
+
+ + + + + {columns.map(col => ( + + ))} + + + + + + {loading ? ( + + + + ) : records.length === 0 ? ( + + + + ) : ( + records.map((record, idx) => ( + + + + + + + + + + + + + + )) + )} + +
# handleSort(col.key)} + className="px-3 py-2.5 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200" + > +
+ {col.label} + {getSortIcon(col.key)} +
+
狀態操作
+ 載入中... +
+ 沒有記錄 +
{(page - 1) * perPage + idx + 1}{record.employee_name || '-'}{record.date || '-'}{record.weekday || '-'}{record.shift_code || '-'}{record.actual_in || '-'}{record.actual_out || '-'}{record.expected_in || '-'}{record.expected_out || '-'} + {getStatusBadge(record)} + + + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + 第 {page} / {totalPages} 頁,共 {total} 筆記錄 + +
+ + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/holidays/Holidays.jsx b/frontend/src/pages/holidays/Holidays.jsx new file mode 100644 index 0000000..c2ebd13 --- /dev/null +++ b/frontend/src/pages/holidays/Holidays.jsx @@ -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 ( +
+
+
+

假期 Holidays

+

+ 管理公眾假期 + 員工請假 (SL 病假 / CL 補鐘 / AL 年假) +

+
+
+ + {/* Tabs */} +
+ +
+ + {tab === 'holidays' ? : } +
+ ) +} + +// ============ 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 ( +
+ {/* Toolbar */} +
+
+ + +
+
+ + +
+ + {/* Table */} +
+ {loading ? ( +
載入中...
+ ) : holidays.length === 0 ? ( +
{year} 年冇假期資料
+ ) : ( + + + + + + + + + + + + + + {holidays.map(h => ( + + + + + + + + + + ))} + +
日期名稱 (中)Name (EN)類型來源備註操作
{h.date}{h.name}{h.name_en || '-'} + {h.is_mandatory ? ( + 法定 + ) : ( + 銀行/學校 + )} + {h.source || '-'}{h.notes || '-'} + + +
+ )} +
+ +
+ 共 {holidays.length} 個公眾假期 · 來源:gov.hk +
+ + {(showCreate || editing) && ( + { setShowCreate(false); setEditing(null) }} + onSaved={() => { setShowCreate(false); setEditing(null); load() }} + /> + )} +
+ ) +} + +// ============ 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 ( +
+ {/* Filters */} +
+ + setFilterFrom(e.target.value)} + placeholder="由" + className="px-3 py-1.5 border border-slate-300 rounded-md text-sm" + /> + setFilterTo(e.target.value)} + placeholder="至" + className="px-3 py-1.5 border border-slate-300 rounded-md text-sm" + /> + + +
+ + + + +
+ + {/* Table */} +
+ {loading ? ( +
載入中...
+ ) : leaves.length === 0 ? ( +
冇請假記錄
+ ) : ( + + + + + + + + + + + + + + + {leaves.map(lv => ( + + + + + + + + + + + ))} + +
員工日期類型時數原因審批人來源操作
{lv.employee_name}{lv.date} + + {LEAVE_TYPES.find(t => t.value === lv.leave_type)?.label || lv.leave_type} + + {lv.hours}h{lv.reason || '-'}{lv.approved_by || '-'}{lv.source || '-'} + + +
+ )} +
+ +
+ 共 {leaves.length} 條請假記錄 +
+ + {(showCreate || editing) && ( + { setShowCreate(false); setEditing(null) }} + onSaved={() => { setShowCreate(false); setEditing(null); load() }} + /> + )} +
+ ) +} + +// ============ 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 ( +
+
e.stopPropagation()}> +
+

{isEdit ? '編輯假期' : '加公假'}

+ +
+
+
+ + setDate(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" + /> +
+
+ + setName(e.target.value)} + placeholder="元旦 / 香港回歸紀念日" + className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" + /> +
+
+ + 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" + /> +
+
+ setIsMandatory(e.target.checked)} + className="rounded" + /> + +
+
+ + setNotes(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" + /> +
+
+
+ + +
+
+
+ ) +} + +// ============ 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 ( +
+
e.stopPropagation()}> +
+

{isEdit ? '編輯請假' : '加請假'}

+ +
+
+
+ + {employees.length > 0 ? ( + + ) : ( + setEmployeeName(e.target.value)} + placeholder="員工姓名" + className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" + /> + )} +
+
+ + setDate(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" + /> +
+
+ +
+ {LEAVE_TYPES.map(t => ( + + ))} +
+
+
+ + setHours(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" + /> +
+ {[1, 2, 4, 8].map(h => ( + + ))} +
+
+
+ + setReason(e.target.value)} + placeholder="睇醫生 / Family trip / 補鐘" + className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" + /> +
+
+ + setApprovedBy(e.target.value)} + placeholder="Manager A" + className="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" + /> +
+
+
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/import/Import.jsx b/frontend/src/pages/import/Import.jsx new file mode 100644 index 0000000..9c84cd2 --- /dev/null +++ b/frontend/src/pages/import/Import.jsx @@ -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 ( +
+ {/* Header */} +
+

Excel Import

+

上傳 Excel 文件批量導入數據

+
+ + {/* Section selector */} +
+
+ 選擇類別: + + + +
+
+ + {/* Progress steps */} +
+ {[1, 2, 3].map((s) => ( +
+
= s ? 'bg-primary-600 text-white' : 'bg-slate-200 text-slate-500' + }`}> + {s} +
+ = s ? 'text-primary-600' : 'text-slate-400'}`}> + {s === 1 ? '上傳檔案' : s === 2 ? '欄位映射' : '完成'} + + {s < 3 && } +
+ ))} +
+ + {/* Step 1: Upload */} + {step === 1 && ( +
+
fileInputRef.current?.click()} + > + +

點擊上傳 Excel 文件

+

支援 .xlsx, .xls 格式

+ +
+
+ )} + + {/* Step 2: Mapping & Preview */} + {step === 2 && ( +
+ {/* File info */} +
+
+ +
+
{file?.name}
+
{preview.length + 1} rows
+
+
+ +
+ + {/* Mapping */} +
+
+

欄位映射 Field Mapping

+

將 Excel 欄位映射到系統欄位

+
+
+ {headers.map((header, idx) => ( +
+
+
{header}
+
Column {idx + 1}
+
+ +
+ ))} +
+
+ + {/* Preview */} +
+
+

預覽 Preview

+
+
+ + + + {headers.map((h, i) => ( + + ))} + + + + {preview.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {h} + {mapping[i] && ( + → {mapping[i]} + )} +
+ {String(cell)} +
+
+
+ + {/* Options */} +
+ +
+ + {/* Import button */} +
+ +
+
+ )} + + {/* Step 3: Result */} + {step === 3 && result && ( +
+
+ +
+ +

導入完成!

+

已完成數據導入,以下是結果摘要

+ +
+
+
{result.created}
+
已建立
+
+
+
{result.updated}
+
已更新
+
+
+
{result.skipped}
+
已跳過
+
+
+ + {result.errors?.length > 0 && ( +
+
+ + 錯誤 ({result.errors.length}) +
+
    + {result.errors.slice(0, 10).map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} + +
+ + + 查看列表 + +
+
+ )} + +
+ ) +} diff --git a/frontend/src/pages/roster/Roster.jsx b/frontend/src/pages/roster/Roster.jsx new file mode 100644 index 0000000..c2894b9 --- /dev/null +++ b/frontend/src/pages/roster/Roster.jsx @@ -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
載入中...
+ } + + return ( +
+

班次管理 Roster

+ + {/* Upload Section */} +
+

+ + 上傳班次表 +

+ + {!rosterInfo.uploaded ? ( +
+
+ + setFile(e.target.files[0])} + className="input-field" + /> +
+ +
+ ) : ( +
+
+ +
+

已上傳班次表

+

+ {rosterInfo.rows} 個班次,{rosterInfo.columns} 欄 +

+
+
+ +
+ )} +
+ + {/* Roster Table */} + {rosterData.headers.length > 0 && ( +
+
+

+ + 班次表 +

+
+
+ + + + {rosterData.headers.map((header, idx) => ( + + ))} + + + + {rosterData.rows.map((row, rowIdx) => ( + + {rosterData.headers.map((header, colIdx) => ( + + ))} + + ))} + +
+ {header} +
+ {row[colIdx] !== null && row[colIdx] !== undefined + ? String(row[colIdx]) + : '-'} +
+
+
+ )} + + {/* Instructions */} + {!rosterInfo.uploaded && ( +
+

如何使用

+
    +
  1. 上傳包含班次資料的 Excel 檔案
  2. +
  3. Excel 需要有「班次」同「描述」欄
  4. +
  5. 上傳後可以睇到所有班次
  6. +
  7. 喺 Attendance Excel 入面加「班次」欄,填入員工所屬班次代碼
  8. +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/upload/Upload.jsx b/frontend/src/pages/upload/Upload.jsx new file mode 100644 index 0000000..6210495 --- /dev/null +++ b/frontend/src/pages/upload/Upload.jsx @@ -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 ( +
+
+

上傳 Excel

+

直接上傳 Excel 檔案作為數據源

+
+ + {/* Section selector */} +
+
+ 選擇類別: + {SECTIONS.map(s => ( + + ))} +
+
+ + {/* Current file info */} + {info && ( +
+
+
+
+ {section === 'accident' ? '意外' : '出勤'} 數據: +
+
+ {info.uploaded + ? `${info.rows} 行, ${info.columns} 列` + : '尚未上傳'} +
+ {info.headers && info.headers.length > 0 && ( +
+ 欄位:{info.headers.slice(0, 5).join(', ')} + {info.headers.length > 5 && '...'} +
+ )} +
+
+ {info.uploaded && ( + + )} + {info.uploaded && ( + + )} +
+
+
+ )} + + {/* Upload area */} +
+
fileInputRef.current?.click()} + > + +

+ {file ? file.name : '點擊選擇 Excel 檔案'} +

+

支援 .xlsx, .xls 格式

+ +
+ + {file && ( +
+
+
+ +
+
{file.name}
+
+ {(file.size / 1024).toFixed(1)} KB +
+
+
+
+ + +
+
+ {uploading && uploadProgress > 0 && ( +
+
+
+ )} +
+ )} +
+ + {/* Result */} + {result && ( +
+
+ +
+
{result.message}
+
+ {result.rows} 行數據已就緒 +
+
+
+
+ )} + + {/* Instructions */} +
+
+ +
+
注意事項
+
    +
  • • 上傳的 Excel 將直接作為數據源
  • +
  • • 第一行必須是欄位名稱
  • +
  • • 之後每行是一條記錄
  • +
  • • 現有數據會被新上傳的文件替換
  • +
+
+
+
+
+ ) +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..26f08d8 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..fbbaab5 --- /dev/null +++ b/frontend/vite.config.js @@ -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, + } + } + } +}) diff --git a/scripts/logs-cleanup.sh b/scripts/logs-cleanup.sh new file mode 100755 index 0000000..95d7762 --- /dev/null +++ b/scripts/logs-cleanup.sh @@ -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" diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..79261a8 --- /dev/null +++ b/setup.sh @@ -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!"