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
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
.env
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install Node.js for frontend build + build deps for bcrypt
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first (for caching)
|
||||
COPY backend/requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy backend code
|
||||
COPY backend/ .
|
||||
|
||||
# Copy frontend source
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Build frontend
|
||||
RUN cd frontend && npm install && npm run build
|
||||
|
||||
# Move built frontend to static folder
|
||||
RUN mkdir -p /app/static && mv frontend/dist/* /app/static/
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,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`。
|
||||
@@ -0,0 +1,377 @@
|
||||
# AARS - Attendance & Accident Record System
|
||||
|
||||
## 1. Concept & Vision
|
||||
|
||||
一個為中小企設計嘅出勤同意外記錄系統。介面乾淨、專業、易用,帶有香港本地特色(繁體中文、HKT時區)。兩大一頁式 dashboard,展示即時數據,所有記錄都可以 filter、sort、export。
|
||||
|
||||
目標係取代 Excel 試算表,做一個可以多人協作、數據唔會重複、report 一click 生成嘅系統。
|
||||
|
||||
---
|
||||
|
||||
## 2. Design Language
|
||||
|
||||
**Aesthetic:** 淺色專業商務風,白底配淺灰,帶有 System Blue 強調色。似 classic Notion / Linear 嗰種乾淨感覺,但再 warm 少少。
|
||||
|
||||
**Colors (TailwindCSS custom):**
|
||||
- Primary: `#2563EB` (blue-600)
|
||||
- Primary Dark: `#1D4ED8` (blue-700)
|
||||
- Accent: `#06B6D4` (cyan-500)
|
||||
- Background: `#F8FAFC` (slate-50)
|
||||
- Card: `#FFFFFF`
|
||||
- Border: `#E2E8F0` (slate-200)
|
||||
- Text Primary: `#0F172A` (slate-900)
|
||||
- Text Secondary: `#64748B` (slate-500)
|
||||
- Success: `#10B981` (emerald-500)
|
||||
- Warning: `#F59E0B` (amber-500)
|
||||
- Danger: `#EF4444` (red-500)
|
||||
|
||||
**Typography:**
|
||||
- Font: Inter (Google Fonts) + Noto Sans TC (繁體中文)
|
||||
- Headings: 600-700 weight
|
||||
- Body: 400-500 weight
|
||||
|
||||
**Spatial System:**
|
||||
- Base unit: 4px
|
||||
- Card padding: 24px
|
||||
- Section gap: 32px
|
||||
- Border radius: 8px (cards), 6px (buttons), 4px (inputs)
|
||||
|
||||
**Motion:**
|
||||
- Page transitions: fade 150ms
|
||||
- Hover states: 200ms ease
|
||||
- Loading: pulse animation
|
||||
- No bouncy/playful animations - keep it professional
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout & Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Header: Logo + Nav (Attendance | Accident) + User │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Page Content: │
|
||||
│ ┌─ Dashboard ─┐ ┌─ List View ─┐ ┌─ Detail ─┐ │
|
||||
│ │ Stats cards │ │ Table + Fn │ │ Edit form│ │
|
||||
│ │ Charts │ │ Filter/Sort │ │ History │ │
|
||||
│ └────────────┘ └─────────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Pages:**
|
||||
1. **Login** (`/login`) - Email + password,JWT auth
|
||||
2. **Dashboard** (`/`) - Stats cards + recent activity + quick actions
|
||||
3. **Attendance List** (`/attendance`) - Table with all records
|
||||
4. **Attendance Detail** (`/attendance/:id`) - View/Edit single record
|
||||
5. **Accident List** (`/accident`) - Table with all records
|
||||
6. **Accident Detail** (`/accident/:id`) - View/Edit single record
|
||||
7. **Import** (`/import`) - Excel upload + mapping + preview
|
||||
|
||||
**Responsive:** Desktop-first,但 table 可以 horizontal scroll 喺 mobile
|
||||
|
||||
---
|
||||
|
||||
## 4. Features & Interactions
|
||||
|
||||
### 4.1 Authentication
|
||||
- Login with email + password
|
||||
- JWT token stored in localStorage
|
||||
- Token expiry: 24 hours
|
||||
- Auto-redirect to login if expired
|
||||
|
||||
### 4.2 Dashboard
|
||||
**Attendance Section:**
|
||||
- Total records count
|
||||
- Today attendance count
|
||||
- This month attendance count
|
||||
- Records trend chart (last 7 days / 30 days)
|
||||
|
||||
**Accident Section:**
|
||||
- Total records count
|
||||
- This month accidents count
|
||||
- Unresolved accidents count
|
||||
- Recent accidents list (last 5)
|
||||
|
||||
### 4.3 List View
|
||||
- Paginated table (20 per page)
|
||||
- Columns: ID, Date, Employee Name, Department, Status, Actions
|
||||
- **Filter:** By date range, department, status, search text
|
||||
- **Sort:** Click column header to sort ASC/DESC
|
||||
- **Actions:** View, Edit, Delete (soft delete)
|
||||
- **Bulk Actions:** Select multiple → Delete / Export selected
|
||||
- **Export:** PDF (single/all selected) / Excel (all filtered results)
|
||||
|
||||
### 4.4 Detail / Edit View
|
||||
- Form with all fields
|
||||
- Read-only view mode vs Edit mode
|
||||
- **History tab:** Show who created/modified when
|
||||
- **Save:** Validates + saves to SQLite
|
||||
- **Cancel:** Returns to list without saving
|
||||
|
||||
### 4.5 Excel Import
|
||||
- Upload Excel file (.xlsx, .xls)
|
||||
- Auto-detect columns from first row
|
||||
- **Field mapping:** Map Excel columns to system fields
|
||||
- **Duplicate detection:** Check by unique key (Employee ID + Date for attendance, Date + Location for accident)
|
||||
- **Preview:** Show first 10 rows with validation status
|
||||
- **Options:**
|
||||
- "Update existing" → skip duplicates or update
|
||||
- "Create new only" → skip duplicates
|
||||
- **Import button:** Bulk insert/update
|
||||
- **Result:** Show success count, skipped (duplicates), errors
|
||||
|
||||
### 4.6 Field Management
|
||||
- Default fields per section (pre-defined)
|
||||
- **Add custom field:** Name, type (text/number/date/select), required
|
||||
- Custom fields stored in JSON column
|
||||
- Can be used in list view, detail view, export
|
||||
|
||||
### 4.7 Export
|
||||
- **Excel:** All filtered results, includes all fields
|
||||
- **PDF:** Formatted report with header, table, pagination
|
||||
- Export respects current filter/sort state
|
||||
|
||||
---
|
||||
|
||||
## 5. Component Inventory
|
||||
|
||||
### 5.1 Header
|
||||
- Logo (left): "AARS" text logo
|
||||
- Nav tabs (center): "出勤 Attendance" | "意外 Accident"
|
||||
- Active tab: blue underline
|
||||
- User menu (right): Username + dropdown (Profile, Logout)
|
||||
|
||||
### 5.2 Stats Card
|
||||
- Icon (colored circle bg) + metric value (large) + label (small)
|
||||
- Hover: subtle shadow lift
|
||||
|
||||
### 5.3 Data Table
|
||||
- Header row: grey bg, bold text
|
||||
- Sortable columns: sort icon, click to toggle
|
||||
- Row hover: light blue bg
|
||||
- Checkbox column for bulk select
|
||||
- Action buttons: View (blue), Edit (gray), Delete (red)
|
||||
- Empty state: "No records found" + illustration
|
||||
|
||||
### 5.4 Filter Bar
|
||||
- Date range picker (two inputs)
|
||||
- Dropdown for department/status
|
||||
- Text search input
|
||||
- "Clear filters" button
|
||||
- "Export" button
|
||||
|
||||
### 5.5 Form
|
||||
- Label + input pairs, stacked
|
||||
- Required indicator: red asterisk
|
||||
- Input states: default, focus (blue ring), error (red border + message)
|
||||
- Select dropdowns, date pickers
|
||||
- Submit button (blue), Cancel button (gray outline)
|
||||
|
||||
### 5.6 Modal
|
||||
- Centered overlay, white card
|
||||
- Header + body + footer
|
||||
- Close X button top right
|
||||
- Backdrop click to close
|
||||
|
||||
### 5.7 Toast Notifications
|
||||
- Success (green), Error (red), Info (blue)
|
||||
- Auto-dismiss after 3s
|
||||
- Bottom right corner
|
||||
|
||||
### 5.8 Import Wizard
|
||||
- Step 1: Upload Excel
|
||||
- Step 2: Map columns
|
||||
- Step 3: Preview + validate
|
||||
- Step 4: Confirm + import
|
||||
- Progress indicator at top
|
||||
|
||||
---
|
||||
|
||||
## 6. Technical Approach
|
||||
|
||||
### 6.1 Backend (FastAPI)
|
||||
```
|
||||
backend/
|
||||
├── main.py # FastAPI app entry
|
||||
├── database.py # SQLite + SQLAlchemy
|
||||
├── models.py # SQLAlchemy models
|
||||
├── schemas.py # Pydantic schemas
|
||||
├── auth.py # JWT auth
|
||||
├── routers/
|
||||
│ ├── attendance.py
|
||||
│ ├── accident.py
|
||||
│ ├── import.py
|
||||
│ └── export.py
|
||||
└── utils/
|
||||
├── excel.py # openpyxl helpers
|
||||
└── pdf.py # reportlab helpers
|
||||
```
|
||||
|
||||
**API Endpoints:**
|
||||
```
|
||||
POST /api/auth/login
|
||||
GET /api/auth/me
|
||||
|
||||
GET /api/attendance # List with filter/sort/paginate
|
||||
POST /api/attendance # Create
|
||||
GET /api/attendance/:id # Get one
|
||||
PUT /api/attendance/:id # Update
|
||||
DELETE /api/attendance/:id # Soft delete
|
||||
|
||||
GET /api/accident # List with filter/sort/paginate
|
||||
POST /api/accident # Create
|
||||
GET /api/accident/:id # Get one
|
||||
PUT /api/accident/:id # Update
|
||||
DELETE /api/accident/:id # Soft delete
|
||||
|
||||
POST /api/import/attendance # Excel import
|
||||
POST /api/import/accident # Excel import
|
||||
|
||||
GET /api/export/attendance/excel
|
||||
GET /api/export/attendance/pdf
|
||||
GET /api/export/accident/excel
|
||||
GET //api/export/accident/pdf
|
||||
|
||||
GET /api/dashboard/attendance # Stats
|
||||
GET /api/dashboard/accident # Stats
|
||||
|
||||
GET /api/fields/attendance # Custom fields
|
||||
POST /api/fields/attendance
|
||||
GET /api/fields/accident
|
||||
POST /api/fields/accident
|
||||
```
|
||||
|
||||
### 6.2 Database Schema
|
||||
|
||||
**Users:**
|
||||
```sql
|
||||
id, email, password_hash, name, role, created_at, updated_at, is_active
|
||||
```
|
||||
|
||||
**Attendance:**
|
||||
```sql
|
||||
id, employee_id, employee_name, department, date, check_in, check_out,
|
||||
overtime_hours, leave_type, leave_hours, status, remarks,
|
||||
custom_fields (JSON), created_by, created_at, updated_at, deleted_at
|
||||
```
|
||||
|
||||
**Accident:**
|
||||
```sql
|
||||
id, date, time, location, employee_name, employee_id, department,
|
||||
description, severity, medical_report, action_taken, responsible_person,
|
||||
custom_fields (JSON), created_by, created_at, updated_at, deleted_at
|
||||
```
|
||||
|
||||
**AttendanceCustomFields / AccidentCustomFields:**
|
||||
```sql
|
||||
id, field_name, field_type, required, options, order, created_at
|
||||
```
|
||||
|
||||
### 6.3 Frontend (React + Vite)
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── App.jsx
|
||||
│ ├── main.jsx
|
||||
│ ├── api.js # Axios API calls
|
||||
│ ├── context/
|
||||
│ │ └── AuthContext.jsx
|
||||
│ ├── pages/
|
||||
│ │ ├── Login.jsx
|
||||
│ │ ├── Dashboard.jsx
|
||||
│ │ ├── attendance/
|
||||
│ │ │ ├── List.jsx
|
||||
│ │ │ └── Detail.jsx
|
||||
│ │ ├── accident/
|
||||
│ │ │ ├── List.jsx
|
||||
│ │ │ └── Detail.jsx
|
||||
│ │ └── import/
|
||||
│ │ └── Import.jsx
|
||||
│ └── components/
|
||||
│ ├── Layout.jsx
|
||||
│ ├── DataTable.jsx
|
||||
│ ├── FilterBar.jsx
|
||||
│ ├── StatsCard.jsx
|
||||
│ ├── Modal.jsx
|
||||
│ └── Toast.jsx
|
||||
├── index.html
|
||||
├── tailwind.config.js
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
### 6.4 Docker Setup
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
aars:
|
||||
build: .
|
||||
ports:
|
||||
- "18775:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- TIMEZONE=Asia/Hong_Kong
|
||||
- SECRET_KEY=<generated>
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 6.5 Key Libraries
|
||||
- **Backend:** fastapi, uvicorn, sqlalchemy, pydantic, python-jose, passlib, openpyxl, reportlab
|
||||
- **Frontend:** react, react-router-dom, axios, tailwindcss, recharts (charts), react-hot-toast
|
||||
|
||||
---
|
||||
|
||||
## 7. Default Fields
|
||||
|
||||
### Attendance Default Fields
|
||||
| Field | Type | Required |
|
||||
|-------|------|----------|
|
||||
| employee_id | text | Yes |
|
||||
| employee_name | text | Yes |
|
||||
| department | select | Yes |
|
||||
| date | date | Yes |
|
||||
| check_in | time | No |
|
||||
| check_out | time | No |
|
||||
| overtime_hours | number | No |
|
||||
| leave_type | select | No |
|
||||
| leave_hours | number | No |
|
||||
| status | select | Yes |
|
||||
| remarks | text | No |
|
||||
|
||||
### Accident Default Fields
|
||||
| Field | Type | Required |
|
||||
|-------|------|----------|
|
||||
| date | date | Yes |
|
||||
| time | time | Yes |
|
||||
| location | text | Yes |
|
||||
| employee_name | text | Yes |
|
||||
| employee_id | text | Yes |
|
||||
| department | select | Yes |
|
||||
| description | text | Yes |
|
||||
| severity | select | Yes |
|
||||
| medical_report | text | No |
|
||||
| action_taken | text | No |
|
||||
| responsible_person | text | No |
|
||||
|
||||
---
|
||||
|
||||
## 8. Security
|
||||
|
||||
- Passwords hashed with bcrypt
|
||||
- JWT with 24h expiry
|
||||
- CORS configured for known origins only
|
||||
- Input sanitization on all endpoints
|
||||
- Soft delete (never hard delete user data)
|
||||
- Rate limiting on login endpoint
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of Scope (v1)
|
||||
|
||||
- User management (create/edit users) - only admin can do this via direct DB
|
||||
- Multi-company support
|
||||
- Recurring attendance (scheduled check-in reminders)
|
||||
- Document/photo upload for accidents
|
||||
- Email notifications
|
||||
- Mobile responsive redesign
|
||||
- API for third-party integrations
|
||||
@@ -0,0 +1,72 @@
|
||||
from fastapi import Depends, HTTPException, status, Query
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError, jwt
|
||||
from database import get_db, SECRET_KEY, ALGORITHM
|
||||
from models import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Validate JWT token and return current user."""
|
||||
token = credentials.credentials
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id_str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Ensure current user is an admin."""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_user_from_query(
|
||||
token: str = Query(...),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Validate JWT token from query parameter and return current user."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id_str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
user_id = int(user_id_str)
|
||||
except (JWTError, ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, Float, Boolean, DateTime, Date, Time, JSON
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from passlib.context import CryptContext
|
||||
from jose import jwt
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
import secret
|
||||
|
||||
# Database setup
|
||||
DATABASE_URL = "sqlite:///./data/aars.db"
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto")
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
expire = datetime.now(timezone.utc).timestamp() + (ACCESS_TOKEN_EXPIRE_HOURS * 3600)
|
||||
to_encode = data.copy()
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database with tables and default admin user."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check if admin exists
|
||||
from models import User
|
||||
admin = db.query(User).filter(User.email == "admin@aars.hk").first()
|
||||
if not admin:
|
||||
# Create default admin
|
||||
admin = User(
|
||||
email="admin@aars.hk",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
name="系統管理員",
|
||||
role="admin",
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print("✅ Default admin user created: admin@aars.hk / admin123")
|
||||
else:
|
||||
print("✅ Admin user already exists")
|
||||
finally:
|
||||
db.close()
|
||||
+781
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
sqlalchemy==2.0.35
|
||||
pydantic==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.12
|
||||
openpyxl==3.1.5
|
||||
reportlab==4.2.5
|
||||
xlsxwriter==3.2.0
|
||||
aiofiles==24.1.0
|
||||
@@ -0,0 +1,187 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import date, time, datetime
|
||||
|
||||
|
||||
# ============ Auth ============
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Attendance ============
|
||||
class AttendanceBase(BaseModel):
|
||||
employee_id: str
|
||||
employee_name: str
|
||||
department: Optional[str] = None
|
||||
date: date
|
||||
check_in: Optional[time] = None
|
||||
check_out: Optional[time] = None
|
||||
overtime_hours: float = 0
|
||||
leave_type: Optional[str] = None
|
||||
leave_hours: float = 0
|
||||
status: str
|
||||
remarks: Optional[str] = None
|
||||
custom_fields: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class AttendanceCreate(AttendanceBase):
|
||||
pass
|
||||
|
||||
|
||||
class AttendanceUpdate(BaseModel):
|
||||
employee_id: Optional[str] = None
|
||||
employee_name: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
date: Optional[date] = None
|
||||
check_in: Optional[time] = None
|
||||
check_out: Optional[time] = None
|
||||
overtime_hours: Optional[float] = None
|
||||
leave_type: Optional[str] = None
|
||||
leave_hours: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
remarks: Optional[str] = None
|
||||
custom_fields: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AttendanceResponse(AttendanceBase):
|
||||
id: int
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Accident ============
|
||||
class AccidentBase(BaseModel):
|
||||
date: date
|
||||
time: Optional[time] = None
|
||||
location: str
|
||||
employee_name: str
|
||||
employee_id: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
description: str
|
||||
severity: str
|
||||
medical_report: Optional[str] = None
|
||||
action_taken: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
custom_fields: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class AccidentCreate(AccidentBase):
|
||||
pass
|
||||
|
||||
|
||||
class AccidentUpdate(BaseModel):
|
||||
date: Optional[date] = None
|
||||
time: Optional[time] = None
|
||||
location: Optional[str] = None
|
||||
employee_name: Optional[str] = None
|
||||
employee_id: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
medical_report: Optional[str] = None
|
||||
action_taken: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
custom_fields: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AccidentResponse(AccidentBase):
|
||||
id: int
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Custom Fields ============
|
||||
class CustomFieldBase(BaseModel):
|
||||
field_name: str
|
||||
field_type: str
|
||||
field_key: str
|
||||
required: bool = False
|
||||
options: List[str] = []
|
||||
order: int = 0
|
||||
|
||||
|
||||
class CustomFieldCreate(CustomFieldBase):
|
||||
pass
|
||||
|
||||
|
||||
class CustomFieldResponse(CustomFieldBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Dashboard ============
|
||||
class AttendanceStats(BaseModel):
|
||||
total: int
|
||||
today: int
|
||||
this_month: int
|
||||
by_status: Dict[str, int]
|
||||
by_department: Dict[str, int]
|
||||
trend: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class AccidentStats(BaseModel):
|
||||
total: int
|
||||
this_month: int
|
||||
unresolved: int
|
||||
by_severity: Dict[str, int]
|
||||
recent: List[AccidentResponse]
|
||||
|
||||
|
||||
# ============ Import ============
|
||||
class ImportRequest(BaseModel):
|
||||
section: str # "attendance" or "accident"
|
||||
update_existing: bool = True
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
created: int
|
||||
updated: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
# ============ Pagination / Filter ============
|
||||
class FilterParams(BaseModel):
|
||||
page: int = 1
|
||||
per_page: int = 20
|
||||
sort_by: Optional[str] = None
|
||||
sort_order: Optional[str] = "asc" # asc or desc
|
||||
search: Optional[str] = None
|
||||
date_from: Optional[date] = None
|
||||
date_to: Optional[date] = None
|
||||
department: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
items: List[Any]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
total_pages: int
|
||||
@@ -0,0 +1,2 @@
|
||||
# AARS JWT Secret - CHANGE THIS IN PRODUCTION!
|
||||
JWT_SECRET = "aars-jwt-secret-2026-prod-do-not-share"
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick test script for AARS backend - run locally without Docker"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
# Generate secret if missing
|
||||
if not os.path.exists("secret.py"):
|
||||
with open("secret.py", "w") as f:
|
||||
f.write(f"JWT_SECRET = 'dev-secret-{os.urandom(16).hex()}'\n")
|
||||
|
||||
# Import and run
|
||||
from database import init_db, SessionLocal
|
||||
from models import User
|
||||
|
||||
print("🧪 Testing AARS Backend...")
|
||||
print()
|
||||
|
||||
# Test 1: Database init
|
||||
print("1️⃣ Testing database initialization...")
|
||||
try:
|
||||
init_db()
|
||||
print(" ✅ Database initialized")
|
||||
except Exception as e:
|
||||
print(f" ❌ Database init failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Check admin user
|
||||
print("2️⃣ Testing admin user...")
|
||||
try:
|
||||
db = SessionLocal()
|
||||
admin = db.query(User).filter(User.email == "admin@aars.hk").first()
|
||||
if admin:
|
||||
print(f" ✅ Admin exists: {admin.email}")
|
||||
else:
|
||||
print(" ⚠️ Admin not found")
|
||||
db.close()
|
||||
except Exception as e:
|
||||
print(f" ❌ Query failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 3: Test password hashing
|
||||
print("3️⃣ Testing password hashing...")
|
||||
try:
|
||||
from database import get_password_hash, verify_password
|
||||
pw_hash = get_password_hash("test123")
|
||||
assert verify_password("test123", pw_hash)
|
||||
print(" ✅ Password hashing works")
|
||||
except Exception as e:
|
||||
print(f" ❌ Password hashing failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("🎉 All basic tests passed!")
|
||||
print()
|
||||
print("To run the backend:")
|
||||
print(" cd backend")
|
||||
print(" pip install -r requirements.txt")
|
||||
print(" uvicorn main:app --reload --port 8000")
|
||||
print()
|
||||
print("Then visit: http://localhost:8000")
|
||||
print("API docs: http://localhost:8000/docs")
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
|
||||
<title>AARS - Attendance & Accident Record System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-pulse text-slate-500">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="attendance" element={<AttendanceList />} />
|
||||
<Route path="attendance/:id" element={<AttendanceDetail />} />
|
||||
<Route path="accident" element={<AccidentList />} />
|
||||
<Route path="accident/:id" element={<AccidentDetail />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="roster" element={<RosterPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">A</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900">AARS</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<LayoutDashboard size={18} />
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/attendance"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Users size={18} />
|
||||
出勤 Attendance
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/accident"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<AlertTriangle size={18} />
|
||||
意外 Accident
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/roster"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Calendar size={18} />
|
||||
班次 Roster
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
<NavLink
|
||||
to="/upload"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Upload size={16} />
|
||||
<span className="hidden sm:inline">上傳</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-slate-200">
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="text-sm font-medium text-slate-900">{user?.name}</div>
|
||||
<div className="text-xs text-slate-500">{user?.role}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import api from '../api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('aars_token')
|
||||
if (token) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
fetchUser()
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/auth/me')
|
||||
setUser(data)
|
||||
} catch (err) {
|
||||
localStorage.removeItem('aars_token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (email, password) => {
|
||||
const { data } = await api.post('/api/auth/login', { email, password })
|
||||
localStorage.setItem('aars_token', data.access_token)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${data.access_token}`
|
||||
await fetchUser()
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('aars_token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Noto Sans TC', system-ui, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2 rounded-md font-medium
|
||||
hover:bg-primary-700 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-slate-700 px-4 py-2 rounded-md font-medium
|
||||
border border-slate-300 hover:bg-slate-50 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white px-4 py-2 rounded-md font-medium
|
||||
hover:bg-red-700 transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 border border-slate-300 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500
|
||||
placeholder:text-slate-400;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg border border-slate-200 shadow-sm;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster position="bottom-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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 }) => (
|
||||
<Link to={link} className={`card p-4 hover:shadow-md transition-shadow border-l-4 ${color}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-slate-500">{title}</div>
|
||||
<div className="text-xl font-bold text-slate-900 mt-1">{value ?? '-'}</div>
|
||||
{subtitle && <div className="text-xs text-slate-400 mt-0.5">{subtitle}</div>}
|
||||
</div>
|
||||
<Icon size={20} className={color.replace('border-l-', 'text-')} />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
const SmallBarChart = ({ data, color }) => (
|
||||
<div className="h-32">
|
||||
{data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 5, right: 5 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 10 }} />
|
||||
<YAxis dataKey="name" type="category" width={80} tick={{ fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }} />
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]}>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無數據</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const TrendChart = ({ data }) => (
|
||||
<div className="h-40">
|
||||
{data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ left: 5, right: 15 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="value" stroke="#ef4444" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無數據</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Lateness chart data
|
||||
const latenessChartData = latenessData?.stats?.slice(0, 8).map(s => ({
|
||||
name: String(s.name).slice(0, 10),
|
||||
value: s.late_minutes,
|
||||
count: s.late_count
|
||||
})) || []
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-slate-900">Dashboard</h1>
|
||||
<button onClick={fetchData} className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-md">
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ========== ATTENDANCE SECTION ========== */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||
<Users size={20} className="text-blue-600" />
|
||||
出勤 Attendance
|
||||
</h2>
|
||||
<Link to="/attendance" className="text-sm text-blue-600 hover:text-blue-700">查看全部 →</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<SectionCard title="出勤記錄" value={attInfo?.rows || 0} icon={Users} color="border-l-blue-500" link="/attendance" />
|
||||
<SectionCard title="出勤部門" value={attDeptData.length} subtitle="個部門" icon={MapPin} color="border-l-cyan-500" link="/attendance" />
|
||||
<SectionCard
|
||||
title="遲到人次"
|
||||
value={latenessData?.total_late_count || 0}
|
||||
icon={AlertCircle}
|
||||
color="border-l-orange-500"
|
||||
link="/attendance"
|
||||
/>
|
||||
<SectionCard
|
||||
title="遲到總分鐘"
|
||||
value={latenessData?.total_late_minutes || 0}
|
||||
subtitle="分鐘"
|
||||
icon={Clock}
|
||||
color="border-l-red-500"
|
||||
link="/attendance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="card p-4 border-l-4 border-l-blue-500">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">部門分佈</h3>
|
||||
<SmallBarChart data={attDeptData} color="blue" />
|
||||
</div>
|
||||
<div className="card p-4 border-l-4 border-l-blue-500">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">出勤狀態</h3>
|
||||
<SmallBarChart data={attStatusData} color="blue" />
|
||||
</div>
|
||||
|
||||
{/* Lateness Chart */}
|
||||
<div className="card p-4 border-l-4 border-l-orange-500">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">員工遲到(分鐘)</h3>
|
||||
<div className="h-32">
|
||||
{latenessChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={latenessChartData} layout="vertical" margin={{ left: 5, right: 5 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 10 }} />
|
||||
<YAxis dataKey="name" type="category" width={60} tick={{ fontSize: 9 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '6px', border: '1px solid #e2e8f0', fontSize: 12 }}
|
||||
formatter={(value, name, props) => [`${value}分鐘 (${props.payload.count}次)`, '遲到']}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} fill="#f59e0b" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-slate-400 text-xs">暫無遲到數據</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top late employees table */}
|
||||
{latenessData?.stats?.length > 0 && (
|
||||
<div className="card p-4 mt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<AlertCircle size={16} className="text-orange-500" />
|
||||
遲到排行榜
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-500">員工</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-500">班次</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-500">遲到次數</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-500">總分鐘</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{latenessData.stats.slice(0, 5).map((stat, idx) => (
|
||||
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-2 px-3 font-medium">{stat.name}</td>
|
||||
<td className="text-center py-2 px-3">{stat.shift}</td>
|
||||
<td className="text-center py-2 px-3 text-orange-600 font-medium">{stat.late_count} 次</td>
|
||||
<td className="text-center py-2 px-3 text-red-600 font-medium">{stat.late_minutes} 分鐘</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== ACCIDENT SECTION ========== */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||
<AlertTriangle size={20} className="text-red-600" />
|
||||
意外 Accident
|
||||
</h2>
|
||||
<Link to="/accident" className="text-sm text-red-600 hover:text-red-700">查看全部 →</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<SectionCard title="意外記錄" value={accInfo?.rows || 0} icon={AlertTriangle} color="border-l-red-500" link="/accident" />
|
||||
<SectionCard title="出事地點" value={accLocationData.length} subtitle="個地點" icon={MapPin} color="border-l-orange-500" link="/accident" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="card p-4 border-l-4 border-l-red-500">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">緊急程度</h3>
|
||||
<SmallBarChart data={accSeverityData} color="red" />
|
||||
</div>
|
||||
<div className="card p-4 border-l-4 border-l-orange-500">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">出事地點</h3>
|
||||
<SmallBarChart data={accLocationData} color="orange" />
|
||||
</div>
|
||||
<div className="card p-4 border-l-4 border-l-red-500">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">月度趨勢</h3>
|
||||
<TrendChart data={accMonthlyData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/upload" className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-md hover:bg-primary-700">
|
||||
<FileSpreadsheet size={16} />
|
||||
上傳 Excel
|
||||
</Link>
|
||||
<Link to="/roster" className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 text-sm rounded-md hover:bg-slate-200">
|
||||
<Clock size={16} />
|
||||
班次管理
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
toast.success('登入成功!')
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '登入失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white font-bold text-2xl">A</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">AARS</h1>
|
||||
<p className="text-slate-500 mt-1">Attendance & Accident Record System</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<div className="card p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
電郵 Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="admin@aars.hk"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
密碼 Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '登入中...' : '登入'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-slate-500">
|
||||
<p>預設帳戶:admin@aars.hk / admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-slate-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/accident"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回列表
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card p-8 text-center">
|
||||
<div className="text-red-500">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/accident')}
|
||||
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/accident"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
意外記錄 #{id}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Record Details */}
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText size={24} className="text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">記錄詳情</div>
|
||||
<div className="text-sm text-slate-500">Excel 原始數據</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{headers.map(key => (
|
||||
<div key={key} className="grid grid-cols-12">
|
||||
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
|
||||
{key}
|
||||
</div>
|
||||
<div className="col-span-9 px-4 py-3 text-sm text-slate-900">
|
||||
{record[key] !== null && record[key] !== undefined
|
||||
? String(record[key])
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-slate-900">意外記錄 Accident</h1>
|
||||
<Link
|
||||
to="/upload"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
|
||||
>
|
||||
上傳 Excel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜尋所有欄位..."
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={(e) => handleFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">篩選欄位</option>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<option key={h} value={h}>{h}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{filterKey && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
placeholder="輸入篩選值..."
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
|
||||
/>
|
||||
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600 w-12">#</th>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
|
||||
onClick={() => handleSort(h)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{h}
|
||||
{getSortIcon(h)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : processedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-6 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => (
|
||||
<tr
|
||||
key={record._row}
|
||||
className="hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => window.location.href = `/accident/${record._row}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
|
||||
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
to={`/accident/${record._row}`}
|
||||
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-slate-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/attendance"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回列表
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card p-8 text-center">
|
||||
<div className="text-red-500">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/attendance')}
|
||||
className="mt-4 px-4 py-2 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/attendance"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
出勤記錄 #{id}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Record Details */}
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText size={24} className="text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">記錄詳情</div>
|
||||
<div className="text-sm text-slate-500">Excel 原始數據</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{headers.map(key => (
|
||||
<div key={key} className="grid grid-cols-12">
|
||||
<div className="col-span-3 px-4 py-3 text-sm font-medium text-slate-500 bg-slate-50">
|
||||
{key}
|
||||
</div>
|
||||
<div className="col-span-9 px-4 py-3 text-sm text-slate-900">
|
||||
{record[key] !== null && record[key] !== undefined
|
||||
? String(record[key])
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
// Get lateness info for a record
|
||||
const getLatenessInfo = (record) => {
|
||||
// Find staff name and date columns
|
||||
const staffKey = Object.keys(record).find(k =>
|
||||
k.toLowerCase().includes('staff') || k.toLowerCase().includes('name') || k.toLowerCase().includes('員工')
|
||||
)
|
||||
const dateKey = Object.keys(record).find(k =>
|
||||
k.toLowerCase().includes('date') || k.toLowerCase().includes('日期')
|
||||
)
|
||||
|
||||
if (!staffKey || !dateKey) return null
|
||||
|
||||
const staff = record[staffKey]
|
||||
const date = record[dateKey]
|
||||
|
||||
if (!staff || !date) return null
|
||||
|
||||
const key = `${staff}-${String(date).slice(0, 10)}`
|
||||
return latenessMap[key]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-slate-900">出勤記錄 Attendance</h1>
|
||||
<Link
|
||||
to="/upload"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
上傳 Excel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜尋所有欄位..."
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-slate-400" />
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={(e) => handleFilter(e.target.value)}
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2"
|
||||
>
|
||||
<option value="">篩選欄位</option>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<option key={h} value={h}>{h}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{filterKey && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
placeholder="輸入篩選值..."
|
||||
className="text-sm border border-slate-300 rounded-md px-2 py-2 w-32"
|
||||
/>
|
||||
<button onClick={clearFilter} className="p-1 text-slate-400 hover:text-slate-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<div className="text-sm text-slate-500 py-2">
|
||||
{processedData.length} 筆記錄
|
||||
{latenessRecords.length > 0 && (
|
||||
<span className="ml-2 text-orange-600">
|
||||
• {latenessRecords.length} 筆遲到
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-600 w-12">#</th>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left text-xs font-semibold text-slate-600 cursor-pointer hover:bg-slate-200"
|
||||
onClick={() => handleSort(h)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{h}
|
||||
{getSortIcon(h)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-slate-600 w-20">狀態</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-600 w-16">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
|
||||
載入中...
|
||||
</td>
|
||||
</tr>
|
||||
) : processedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-3 py-6 text-center text-slate-400">
|
||||
沒有記錄
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processedData.map((record) => {
|
||||
const lateInfo = getLatenessInfo(record)
|
||||
const isLate = lateInfo && lateInfo.minutes_late > 0
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={record._row}
|
||||
className={`hover:bg-slate-50 cursor-pointer ${isLate ? 'bg-orange-50' : ''}`}
|
||||
onClick={() => window.location.href = `/attendance/${record._row}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{record._row}</td>
|
||||
{headers.slice(0, 6).map(h => (
|
||||
<td key={h} className="px-3 py-2 text-slate-700 max-w-xs truncate">
|
||||
{record[h] !== null && record[h] !== undefined ? String(record[h]) : '-'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isLate ? (
|
||||
<div className="flex items-center justify-center gap-1 text-orange-600" title={`遲到 ${lateInfo.minutes_late} 分鐘`}>
|
||||
<AlertCircle size={14} />
|
||||
<span className="text-xs font-medium">{lateInfo.minutes_late}分</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1 text-green-600">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs">正常</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
to={`/attendance/${record._row}`}
|
||||
className="p-1 text-slate-400 hover:text-primary-600 inline-block"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle, XCircle, ArrowRight, Download } from 'lucide-react'
|
||||
|
||||
const ATTENDANCE_COLUMNS = ['date', 'employee_name', 'employee_id', 'department', 'check_in', 'check_out', 'status']
|
||||
const ACCIDENT_COLUMNS = ['date', 'time', 'location', 'employee_name', 'employee_id', 'department', 'description', 'severity', 'medical_report', 'action_taken', 'responsible_person']
|
||||
|
||||
function downloadTemplate(section) {
|
||||
const XLSX = window.XLSX
|
||||
if (!XLSX) return toast.error('XLSX library not loaded')
|
||||
|
||||
const columns = section === 'attendance' ? ATTENDANCE_COLUMNS : ACCIDENT_COLUMNS
|
||||
const data = [columns, ['2026-01-01', '張三', 'A001', '工程部', '09:00', '18:00', 'present']]
|
||||
const ws = XLSX.utils.aoa_to_sheet(data)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, section)
|
||||
XLSX.writeFile(wb, `${section}_template.xlsx`)
|
||||
}
|
||||
|
||||
export default function ImportPage() {
|
||||
const [step, setStep] = useState(1)
|
||||
const [section, setSection] = useState('attendance')
|
||||
const [file, setFile] = useState(null)
|
||||
const [headers, setHeaders] = useState([])
|
||||
const [preview, setPreview] = useState([])
|
||||
const [mapping, setMapping] = useState({})
|
||||
const [updateExisting, setUpdateExisting] = useState(true)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const fileInputRef = useRef()
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.name.match(/\.(xlsx|xls)$/i)) {
|
||||
toast.error('請上傳 Excel 文件 (.xlsx, .xls)')
|
||||
return
|
||||
}
|
||||
|
||||
setFile(file)
|
||||
|
||||
// Read and parse headers
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const data = new Uint8Array(event.target.result)
|
||||
const XLSX = window.XLSX
|
||||
const wb = XLSX.read(data, { type: 'array' })
|
||||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||
const range = XLSX.utils.decode_range(ws['!ref'])
|
||||
|
||||
// Get headers
|
||||
const headersRow = []
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const addr = XLSX.utils.encode_cell({ r: 0, c: C })
|
||||
headersRow.push(ws[addr]?.v || `Column ${C + 1}`)
|
||||
}
|
||||
setHeaders(headersRow)
|
||||
|
||||
// Get preview (first 5 rows)
|
||||
const previewRows = []
|
||||
for (let R = 1; R <= Math.min(5, range.e.r); ++R) {
|
||||
const row = []
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
const addr = XLSX.utils.encode_cell({ r: R, c: C })
|
||||
row.push(ws[addr]?.v || '')
|
||||
}
|
||||
previewRows.push(row)
|
||||
}
|
||||
setPreview(previewRows)
|
||||
|
||||
// Auto-map using Excel column names directly
|
||||
const autoMapping = {}
|
||||
headersRow.forEach((header, idx) => {
|
||||
if (header && header.trim()) {
|
||||
autoMapping[idx] = header.trim()
|
||||
}
|
||||
})
|
||||
|
||||
setMapping(autoMapping)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
toast.error('讀取 Excel 文件失敗')
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
const handleMappingChange = (colIndex, field) => {
|
||||
setMapping(prev => ({ ...prev, [colIndex]: field }))
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('update_existing', updateExisting.toString())
|
||||
|
||||
const { data } = await api.post(`/api/import/${section}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
setResult(data)
|
||||
setStep(3)
|
||||
toast.success('Import completed!')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || 'Import failed')
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Excel Import</h1>
|
||||
<p className="text-slate-500 mt-1">上傳 Excel 文件批量導入數據</p>
|
||||
</div>
|
||||
|
||||
{/* Section selector */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-slate-700">選擇類別:</span>
|
||||
<button
|
||||
onClick={() => setSection('attendance')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
section === 'attendance'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
出勤 Attendance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSection('accident')}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
section === 'accident'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
意外 Accident
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadTemplate(section)}
|
||||
className="ml-auto flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md font-medium hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download size={16} />
|
||||
下載範本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress steps */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-medium ${
|
||||
step >= s ? 'bg-primary-600 text-white' : 'bg-slate-200 text-slate-500'
|
||||
}`}>
|
||||
{s}
|
||||
</div>
|
||||
<span className={`text-sm ${step >= s ? 'text-primary-600' : 'text-slate-400'}`}>
|
||||
{s === 1 ? '上傳檔案' : s === 2 ? '欄位映射' : '完成'}
|
||||
</span>
|
||||
{s < 3 && <ArrowRight size={16} className="text-slate-300 mx-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
{step === 1 && (
|
||||
<div className="card p-12">
|
||||
<div
|
||||
className="border-2 border-dashed border-slate-300 rounded-lg p-12 text-center hover:border-primary-400 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={48} className="mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-lg font-medium text-slate-700">點擊上傳 Excel 文件</p>
|
||||
<p className="text-sm text-slate-500 mt-2">支援 .xlsx, .xls 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Mapping & Preview */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
{/* File info */}
|
||||
<div className="card p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{file?.name}</div>
|
||||
<div className="text-sm text-slate-500">{preview.length + 1} rows</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setStep(1); setFile(null); setHeaders([]); setPreview([]); setMapping({}) }}
|
||||
className="text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
重新上傳
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mapping */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="font-medium text-slate-900">欄位映射 Field Mapping</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">將 Excel 欄位映射到系統欄位</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{headers.map((header, idx) => (
|
||||
<div key={idx} className="grid grid-cols-2 gap-4 items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-700">{header}</div>
|
||||
<div className="text-xs text-slate-400">Column {idx + 1}</div>
|
||||
</div>
|
||||
<select
|
||||
value={mapping[idx] || ''}
|
||||
onChange={(e) => handleMappingChange(idx, e.target.value)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">-- 不映射 --</option>
|
||||
{section === 'attendance' ? (
|
||||
<>
|
||||
<option value="employee_id">員工編號 Employee ID</option>
|
||||
<option value="employee_name">員工姓名 Employee Name</option>
|
||||
<option value="department">部門 Department</option>
|
||||
<option value="date">日期 Date</option>
|
||||
<option value="check_in">上班時間 Check In</option>
|
||||
<option value="check_out">下班時間 Check Out</option>
|
||||
<option value="status">狀態 Status</option>
|
||||
<option value="overtime_hours">加班時數 OT Hours</option>
|
||||
<option value="leave_type">請假類型 Leave Type</option>
|
||||
<option value="remarks">備註 Remarks</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="date">日期 Date</option>
|
||||
<option value="time">時間 Time</option>
|
||||
<option value="location">地點 Location</option>
|
||||
<option value="employee_name">員工姓名 Employee Name</option>
|
||||
<option value="employee_id">員工編號 Employee ID</option>
|
||||
<option value="department">部門 Department</option>
|
||||
<option value="description">描述 Description</option>
|
||||
<option value="severity">嚴重程度 Severity</option>
|
||||
<option value="medical_report">醫療報告 Medical Report</option>
|
||||
<option value="action_taken">行動 Action Taken</option>
|
||||
<option value="responsible_person">負責人 Responsible Person</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="font-medium text-slate-900">預覽 Preview</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} className="px-4 py-2 text-left text-xs font-medium text-slate-600">
|
||||
{h}
|
||||
{mapping[i] && (
|
||||
<span className="ml-2 text-primary-600">→ {mapping[i]}</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{preview.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-4 py-2 text-slate-700">
|
||||
{String(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="card p-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={updateExisting}
|
||||
onChange={(e) => setUpdateExisting(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">更新已存在的記錄</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
如果數據已存在,則更新而非跳過(以 Employee ID + Date 判斷重複)
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Import button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || Object.keys(mapping).length === 0}
|
||||
className="btn-primary px-8 py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{importing ? '導入中...' : '開始導入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Result */}
|
||||
{step === 3 && result && (
|
||||
<div className="card p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle size={32} className="text-green-600" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">導入完成!</h2>
|
||||
<p className="text-slate-500 mb-8">已完成數據導入,以下是結果摘要</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-green-600">{result.created}</div>
|
||||
<div className="text-sm text-green-700">已建立</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-blue-600">{result.updated}</div>
|
||||
<div className="text-sm text-blue-700">已更新</div>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 rounded-lg">
|
||||
<div className="text-3xl font-bold text-amber-600">{result.skipped}</div>
|
||||
<div className="text-sm text-amber-700">已跳過</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="text-left card p-4 mb-8 border border-red-200">
|
||||
<div className="flex items-center gap-2 text-red-600 font-medium mb-2">
|
||||
<AlertCircle size={18} />
|
||||
錯誤 ({result.errors.length})
|
||||
</div>
|
||||
<ul className="text-sm text-red-600 space-y-1">
|
||||
{result.errors.slice(0, 10).map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => { setStep(1); setFile(null); setHeaders([]); setPreview([]); setMapping({}); setResult(null) }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
繼續導入
|
||||
</button>
|
||||
<Link
|
||||
to={section === 'attendance' ? '/attendance' : '/accident'}
|
||||
className="btn-primary"
|
||||
>
|
||||
查看列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Upload, CheckCircle, Trash2, Clock, Users } from 'lucide-react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function RosterPage() {
|
||||
const [rosterData, setRosterData] = useState({ headers: [], rows: [] })
|
||||
const [rosterInfo, setRosterInfo] = useState({ uploaded: false })
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadRosterData = async () => {
|
||||
try {
|
||||
const [infoRes, dataRes] = await Promise.all([
|
||||
api.get('/api/roster/info'),
|
||||
api.get('/api/roster/data').catch(() => ({ data: { headers: [], rows: [] } }))
|
||||
])
|
||||
setRosterInfo(infoRes.data)
|
||||
setRosterData(dataRes.data || { headers: [], rows: [] })
|
||||
} catch (err) {
|
||||
console.error('Load error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRosterData()
|
||||
}, [])
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
await api.post('/api/roster/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
toast.success('上傳成功!')
|
||||
setFile(null)
|
||||
loadRosterData()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '上傳失敗')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('確定要刪除班次表嗎?')) return
|
||||
try {
|
||||
await api.delete('/api/roster')
|
||||
toast.success('已刪除')
|
||||
setRosterData({ headers: [], rows: [] })
|
||||
setRosterInfo({ uploaded: false })
|
||||
} catch (err) {
|
||||
toast.error('刪除失敗')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center">載入中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">班次管理 Roster</h1>
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Upload size={20} />
|
||||
上傳班次表
|
||||
</h2>
|
||||
|
||||
{!rosterInfo.uploaded ? (
|
||||
<form onSubmit={handleUpload} className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
選擇班次表 Excel 檔案
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={!file || uploading} className="btn-primary">
|
||||
{uploading ? '上傳中...' : '上傳'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="text-green-500" size={24} />
|
||||
<div>
|
||||
<p className="font-medium">已上傳班次表</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{rosterInfo.rows} 個班次,{rosterInfo.columns} 欄
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleDelete} className="btn-danger">
|
||||
<Trash2 size={16} className="inline mr-1" />
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Roster Table */}
|
||||
{rosterData.headers.length > 0 && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock size={20} />
|
||||
班次表
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100 border-b border-slate-200">
|
||||
<tr>
|
||||
{rosterData.headers.map((header, idx) => (
|
||||
<th key={idx} className="px-3 py-2 text-left text-xs font-semibold text-slate-600">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rosterData.rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="hover:bg-slate-50">
|
||||
{rosterData.headers.map((header, colIdx) => (
|
||||
<td key={colIdx} className="px-3 py-2 text-slate-700">
|
||||
{row[colIdx] !== null && row[colIdx] !== undefined
|
||||
? String(row[colIdx])
|
||||
: '-'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
{!rosterInfo.uploaded && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">如何使用</h2>
|
||||
<ol className="list-decimal list-inside space-y-2 text-slate-600">
|
||||
<li>上傳包含班次資料的 Excel 檔案</li>
|
||||
<li>Excel 需要有「班次」同「描述」欄</li>
|
||||
<li>上傳後可以睇到所有班次</li>
|
||||
<li>喺 Attendance Excel 入面加「班次」欄,填入員工所屬班次代碼</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Upload, FileSpreadsheet, CheckCircle, AlertCircle, Download, Trash2 } from 'lucide-react'
|
||||
import api from '../../api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'accident', label: '意外 Accident', color: 'red' },
|
||||
{ key: 'attendance', label: '出勤 Attendance', color: 'blue' },
|
||||
]
|
||||
|
||||
export default function UploadPage() {
|
||||
const [section, setSection] = useState('accident')
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [info, setInfo] = useState(null)
|
||||
const fileInputRef = useRef()
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
const currentSection = SECTIONS.find(s => s.key === section)
|
||||
|
||||
// Load current file info when section changes
|
||||
useEffect(() => {
|
||||
loadSectionInfo()
|
||||
}, [section])
|
||||
|
||||
async function loadSectionInfo() {
|
||||
try {
|
||||
const { data } = await api.get(`/api/${section}/info`)
|
||||
setInfo(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!confirm(`確定要刪除 ${section} 的所有數據嗎?這個操作無法撤銷!`)) return
|
||||
try {
|
||||
await api.delete(`/api/upload/${section}`)
|
||||
toast.success(`${section} 數據已清除`)
|
||||
setInfo({ uploaded: false, rows: 0, columns: 0, headers: [] })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '清除失敗')
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(e) {
|
||||
const f = e.target.files[0]
|
||||
if (f) {
|
||||
setFile(f)
|
||||
setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setResult(null)
|
||||
setUploadProgress(0)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
// Simulate progress
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(p => Math.min(p + 10, 90))
|
||||
}, 200)
|
||||
|
||||
const { data } = await api.post(`/api/upload/${section}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total))
|
||||
}
|
||||
})
|
||||
|
||||
clearInterval(progressInterval)
|
||||
setUploadProgress(100)
|
||||
|
||||
setResult(data)
|
||||
toast.success(`${section === 'accident' ? '意外' : '出勤'} 數據上傳成功!`)
|
||||
setFile(null)
|
||||
loadSectionInfo()
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || '上傳失敗')
|
||||
setUploadProgress(0)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelUpload() {
|
||||
// Cancel is handled by just resetting the state
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
setFile(null)
|
||||
toast.success('已取消上傳')
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
try {
|
||||
const { data: tokenData } = await api.post('/api/auth/login', {
|
||||
email: 'admin@aars.hk',
|
||||
password: 'Admin@1234'
|
||||
})
|
||||
|
||||
const token = tokenData.access_token
|
||||
window.location.href = `https://excel.donton.cloud/api/export/${section}/excel?token=${token}`
|
||||
} catch (err) {
|
||||
toast.error('Download failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">上傳 Excel</h1>
|
||||
<p className="text-slate-500 mt-1">直接上傳 Excel 檔案作為數據源</p>
|
||||
</div>
|
||||
|
||||
{/* Section selector */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-slate-700">選擇類別:</span>
|
||||
{SECTIONS.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => { setSection(s.key); setInfo(null); loadSectionInfo() }}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
section === s.key
|
||||
? s.color === 'red' ? 'bg-red-600 text-white' : 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current file info */}
|
||||
{info && (
|
||||
<div className="card p-4 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{section === 'accident' ? '意外' : '出勤'} 數據:
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
{info.uploaded
|
||||
? `${info.rows} 行, ${info.columns} 列`
|
||||
: '尚未上傳'}
|
||||
</div>
|
||||
{info.headers && info.headers.length > 0 && (
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
欄位:{info.headers.slice(0, 5).join(', ')}
|
||||
{info.headers.length > 5 && '...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{info.uploaded && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
<Download size={16} />
|
||||
下載
|
||||
</button>
|
||||
)}
|
||||
{info.uploaded && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<div className="card p-8">
|
||||
<div
|
||||
className="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:border-primary-400 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={48} className="mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-lg font-medium text-slate-700">
|
||||
{file ? file.name : '點擊選擇 Excel 檔案'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-2">支援 .xlsx, .xls 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium">{file.name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCancelUpload}
|
||||
disabled={!uploading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-md hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? '上傳中...' : '上傳'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{uploading && uploadProgress > 0 && (
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="card p-4 bg-green-50 border border-green-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle size={24} className="text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium text-green-900">{result.message}</div>
|
||||
<div className="text-sm text-green-700">
|
||||
{result.rows} 行數據已就緒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="card p-4 bg-amber-50 border border-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium text-amber-900">注意事項</div>
|
||||
<ul className="text-sm text-amber-800 mt-2 space-y-1">
|
||||
<li>• 上傳的 Excel 將直接作為數據源</li>
|
||||
<li>• 第一行必須是欄位名稱</li>
|
||||
<li>• 之後每行是一條記錄</li>
|
||||
<li>• 現有數據會被新上傳的文件替換</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'Noto Sans TC', 'system-ui', 'sans-serif'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# AARS Setup Script
|
||||
# Run this on VPS to setup the project
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== AARS Setup Script ==="
|
||||
echo ""
|
||||
|
||||
# Generate JWT secret if not exists
|
||||
if [ ! -f backend/secret.py ] || grep -q "change-me" backend/secret.py; then
|
||||
JWT_SECRET="aars-jwt-$(date +%s)-$(head -c 32 /dev/urandom | base64)"
|
||||
echo "JWT_SECRET=$JWT_SECRET" > backend/secret.py
|
||||
echo "✅ Generated JWT secret"
|
||||
else
|
||||
echo "✅ JWT secret already exists"
|
||||
fi
|
||||
|
||||
# Create data directory
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
|
||||
# Build Docker image
|
||||
echo ""
|
||||
echo "=== Building Docker image ==="
|
||||
docker compose build
|
||||
|
||||
echo ""
|
||||
echo "=== Starting container ==="
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "=== Waiting for container to be healthy ==="
|
||||
sleep 5
|
||||
|
||||
# Check health
|
||||
for i in {1..10}; do
|
||||
if curl -sf http://localhost:18775/api/auth/me > /dev/null 2>&1; then
|
||||
echo "✅ Container is healthy!"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Waiting for container... ($i/10)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete! ==="
|
||||
echo ""
|
||||
echo "📍 AARS URL: http://187.127.116.15:18775"
|
||||
echo "👤 Default login: admin@aars.hk / admin123"
|
||||
echo ""
|
||||
echo "⚠️ Please change the default password after first login!"
|
||||
Reference in New Issue
Block a user