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