commit f0c30d8005af9e97245f1770bad1f60a72c4664c Author: unknown Date: Fri Jul 3 08:59:41 2026 +0900 Initial commit: IT센터 출입자관리시스템 (ACS) 방문자 사전신청·승인, 입·출입 체크인/아웃, QR 배지, 재실현황, 블랙리스트, 대시보드 통계, 방문 리포트(엑셀)까지 7단계 전 기능 구현. - backend: Spring Boot 3.4.5 / Java 21 (JDK 26 빌드), 세션 인증, JPA, H2/PostgreSQL, POI, ZXing, Flyway - frontend: React 19 / Vite 6 / TypeScript - infra: Docker Compose (db·app·web nginx), Flyway V1__init, Python 사용자 시드 - docs: 워크플로우 / 시퀀스 다이어그램(Mermaid) / 이슈·유의사항 Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3353e27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Java / Maven +backend/target/ +*.class + +# Node / Vite +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# IDE / OS +.idea/ +.vscode/ +*.iml +.DS_Store +Thumbs.db + +# Local env +infra/.env + +# Editor/EDR leftover temp files (see docs/issues-and-guidelines.md §2-3) +*.tmp.* + +# Generated pass images (dev SMS outbox) +backend/sms-outbox/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d91cdb --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# IT센터 출입자관리시스템 (Access Control System) + +IT센터 방문자/출입자 관리 웹 시스템. 방문 사전신청·승인, 입·출입 체크인, QR/배지 발급, +통계·리포트·블랙리스트를 목표로 하며, 출입통제 하드웨어는 추후 연동 가능하도록 추상화한다. + +## 기술 스택 +- **백엔드**: Spring Boot 3.4.5 / Java 21 (JDK 26 빌드) · Spring Security(세션) · JPA · H2(dev)·PostgreSQL(prod) · POI · ZXing · Flyway +- **프론트엔드**: React 19 · Vite 6 · TypeScript · react-router 7 +- **포트**: API `8080`, 웹 `5173` + +## 역할 +| 역할 | 권한 | +|------|------| +| `ADMIN` | 전체 관리, 사용자/블랙리스트/리포트, 모든 승인 | +| `SECURITY` | 입·출입 콘솔(체크인/아웃), 재실현황, 배지 발급 | +| `HOST` | 방문 사전신청 등록, 담당 방문 승인 | + +## 실행 (로컬 개발, H2) +```cmd +:: 백엔드 (http://localhost:8080) +run-backend.cmd + +:: 프론트엔드 (http://localhost:5173) +run-frontend.cmd +``` +> 두 스크립트는 `C:\ai-dev\scripts\env.cmd`(포터블 JDK/Maven/Node)를 먼저 로드한다. + +### 초기 계정 (DataSeeder, dev 전용) +| 아이디 | 비밀번호 | 역할 | +|--------|----------|------| +| admin | ChangeMe123! | ADMIN | +| security | ChangeMe123! | SECURITY | +| host | ChangeMe123! | HOST | + +최초 로그인 시 비밀번호 변경이 요구된다. + +## 주요 API +- `POST /api/auth/login` · `POST /api/auth/logout` · `GET /api/auth/me` · `POST /api/auth/change-password` +- `GET /api/zones` +- `GET/POST /api/visit-requests` · `GET /api/visit-requests/pending` · `POST /api/visit-requests/{id}/cancel` · `POST /api/visit-requests/upload`(엑셀) +- `POST /api/approvals/{id}/approve` · `POST /api/approvals/{id}/reject` +- `POST /api/access/check-in` · `POST /api/access/check-out` · `GET /api/access/inside` · `GET /api/access/search?q=` +- `GET /api/passes/{id}` · `GET /api/passes/{id}/qr.png`(배지 QR) +- `GET /api/stats/summary` · `GET /api/reports/visits.xlsx?from=&to=` +- `GET/POST /api/blacklist` · `DELETE /api/blacklist/{id}` (ADMIN) + +API 스모크 테스트는 [backend/test-api.http](backend/test-api.http) 참고. + +## 구현 현황 +- [x] **1단계** 스캐폴딩 (pom, 설정, 공통 클래스, 전역 예외/응답 래퍼, JPA auditing) +- [x] **2단계** 인증·인가 (세션 로그인, 역할 기반 권한, 시드) +- [x] **3단계** 방문 사전신청 + 승인 (신청/취소/엑셀 업로드, 승인/반려 시 qrToken 발급) + 프론트 화면 +- [x] **4단계** 입·출입 체크인/체크아웃 + 재실현황 (`AccessEvent`, `AccessControlGateway`+Mock, QR/이름 체크인, 재실 목록, 중복입장·만료 검증) + 출입콘솔 화면 +- [x] **5단계** QR/배지 발급 (ZXing PNG `/api/passes/{id}/qr.png`, 배지 인쇄 화면) +- [x] **6단계** 블랙리스트(체크인 시 차단) + 대시보드 통계 집계 + 방문 리포트 엑셀(POI) 다운로드 +- [x] **7단계** Flyway `V1__init.sql`(prod) + Docker Compose(db·app·web nginx) + Python 사용자 시드 + +## Docker 실행 (prod, PostgreSQL) +```bash +cd infra +cp .env.example .env # 값 수정 (아래 참고) +docker compose up -d --build # db + app(:8080) + web(nginx :80) +docker compose run --rm seed # 사용자 시드 적재 (admin/security/host) +docker compose logs -f app # 기동/마이그레이션 로그 +``` +- 백엔드는 `prod` 프로파일로 기동, Flyway가 스키마를 생성/검증한다. +- 웹(nginx)이 정적 파일 + `/api` 리버스 프록시를 담당한다. +- DB는 named volume(`db_data`)에 영속 — 재기동해도 데이터 유지(dev H2와 다름). + +### .env 주요 값 +| 변수 | 설명 | +|------|------| +| `POSTGRES_PASSWORD` | DB 비밀번호 (실제값으로 변경) | +| `WEB_PORT` | 웹 공개 포트 (기본 80) | +| `ACS_SMS_PROVIDER` | `dev`(로그만) / `hanbank`(사내 API로 LMS 실발송) | +| `ACS_PUBLIC_BASE_URL` | 문자 링크가 가리키는 주소 — **방문자 휴대폰에서 접속 가능한 실제 URL** (localhost 금지) | +| `ACS_SMS_API_URL` | 사내 메시지 API 주소 (기본 `http://210.104.132.59:8000`) | + +### 문자 실발송(hanbank) 전제 +1. 서버에서 메시지 API 도달 확인: `nc -vz 210.104.132.59 8000` +2. `ACS_SMS_PROVIDER=hanbank`, `ACS_PUBLIC_BASE_URL`=외부 접속 URL 로 설정 후 재기동 + +### HTTPS / 카메라 스캔 (선택, 접속 방식에 따라 필요) +- 출입콘솔의 **웹캠 QR 스캔**은 secure context(HTTPS 또는 localhost)에서만 동작한다. + 서버를 `http://내부IP`로 접속하면 카메라가 차단된다. +- 방문자 공개 링크(`/pass/{token}`)도 HTTPS 도메인이면 휴대폰에서 안전하게 열린다. +- 적용하려면: 사내 도메인/인증서 확보 → `frontend/nginx.conf`에 443 TLS server 블록 추가, + `docker-compose.yml` web 서비스에 `"443:443"` 매핑 + 인증서 볼륨 마운트, + `ACS_PUBLIC_BASE_URL`을 `https://...`로 설정. (도메인/인증서 확정 후 진행) + +### 사내망 빌드 참고 +`docker compose build`는 컨테이너 안에서 npm/maven 의존성을 받는다. dev 서버는 공개망 직접 +접근이 되어 대개 그대로 성공하지만, 사내 SSL 인스펙션에 걸려 인증서 오류가 나면 Dockerfile에 +사내 CA 주입 또는 내부 미러 설정이 필요하다(그때 별도 반영). + +## 디렉터리 +``` +backend/ Spring Boot (com.itcenter.acs) +frontend/ React + Vite +docs/ 워크플로우·시퀀스 다이어그램·이슈/유의사항 문서 +``` + +## 문서 +- [docs/workflow.md](docs/workflow.md) — 전체 업무 워크플로우(서술형) +- [docs/workflow-sequence.md](docs/workflow-sequence.md) — 시퀀스/상태 다이어그램(Mermaid) +- [docs/issues-and-guidelines.md](docs/issues-and-guidelines.md) — 이슈 정리 및 유의사항(규칙) + +## 빌드 노트 +- JDK 26 환경에서 Lombok은 **1.18.46** + maven-compiler-plugin `annotationProcessorPaths` 설정이 필요하다 + (Spring Boot가 핀한 1.18.38은 JDK 26에서 애너테이션 처리가 동작하지 않음). `backend/pom.xml`에 반영됨. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1ebf457 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +target/ +*.iml +.idea/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..45376f2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +# ===== build ===== +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /workspace +COPY pom.xml ./ +RUN mvn -B -ntp dependency:go-offline || true +COPY src ./src +RUN mvn -B -ntp -DskipTests package + +# ===== run ===== +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /workspace/target/*.jar /app/app.jar +EXPOSE 8080 +ENV SPRING_PROFILES_ACTIVE=prod +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..14a07cb --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,138 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.5 + + + + com.itcenter + acs + 0.0.1-SNAPSHOT + acs + IT Center Access Control System (visitor / access management) + + + 21 + + 1.18.46 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + + org.postgresql + postgresql + runtime + + + com.h2database + h2 + runtime + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + + org.apache.poi + poi + 5.2.3 + + + org.apache.poi + poi-ooxml + 5.2.3 + + + + + com.google.zxing + core + 3.5.3 + + + com.google.zxing + javase + 3.5.3 + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/backend/src/main/java/com/itcenter/acs/AcsApplication.java b/backend/src/main/java/com/itcenter/acs/AcsApplication.java new file mode 100644 index 0000000..85f6d7c --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/AcsApplication.java @@ -0,0 +1,11 @@ +package com.itcenter.acs; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AcsApplication { + public static void main(String[] args) { + SpringApplication.run(AcsApplication.class, args); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/config/DataSeeder.java b/backend/src/main/java/com/itcenter/acs/config/DataSeeder.java new file mode 100644 index 0000000..c9cf5a4 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/config/DataSeeder.java @@ -0,0 +1,73 @@ +package com.itcenter.acs.config; + +import com.itcenter.acs.entity.RoleType; +import com.itcenter.acs.entity.User; +import com.itcenter.acs.entity.Zone; +import com.itcenter.acs.repository.UserRepository; +import com.itcenter.acs.repository.ZoneRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * Seeds initial accounts and zones for local/dev so the app is usable out of the box. + * Idempotent: only inserts when the username/zone code is missing. + * Disabled in prod, where Flyway + the Python CSV seeder own initial data instead. + */ +@Slf4j +@Component +@Profile("!prod") +@RequiredArgsConstructor +public class DataSeeder implements CommandLineRunner { + + private final UserRepository userRepository; + private final ZoneRepository zoneRepository; + private final PasswordEncoder passwordEncoder; + + private static final String DEFAULT_PASSWORD = "ChangeMe123!"; + + @Override + public void run(String... args) { + seedUser("admin", "관리자", "IT운영팀", Set.of(RoleType.ADMIN)); + seedUser("security", "보안담당", "보안팀", Set.of(RoleType.SECURITY)); + seedUser("host", "홍길동", "개발1팀", Set.of(RoleType.HOST)); + + seedZone("LOBBY", "로비", 1); + seedZone("OFFICE", "사무공간", 2); + seedZone("SERVER_ROOM", "전산실", 3); + } + + private void seedUser(String username, String fullName, String department, Set roles) { + if (userRepository.existsByUsername(username)) { + return; + } + User user = new User(); + user.setUsername(username); + user.setPasswordHash(passwordEncoder.encode(DEFAULT_PASSWORD)); + user.setFullName(fullName); + user.setDepartment(department); + user.setEmail(username + "@itcenter.local"); + user.setRoles(roles); + user.setMustChangePassword(true); + userRepository.save(user); + log.info("[seed] user '{}' created (default password: {})", username, DEFAULT_PASSWORD); + } + + private void seedZone(String code, String name, int level) { + if (zoneRepository.findByCode(code).isPresent()) { + return; + } + Zone zone = new Zone(); + zone.setCode(code); + zone.setName(name); + zone.setSecurityLevel(level); + zone.setActive(true); + zoneRepository.save(zone); + log.info("[seed] zone '{}' ({}) created", code, name); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/config/GlobalExceptionHandler.java b/backend/src/main/java/com/itcenter/acs/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..4450373 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/config/GlobalExceptionHandler.java @@ -0,0 +1,54 @@ +package com.itcenter.acs.config; + +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.exception.ApiException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +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.stream.Collectors; + +/** + * Translates exceptions into the standard ApiResponse envelope so the + * frontend always receives { code, message, data }. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ApiException.class) + public ResponseEntity> handleApi(ApiException ex) { + return ResponseEntity.status(ex.getCode()) + .body(ApiResponse.error(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + return ResponseEntity.badRequest().body(ApiResponse.error(400, message)); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity> handleBadCredentials(BadCredentialsException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(401, "아이디 또는 비밀번호가 올바르지 않습니다.")); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(403, "접근 권한이 없습니다.")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(500, "서버 오류: " + ex.getMessage())); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/config/JpaConfig.java b/backend/src/main/java/com/itcenter/acs/config/JpaConfig.java new file mode 100644 index 0000000..a018690 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/config/JpaConfig.java @@ -0,0 +1,12 @@ +package com.itcenter.acs.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * Enables @CreatedDate / @LastModifiedDate population on BaseEntity. + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/backend/src/main/java/com/itcenter/acs/config/SecurityConfig.java b/backend/src/main/java/com/itcenter/acs/config/SecurityConfig.java new file mode 100644 index 0000000..caa0acf --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/config/SecurityConfig.java @@ -0,0 +1,106 @@ +package com.itcenter.acs.config; + +import com.itcenter.acs.dto.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +/** + * Session-based authentication with role authorization. + * Roles: ADMIN (all), SECURITY (access console), HOST (requests/approvals). + */ +@Configuration +public class SecurityConfig { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityContextRepository securityContextRepository() { + return new HttpSessionSecurityContextRepository(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> {}) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authz -> authz + // public + .requestMatchers("/api/auth/login", "/api/auth/logout").permitAll() + .requestMatchers("/", "/health", "/error").permitAll() + .requestMatchers("/h2-console/**").permitAll() + // public visitor pass (opened from the SMS link, token-guarded) + .requestMatchers("/api/public/**").permitAll() + // staff access console (search + force check-in/out) — 담당자(HOST)/보안/관리자 + .requestMatchers("/api/access/**").hasAnyRole("HOST", "SECURITY", "ADMIN") + // approvals — IT센터 관리자(admin) 전용 + .requestMatchers("/api/approvals/**").hasRole("ADMIN") + // blacklist & admin management — admin only + .requestMatchers("/api/blacklist/**", "/api/admin/**").hasRole("ADMIN") + // reports — security & admin + .requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN") + // everything else requires a logged-in user + .anyRequest().authenticated() + ) + // H2 console renders in a frame + .headers(h -> h.frameOptions(f -> f.sameOrigin())) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .securityContext(sc -> sc.securityContextRepository(securityContextRepository())) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((req, res, e) -> writeError(res, 401, "로그인이 필요합니다.")) + .accessDeniedHandler((req, res, e) -> writeError(res, 403, "접근 권한이 없습니다.")) + ); + + return http.build(); + } + + private void writeError(HttpServletResponse res, int code, String message) throws java.io.IOException { + res.setStatus(code); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + res.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(res.getWriter(), ApiResponse.error(code, message)); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of( + "http://localhost:5173", + "http://127.0.0.1:5173" + )); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/AccessController.java b/backend/src/main/java/com/itcenter/acs/controller/AccessController.java new file mode 100644 index 0000000..8b7f8c9 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/AccessController.java @@ -0,0 +1,65 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.AccessActionResponse; +import com.itcenter.acs.dto.AccessRecordResponse; +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.CheckInRequest; +import com.itcenter.acs.dto.InsideVisitorResponse; +import com.itcenter.acs.dto.VisitRequestResponse; +import com.itcenter.acs.security.SecurityUtils; +import com.itcenter.acs.service.AccessService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Access console for SECURITY/ADMIN: check-in/out and currently-inside list. + * Authorized by SecurityConfig (/api/access/** = SECURITY or ADMIN). + */ +@RestController +@RequestMapping("/api/access") +@RequiredArgsConstructor +public class AccessController { + + private final AccessService accessService; + + @PostMapping("/check-in") + public ResponseEntity> checkIn(@RequestBody CheckInRequest request) { + AccessActionResponse res = accessService.checkIn(request, SecurityUtils.currentUserId()); + return ResponseEntity.ok(ApiResponse.success(res.getMessage(), res)); + } + + @PostMapping("/check-out") + public ResponseEntity> checkOut(@RequestBody CheckInRequest request) { + AccessActionResponse res = accessService.checkOut(request, SecurityUtils.currentUserId()); + return ResponseEntity.ok(ApiResponse.success(res.getMessage(), res)); + } + + @GetMapping("/inside") + public ResponseEntity>> inside() { + return ResponseEntity.ok(ApiResponse.success(accessService.listInside())); + } + + /** Today's access records (entered today, incl. those who left) for the staff console. */ + @GetMapping("/today") + public ResponseEntity>> today() { + return ResponseEntity.ok(ApiResponse.success(accessService.listTodayRecords())); + } + + /** Search approved requests by visitor name for manual check-in. */ + @GetMapping("/search") + public ResponseEntity>> search( + @RequestParam(value = "q", required = false) String q) { + List items = accessService.searchApproved(q).stream() + .map(VisitRequestResponse::from) + .toList(); + return ResponseEntity.ok(ApiResponse.success(items)); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/ApprovalController.java b/backend/src/main/java/com/itcenter/acs/controller/ApprovalController.java new file mode 100644 index 0000000..7086ebe --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/ApprovalController.java @@ -0,0 +1,44 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.ApprovalDecisionRequest; +import com.itcenter.acs.dto.VisitRequestResponse; +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.security.SecurityUtils; +import com.itcenter.acs.service.ApprovalService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Approve/reject visit requests. Restricted to HOST/ADMIN by SecurityConfig. + */ +@RestController +@RequestMapping("/api/approvals") +@RequiredArgsConstructor +public class ApprovalController { + + private final ApprovalService approvalService; + + @PostMapping("/{visitRequestId}/approve") + public ResponseEntity> approve( + @PathVariable Long visitRequestId, + @RequestBody(required = false) ApprovalDecisionRequest request) { + String comment = request != null ? request.getComment() : null; + VisitRequest vr = approvalService.approve(visitRequestId, SecurityUtils.currentUserId(), comment); + return ResponseEntity.ok(ApiResponse.success("승인되었습니다.", VisitRequestResponse.from(vr))); + } + + @PostMapping("/{visitRequestId}/reject") + public ResponseEntity> reject( + @PathVariable Long visitRequestId, + @RequestBody(required = false) ApprovalDecisionRequest request) { + String comment = request != null ? request.getComment() : null; + VisitRequest vr = approvalService.reject(visitRequestId, SecurityUtils.currentUserId(), comment); + return ResponseEntity.ok(ApiResponse.success("반려되었습니다.", VisitRequestResponse.from(vr))); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/AuthController.java b/backend/src/main/java/com/itcenter/acs/controller/AuthController.java new file mode 100644 index 0000000..48ab381 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/AuthController.java @@ -0,0 +1,109 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.ChangePasswordRequest; +import com.itcenter.acs.dto.CurrentUserResponse; +import com.itcenter.acs.dto.LoginRequest; +import com.itcenter.acs.entity.User; +import com.itcenter.acs.security.SecurityUtils; +import com.itcenter.acs.security.UserPrincipal; +import com.itcenter.acs.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Set; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final SecurityContextRepository securityContextRepository; + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody LoginRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); + + // Persist the authentication into the session-backed SecurityContext. + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, httpRequest, httpResponse); + + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + return ResponseEntity.ok(ApiResponse.success(toCurrentUser(principal))); + } + + @PostMapping("/logout") + public ResponseEntity> logout( + HttpServletRequest request, HttpServletResponse response) { + SecurityContextHolder.clearContext(); + if (request.getSession(false) != null) { + request.getSession(false).invalidate(); + } + return ResponseEntity.ok(ApiResponse.success("로그아웃되었습니다.")); + } + + @GetMapping("/me") + public ResponseEntity> me() { + return ResponseEntity.ok(ApiResponse.success(toCurrentUser(SecurityUtils.currentPrincipal()))); + } + + @PostMapping("/change-password") + public ResponseEntity> changePassword( + @Valid @RequestBody ChangePasswordRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + User updated = authService.changePassword(SecurityUtils.currentUserId(), + request.getOldPassword(), request.getNewPassword()); + + // Refresh the session principal so /me reflects mustChangePassword=false + // (the principal was captured at login and is otherwise stale). + UserPrincipal principal = new UserPrincipal(updated); + Authentication newAuth = new UsernamePasswordAuthenticationToken( + principal, null, principal.getAuthorities()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(newAuth); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, httpRequest, httpResponse); + + return ResponseEntity.ok(ApiResponse.success("비밀번호가 변경되었습니다.")); + } + + private CurrentUserResponse toCurrentUser(UserPrincipal principal) { + Set roles = principal.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .map(a -> a.replace("ROLE_", "")) + .collect(Collectors.toSet()); + return new CurrentUserResponse( + principal.getId(), + principal.getUsername(), + principal.getFullName(), + roles, + principal.isMustChangePassword()); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/BlacklistController.java b/backend/src/main/java/com/itcenter/acs/controller/BlacklistController.java new file mode 100644 index 0000000..f6edc85 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/BlacklistController.java @@ -0,0 +1,50 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.BlacklistRequest; +import com.itcenter.acs.dto.BlacklistResponse; +import com.itcenter.acs.security.SecurityUtils; +import com.itcenter.acs.service.BlacklistService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Blacklist management. Restricted to ADMIN by SecurityConfig (/api/blacklist/**). + */ +@RestController +@RequestMapping("/api/blacklist") +@RequiredArgsConstructor +public class BlacklistController { + + private final BlacklistService blacklistService; + + @GetMapping + public ResponseEntity>> list() { + List items = blacklistService.listActive().stream() + .map(BlacklistResponse::from) + .toList(); + return ResponseEntity.ok(ApiResponse.success(items)); + } + + @PostMapping + public ResponseEntity> add(@Valid @RequestBody BlacklistRequest request) { + return ResponseEntity.ok(ApiResponse.success( + BlacklistResponse.from(blacklistService.add(request, SecurityUtils.currentUserId())))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> deactivate(@PathVariable Long id) { + blacklistService.deactivate(id); + return ResponseEntity.ok(ApiResponse.success("차단이 해제되었습니다.")); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/HomeController.java b/backend/src/main/java/com/itcenter/acs/controller/HomeController.java new file mode 100644 index 0000000..8f81763 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/HomeController.java @@ -0,0 +1,20 @@ +package com.itcenter.acs.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class HomeController { + + @GetMapping("/") + public Map root() { + return Map.of("app", "IT Center Access Control System", "status", "ok"); + } + + @GetMapping("/health") + public Map health() { + return Map.of("status", "UP"); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/PassController.java b/backend/src/main/java/com/itcenter/acs/controller/PassController.java new file mode 100644 index 0000000..270c95b --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/PassController.java @@ -0,0 +1,55 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.VisitRequestResponse; +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.VisitStatus; +import com.itcenter.acs.exception.ApiException; +import com.itcenter.acs.service.QrService; +import com.itcenter.acs.service.VisitRequestService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Visitor pass/badge: metadata + QR PNG. The QR encodes the approved request's + * qrToken, which the access console scans for check-in. + */ +@RestController +@RequestMapping("/api/passes") +@RequiredArgsConstructor +public class PassController { + + private final VisitRequestService visitRequestService; + private final QrService qrService; + + /** Badge metadata (reuses the visit-request view, which carries the qrToken). */ + @GetMapping("/{id}") + public ResponseEntity> badge(@PathVariable Long id) { + VisitRequest vr = requireApproved(id); + return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(vr))); + } + + @GetMapping(value = "/{id}/qr.png", produces = MediaType.IMAGE_PNG_VALUE) + public ResponseEntity qr(@PathVariable Long id) { + VisitRequest vr = requireApproved(id); + byte[] png = qrService.pngForText(vr.getQrToken(), 240); + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .cacheControl(CacheControl.noCache()) + .body(png); + } + + private VisitRequest requireApproved(Long id) { + VisitRequest vr = visitRequestService.get(id); + if (vr.getStatus() != VisitStatus.APPROVED || vr.getQrToken() == null) { + throw ApiException.badRequest("승인되어 출입증이 발급된 방문만 배지를 볼 수 있습니다."); + } + return vr; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/PublicPassController.java b/backend/src/main/java/com/itcenter/acs/controller/PublicPassController.java new file mode 100644 index 0000000..89a2378 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/PublicPassController.java @@ -0,0 +1,83 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.AccessActionResponse; +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.CheckInRequest; +import com.itcenter.acs.dto.PublicPassResponse; +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.VisitStatus; +import com.itcenter.acs.exception.ApiException; +import com.itcenter.acs.repository.VisitRequestRepository; +import com.itcenter.acs.service.AccessService; +import com.itcenter.acs.service.QrService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Public (no-login) visitor pass + self-service kiosk actions, addressed by the + * unguessable qrToken. The visitor opens the SMS link on their phone (pass view) + * and, at the entrance kiosk, scans the QR to self check-in/out. Possession of + * the token is the authorization; each token only affects its own visit. + */ +@RestController +@RequestMapping("/api/public/passes") +@RequiredArgsConstructor +public class PublicPassController { + + private final VisitRequestRepository visitRequestRepository; + private final QrService qrService; + private final AccessService accessService; + + @GetMapping("/{token}") + public ResponseEntity> pass(@PathVariable String token) { + VisitRequest vr = requireApproved(token); + boolean inside = accessService.isInside(vr.getId()); + boolean completedToday = !inside && accessService.hasExitedToday(vr.getId()); + return ResponseEntity.ok(ApiResponse.success(PublicPassResponse.from(vr, inside, completedToday))); + } + + @GetMapping(value = "/{token}/qr.png", produces = MediaType.IMAGE_PNG_VALUE) + public ResponseEntity qr(@PathVariable String token) { + VisitRequest vr = requireApproved(token); + byte[] png = qrService.pngForText(vr.getQrToken(), 240); + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .cacheControl(CacheControl.noCache()) + .body(png); + } + + /** Self check-in from the entrance kiosk (no operator). */ + @PostMapping("/{token}/check-in") + public ResponseEntity> checkIn(@PathVariable String token) { + return ResponseEntity.ok(ApiResponse.success(accessService.checkIn(kioskReq(token), null))); + } + + /** Self check-out from the entrance kiosk (no operator). */ + @PostMapping("/{token}/check-out") + public ResponseEntity> checkOut(@PathVariable String token) { + return ResponseEntity.ok(ApiResponse.success(accessService.checkOut(kioskReq(token), null))); + } + + private CheckInRequest kioskReq(String token) { + CheckInRequest req = new CheckInRequest(); + req.setQrToken(token); + req.setGateId("KIOSK"); + return req; + } + + private VisitRequest requireApproved(String token) { + VisitRequest vr = visitRequestRepository.findByQrToken(token) + .orElseThrow(() -> ApiException.notFound("유효하지 않은 출입증입니다.")); + if (vr.getStatus() != VisitStatus.APPROVED) { + throw ApiException.badRequest("승인된 출입증만 확인할 수 있습니다."); + } + return vr; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/ReportController.java b/backend/src/main/java/com/itcenter/acs/controller/ReportController.java new file mode 100644 index 0000000..67cffe7 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/ReportController.java @@ -0,0 +1,38 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.service.ReportService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +/** + * Excel reports. Restricted to SECURITY/ADMIN by SecurityConfig (/api/reports/**). + */ +@RestController +@RequestMapping("/api/reports") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @GetMapping("/visits.xlsx") + public ResponseEntity visitsReport( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + byte[] xlsx = reportService.visitReport(from, to); + String filename = "visit-report_" + from + "_" + to + ".xlsx"; + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(xlsx); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/StatsController.java b/backend/src/main/java/com/itcenter/acs/controller/StatsController.java new file mode 100644 index 0000000..a838af8 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/StatsController.java @@ -0,0 +1,23 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.StatsSummaryResponse; +import com.itcenter.acs.service.StatsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/stats") +@RequiredArgsConstructor +public class StatsController { + + private final StatsService statsService; + + @GetMapping("/summary") + public ResponseEntity> summary() { + return ResponseEntity.ok(ApiResponse.success(statsService.summary())); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/VisitRequestController.java b/backend/src/main/java/com/itcenter/acs/controller/VisitRequestController.java new file mode 100644 index 0000000..e3c0b9d --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/VisitRequestController.java @@ -0,0 +1,80 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.ExcelImportResult; +import com.itcenter.acs.dto.VisitRequestCreateRequest; +import com.itcenter.acs.dto.VisitRequestResponse; +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.security.SecurityUtils; +import com.itcenter.acs.service.ExcelImportService; +import com.itcenter.acs.service.VisitRequestService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/visit-requests") +@RequiredArgsConstructor +public class VisitRequestController { + + private final VisitRequestService visitRequestService; + private final ExcelImportService excelImportService; + + /** Create a single pre-registration; current user becomes the host. */ + @PostMapping + public ResponseEntity> create( + @Valid @RequestBody VisitRequestCreateRequest request) { + VisitRequest created = visitRequestService.create(request, SecurityUtils.currentUserId()); + return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(created))); + } + + /** ADMIN/SECURITY see all; HOST sees only their own requests. */ + @GetMapping + public ResponseEntity>> list() { + List items = + (SecurityUtils.hasRole("ADMIN") || SecurityUtils.hasRole("SECURITY")) + ? visitRequestService.listAll() + : visitRequestService.listByHost(SecurityUtils.currentUserId()); + return ResponseEntity.ok(ApiResponse.success(toResponses(items))); + } + + /** Pending queue for the approval screen. */ + @GetMapping("/pending") + public ResponseEntity>> pending() { + return ResponseEntity.ok(ApiResponse.success(toResponses(visitRequestService.listPending()))); + } + + @GetMapping("/{id}") + public ResponseEntity> get(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(visitRequestService.get(id)))); + } + + @PostMapping("/{id}/cancel") + public ResponseEntity> cancel(@PathVariable Long id) { + VisitRequest vr = visitRequestService.cancel(id, SecurityUtils.currentUserId(), SecurityUtils.hasRole("ADMIN")); + return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(vr))); + } + + @PostMapping("/upload") + public ResponseEntity> upload( + @RequestParam("file") MultipartFile file) throws IOException { + ExcelImportResult result = excelImportService.importVisitRequests(file, SecurityUtils.currentUserId()); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + private List toResponses(List items) { + return items.stream().map(VisitRequestResponse::from).collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/controller/ZoneController.java b/backend/src/main/java/com/itcenter/acs/controller/ZoneController.java new file mode 100644 index 0000000..2b3977e --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/controller/ZoneController.java @@ -0,0 +1,29 @@ +package com.itcenter.acs.controller; + +import com.itcenter.acs.dto.ApiResponse; +import com.itcenter.acs.dto.ZoneResponse; +import com.itcenter.acs.repository.ZoneRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/zones") +@RequiredArgsConstructor +public class ZoneController { + + private final ZoneRepository zoneRepository; + + @GetMapping + public ResponseEntity>> list() { + List zones = zoneRepository.findByActiveTrueOrderBySecurityLevelAsc().stream() + .map(ZoneResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(ApiResponse.success(zones)); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/AccessActionResponse.java b/backend/src/main/java/com/itcenter/acs/dto/AccessActionResponse.java new file mode 100644 index 0000000..238b7c4 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/AccessActionResponse.java @@ -0,0 +1,17 @@ +package com.itcenter.acs.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +public class AccessActionResponse { + private Long visitRequestId; + private String visitorName; + private String direction; + private LocalDateTime eventAt; + private boolean gateOpened; + private String message; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/AccessRecordResponse.java b/backend/src/main/java/com/itcenter/acs/dto/AccessRecordResponse.java new file mode 100644 index 0000000..df307eb --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/AccessRecordResponse.java @@ -0,0 +1,25 @@ +package com.itcenter.acs.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * One visitor's access record for today: planned window (신청 입/퇴장) and actual + * times (실제 입/퇴장). Includes those who have already left, so the staff console + * can show the full day's traffic. + */ +@Data +@AllArgsConstructor +public class AccessRecordResponse { + private Long visitRequestId; + private String visitorName; + private String company; + private String zoneName; + private LocalDateTime visitFrom; // 신청(예정) 입장 + private LocalDateTime visitTo; // 신청(예정) 퇴장 + private LocalDateTime checkInAt; // 실제 입장 (오늘 최초 IN) + private LocalDateTime checkOutAt; // 실제 퇴장 (오늘 최종 OUT, 재실 중이면 null) + private boolean inside; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/ApiResponse.java b/backend/src/main/java/com/itcenter/acs/dto/ApiResponse.java new file mode 100644 index 0000000..ec84b63 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/ApiResponse.java @@ -0,0 +1,29 @@ +package com.itcenter.acs.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Standard API envelope: every endpoint returns { code, message, data }. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + private int code; + private String message; + private T data; + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "Success", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/ApprovalDecisionRequest.java b/backend/src/main/java/com/itcenter/acs/dto/ApprovalDecisionRequest.java new file mode 100644 index 0000000..711dcd3 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/ApprovalDecisionRequest.java @@ -0,0 +1,9 @@ +package com.itcenter.acs.dto; + +import lombok.Data; + +@Data +public class ApprovalDecisionRequest { + /** Optional comment / reason (recommended for rejections). */ + private String comment; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/BlacklistRequest.java b/backend/src/main/java/com/itcenter/acs/dto/BlacklistRequest.java new file mode 100644 index 0000000..7115fed --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/BlacklistRequest.java @@ -0,0 +1,16 @@ +package com.itcenter.acs.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class BlacklistRequest { + @NotBlank(message = "이름을 입력하세요.") + private String name; + + private String company; + private String contact; + + @NotBlank(message = "차단 사유를 입력하세요.") + private String reason; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/BlacklistResponse.java b/backend/src/main/java/com/itcenter/acs/dto/BlacklistResponse.java new file mode 100644 index 0000000..000c762 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/BlacklistResponse.java @@ -0,0 +1,31 @@ +package com.itcenter.acs.dto; + +import com.itcenter.acs.entity.Blacklist; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class BlacklistResponse { + private Long id; + private String name; + private String company; + private String contact; + private String reason; + private boolean active; + private String createdByName; + private LocalDateTime createdAt; + + public static BlacklistResponse from(Blacklist b) { + BlacklistResponse r = new BlacklistResponse(); + r.id = b.getId(); + r.name = b.getName(); + r.company = b.getCompany(); + r.contact = b.getContact(); + r.reason = b.getReason(); + r.active = b.isActive(); + r.createdByName = b.getCreatedBy() != null ? b.getCreatedBy().getFullName() : null; + r.createdAt = b.getCreatedAt(); + return r; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/ChangePasswordRequest.java b/backend/src/main/java/com/itcenter/acs/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..fcc38c3 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/ChangePasswordRequest.java @@ -0,0 +1,15 @@ +package com.itcenter.acs.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ChangePasswordRequest { + @NotBlank(message = "현재 비밀번호를 입력하세요.") + private String oldPassword; + + @NotBlank(message = "새 비밀번호를 입력하세요.") + @Size(min = 8, message = "새 비밀번호는 8자 이상이어야 합니다.") + private String newPassword; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/CheckInRequest.java b/backend/src/main/java/com/itcenter/acs/dto/CheckInRequest.java new file mode 100644 index 0000000..a9d71ed --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/CheckInRequest.java @@ -0,0 +1,14 @@ +package com.itcenter.acs.dto; + +import lombok.Data; + +/** + * Check-in / check-out command. Provide either qrToken (QR scan) or + * visitRequestId (manual selection from search). + */ +@Data +public class CheckInRequest { + private String qrToken; + private Long visitRequestId; + private String gateId; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/CurrentUserResponse.java b/backend/src/main/java/com/itcenter/acs/dto/CurrentUserResponse.java new file mode 100644 index 0000000..8943f3a --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/CurrentUserResponse.java @@ -0,0 +1,16 @@ +package com.itcenter.acs.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.Set; + +@Data +@AllArgsConstructor +public class CurrentUserResponse { + private Long id; + private String username; + private String fullName; + private Set roles; + private boolean mustChangePassword; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/ExcelImportResult.java b/backend/src/main/java/com/itcenter/acs/dto/ExcelImportResult.java new file mode 100644 index 0000000..cf0f1d7 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/ExcelImportResult.java @@ -0,0 +1,17 @@ +package com.itcenter.acs.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class ExcelImportResult { + private int totalRows; + private int successCount; + private List errors = new ArrayList<>(); + + public boolean isSuccess() { + return errors.isEmpty(); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/InsideVisitorResponse.java b/backend/src/main/java/com/itcenter/acs/dto/InsideVisitorResponse.java new file mode 100644 index 0000000..51f30d3 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/InsideVisitorResponse.java @@ -0,0 +1,20 @@ +package com.itcenter.acs.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * A visitor currently inside (checked in, not yet checked out). + */ +@Data +@AllArgsConstructor +public class InsideVisitorResponse { + private Long visitRequestId; + private String visitorName; + private String company; + private String zoneName; + private String hostName; + private LocalDateTime checkInAt; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/LoginRequest.java b/backend/src/main/java/com/itcenter/acs/dto/LoginRequest.java new file mode 100644 index 0000000..0f66819 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/LoginRequest.java @@ -0,0 +1,13 @@ +package com.itcenter.acs.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class LoginRequest { + @NotBlank(message = "아이디를 입력하세요.") + private String username; + + @NotBlank(message = "비밀번호를 입력하세요.") + private String password; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/PublicPassResponse.java b/backend/src/main/java/com/itcenter/acs/dto/PublicPassResponse.java new file mode 100644 index 0000000..7ca6ceb --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/PublicPassResponse.java @@ -0,0 +1,35 @@ +package com.itcenter.acs.dto; + +import com.itcenter.acs.entity.VisitRequest; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Minimal, non-sensitive pass view served to the visitor via the public + * token link. Intentionally omits contact/host/internal fields. + */ +@Data +public class PublicPassResponse { + private String visitorName; + private String company; + private String zoneName; + private LocalDateTime visitFrom; + private LocalDateTime visitTo; + /** Whether the visitor is currently inside — drives the kiosk 입장/퇴장 button. */ + private boolean inside; + /** Whether the visit already completed (entered & exited) today — blocks re-entry. */ + private boolean completedToday; + + public static PublicPassResponse from(VisitRequest vr, boolean inside, boolean completedToday) { + PublicPassResponse r = new PublicPassResponse(); + r.visitorName = vr.getVisitor().getName(); + r.company = vr.getVisitor().getCompany(); + r.zoneName = vr.getZoneName(); + r.visitFrom = vr.getVisitFrom(); + r.visitTo = vr.getVisitTo(); + r.inside = inside; + r.completedToday = completedToday; + return r; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/StatsSummaryResponse.java b/backend/src/main/java/com/itcenter/acs/dto/StatsSummaryResponse.java new file mode 100644 index 0000000..1919354 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/StatsSummaryResponse.java @@ -0,0 +1,14 @@ +package com.itcenter.acs.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class StatsSummaryResponse { + private long todayVisits; + private long pending; + private long approved; + private long currentlyInside; + private long total; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/VisitRequestCreateRequest.java b/backend/src/main/java/com/itcenter/acs/dto/VisitRequestCreateRequest.java new file mode 100644 index 0000000..7e232f1 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/VisitRequestCreateRequest.java @@ -0,0 +1,34 @@ +package com.itcenter.acs.dto; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class VisitRequestCreateRequest { + @NotBlank(message = "방문자 이름을 입력하세요.") + private String visitorName; + + private String company; + + @NotBlank(message = "방문자 연락처를 입력하세요.") + private String contact; + + private String email; + private String vehicleNo; + + /** Access zone label (fixed list value or "기타" free text). */ + private String zoneName; + + @NotBlank(message = "출입 목적을 입력하세요.") + private String purpose; + + @NotNull(message = "방문 시작 일시를 입력하세요.") + private LocalDateTime visitFrom; + + @NotNull(message = "방문 종료 일시를 입력하세요.") + private LocalDateTime visitTo; +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/VisitRequestResponse.java b/backend/src/main/java/com/itcenter/acs/dto/VisitRequestResponse.java new file mode 100644 index 0000000..fff599e --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/VisitRequestResponse.java @@ -0,0 +1,45 @@ +package com.itcenter.acs.dto; + +import com.itcenter.acs.entity.VisitRequest; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class VisitRequestResponse { + private Long id; + private String visitorName; + private String company; + private String contact; + private String vehicleNo; + private Long hostId; + private String hostName; + private String hostDepartment; + private String zoneName; + private String purpose; + private LocalDateTime visitFrom; + private LocalDateTime visitTo; + private String status; + private String qrToken; + private LocalDateTime createdAt; + + public static VisitRequestResponse from(VisitRequest vr) { + VisitRequestResponse r = new VisitRequestResponse(); + r.id = vr.getId(); + r.visitorName = vr.getVisitor().getName(); + r.company = vr.getVisitor().getCompany(); + r.contact = vr.getVisitor().getContact(); + r.vehicleNo = vr.getVisitor().getVehicleNo(); + r.hostId = vr.getHost().getId(); + r.hostName = vr.getHost().getFullName(); + r.hostDepartment = vr.getHost().getDepartment(); + r.zoneName = vr.getZoneName(); + r.purpose = vr.getPurpose(); + r.visitFrom = vr.getVisitFrom(); + r.visitTo = vr.getVisitTo(); + r.status = vr.getStatus().name(); + r.qrToken = vr.getQrToken(); + r.createdAt = vr.getCreatedAt(); + return r; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/dto/ZoneResponse.java b/backend/src/main/java/com/itcenter/acs/dto/ZoneResponse.java new file mode 100644 index 0000000..c6be4c0 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/dto/ZoneResponse.java @@ -0,0 +1,18 @@ +package com.itcenter.acs.dto; + +import com.itcenter.acs.entity.Zone; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ZoneResponse { + private Long id; + private String code; + private String name; + private int securityLevel; + + public static ZoneResponse from(Zone zone) { + return new ZoneResponse(zone.getId(), zone.getCode(), zone.getName(), zone.getSecurityLevel()); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/AccessEvent.java b/backend/src/main/java/com/itcenter/acs/entity/AccessEvent.java new file mode 100644 index 0000000..c389d5f --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/AccessEvent.java @@ -0,0 +1,52 @@ +package com.itcenter.acs.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * A single check-in (IN) or check-out (OUT) at a gate, tied to an approved + * visit request. The source of truth for the currently-inside list and stats. + */ +@Entity +@Table(name = "access_events", indexes = { + @Index(name = "idx_ae_visit_request", columnList = "visit_request_id"), + @Index(name = "idx_ae_event_at", columnList = "eventAt"), + @Index(name = "idx_ae_direction", columnList = "direction") +}) +@Getter +@Setter +@NoArgsConstructor +public class AccessEvent extends BaseEntity { + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "visit_request_id", nullable = false) + private VisitRequest visitRequest; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 8) + private Direction direction; + + /** Logical gate/reader identifier (e.g. LOBBY-1). Free-form for now. */ + @Column(length = 40) + private String gateId; + + /** Security/reception user who processed the event (null if device-driven). */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "operator_id") + private User operator; + + @Column(nullable = false) + private LocalDateTime eventAt; +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/Approval.java b/backend/src/main/java/com/itcenter/acs/entity/Approval.java new file mode 100644 index 0000000..9b25abe --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/Approval.java @@ -0,0 +1,44 @@ +package com.itcenter.acs.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * An approve/reject decision recorded against a visit request. + */ +@Entity +@Table(name = "approvals") +@Getter +@Setter +@NoArgsConstructor +public class Approval extends BaseEntity { + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "visit_request_id", nullable = false) + private VisitRequest visitRequest; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "approver_id", nullable = false) + private User approver; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ApprovalDecision decision; + + @Column(length = 500) + private String comment; + + @Column(nullable = false) + private LocalDateTime decidedAt; +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/ApprovalDecision.java b/backend/src/main/java/com/itcenter/acs/entity/ApprovalDecision.java new file mode 100644 index 0000000..017cd2e --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/ApprovalDecision.java @@ -0,0 +1,6 @@ +package com.itcenter.acs.entity; + +public enum ApprovalDecision { + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/BaseEntity.java b/backend/src/main/java/com/itcenter/acs/entity/BaseEntity.java new file mode 100644 index 0000000..4bc94e6 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/BaseEntity.java @@ -0,0 +1,38 @@ +package com.itcenter.acs.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * Shared identity + auditing columns. createdAt/updatedAt are populated + * automatically by Spring Data JPA auditing (see JpaConfig). + */ +@Getter +@Setter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/Blacklist.java b/backend/src/main/java/com/itcenter/acs/entity/Blacklist.java new file mode 100644 index 0000000..f06b682 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/Blacklist.java @@ -0,0 +1,45 @@ +package com.itcenter.acs.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A blocked person. Matched on name (+ optional contact) at check-in. + */ +@Entity +@Table(name = "blacklist", indexes = { + @Index(name = "idx_bl_name", columnList = "name"), + @Index(name = "idx_bl_contact", columnList = "contact") +}) +@Getter +@Setter +@NoArgsConstructor +public class Blacklist extends BaseEntity { + + @Column(nullable = false, length = 80) + private String name; + + @Column(length = 120) + private String company; + + @Column(length = 40) + private String contact; + + @Column(nullable = false, length = 255) + private String reason; + + @Column(nullable = false) + private boolean active = true; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "created_by") + private User createdBy; +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/Direction.java b/backend/src/main/java/com/itcenter/acs/entity/Direction.java new file mode 100644 index 0000000..e6e3227 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/Direction.java @@ -0,0 +1,9 @@ +package com.itcenter.acs.entity; + +/** + * Direction of an access event at a gate. + */ +public enum Direction { + IN, + OUT +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/RoleType.java b/backend/src/main/java/com/itcenter/acs/entity/RoleType.java new file mode 100644 index 0000000..81f30c3 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/RoleType.java @@ -0,0 +1,13 @@ +package com.itcenter.acs.entity; + +/** + * Application roles. Spring Security authorities are derived as "ROLE_" + name(). + * - ADMIN : 전체 관리(사용자/블랙리스트/리포트) + * - SECURITY : 입·출입 콘솔(체크인/아웃), 재실현황, 배지 발급 + * - HOST : 방문 사전신청 등록, 본인 담당 방문 승인 + */ +public enum RoleType { + ADMIN, + SECURITY, + HOST +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/User.java b/backend/src/main/java/com/itcenter/acs/entity/User.java new file mode 100644 index 0000000..f396439 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/User.java @@ -0,0 +1,59 @@ +package com.itcenter.acs.entity; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.HashSet; +import java.util.Set; + +/** + * A system account (ADMIN / SECURITY / HOST). Visitors are NOT users. + */ +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class User extends BaseEntity { + + @Column(unique = true, nullable = false, length = 50) + private String username; + + @Column(nullable = false) + private String passwordHash; + + @Column(nullable = false, length = 80) + private String fullName; + + @Column(length = 120) + private String email; + + /** Organizational department/team; also used as the host's team label. */ + @Column(length = 80) + private String department; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "role", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private Set roles = new HashSet<>(); + + @Column(nullable = false) + private boolean mustChangePassword = true; + + @Column(nullable = false) + private boolean enabled = true; + + @Column(nullable = false) + private boolean locked = false; +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/VisitRequest.java b/backend/src/main/java/com/itcenter/acs/entity/VisitRequest.java new file mode 100644 index 0000000..307d57d --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/VisitRequest.java @@ -0,0 +1,62 @@ +package com.itcenter.acs.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * A request for a visitor to access a zone during a time window. + * This is the unit of access authorization; on approval it receives a qrToken. + */ +@Entity +@Table(name = "visit_requests", indexes = { + @Index(name = "idx_vr_status", columnList = "status"), + @Index(name = "idx_vr_visit_from", columnList = "visitFrom"), + @Index(name = "idx_vr_qr_token", columnList = "qrToken") +}) +// zone is stored as free text (fixed list + "기타" custom input) rather than an FK. +@Getter +@Setter +@NoArgsConstructor +public class VisitRequest extends BaseEntity { + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "visitor_id", nullable = false) + private Visitor visitor; + + /** The employee (HOST) responsible for this visit. */ + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "host_id", nullable = false) + private User host; + + @Column(name = "zone_name", length = 80) + private String zoneName; + + @Column(nullable = false, length = 255) + private String purpose; + + @Column(nullable = false) + private LocalDateTime visitFrom; + + @Column(nullable = false) + private LocalDateTime visitTo; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private VisitStatus status = VisitStatus.PENDING; + + /** UUID issued on approval; encoded into the QR pass/badge. Null until approved. */ + @Column(length = 64) + private String qrToken; +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/VisitStatus.java b/backend/src/main/java/com/itcenter/acs/entity/VisitStatus.java new file mode 100644 index 0000000..d8902f3 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/VisitStatus.java @@ -0,0 +1,14 @@ +package com.itcenter.acs.entity; + +/** + * Lifecycle of a visit request. + * DRAFT -> PENDING -> (APPROVED | REJECTED); APPROVED/PENDING -> CANCELLED; APPROVED -> EXPIRED. + */ +public enum VisitStatus { + DRAFT, + PENDING, + APPROVED, + REJECTED, + CANCELLED, + EXPIRED +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/Visitor.java b/backend/src/main/java/com/itcenter/acs/entity/Visitor.java new file mode 100644 index 0000000..d635bab --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/Visitor.java @@ -0,0 +1,39 @@ +package com.itcenter.acs.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Personal details of a visiting person. Reused across repeat visits. + */ +@Entity +@Table(name = "visitors", indexes = { + @Index(name = "idx_visitor_name", columnList = "name"), + @Index(name = "idx_visitor_contact", columnList = "contact") +}) +@Getter +@Setter +@NoArgsConstructor +public class Visitor extends BaseEntity { + + @Column(nullable = false, length = 80) + private String name; + + @Column(length = 120) + private String company; + + @Column(length = 40) + private String contact; + + @Column(length = 120) + private String email; + + /** Vehicle plate number, if entering by car. */ + @Column(length = 20) + private String vehicleNo; +} diff --git a/backend/src/main/java/com/itcenter/acs/entity/Zone.java b/backend/src/main/java/com/itcenter/acs/entity/Zone.java new file mode 100644 index 0000000..9603bf3 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/entity/Zone.java @@ -0,0 +1,36 @@ +package com.itcenter.acs.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A physical access area within the IT center (e.g. LOBBY, OFFICE, SERVER_ROOM). + * A visit request grants access to one zone. + */ +@Entity +@Table(name = "zones") +@Getter +@Setter +@NoArgsConstructor +public class Zone extends BaseEntity { + + @Column(unique = true, nullable = false, length = 30) + private String code; + + @Column(nullable = false, length = 80) + private String name; + + @Column(length = 255) + private String description; + + /** Higher = more restricted (e.g. server room). Drives approval policy later. */ + @Column(nullable = false) + private int securityLevel = 1; + + @Column(nullable = false) + private boolean active = true; +} diff --git a/backend/src/main/java/com/itcenter/acs/exception/ApiException.java b/backend/src/main/java/com/itcenter/acs/exception/ApiException.java new file mode 100644 index 0000000..b9d15f7 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/exception/ApiException.java @@ -0,0 +1,29 @@ +package com.itcenter.acs.exception; + +import lombok.Getter; + +/** + * Business/validation error carrying an HTTP status code, surfaced by + * GlobalExceptionHandler as an ApiResponse.error(code, message). + */ +@Getter +public class ApiException extends RuntimeException { + private final int code; + + public ApiException(int code, String message) { + super(message); + this.code = code; + } + + public static ApiException badRequest(String message) { + return new ApiException(400, message); + } + + public static ApiException notFound(String message) { + return new ApiException(404, message); + } + + public static ApiException conflict(String message) { + return new ApiException(409, message); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/gateway/AccessControlGateway.java b/backend/src/main/java/com/itcenter/acs/gateway/AccessControlGateway.java new file mode 100644 index 0000000..31f8358 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/gateway/AccessControlGateway.java @@ -0,0 +1,29 @@ +package com.itcenter.acs.gateway; + +/** + * Abstraction over physical access-control hardware (card readers, speed gates). + * The current system is software-only; {@link MockAccessControlGateway} is the + * default implementation. A real driver can be added later as another bean + * without touching the service layer. + */ +public interface AccessControlGateway { + + /** + * Command a gate to open for a verified credential. + * + * @param gateId logical gate identifier (e.g. "LOBBY-1") + * @param reason human-readable reason for the audit trail + * @return result describing whether the hardware accepted the command + */ + GateResult openGate(String gateId, String reason); + + /** Whether the gate/device is reachable and online. */ + boolean isOnline(String gateId); + + /** Outcome of a gate command. */ + record GateResult(boolean accepted, String message) { + public static GateResult ok(String gateId) { + return new GateResult(true, "게이트 개방: " + gateId); + } + } +} diff --git a/backend/src/main/java/com/itcenter/acs/gateway/MockAccessControlGateway.java b/backend/src/main/java/com/itcenter/acs/gateway/MockAccessControlGateway.java new file mode 100644 index 0000000..775ae83 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/gateway/MockAccessControlGateway.java @@ -0,0 +1,26 @@ +package com.itcenter.acs.gateway; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * Software-only stub: logs the command and reports success. Default gateway + * until real hardware integration is added. Replace/augment by providing + * another {@link AccessControlGateway} bean marked @Primary. + */ +@Slf4j +@Component +public class MockAccessControlGateway implements AccessControlGateway { + + @Override + public GateResult openGate(String gateId, String reason) { + String gate = (gateId == null || gateId.isBlank()) ? "(unspecified)" : gateId; + log.info("[gate] OPEN gate={} reason={}", gate, reason); + return GateResult.ok(gate); + } + + @Override + public boolean isOnline(String gateId) { + return true; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/notification/HanbankMessagePassNotifier.java b/backend/src/main/java/com/itcenter/acs/notification/HanbankMessagePassNotifier.java new file mode 100644 index 0000000..fd62f79 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/notification/HanbankMessagePassNotifier.java @@ -0,0 +1,94 @@ +package com.itcenter.acs.notification; + +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.Visitor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.time.format.DateTimeFormatter; +import java.util.Map; + +/** + * Sends the visitor pass via the in-house Bank-of-Korea DMZ message API + * (SMS/LMS). The API cannot attach images, so the message carries a link to the + * public pass page ({@code /pass/{token}}) where the visitor views the QR. + * + *

