commit f98da7337600d2799c742138881bf3fe691b47ba Author: ChuXun <70203584+ChuXunYu@users.noreply.github.com> Date: Wed Jan 28 23:56:33 2026 +0800 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85058a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Backend +backend/target/ + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.env +frontend/.env.* +frontend/vite.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/FRONTEND_HANDOFF.md b/FRONTEND_HANDOFF.md new file mode 100644 index 0000000..51960ad --- /dev/null +++ b/FRONTEND_HANDOFF.md @@ -0,0 +1,288 @@ +# Smart Office 前端交付文档(给另一个模型使用) + +> 目标:在没有上下文的情况下,仅凭本文档生成**与当前后端完全兼容**的前端界面。 +> 技术栈:React 18 + Vite + +--- + +## 1. 项目概览 +- 系统名称:Smart Office(智能办公管理系统) +- 当前版本:v1.0(MVP) +- 已实现模块:登录/权限、考勤打卡、请假审批、通知、用户管理 +- 角色:`ADMIN` / `MANAGER` / `EMPLOYEE` + +### 默认账号 +- admin / admin123 +- manager / manager123 +- employee / employee123 + +### 目标页面 +- 登录 +- 概览(Dashboard) +- 考勤(打卡、个人记录) +- 请假(提交、我的请假) +- 审批(经理/管理员可见) +- 用户管理(管理员/经理可见,创建仅管理员) +- 通知 + +--- + +## 2. 技术与接入约定 +- API Base:`/api/v1` +- 本地后端:`http://localhost:8080`(如在 8081,可通过 `VITE_PROXY_TARGET` 或 `VITE_API_BASE` 配置) +- Token:JWT,存储在 LocalStorage,Header:`Authorization: Bearer ` +- 后端响应结构统一: +```json +{ + "code": 200, + "message": "OK", + "data": {}, + "timestamp": "2026-01-28T11:31:58.291Z" +} +``` + +--- + +## 3. 权限规则(前端必须遵守) +- `ADMIN`:全部页面、可创建用户/修改状态、可审批 +- `MANAGER`:可查看用户列表、审批 +- `EMPLOYEE`:仅个人考勤、请假、通知、概览 + +UI 级别必须隐藏不可访问页面,但后端也会校验权限。 + +--- + +## 4. 路由建议 +- `/login` 登录页 +- `/` 概览 +- `/attendance` 考勤 +- `/leave` 请假 +- `/approvals` 审批(ADMIN/MANAGER) +- `/users` 用户(ADMIN/MANAGER) +- `/notifications` 通知 + +--- + +## 5. 后端接口文档(完整) + +### 5.1 Auth +#### 登录 +- **POST** `/auth/login` +```json +{ "username": "admin", "password": "admin123" } +``` +响应: +```json +{ + "code": 200, + "message": "OK", + "data": { + "token": "jwt-token", + "user": { + "id": 1, + "username": "admin", + "fullName": "管理员", + "role": "ADMIN", + "status": "ACTIVE", + "lastLoginAt": "2026-01-28T11:31:58.275Z" + } + } +} +``` + +#### 注册 +- **POST** `/auth/register` +```json +{ + "username": "u1", + "password": "u1123", + "fullName": "Test User", + "email": "u1@example.com", + "phone": "123456" +} +``` +响应:`UserDto` + +#### 当前用户 +- **GET** `/auth/me` +- Header:`Authorization` +- 响应:`UserDto` + +--- + +### 5.2 用户管理 Users +> ADMIN/MANAGER 可访问;创建/状态变更仅 ADMIN + +#### 列表 +- **GET** `/users` +- 响应:`UserDto[]` + +#### 详情 +- **GET** `/users/{id}` +- 响应:`UserDto` + +#### 创建用户 +- **POST** `/users` +```json +{ + "username": "u1", + "password": "u1123", + "fullName": "Test User", + "email": "u1@example.com", + "phone": "123456", + "role": "EMPLOYEE" +} +``` + +#### 修改状态 +- **PATCH** `/users/{id}/status` +```json +{ "status": "ACTIVE" } +``` + +--- + +### 5.3 考勤 Attendance +#### 上班打卡 +- **POST** `/attendance/check-in` +```json +{ "location": "HQ" } +``` + +#### 下班签退 +- **POST** `/attendance/check-out` + +#### 我的考勤记录 +- **GET** `/attendance/me` + +--- + +### 5.4 请假 Leave Requests +#### 提交请假 +- **POST** `/leave-requests` +```json +{ + "type": "ANNUAL", + "startTime": "2026-01-29T09:00:00Z", + "endTime": "2026-01-29T18:00:00Z", + "reason": "Family matters" +} +``` + +#### 我的请假 +- **GET** `/leave-requests/my` + +#### 待审批列表(ADMIN/MANAGER) +- **GET** `/leave-requests/pending` + +#### 审批通过 +- **POST** `/leave-requests/{id}/approve` +```json +{ "note": "Approved" } +``` + +#### 审批拒绝 +- **POST** `/leave-requests/{id}/reject` +```json +{ "note": "Rejected" } +``` + +--- + +### 5.5 通知 Notifications +#### 通知列表 +- **GET** `/notifications` + +#### 标记已读 +- **POST** `/notifications/{id}/read` + +--- + +## 6. DTO 数据结构 + +### UserDto +```ts +{ + id: number + username: string + fullName: string + email?: string + phone?: string + role: 'ADMIN' | 'MANAGER' | 'EMPLOYEE' + status: 'ACTIVE' | 'DISABLED' | 'LOCKED' + lastLoginAt?: string +} +``` + +### AttendanceDto +```ts +{ + id: number + date: string + checkInTime?: string + checkOutTime?: string + workHours: number + status: string + location?: string +} +``` + +### LeaveDto +```ts +{ + id: number + requester: UserDto + type: string + startTime: string + endTime: string + reason: string + status: string + approver?: UserDto + decidedAt?: string + createdAt: string +} +``` + +### NotificationDto +```ts +{ + id: number + type: string + title: string + content: string + readAt?: string + createdAt: string +} +``` + +--- + +## 7. 枚举 +- 角色:`ADMIN | MANAGER | EMPLOYEE` +- 用户状态:`ACTIVE | DISABLED | LOCKED` +- 请假类型:`ANNUAL | SICK | PERSONAL | MARRIAGE | MATERNITY | BEREAVEMENT` + +--- + +## 8. 错误码参考 +- 400:参数校验失败 +- 401:未登录 / Token 无效 +- 403:无权限 +- 404:资源不存在 +- 409:冲突(如重复、已处理) +- 500:服务器异常 + +--- + +## 9. 设计要求(给生成前端用) +- 必须生成 **企业级、现代、干净** 的 UI +- 页面需覆盖所有模块,角色权限需隐藏 +- 所有接口调用必须与上述 API 完全一致 +- Token 保存到 LocalStorage,并在每次请求中带上 + +--- + +## 10. v1.1 增量(目前无 API,前端可暂不实现) +- 考勤规则配置实体 +- 部门结构实体 +> 后端暂未提供接口,仅模型存在 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d64061b --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Smart Office(智能办公管理系统) + +本仓库已根据需求文档完成 v1.0(MVP)完整实现,包含 **后端 Spring Boot** 与 **前端 React**。MVP 聚焦“可运行、可演示、流程闭环”的核心能力。 + +## 已实现范围(v1.0) +- 认证与权限:JWT + 角色分级(管理员/经理/员工) +- 用户管理:用户新增、查询、状态管理 +- 考勤管理:上班/下班打卡、个人考勤记录 +- 请假审批:请假申请、审批通过/拒绝 +- 通知服务:站内通知(审批结果) + +## 目录结构 +``` +backend/ # Spring Boot 3.2 后端 +frontend/ # React 18 + Vite 前端 +plans/ # 需求/架构/数据库文档 +scripts/ # 数据库初始化脚本(MySQL) +``` + +## 启动方式 + +### 后端 +```bash +cd backend +mvn spring-boot:run +``` +- 默认使用 H2 内存数据库,访问控制台:`http://localhost:8080/h2` +- Swagger: `http://localhost:8080/swagger` + +可切换 MySQL: +```bash +mvn spring-boot:run -Dspring-boot.run.profiles=mysql +``` + +### 前端 +```bash +cd frontend +npm install +npm run dev +``` +访问:`http://localhost:5173` + +如后端运行在非 8080 端口(例如 8081),可在前端设置代理目标: +```bash +# macOS/Linux +VITE_PROXY_TARGET=http://localhost:8081 npm run dev + +# Windows PowerShell +$env:VITE_PROXY_TARGET="http://localhost:8081"; npm run dev +``` + +也可以直接配置 API Base(绕过代理): +```bash +# macOS/Linux +VITE_API_BASE=http://localhost:8081/api/v1 npm run dev + +# Windows PowerShell +$env:VITE_API_BASE="http://localhost:8081/api/v1"; npm run dev +``` + +## 默认账号 +- 管理员:admin / admin123 +- 经理:manager / manager123 +- 员工:employee / employee123 + +## 前端外观 +- 登录页背景图文件:`frontend/public/login-bg.jpg`(可替换为自定义图片) + +## 需求文档 +见 `plans/project_requirements_document.md`,已在 v1.0 标注了 MVP 交付边界与后续迭代项。 diff --git a/ROOT_STRUCTURE.md b/ROOT_STRUCTURE.md new file mode 100644 index 0000000..0942aa2 --- /dev/null +++ b/ROOT_STRUCTURE.md @@ -0,0 +1,15 @@ +# 根目录结构说明 + +> 目录:`E:\50425\Documents\Github\Test`(对应 WSL 路径:`/mnt/e/50425/Documents/Github/Test`) + +- `backend/`:后端 Spring Boot 3.2 源码与构建产物(Java)。 +- `frontend/`:前端 React 18 + Vite 工程(UI、路由、API 调用、样式等)。 +- `plans/`:需求与架构类文档(需求说明、数据库设计等)。 +- `scripts/`:数据库初始化与相关脚本(如 MySQL)。 +- `docker-compose.yml`:一键启动 MySQL 与后端的容器编排配置。 +- `FRONTEND_HANDOFF.md`:前端接口交付说明(API、字段、权限与页面约束)。 +- `README.md`:项目说明与启动方式(后端/前端、默认账号)。 +- `.gitignore`:Git 忽略规则(如 `node_modules`、`dist`、后端构建产物)。 +- `NUL`:占位文件/系统兼容文件(保留即可,不影响运行)。 + +如需补充更细的子目录说明(例如 `frontend/src` 或 `backend/src`),告诉我我可以继续扩展。 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..abd9c9c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM maven:3.9.6-eclipse-temurin-17 AS builder +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn -q -DskipTests package + +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=builder /app/target/*.jar app.jar +ENV SPRING_PROFILES_ACTIVE=mysql +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..78033f0 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + com.smartoffice + smart-office-backend + 0.1.0 + smart-office-backend + Smart Office Management System - Backend + + + 17 + 3.2.2 + 0.11.5 + 8.0.33 + ${spring-boot.version} + ${java.version} + UTF-8 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + com.h2database + h2 + runtime + + + mysql + mysql-connector-java + ${mysql.version} + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${project.build.sourceEncoding} + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot-maven-plugin.version} + + + + repackage + + + + + + + diff --git a/backend/run-backend.out.log b/backend/run-backend.out.log new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/backend/run-backend.out.log @@ -0,0 +1 @@ +test diff --git a/backend/src/main/java/com/smartoffice/SmartOfficeApplication.java b/backend/src/main/java/com/smartoffice/SmartOfficeApplication.java new file mode 100644 index 0000000..273c7c1 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/SmartOfficeApplication.java @@ -0,0 +1,11 @@ +package com.smartoffice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SmartOfficeApplication { + public static void main(String[] args) { + SpringApplication.run(SmartOfficeApplication.class, args); + } +} diff --git a/backend/src/main/java/com/smartoffice/attendance/AttendanceController.java b/backend/src/main/java/com/smartoffice/attendance/AttendanceController.java new file mode 100644 index 0000000..49dcced --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/AttendanceController.java @@ -0,0 +1,38 @@ +package com.smartoffice.attendance; + +import com.smartoffice.auth.UserPrincipal; +import com.smartoffice.common.ApiResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/attendance") +public class AttendanceController { + private final AttendanceService attendanceService; + + public AttendanceController(AttendanceService attendanceService) { + this.attendanceService = attendanceService; + } + + @PostMapping("/check-in") + public ApiResponse checkIn(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody(required = false) CheckInRequest request) { + if (request == null) { + request = new CheckInRequest(); + } + return ApiResponse.created(attendanceService.checkIn(principal.getUsername(), request)); + } + + @PostMapping("/check-out") + public ApiResponse checkOut(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(attendanceService.checkOut(principal.getUsername())); + } + + @GetMapping("/me") + public ApiResponse> myRecords(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(attendanceService.myRecords(principal.getUsername())); + } +} diff --git a/backend/src/main/java/com/smartoffice/attendance/AttendanceDto.java b/backend/src/main/java/com/smartoffice/attendance/AttendanceDto.java new file mode 100644 index 0000000..5dcd399 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/AttendanceDto.java @@ -0,0 +1,26 @@ +package com.smartoffice.attendance; + +import java.time.Instant; +import java.time.LocalDate; + +public record AttendanceDto( + Long id, + LocalDate date, + Instant checkInTime, + Instant checkOutTime, + double workHours, + AttendanceStatus status, + String location +) { + public static AttendanceDto from(AttendanceRecord record) { + return new AttendanceDto( + record.getId(), + record.getAttendanceDate(), + record.getCheckInTime(), + record.getCheckOutTime(), + record.getWorkHours(), + record.getStatus(), + record.getLocation() + ); + } +} diff --git a/backend/src/main/java/com/smartoffice/attendance/AttendanceRecord.java b/backend/src/main/java/com/smartoffice/attendance/AttendanceRecord.java new file mode 100644 index 0000000..13cca82 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/AttendanceRecord.java @@ -0,0 +1,92 @@ +package com.smartoffice.attendance; + +import com.smartoffice.user.User; +import jakarta.persistence.*; + +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "attendance_records") +public class AttendanceRecord { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + private User user; + + @Column(nullable = false) + private LocalDate attendanceDate; + + private Instant checkInTime; + + private Instant checkOutTime; + + private double workHours; + + @Enumerated(EnumType.STRING) + private AttendanceStatus status = AttendanceStatus.NORMAL; + + private String location; + + public Long getId() { + return id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public LocalDate getAttendanceDate() { + return attendanceDate; + } + + public void setAttendanceDate(LocalDate attendanceDate) { + this.attendanceDate = attendanceDate; + } + + public Instant getCheckInTime() { + return checkInTime; + } + + public void setCheckInTime(Instant checkInTime) { + this.checkInTime = checkInTime; + } + + public Instant getCheckOutTime() { + return checkOutTime; + } + + public void setCheckOutTime(Instant checkOutTime) { + this.checkOutTime = checkOutTime; + } + + public double getWorkHours() { + return workHours; + } + + public void setWorkHours(double workHours) { + this.workHours = workHours; + } + + public AttendanceStatus getStatus() { + return status; + } + + public void setStatus(AttendanceStatus status) { + this.status = status; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } +} diff --git a/backend/src/main/java/com/smartoffice/attendance/AttendanceRepository.java b/backend/src/main/java/com/smartoffice/attendance/AttendanceRepository.java new file mode 100644 index 0000000..a8588f4 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/AttendanceRepository.java @@ -0,0 +1,13 @@ +package com.smartoffice.attendance; + +import com.smartoffice.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface AttendanceRepository extends JpaRepository { + Optional findByUserAndAttendanceDate(User user, LocalDate date); + List findAllByUserOrderByAttendanceDateDesc(User user); +} diff --git a/backend/src/main/java/com/smartoffice/attendance/AttendanceRuleConfig.java b/backend/src/main/java/com/smartoffice/attendance/AttendanceRuleConfig.java new file mode 100644 index 0000000..4c5d261 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/AttendanceRuleConfig.java @@ -0,0 +1,244 @@ +package com.smartoffice.attendance; + +import com.smartoffice.organization.Department; +import jakarta.persistence.*; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; + +@Entity +@Table(name = "attendance_rule_config") +public class AttendanceRuleConfig { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String ruleName; + + @Column(nullable = false) + private LocalTime workStartTime; + + @Column(nullable = false) + private LocalTime workEndTime; + + // Comma-separated ISO day-of-week numbers (1=Mon ... 7=Sun) + @Column(nullable = false) + private String workDays = "1,2,3,4,5"; + + private LocalTime checkInWindowStart; + + private LocalTime checkInWindowEnd; + + private LocalTime checkOutWindowStart; + + private LocalTime checkOutWindowEnd; + + @Column(nullable = false) + private Integer lateMinutesThreshold = 0; + + @Column(nullable = false) + private Integer earlyLeaveMinutesThreshold = 0; + + private LocalDate effectiveFrom; + + private LocalDate effectiveTo; + + private boolean enabled = true; + + private boolean allowMakeup = false; + + private Integer maxMakeupPerMonth = 0; + + private Integer flexMinutes = 0; + + private String workCalendarCode; + + private String workCalendarName; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_id") + private Department department; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "updated_at") + private Instant updatedAt; + + @PrePersist + void onCreate() { + this.createdAt = Instant.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public LocalTime getWorkStartTime() { + return workStartTime; + } + + public void setWorkStartTime(LocalTime workStartTime) { + this.workStartTime = workStartTime; + } + + public LocalTime getWorkEndTime() { + return workEndTime; + } + + public void setWorkEndTime(LocalTime workEndTime) { + this.workEndTime = workEndTime; + } + + public String getWorkDays() { + return workDays; + } + + public void setWorkDays(String workDays) { + this.workDays = workDays; + } + + public LocalTime getCheckInWindowStart() { + return checkInWindowStart; + } + + public void setCheckInWindowStart(LocalTime checkInWindowStart) { + this.checkInWindowStart = checkInWindowStart; + } + + public LocalTime getCheckInWindowEnd() { + return checkInWindowEnd; + } + + public void setCheckInWindowEnd(LocalTime checkInWindowEnd) { + this.checkInWindowEnd = checkInWindowEnd; + } + + public LocalTime getCheckOutWindowStart() { + return checkOutWindowStart; + } + + public void setCheckOutWindowStart(LocalTime checkOutWindowStart) { + this.checkOutWindowStart = checkOutWindowStart; + } + + public LocalTime getCheckOutWindowEnd() { + return checkOutWindowEnd; + } + + public void setCheckOutWindowEnd(LocalTime checkOutWindowEnd) { + this.checkOutWindowEnd = checkOutWindowEnd; + } + + public boolean isAllowMakeup() { + return allowMakeup; + } + + public void setAllowMakeup(boolean allowMakeup) { + this.allowMakeup = allowMakeup; + } + + public Integer getMaxMakeupPerMonth() { + return maxMakeupPerMonth; + } + + public void setMaxMakeupPerMonth(Integer maxMakeupPerMonth) { + this.maxMakeupPerMonth = maxMakeupPerMonth; + } + + public Integer getFlexMinutes() { + return flexMinutes; + } + + public void setFlexMinutes(Integer flexMinutes) { + this.flexMinutes = flexMinutes; + } + + public String getWorkCalendarCode() { + return workCalendarCode; + } + + public void setWorkCalendarCode(String workCalendarCode) { + this.workCalendarCode = workCalendarCode; + } + + public String getWorkCalendarName() { + return workCalendarName; + } + + public void setWorkCalendarName(String workCalendarName) { + this.workCalendarName = workCalendarName; + } + + public Integer getLateMinutesThreshold() { + return lateMinutesThreshold; + } + + public void setLateMinutesThreshold(Integer lateMinutesThreshold) { + this.lateMinutesThreshold = lateMinutesThreshold; + } + + public Integer getEarlyLeaveMinutesThreshold() { + return earlyLeaveMinutesThreshold; + } + + public void setEarlyLeaveMinutesThreshold(Integer earlyLeaveMinutesThreshold) { + this.earlyLeaveMinutesThreshold = earlyLeaveMinutesThreshold; + } + + public LocalDate getEffectiveFrom() { + return effectiveFrom; + } + + public void setEffectiveFrom(LocalDate effectiveFrom) { + this.effectiveFrom = effectiveFrom; + } + + public LocalDate getEffectiveTo() { + return effectiveTo; + } + + public void setEffectiveTo(LocalDate effectiveTo) { + this.effectiveTo = effectiveTo; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Department getDepartment() { + return department; + } + + public void setDepartment(Department department) { + this.department = department; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/smartoffice/attendance/AttendanceRuleConfigRepository.java b/backend/src/main/java/com/smartoffice/attendance/AttendanceRuleConfigRepository.java new file mode 100644 index 0000000..2e230cd --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/AttendanceRuleConfigRepository.java @@ -0,0 +1,6 @@ +package com.smartoffice.attendance; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendanceRuleConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/smartoffice/attendance/AttendanceService.java b/backend/src/main/java/com/smartoffice/attendance/AttendanceService.java new file mode 100644 index 0000000..c197a70 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/AttendanceService.java @@ -0,0 +1,63 @@ +package com.smartoffice.attendance; + +import com.smartoffice.common.ApiException; +import com.smartoffice.user.User; +import com.smartoffice.user.UserRepository; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +@Service +public class AttendanceService { + private final AttendanceRepository attendanceRepository; + private final UserRepository userRepository; + + public AttendanceService(AttendanceRepository attendanceRepository, UserRepository userRepository) { + this.attendanceRepository = attendanceRepository; + this.userRepository = userRepository; + } + + @Transactional + public AttendanceDto checkIn(String username, CheckInRequest request) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ApiException(404, "User not found")); + LocalDate today = LocalDate.now(); + if (attendanceRepository.findByUserAndAttendanceDate(user, today).isPresent()) { + throw new ApiException(409, "Already checked in today"); + } + AttendanceRecord record = new AttendanceRecord(); + record.setUser(user); + record.setAttendanceDate(today); + record.setCheckInTime(Instant.now()); + record.setLocation(request.getLocation()); + attendanceRepository.save(record); + return AttendanceDto.from(record); + } + + @Transactional + public AttendanceDto checkOut(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ApiException(404, "User not found")); + AttendanceRecord record = attendanceRepository.findByUserAndAttendanceDate(user, LocalDate.now()) + .orElseThrow(() -> new ApiException(404, "No check-in record today")); + if (record.getCheckOutTime() != null) { + throw new ApiException(409, "Already checked out today"); + } + record.setCheckOutTime(Instant.now()); + Duration duration = Duration.between(record.getCheckInTime(), record.getCheckOutTime()); + record.setWorkHours(Math.round(duration.toMinutes() / 60.0 * 100.0) / 100.0); + attendanceRepository.save(record); + return AttendanceDto.from(record); + } + + public List myRecords(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ApiException(404, "User not found")); + return attendanceRepository.findAllByUserOrderByAttendanceDateDesc(user) + .stream().map(AttendanceDto::from).toList(); + } +} diff --git a/backend/src/main/java/com/smartoffice/attendance/AttendanceStatus.java b/backend/src/main/java/com/smartoffice/attendance/AttendanceStatus.java new file mode 100644 index 0000000..66849a2 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/AttendanceStatus.java @@ -0,0 +1,8 @@ +package com.smartoffice.attendance; + +public enum AttendanceStatus { + NORMAL, + LATE, + EARLY_LEAVE, + ABSENT +} diff --git a/backend/src/main/java/com/smartoffice/attendance/CheckInRequest.java b/backend/src/main/java/com/smartoffice/attendance/CheckInRequest.java new file mode 100644 index 0000000..f9ec242 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/attendance/CheckInRequest.java @@ -0,0 +1,13 @@ +package com.smartoffice.attendance; + +public class CheckInRequest { + private String location; + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } +} diff --git a/backend/src/main/java/com/smartoffice/auth/AuthController.java b/backend/src/main/java/com/smartoffice/auth/AuthController.java new file mode 100644 index 0000000..d6333c0 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/AuthController.java @@ -0,0 +1,32 @@ +package com.smartoffice.auth; + +import com.smartoffice.common.ApiResponse; +import com.smartoffice.user.UserDto; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.ok(authService.login(request)); + } + + @PostMapping("/register") + public ApiResponse register(@Valid @RequestBody RegisterRequest request) { + return ApiResponse.created(authService.register(request)); + } + + @GetMapping("/me") + public ApiResponse me(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(UserDto.from(principal.getUser())); + } +} diff --git a/backend/src/main/java/com/smartoffice/auth/AuthResponse.java b/backend/src/main/java/com/smartoffice/auth/AuthResponse.java new file mode 100644 index 0000000..870f265 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/AuthResponse.java @@ -0,0 +1,6 @@ +package com.smartoffice.auth; + +import com.smartoffice.user.UserDto; + +public record AuthResponse(String token, UserDto user) { +} diff --git a/backend/src/main/java/com/smartoffice/auth/AuthService.java b/backend/src/main/java/com/smartoffice/auth/AuthService.java new file mode 100644 index 0000000..1ad0f63 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/AuthService.java @@ -0,0 +1,102 @@ +package com.smartoffice.auth; + +import com.smartoffice.common.ApiException; +import com.smartoffice.user.Role; +import com.smartoffice.user.User; +import com.smartoffice.user.UserDto; +import com.smartoffice.user.UserRepository; +import com.smartoffice.user.UserStatus; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +@Service +public class AuthService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + private final int maxAttempts; + private final int lockMinutes; + + public AuthService(UserRepository userRepository, + PasswordEncoder passwordEncoder, + AuthenticationManager authenticationManager, + JwtService jwtService, + @Value("${app.security.lockout.max-attempts}") int maxAttempts, + @Value("${app.security.lockout.lock-minutes}") int lockMinutes) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.authenticationManager = authenticationManager; + this.jwtService = jwtService; + this.maxAttempts = maxAttempts; + this.lockMinutes = lockMinutes; + } + + @Transactional + public AuthResponse login(LoginRequest request) { + User user = userRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new ApiException(401, "Invalid credentials")); + + if (user.getStatus() == UserStatus.DISABLED) { + throw new ApiException(403, "Account disabled"); + } + if (user.getLockedUntil() != null && user.getLockedUntil().isAfter(Instant.now())) { + throw new ApiException(423, "Account locked until " + user.getLockedUntil()); + } + if (user.getStatus() == UserStatus.LOCKED && user.getLockedUntil() == null) { + throw new ApiException(423, "Account locked"); + } + if (user.getLockedUntil() != null && user.getLockedUntil().isBefore(Instant.now())) { + user.setLockedUntil(null); + user.setStatus(UserStatus.ACTIVE); + user.setFailedLoginAttempts(0); + userRepository.save(user); + } + + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + user.setFailedLoginAttempts(0); + user.setLockedUntil(null); + user.setLastLoginAt(Instant.now()); + user.setStatus(UserStatus.ACTIVE); + userRepository.save(user); + String token = jwtService.generateToken(principal); + return new AuthResponse(token, UserDto.from(user)); + } catch (Exception ex) { + int attempts = user.getFailedLoginAttempts() + 1; + user.setFailedLoginAttempts(attempts); + if (attempts >= maxAttempts) { + user.setLockedUntil(Instant.now().plusSeconds(lockMinutes * 60L)); + user.setStatus(UserStatus.LOCKED); + } + userRepository.save(user); + throw new ApiException(401, "Invalid credentials"); + } + } + + @Transactional + public UserDto register(RegisterRequest request) { + if (userRepository.existsByUsername(request.getUsername())) { + throw new ApiException(409, "Username already exists"); + } + User user = new User(); + user.setUsername(request.getUsername()); + user.setPasswordHash(passwordEncoder.encode(request.getPassword())); + user.setFullName(request.getFullName()); + user.setEmail(request.getEmail()); + user.setPhone(request.getPhone()); + user.setRole(Role.EMPLOYEE); + user.setStatus(UserStatus.ACTIVE); + userRepository.save(user); + return UserDto.from(user); + } +} diff --git a/backend/src/main/java/com/smartoffice/auth/JwtAuthFilter.java b/backend/src/main/java/com/smartoffice/auth/JwtAuthFilter.java new file mode 100644 index 0000000..958f429 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/JwtAuthFilter.java @@ -0,0 +1,49 @@ +package com.smartoffice.auth; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + private final JwtService jwtService; + private final UserPrincipalService userPrincipalService; + + public JwtAuthFilter(JwtService jwtService, UserPrincipalService userPrincipalService) { + this.jwtService = jwtService; + this.userPrincipalService = userPrincipalService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { + String token = header.substring(7); + try { + Claims claims = jwtService.parse(token); + String username = claims.getSubject(); + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserPrincipal principal = (UserPrincipal) userPrincipalService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ignored) { + // invalid token -> continue without auth + } + } + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/smartoffice/auth/JwtService.java b/backend/src/main/java/com/smartoffice/auth/JwtService.java new file mode 100644 index 0000000..71c2d69 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/JwtService.java @@ -0,0 +1,45 @@ +package com.smartoffice.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +@Service +public class JwtService { + private final byte[] secret; + private final long expirationMinutes; + + public JwtService(@Value("${app.security.jwt.secret}") String secret, + @Value("${app.security.jwt.expiration-minutes}") long expirationMinutes) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.expirationMinutes = expirationMinutes; + } + + public String generateToken(UserPrincipal principal) { + Instant now = Instant.now(); + Instant expiry = now.plus(expirationMinutes, ChronoUnit.MINUTES); + return Jwts.builder() + .setSubject(principal.getUsername()) + .claim("role", principal.getUser().getRole().name()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expiry)) + .signWith(Keys.hmacShaKeyFor(secret), SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parse(String token) { + return Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(secret)) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/backend/src/main/java/com/smartoffice/auth/LoginRequest.java b/backend/src/main/java/com/smartoffice/auth/LoginRequest.java new file mode 100644 index 0000000..f073a97 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/LoginRequest.java @@ -0,0 +1,27 @@ +package com.smartoffice.auth; + +import jakarta.validation.constraints.NotBlank; + +public class LoginRequest { + @NotBlank + private String username; + + @NotBlank + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/backend/src/main/java/com/smartoffice/auth/RegisterRequest.java b/backend/src/main/java/com/smartoffice/auth/RegisterRequest.java new file mode 100644 index 0000000..61be095 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/RegisterRequest.java @@ -0,0 +1,60 @@ +package com.smartoffice.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class RegisterRequest { + @NotBlank + private String username; + + @NotBlank + private String password; + + @NotBlank + private String fullName; + + @Email + private String email; + + private String phone; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } +} diff --git a/backend/src/main/java/com/smartoffice/auth/UserPrincipal.java b/backend/src/main/java/com/smartoffice/auth/UserPrincipal.java new file mode 100644 index 0000000..0d28aaf --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/UserPrincipal.java @@ -0,0 +1,57 @@ +package com.smartoffice.auth; + +import com.smartoffice.user.User; +import com.smartoffice.user.UserStatus; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +public class UserPrincipal implements UserDetails { + private final User user; + + public UserPrincipal(User user) { + this.user = user; + } + + public User getUser() { + return user; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); + } + + @Override + public String getPassword() { + return user.getPasswordHash(); + } + + @Override + public String getUsername() { + return user.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return user.getStatus() != UserStatus.LOCKED; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return user.getStatus() == UserStatus.ACTIVE; + } +} diff --git a/backend/src/main/java/com/smartoffice/auth/UserPrincipalService.java b/backend/src/main/java/com/smartoffice/auth/UserPrincipalService.java new file mode 100644 index 0000000..d6f96b3 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/auth/UserPrincipalService.java @@ -0,0 +1,24 @@ +package com.smartoffice.auth; + +import com.smartoffice.user.User; +import com.smartoffice.user.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class UserPrincipalService implements UserDetailsService { + private final UserRepository userRepository; + + public UserPrincipalService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + return new UserPrincipal(user); + } +} diff --git a/backend/src/main/java/com/smartoffice/common/ApiException.java b/backend/src/main/java/com/smartoffice/common/ApiException.java new file mode 100644 index 0000000..f4e9208 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/common/ApiException.java @@ -0,0 +1,14 @@ +package com.smartoffice.common; + +public class ApiException extends RuntimeException { + private final int status; + + public ApiException(int status, String message) { + super(message); + this.status = status; + } + + public int getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/smartoffice/common/ApiResponse.java b/backend/src/main/java/com/smartoffice/common/ApiResponse.java new file mode 100644 index 0000000..9fc7fb2 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/common/ApiResponse.java @@ -0,0 +1,48 @@ +package com.smartoffice.common; + +import java.time.Instant; + +public class ApiResponse { + private int code; + private String message; + private T data; + private Instant timestamp; + + public ApiResponse() { + } + + public ApiResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + this.timestamp = Instant.now(); + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(200, "OK", data); + } + + public static ApiResponse created(T data) { + return new ApiResponse<>(201, "Created", data); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public T getData() { + return data; + } + + public Instant getTimestamp() { + return timestamp; + } +} diff --git a/backend/src/main/java/com/smartoffice/common/GlobalExceptionHandler.java b/backend/src/main/java/com/smartoffice/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..a07b891 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/common/GlobalExceptionHandler.java @@ -0,0 +1,33 @@ +package com.smartoffice.common; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(ApiException.class) + public ResponseEntity> handleApi(ApiException ex) { + return ResponseEntity.status(ex.getStatus()) + .body(ApiResponse.error(ex.getStatus(), ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidation(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + for (FieldError error : ex.getBindingResult().getFieldErrors()) { + errors.put(error.getField(), error.getDefaultMessage()); + } + return ResponseEntity.badRequest().body(new ApiResponse<>(400, "Validation error", errors)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception ex) { + return ResponseEntity.internalServerError().body(ApiResponse.error(500, ex.getMessage())); + } +} diff --git a/backend/src/main/java/com/smartoffice/common/HealthController.java b/backend/src/main/java/com/smartoffice/common/HealthController.java new file mode 100644 index 0000000..7691ea2 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/common/HealthController.java @@ -0,0 +1,14 @@ +package com.smartoffice.common; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class HealthController { + @GetMapping("/api/v1/health") + public ApiResponse> health() { + return ApiResponse.ok(Map.of("status", "UP")); + } +} diff --git a/backend/src/main/java/com/smartoffice/config/CorsConfig.java b/backend/src/main/java/com/smartoffice/config/CorsConfig.java new file mode 100644 index 0000000..0bfb823 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/config/CorsConfig.java @@ -0,0 +1,24 @@ +package com.smartoffice.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:5173")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*") ); + config.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/backend/src/main/java/com/smartoffice/config/DataSeeder.java b/backend/src/main/java/com/smartoffice/config/DataSeeder.java new file mode 100644 index 0000000..8d6f3cf --- /dev/null +++ b/backend/src/main/java/com/smartoffice/config/DataSeeder.java @@ -0,0 +1,40 @@ +package com.smartoffice.config; + +import com.smartoffice.user.Role; +import com.smartoffice.user.User; +import com.smartoffice.user.UserRepository; +import com.smartoffice.user.UserStatus; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class DataSeeder implements CommandLineRunner { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public DataSeeder(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + public void run(String... args) { + if (userRepository.count() > 0) { + return; + } + userRepository.save(createUser("admin", "管理员", Role.ADMIN)); + userRepository.save(createUser("manager", "部门经理", Role.MANAGER)); + userRepository.save(createUser("employee", "普通员工", Role.EMPLOYEE)); + } + + private User createUser(String username, String fullName, Role role) { + User user = new User(); + user.setUsername(username); + user.setPasswordHash(passwordEncoder.encode(username + "123")); + user.setFullName(fullName); + user.setRole(role); + user.setStatus(UserStatus.ACTIVE); + return user; + } +} diff --git a/backend/src/main/java/com/smartoffice/config/SecurityConfig.java b/backend/src/main/java/com/smartoffice/config/SecurityConfig.java new file mode 100644 index 0000000..74e56ca --- /dev/null +++ b/backend/src/main/java/com/smartoffice/config/SecurityConfig.java @@ -0,0 +1,50 @@ +package com.smartoffice.config; + +import com.smartoffice.auth.JwtAuthFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + private final JwtAuthFilter jwtAuthFilter; + + public SecurityConfig(JwtAuthFilter jwtAuthFilter) { + this.jwtAuthFilter = jwtAuthFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.cors(cors -> {}) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/**", "/swagger/**", "/api-docs/**", "/h2/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/health").permitAll() + .anyRequest().authenticated() + ) + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/backend/src/main/java/com/smartoffice/leave/DecisionRequest.java b/backend/src/main/java/com/smartoffice/leave/DecisionRequest.java new file mode 100644 index 0000000..123b85e --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/DecisionRequest.java @@ -0,0 +1,16 @@ +package com.smartoffice.leave; + +import jakarta.validation.constraints.NotBlank; + +public class DecisionRequest { + @NotBlank + private String note; + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } +} diff --git a/backend/src/main/java/com/smartoffice/leave/LeaveController.java b/backend/src/main/java/com/smartoffice/leave/LeaveController.java new file mode 100644 index 0000000..296ed4d --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/LeaveController.java @@ -0,0 +1,53 @@ +package com.smartoffice.leave; + +import com.smartoffice.auth.UserPrincipal; +import com.smartoffice.common.ApiResponse; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/leave-requests") +public class LeaveController { + private final LeaveService leaveService; + + public LeaveController(LeaveService leaveService) { + this.leaveService = leaveService; + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody LeaveRequestCreate request) { + return ApiResponse.created(leaveService.create(principal.getUsername(), request)); + } + + @GetMapping("/my") + public ApiResponse> myRequests(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(leaveService.myRequests(principal.getUsername())); + } + + @GetMapping("/pending") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> pending() { + return ApiResponse.ok(leaveService.pendingRequests()); + } + + @PostMapping("/{id}/approve") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse approve(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("id") Long id, + @Valid @RequestBody DecisionRequest request) { + return ApiResponse.ok(leaveService.approve(principal.getUsername(), id, request.getNote(), true)); + } + + @PostMapping("/{id}/reject") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse reject(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("id") Long id, + @Valid @RequestBody DecisionRequest request) { + return ApiResponse.ok(leaveService.approve(principal.getUsername(), id, request.getNote(), false)); + } +} diff --git a/backend/src/main/java/com/smartoffice/leave/LeaveDto.java b/backend/src/main/java/com/smartoffice/leave/LeaveDto.java new file mode 100644 index 0000000..037896d --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/LeaveDto.java @@ -0,0 +1,33 @@ +package com.smartoffice.leave; + +import com.smartoffice.user.UserDto; + +import java.time.Instant; + +public record LeaveDto( + Long id, + UserDto requester, + LeaveType type, + Instant startTime, + Instant endTime, + String reason, + LeaveStatus status, + UserDto approver, + Instant decidedAt, + Instant createdAt +) { + public static LeaveDto from(LeaveRequest request) { + return new LeaveDto( + request.getId(), + UserDto.from(request.getUser()), + request.getType(), + request.getStartTime(), + request.getEndTime(), + request.getReason(), + request.getStatus(), + request.getApprover() == null ? null : UserDto.from(request.getApprover()), + request.getDecidedAt(), + request.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/smartoffice/leave/LeaveRepository.java b/backend/src/main/java/com/smartoffice/leave/LeaveRepository.java new file mode 100644 index 0000000..d678fe8 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/LeaveRepository.java @@ -0,0 +1,11 @@ +package com.smartoffice.leave; + +import com.smartoffice.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LeaveRepository extends JpaRepository { + List findAllByUserOrderByCreatedAtDesc(User user); + List findAllByStatusOrderByCreatedAtDesc(LeaveStatus status); +} diff --git a/backend/src/main/java/com/smartoffice/leave/LeaveRequest.java b/backend/src/main/java/com/smartoffice/leave/LeaveRequest.java new file mode 100644 index 0000000..68adb1e --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/LeaveRequest.java @@ -0,0 +1,117 @@ +package com.smartoffice.leave; + +import com.smartoffice.user.User; +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity +@Table(name = "leave_requests") +public class LeaveRequest { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private LeaveType type; + + @Column(nullable = false) + private Instant startTime; + + @Column(nullable = false) + private Instant endTime; + + @Column(length = 500) + private String reason; + + @Enumerated(EnumType.STRING) + private LeaveStatus status = LeaveStatus.PENDING; + + @ManyToOne + private User approver; + + private Instant decidedAt; + + private Instant createdAt; + + @PrePersist + void onCreate() { + createdAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public LeaveType getType() { + return type; + } + + public void setType(LeaveType type) { + this.type = type; + } + + public Instant getStartTime() { + return startTime; + } + + public void setStartTime(Instant startTime) { + this.startTime = startTime; + } + + public Instant getEndTime() { + return endTime; + } + + public void setEndTime(Instant endTime) { + this.endTime = endTime; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public LeaveStatus getStatus() { + return status; + } + + public void setStatus(LeaveStatus status) { + this.status = status; + } + + public User getApprover() { + return approver; + } + + public void setApprover(User approver) { + this.approver = approver; + } + + public Instant getDecidedAt() { + return decidedAt; + } + + public void setDecidedAt(Instant decidedAt) { + this.decidedAt = decidedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/smartoffice/leave/LeaveRequestCreate.java b/backend/src/main/java/com/smartoffice/leave/LeaveRequestCreate.java new file mode 100644 index 0000000..7af2979 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/LeaveRequestCreate.java @@ -0,0 +1,52 @@ +package com.smartoffice.leave; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; + +public class LeaveRequestCreate { + @NotNull + private LeaveType type; + + @NotNull + private Instant startTime; + + @NotNull + private Instant endTime; + + @NotBlank + private String reason; + + public LeaveType getType() { + return type; + } + + public void setType(LeaveType type) { + this.type = type; + } + + public Instant getStartTime() { + return startTime; + } + + public void setStartTime(Instant startTime) { + this.startTime = startTime; + } + + public Instant getEndTime() { + return endTime; + } + + public void setEndTime(Instant endTime) { + this.endTime = endTime; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/smartoffice/leave/LeaveService.java b/backend/src/main/java/com/smartoffice/leave/LeaveService.java new file mode 100644 index 0000000..87b5187 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/LeaveService.java @@ -0,0 +1,72 @@ +package com.smartoffice.leave; + +import com.smartoffice.common.ApiException; +import com.smartoffice.notification.NotificationService; +import com.smartoffice.notification.NotificationType; +import com.smartoffice.user.User; +import com.smartoffice.user.UserRepository; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +public class LeaveService { + private final LeaveRepository leaveRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + + public LeaveService(LeaveRepository leaveRepository, UserRepository userRepository, NotificationService notificationService) { + this.leaveRepository = leaveRepository; + this.userRepository = userRepository; + this.notificationService = notificationService; + } + + @Transactional + public LeaveDto create(String username, LeaveRequestCreate request) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ApiException(404, "User not found")); + if (request.getEndTime().isBefore(request.getStartTime())) { + throw new ApiException(400, "End time must be after start time"); + } + LeaveRequest leave = new LeaveRequest(); + leave.setUser(user); + leave.setType(request.getType()); + leave.setStartTime(request.getStartTime()); + leave.setEndTime(request.getEndTime()); + leave.setReason(request.getReason()); + leaveRepository.save(leave); + return LeaveDto.from(leave); + } + + public List myRequests(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ApiException(404, "User not found")); + return leaveRepository.findAllByUserOrderByCreatedAtDesc(user) + .stream().map(LeaveDto::from).toList(); + } + + public List pendingRequests() { + return leaveRepository.findAllByStatusOrderByCreatedAtDesc(LeaveStatus.PENDING) + .stream().map(LeaveDto::from).toList(); + } + + @Transactional + public LeaveDto approve(String approverUsername, Long id, String note, boolean approved) { + User approver = userRepository.findByUsername(approverUsername) + .orElseThrow(() -> new ApiException(404, "User not found")); + LeaveRequest leave = leaveRepository.findById(id) + .orElseThrow(() -> new ApiException(404, "Leave request not found")); + if (leave.getStatus() != LeaveStatus.PENDING) { + throw new ApiException(409, "Leave request already processed"); + } + leave.setApprover(approver); + leave.setDecidedAt(Instant.now()); + leave.setStatus(approved ? LeaveStatus.APPROVED : LeaveStatus.REJECTED); + String title = approved ? "请假已批准" : "请假被拒绝"; + String content = title + " - " + note; + notificationService.notifyUser(leave.getUser(), NotificationType.APPROVAL, title, content); + return LeaveDto.from(leave); + } +} diff --git a/backend/src/main/java/com/smartoffice/leave/LeaveStatus.java b/backend/src/main/java/com/smartoffice/leave/LeaveStatus.java new file mode 100644 index 0000000..1c54c44 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/LeaveStatus.java @@ -0,0 +1,8 @@ +package com.smartoffice.leave; + +public enum LeaveStatus { + PENDING, + APPROVED, + REJECTED, + CANCELLED +} diff --git a/backend/src/main/java/com/smartoffice/leave/LeaveType.java b/backend/src/main/java/com/smartoffice/leave/LeaveType.java new file mode 100644 index 0000000..0cc294f --- /dev/null +++ b/backend/src/main/java/com/smartoffice/leave/LeaveType.java @@ -0,0 +1,10 @@ +package com.smartoffice.leave; + +public enum LeaveType { + ANNUAL, + SICK, + PERSONAL, + MARRIAGE, + MATERNITY, + BEREAVEMENT +} diff --git a/backend/src/main/java/com/smartoffice/notification/Notification.java b/backend/src/main/java/com/smartoffice/notification/Notification.java new file mode 100644 index 0000000..832c8fc --- /dev/null +++ b/backend/src/main/java/com/smartoffice/notification/Notification.java @@ -0,0 +1,84 @@ +package com.smartoffice.notification; + +import com.smartoffice.user.User; +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity +@Table(name = "notifications") +public class Notification { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, length = 1000) + private String content; + + private Instant readAt; + + private Instant createdAt; + + @PrePersist + void onCreate() { + createdAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public NotificationType getType() { + return type; + } + + public void setType(NotificationType type) { + this.type = type; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Instant getReadAt() { + return readAt; + } + + public void setReadAt(Instant readAt) { + this.readAt = readAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/smartoffice/notification/NotificationController.java b/backend/src/main/java/com/smartoffice/notification/NotificationController.java new file mode 100644 index 0000000..2a97193 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/notification/NotificationController.java @@ -0,0 +1,28 @@ +package com.smartoffice.notification; + +import com.smartoffice.auth.UserPrincipal; +import com.smartoffice.common.ApiResponse; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/notifications") +public class NotificationController { + private final NotificationService notificationService; + + public NotificationController(NotificationService notificationService) { + this.notificationService = notificationService; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal UserPrincipal principal) { + return ApiResponse.ok(notificationService.listFor(principal.getUsername())); + } + + @PostMapping("/{id}/read") + public ApiResponse markRead(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Long id) { + return ApiResponse.ok(notificationService.markRead(principal.getUsername(), id)); + } +} diff --git a/backend/src/main/java/com/smartoffice/notification/NotificationDto.java b/backend/src/main/java/com/smartoffice/notification/NotificationDto.java new file mode 100644 index 0000000..b4645db --- /dev/null +++ b/backend/src/main/java/com/smartoffice/notification/NotificationDto.java @@ -0,0 +1,23 @@ +package com.smartoffice.notification; + +import java.time.Instant; + +public record NotificationDto( + Long id, + NotificationType type, + String title, + String content, + Instant readAt, + Instant createdAt +) { + public static NotificationDto from(Notification notification) { + return new NotificationDto( + notification.getId(), + notification.getType(), + notification.getTitle(), + notification.getContent(), + notification.getReadAt(), + notification.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/smartoffice/notification/NotificationRepository.java b/backend/src/main/java/com/smartoffice/notification/NotificationRepository.java new file mode 100644 index 0000000..0456574 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/notification/NotificationRepository.java @@ -0,0 +1,10 @@ +package com.smartoffice.notification; + +import com.smartoffice.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + List findAllByUserOrderByCreatedAtDesc(User user); +} diff --git a/backend/src/main/java/com/smartoffice/notification/NotificationService.java b/backend/src/main/java/com/smartoffice/notification/NotificationService.java new file mode 100644 index 0000000..4a83b23 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/notification/NotificationService.java @@ -0,0 +1,51 @@ +package com.smartoffice.notification; + +import com.smartoffice.common.ApiException; +import com.smartoffice.user.User; +import com.smartoffice.user.UserRepository; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +public class NotificationService { + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + + public NotificationService(NotificationRepository notificationRepository, UserRepository userRepository) { + this.notificationRepository = notificationRepository; + this.userRepository = userRepository; + } + + @Transactional + public void notifyUser(User user, NotificationType type, String title, String content) { + Notification notification = new Notification(); + notification.setUser(user); + notification.setType(type); + notification.setTitle(title); + notification.setContent(content); + notificationRepository.save(notification); + } + + public List listFor(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ApiException(404, "User not found")); + return notificationRepository.findAllByUserOrderByCreatedAtDesc(user) + .stream().map(NotificationDto::from).toList(); + } + + @Transactional + public NotificationDto markRead(String username, Long id) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ApiException(404, "User not found")); + Notification notification = notificationRepository.findById(id) + .orElseThrow(() -> new ApiException(404, "Notification not found")); + if (!notification.getUser().getId().equals(user.getId())) { + throw new ApiException(403, "Not allowed"); + } + notification.setReadAt(Instant.now()); + return NotificationDto.from(notification); + } +} diff --git a/backend/src/main/java/com/smartoffice/notification/NotificationType.java b/backend/src/main/java/com/smartoffice/notification/NotificationType.java new file mode 100644 index 0000000..9e4b7ff --- /dev/null +++ b/backend/src/main/java/com/smartoffice/notification/NotificationType.java @@ -0,0 +1,7 @@ +package com.smartoffice.notification; + +public enum NotificationType { + SYSTEM, + ATTENDANCE, + APPROVAL +} diff --git a/backend/src/main/java/com/smartoffice/organization/Department.java b/backend/src/main/java/com/smartoffice/organization/Department.java new file mode 100644 index 0000000..e8a074a --- /dev/null +++ b/backend/src/main/java/com/smartoffice/organization/Department.java @@ -0,0 +1,187 @@ +package com.smartoffice.organization; + +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity +@Table(name = "sys_department") +public class Department { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "dept_code", nullable = false, unique = true) + private String deptCode; + + @Column(name = "dept_name", nullable = false) + private String deptName; + + @Column(name = "parent_id") + private Long parentId = 0L; + + private String ancestors; + + private Integer level = 1; + + private String path; + + @Column(name = "leader_id") + private Long leaderId; + + @Column(name = "sort") + private Integer sort = 0; + + private Integer status = 1; + + private String phone; + + private String email; + + private String address; + + private String remark; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "updated_at") + private Instant updatedAt; + + private boolean deleted = false; + + @PrePersist + void onCreate() { + this.createdAt = Instant.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getDeptCode() { + return deptCode; + } + + public void setDeptCode(String deptCode) { + this.deptCode = deptCode; + } + + public String getDeptName() { + return deptName; + } + + public void setDeptName(String deptName) { + this.deptName = deptName; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public String getAncestors() { + return ancestors; + } + + public void setAncestors(String ancestors) { + this.ancestors = ancestors; + } + + public Integer getLevel() { + return level; + } + + public void setLevel(Integer level) { + this.level = level; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Long getLeaderId() { + return leaderId; + } + + public void setLeaderId(Long leaderId) { + this.leaderId = leaderId; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } +} diff --git a/backend/src/main/java/com/smartoffice/organization/DepartmentRepository.java b/backend/src/main/java/com/smartoffice/organization/DepartmentRepository.java new file mode 100644 index 0000000..8db344b --- /dev/null +++ b/backend/src/main/java/com/smartoffice/organization/DepartmentRepository.java @@ -0,0 +1,6 @@ +package com.smartoffice.organization; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DepartmentRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/smartoffice/user/CreateUserRequest.java b/backend/src/main/java/com/smartoffice/user/CreateUserRequest.java new file mode 100644 index 0000000..879c4a6 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/CreateUserRequest.java @@ -0,0 +1,72 @@ +package com.smartoffice.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class CreateUserRequest { + @NotBlank + private String username; + + @NotBlank + private String password; + + @NotBlank + private String fullName; + + @Email + private String email; + + private String phone; + + @NotNull + private Role role; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public Role getRole() { + return role; + } + + public void setRole(Role role) { + this.role = role; + } +} diff --git a/backend/src/main/java/com/smartoffice/user/Role.java b/backend/src/main/java/com/smartoffice/user/Role.java new file mode 100644 index 0000000..03ca2b0 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/Role.java @@ -0,0 +1,7 @@ +package com.smartoffice.user; + +public enum Role { + ADMIN, + MANAGER, + EMPLOYEE +} diff --git a/backend/src/main/java/com/smartoffice/user/UpdateUserStatusRequest.java b/backend/src/main/java/com/smartoffice/user/UpdateUserStatusRequest.java new file mode 100644 index 0000000..7dc050a --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/UpdateUserStatusRequest.java @@ -0,0 +1,16 @@ +package com.smartoffice.user; + +import jakarta.validation.constraints.NotNull; + +public class UpdateUserStatusRequest { + @NotNull + private UserStatus status; + + public UserStatus getStatus() { + return status; + } + + public void setStatus(UserStatus status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/smartoffice/user/User.java b/backend/src/main/java/com/smartoffice/user/User.java new file mode 100644 index 0000000..15b25a1 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/User.java @@ -0,0 +1,147 @@ +package com.smartoffice.user; + +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity +@Table(name = "users") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false) + private String passwordHash; + + @Column(nullable = false) + private String fullName; + + private String email; + + private String phone; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UserStatus status = UserStatus.ACTIVE; + + private int failedLoginAttempts; + + private Instant lockedUntil; + + private Instant lastLoginAt; + + private Instant createdAt; + + private Instant updatedAt; + + @PrePersist + void onCreate() { + this.createdAt = Instant.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public Role getRole() { + return role; + } + + public void setRole(Role role) { + this.role = role; + } + + public UserStatus getStatus() { + return status; + } + + public void setStatus(UserStatus status) { + this.status = status; + } + + public int getFailedLoginAttempts() { + return failedLoginAttempts; + } + + public void setFailedLoginAttempts(int failedLoginAttempts) { + this.failedLoginAttempts = failedLoginAttempts; + } + + public Instant getLockedUntil() { + return lockedUntil; + } + + public void setLockedUntil(Instant lockedUntil) { + this.lockedUntil = lockedUntil; + } + + public Instant getLastLoginAt() { + return lastLoginAt; + } + + public void setLastLoginAt(Instant lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/smartoffice/user/UserController.java b/backend/src/main/java/com/smartoffice/user/UserController.java new file mode 100644 index 0000000..e177811 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/UserController.java @@ -0,0 +1,42 @@ +package com.smartoffice.user; + +import com.smartoffice.common.ApiResponse; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/users") +public class UserController { + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> list() { + return ApiResponse.ok(userService.list()); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(userService.get(id)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse create(@Valid @RequestBody CreateUserRequest request) { + return ApiResponse.created(userService.create(request)); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse updateStatus(@PathVariable Long id, @Valid @RequestBody UpdateUserStatusRequest request) { + return ApiResponse.ok(userService.updateStatus(id, request)); + } +} diff --git a/backend/src/main/java/com/smartoffice/user/UserDto.java b/backend/src/main/java/com/smartoffice/user/UserDto.java new file mode 100644 index 0000000..ba0e0de --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/UserDto.java @@ -0,0 +1,27 @@ +package com.smartoffice.user; + +import java.time.Instant; + +public record UserDto( + Long id, + String username, + String fullName, + String email, + String phone, + Role role, + UserStatus status, + Instant lastLoginAt +) { + public static UserDto from(User user) { + return new UserDto( + user.getId(), + user.getUsername(), + user.getFullName(), + user.getEmail(), + user.getPhone(), + user.getRole(), + user.getStatus(), + user.getLastLoginAt() + ); + } +} diff --git a/backend/src/main/java/com/smartoffice/user/UserRepository.java b/backend/src/main/java/com/smartoffice/user/UserRepository.java new file mode 100644 index 0000000..7eda251 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.smartoffice.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + boolean existsByUsername(String username); +} diff --git a/backend/src/main/java/com/smartoffice/user/UserService.java b/backend/src/main/java/com/smartoffice/user/UserService.java new file mode 100644 index 0000000..dffd767 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/UserService.java @@ -0,0 +1,53 @@ +package com.smartoffice.user; + +import com.smartoffice.common.ApiException; +import jakarta.transaction.Transactional; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + public List list() { + return userRepository.findAll().stream().map(UserDto::from).toList(); + } + + public UserDto get(Long id) { + return userRepository.findById(id).map(UserDto::from) + .orElseThrow(() -> new ApiException(404, "User not found")); + } + + @Transactional + public UserDto create(CreateUserRequest request) { + if (userRepository.existsByUsername(request.getUsername())) { + throw new ApiException(409, "Username already exists"); + } + User user = new User(); + user.setUsername(request.getUsername()); + user.setPasswordHash(passwordEncoder.encode(request.getPassword())); + user.setFullName(request.getFullName()); + user.setEmail(request.getEmail()); + user.setPhone(request.getPhone()); + user.setRole(request.getRole()); + user.setStatus(UserStatus.ACTIVE); + userRepository.save(user); + return UserDto.from(user); + } + + @Transactional + public UserDto updateStatus(Long id, UpdateUserStatusRequest request) { + User user = userRepository.findById(id) + .orElseThrow(() -> new ApiException(404, "User not found")); + user.setStatus(request.getStatus()); + return UserDto.from(user); + } +} diff --git a/backend/src/main/java/com/smartoffice/user/UserStatus.java b/backend/src/main/java/com/smartoffice/user/UserStatus.java new file mode 100644 index 0000000..9992342 --- /dev/null +++ b/backend/src/main/java/com/smartoffice/user/UserStatus.java @@ -0,0 +1,7 @@ +package com.smartoffice.user; + +public enum UserStatus { + ACTIVE, + DISABLED, + LOCKED +} diff --git a/backend/src/main/resources/application-mysql.yml b/backend/src/main/resources/application-mysql.yml new file mode 100644 index 0000000..15f891b --- /dev/null +++ b/backend/src/main/resources/application-mysql.yml @@ -0,0 +1,9 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/smart_office?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: root + jpa: + hibernate: + ddl-auto: update diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..b108388 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,41 @@ +server: + port: 8080 + +spring: + application: + name: smart-office-backend + datasource: + url: jdbc:h2:mem:smartoffice;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + open-in-view: false + h2: + console: + enabled: true + path: /h2 + +app: + security: + jwt: + secret: "change-me-to-a-long-random-secret" + expiration-minutes: 1440 + lockout: + max-attempts: 5 + lock-minutes: 30 + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger + +logging: + level: + org.springframework.security: INFO diff --git a/backend/src/test/java/com/smartoffice/ApiFlowTest.java b/backend/src/test/java/com/smartoffice/ApiFlowTest.java new file mode 100644 index 0000000..9dcda05 --- /dev/null +++ b/backend/src/test/java/com/smartoffice/ApiFlowTest.java @@ -0,0 +1,106 @@ +package com.smartoffice; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class ApiFlowTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void login_returns_token_and_user() throws Exception { + JsonNode response = login("admin", "admin123"); + assertThat(response.path("data").path("token").asText()).isNotBlank(); + assertThat(response.path("data").path("user").path("username").asText()).isEqualTo("admin"); + } + + @Test + void check_in_creates_attendance_record() throws Exception { + String token = tokenFor("employee", "employee123"); + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("location", "HQ"); + + mockMvc.perform(post("/api/v1/attendance/check-in") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + } + + @Test + void manager_can_approve_leave_request() throws Exception { + String employeeToken = tokenFor("employee", "employee123"); + ObjectNode leaveRequest = objectMapper.createObjectNode(); + Instant start = Instant.now().plusSeconds(3600); + leaveRequest.put("type", "ANNUAL"); + leaveRequest.put("startTime", start.toString()); + leaveRequest.put("endTime", start.plusSeconds(3600).toString()); + leaveRequest.put("reason", "Family matters"); + + String createdResponse = mockMvc.perform(post("/api/v1/leave-requests") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + employeeToken) + .content(objectMapper.writeValueAsString(leaveRequest))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode createdJson = objectMapper.readTree(createdResponse); + Long leaveId = createdJson.path("data").path("id").asLong(); + + String managerToken = tokenFor("manager", "manager123"); + ObjectNode decision = objectMapper.createObjectNode(); + decision.put("note", "Approved"); + + String approvedResponse = mockMvc.perform(post("/api/v1/leave-requests/" + leaveId + "/approve") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + managerToken) + .content(objectMapper.writeValueAsString(decision))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode approvedJson = objectMapper.readTree(approvedResponse); + assertThat(approvedJson.path("data").path("status").asText()).isEqualTo("APPROVED"); + } + + private String tokenFor(String username, String password) throws Exception { + return login(username, password).path("data").path("token").asText(); + } + + private JsonNode login(String username, String password) throws Exception { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("username", username); + payload.put("password", password); + + String response = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + return objectMapper.readTree(response); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9251d15 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_DATABASE: smart_office + MYSQL_ROOT_PASSWORD: root + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + depends_on: + mysql: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: mysql + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/smart_office?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: root + ports: + - "8080:8080" + +volumes: + mysql_data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9a18467 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Smart Office + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..07f4fdc --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1768 @@ +{ + "name": "smart-office", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smart-office", + "version": "0.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.4.3", + "vite": "^5.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", + "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", + "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", + "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", + "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", + "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", + "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", + "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", + "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", + "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", + "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", + "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", + "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", + "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", + "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", + "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", + "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", + "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", + "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", + "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", + "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.279", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", + "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", + "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.0", + "@rollup/rollup-android-arm64": "4.57.0", + "@rollup/rollup-darwin-arm64": "4.57.0", + "@rollup/rollup-darwin-x64": "4.57.0", + "@rollup/rollup-freebsd-arm64": "4.57.0", + "@rollup/rollup-freebsd-x64": "4.57.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", + "@rollup/rollup-linux-arm-musleabihf": "4.57.0", + "@rollup/rollup-linux-arm64-gnu": "4.57.0", + "@rollup/rollup-linux-arm64-musl": "4.57.0", + "@rollup/rollup-linux-loong64-gnu": "4.57.0", + "@rollup/rollup-linux-loong64-musl": "4.57.0", + "@rollup/rollup-linux-ppc64-gnu": "4.57.0", + "@rollup/rollup-linux-ppc64-musl": "4.57.0", + "@rollup/rollup-linux-riscv64-gnu": "4.57.0", + "@rollup/rollup-linux-riscv64-musl": "4.57.0", + "@rollup/rollup-linux-s390x-gnu": "4.57.0", + "@rollup/rollup-linux-x64-gnu": "4.57.0", + "@rollup/rollup-linux-x64-musl": "4.57.0", + "@rollup/rollup-openbsd-x64": "4.57.0", + "@rollup/rollup-openharmony-arm64": "4.57.0", + "@rollup/rollup-win32-arm64-msvc": "4.57.0", + "@rollup/rollup-win32-ia32-msvc": "4.57.0", + "@rollup/rollup-win32-x64-gnu": "4.57.0", + "@rollup/rollup-win32-x64-msvc": "4.57.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fe1d7df --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "smart-office", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.4.3", + "vite": "^5.2.0" + } +} diff --git a/frontend/public/login-bg.jpg b/frontend/public/login-bg.jpg new file mode 100644 index 0000000..3d3b81c Binary files /dev/null and b/frontend/public/login-bg.jpg differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..4b36480 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Routes, Route } from 'react-router-dom'; +import RequireAuth from './components/RequireAuth'; +import Layout from './components/Layout'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import Attendance from './pages/Attendance'; +import Leave from './pages/Leave'; +import Approvals from './pages/Approvals'; +import Users from './pages/Users'; +import Notifications from './pages/Notifications'; + +export default function App() { + return ( + + } /> + + + + + } + > + } /> + } /> + } /> + + + + } + /> + + + + } + /> + } /> + + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..fe7e921 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { NavLink, Outlet } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; +import { Role } from '../types'; + +const NAV = [ + { to: '/', label: '概览', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] }, + { to: '/attendance', label: '考勤', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] }, + { to: '/leave', label: '请假', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] }, + { to: '/approvals', label: '审批', roles: ['ADMIN', 'MANAGER'] as Role[] }, + { to: '/users', label: '用户管理', roles: ['ADMIN', 'MANAGER'] as Role[] }, + { to: '/notifications', label: '通知', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] }, +]; + +export default function Layout() { + const { user, logout } = useAuth(); + const role = user?.role; + + return ( +
+ + +
+
+
智能办公管理系统
+
+ {user?.role} +
+
{user?.fullName}
+
@{user?.username}
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx new file mode 100644 index 0000000..df306ea --- /dev/null +++ b/frontend/src/components/RequireAuth.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { Role } from '../types'; +import { useAuth } from '../lib/auth'; + +export default function RequireAuth({ + roles, + children, +}: { + roles?: Role[]; + children: React.ReactElement; +}) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) return
加载中...
; + if (!user) return ; + if (roles && !roles.includes(user.role)) return ; + return children; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..5fe69a5 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,49 @@ +import { ApiResponse } from '../types'; + +const API_BASE = import.meta.env.VITE_API_BASE ?? '/api/v1'; +const TOKEN_KEY = 'smartoffice_token'; + +async function request(path: string, options: RequestInit = {}) { + const token = localStorage.getItem(TOKEN_KEY); + const headers: Record = { + ...(options.headers as Record | undefined), + }; + + if (options.body && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }); + + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const json = (await res.json()) as ApiResponse; + if (!res.ok || json.code !== 200) { + throw new Error(json.message || `HTTP ${res.status}`); + } + return json.data; + } + + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), + patch: (path: string, body?: unknown) => + request(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }), +}; + +export const storage = { + token: () => localStorage.getItem(TOKEN_KEY), + setToken: (token: string) => localStorage.setItem(TOKEN_KEY, token), + clearToken: () => localStorage.removeItem(TOKEN_KEY), +}; diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..f494a51 --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,71 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { api, storage } from './api'; +import { Role, UserDto } from '../types'; + +const USER_KEY = 'smartoffice_user'; + +type AuthState = { + user: UserDto | null; + role: Role | null; + loading: boolean; + login: (username: string, password: string) => Promise; + logout: () => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(() => { + const raw = localStorage.getItem(USER_KEY); + return raw ? (JSON.parse(raw) as UserDto) : null; + }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = storage.token(); + if (!token) { + setLoading(false); + return; + } + if (user) { + setLoading(false); + return; + } + api + .get('/auth/me') + .then((me) => { + setUser(me); + localStorage.setItem(USER_KEY, JSON.stringify(me)); + }) + .finally(() => setLoading(false)); + }, [user]); + + const login = async (username: string, password: string) => { + const data = await api.post<{ token: string; user: UserDto }>('/auth/login', { + username, + password, + }); + storage.setToken(data.token); + localStorage.setItem(USER_KEY, JSON.stringify(data.user)); + setUser(data.user); + }; + + const logout = () => { + storage.clearToken(); + localStorage.removeItem(USER_KEY); + setUser(null); + }; + + const value = useMemo( + () => ({ user, role: user?.role ?? null, loading, login, logout }), + [user, loading] + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('AuthProvider missing'); + return ctx; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1cd0e2d --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { AuthProvider } from './lib/auth'; +import App from './App'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +); diff --git a/frontend/src/pages/Approvals.tsx b/frontend/src/pages/Approvals.tsx new file mode 100644 index 0000000..a24401c --- /dev/null +++ b/frontend/src/pages/Approvals.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useState } from 'react'; +import { api } from '../lib/api'; +import { LeaveDto } from '../types'; + +export default function Approvals() { + const [list, setList] = useState([]); + const [notes, setNotes] = useState>({}); + + const load = () => api.get('/leave-requests/pending').then(setList); + + useEffect(() => { + load(); + }, []); + + const act = async (id: number, action: 'approve' | 'reject') => { + await api.post(`/leave-requests/${id}/${action}`, { + note: notes[id] || '', + }); + load(); + }; + + return ( +
+
+

审批

+

待审批的请假请求

+
+ +
+ + + + + + + + + + + + + {list.map((l) => ( + + + + + + + + + ))} + +
申请人类型时间原因备注操作
{l.requester.fullName}{l.type} + {new Date(l.startTime).toLocaleString()} → {new Date(l.endTime).toLocaleString()} + {l.reason} + setNotes({ ...notes, [l.id]: e.target.value })} + /> + + + +
+ + {!list.length &&
暂无待审批记录
} +
+
+ ); +} diff --git a/frontend/src/pages/Attendance.tsx b/frontend/src/pages/Attendance.tsx new file mode 100644 index 0000000..4d1c147 --- /dev/null +++ b/frontend/src/pages/Attendance.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; +import { api } from '../lib/api'; +import { AttendanceDto } from '../types'; + +export default function Attendance() { + const [records, setRecords] = useState([]); + const [location, setLocation] = useState('HQ'); + const [loading, setLoading] = useState(true); + const [action, setAction] = useState(''); + + const load = () => + api.get('/attendance/me').then((data) => { + setRecords(data); + setLoading(false); + }); + + useEffect(() => { + load(); + }, []); + + const checkIn = async () => { + setAction('checkin'); + await api.post('/attendance/check-in', { location }); + await load(); + setAction(''); + }; + + const checkOut = async () => { + setAction('checkout'); + await api.post('/attendance/check-out'); + await load(); + setAction(''); + }; + + return ( +
+
+

考勤

+

打卡与个人记录

+
+ +
+
打卡
+
+ setLocation(e.target.value)} /> + + +
+
+ +
+
我的考勤记录
+ {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + + + + + {records.map((r) => ( + + + + + + + + + ))} + +
日期签到签退工时地点状态
{r.date}{r.checkInTime || '-'}{r.checkOutTime || '-'}{r.workHours}{r.location || '-'} + {r.status} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..25c449c --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useState } from 'react'; +import { api } from '../lib/api'; +import { AttendanceDto, LeaveDto, NotificationDto } from '../types'; + +export default function Dashboard() { + const [attendance, setAttendance] = useState([]); + const [leaves, setLeaves] = useState([]); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + api.get('/attendance/me'), + api.get('/leave-requests/my'), + api.get('/notifications'), + ]) + .then(([a, l, n]) => { + setAttendance(a); + setLeaves(l); + setNotifications(n); + }) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+

