From eafc1c33fc336509b85526e37848ae84e2f79e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?IT=E7=8B=97?= Date: Thu, 16 Jul 2026 03:16:33 +0800 Subject: [PATCH] Add full AARS system - Attendance & Accident Record System - FastAPI backend with SQLite database - React + TailwindCSS frontend - JWT authentication - Excel upload/download for attendance and accident records - Shift roster management with S7a/S7b split - Lateness/OT calculation based on shift times - Roster info API endpoints - Export API with token query parameter support - Docker compose deployment --- .gitignore | 5 + Dockerfile | 40 ++ README.md | 129 ++++ SPEC.md | 377 +++++++++++ backend/auth.py | 72 +++ backend/database.py | 72 +++ backend/main.py | 781 +++++++++++++++++++++++ backend/models.py | 94 +++ backend/requirements.txt | 11 + backend/schemas.py | 187 ++++++ backend/secret.py | 2 + backend/test_backend.py | 65 ++ docker-compose.yml | 21 + frontend/index.html | 17 + frontend/package.json | 29 + frontend/postcss.config.js | 6 + frontend/src/App.jsx | 53 ++ frontend/src/api.js | 32 + frontend/src/components/Layout.jsx | 127 ++++ frontend/src/context/AuthContext.jsx | 52 ++ frontend/src/index.css | 39 ++ frontend/src/main.jsx | 18 + frontend/src/pages/Dashboard.jsx | 340 ++++++++++ frontend/src/pages/Login.jsx | 87 +++ frontend/src/pages/accident/Detail.jsx | 129 ++++ frontend/src/pages/accident/List.jsx | 235 +++++++ frontend/src/pages/attendance/Detail.jsx | 129 ++++ frontend/src/pages/attendance/List.jsx | 301 +++++++++ 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 + setup.sh | 52 ++ 34 files changed, 4388 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.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 100644 docker-compose.yml create mode 100644 frontend/index.html 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/Layout.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/index.css 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/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/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 100644 setup.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3abbf11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +data/ +__pycache__/ +*.pyc +node_modules/ +.env 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..f77a77c --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# 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`。 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/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/main.py b/backend/main.py new file mode 100644 index 0000000..822d1e7 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,781 @@ +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 +from schemas import * +from auth import get_current_user, get_current_admin, get_current_user_from_query + +app = FastAPI(title="AARS API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 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() + +# ============ 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): + raise HTTPException(status_code=401, detail="Invalid email or password") + access_token = create_access_token({"sub": str(user.id)}) + 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(...), + current_user: User = Depends(get_current_user) +): + """Upload Excel file - replaces any existing file for this section""" + 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 + path = get_excel_path(section) + contents = await file.read() + + with open(path, "wb") as f: + f.write(contents) + + # Return info about the uploaded file + headers, rows = read_excel_file(section) + + return { + "message": f"{section} file uploaded successfully", + "filename": file.filename, + "rows": len(rows), + "columns": len(headers), + "headers": headers + } + + +# ============ 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) + if check_in_dt.hour == 18 and check_in_dt.minute == 0 and check_in_dt.second == 0: + result["is_missing"] = True + result["status_code"] = "missing" + 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"] = int(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"] = int(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"] = int(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 "正常" + + +@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) ============ +@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": [] + } + +# ============ 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), + db: Session = Depends(get_db) +): + """Export the stored Excel file""" + from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + from jose import JWTError, jwt + from database import SECRET_KEY, ALGORITHM + + credentials_exception = HTTPException( + status_code=401, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + current_user = None + + # If token in query, use it + 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 no query token, try Authorization header + 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") + + 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 ============ + +@app.get("/") +async def root(): + return RedirectResponse(url="/index.html") + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..37ba7ba --- /dev/null +++ b/backend/models.py @@ -0,0 +1,94 @@ +from sqlalchemy import Column, Integer, String, Text, Float, Boolean, DateTime, Date, Time, JSON, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base +from datetime import datetime, timezone + + +def now_hkt(): + """Return current time in HKT timezone.""" + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), unique=True, index=True, nullable=False) + password_hash = Column(String(255), nullable=False) + name = Column(String(255), nullable=False) + role = Column(String(50), default="user") # admin, user + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=now_hkt) + updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt) + deleted_at = Column(DateTime, nullable=True) + + +class Attendance(Base): + __tablename__ = "attendance" + + id = Column(Integer, primary_key=True, index=True) + employee_id = Column(String(100), index=True, nullable=False) + employee_name = Column(String(255), nullable=False) + department = Column(String(100), index=True) + date = Column(Date, nullable=False) + check_in = Column(Time, nullable=True) + check_out = Column(Time, nullable=True) + overtime_hours = Column(Float, default=0) + leave_type = Column(String(50), nullable=True) # annual, sick, unpaid, other + leave_hours = Column(Float, default=0) + status = Column(String(50), nullable=False) # present, absent, late, leave + remarks = Column(Text, nullable=True) + custom_fields = Column(JSON, default={}) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime, default=now_hkt) + updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt) + deleted_at = Column(DateTime, nullable=True) # Soft delete + + +class Accident(Base): + __tablename__ = "accidents" + + id = Column(Integer, primary_key=True, index=True) + date = Column(Date, nullable=False) + time = Column(Time, nullable=True) + location = Column(String(255), nullable=False) + employee_name = Column(String(255), nullable=False) + employee_id = Column(String(100)) + department = Column(String(100)) + description = Column(Text, nullable=False) + severity = Column(String(50), nullable=False) # minor, moderate, serious, fatal + medical_report = Column(Text, nullable=True) + action_taken = Column(Text, nullable=True) + responsible_person = Column(String(255), nullable=True) + custom_fields = Column(JSON, default={}) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime, default=now_hkt) + updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt) + deleted_at = Column(DateTime, nullable=True) # Soft delete + + +class AttendanceCustomField(Base): + __tablename__ = "attendance_custom_fields" + + id = Column(Integer, primary_key=True, index=True) + field_name = Column(String(100), nullable=False) + field_type = Column(String(50), nullable=False) # text, number, date, select + field_key = Column(String(100), unique=True, nullable=False) + required = Column(Boolean, default=False) + options = Column(JSON, default=[]) # For select type + order = Column(Integer, default=0) + created_at = Column(DateTime, default=now_hkt) + + +class AccidentCustomField(Base): + __tablename__ = "accident_custom_fields" + + id = Column(Integer, primary_key=True, index=True) + field_name = Column(String(100), nullable=False) + field_type = Column(String(50), nullable=False) # text, number, date, select + field_key = Column(String(100), unique=True, nullable=False) + required = Column(Boolean, default=False) + options = Column(JSON, default=[]) # For select type + order = Column(Integer, default=0) + created_at = Column(DateTime, default=now_hkt) 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dc9b0e9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + aars: + build: + context: . + dockerfile: Dockerfile + ports: + - "18775:8000" + volumes: + - ./data:/app/data + 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/index.html b/frontend/index.html new file mode 100644 index 0000000..6aa8ada --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + AARS - Attendance & Accident Record System + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bbe1b3f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "aars-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "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" + } +} 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..59ef34e --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,53 @@ +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 AccidentList from './pages/accident/List' +import AccidentDetail from './pages/accident/Detail' +import UploadPage from './pages/upload/Upload' +import RosterPage from './pages/roster/Roster' + +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..6b53bfe --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,32 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '', + timeout: 30000, +}) + +// Request interceptor to add auth token +api.interceptors.request.use( + config => { + const token = localStorage.getItem('aars_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => Promise.reject(error) +) + +// Response interceptor for error handling +api.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + localStorage.removeItem('aars_token') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +export default api diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..8e06cfb --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,127 @@ +import { Outlet, NavLink, useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar } from 'lucide-react' + +export default function Layout() { + const { user, logout } = useAuth() + const navigate = useNavigate() + + const handleLogout = () => { + logout() + navigate('/login') + } + + return ( +
+ {/* Header */} +
+
+
+ {/* Logo */} +
+
+
+ A +
+ AARS +
+ + {/* Navigation */} + +
+ + {/* Right side */} +
+ + `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}
+
+ +
+
+
+
+
+ + {/* Main content */} +
+ +
+
+ ) +} 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/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..cee2419 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,18 @@ +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' + +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..840cf28 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,340 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import api from '../api' +import { Users, AlertTriangle, FileSpreadsheet, RefreshCw, TrendingUp, MapPin, Clock, AlertCircle } from 'lucide-react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts' + +const COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'] + +export default function Dashboard() { + // Attendance state + const [attInfo, setAttInfo] = useState(null) + const [attDeptData, setAttDeptData] = useState([]) + const [attStatusData, setAttStatusData] = useState([]) + const [latenessData, setLatenessData] = useState(null) + + // Accident state + const [accInfo, setAccInfo] = useState(null) + const [accSeverityData, setAccSeverityData] = useState([]) + const [accLocationData, setAccLocationData] = useState([]) + const [accMonthlyData, setAccMonthlyData] = useState([]) + + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchData() + }, []) + + const fetchData = async () => { + setLoading(true) + try { + const [attInfoRes, accInfoRes, attDataRes, accDataRes, lateRes] = await Promise.all([ + api.get('/api/attendance/info'), + api.get('/api/accident/info'), + api.get('/api/attendance?page=1&page_size=1000'), + api.get('/api/accident?page=1&page_size=500'), + api.get('/api/attendance/lateness').catch(() => ({ data: null })) + ]) + + setAttInfo(attInfoRes.data) + setAccInfo(accInfoRes.data) + setLatenessData(lateRes.data) + + // Attendance - department + if (attDataRes.data.data && attDataRes.data.data.length > 0) { + const deptCount = {} + attDataRes.data.data.forEach(row => { + const deptKey = Object.keys(row).find(k => + k.toLowerCase().includes('company') || k.includes('Company') + ) + const dept = deptKey ? String(row[deptKey] || 'Unknown') : 'Unknown' + deptCount[dept] = (deptCount[dept] || 0) + 1 + }) + setAttDeptData(Object.entries(deptCount) + .map(([name, value]) => ({ name: name.slice(0, 15), value })) + .sort((a, b) => b.value - a.value) + .slice(0, 6)) + + // Status - from DURATION or other fields + const statusCount = {} + attDataRes.data.data.forEach(row => { + const durKey = Object.keys(row).find(k => + k.toLowerCase().includes('duration') || k.includes('DURATION') + ) + if (durKey && row[durKey]) { + const dur = String(row[durKey]) + if (dur.includes('hours')) { + statusCount['正常工作'] = (statusCount['正常工作'] || 0) + 1 + } else { + statusCount['其他'] = (statusCount['其他'] || 0) + 1 + } + } + }) + setAttStatusData(Object.entries(statusCount) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value)) + } + + // Accident - severity, location, monthly + if (accDataRes.data.data && accDataRes.data.data.length > 0) { + const sevCount = {} + const locCount = {} + const dateCount = {} + + accDataRes.data.data.forEach(row => { + const sevKey = Object.keys(row).find(k => + k.toLowerCase().includes('severity') || k.includes('緊急') || k.includes('程度') + ) + if (sevKey) { + const sev = String(row[sevKey] || 'Unknown') + sevCount[sev] = (sevCount[sev] || 0) + 1 + } + + const locKey = Object.keys(row).find(k => + k.toLowerCase().includes('location') || k.includes('部門') || k.includes('地點') + ) + if (locKey) { + const loc = String(row[locKey] || 'Unknown') + locCount[loc] = (locCount[loc] || 0) + 1 + } + + const dateKey = Object.keys(row).find(k => + k.toLowerCase().includes('date') || k.includes('日期') || k.includes('時間戳') + ) + if (dateKey && row[dateKey]) { + const dateStr = String(row[dateKey]).slice(0, 7) + dateCount[dateStr] = (dateCount[dateStr] || 0) + 1 + } + }) + + setAccSeverityData(Object.entries(sevCount) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value)) + + setAccLocationData(Object.entries(locCount) + .map(([name, value]) => ({ name: name.slice(0, 12), value })) + .sort((a, b) => b.value - a.value) + .slice(0, 6)) + + setAccMonthlyData(Object.entries(dateCount) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => a.name.localeCompare(b.name))) + } + } catch (err) { + console.error('Failed to fetch data:', err) + } finally { + setLoading(false) + } + } + + const SectionCard = ({ title, value, subtitle, icon: Icon, color, link }) => ( + +
+
+
{title}
+
{value ?? '-'}
+ {subtitle &&
{subtitle}
} +
+ +
+ + ) + + const SmallBarChart = ({ data, color }) => ( +
+ {data.length > 0 ? ( + + + + + + + {data.map((_, index) => ( + + ))} + + + + ) : ( +
暫無數據
+ )} +
+ ) + + const TrendChart = ({ data }) => ( +
+ {data.length > 0 ? ( + + + + + + + + + + ) : ( +
暫無數據
+ )} +
+ ) + + // Lateness chart data + const latenessChartData = latenessData?.stats?.slice(0, 8).map(s => ({ + name: String(s.name).slice(0, 10), + value: s.late_minutes, + count: s.late_count + })) || [] + + return ( +
+ {/* Header */} +
+

Dashboard

+ +
+ + {/* ========== ATTENDANCE SECTION ========== */} +
+
+

+ + 出勤 Attendance +

+ 查看全部 → +
+ +
+ + + + +
+ +
+
+

部門分佈

+ +
+
+

出勤狀態

+ +
+ + {/* Lateness Chart */} +
+

員工遲到(分鐘)

+
+ {latenessChartData.length > 0 ? ( + + + + + [`${value}分鐘 (${props.payload.count}次)`, '遲到']} + /> + + + + ) : ( +
暫無遲到數據
+ )} +
+
+
+ + {/* Top late employees table */} + {latenessData?.stats?.length > 0 && ( +
+

+ + 遲到排行榜 +

+
+ + + + + + + + + + + {latenessData.stats.slice(0, 5).map((stat, idx) => ( + + + + + + + ))} + +
員工班次遲到次數總分鐘
{stat.name}{stat.shift}{stat.late_count} 次{stat.late_minutes} 分鐘
+
+
+ )} +
+ + {/* ========== ACCIDENT SECTION ========== */} +
+
+

+ + 意外 Accident +

+ 查看全部 → +
+ +
+ + +
+ +
+
+

緊急程度

+ +
+
+

出事地點

+ +
+
+

月度趨勢

+ +
+
+
+ + {/* Quick Actions */} +
+ + + 上傳 Excel + + + + 班次管理 + +
+
+ ) +} 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/Detail.jsx b/frontend/src/pages/accident/Detail.jsx new file mode 100644 index 0000000..314cae8 --- /dev/null +++ b/frontend/src/pages/accident/Detail.jsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import api from '../../api' +import toast from 'react-hot-toast' +import { ArrowLeft, FileText } from 'lucide-react' + +export default function AccidentDetail() { + const { id } = useParams() + const navigate = useNavigate() + const [record, setRecord] = useState(null) + const [headers, setHeaders] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + fetchRecord() + }, [id]) + + const fetchRecord = async () => { + setLoading(true) + setError(null) + try { + // Get all records to find the one with this row id + const { data } = await api.get(`/api/accident?page=1&page_size=1000`) + + if (data.data && data.data.length > 0) { + // Extract headers from first record + const keys = Object.keys(data.data[0]).filter(k => k !== '_row') + setHeaders(keys) + + // Find the record with matching _row + const found = data.data.find(r => r._row === parseInt(id)) + if (found) { + setRecord(found) + } else { + setError('Record not found') + } + } else { + setError('No data available') + } + } catch (err) { + console.error('Error fetching record:', err) + setError('Failed to load record') + } finally { + setLoading(false) + } + } + + if (loading) { + return ( +
+
載入中...
+
+ ) + } + + if (error) { + return ( +
+
+ + + 返回列表 + +
+
+
{error}
+ +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + + 返回 + +

+ 意外記錄 #{id} +

+
+
+ + {/* Record Details */} +
+
+
+ +
+
記錄詳情
+
Excel 原始數據
+
+
+
+ +
+ {headers.map(key => ( +
+
+ {key} +
+
+ {record[key] !== null && record[key] !== undefined + ? String(record[key]) + : '-'} +
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/accident/List.jsx b/frontend/src/pages/accident/List.jsx new file mode 100644 index 0000000..c9914a3 --- /dev/null +++ b/frontend/src/pages/accident/List.jsx @@ -0,0 +1,235 @@ +import { useState, useEffect, useMemo } from 'react' +import { Link } from 'react-router-dom' +import api from '../../api' +import toast from 'react-hot-toast' +import { Search, Eye, ChevronUp, ChevronDown, Filter, X } from 'lucide-react' + +export default function AccidentList() { + const [records, setRecords] = useState([]) + const [headers, setHeaders] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [sortKey, setSortKey] = useState(null) + const [sortDir, setSortDir] = useState('asc') + const [filterKey, setFilterKey] = useState('') + const [filterValue, setFilterValue] = useState('') + + useEffect(() => { + fetchRecords() + }, []) + + const fetchRecords = async () => { + setLoading(true) + try { + const { data } = await api.get('/api/accident?page=1&page_size=1000') + setRecords(data.data || []) + + if (data.data && data.data.length > 0) { + const keys = Object.keys(data.data[0]).filter(k => k !== '_row') + setHeaders(keys) + } + } catch (err) { + toast.error('Failed to load records') + } finally { + setLoading(false) + } + } + + // Filtered and sorted data + const processedData = useMemo(() => { + let result = [...records] + + // Filter + if (filterKey && filterValue) { + result = result.filter(r => { + const val = String(r[filterKey] || '').toLowerCase() + return val.includes(filterValue.toLowerCase()) + }) + } + + // Search + if (search) { + const searchLower = search.toLowerCase() + result = result.filter(r => + Object.values(r).some(v => + v && String(v).toLowerCase().includes(searchLower) + ) + ) + } + + // Sort + if (sortKey) { + result.sort((a, b) => { + const aVal = a[sortKey] || '' + const bVal = b[sortKey] || '' + const aNum = Number(aVal) + const bNum = Number(bVal) + + let cmp = 0 + if (!isNaN(aNum) && !isNaN(bNum)) { + cmp = aNum - bNum + } else { + cmp = String(aVal).localeCompare(String(bVal)) + } + return sortDir === 'asc' ? cmp : -cmp + }) + } + + return result + }, [records, search, sortKey, sortDir, filterKey, filterValue]) + + const handleSort = (key) => { + if (sortKey === key) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc') + } 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' ? : + } + + return ( +
+ {/* Header */} +
+

意外記錄 Accident

+ + 上傳 Excel + +
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="搜尋所有欄位..." + className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md" + /> +
+ + {/* Column Filter */} +
+ + + + {filterKey && ( + <> + setFilterValue(e.target.value)} + placeholder="輸入篩選值..." + className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32" + /> + + + )} +
+ + {/* Result count */} +
+ {processedData.length} 筆記錄 +
+
+
+ + {/* Table */} +
+
+ + + + + {headers.slice(0, 6).map(h => ( + + ))} + + + + + {loading ? ( + + + + ) : processedData.length === 0 ? ( + + + + ) : ( + processedData.map((record) => ( + window.location.href = `/accident/${record._row}`} + > + + {headers.slice(0, 6).map(h => ( + + ))} + + + )) + )} + +
# handleSort(h)} + > +
+ {h} + {getSortIcon(h)} +
+
操作
+ 載入中... +
+ 沒有記錄 +
{record._row} + {record[h] !== null && record[h] !== undefined ? 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..18b8059 --- /dev/null +++ b/frontend/src/pages/attendance/Detail.jsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import api from '../../api' +import toast from 'react-hot-toast' +import { ArrowLeft, FileText } from 'lucide-react' + +export default function AttendanceDetail() { + const { id } = useParams() + const navigate = useNavigate() + const [record, setRecord] = useState(null) + const [headers, setHeaders] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + fetchRecord() + }, [id]) + + const fetchRecord = async () => { + setLoading(true) + setError(null) + try { + // Get all records to find the one with this row id + const { data } = await api.get(`/api/attendance?page=1&page_size=1000`) + + if (data.data && data.data.length > 0) { + // Extract headers from first record + const keys = Object.keys(data.data[0]).filter(k => k !== '_row') + setHeaders(keys) + + // Find the record with matching _row + const found = data.data.find(r => r._row === parseInt(id)) + if (found) { + setRecord(found) + } else { + setError('Record not found') + } + } else { + setError('No data available') + } + } catch (err) { + console.error('Error fetching record:', err) + setError('Failed to load record') + } finally { + setLoading(false) + } + } + + if (loading) { + return ( +
+
載入中...
+
+ ) + } + + if (error) { + return ( +
+
+ + + 返回列表 + +
+
+
{error}
+ +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + + 返回 + +

+ 出勤記錄 #{id} +

+
+
+ + {/* Record Details */} +
+
+
+ +
+
記錄詳情
+
Excel 原始數據
+
+
+
+ +
+ {headers.map(key => ( +
+
+ {key} +
+
+ {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..2dc56e2 --- /dev/null +++ b/frontend/src/pages/attendance/List.jsx @@ -0,0 +1,301 @@ +import { useState, useEffect, useMemo } from 'react' +import { Link } from 'react-router-dom' +import api from '../../api' +import toast from 'react-hot-toast' +import { Search, Eye, ChevronUp, ChevronDown, Filter, X, AlertCircle, Clock } from 'lucide-react' + +export default function AttendanceList() { + const [records, setRecords] = useState([]) + const [headers, setHeaders] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [sortKey, setSortKey] = useState(null) + const [sortDir, setSortDir] = useState('asc') + const [filterKey, setFilterKey] = useState('') + const [filterValue, setFilterValue] = useState('') + const [latenessRecords, setLatenessRecords] = useState([]) + + useEffect(() => { + fetchRecords() + fetchLateness() + }, []) + + const fetchRecords = async () => { + setLoading(true) + try { + const { data } = await api.get('/api/attendance?page=1&page_size=1000') + setRecords(data.data || []) + + if (data.data && data.data.length > 0) { + const keys = Object.keys(data.data[0]).filter(k => k !== '_row') + setHeaders(keys) + } + } catch (err) { + toast.error('Failed to load records') + } finally { + setLoading(false) + } + } + + const fetchLateness = async () => { + try { + const { data } = await api.get('/api/attendance/lateness/records') + setLatenessRecords(data || []) + } catch (err) { + console.error('Failed to load lateness data:', err) + } + } + + // Create a map of late records for quick lookup + const latenessMap = useMemo(() => { + const map = {} + latenessRecords.forEach(rec => { + const key = `${rec.staff}-${rec.date}` + map[key] = rec + }) + return map + }, [latenessRecords]) + + // Filtered and sorted data + const processedData = useMemo(() => { + let result = [...records] + + // Filter + if (filterKey && filterValue) { + result = result.filter(r => { + const val = String(r[filterKey] || '').toLowerCase() + return val.includes(filterValue.toLowerCase()) + }) + } + + // Search + if (search) { + const searchLower = search.toLowerCase() + result = result.filter(r => + Object.values(r).some(v => + v && String(v).toLowerCase().includes(searchLower) + ) + ) + } + + // Sort + if (sortKey) { + result.sort((a, b) => { + const aVal = a[sortKey] || '' + const bVal = b[sortKey] || '' + const aNum = Number(aVal) + const bNum = Number(bVal) + + let cmp = 0 + if (!isNaN(aNum) && !isNaN(bNum)) { + cmp = aNum - bNum + } else { + cmp = String(aVal).localeCompare(String(bVal)) + } + return sortDir === 'asc' ? cmp : -cmp + }) + } + + return result + }, [records, search, sortKey, sortDir, filterKey, filterValue]) + + const handleSort = (key) => { + if (sortKey === key) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc') + } 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' ? : + } + + // Get lateness info for a record + const getLatenessInfo = (record) => { + // Find staff name and date columns + const staffKey = Object.keys(record).find(k => + k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工') + ) + const dateKey = Object.keys(record).find(k => + k.toLowerCase().includes('date') || k.toLowerCase().includes('日期') + ) + + if (!staffKey || !dateKey) return null + + const staff = record[staffKey] + const date = record[dateKey] + + if (!staff || !date) return null + + const key = `${staff}-${String(date).slice(0, 10)}` + return latenessMap[key] + } + + return ( +
+ {/* Header */} +
+

出勤記錄 Attendance

+ + 上傳 Excel + +
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="搜尋所有欄位..." + className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md" + /> +
+ + {/* Column Filter */} +
+ + + + {filterKey && ( + <> + setFilterValue(e.target.value)} + placeholder="輸入篩選值..." + className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32" + /> + + + )} +
+ + {/* Result count */} +
+ {processedData.length} 筆記錄 + {latenessRecords.length > 0 && ( + + • {latenessRecords.length} 筆遲到 + + )} +
+
+
+ + {/* Table */} +
+
+ + + + + {headers.slice(0, 6).map(h => ( + + ))} + + + + + + {loading ? ( + + + + ) : processedData.length === 0 ? ( + + + + ) : ( + processedData.map((record) => { + const lateInfo = getLatenessInfo(record) + const isLate = lateInfo && lateInfo.minutes_late > 0 + + return ( + window.location.href = `/attendance/${record._row}`} + > + + {headers.slice(0, 6).map(h => ( + + ))} + + + + ) + }) + )} + +
# handleSort(h)} + > +
+ {h} + {getSortIcon(h)} +
+
狀態操作
+ 載入中... +
+ 沒有記錄 +
{record._row} + {record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'} + + {isLate ? ( +
+ + {lateInfo.minutes_late}分 +
+ ) : ( +
+ + 正常 +
+ )} +
e.stopPropagation()}> + + + +
+
+
+
+ ) +} 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..1a39bce --- /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..ff27386 --- /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/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!"