Active only when {@code acs.sms.provider=hanbank}; otherwise the dev + * {@link LoggingPassNotifier} is used. The API takes no auth key — it is reached + * over the trusted internal network, so the ACS server must have connectivity to + * {@code acs.sms.api-url}. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "acs.sms.provider", havingValue = "hanbank") +public class HanbankMessagePassNotifier implements PassNotifier { + + private static final DateTimeFormatter WINDOW_FMT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm"); + + private final RestClient restClient; + private final String publicBaseUrl; + + public HanbankMessagePassNotifier( + @Value("${acs.sms.api-url}") String apiUrl, + @Value("${acs.public-base-url}") String publicBaseUrl) { + this.restClient = RestClient.create(apiUrl); + this.publicBaseUrl = publicBaseUrl.endsWith("/") + ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) + : publicBaseUrl; + } + + @Override + public void sendPass(VisitRequest visitRequest, byte[] qrPng) { + Visitor visitor = visitRequest.getVisitor(); + String phone = visitor != null ? digitsOnly(visitor.getContact()) : ""; + if (phone.isBlank()) { + log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId()); + return; + } + + Map body = Map.of( + "receive_number", phone, + "content", buildContent(visitRequest), + "msg_type", "LMS", + "reserve_time", ""); + + try { + SmsResponse res = restClient.post() + .uri("/sens/sms") + .body(body) + .retrieve() + .body(SmsResponse.class); + + if (res != null && "202".equals(res.statusCode())) { + log.info("[sms] 출입증 LMS 발송 성공 → {} (requestId={})", phone, res.requestId()); + } else { + log.warn("[sms] 출입증 LMS 발송 실패 → {} (statusCode={}, statusName={})", + phone, res != null ? res.statusCode() : "null", res != null ? res.statusName() : "null"); + } + } catch (Exception e) { + log.warn("[sms] 출입증 LMS 발송 호출 오류 → {} : {}", phone, e.getMessage()); + } + } + + private String buildContent(VisitRequest vr) { + Visitor visitor = vr.getVisitor(); + String name = visitor != null ? visitor.getName() : "방문자"; + String zone = vr.getZoneName() != null ? vr.getZoneName() : "-"; + String window = WINDOW_FMT.format(vr.getVisitFrom()) + " ~ " + WINDOW_FMT.format(vr.getVisitTo()); + String link = publicBaseUrl + "/pass/" + vr.getQrToken(); + return String.format( + "[IT센터 출입증]\n%s님, 출입 신청이 승인되었습니다.\n출입구역: %s\n출입기간: %s\n아래 링크에서 출입증(QR)을 확인하세요.\n%s\n전산실 입장 시 QR을 출입관리시스템에 입력하세요.", + name, zone, window, link); + } + + private static String digitsOnly(String s) { + return s == null ? "" : s.replaceAll("[^0-9]", ""); + } + + /** Response body of POST /sens/sms (statusCode "202" = success). */ + private record SmsResponse(String requestId, String requestTime, String statusCode, String statusName) { + } +} diff --git a/backend/src/main/java/com/itcenter/acs/notification/LoggingPassNotifier.java b/backend/src/main/java/com/itcenter/acs/notification/LoggingPassNotifier.java new file mode 100644 index 0000000..231db5b --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/notification/LoggingPassNotifier.java @@ -0,0 +1,78 @@ +package com.itcenter.acs.notification; + +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.Visitor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.format.DateTimeFormatter; + +/** + * Dev/default {@link PassNotifier}: does not call a real SMS/MMS gateway. + * Instead it logs the intended send and writes the QR PNG to an outbox + * directory so the approval → delivery flow can be exercised without a paid + * gateway account or a pre-registered sender number. Swap in a real provider + * by adding another {@link PassNotifier} bean marked {@code @Primary}. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "acs.sms.provider", havingValue = "dev", matchIfMissing = true) +public class LoggingPassNotifier implements PassNotifier { + + private static final DateTimeFormatter WINDOW_FMT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm"); + + /** Directory where the QR image "sent" to the visitor is written (dev only). */ + private final Path outboxDir; + + public LoggingPassNotifier(@Value("${acs.sms.outbox-dir:./sms-outbox}") String outboxDir) { + this.outboxDir = Path.of(outboxDir); + } + + @Override + public void sendPass(VisitRequest visitRequest, byte[] qrPng) { + Visitor visitor = visitRequest.getVisitor(); + String phone = visitor != null ? visitor.getContact() : null; + if (phone == null || phone.isBlank()) { + log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId()); + return; + } + + String message = buildMessage(visitRequest); + Path saved = writeOutbox(visitRequest, qrPng); + + log.info("[sms] (DEV) 출입증 MMS 발송 → {} ({}자, QR {} bytes){}\n----- 메시지 -----\n{}\n------------------", + phone, message.length(), qrPng.length, + saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message); + } + + private String buildMessage(VisitRequest vr) { + Visitor visitor = vr.getVisitor(); + String name = visitor != null ? visitor.getName() : "방문자"; + String zone = vr.getZoneName() != null ? vr.getZoneName() : "-"; + String window = WINDOW_FMT.format(vr.getVisitFrom()) + " ~ " + WINDOW_FMT.format(vr.getVisitTo()); + return String.format( + "[IT센터 출입증]\n%s님, 출입 신청이 승인되었습니다.\n출입구역: %s\n출입기간: %s\n첨부된 QR을 입장 시 출입관리시스템에 입력하세요.", + name, zone, window); + } + + /** Writes the QR image to the outbox dir for inspection. Returns null on failure. */ + private Path writeOutbox(VisitRequest vr, byte[] qrPng) { + try { + Files.createDirectories(outboxDir); + String safeName = vr.getVisitor() != null && vr.getVisitor().getName() != null + ? vr.getVisitor().getName().replaceAll("[^\\p{L}\\p{N}]", "_") + : "visitor"; + Path file = outboxDir.resolve("pass-" + vr.getId() + "-" + safeName + ".png"); + Files.write(file, qrPng); + return file.toAbsolutePath(); + } catch (IOException e) { + log.warn("[sms] 출입증 이미지 저장 실패: {}", e.getMessage()); + return null; + } + } +} diff --git a/backend/src/main/java/com/itcenter/acs/notification/PassNotifier.java b/backend/src/main/java/com/itcenter/acs/notification/PassNotifier.java new file mode 100644 index 0000000..56a6b0c --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/notification/PassNotifier.java @@ -0,0 +1,23 @@ +package com.itcenter.acs.notification; + +import com.itcenter.acs.entity.VisitRequest; + +/** + * Delivers an approved visitor pass (QR image) to the visitor, typically as an + * MMS to their registered phone number. The current system is software-only; + * {@link LoggingPassNotifier} is the default (dev) implementation that logs the + * send and writes the QR image to an outbox directory instead of calling a real + * SMS/MMS gateway. A real provider (Aligo, NHN Cloud, …) can be added later as + * another {@link PassNotifier} bean marked {@code @Primary} without touching the + * service layer. + */ +public interface PassNotifier { + + /** + * Send the pass to the visitor. + * + * @param visitRequest the approved request (carries visitor, phone, window) + * @param qrPng PNG bytes of the pass QR to attach/send + */ + void sendPass(VisitRequest visitRequest, byte[] qrPng); +} diff --git a/backend/src/main/java/com/itcenter/acs/repository/AccessEventRepository.java b/backend/src/main/java/com/itcenter/acs/repository/AccessEventRepository.java new file mode 100644 index 0000000..0ba5d3e --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/AccessEventRepository.java @@ -0,0 +1,32 @@ +package com.itcenter.acs.repository; + +import com.itcenter.acs.entity.AccessEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface AccessEventRepository extends JpaRepository { + + Optional findFirstByVisitRequestIdOrderByEventAtDesc(Long visitRequestId); + + List findByVisitRequestIdOrderByEventAtAsc(Long visitRequestId); + + /** Distinct visit-request ids that had an entry (IN) within the window (e.g. today). */ + @Query("select distinct e.visitRequest.id from AccessEvent e " + + "where e.direction = com.itcenter.acs.entity.Direction.IN " + + "and e.eventAt >= :start and e.eventAt < :end") + List findVisitRequestIdsCheckedInBetween( + @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + /** Visit request ids that are currently inside (more INs than OUTs). */ + @Query("select e.visitRequest.id from AccessEvent e group by e.visitRequest.id " + + "having sum(case when e.direction = com.itcenter.acs.entity.Direction.IN then 1 else -1 end) > 0") + List findInsideVisitRequestIds(); + + long countByDirectionAndEventAtBetween( + com.itcenter.acs.entity.Direction direction, LocalDateTime from, LocalDateTime to); +} diff --git a/backend/src/main/java/com/itcenter/acs/repository/ApprovalRepository.java b/backend/src/main/java/com/itcenter/acs/repository/ApprovalRepository.java new file mode 100644 index 0000000..59035e2 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/ApprovalRepository.java @@ -0,0 +1,10 @@ +package com.itcenter.acs.repository; + +import com.itcenter.acs.entity.Approval; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ApprovalRepository extends JpaRepository { + List findByVisitRequestIdOrderByDecidedAtDesc(Long visitRequestId); +} diff --git a/backend/src/main/java/com/itcenter/acs/repository/BlacklistRepository.java b/backend/src/main/java/com/itcenter/acs/repository/BlacklistRepository.java new file mode 100644 index 0000000..be95e31 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/BlacklistRepository.java @@ -0,0 +1,18 @@ +package com.itcenter.acs.repository; + +import com.itcenter.acs.entity.Blacklist; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface BlacklistRepository extends JpaRepository { + + List findByActiveTrueOrderByUpdatedAtDesc(); + + /** Active entries matching a visitor's name (contact optional). */ + @Query("select b from Blacklist b where b.active = true and lower(b.name) = lower(:name) " + + "and (b.contact is null or b.contact = '' or :contact is null or b.contact = :contact)") + List findActiveMatches(@Param("name") String name, @Param("contact") String contact); +} diff --git a/backend/src/main/java/com/itcenter/acs/repository/UserRepository.java b/backend/src/main/java/com/itcenter/acs/repository/UserRepository.java new file mode 100644 index 0000000..5e49c3b --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.itcenter.acs.repository; + +import com.itcenter.acs.entity.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/itcenter/acs/repository/VisitRequestRepository.java b/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java new file mode 100644 index 0000000..78e1766 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/VisitRequestRepository.java @@ -0,0 +1,37 @@ +package com.itcenter.acs.repository; + +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.VisitStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface VisitRequestRepository extends JpaRepository { + List findByStatusOrderByVisitFromAsc(VisitStatus status); + List findByHostIdOrderByVisitFromDesc(Long hostId); + List findAllByOrderByVisitFromDesc(); + Optional findByQrToken(String qrToken); + + long countByStatus(VisitStatus status); + long countByVisitFromBetween(LocalDateTime from, LocalDateTime to); + List findByVisitFromBetweenOrderByVisitFromAsc(LocalDateTime from, LocalDateTime to); + + /** Approved requests whose visitor name matches, for the check-in console search. */ + @Query("select vr from VisitRequest vr where vr.status = com.itcenter.acs.entity.VisitStatus.APPROVED " + + "and lower(vr.visitor.name) like lower(concat('%', :q, '%')) order by vr.visitFrom asc") + List searchApprovedByVisitorName(@Param("q") String q); + + /** Active (PENDING/APPROVED) duplicate: same visitor, same window, same zone. */ + @Query("select count(vr) > 0 from VisitRequest vr where vr.visitor.id = :visitorId " + + "and vr.visitFrom = :from and vr.visitTo = :to " + + "and ((:zone is null and vr.zoneName is null) or vr.zoneName = :zone) " + + "and vr.status in (com.itcenter.acs.entity.VisitStatus.PENDING, com.itcenter.acs.entity.VisitStatus.APPROVED)") + boolean existsActiveDuplicate(@Param("visitorId") Long visitorId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + @Param("zone") String zone); +} diff --git a/backend/src/main/java/com/itcenter/acs/repository/VisitorRepository.java b/backend/src/main/java/com/itcenter/acs/repository/VisitorRepository.java new file mode 100644 index 0000000..ec734d4 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/VisitorRepository.java @@ -0,0 +1,12 @@ +package com.itcenter.acs.repository; + +import com.itcenter.acs.entity.Visitor; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface VisitorRepository extends JpaRepository { + Optional findFirstByNameAndContact(String name, String contact); + List findByNameContainingIgnoreCase(String name); +} diff --git a/backend/src/main/java/com/itcenter/acs/repository/ZoneRepository.java b/backend/src/main/java/com/itcenter/acs/repository/ZoneRepository.java new file mode 100644 index 0000000..67ca58d --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/repository/ZoneRepository.java @@ -0,0 +1,12 @@ +package com.itcenter.acs.repository; + +import com.itcenter.acs.entity.Zone; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ZoneRepository extends JpaRepository { + Optional findByCode(String code); + List findByActiveTrueOrderBySecurityLevelAsc(); +} diff --git a/backend/src/main/java/com/itcenter/acs/security/CustomUserDetailsService.java b/backend/src/main/java/com/itcenter/acs/security/CustomUserDetailsService.java new file mode 100644 index 0000000..543933e --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/security/CustomUserDetailsService.java @@ -0,0 +1,23 @@ +package com.itcenter.acs.security; + +import com.itcenter.acs.entity.User; +import com.itcenter.acs.repository.UserRepository; +import lombok.RequiredArgsConstructor; +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 +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username)); + return new UserPrincipal(user); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/security/SecurityUtils.java b/backend/src/main/java/com/itcenter/acs/security/SecurityUtils.java new file mode 100644 index 0000000..f7cbcc5 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/security/SecurityUtils.java @@ -0,0 +1,31 @@ +package com.itcenter.acs.security; + +import com.itcenter.acs.exception.ApiException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Helpers for reading the authenticated principal from the SecurityContext. + */ +public final class SecurityUtils { + + private SecurityUtils() { + } + + public static UserPrincipal currentPrincipal() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !(auth.getPrincipal() instanceof UserPrincipal principal)) { + throw new ApiException(401, "로그인이 필요합니다."); + } + return principal; + } + + public static Long currentUserId() { + return currentPrincipal().getId(); + } + + public static boolean hasRole(String role) { + return SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_" + role)); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/security/UserPrincipal.java b/backend/src/main/java/com/itcenter/acs/security/UserPrincipal.java new file mode 100644 index 0000000..08188ca --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/security/UserPrincipal.java @@ -0,0 +1,76 @@ +package com.itcenter.acs.security; + +import com.itcenter.acs.entity.User; +import lombok.Getter; +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.Set; +import java.util.stream.Collectors; + +/** + * Spring Security principal backed by our User entity. Roles become + * "ROLE_ADMIN" / "ROLE_SECURITY" / "ROLE_HOST" authorities. + */ +@Getter +public class UserPrincipal implements UserDetails { + + private final Long id; + private final String username; + private final String password; + private final String fullName; + private final boolean mustChangePassword; + private final boolean enabled; + private final boolean locked; + private final Set authorities; + + public UserPrincipal(User user) { + this.id = user.getId(); + this.username = user.getUsername(); + this.password = user.getPasswordHash(); + this.fullName = user.getFullName(); + this.mustChangePassword = user.isMustChangePassword(); + this.enabled = user.isEnabled(); + this.locked = user.isLocked(); + this.authorities = user.getRoles().stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r.name())) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return !locked; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return enabled; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/AccessService.java b/backend/src/main/java/com/itcenter/acs/service/AccessService.java new file mode 100644 index 0000000..2ef3641 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/AccessService.java @@ -0,0 +1,206 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.dto.AccessActionResponse; +import com.itcenter.acs.dto.AccessRecordResponse; +import com.itcenter.acs.dto.CheckInRequest; +import com.itcenter.acs.dto.InsideVisitorResponse; +import com.itcenter.acs.entity.AccessEvent; +import com.itcenter.acs.entity.Direction; +import com.itcenter.acs.entity.User; +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.VisitStatus; +import com.itcenter.acs.exception.ApiException; +import com.itcenter.acs.gateway.AccessControlGateway; +import com.itcenter.acs.repository.AccessEventRepository; +import com.itcenter.acs.repository.UserRepository; +import com.itcenter.acs.repository.VisitRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AccessService { + + private final VisitRequestRepository visitRequestRepository; + private final AccessEventRepository accessEventRepository; + private final UserRepository userRepository; + private final AccessControlGateway gateway; + private final BlacklistService blacklistService; + + /** Record an entry (IN) and open the gate. */ + public AccessActionResponse checkIn(CheckInRequest req, Long operatorId) { + VisitRequest vr = resolve(req); + + if (vr.getStatus() != VisitStatus.APPROVED) { + throw ApiException.badRequest("승인되지 않은 방문입니다. (상태: " + vr.getStatus() + ")"); + } + + // 같은 '일자'이면 시간은 엄격히 보지 않고 입장을 허용한다(늦은 도착 허용). + // 단, 일자가 다르면(신청일이 지났거나 아직 이르면) 재신청이 필요하다. + // 실제 입장 시각은 access_events(event_at)에 신청 시각과 별도로 기록된다. + LocalDateTime now = LocalDateTime.now(); + LocalDate today = now.toLocalDate(); + if (today.isAfter(vr.getVisitTo().toLocalDate())) { + vr.setStatus(VisitStatus.EXPIRED); + throw ApiException.badRequest("방문 신청일이 지났습니다. 출입 신청을 다시 해주세요."); + } + if (today.isBefore(vr.getVisitFrom().toLocalDate())) { + throw ApiException.badRequest("아직 방문 신청일이 아닙니다. (신청일: " + vr.getVisitFrom().toLocalDate() + ")"); + } + String blockReason = blacklistService.blockReason( + vr.getVisitor().getName(), vr.getVisitor().getContact()); + if (blockReason != null) { + throw new ApiException(403, "차단된 방문자입니다. 사유: " + blockReason); + } + + if (isInside(vr.getId())) { + throw ApiException.conflict("이미 입장 처리된 방문자입니다."); + } + if (hasExitedToday(vr.getId())) { + throw ApiException.badRequest("금일 출입이 이미 완료되었습니다. 재입장은 불가합니다."); + } + + User operator = loadOperator(operatorId); + AccessEvent event = record(vr, Direction.IN, req.getGateId(), operator, now); + + AccessControlGateway.GateResult gate = + gateway.openGate(req.getGateId(), "입장: " + vr.getVisitor().getName()); + + return new AccessActionResponse(vr.getId(), vr.getVisitor().getName(), + Direction.IN.name(), event.getEventAt(), gate.accepted(), "입장 처리되었습니다."); + } + + /** Record an exit (OUT). */ + public AccessActionResponse checkOut(CheckInRequest req, Long operatorId) { + VisitRequest vr = resolve(req); + + if (!isInside(vr.getId())) { + throw ApiException.conflict("입장 기록이 없어 퇴장 처리할 수 없습니다."); + } + + User operator = loadOperator(operatorId); + AccessEvent event = record(vr, Direction.OUT, req.getGateId(), operator, LocalDateTime.now()); + + return new AccessActionResponse(vr.getId(), vr.getVisitor().getName(), + Direction.OUT.name(), event.getEventAt(), true, "퇴장 처리되었습니다."); + } + + @Transactional(readOnly = true) + public List listInside() { + return accessEventRepository.findInsideVisitRequestIds().stream() + .map(visitRequestRepository::findById) + .filter(java.util.Optional::isPresent) + .map(java.util.Optional::get) + .map(vr -> { + LocalDateTime checkInAt = accessEventRepository + .findFirstByVisitRequestIdOrderByEventAtDesc(vr.getId()) + .map(AccessEvent::getEventAt) + .orElse(null); + return new InsideVisitorResponse( + vr.getId(), + vr.getVisitor().getName(), + vr.getVisitor().getCompany(), + vr.getZoneName(), + vr.getHost().getFullName(), + checkInAt); + }) + .toList(); + } + + /** Today's access records — everyone who entered today, incl. those who left. */ + @Transactional(readOnly = true) + public List listTodayRecords() { + LocalDateTime start = LocalDate.now().atStartOfDay(); + LocalDateTime end = start.plusDays(1); + List out = new java.util.ArrayList<>(); + for (Long id : accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end)) { + VisitRequest vr = visitRequestRepository.findById(id).orElse(null); + if (vr == null) { + continue; + } + List events = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(id).stream() + .filter(e -> !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end)) + .toList(); + LocalDateTime checkInAt = events.stream() + .filter(e -> e.getDirection() == Direction.IN) + .map(AccessEvent::getEventAt).findFirst().orElse(null); + boolean inside = isInside(id); + LocalDateTime checkOutAt = inside ? null : events.stream() + .filter(e -> e.getDirection() == Direction.OUT) + .map(AccessEvent::getEventAt).reduce((a, b) -> b).orElse(null); + out.add(new AccessRecordResponse(id, vr.getVisitor().getName(), vr.getVisitor().getCompany(), + vr.getZoneName(), vr.getVisitFrom(), vr.getVisitTo(), checkInAt, checkOutAt, inside)); + } + out.sort((a, b) -> { + LocalDateTime x = a.getCheckInAt(), y = b.getCheckInAt(); + if (x == null && y == null) return 0; + if (x == null) return 1; + if (y == null) return -1; + return y.compareTo(x); // most recent entry first + }); + return out; + } + + @Transactional(readOnly = true) + public List searchApproved(String q) { + if (q == null || q.isBlank()) { + return visitRequestRepository.findByStatusOrderByVisitFromAsc(VisitStatus.APPROVED); + } + return visitRequestRepository.searchApprovedByVisitorName(q.trim()); + } + + // ===== helpers ===== + + private VisitRequest resolve(CheckInRequest req) { + if (req.getQrToken() != null && !req.getQrToken().isBlank()) { + return visitRequestRepository.findByQrToken(req.getQrToken().trim()) + .orElseThrow(() -> ApiException.notFound("유효하지 않은 QR 코드입니다.")); + } + if (req.getVisitRequestId() != null) { + return visitRequestRepository.findById(req.getVisitRequestId()) + .orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다.")); + } + throw ApiException.badRequest("QR 코드 또는 방문 신청을 지정하세요."); + } + + /** True if the last recorded event for the visit is an entry (currently inside). */ + public boolean isInside(Long visitRequestId) { + return accessEventRepository.findFirstByVisitRequestIdOrderByEventAtDesc(visitRequestId) + .map(e -> e.getDirection() == Direction.IN) + .orElse(false); + } + + /** True if the visit already has an exit (OUT) recorded today — blocks same-day re-entry. */ + public boolean hasExitedToday(Long visitRequestId) { + LocalDateTime start = LocalDate.now().atStartOfDay(); + LocalDateTime end = start.plusDays(1); + return accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(visitRequestId).stream() + .anyMatch(e -> e.getDirection() == Direction.OUT + && !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end)); + } + + private AccessEvent record(VisitRequest vr, Direction direction, String gateId, User operator, LocalDateTime at) { + AccessEvent event = new AccessEvent(); + event.setVisitRequest(vr); + event.setDirection(direction); + event.setGateId(gateId); + event.setOperator(operator); + event.setEventAt(at); + return accessEventRepository.save(event); + } + + /** Loads the operator, or null for self-service (kiosk) actions. */ + private User loadOperator(Long operatorId) { + if (operatorId == null) { + return null; + } + return userRepository.findById(operatorId) + .orElseThrow(() -> ApiException.notFound("처리자(운영자)를 찾을 수 없습니다.")); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java b/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java new file mode 100644 index 0000000..ac96754 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/ApprovalService.java @@ -0,0 +1,90 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.entity.Approval; +import com.itcenter.acs.entity.ApprovalDecision; +import com.itcenter.acs.entity.User; +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.VisitStatus; +import com.itcenter.acs.exception.ApiException; +import com.itcenter.acs.notification.PassNotifier; +import com.itcenter.acs.repository.ApprovalRepository; +import com.itcenter.acs.repository.UserRepository; +import com.itcenter.acs.repository.VisitRequestRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class ApprovalService { + + /** QR pixel size for the pass image sent to the visitor. */ + private static final int PASS_QR_SIZE = 240; + + private final VisitRequestRepository visitRequestRepository; + private final ApprovalRepository approvalRepository; + private final UserRepository userRepository; + private final QrService qrService; + private final PassNotifier passNotifier; + + public VisitRequest approve(Long visitRequestId, Long approverId, String comment) { + return decide(visitRequestId, approverId, comment, ApprovalDecision.APPROVED); + } + + public VisitRequest reject(Long visitRequestId, Long approverId, String comment) { + return decide(visitRequestId, approverId, comment, ApprovalDecision.REJECTED); + } + + private VisitRequest decide(Long visitRequestId, Long approverId, String comment, ApprovalDecision decision) { + VisitRequest vr = visitRequestRepository.findById(visitRequestId) + .orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다.")); + + if (vr.getStatus() != VisitStatus.PENDING) { + throw ApiException.conflict("이미 처리된 신청입니다. (현재 상태: " + vr.getStatus() + ")"); + } + + User approver = userRepository.findById(approverId) + .orElseThrow(() -> ApiException.notFound("승인자를 찾을 수 없습니다.")); + + if (decision == ApprovalDecision.APPROVED) { + vr.setStatus(VisitStatus.APPROVED); + vr.setQrToken(UUID.randomUUID().toString()); + } else { + vr.setStatus(VisitStatus.REJECTED); + } + + Approval approval = new Approval(); + approval.setVisitRequest(vr); + approval.setApprover(approver); + approval.setDecision(decision); + approval.setComment(comment); + approval.setDecidedAt(LocalDateTime.now()); + approvalRepository.save(approval); + + if (decision == ApprovalDecision.APPROVED) { + notifyVisitor(vr); + } + + return vr; + } + + /** + * Sends the freshly issued pass to the visitor. A delivery failure must not + * roll back the approval, so it is caught and logged rather than propagated. + */ + private void notifyVisitor(VisitRequest vr) { + try { + byte[] qrPng = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE); + passNotifier.sendPass(vr, qrPng); + } catch (Exception e) { + log.warn("[approval] 출입증 발송 실패 (승인은 정상 처리됨). visitRequestId={} err={}", + vr.getId(), e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/AuthService.java b/backend/src/main/java/com/itcenter/acs/service/AuthService.java new file mode 100644 index 0000000..8ba8712 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/AuthService.java @@ -0,0 +1,34 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.entity.User; +import com.itcenter.acs.exception.ApiException; +import com.itcenter.acs.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public User changePassword(Long userId, String oldPassword, String newPassword) { + User user = userRepository.findById(userId) + .orElseThrow(() -> ApiException.notFound("사용자를 찾을 수 없습니다.")); + + if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) { + throw ApiException.badRequest("현재 비밀번호가 올바르지 않습니다."); + } + if (passwordEncoder.matches(newPassword, user.getPasswordHash())) { + throw ApiException.badRequest("새 비밀번호가 기존 비밀번호와 동일합니다."); + } + + user.setPasswordHash(passwordEncoder.encode(newPassword)); + user.setMustChangePassword(false); + return user; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/BlacklistService.java b/backend/src/main/java/com/itcenter/acs/service/BlacklistService.java new file mode 100644 index 0000000..e9eb9c7 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/BlacklistService.java @@ -0,0 +1,59 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.dto.BlacklistRequest; +import com.itcenter.acs.entity.Blacklist; +import com.itcenter.acs.entity.User; +import com.itcenter.acs.exception.ApiException; +import com.itcenter.acs.repository.BlacklistRepository; +import com.itcenter.acs.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class BlacklistService { + + private final BlacklistRepository blacklistRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List listActive() { + return blacklistRepository.findByActiveTrueOrderByUpdatedAtDesc(); + } + + public Blacklist add(BlacklistRequest req, Long createdByUserId) { + User creator = userRepository.findById(createdByUserId) + .orElseThrow(() -> ApiException.notFound("등록자를 찾을 수 없습니다.")); + Blacklist b = new Blacklist(); + b.setName(req.getName()); + b.setCompany(req.getCompany()); + b.setContact(req.getContact()); + b.setReason(req.getReason()); + b.setActive(true); + b.setCreatedBy(creator); + return blacklistRepository.save(b); + } + + /** Soft-deactivate (lift) a block. */ + public void deactivate(Long id) { + Blacklist b = blacklistRepository.findById(id) + .orElseThrow(() -> ApiException.notFound("차단 항목을 찾을 수 없습니다.")); + b.setActive(false); + } + + /** Returns the matching block reason, or null if not blacklisted. */ + @Transactional(readOnly = true) + public String blockReason(String name, String contact) { + if (name == null) { + return null; + } + return blacklistRepository.findActiveMatches(name, contact).stream() + .findFirst() + .map(Blacklist::getReason) + .orElse(null); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/ExcelImportService.java b/backend/src/main/java/com/itcenter/acs/service/ExcelImportService.java new file mode 100644 index 0000000..0475e14 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/ExcelImportService.java @@ -0,0 +1,138 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.dto.ExcelImportResult; +import com.itcenter.acs.dto.VisitRequestCreateRequest; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +/** + * Bulk-imports visit requests from an .xlsx file. + * Columns (row 1 = header, skipped): + * 0 visitorName | 1 company | 2 contact | 3 email | 4 vehicleNo + * 5 zoneName | 6 purpose | 7 visitFrom | 8 visitTo + * Dates accept Excel date cells or "yyyy-MM-dd HH:mm" / "yyyy-MM-dd" text. + */ +@Service +@RequiredArgsConstructor +public class ExcelImportService { + + private final VisitRequestService visitRequestService; + + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd[ HH:mm]"); + + // Not @Transactional: each row imports in its own transaction (create() is + // @Transactional), so a duplicate/invalid row fails independently without + // poisoning the others' transaction. + public ExcelImportResult importVisitRequests(MultipartFile file, Long hostUserId) throws IOException { + ExcelImportResult result = new ExcelImportResult(); + int dataRows = 0; + + try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) { + Sheet sheet = workbook.getSheetAt(0); + for (Row row : sheet) { + if (row.getRowNum() == 0) { + continue; // header + } + if (isEmptyRow(row)) { + continue; + } + dataRows++; + try { + VisitRequestCreateRequest req = parseRow(row); + visitRequestService.create(req, hostUserId); + result.setSuccessCount(result.getSuccessCount() + 1); + } catch (Exception e) { + result.getErrors().add("행 " + (row.getRowNum() + 1) + ": " + e.getMessage()); + } + } + } + result.setTotalRows(dataRows); + return result; + } + + private VisitRequestCreateRequest parseRow(Row row) { + VisitRequestCreateRequest req = new VisitRequestCreateRequest(); + req.setVisitorName(requireString(row.getCell(0), "방문자 이름")); + req.setCompany(getString(row.getCell(1))); + req.setContact(requireString(row.getCell(2), "연락처")); + req.setEmail(getString(row.getCell(3))); + req.setVehicleNo(getString(row.getCell(4))); + req.setZoneName(getString(row.getCell(5))); + req.setPurpose(requireString(row.getCell(6), "출입 목적")); + req.setVisitFrom(requireDateTime(row.getCell(7), "출입 일시")); + req.setVisitTo(requireDateTime(row.getCell(8), "퇴실 일시")); + return req; + } + + private boolean isEmptyRow(Row row) { + for (int c = 0; c <= 8; c++) { + String v = getString(row.getCell(c)); + if (v != null && !v.isBlank()) { + return false; + } + } + return true; + } + + private String getString(Cell cell) { + if (cell == null) { + return null; + } + return switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue().trim(); + case NUMERIC -> DateUtil.isCellDateFormatted(cell) + ? cell.getLocalDateTimeCellValue().toString() + : stripDecimal(cell.getNumericCellValue()); + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); + case FORMULA -> cell.getCellFormula(); + default -> null; + }; + } + + private String stripDecimal(double d) { + if (d == Math.floor(d) && !Double.isInfinite(d)) { + return String.valueOf((long) d); + } + return String.valueOf(d); + } + + private String requireString(Cell cell, String field) { + String v = getString(cell); + if (v == null || v.isBlank()) { + throw new IllegalArgumentException(field + "은(는) 필수입니다."); + } + return v; + } + + private LocalDateTime requireDateTime(Cell cell, String field) { + if (cell == null) { + throw new IllegalArgumentException(field + "은(는) 필수입니다."); + } + if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { + return cell.getLocalDateTimeCellValue(); + } + String text = requireString(cell, field); + try { + if (text.length() <= 10) { + return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay(); + } + return LocalDateTime.parse(text.replace('T', ' '), DT); + } catch (Exception e) { + throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다 (yyyy-MM-dd HH:mm): " + text); + } + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/QrService.java b/backend/src/main/java/com/itcenter/acs/service/QrService.java new file mode 100644 index 0000000..75db5f1 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/QrService.java @@ -0,0 +1,38 @@ +package com.itcenter.acs.service; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; +import com.itcenter.acs.exception.ApiException; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.util.Map; + +/** + * Generates QR-code PNG images for visitor passes/badges (ZXing). + */ +@Service +public class QrService { + + public byte[] pngForText(String text, int size) { + if (text == null || text.isBlank()) { + throw ApiException.badRequest("QR로 인코딩할 값이 없습니다."); + } + try { + Map hints = Map.of( + EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M, + EncodeHintType.MARGIN, 1, + EncodeHintType.CHARACTER_SET, "UTF-8"); + BitMatrix matrix = new QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size, hints); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(matrix, "PNG", out); + return out.toByteArray(); + } catch (Exception e) { + throw new ApiException(500, "QR 생성 실패: " + e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/ReportService.java b/backend/src/main/java/com/itcenter/acs/service/ReportService.java new file mode 100644 index 0000000..a6dde09 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/ReportService.java @@ -0,0 +1,108 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.VisitStatus; +import com.itcenter.acs.exception.ApiException; +import com.itcenter.acs.repository.VisitRequestRepository; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayOutputStream; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * Builds an Excel (.xlsx) report of visit requests in a date range (by visitFrom). + */ +@Service +@RequiredArgsConstructor +public class ReportService { + + private final VisitRequestRepository visitRequestRepository; + + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final Map STATUS_KO = Map.of( + VisitStatus.DRAFT, "임시저장", + VisitStatus.PENDING, "승인대기", + VisitStatus.APPROVED, "승인완료", + VisitStatus.REJECTED, "반려", + VisitStatus.CANCELLED, "취소", + VisitStatus.EXPIRED, "만료"); + + @Transactional(readOnly = true) + public byte[] visitReport(LocalDate from, LocalDate to) { + if (from == null || to == null) { + throw ApiException.badRequest("조회 기간(from, to)을 지정하세요."); + } + if (to.isBefore(from)) { + throw ApiException.badRequest("종료일이 시작일보다 빠를 수 없습니다."); + } + + List rows = visitRequestRepository + .findByVisitFromBetweenOrderByVisitFromAsc(from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "출입일시", "퇴실일시", "상태"}; + + try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + Sheet sheet = wb.createSheet("출입기록"); + CellStyle headerStyle = headerStyle(wb); + + Row head = sheet.createRow(0); + for (int i = 0; i < headers.length; i++) { + Cell c = head.createCell(i); + c.setCellValue(headers[i]); + c.setCellStyle(headerStyle); + } + + int r = 1; + for (VisitRequest vr : rows) { + Row row = sheet.createRow(r++); + row.createCell(0).setCellValue(vr.getVisitor().getName()); + row.createCell(1).setCellValue(nv(vr.getVisitor().getCompany())); + row.createCell(2).setCellValue(nv(vr.getVisitor().getContact())); + row.createCell(3).setCellValue(nv(vr.getZoneName())); + row.createCell(4).setCellValue(vr.getHost().getFullName()); + row.createCell(5).setCellValue(nv(vr.getPurpose())); + row.createCell(6).setCellValue(fmt(vr.getVisitFrom())); + row.createCell(7).setCellValue(fmt(vr.getVisitTo())); + row.createCell(8).setCellValue(STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name())); + } + + for (int i = 0; i < headers.length; i++) { + sheet.autoSizeColumn(i); + } + + wb.write(out); + return out.toByteArray(); + } catch (Exception e) { + throw new ApiException(500, "리포트 생성 실패: " + e.getMessage()); + } + } + + private CellStyle headerStyle(Workbook wb) { + CellStyle style = wb.createCellStyle(); + Font font = wb.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private String fmt(LocalDateTime t) { + return t != null ? t.format(DT) : ""; + } + + private String nv(String s) { + return s != null ? s : ""; + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/StatsService.java b/backend/src/main/java/com/itcenter/acs/service/StatsService.java new file mode 100644 index 0000000..29dd2ee --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/StatsService.java @@ -0,0 +1,32 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.dto.StatsSummaryResponse; +import com.itcenter.acs.entity.VisitStatus; +import com.itcenter.acs.repository.AccessEventRepository; +import com.itcenter.acs.repository.VisitRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StatsService { + + private final VisitRequestRepository visitRequestRepository; + private final AccessEventRepository accessEventRepository; + + public StatsSummaryResponse summary() { + LocalDateTime dayStart = LocalDate.now().atStartOfDay(); + LocalDateTime dayEnd = dayStart.plusDays(1); + return new StatsSummaryResponse( + visitRequestRepository.countByVisitFromBetween(dayStart, dayEnd), + visitRequestRepository.countByStatus(VisitStatus.PENDING), + visitRequestRepository.countByStatus(VisitStatus.APPROVED), + accessEventRepository.findInsideVisitRequestIds().size(), + visitRequestRepository.count()); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/service/VisitRequestService.java b/backend/src/main/java/com/itcenter/acs/service/VisitRequestService.java new file mode 100644 index 0000000..911a59c --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/service/VisitRequestService.java @@ -0,0 +1,97 @@ +package com.itcenter.acs.service; + +import com.itcenter.acs.dto.VisitRequestCreateRequest; +import com.itcenter.acs.entity.User; +import com.itcenter.acs.entity.VisitRequest; +import com.itcenter.acs.entity.VisitStatus; +import com.itcenter.acs.entity.Visitor; +import com.itcenter.acs.exception.ApiException; +import com.itcenter.acs.repository.UserRepository; +import com.itcenter.acs.repository.VisitRequestRepository; +import com.itcenter.acs.repository.VisitorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class VisitRequestService { + + private final VisitRequestRepository visitRequestRepository; + private final VisitorRepository visitorRepository; + private final UserRepository userRepository; + + /** + * Create a pre-registration. Reuses an existing visitor (matched by name+contact) + * or creates a new one. The created request starts in PENDING. + */ + public VisitRequest create(VisitRequestCreateRequest req, Long hostUserId) { + if (req.getVisitTo().isBefore(req.getVisitFrom())) { + throw ApiException.badRequest("방문 종료 일시가 시작 일시보다 빠를 수 없습니다."); + } + + User host = userRepository.findById(hostUserId) + .orElseThrow(() -> ApiException.notFound("호스트 사용자를 찾을 수 없습니다.")); + + Visitor visitor = visitorRepository + .findFirstByNameAndContact(req.getVisitorName(), req.getContact()) + .orElseGet(Visitor::new); + visitor.setName(req.getVisitorName()); + visitor.setCompany(req.getCompany()); + visitor.setContact(req.getContact()); + visitor.setEmail(req.getEmail()); + visitor.setVehicleNo(req.getVehicleNo()); + visitor = visitorRepository.save(visitor); + + if (visitRequestRepository.existsActiveDuplicate( + visitor.getId(), req.getVisitFrom(), req.getVisitTo(), req.getZoneName())) { + throw ApiException.conflict("이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복)"); + } + + VisitRequest vr = new VisitRequest(); + vr.setVisitor(visitor); + vr.setHost(host); + vr.setZoneName(req.getZoneName()); + vr.setPurpose(req.getPurpose()); + vr.setVisitFrom(req.getVisitFrom()); + vr.setVisitTo(req.getVisitTo()); + vr.setStatus(VisitStatus.PENDING); + return visitRequestRepository.save(vr); + } + + @Transactional(readOnly = true) + public List listAll() { + return visitRequestRepository.findAllByOrderByVisitFromDesc(); + } + + @Transactional(readOnly = true) + public List listByHost(Long hostUserId) { + return visitRequestRepository.findByHostIdOrderByVisitFromDesc(hostUserId); + } + + @Transactional(readOnly = true) + public List listPending() { + return visitRequestRepository.findByStatusOrderByVisitFromAsc(VisitStatus.PENDING); + } + + @Transactional(readOnly = true) + public VisitRequest get(Long id) { + return visitRequestRepository.findById(id) + .orElseThrow(() -> ApiException.notFound("방문 신청을 찾을 수 없습니다.")); + } + + public VisitRequest cancel(Long id, Long requesterId, boolean isAdmin) { + VisitRequest vr = get(id); + if (!isAdmin && !vr.getHost().getId().equals(requesterId)) { + throw new ApiException(403, "본인이 등록한 신청만 취소할 수 있습니다."); + } + if (vr.getStatus() != VisitStatus.PENDING && vr.getStatus() != VisitStatus.APPROVED) { + throw ApiException.badRequest("취소할 수 없는 상태입니다: " + vr.getStatus()); + } + vr.setStatus(VisitStatus.CANCELLED); + return vr; + } +} diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties new file mode 100644 index 0000000..b6c6520 --- /dev/null +++ b/backend/src/main/resources/application-prod.properties @@ -0,0 +1,31 @@ +spring.application.name=acs +server.port=8080 + +# ===== PostgreSQL (prod) ===== +spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:acs} +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.username=${POSTGRES_USER:postgres} +spring.datasource.password=${POSTGRES_PASSWORD:change_me} + +# ===== JPA / Hibernate ===== +# Schema is owned by Flyway migrations; Hibernate only validates. +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.open-in-view=false +spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect + +# ===== Flyway ===== +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration + +# ===== SMS/LMS pass delivery ===== +# provider=dev -> logs + writes QR to outbox-dir; provider=hanbank -> sends LMS +# with the public pass link via the in-house DMZ message API. +acs.sms.provider=${ACS_SMS_PROVIDER:dev} +acs.sms.api-url=${ACS_SMS_API_URL:http://210.104.132.59:8000} +acs.sms.outbox-dir=${ACS_SMS_OUTBOX_DIR:/app/sms-outbox} +# Base URL the SMS link points to — must be reachable from the visitor's phone. +acs.public-base-url=${ACS_PUBLIC_BASE_URL:http://localhost} + +logging.level.root=WARN +logging.level.com.itcenter.acs=INFO diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 0000000..f5a2241 --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1,40 @@ +spring.application.name=acs +server.port=8080 + +# ===== Logging ===== +logging.level.root=INFO +logging.level.com.itcenter.acs=DEBUG + +# ===== H2 in-memory (local dev, default) ===== +spring.datasource.url=jdbc:h2:mem:acsdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# ===== JPA / Hibernate ===== +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.format_sql=false + +# H2 console (dev only): http://localhost:8080/h2-console (JDBC URL above) +spring.h2.console.enabled=true + +# Flyway is for the prod (PostgreSQL) profile; schema in dev comes from ddl-auto +spring.flyway.enabled=false + +# Multipart (Excel upload) +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB + +# ===== SMS/LMS pass delivery ===== +# provider=dev -> LoggingPassNotifier: logs + writes QR to outbox-dir (no network) +# provider=hanbank -> HanbankMessagePassNotifier: sends an LMS with the public +# pass link via the in-house DMZ message API (requires network reachability). +acs.sms.provider=dev +acs.sms.outbox-dir=./sms-outbox +acs.sms.api-url=http://210.104.132.59:8000 +# Base URL the SMS link points to (the visitor's public pass page). +acs.public-base-url=http://localhost:5173 + +spring.profiles.active=local diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..357d0f1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,115 @@ +-- IT센터 출입자관리시스템 — 초기 스키마 (PostgreSQL / prod) +-- Hibernate ddl-auto=validate 가 검증하므로 엔티티와 컬럼명·타입이 일치해야 한다. + +-- ===== users ===== +CREATE TABLE users ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(80) NOT NULL, + email VARCHAR(120), + department VARCHAR(80), + must_change_password BOOLEAN NOT NULL, + enabled BOOLEAN NOT NULL, + locked BOOLEAN NOT NULL +); + +CREATE TABLE user_roles ( + user_id BIGINT NOT NULL REFERENCES users (id), + role VARCHAR(20) NOT NULL +); +CREATE INDEX idx_user_roles_user ON user_roles (user_id); + +-- ===== zones ===== +CREATE TABLE zones ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + code VARCHAR(30) NOT NULL UNIQUE, + name VARCHAR(80) NOT NULL, + description VARCHAR(255), + security_level INTEGER NOT NULL, + active BOOLEAN NOT NULL +); + +-- ===== visitors ===== +CREATE TABLE visitors ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + name VARCHAR(80) NOT NULL, + company VARCHAR(120), + contact VARCHAR(40), + email VARCHAR(120), + vehicle_no VARCHAR(20) +); +CREATE INDEX idx_visitor_name ON visitors (name); +CREATE INDEX idx_visitor_contact ON visitors (contact); + +-- ===== visit_requests ===== +CREATE TABLE visit_requests ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + visitor_id BIGINT NOT NULL REFERENCES visitors (id), + host_id BIGINT NOT NULL REFERENCES users (id), + zone_name VARCHAR(80), + purpose VARCHAR(255) NOT NULL, + visit_from TIMESTAMP NOT NULL, + visit_to TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL, + qr_token VARCHAR(64) +); +CREATE INDEX idx_vr_status ON visit_requests (status); +CREATE INDEX idx_vr_visit_from ON visit_requests (visit_from); +CREATE INDEX idx_vr_qr_token ON visit_requests (qr_token); + +-- ===== approvals ===== +CREATE TABLE approvals ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + visit_request_id BIGINT NOT NULL REFERENCES visit_requests (id), + approver_id BIGINT NOT NULL REFERENCES users (id), + decision VARCHAR(20) NOT NULL, + comment VARCHAR(500), + decided_at TIMESTAMP NOT NULL +); + +-- ===== access_events ===== +CREATE TABLE access_events ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + visit_request_id BIGINT NOT NULL REFERENCES visit_requests (id), + direction VARCHAR(8) NOT NULL, + gate_id VARCHAR(40), + operator_id BIGINT REFERENCES users (id), + event_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_ae_visit_request ON access_events (visit_request_id); +CREATE INDEX idx_ae_event_at ON access_events (event_at); +CREATE INDEX idx_ae_direction ON access_events (direction); + +-- ===== blacklist ===== +CREATE TABLE blacklist ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + name VARCHAR(80) NOT NULL, + company VARCHAR(120), + contact VARCHAR(40), + reason VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL, + created_by BIGINT REFERENCES users (id) +); +CREATE INDEX idx_bl_name ON blacklist (name); +CREATE INDEX idx_bl_contact ON blacklist (contact); + +-- ===== seed: 출입 구역 (users 는 scripts/seed-load.py 로 적재) ===== +INSERT INTO zones (created_at, updated_at, code, name, description, security_level, active) VALUES + (now(), now(), 'LOBBY', '로비', NULL, 1, TRUE), + (now(), now(), 'OFFICE', '사무공간', NULL, 2, TRUE), + (now(), now(), 'SERVER_ROOM', '전산실', NULL, 3, TRUE); diff --git a/backend/src/test/java/com/itcenter/acs/FlywayValidationTest.java b/backend/src/test/java/com/itcenter/acs/FlywayValidationTest.java new file mode 100644 index 0000000..e8cea70 --- /dev/null +++ b/backend/src/test/java/com/itcenter/acs/FlywayValidationTest.java @@ -0,0 +1,21 @@ +package com.itcenter.acs; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Boots the full context with Flyway running V1__init.sql on H2 (PostgreSQL mode) + * and Hibernate ddl-auto=validate. Fails if the migration and the JPA entities + * drift apart (missing/renamed column, wrong type), which is the most likely way + * a prod boot would break. Acts as a regression guard for the migration. + */ +@SpringBootTest +@ActiveProfiles("fwtest") +class FlywayValidationTest { + + @Test + void migrationMatchesEntities() { + // Context load = Flyway migrate + Hibernate validate both succeeded. + } +} diff --git a/backend/src/test/resources/application-fwtest.properties b/backend/src/test/resources/application-fwtest.properties new file mode 100644 index 0000000..fb2d6f3 --- /dev/null +++ b/backend/src/test/resources/application-fwtest.properties @@ -0,0 +1,11 @@ +# Temporary profile: run Flyway V1__init.sql on H2 (PostgreSQL mode) and let +# Hibernate ddl-auto=validate check the migration matches the entities. +spring.datasource.url=jdbc:h2:mem:fwtest;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.h2.console.enabled=false diff --git a/backend/test-api.http b/backend/test-api.http new file mode 100644 index 0000000..efcdfd0 --- /dev/null +++ b/backend/test-api.http @@ -0,0 +1,53 @@ +### IT센터 출입자관리시스템 — API smoke test +### 세션 쿠키 기반. 로그인 후 같은 클라이언트로 호출하세요. +@base = http://localhost:8080 + +### 1) 로그인 (호스트) +POST {{base}}/api/auth/login +Content-Type: application/json + +{ "username": "host", "password": "ChangeMe123!" } + +### 2) 현재 사용자 +GET {{base}}/api/auth/me + +### 3) 출입 구역 목록 +GET {{base}}/api/zones + +### 4) 방문 사전 신청 +POST {{base}}/api/visit-requests +Content-Type: application/json + +{ + "visitorName": "김방문", + "company": "ACME", + "contact": "010-1234-5678", + "zoneId": 1, + "purpose": "정기 미팅", + "visitFrom": "2026-07-01T10:00", + "visitTo": "2026-07-01T12:00" +} + +### 5) 방문 신청 목록 +GET {{base}}/api/visit-requests + +### 6) 승인 대기 목록 +GET {{base}}/api/visit-requests/pending + +### 7) 로그인 (관리자) — 승인 권한 +POST {{base}}/api/auth/login +Content-Type: application/json + +{ "username": "admin", "password": "ChangeMe123!" } + +### 8) 승인 (qrToken 발급됨) +POST {{base}}/api/approvals/1/approve +Content-Type: application/json + +{ "comment": "확인 완료" } + +### 9) 반려 +POST {{base}}/api/approvals/2/reject +Content-Type: application/json + +{ "comment": "방문 목적 불명확" } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..17030fe --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# ACS 문서 + +IT센터 출입자관리시스템 관련 문서 모음. + +| 문서 | 내용 | +|------|------| +| [workflow.md](workflow.md) | 전체 업무 워크플로우 (서술형) — 역할, 상태 머신, 신청→승인→입·출입→통계 흐름, 배포/운영 | +| [workflow-sequence.md](workflow-sequence.md) | 워크플로우 시퀀스/상태 다이어그램 (Mermaid) | +| [issues-and-guidelines.md](issues-and-guidelines.md) | 기획·개발·테스트·수정 단계 이슈 정리 및 유의사항(규칙) | + +> 프로젝트 개요·실행 방법·API 요약은 상위 [README.md](../README.md) 참조. diff --git a/docs/issues-and-guidelines.md b/docs/issues-and-guidelines.md new file mode 100644 index 0000000..e633185 --- /dev/null +++ b/docs/issues-and-guidelines.md @@ -0,0 +1,175 @@ +# ACS 개발 이슈 정리 및 유의사항(규칙) + +> 문서 작성일: 2026-07-03 +> 대상: IT센터 출입자관리시스템 (`C:\ai-dev\workspace\access-control-system`) +> 목적: 기획·개발·테스트·수정 단계에서 실제로 겪은 이슈를 정리하고, 재발 방지를 위한 **규칙**으로 제안한다. +> 표기: 각 항목은 **[이슈] → [규칙]** 형태. 규칙 요약은 문서 끝 §6 체크리스트 참조. + +--- + +## 1. 기획 단계 + +### 1-1. 출입 하드웨어 미확정 +- **[이슈]** 실제 출입통제 게이트 장비가 확정되지 않은 상태에서 개발을 시작해야 했다. +- **[규칙]** 외부 의존(장비·SMS·인증서 등)은 **인터페이스로 추상화**하고 Mock 구현을 기본 제공한다. ACS는 `AccessControlGateway` + `MockAccessControlGateway`로 이 원칙을 지켰다. 실장비는 구현체 교체만으로 붙일 수 있어야 한다. + +### 1-2. 상태(라이프사이클) 정의를 코드보다 먼저 +- **[이슈]** 방문 신청의 상태(승인 대기/승인/반려/취소/만료)가 모호하면 서비스 곳곳의 분기가 흐트러진다. +- **[규칙]** 도메인 상태 머신을 **먼저 확정**하고 enum(`VisitStatus`)으로 고정한다. 상태 전이 규칙(예: `PENDING`만 승인 가능, 방문일 경과 시 `EXPIRED`)을 문서/주석에 남긴다. + +### 1-3. 역할·권한 경계 +- **[이슈]** ADMIN/SECURITY/HOST의 화면·API 접근 범위가 불명확하면 권한 누수가 생긴다. +- **[규칙]** 역할별 접근 표를 기획 단계에서 확정하고, 프론트 라우팅 가드(`Protected roles=[...]`)와 백엔드 인가(`SecurityConfig`)에서 **이중으로** 강제한다. 프론트 가드만 믿지 않는다. + +### 1-4. 방문자 개인정보·공개 링크 +- **[이슈]** 방문자에게 보내는 출입증 링크(`/pass/:token`)는 비로그인 공개 페이지다. +- **[규칙]** 공개 식별자는 **추측 불가능한 토큰**(UUID `qrToken`)만 사용하고, 순번 ID를 공개 URL에 노출하지 않는다. + +--- + +## 2. 개발 단계 — 빌드/환경 + +> 이 프로젝트는 JDK 26 + Maven + 사내 보안환경(EDR·선택적 SSL 인스펙션)이라는 특수 조합에서 개발되었다. 아래는 재현성이 확인된 함정들이다. + +### 2-1. JDK 26에서 Lombok이 조용히 동작 안 함 +- **[이슈]** `cannot find symbol: method getX/setX` 수백 개. Lombok 애너테이션 프로세서가 침묵 실패. +- **[규칙]** 두 가지를 **모두** 적용한다 (하나만으론 부족): + 1. `pom.xml `에 `1.18.46` (Spring Boot가 핀한 1.18.38은 JDK 26 미지원). + 2. `maven-compiler-plugin`에 명시적 `annotationProcessorPaths`로 lombok 등록 (JDK 23+는 classpath 자동 발견을 하지 않음). +- 참고: `backend/pom.xml`이 정상 레퍼런스. + +### 2-2. 콜드 Maven 빌드가 `.lastUpdated` 파일잠금으로 죽음 +- **[이슈]** 처음 빌드 시 `FileSystemException: ....lastUpdated: 다른 프로세스가 파일 사용 중` → reactor 전체 중단. 원인은 사내 EDR의 실시간 파일 잠금. +- **[규칙]** + - `MAVEN_OPTS`에 `-Dmaven.legacyLocalRepo=true` (env.cmd에 반영됨). 직접 mvn 실행 시에도 포함. + - 콜드 빌드는 실패하면 `find .m2 -name '*.lastUpdated' -delete` 후 **재시도 루프**로 repo를 데운다. 한번 캐시가 따뜻해지면 이후엔 깨끗하게 빌드된다. + - 검증 실행은 `mvn package`로 fat jar를 만들어 **`java -jar`로 구동**하는 것이 가장 안전(런타임에 Maven 불필요 → 잠금 회피). + +### 2-3. 편집 중 잔여 `.tmp.*` 파일 +- **[이슈]** 소스 곳곳에 `*.java.tmp.PID.hash`, `*.tsx.tmp...` 같은 잔여 파일이 남아 있다(현재도 7개 존재). 에디터/툴이 원자적 교체를 하는 중 EDR 잠금으로 임시본이 정리되지 못한 흔적. +- **[규칙]** + - 커밋/빌드 전 `find . -name "*.tmp.*" -not -path '*/target/*' -delete`로 정리. + - `.gitignore`에 `*.tmp.*` 패턴을 추가해 저장소 오염을 막는다. + +### 2-4. 사내 SSL 인스펙션 (선택적) +- **[이슈]** 명시적 프록시는 없지만 일부 도메인은 사내 장비가 인증서를 재서명(Bank of Korea CA). curl/git은 폐기검사 hard-fail(`CRYPT_E_NO_REVOCATION_CHECK`)로 끊김. +- **[규칙]** + - **검증을 끄지 않는다.** `verify=False`, 사내 CA 단독 번들 교체(`REQUESTS_CA_BUNDLE`=사내단독) 금지 — 공인 CA 호스트(npm/pypi/github)가 깨진다. + - **추가형/저장소형 신뢰**만 사용: Windows 저장소(공인+사내 CA)를 쓰고, 폐기검사만 건너뛴다(curl `ssl-no-revoke`, git `schannelCheckRevoke=false`). + - TLS/인증서 오류가 나면 임의 대응 대신 **`corporate-cert-fix` 스킬**을 사용한다. + +### 2-5. `.cmd` 스크립트 인코딩 +- **[이슈]** `.cmd` 편집 시 LF 혼입 → cmd.exe 파싱 깨짐. 한글 주석이 든 .cmd는 CP949 콘솔에서 바이트 desync로 줄이 깨져 엉뚱한 명령 실행. +- **[규칙]** `scripts\*.cmd`는 **ASCII 전용 + CRLF 줄바꿈**. 한글 설명은 `.cmd`가 아니라 별도 `readme\`·`data\` md에 둔다. + +--- + +## 3. 개발 단계 — 도메인 로직 + +### 3-1. 승인 시점에 qrToken 발급 +- **[이슈]** 출입증 QR·공개 링크·문자 발송이 모두 하나의 토큰에 의존한다. +- **[규칙]** `qrToken`(UUID)은 **승인(`APPROVED`) 시점에만** 발급한다(`ApprovalService`). 신청/반려 단계에서는 발급하지 않는다. + +### 3-2. 알림 발송 실패가 승인을 롤백하면 안 됨 +- **[이슈]** 문자 발송(외부 API)이 실패하면 승인 트랜잭션까지 롤백될 위험. +- **[규칙]** 부수효과(알림)의 실패는 **catch & log**로 흡수하고 주 트랜잭션(승인)은 커밋한다(`ApprovalService.notifyVisitor`). 외부 I/O 실패로 핵심 업무가 무효화되지 않게 한다. + +### 3-3. 체크인 검증 순서 고정 +- **[이슈]** 검증 순서가 흐트러지면 만료·차단·중복입장이 잘못된 우선순위로 처리될 수 있다. +- **[규칙]** `AccessService.checkIn`의 검증 순서를 유지한다: + 1. 상태 `APPROVED` 확인 + 2. 방문 종료일 경과 → `EXPIRED` 전이 + 거부 + 3. 방문 시작일 이전 → 거부 + 4. 블랙리스트 매칭 → 403 차단 + 5. 이미 재실 중 → 409 중복입장 + 6. 금일 퇴장 완료 → 재입장 불가 +- 상태 값·거부 사유 메시지를 응답에 명확히 담는다. + +### 3-4. "일자" 기준 판정 (늦은 도착 허용) +- **[이슈]** 시각까지 엄격히 보면 몇 분 늦은 방문자가 입장 거부된다. +- **[규칙]** 입장 허용은 **날짜(LocalDate) 기준**으로 판정하고, 실제 입출입 시각은 신청 시각과 **분리하여** `access_events.event_at`에 기록한다. + +### 3-5. 재실 판정은 마지막 이벤트로 +- **[이슈]** 입장/퇴장을 별도 플래그로 관리하면 정합성이 깨진다. +- **[규칙]** "현재 재실 중"은 별도 상태 컬럼이 아니라 **해당 방문의 마지막 `AccessEvent` 방향이 `IN`인가**로 판정한다(`isInside`). 단일 진실원본(이벤트 로그)을 유지한다. + +### 3-6. 웹캠 QR 스캔은 secure context 필수 +- **[이슈]** 출입콘솔/키오스크의 웹캠 QR 스캔이 `http://내부IP` 접속 시 카메라 차단으로 동작 안 함. +- **[규칙]** 카메라 기능은 **HTTPS 또는 localhost**에서만 동작함을 전제로 배포한다. QR 스캔이 필요한 단말은 HTTPS 도메인 또는 localhost로 접속시킨다(대안: 이름 검색 체크인). + +### 3-7. 문자 링크 주소(`ACS_PUBLIC_BASE_URL`) +- **[이슈]** 문자에 담긴 출입증 링크가 `localhost`면 방문자 휴대폰에서 열리지 않는다. +- **[규칙]** `ACS_PUBLIC_BASE_URL`은 **방문자 휴대폰에서 실제 접속 가능한 외부 URL**로 설정한다. localhost 금지. 실발송(`hanbank`) 전 메시지 API 도달성(`nc -vz`)을 확인한다. + +--- + +## 4. 테스트 단계 + +### 4-1. 런타임 end-to-end 검증 +- **[이슈]** 컴파일 성공 ≠ 동작. 특히 QR 이미지·엑셀·권한 가드는 실제 응답을 봐야 안다. +- **[규칙]** 핵심 플로우는 **curl 실서버 검증**으로 확인한다(로그인→신청→승인→체크인→재실→체크아웃, 역할 가드 403 포함). ACS는 1~7단계 전부 런타임 검증됨: QR PNG 240x240 유효/없으면 404, 블랙리스트 add→체크인 403→해제→200, stats 정확, 리포트 xlsx 셀 내용·역할 가드·잘못된 기간 400 확인. +- API 스모크 테스트는 `backend/test-api.http`에 유지한다. + +### 4-2. Flyway 마이그레이션 검증 자동화 +- **[이슈]** prod 스키마(Flyway `V1__init.sql`)와 JPA 엔티티가 어긋나면 운영 기동 시 터진다. +- **[규칙]** Flyway 스크립트를 **JUnit 회귀 테스트**로 검증한다. ACS는 `FlywayValidationTest`(`@ActiveProfiles("fwtest")`, H2 PostgreSQL-mode + `ddl-auto=validate`)로 V1을 검증한다. 스키마를 바꿀 때 이 테스트를 반드시 통과시킨다. + +### 4-3. 경계·예외 케이스 +- **[이슈]** 정상 경로만 보면 중복입장/만료/차단/권한 없음이 방치된다. +- **[규칙]** 각 API의 **거부·오류 경로**(403/404/409/400)를 정상 경로와 함께 테스트한다. 상태 코드와 사유 메시지를 검증한다. + +### 4-4. Docker 실기동은 별도 확인 필요 +- **[이슈]** 검증 시 Docker 데몬이 꺼져 있어 `docker compose up` 실기동은 미검증으로 남았다. +- **[규칙]** 컨테이너 빌드/기동은 **환경 가용 시 별도 검증** 항목으로 명시하고, 미검증이면 "미검증"으로 솔직히 남긴다. 특히 사내망 `docker build`는 SSL 인스펙션에 걸리면 사내 CA 주입/내부 미러가 필요할 수 있다. + +--- + +## 5. 수정/유지보수 단계 + +### 5-1. 버전 관리 시작 +- **[이슈]** 현재 저장소에 **git 커밋 이력이 없다**(작업이 커밋되지 않음). 잔여 `.tmp` 파일도 함께 방치. +- **[규칙]** 의미 있는 단위로 **즉시 커밋**한다. 커밋 전 `.tmp.*` 정리 + 빌드/핵심 테스트 통과 확인. `.gitignore`에 `target/`, `node_modules/`, `*.tmp.*`, `.env`, `sms-outbox/` 등을 등록해 산출물·비밀·임시파일이 커밋되지 않게 한다. + +### 5-2. dev 시드/비밀번호가 prod로 새면 안 됨 +- **[이슈]** `DataSeeder`의 admin/security/host + `ChangeMe123!`는 개발 편의용이다. +- **[규칙]** 시드 계정은 `@Profile("!prod")`로 **prod에서 비활성**한다(적용됨). prod 사용자는 별도 스크립트(`scripts/seed-load.py`)로 적재하고, 최초 로그인 시 비밀번호 변경(`mustChangePassword`)을 강제한다. 기본 비밀번호를 문서/코드에 그대로 두지 않는다. + +### 5-3. 상태 전이 로직 수정 시 파급 확인 +- **[이슈]** `VisitStatus` 전이나 체크인 검증을 고치면 승인·재실·리포트·통계가 연쇄 영향을 받는다. +- **[규칙]** 상태/검증 로직을 수정하면 §3-3 검증 순서와 §4의 end-to-end·Flyway 테스트를 **재실행**한다. 상태 값을 추가/변경하면 프론트 표시 문자열과 통계 집계도 함께 갱신한다. + +### 5-4. 비밀·설정은 `.env`로 분리 +- **[이슈]** DB 비밀번호, SMS provider, 공개 URL 등이 코드에 박히면 환경 이전이 위험해진다. +- **[규칙]** 환경 의존 값은 전부 `.env`(예: `POSTGRES_PASSWORD`, `WEB_PORT`, `ACS_SMS_PROVIDER`, `ACS_PUBLIC_BASE_URL`, `ACS_SMS_API_URL`)로 분리하고 `.env.example`만 커밋한다. 실제 `.env`는 커밋 금지. + +### 5-5. HTTPS/장비 연동 등 미완 항목 추적 +- **[이슈]** HTTPS(카메라), 실 출입장비, 사내망 Docker 빌드는 도메인/인증서/장비 확정 후 진행할 항목으로 남아 있다. +- **[규칙]** 미완/보류 항목은 README·이 문서에 **명시적으로 남기고**, 전제 조건(도메인·인증서·장비 스펙)이 갖춰지면 반영한다. + +--- + +## 6. 규칙 요약 체크리스트 + +**환경/빌드** +- [ ] Lombok 1.18.46 + `annotationProcessorPaths` 유지 (JDK 26) +- [ ] `MAVEN_OPTS`에 `-Dmaven.legacyLocalRepo=true`, 콜드빌드는 retry, 검증은 `java -jar` +- [ ] 커밋 전 `*.tmp.*` 정리, `.gitignore` 등록 +- [ ] TLS 오류 시 검증 끄지 말 것 → `corporate-cert-fix` 스킬 (추가형 신뢰) +- [ ] `scripts\*.cmd`는 ASCII + CRLF, 한글 설명은 md로 분리 + +**도메인 로직** +- [ ] `qrToken`은 승인 시점에만 발급 +- [ ] 알림 실패는 catch & log (승인 롤백 금지) +- [ ] 체크인 검증 순서 6단계 고정, 날짜 기준 판정 +- [ ] 재실 판정은 마지막 이벤트 방향으로 +- [ ] 카메라 QR = secure context, 문자 URL은 외부 접속 가능 주소 + +**테스트** +- [ ] 정상+거부(403/404/409/400) 경로 모두 curl 검증 +- [ ] Flyway `FlywayValidationTest` 통과 +- [ ] Docker 실기동은 가용 시 별도 검증, 미검증이면 명시 + +**보안/운영** +- [ ] 시드 계정 `@Profile("!prod")`, 기본 비밀번호 강제 변경 +- [ ] 비밀/설정은 `.env` 분리, `.env.example`만 커밋 +- [ ] 의미 단위 즉시 커밋, 미완 항목은 문서에 추적 diff --git a/docs/workflow-sequence.md b/docs/workflow-sequence.md new file mode 100644 index 0000000..f4a3ae7 --- /dev/null +++ b/docs/workflow-sequence.md @@ -0,0 +1,163 @@ +# ACS 워크플로우 — 시퀀스/상태 다이어그램 (Mermaid) + +> 대상: IT센터 출입자관리시스템 · 작성일 2026-07-03 +> GitHub·VS Code·mermaid.live 등에서 렌더링됨. 서술형 문서는 [workflow.md](workflow.md) 참조. + +--- + +## 1. 방문 신청 상태 머신 + +```mermaid +stateDiagram-v2 + [*] --> DRAFT: 신청 초안 + DRAFT --> PENDING: 제출 + PENDING --> APPROVED: 승인 (qrToken 발급) + PENDING --> REJECTED: 반려 + PENDING --> CANCELLED: 취소 + APPROVED --> CANCELLED: 취소 + APPROVED --> EXPIRED: 방문 종료일 경과 후 체크인 시도 + APPROVED --> [*]: 출입 완료 + REJECTED --> [*] + CANCELLED --> [*] + EXPIRED --> [*] +``` + +--- + +## 2. 전체 흐름 (신청 → 승인 → 발송 → 입·출입) + +```mermaid +sequenceDiagram + autonumber + actor Host as HOST/담당자 + actor Admin as ADMIN + participant API as ACS 백엔드 + participant SMS as PassNotifier(SMS) + actor Visitor as 방문자 + actor Sec as SECURITY/게이트 + participant GW as AccessControlGateway + + Host->>API: ① 방문 사전신청 (POST /api/visit-requests) + note right of API: 상태 = PENDING (개별 or 엑셀 업로드) + + Admin->>API: ② 승인함 조회 (GET /api/visit-requests/pending) + Admin->>API: ③ 승인 (POST /api/approvals/{id}/approve) + note right of API: 상태 = APPROVED, qrToken(UUID) 발급, Approval 기록 + API->>SMS: ④ 출입증(QR PNG) 발송 요청 + note right of SMS: 발송 실패는 catch&log — 승인은 롤백 안 함 + SMS-->>Visitor: 출입증 링크 문자 (ACS_PUBLIC_BASE_URL/pass/:token) + + Visitor->>API: ⑤ 공개 출입증 열람 (GET /pass/:token, 비로그인) + API-->>Visitor: QR / 방문정보 표시 + + Visitor->>Sec: ⑥ 방문일 현장 도착 + Sec->>API: ⑦ 체크인 (POST /api/access/check-in, QR/이름) + note right of API: 상태·일자·블랙리스트·중복·재입장 검증 (아래 §3) + API->>GW: openGate(gateId) + GW-->>API: accepted + API-->>Sec: 입장 처리 (AccessEvent IN 기록) + + Sec->>API: ⑧ 재실현황 (GET /api/access/inside) + API-->>Sec: 재실 방문자 목록 + + Visitor->>Sec: ⑨ 퇴장 + Sec->>API: ⑩ 체크아웃 (POST /api/access/check-out) + API-->>Sec: 퇴장 처리 (AccessEvent OUT 기록) + + Admin->>API: ⑪ 대시보드 통계 (GET /api/stats/summary) + Sec->>API: ⑫ 방문 리포트 (GET /api/reports/visits.xlsx) + API-->>Sec: 엑셀(POI) 다운로드 +``` + +--- + +## 3. 체크인 검증 로직 (AccessService.checkIn) + +```mermaid +sequenceDiagram + autonumber + actor Sec as SECURITY / 키오스크 + participant AS as AccessService + participant BL as BlacklistService + participant AE as AccessEventRepo + participant GW as AccessControlGateway + + Sec->>AS: check-in (qrToken 또는 visitRequestId) + AS->>AS: 방문신청 resolve + + alt 상태 ≠ APPROVED + AS-->>Sec: 400 승인되지 않은 방문 + else 방문 종료일 경과 + AS->>AS: 상태 = EXPIRED + AS-->>Sec: 400 신청일 경과 — 재신청 필요 + else 방문 시작일 이전 + AS-->>Sec: 400 아직 방문일 아님 + else + AS->>BL: blockReason(name, contact) + alt 블랙리스트 매칭 + BL-->>AS: 사유 + AS-->>Sec: 403 차단된 방문자 + else 통과 + AS->>AE: isInside? / hasExitedToday? + alt 이미 재실 중 + AS-->>Sec: 409 중복 입장 + else 금일 퇴장 완료 + AS-->>Sec: 400 재입장 불가 + else 정상 + AS->>AE: AccessEvent(IN) 저장 + AS->>GW: openGate(gateId) + GW-->>AS: accepted + AS-->>Sec: 입장 처리 완료 + end + end + end +``` + +--- + +## 4. 시스템 컴포넌트 개요 + +```mermaid +flowchart LR + subgraph FE[프론트엔드 React/Vite] + L[로그인] --> DB1[대시보드] + VR[방문신청] --> AQ[승인함 ADMIN] + AC[출입콘솔] --> KIOSK[키오스크 공개] + RPT[리포트] & BLK[블랙리스트 ADMIN] + PP[공개 출입증 /pass/:token] + end + + subgraph BE[백엔드 Spring Boot] + CTRL[Controllers] --> SVC[Services] + SVC --> REPO[(JPA Repositories)] + SVC --> QR[QrService ZXing] + SVC --> NOTI[PassNotifier SMS] + SVC --> GWY[AccessControlGateway Mock/실장비] + SVC --> POI[Report/Excel POI] + end + + subgraph DATA[저장소] + DBH[(H2 dev)] + DBP[(PostgreSQL prod + Flyway)] + end + + FE -->|/api 세션 인증| CTRL + REPO --> DBH + REPO --> DBP + NOTI -->|hanbank| MSG[사내 메시지 API] + PP -.공개 링크.-> FE +``` + +--- + +## 5. 배포 파이프라인 (운영, Docker) + +```mermaid +flowchart LR + ENV[.env 설정] --> BUILD[docker compose build] + BUILD --> UP[compose up -d: db + app + web] + UP --> FW[app 기동 시 Flyway V1__init 적용] + FW --> SEED[compose run seed: 사용자 적재] + SEED --> READY[서비스 준비: nginx :80 → /api → app :8080] + READY --> DBV[(db_data 볼륨 영속)] +``` diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..76d4ac8 --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,232 @@ +# IT센터 출입자관리시스템(ACS) 워크플로우 + +> 문서 작성일: 2026-07-03 +> 대상: `C:\ai-dev\workspace\access-control-system` (Spring Boot 3.4.5 / Java 21 · React 19 · Vite 6) +> 목적: 방문자 사전신청 → 승인 → 출입증 발송 → 입·출입 체크 → 재실현황/리포트까지의 전체 업무 흐름 정리 + +--- + +## 1. 시스템 개요 + +IT센터를 방문하는 외부 방문자의 **사전신청·승인·출입·통계**를 관리하는 웹 시스템. +출입통제 하드웨어(게이트)는 `AccessControlGateway` 인터페이스로 추상화되어 있어 현재는 Mock으로 동작하고, 추후 실제 장비 연동이 가능하다. + +| 구분 | 내용 | +|------|------| +| 백엔드 | Spring Boot 3.4.5 / Java 21 · Spring Security(세션) · JPA · H2(dev)/PostgreSQL(prod) · POI · ZXing · Flyway | +| 프론트엔드 | React 19 · Vite 6 · TypeScript · react-router 7 | +| 포트 | API `8080`, 웹 `5173`(dev) / nginx `80`(prod) | +| 인증 | 세션 기반, 최초 로그인 시 비밀번호 강제 변경 | + +--- + +## 2. 역할(Role)과 접근 범위 + +| 역할 | 주요 권한 | 접근 화면 | +|------|-----------|-----------| +| `ADMIN` | 전체 관리, 모든 승인/반려, 사용자·블랙리스트·리포트 | 대시보드, 방문신청, 승인함, 출입콘솔, 블랙리스트, 리포트 | +| `SECURITY` | 입·출입 콘솔(체크인/아웃), 재실현황, 배지 발급, 리포트 | 대시보드, 방문신청, 출입콘솔, 리포트 | +| `HOST` | 방문 사전신청 등록, 담당 방문 확인 | 대시보드, 방문신청, 출입콘솔 | +| (비로그인) | 방문자 공개 출입증(`/pass/:token`), 키오스크(`/kiosk`) | 공개 페이지 | + +> 라우팅 기준: `frontend/src/App.tsx`. `승인함(/approvals)`·`블랙리스트(/blacklist)`는 ADMIN 전용, `리포트(/reports)`는 SECURITY·ADMIN. + +--- + +## 3. 방문 신청 생명주기 (상태 머신) + +`VisitStatus` (backend/entity/VisitStatus.java) 기준 상태 전이: + +``` + 신청 등록 승인 + [DRAFT] ───────────────▶ [PENDING] ───────────────▶ [APPROVED] ──▶ 출입 가능 + │ │ + │ 반려 │ 방문일 경과(체크인 시도) + ▼ ▼ + [REJECTED] [EXPIRED] + + [PENDING] 또는 [APPROVED] ──── 취소 ────▶ [CANCELLED] +``` + +| 상태 | 의미 | 진입 조건 | +|------|------|-----------| +| `DRAFT` | 임시 저장 | 신청 초안 | +| `PENDING` | 승인 대기 | 신청 제출 | +| `APPROVED` | 승인됨(출입 가능, qrToken 발급) | 관리자 승인 | +| `REJECTED` | 반려됨 | 관리자 반려 | +| `CANCELLED` | 신청 취소됨 | 신청자/관리자 취소 | +| `EXPIRED` | 방문일 경과로 만료 | 방문 종료일 이후 체크인 시도 시 자동 전이 | + +> 핵심 규칙: **승인 시점(ApprovalService.approve)** 에 `qrToken = UUID`가 발급되고, 이 토큰으로 출입증(QR)·공개 페이지·문자 발송이 이루어진다. + +--- + +## 4. 전체 업무 워크플로우 (End-to-End) + +``` +[HOST/담당자] [ADMIN] [방문자] [SECURITY/게이트] + │ │ │ │ + ①방문 사전신청 ───────────▶ ②승인함 검토 │ │ + (개별 or 엑셀 업로드) │ │ │ + │ ③승인 / 반려 │ │ + │ │ (승인 시 qrToken) │ │ + │ ├── ④출입증 문자발송 ─▶ 휴대폰 링크 수신 │ + │ │ ⑤공개 출입증(/pass/:token) │ + │ │ │ QR 확인 │ + │ │ │ │ + │ │ └── ⑥방문일 현장 도착 ────▶ ⑦체크인(QR/이름) + │ │ │ ├ 블랙리스트 검증 + │ │ │ ├ 상태/일자 검증 + │ │ │ └ 게이트 오픈 + 입장기록 + │ │ ⑧재실현황 표시 + │ │ └── ⑨퇴장 ───────────────▶ ⑩체크아웃(OUT 기록) + │ │ │ + └───────────────── ⑪대시보드 통계 / ⑫방문 리포트(엑셀) ────────────────────┘ +``` + +### 단계별 상세 + +**① 방문 사전신청 (HOST/ADMIN/SECURITY)** — `POST /api/visit-requests` +- 방문자 정보(이름·연락처·회사), 방문 구역(Zone), 방문 기간(visitFrom~visitTo), 담당 호스트 지정. +- 개별 등록 또는 **엑셀 일괄 업로드**(`POST /api/visit-requests/upload`, `ExcelImportService`). +- 상태 → `PENDING`. + +**② ③ 승인/반려 (ADMIN)** — `GET /api/visit-requests/pending` → `POST /api/approvals/{id}/approve|reject` +- `PENDING` 상태만 처리 가능(이미 처리된 건은 409 conflict). +- 승인: 상태 → `APPROVED`, **`qrToken` 발급**, `Approval` 기록 저장. +- 반려: 상태 → `REJECTED`, 사유(comment) 기록. + +**④ 출입증 발송 (자동)** — `PassNotifier` +- 승인 직후 QR PNG(240px)를 생성하여 방문자에게 발송. +- 발송 실패는 승인 트랜잭션을 롤백하지 않음 (catch & log — 승인은 정상 처리). +- Provider: `dev`(로그만, `LoggingPassNotifier`) / `hanbank`(사내 메시지 API로 LMS 실발송, `HanbankMessagePassNotifier`). +- 문자에는 `ACS_PUBLIC_BASE_URL` 기반 공개 출입증 링크 포함. + +**⑤ 공개 출입증 (방문자, 비로그인)** — `GET /pass/:token` → `PublicPassController` +- 방문자가 휴대폰에서 링크 접속 → QR/방문정보 확인. +- secure context(HTTPS/localhost)에서만 카메라·안전 접속 보장. + +**⑥ ⑦ 입장 체크인 (SECURITY/게이트 or 키오스크)** — `POST /api/access/check-in` +- 식별: QR 토큰 스캔 또는 방문자 이름 검색(`GET /api/access/search?q=`). +- **검증 순서** (`AccessService.checkIn`): + 1. 상태 == `APPROVED` 아니면 거부. + 2. 방문 종료일 경과 → 상태 `EXPIRED` 전이 + 거부("재신청 필요"). + 3. 방문 시작일 이전 → 거부("아직 방문일 아님"). + 4. 블랙리스트 매칭(이름+연락처) → 403 차단. + 5. 이미 입장 중 → 409 중복입장. + 6. 금일 이미 퇴장 완료 → 재입장 불가. +- 통과 시: `AccessEvent(IN)` 기록 + `gateway.openGate()` 호출(게이트 오픈). +- **늦은 도착 허용**: 같은 '일자'면 시간은 엄격히 보지 않음. 실제 입장 시각은 `access_events.event_at`에 별도 기록. + +**⑧ 재실현황 (SECURITY/ADMIN)** — `GET /api/access/inside` +- 마지막 이벤트가 `IN`인 방문자 = 현재 재실 중. 이름·회사·구역·호스트·입장시각 표시. +- 금일 전체 출입기록: `AccessService.listTodayRecords` (입장/퇴장/재실여부 포함). + +**⑨ ⑩ 퇴장 체크아웃** — `POST /api/access/check-out` +- 입장 기록이 없으면 409(퇴장 불가). +- `AccessEvent(OUT)` 기록. 재실현황에서 제외됨. + +**⑪ 대시보드 통계** — `GET /api/stats/summary` → `StatsController` +- 오늘의 방문/승인대기/재실 인원 등 요약 집계. + +**⑫ 방문 리포트 (SECURITY/ADMIN)** — `GET /api/reports/visits.xlsx?from=&to=` +- 기간별 방문 내역을 Apache POI로 엑셀 생성·다운로드(`ReportService`). + +--- + +## 5. 키오스크 셀프 체크인 흐름 (비로그인) + +`GET /kiosk` → `KioskPage` — 입구 무인 단말에서 방문자가 직접 QR을 스캔하여 셀프 체크인/아웃. +- 백엔드는 동일한 `check-in`/`check-out` API 사용하되 operator = null(셀프서비스). +- 웹캠 QR 스캔은 secure context 필요(`useQrScanner.ts`). + +--- + +## 6. 블랙리스트 흐름 (ADMIN) + +`GET/POST /api/blacklist`, `DELETE /api/blacklist/{id}` → `BlacklistController` +- 이름+연락처 기준 차단 명단 관리. +- **체크인 시 자동 검증**: `BlacklistService.blockReason()`이 매칭되면 입장 403 차단(사유 표시). + +--- + +## 7. 컴포넌트 데이터 흐름 (백엔드 계층) + +``` +Controller → Service → Repository(JPA) → DB(H2/PostgreSQL) + │ │ + │ ├─ ApprovalService ─▶ QrService(ZXing) ─▶ PassNotifier(SMS) + │ ├─ AccessService ───▶ AccessControlGateway(Mock/실장비) + │ │ └─ BlacklistService + │ └─ ReportService(POI) / StatsService / ExcelImportService(POI) + │ + └─ 공통: GlobalExceptionHandler(예외→ApiResponse), ApiResponse(응답 래퍼), SecurityConfig(세션 인가) +``` + +주요 엔티티: `User`, `Visitor`, `VisitRequest`, `Approval`, `AccessEvent`, `Zone`, `Blacklist` +(공통 `BaseEntity` — JPA auditing으로 생성/수정 시각 자동 기록) + +--- + +## 8. 배포/운영 워크플로우 + +### 로컬 개발 (H2) +```cmd +run-backend.cmd :: http://localhost:8080 (env.cmd로 포터블 JDK/Maven 로드) +run-frontend.cmd :: http://localhost:5173 +``` +초기 계정(DataSeeder, dev 전용): `admin` / `security` / `host` — 비밀번호 `ChangeMe123!` (최초 로그인 시 변경 요구). + +### 운영 (Docker, PostgreSQL) +```bash +cd infra +cp .env.example .env # POSTGRES_PASSWORD, WEB_PORT, ACS_SMS_PROVIDER, ACS_PUBLIC_BASE_URL 설정 +docker compose up -d --build # db + app(:8080) + web(nginx :80) +docker compose run --rm seed # 사용자 시드 적재 +docker compose logs -f app # Flyway 마이그레이션/기동 로그 +``` +- `prod` 프로파일 + Flyway `V1__init.sql`로 스키마 관리, DB는 named volume(`db_data`)에 영속. +- nginx가 정적 파일 서빙 + `/api` 리버스 프록시. + +### 문자 실발송(hanbank) 전제 +1. 서버→메시지 API 도달 확인: `nc -vz 210.104.132.59 8000` +2. `ACS_SMS_PROVIDER=hanbank`, `ACS_PUBLIC_BASE_URL`=외부 접속 가능한 실제 URL(localhost 금지) 설정 후 재기동. + +--- + +## 9. 구현 현황 (7단계) + +| 단계 | 내용 | 상태 | +|------|------|------| +| 1 | 스캐폴딩 (pom, 설정, 공통 클래스, 전역 예외/응답 래퍼, JPA auditing) | ✅ | +| 2 | 인증·인가 (세션 로그인, 역할 기반 권한, 시드) | ✅ | +| 3 | 방문 사전신청 + 승인 (신청/취소/엑셀 업로드, 승인/반려 시 qrToken 발급) | ✅ | +| 4 | 입·출입 체크인/체크아웃 + 재실현황 (AccessEvent, Gateway+Mock, 중복입장·만료 검증) | ✅ | +| 5 | QR/배지 발급 (ZXing PNG, 배지 인쇄 화면) | ✅ | +| 6 | 블랙리스트(체크인 차단) + 대시보드 통계 + 방문 리포트 엑셀(POI) | ✅ | +| 7 | Flyway V1__init.sql(prod) + Docker Compose(db·app·web nginx) + Python 사용자 시드 | ✅ | + +--- + +## 10. 주요 API 요약 + +| 분류 | 엔드포인트 | +|------|-----------| +| 인증 | `POST /api/auth/login` · `/logout` · `GET /api/auth/me` · `POST /api/auth/change-password` | +| 구역 | `GET /api/zones` | +| 방문신청 | `GET/POST /api/visit-requests` · `GET .../pending` · `POST .../{id}/cancel` · `POST .../upload`(엑셀) | +| 승인 | `POST /api/approvals/{id}/approve` · `/reject` | +| 출입 | `POST /api/access/check-in` · `/check-out` · `GET /api/access/inside` · `/search?q=` | +| 출입증 | `GET /api/passes/{id}` · `/qr.png` · 공개 `/pass/:token` | +| 통계/리포트 | `GET /api/stats/summary` · `GET /api/reports/visits.xlsx?from=&to=` | +| 블랙리스트 | `GET/POST /api/blacklist` · `DELETE /api/blacklist/{id}` (ADMIN) | + +> API 스모크 테스트: `backend/test-api.http` + +--- + +## 11. 향후 연동 포인트 + +- **실 출입장비 연동**: `AccessControlGateway` 구현체를 Mock → 실장비 드라이버로 교체. +- **HTTPS/카메라 스캔**: 사내 도메인·인증서 확보 후 nginx 443 TLS 블록 + `ACS_PUBLIC_BASE_URL` https 설정 (웹캠 QR 스캔은 secure context 필수). +- **사내망 Docker 빌드**: SSL 인스펙션 대응(사내 CA 주입 또는 내부 미러) 필요 시 Dockerfile 반영. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3ff38cc --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.vite/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e3ce8f5 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +# ===== build ===== +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# ===== serve (nginx serves SPA + proxies /api to backend) ===== +FROM nginx:stable-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..52b5360 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + IT센터 출입자관리시스템 + + +

+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..20496ec --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA client-side routing: fall back to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API calls to the backend service (session cookie preserved) + location /api/ { + proxy_pass http://app:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cookie_path / /; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c566f2c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2028 @@ +{ + "name": "acs-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "acs-frontend", + "version": "0.0.1", + "dependencies": { + "date-fns": "^4.4.0", + "jsqr": "^1.4.0", + "react": "^19.0.0", + "react-datepicker": "^9.1.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "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/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.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.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "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/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "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.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/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==", + "dev": true, + "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/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, + "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.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-datepicker": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-9.1.0.tgz", + "integrity": "sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.15", + "clsx": "^2.1.1", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "date-fns-tz": "^3.0.0", + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "date-fns-tz": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "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": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/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": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "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..925495c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "acs-frontend", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "date-fns": "^4.4.0", + "jsqr": "^1.4.0", + "react": "^19.0.0", + "react-datepicker": "^9.1.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..95d5a67 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { useAuth } from './auth/AuthContext'; +import { Role } from './types'; +import { Layout } from './components/Layout'; +import { LoginPage } from './pages/LoginPage'; +import { ChangePasswordPage } from './pages/ChangePasswordPage'; +import { DashboardPage } from './pages/DashboardPage'; +import { VisitRequestListPage } from './pages/VisitRequestListPage'; +import { VisitRequestFormPage } from './pages/VisitRequestFormPage'; +import { ApprovalQueuePage } from './pages/ApprovalQueuePage'; +import { AccessConsolePage } from './pages/AccessConsolePage'; +import { BadgePage } from './pages/BadgePage'; +import { PublicPassPage } from './pages/PublicPassPage'; +import { KioskPage } from './pages/KioskPage'; +import { BlacklistPage } from './pages/BlacklistPage'; +import { ReportPage } from './pages/ReportPage'; + +/** Requires a logged-in user; optionally one of the given roles. */ +const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => { + const { user, loading, hasRole } = useAuth(); + if (loading) { + return
불러오는 중…
; + } + if (!user) { + return ; + } + if (user.mustChangePassword) { + return ; + } + if (roles && !hasRole(...roles)) { + return
접근 권한이 없습니다.
; + } + return {children}; +}; + +export default function App() { + return ( + + + } /> + } /> + {/* Public visitor pass — opened from the SMS link, no login. */} + } /> + {/* Public entrance kiosk — visitor self check-in/out, no login. */} + } /> + + } /> + } /> + } /> + + + + } + /> + + + + } + /> + } /> + + + + } + /> + + + + } + /> + + } /> + } /> + + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..f2fef9f --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,143 @@ +import { + AccessAction, + AccessRecord, + ApiResponse, + BlacklistCreate, + BlacklistItem, + ChangePasswordRequest, + CurrentUser, + ExcelImportResult, + InsideVisitor, + LoginRequest, + StatsSummary, + PublicPass, + VisitRequestCreate, + VisitRequestView, + Zone, +} from './types'; + +const BASE = '/api'; + +/** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */ +const NO_REDIRECT_ON_401 = ['/auth/me', '/auth/login', '/auth/logout', '/public/']; + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + credentials: 'include', + ...init, + }); + // Session expired / not authenticated on a protected call → send to login. + if ( + res.status === 401 && + !NO_REDIRECT_ON_401.some((p) => path.startsWith(p)) && + typeof window !== 'undefined' && + !['/login', '/kiosk'].includes(window.location.pathname) && + !window.location.pathname.startsWith('/pass/') + ) { + window.location.assign('/login'); + } + let body: ApiResponse | null = null; + try { + body = (await res.json()) as ApiResponse; + } catch { + // non-JSON (shouldn't happen with our envelope) + } + if (!res.ok || !body) { + throw new Error(body?.message || `HTTP ${res.status}`); + } + return body.data as T; +} + +function jsonInit(method: string, payload: unknown): RequestInit { + return { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }; +} + +// ===== Auth ===== +export const login = (req: LoginRequest) => + request('/auth/login', jsonInit('POST', req)); + +export const logout = () => + request('/auth/logout', { method: 'POST' }); + +export const getCurrentUser = () => request('/auth/me'); + +export const changePassword = (req: ChangePasswordRequest) => + request('/auth/change-password', jsonInit('POST', req)); + +// ===== Zones ===== +export const listZones = () => request('/zones'); + +// ===== Visit requests ===== +export const listVisitRequests = () => + request('/visit-requests'); + +export const listPendingRequests = () => + request('/visit-requests/pending'); + +export const getVisitRequest = (id: number) => + request(`/visit-requests/${id}`); + +export const createVisitRequest = (req: VisitRequestCreate) => + request('/visit-requests', jsonInit('POST', req)); + +export const cancelVisitRequest = (id: number) => + request(`/visit-requests/${id}/cancel`, { method: 'POST' }); + +export const uploadVisitRequests = (file: File) => { + const form = new FormData(); + form.append('file', file); + return request('/visit-requests/upload', { + method: 'POST', + body: form, + }); +}; + +// ===== Approvals ===== +export const approveRequest = (id: number, comment?: string) => + request(`/approvals/${id}/approve`, jsonInit('POST', { comment })); + +export const rejectRequest = (id: number, comment?: string) => + request(`/approvals/${id}/reject`, jsonInit('POST', { comment })); + +// ===== Access console (check-in / out, currently inside) ===== +export const searchApprovedForCheckIn = (q: string) => + request(`/access/search?q=${encodeURIComponent(q)}`); + +export const checkIn = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) => + request('/access/check-in', jsonInit('POST', payload)); + +export const checkOut = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) => + request('/access/check-out', jsonInit('POST', payload)); + +export const listInside = () => request('/access/inside'); +export const listTodayAccess = () => request('/access/today'); + +// ===== Passes / badge ===== +export const getPass = (id: number) => request(`/passes/${id}`); +export const passQrUrl = (id: number) => `/api/passes/${id}/qr.png`; + +// ===== Public pass + self-service kiosk (no login, token-based) ===== +export const getPublicPass = (token: string) => request(`/public/passes/${token}`); +export const publicPassQrUrl = (token: string) => `/api/public/passes/${token}/qr.png`; +export const publicCheckIn = (token: string) => + request(`/public/passes/${token}/check-in`, { method: 'POST' }); +export const publicCheckOut = (token: string) => + request(`/public/passes/${token}/check-out`, { method: 'POST' }); + +// ===== Stats ===== +export const getStatsSummary = () => request('/stats/summary'); + +// ===== Blacklist (ADMIN) ===== +export const listBlacklist = () => request('/blacklist'); +export const addBlacklist = (req: BlacklistCreate) => + request('/blacklist', jsonInit('POST', req)); +export const deleteBlacklist = (id: number) => + request(`/blacklist/${id}`, { method: 'DELETE' }); + +// ===== Reports ===== +export const reportDownloadUrl = (from: string, to: string) => + `/api/reports/visits.xlsx?from=${from}&to=${to}`; diff --git a/frontend/src/assets/bok-badge.png b/frontend/src/assets/bok-badge.png new file mode 100644 index 0000000..724af3c Binary files /dev/null and b/frontend/src/assets/bok-badge.png differ diff --git a/frontend/src/assets/bok-removebg.png b/frontend/src/assets/bok-removebg.png new file mode 100644 index 0000000..724af3c Binary files /dev/null and b/frontend/src/assets/bok-removebg.png differ diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..16bc54e --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,54 @@ +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { getCurrentUser } from '../api'; +import { CurrentUser, Role } from '../types'; + +interface AuthState { + user: CurrentUser | null; + loading: boolean; + refresh: () => Promise; + clear: () => void; + hasRole: (...roles: Role[]) => boolean; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + try { + setUser(await getCurrentUser()); + } catch { + setUser(null); + } finally { + setLoading(false); + } + }, []); + + const clear = useCallback(() => setUser(null), []); + + const hasRole = useCallback( + (...roles: Role[]) => !!user && roles.some((r) => user.roles.includes(r)), + [user], + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return ( + + {children} + + ); +}; + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth must be used within AuthProvider'); + } + return ctx; +} diff --git a/frontend/src/chime.ts b/frontend/src/chime.ts new file mode 100644 index 0000000..e616b71 --- /dev/null +++ b/frontend/src/chime.ts @@ -0,0 +1,33 @@ +// Simple two-tone "띵동" chime via Web Audio — no audio asset needed. +// Must be triggered from a user gesture (e.g. button click) to satisfy autoplay policy. + +let ctx: AudioContext | null = null; + +function tone(audio: AudioContext, freq: number, startAt: number, dur: number) { + const osc = audio.createOscillator(); + const gain = audio.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + osc.connect(gain); + gain.connect(audio.destination); + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.exponentialRampToValueAtTime(0.35, startAt + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, startAt + dur); + osc.start(startAt); + osc.stop(startAt + dur + 0.02); +} + +/** Plays a descending two-note "ding-dong" chime. Safe no-op if audio is unavailable. */ +export function playChime(): void { + try { + const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return; + ctx = ctx ?? new Ctor(); + if (ctx.state === 'suspended') void ctx.resume(); + const now = ctx.currentTime; + tone(ctx, 784, now, 0.35); // "띵" (G5) + tone(ctx, 523.25, now + 0.18, 0.45); // "동" (C5) + } catch { + /* ignore audio errors */ + } +} diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker.tsx new file mode 100644 index 0000000..d08edea --- /dev/null +++ b/frontend/src/components/DateTimePicker.tsx @@ -0,0 +1,55 @@ +import React, { useRef } from 'react'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import { ko } from 'date-fns/locale'; +import 'react-datepicker/dist/react-datepicker.css'; + +registerLocale('ko', ko); + +interface Props { + /** datetime-local string, e.g. "2026-07-02T08:24". */ + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +/** Date → "YYYY-MM-DDTHH:mm" (local), the format the form/back-end expect. */ +function toLocalString(d: Date): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** + * Korean-localized date+time picker that replaces the browser-native + * (whose popup labels/border/buttons cannot be + * styled). Keeps the calendar open until the user presses [입력] so the choice + * is explicit. + */ +export const DateTimePicker: React.FC = ({ value, onChange, placeholder }) => { + const ref = useRef(null); + + return ( + d && onChange(toLocalString(d))} + showTimeSelect + timeIntervals={5} + timeCaption="시간" + timeFormat="a K:mm" + dateFormat="yyyy.MM.dd (eee) a K:mm" + dateFormatCalendar="yyyy.M월" + locale="ko" + shouldCloseOnSelect={false} + placeholderText={placeholder ?? '날짜와 시간을 선택하세요'} + className="dt-input" + popperClassName="acs-datepicker" + > +
+ +
+
+ ); +}; diff --git a/frontend/src/components/Dialog.tsx b/frontend/src/components/Dialog.tsx new file mode 100644 index 0000000..c6b1c97 --- /dev/null +++ b/frontend/src/components/Dialog.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; + +interface Props { + title: string; + message?: string; + /** Show an optional text field and pass its value to onConfirm. */ + withInput?: boolean; + inputPlaceholder?: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + onConfirm: (text?: string) => void; + onCancel: () => void; +} + +/** + * In-app modal replacing window.prompt/confirm (unsupported in embedded browsers). + * With `withInput`, collects an optional text value (e.g. rejection reason). + */ +export const Dialog: React.FC = ({ + title, message, withInput, inputPlaceholder, confirmLabel = '확인', cancelLabel = '취소', + danger, onConfirm, onCancel, +}) => { + const [text, setText] = useState(''); + return ( +
+
e.stopPropagation()}> +

{title}

+ {message &&

{message}

} + {withInput && ( +