概览

+

关键指标一览与近期动态

+
+ +
+
+
本月考勤记录
+
{attendance.length}
+
来自个人记录
+
+
+
我的请假申请
+
{leaves.length}
+
当前与历史
+
+
+
通知
+
{notifications.length}
+
含未读与已读
+
+
+ +
+
+
最近考勤
+ {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + + + {attendance.slice(0, 5).map((a) => ( + + + + + + + ))} + +
日期签到签退工时
{a.date}{a.checkInTime || '-'}{a.checkOutTime || '-'}{a.workHours}
+ )} +
+ +
+
最近通知
+
+ {notifications.slice(0, 5).map((n) => ( +
+
{n.title}
+
{n.content}
+
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Leave.tsx b/frontend/src/pages/Leave.tsx new file mode 100644 index 0000000..ee6a301 --- /dev/null +++ b/frontend/src/pages/Leave.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from 'react'; +import { api } from '../lib/api'; +import { LeaveDto, LeaveType } from '../types'; + +const TYPES: LeaveType[] = ['ANNUAL', 'SICK', 'PERSONAL', 'MARRIAGE', 'MATERNITY', 'BEREAVEMENT']; + +export default function Leave() { + const [list, setList] = useState([]); + const [type, setType] = useState('ANNUAL'); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [reason, setReason] = useState(''); + + const load = () => api.get('/leave-requests/my').then(setList); + + useEffect(() => { + load(); + }, []); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + await api.post('/leave-requests', { + type, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + reason, + }); + setReason(''); + setStartTime(''); + setEndTime(''); + load(); + }; + + return ( +
+
+

请假

+

提交申请与查看记录

+
+ +
+
+
提交请假
+
+ + + +