Compare commits

...

56 Commits

Author SHA1 Message Date
unknown
7239c21a7d feat: add public visitor application flow 2026-08-03 17:11:04 +09:00
unknown
7ff45fb4ec chore: add ACS deployment check 2026-07-27 16:01:18 +09:00
unknown
05068877db fix: copy visit request template into runtime image 2026-07-27 15:21:12 +09:00
unknown
d4e59fbf9f fix: include visit request template in image 2026-07-27 15:17:32 +09:00
unknown
6c511bf355 fix: handle visit request Excel form upload 2026-07-27 15:08:36 +09:00
unknown
eaecfae330 docs: record 2026-07-22 acs test log 2026-07-22 20:15:40 +09:00
unknown
eec18e7179 fix: show access state in reports 2026-07-22 19:51:35 +09:00
unknown
dee612fc5c fix: show access state in request list 2026-07-22 19:47:48 +09:00
unknown
7cbc660679 fix: refresh dashboard access state 2026-07-22 19:45:11 +09:00
unknown
3efe493f9f fix: show access action zone 2026-07-22 19:32:39 +09:00
unknown
7c579608a0 fix: parse kiosk qr pass urls 2026-07-22 19:22:32 +09:00
unknown
ed972a318d fix: align access console status actions 2026-07-22 19:04:54 +09:00
unknown
e01beea64c fix: make test data reset button responsive 2026-07-20 19:54:59 +09:00
unknown
c45c47109c feat: add admin test data reset 2026-07-20 19:48:40 +09:00
unknown
7617c9f371 fix: restrict deletion to cancelled requests 2026-07-20 19:37:30 +09:00
unknown
10da3ff62d fix: delete visit request test records 2026-07-20 19:26:42 +09:00
unknown
5ba8c5db5e feat: allow admin delete from dashboard 2026-07-20 19:16:12 +09:00
unknown
3ba95a0c02 fix: open visit request list from nav 2026-07-20 19:09:57 +09:00
unknown
e4caed8773 feat: add admin visit request deletion 2026-07-20 18:55:28 +09:00
unknown
85f94ba4f2 feat: show approved count on dashboard 2026-07-20 18:30:11 +09:00
unknown
e6bc4cf734 fix: limit self approval to admins 2026-07-20 18:18:17 +09:00
unknown
104d2f531a fix: correct access dashboard status counts 2026-07-20 17:29:00 +09:00
unknown
6180161225 feat: add email diagnostics 2026-07-16 17:50:58 +09:00
unknown
9c4dac9a70 feat: add SMS diagnostics admin check 2026-07-16 16:23:12 +09:00
unknown
ae1f30592d feat: support internal SMS API 2026-07-16 14:46:57 +09:00
unknown
bd3d933a40 feat: apply datetime and self-approve admin requests 2026-07-16 14:32:36 +09:00
unknown
cff1a1bcad fix: apply datetime selection immediately 2026-07-16 14:24:52 +09:00
unknown
a1c82d819d feat: send visitor pass link on approval 2026-07-16 14:14:47 +09:00
unknown
cc79abde44 fix: confirm visit datetime picker selection 2026-07-16 14:02:24 +09:00
unknown
b094241408 docs: record visit upload server verification 2026-07-16 13:47:36 +09:00
unknown
d2c90dff0e feat: enable visit request Excel upload in Node 2026-07-16 13:43:15 +09:00
unknown
0852995598 docs: record Coolify redeploy requirement 2026-07-16 11:15:36 +09:00
unknown
c6f342a861 feat: add Node ACS admin workflows 2026-07-16 11:00:09 +09:00
unknown
a25872394a Update ACS work log and deliverables 2026-07-14 19:47:01 +09:00
unknown
2653ed64bc Compact visit request form 2026-07-14 19:34:47 +09:00
unknown
7a404cd836 Update visit request form layout 2026-07-14 18:54:58 +09:00
unknown
0ae4b73592 Run migrations on server startup 2026-07-14 16:19:36 +09:00
unknown
e02afcda7d Add auth deployment diagnostics 2026-07-14 16:07:39 +09:00
unknown
9562766320 Seed ACS test login accounts 2026-07-14 16:02:58 +09:00
unknown
7c8bd37b12 Add Node auth routes for ACS login 2026-07-14 15:58:56 +09:00
ce82cf5a26 Remove test account hint from login page 2026-07-13 15:30:38 +09:00
44a4bc1086 Install curl for Coolify healthcheck 2026-07-13 13:56:43 +09:00
unknown
b90bfe35ca Add AI DEV Node deployment foundation 2026-07-13 10:33:21 +09:00
unknown
6abcee3727 docs: record Node deployment constraint 2026-07-10 16:51:49 +09:00
unknown
98b7f117b5 docs: record infrastructure deployment feedback 2026-07-10 16:42:36 +09:00
unknown
6a8b40730a docs: record playground repository migration 2026-07-10 16:21:34 +09:00
unknown
4fe4c37fa4 docs: record Coolify permission issue 2026-07-10 15:40:13 +09:00
unknown
1c717b4e47 docs: record deployment URL status 2026-07-10 15:27:52 +09:00
unknown
3e2566cba9 docs: update ACS work log 2026-07-10 15:19:26 +09:00
unknown
01d48fe808 feat: prepare ACS deployment 2026-07-10 15:18:29 +09:00
unknown
da0d35ae7f fix(ui): 테스트 피드백 반영 (출입증/보고서/상단바/대시보드/엑셀)
1. 출입증 카드 하단 안내문을 한 줄로(.badge-foot font-size 11px + nowrap).
2. 보고서 화면: 명칭 '리포트→보고서'·'방문 리포트→출입관리 보고서', 안내문 보강,
   날짜 입력을 YYYY.MM.DD 표시 커스텀 피커(DatePickerField)로 교체,
   기본값 시작일=이번 달 1일·종료일=오늘.
3. 보고서 엑셀 열 너비를 내용 기준(한글 2폭)으로 계산해 설정 → 셀 잘림 해소
   (POI autoSizeColumn의 CJK 과소측정 문제 회피).
4. 상단바에서 사용자 이름 표시 제거(역할 태그만 유지) → '발송내역' 메뉴 잘림 해소,
   발송내역 화면 [새로고침]→[조회].
5. 대시보드 '최근 출입 신청'에서 현재 재실 중인 방문자는 상태를 '재실중'으로 표시
   (기존 listInside API 재활용, 백엔드 무변경).

검증: 프론트 tsc+vite 빌드 통과, 백엔드 build+test(9건) 통과, 생성 xlsx의 열 너비가
내용에 맞게 설정됨(연락처15·출입일시18·상태10 등) 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:52:11 +09:00
unknown
d8ba443b35 feat(infra): HTTPS(TLS) 스캐폴딩 + 프록시 헤더 처리
- Spring prod: server.forward-headers-strategy=framework — nginx의 X-Forwarded-Proto를
  신뢰해 프록시 뒤에서도 request.isSecure()=true → Secure 쿠키·https 리다이렉트 정상화.
- frontend/nginx-tls.conf: 443 TLS 종료 + 80→443 리다이렉트(+ /api 프록시).
- infra/docker-compose.tls.yml: base와 함께 쓰는 HTTPS 오버레이(443 매핑·인증서 볼륨·
  ACS_PUBLIC_BASE_URL/ACS_COOKIE_SECURE). infra/certs/.gitignore로 인증서·키 커밋 제외.
- README: HTTPS 적용 절차(인증서 준비→.env→compose 오버레이) 갱신.

검증: forward-headers=framework + cookie.secure=true로 기동 후 X-Forwarded-Proto=https 유무 대조 —
헤더 있으면 XSRF-TOKEN·JSESSIONID 모두 Secure, 없으면 XSRF Secure 미부여(프록시 프로토콜 연동 확인).
자체서명 인증서 생성 확인. 전체 컨테이너 TLS e2e는 Docker 데몬 기동 시 별도 검증 필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:12:14 +09:00
unknown
17d6036bca feat(web): 감사 로그·발송 내역 관리 화면 추가
- AuditLogPage(/audit): 최근 관리 행위(승인/반려·블랙리스트) 조회.
- DeliveryOutboxPage(/deliveries): 출입증 발송 내역 조회(성공/실패/전체 필터) + 실패건 수동 재발송.
- api.ts/types에 listAudit·listDeliveries·retryDelivery + AuditLog·PassDelivery 타입 추가.
- Layout 네비(ADMIN)와 App 라우팅(ADMIN 가드) 연결, common.css에 row-gap·cell-error 유틸 추가.

검증: tsc+vite 빌드 통과. 런타임 승인 후 GET /api/admin/audit·/deliveries 응답이 타입과 일치 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:03:51 +09:00
unknown
f7aaeaba2a perf(access): 재실/오늘기록 조회 N+1 제거
- listInside/listTodayRecords가 방문건마다 findById+이벤트 조회를 반복하던 것을
  findAllWithVisitorAndHostByIdIn(join fetch) + findByVisitRequestIdInOrderByEventAtAsc
  배치 조회 2~3쿼리로 축소. 재실 판정은 오늘 이벤트의 마지막 방향으로 계산.
- 테스트: AccessQueryTest(체크인→재실/오늘기록, 체크아웃→재실 해제·checkOut 시각). 전체 9건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:49:14 +09:00
unknown
8ca939c057 feat(notify): P2 — 출입증 발송 outbox + 실패 자동/수동 재발송
- PassDelivery(pass_deliveries) outbox 엔티티: 발송 결과(SENT/FAILED)·채널·수신처·시도횟수·오류 기록.
- PassNotifier 계약 변경: 실패 시 예외 throw(+ channel()/recipient()). Hanbank/Email이 실패를
  더 이상 삼키지 않고 throw → PassDeliveryService가 결과를 outbox에 기록(승인은 롤백 안 됨).
- PassDeliveryRetryScheduler: 매 10분(acs.delivery.retry-cron) FAILED & attempts<max(기본5) 재발송.
- 관리 API: GET /api/admin/deliveries?status=FAILED, POST /api/admin/deliveries/{id}/retry (ADMIN).
- ApprovalService는 passDeliveryService.deliver(vr)로 위임(QR생성·발송·기록 일원화).
- Flyway V3__pass_delivery.sql. FlywayValidationTest가 V1+V2+V3를 엔티티와 대조.
- 테스트: PassDeliveryTest(발송 실패→FAILED 기록→재시도→SENT). 전체 8건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:43:42 +09:00
unknown
6905dcaaff feat(audit): P2 — 관리 행위 감사 로그(AuditLog) 추가
- AuditLog 엔티티/AuditAction(APPROVE/REJECT/BLACKLIST_ADD/BLACKLIST_REMOVE) + AuditService.record
  (호출자 트랜잭션 합류, actor는 SecurityUtils에서 채우되 미인증 컨텍스트는 null 허용).
- 훅: ApprovalService(승인/반려), BlacklistService(추가/해제)에 감사 기록.
- GET /api/admin/audit (ADMIN) 최근 200건 조회 + AuditLogResponse.
- Flyway V2__audit_log.sql (prod). FlywayValidationTest가 V1+V2를 엔티티와 대조.
- 테스트: AuditLogTest(승인/블랙리스트 추가 감사행 검증). 전체 7건 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:34:08 +09:00
113 changed files with 13465 additions and 321 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
backend
dist
frontend/dist
frontend/node_modules
infra
node_modules
*.log
*.bak
.env
.project-env
.git
.gitignore
docs
!docs/
!docs/form_sample.xlsx

6
.gitignore vendored
View File

@@ -6,6 +6,8 @@ backend/target/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
node_modules/
dist/
*.tsbuildinfo *.tsbuildinfo
# IDE / OS # IDE / OS
@@ -14,9 +16,13 @@ frontend/.vite/
*.iml *.iml
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*.log
*.bak
# Local env # Local env
infra/.env infra/.env
.env
.project-env
# Editor/EDR leftover temp files (see docs/issues-and-guidelines.md §2-3) # Editor/EDR leftover temp files (see docs/issues-and-guidelines.md §2-3)
*.tmp.* *.tmp.*

32
CLAUDE.md Normal file
View File

@@ -0,0 +1,32 @@
# ACS Project Rules
## Deployment Target
ACS must follow the AI DEV deployment guide in `docs/AIdev.md`.
- Deploy as a Node.js application.
- Use port `3000`.
- Use the `DATABASE_URL` supplied by `.project-env` / AI DEV.
- Do not depend on a self-managed PostgreSQL container for AI DEV deployment.
- Keep `.project-env` and `.env` out of git.
- Build and deploy through the root `Dockerfile`.
## Current Migration Direction
- The legacy Spring Boot backend remains only as a behavior reference until Node parity is complete.
- The React frontend should be preserved where possible.
- Keep the existing `/api` contract and response envelope stable:
```json
{ "code": 200, "message": "OK", "data": {} }
```
## Required Checks
- `npm run typecheck`
- `npm run build`
- `npm run db:check` in the AI DEV project folder where `.project-env` is loaded
- `curl 127.0.0.1:3000/healthz`
- `curl 127.0.0.1:3000/db`
`npm run minio:check` and `/s3` intentionally report skipped because ACS currently does not use S3/MinIO storage.

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
COPY frontend/package*.json ./frontend/
RUN npm ci
RUN npm --prefix frontend ci
COPY tsconfig.json ./
COPY server ./server
COPY migrations ./migrations
COPY frontend ./frontend
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY --from=build /app/frontend/dist ./frontend/dist
COPY migrations ./migrations
COPY docs/form_sample.xlsx ./docs/form_sample.xlsx
EXPOSE 3000
CMD ["npm", "run", "start:deploy"]

View File

@@ -80,13 +80,26 @@ docker compose logs -f app # 기동/마이그레이션 로그
1. 서버에서 메시지 API 도달 확인: `nc -vz 210.104.132.59 8000` 1. 서버에서 메시지 API 도달 확인: `nc -vz 210.104.132.59 8000`
2. `ACS_SMS_PROVIDER=hanbank`, `ACS_PUBLIC_BASE_URL`=외부 접속 URL 로 설정 후 재기동 2. `ACS_SMS_PROVIDER=hanbank`, `ACS_PUBLIC_BASE_URL`=외부 접속 URL 로 설정 후 재기동
### HTTPS / 카메라 스캔 (선택, 접속 방식에 따라 필요) ### HTTPS 적용 (TLS 종료는 nginx가 담당)
- 출입콘솔의 **웹캠 QR 스캔**은 secure context(HTTPS 또는 localhost)에서만 동작한다. 브라우저 → **nginx(web, TLS 종료)** → 내부 http → Spring(app) 구조. 앱은 `server.forward-headers-strategy=framework`
서버를 `http://내부IP`로 접속하면 카메라가 차단된다. (prod에 반영됨)로 nginx의 `X-Forwarded-Proto`를 신뢰해 Secure 쿠키·https 리다이렉트를 처리한다.
TLS 오버레이가 준비돼 있다 — 인증서만 넣으면 된다:
1. **인증서 준비**`infra/certs/{fullchain.pem,privkey.pem}` (사내 CA 발급 / 공인 CA / 테스트용 자체서명).
자체서명 예: `openssl req -x509 -newkey rsa:2048 -nodes -days 825 -keyout infra/certs/privkey.pem -out infra/certs/fullchain.pem -subj "/CN=<host>"`
(`infra/certs/*.pem``.gitignore`로 커밋 제외.)
2. **`.env`**: `ACS_PUBLIC_BASE_URL=https://<도메인>`, `ACS_COOKIE_SECURE=true`.
3. **기동** (base + TLS 오버레이):
```bash
cd infra
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --build
```
→ nginx가 `443` TLS 종료 + `80→443` 리다이렉트. `frontend/nginx-tls.conf` 사용.
`ACS_COOKIE_SECURE=true`는 위처럼 **실제 HTTPS로 서비스될 때만** 켠다(평문 http에서 켜면 쿠키 미전송 → 로그인 불가).
- 출입콘솔의 **웹캠 QR 스캔**은 secure context(HTTPS 또는 localhost)에서만 동작 → HTTPS면 `http://내부IP`에서도 카메라 사용 가능.
- 방문자 공개 링크(`/pass/{token}`)도 HTTPS 도메인이면 휴대폰에서 안전하게 열린다. - 방문자 공개 링크(`/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 서버는 공개망 직접 `docker compose build`는 컨테이너 안에서 npm/maven 의존성을 받는다. dev 서버는 공개망 직접

View File

@@ -29,13 +29,18 @@ public class DataSeeder implements CommandLineRunner {
private final ZoneRepository zoneRepository; private final ZoneRepository zoneRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private static final String DEFAULT_PASSWORD = "ChangeMe123!"; // 적용(운영 반영) 전까지 신속한 테스트를 위한 단축 계정. dev(비-prod)에서만 시드된다.
// 운영 전환 시 이 시드는 무시되고 Flyway/CSV 시더가 실제 계정을 관리한다.
private static final String TEST_PASSWORD = "1";
@Override @Override
public void run(String... args) { public void run(String... args) {
seedUser("admin", "관리자", "IT운영팀", Set.of(RoleType.ADMIN)); seedUser("admin", "관리자", "IT운영팀", Set.of(RoleType.ADMIN));
seedUser("security", "보안담당", "보안팀", Set.of(RoleType.SECURITY)); seedUser("security", "보안담당", "보안팀", Set.of(RoleType.SECURITY));
seedUser("host", "홍길동", "개발1팀", Set.of(RoleType.HOST)); seedUser("host", "홍길동", "개발1팀", Set.of(RoleType.HOST));
seedUser("a", "관리자", "IT운영팀", Set.of(RoleType.ADMIN));
seedUser("s", "보안담당", "보안팀", Set.of(RoleType.SECURITY));
seedUser("h", "홍길동", "개발1팀", Set.of(RoleType.HOST));
seedZone("LOBBY", "로비", 1); seedZone("LOBBY", "로비", 1);
seedZone("OFFICE", "사무공간", 2); seedZone("OFFICE", "사무공간", 2);
@@ -48,14 +53,14 @@ public class DataSeeder implements CommandLineRunner {
} }
User user = new User(); User user = new User();
user.setUsername(username); user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(DEFAULT_PASSWORD)); user.setPasswordHash(passwordEncoder.encode(TEST_PASSWORD));
user.setFullName(fullName); user.setFullName(fullName);
user.setDepartment(department); user.setDepartment(department);
user.setEmail(username + "@itcenter.local"); user.setEmail(username + "@itcenter.local");
user.setRoles(roles); user.setRoles(roles);
user.setMustChangePassword(true); user.setMustChangePassword(false); // 테스트 편의: 최초 로그인 시 비번변경 강제하지 않음
userRepository.save(user); userRepository.save(user);
log.info("[seed] user '{}' created (default password: {})", username, DEFAULT_PASSWORD); log.info("[seed] test user '{}' created (password: {})", username, TEST_PASSWORD);
} }
private void seedZone(String code, String name, int level) { private void seedZone(String code, String name, int level) {

View File

@@ -0,0 +1,31 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.AuditLogResponse;
import com.itcenter.acs.service.AuditService;
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;
/**
* Read-only audit trail. Restricted to ADMIN by SecurityConfig (/api/admin/**).
*/
@RestController
@RequestMapping("/api/admin/audit")
@RequiredArgsConstructor
public class AuditController {
private final AuditService auditService;
@GetMapping
public ResponseEntity<ApiResponse<List<AuditLogResponse>>> recent() {
List<AuditLogResponse> items = auditService.recent().stream()
.map(AuditLogResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success(items));
}
}

View File

@@ -103,6 +103,8 @@ public class AuthController {
principal.getId(), principal.getId(),
principal.getUsername(), principal.getUsername(),
principal.getFullName(), principal.getFullName(),
principal.getDepartment(),
principal.getEmail(),
roles, roles,
principal.isMustChangePassword()); principal.isMustChangePassword());
} }

View File

@@ -0,0 +1,43 @@
package com.itcenter.acs.controller;
import com.itcenter.acs.dto.ApiResponse;
import com.itcenter.acs.dto.PassDeliveryResponse;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.service.PassDeliveryService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Pass-delivery outbox admin view + manual resend. Restricted to ADMIN by
* SecurityConfig (/api/admin/**).
*/
@RestController
@RequestMapping("/api/admin/deliveries")
@RequiredArgsConstructor
public class DeliveryController {
private final PassDeliveryService passDeliveryService;
@GetMapping
public ResponseEntity<ApiResponse<List<PassDeliveryResponse>>> list(
@RequestParam(value = "status", required = false) DeliveryStatus status) {
List<PassDeliveryResponse> items = passDeliveryService.listByStatus(status).stream()
.map(PassDeliveryResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success(items));
}
@PostMapping("/{id}/retry")
public ResponseEntity<ApiResponse<PassDeliveryResponse>> retry(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(
PassDeliveryResponse.from(passDeliveryService.retryOne(id))));
}
}

View File

@@ -6,6 +6,7 @@ import com.itcenter.acs.dto.VisitRequestCreateRequest;
import com.itcenter.acs.dto.VisitRequestResponse; import com.itcenter.acs.dto.VisitRequestResponse;
import com.itcenter.acs.entity.VisitRequest; import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.security.SecurityUtils; import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.service.ApprovalService;
import com.itcenter.acs.service.ExcelImportService; import com.itcenter.acs.service.ExcelImportService;
import com.itcenter.acs.service.VisitRequestService; import com.itcenter.acs.service.VisitRequestService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -31,13 +32,27 @@ public class VisitRequestController {
private final VisitRequestService visitRequestService; private final VisitRequestService visitRequestService;
private final ExcelImportService excelImportService; private final ExcelImportService excelImportService;
private final ApprovalService approvalService;
/** Create a single pre-registration; current user becomes the host. */ /**
* Create pre-registration(s); current user becomes the host. One request is issued per
* selected server room, so the response may contain more than one (e.g. 4층+5층전산실 → 2건).
* When the registrant is ADMIN, each request is self-approved immediately
* (QR issued) — SECURITY/HOST registrations stay PENDING.
*/
@PostMapping @PostMapping
public ResponseEntity<ApiResponse<VisitRequestResponse>> create( public ResponseEntity<ApiResponse<List<VisitRequestResponse>>> create(
@Valid @RequestBody VisitRequestCreateRequest request) { @Valid @RequestBody VisitRequestCreateRequest request) {
VisitRequest created = visitRequestService.create(request, SecurityUtils.currentUserId()); Long userId = SecurityUtils.currentUserId();
return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(created))); List<VisitRequest> created = visitRequestService.createRequests(request, userId);
if (SecurityUtils.hasRole("ADMIN")) {
List<VisitRequest> approved = new java.util.ArrayList<>(created.size());
for (VisitRequest vr : created) {
approved.add(approvalService.approve(vr.getId(), userId, "본인 등록 자동승인"));
}
created = approved;
}
return ResponseEntity.ok(ApiResponse.success(toResponses(created)));
} }
/** ADMIN/SECURITY see all; HOST sees only their own requests. */ /** ADMIN/SECURITY see all; HOST sees only their own requests. */

View File

@@ -0,0 +1,29 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.AuditLog;
import java.time.LocalDateTime;
/** Read model for an audit trail entry. */
public record AuditLogResponse(
Long id,
LocalDateTime at,
Long actorId,
String actorUsername,
String action,
String targetType,
Long targetId,
String detail) {
public static AuditLogResponse from(AuditLog a) {
return new AuditLogResponse(
a.getId(),
a.getCreatedAt(),
a.getActorId(),
a.getActorUsername(),
a.getAction() != null ? a.getAction().name() : null,
a.getTargetType(),
a.getTargetId(),
a.getDetail());
}
}

View File

@@ -11,6 +11,8 @@ public class CurrentUserResponse {
private Long id; private Long id;
private String username; private String username;
private String fullName; private String fullName;
private String department;
private String email;
private Set<String> roles; private Set<String> roles;
private boolean mustChangePassword; private boolean mustChangePassword;
} }

View File

@@ -0,0 +1,31 @@
package com.itcenter.acs.dto;
import com.itcenter.acs.entity.PassDelivery;
import java.time.LocalDateTime;
/** Read model for a pass-delivery outbox record. */
public record PassDeliveryResponse(
Long id,
Long visitRequestId,
String channel,
String recipient,
String status,
int attempts,
String lastError,
LocalDateTime createdAt,
LocalDateTime updatedAt) {
public static PassDeliveryResponse from(PassDelivery d) {
return new PassDeliveryResponse(
d.getId(),
d.getVisitRequestId(),
d.getChannel(),
d.getRecipient(),
d.getStatus() != null ? d.getStatus().name() : null,
d.getAttempts(),
d.getLastError(),
d.getCreatedAt(),
d.getUpdatedAt());
}
}

View File

@@ -1,11 +1,11 @@
package com.itcenter.acs.dto; package com.itcenter.acs.dto;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
@Data @Data
public class VisitRequestCreateRequest { public class VisitRequestCreateRequest {
@@ -20,12 +20,40 @@ public class VisitRequestCreateRequest {
private String email; private String email;
private String vehicleNo; private String vehicleNo;
/** Access zone label (fixed list value or "기타" free text). */ /**
private String zoneName; * Server rooms (전산실) selected via checkboxes, e.g. ["4층전산실", "5층전산실"].
* Each selected room becomes its own visit request / QR pass.
*/
private List<String> serverRooms;
/**
* Optional detail room (콤보박스, 기타 선택 시 자유 입력값). Auxiliary info — carried
* alongside the server room on the zone label but never issues its own QR. When no
* server room is selected, this becomes the sole access zone.
*/
private String roomZone;
@NotBlank(message = "출입 목적을 입력하세요.") @NotBlank(message = "출입 목적을 입력하세요.")
private String purpose; private String purpose;
/** 작업명 — optional concrete task detail, stored separately from purpose. */
private String workName;
// 출입통제담당자(본인) — 웹은 서버가 로그인 사용자로 채움. 엑셀은 파일값 사용.
private String controlName;
private String controlTeam;
private String controlContact;
// 현장감시자1(고정) — 웹은 서버가 고정 상수로 채움. 엑셀은 파일값(비면 고정).
private String watcher1Name;
private String watcher1Team;
private String watcher1Contact;
// 현장감시자2 — 담당자가 입력.
private String watcher2Name;
private String watcher2Team;
private String watcher2Contact;
@NotNull(message = "방문 시작 일시를 입력하세요.") @NotNull(message = "방문 시작 일시를 입력하세요.")
private LocalDateTime visitFrom; private LocalDateTime visitFrom;

View File

@@ -17,6 +17,16 @@ public class VisitRequestResponse {
private String hostDepartment; private String hostDepartment;
private String zoneName; private String zoneName;
private String purpose; private String purpose;
private String workName;
private String controlName;
private String controlTeam;
private String controlContact;
private String watcher1Name;
private String watcher1Team;
private String watcher1Contact;
private String watcher2Name;
private String watcher2Team;
private String watcher2Contact;
private LocalDateTime visitFrom; private LocalDateTime visitFrom;
private LocalDateTime visitTo; private LocalDateTime visitTo;
private String status; private String status;
@@ -35,6 +45,16 @@ public class VisitRequestResponse {
r.hostDepartment = vr.getHost().getDepartment(); r.hostDepartment = vr.getHost().getDepartment();
r.zoneName = vr.getZoneName(); r.zoneName = vr.getZoneName();
r.purpose = vr.getPurpose(); r.purpose = vr.getPurpose();
r.workName = vr.getWorkName();
r.controlName = vr.getControlName();
r.controlTeam = vr.getControlTeam();
r.controlContact = vr.getControlContact();
r.watcher1Name = vr.getWatcher1Name();
r.watcher1Team = vr.getWatcher1Team();
r.watcher1Contact = vr.getWatcher1Contact();
r.watcher2Name = vr.getWatcher2Name();
r.watcher2Team = vr.getWatcher2Team();
r.watcher2Contact = vr.getWatcher2Contact();
r.visitFrom = vr.getVisitFrom(); r.visitFrom = vr.getVisitFrom();
r.visitTo = vr.getVisitTo(); r.visitTo = vr.getVisitTo();
r.status = vr.getStatus().name(); r.status = vr.getStatus().name();

View File

@@ -0,0 +1,9 @@
package com.itcenter.acs.entity;
/** Auditable administrative actions. */
public enum AuditAction {
APPROVE,
REJECT,
BLACKLIST_ADD,
BLACKLIST_REMOVE
}

View File

@@ -0,0 +1,46 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* An audit record of an administrative action (who did what, when, to which target).
* Immutable once written. {@code actorId} is null for system/scheduler-initiated actions.
*/
@Entity
@Table(name = "audit_logs", indexes = {
@Index(name = "idx_audit_created_at", columnList = "createdAt"),
@Index(name = "idx_audit_action", columnList = "action")
})
@Getter
@Setter
@NoArgsConstructor
public class AuditLog extends BaseEntity {
/** User who performed the action; null for system-initiated actions. */
@Column(name = "actor_id")
private Long actorId;
@Column(name = "actor_username", length = 50)
private String actorUsername;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private AuditAction action;
@Column(name = "target_type", length = 30)
private String targetType;
@Column(name = "target_id")
private Long targetId;
@Column(length = 500)
private String detail;
}

View File

@@ -0,0 +1,7 @@
package com.itcenter.acs.entity;
/** Delivery outcome of a visitor pass notification. */
public enum DeliveryStatus {
SENT,
FAILED
}

View File

@@ -0,0 +1,46 @@
package com.itcenter.acs.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Outbox record of a visitor-pass delivery attempt. A FAILED row is retried by
* {@code PassDeliveryRetryScheduler} until it succeeds or hits the attempt cap.
*/
@Entity
@Table(name = "pass_deliveries", indexes = {
@Index(name = "idx_pd_status", columnList = "status"),
@Index(name = "idx_pd_visit_request", columnList = "visit_request_id")
})
@Getter
@Setter
@NoArgsConstructor
public class PassDelivery extends BaseEntity {
@Column(name = "visit_request_id", nullable = false)
private Long visitRequestId;
/** Delivery channel: "dev" / "hanbank" / "email". */
@Column(length = 20)
private String channel;
@Column(length = 120)
private String recipient;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private DeliveryStatus status;
@Column(nullable = false)
private int attempts;
@Column(name = "last_error", length = 500)
private String lastError;
}

View File

@@ -46,6 +46,34 @@ public class VisitRequest extends BaseEntity {
@Column(nullable = false, length = 255) @Column(nullable = false, length = 255)
private String purpose; private String purpose;
/** 작업명 — concrete task detail, kept separate from the simple purpose category. */
@Column(name = "work_name", length = 255)
private String workName;
// ===== 출입통제담당자 (본인 = 등록한 내부 직원) 스냅샷 =====
@Column(name = "control_name", length = 80)
private String controlName;
@Column(name = "control_team", length = 80)
private String controlTeam;
@Column(name = "control_contact", length = 60)
private String controlContact;
// ===== 현장감시자1 (고정) =====
@Column(name = "watcher1_name", length = 80)
private String watcher1Name;
@Column(name = "watcher1_team", length = 80)
private String watcher1Team;
@Column(name = "watcher1_contact", length = 60)
private String watcher1Contact;
// ===== 현장감시자2 (담당자가 입력) =====
@Column(name = "watcher2_name", length = 80)
private String watcher2Name;
@Column(name = "watcher2_team", length = 80)
private String watcher2Team;
@Column(name = "watcher2_contact", length = 60)
private String watcher2Contact;
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime visitFrom; private LocalDateTime visitFrom;

View File

@@ -50,8 +50,7 @@ public class EmailPassNotifier implements PassNotifier {
Visitor visitor = visitRequest.getVisitor(); Visitor visitor = visitRequest.getVisitor();
String to = visitor != null ? visitor.getEmail() : null; String to = visitor != null ? visitor.getEmail() : null;
if (to == null || to.isBlank()) { if (to == null || to.isBlank()) {
log.warn("[email] 방문자 이메일이 없어 출입증 메일을 발송하지 못했습니다. visitRequestId={}", visitRequest.getId()); throw new IllegalStateException("방문자 이메일이 없어 출입증 메일을 발송할 수 없습니다.");
return;
} }
try { try {
@@ -66,10 +65,22 @@ public class EmailPassNotifier implements PassNotifier {
mailSender.send(message); mailSender.send(message);
log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length); log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length);
} catch (Exception e) { } catch (Exception e) {
log.warn("[email] 출입증 메일 발송 실패 → {} : {}", to, e.getMessage()); // wrap so the caller records a retryable failure
throw new IllegalStateException("메일 발송 실패 → " + to + " : " + e.getMessage(), e);
} }
} }
@Override
public String channel() {
return "email";
}
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getEmail() : null;
}
private String buildBody(VisitRequest vr) { private String buildBody(VisitRequest vr) {
Visitor visitor = vr.getVisitor(); Visitor visitor = vr.getVisitor();
String name = visitor != null ? visitor.getName() : "방문자"; String name = visitor != null ? visitor.getName() : "방문자";

View File

@@ -45,8 +45,7 @@ public class HanbankMessagePassNotifier implements PassNotifier {
Visitor visitor = visitRequest.getVisitor(); Visitor visitor = visitRequest.getVisitor();
String phone = visitor != null ? digitsOnly(visitor.getContact()) : ""; String phone = visitor != null ? digitsOnly(visitor.getContact()) : "";
if (phone.isBlank()) { if (phone.isBlank()) {
log.warn("[sms] 방문자 연락처가 없어 출입증 문자를 발송하지 못했습니다. visitRequestId={}", visitRequest.getId()); throw new IllegalStateException("방문자 연락처가 없어 출입증 문자를 발송할 수 없습니다.");
return;
} }
Map<String, String> body = Map.of( Map<String, String> body = Map.of(
@@ -55,22 +54,29 @@ public class HanbankMessagePassNotifier implements PassNotifier {
"msg_type", "LMS", "msg_type", "LMS",
"reserve_time", ""); "reserve_time", "");
try { SmsResponse res = restClient.post()
SmsResponse res = restClient.post() .uri("/sens/sms")
.uri("/sens/sms") .body(body)
.body(body) .retrieve()
.retrieve() .body(SmsResponse.class);
.body(SmsResponse.class);
if (res != null && "202".equals(res.statusCode())) { if (res == null || !"202".equals(res.statusCode())) {
log.info("[sms] 출입증 LMS 발송 성공 → {} (requestId={})", phone, res.requestId()); throw new IllegalStateException("LMS 발송 실패 (statusCode="
} else { + (res != null ? res.statusCode() : "null")
log.warn("[sms] 출입증 LMS 발송 실패 → {} (statusCode={}, statusName={})", + ", statusName=" + (res != null ? res.statusName() : "null") + ")");
phone, res != null ? res.statusCode() : "null", res != null ? res.statusName() : "null");
}
} catch (Exception e) {
log.warn("[sms] 출입증 LMS 발송 호출 오류 → {} : {}", phone, e.getMessage());
} }
log.info("[sms] 출입증 LMS 발송 성공 → {} (requestId={})", phone, res.requestId());
}
@Override
public String channel() {
return "hanbank";
}
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getContact() : null;
} }
private String buildContent(VisitRequest vr) { private String buildContent(VisitRequest vr) {

View File

@@ -50,6 +50,17 @@ public class LoggingPassNotifier implements PassNotifier {
saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message); saved != null ? " 이미지=" + saved : " (이미지 저장 실패)", message);
} }
@Override
public String channel() {
return "dev";
}
@Override
public String recipient(VisitRequest visitRequest) {
Visitor visitor = visitRequest.getVisitor();
return visitor != null ? visitor.getContact() : null;
}
private String buildMessage(VisitRequest vr) { private String buildMessage(VisitRequest vr) {
Visitor visitor = vr.getVisitor(); Visitor visitor = vr.getVisitor();
String name = visitor != null ? visitor.getName() : "방문자"; String name = visitor != null ? visitor.getName() : "방문자";

View File

@@ -18,6 +18,15 @@ public interface PassNotifier {
* *
* @param visitRequest the approved request (carries visitor, phone, window) * @param visitRequest the approved request (carries visitor, phone, window)
* @param qrPng PNG bytes of the pass QR to attach/send * @param qrPng PNG bytes of the pass QR to attach/send
* @throws RuntimeException if delivery fails — the caller ({@code PassDeliveryService})
* records the failure so it can be retried. Implementations
* must NOT swallow delivery errors.
*/ */
void sendPass(VisitRequest visitRequest, byte[] qrPng); void sendPass(VisitRequest visitRequest, byte[] qrPng);
/** Short channel identifier for the delivery record (e.g. "dev", "hanbank", "email"). */
String channel();
/** The address this channel delivers to for the given request (phone or email); may be null. */
String recipient(VisitRequest visitRequest);
} }

View File

@@ -15,6 +15,9 @@ public interface AccessEventRepository extends JpaRepository<AccessEvent, Long>
List<AccessEvent> findByVisitRequestIdOrderByEventAtAsc(Long visitRequestId); List<AccessEvent> findByVisitRequestIdOrderByEventAtAsc(Long visitRequestId);
/** All events for a set of visits (asc), for computing per-visit state without N+1. */
List<AccessEvent> findByVisitRequestIdInOrderByEventAtAsc(java.util.Collection<Long> visitRequestIds);
/** Distinct visit-request ids that had an entry (IN) within the window (e.g. today). */ /** Distinct visit-request ids that had an entry (IN) within the window (e.g. today). */
@Query("select distinct e.visitRequest.id from AccessEvent e " + @Query("select distinct e.visitRequest.id from AccessEvent e " +
"where e.direction = com.itcenter.acs.entity.Direction.IN " + "where e.direction = com.itcenter.acs.entity.Direction.IN " +

View File

@@ -0,0 +1,10 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.AuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
List<AuditLog> findTop200ByOrderByCreatedAtDesc();
}

View File

@@ -0,0 +1,17 @@
package com.itcenter.acs.repository;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PassDeliveryRepository extends JpaRepository<PassDelivery, Long> {
/** Failed deliveries still under the retry cap, oldest first. */
List<PassDelivery> findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus status, int maxAttempts);
List<PassDelivery> findByStatusOrderByCreatedAtDesc(DeliveryStatus status);
List<PassDelivery> findTop200ByOrderByCreatedAtDesc();
}

View File

@@ -32,6 +32,10 @@ public interface VisitRequestRepository extends JpaRepository<VisitRequest, Long
@Query("select vr from VisitRequest vr where vr.qrToken = :qrToken") @Query("select vr from VisitRequest vr where vr.qrToken = :qrToken")
Optional<VisitRequest> findByQrTokenForUpdate(@Param("qrToken") String qrToken); Optional<VisitRequest> findByQrTokenForUpdate(@Param("qrToken") String qrToken);
/** Loads visits with visitor+host eagerly in one query (avoids N+1 in the access lists). */
@Query("select vr from VisitRequest vr join fetch vr.visitor join fetch vr.host where vr.id in :ids")
List<VisitRequest> findAllWithVisitorAndHostByIdIn(@Param("ids") java.util.Collection<Long> ids);
/** Bulk-expire approved visits whose window has passed (visit_to before the cutoff). */ /** Bulk-expire approved visits whose window has passed (visit_to before the cutoff). */
@Modifying(clearAutomatically = true) @Modifying(clearAutomatically = true)
@Query("update VisitRequest vr set vr.status = com.itcenter.acs.entity.VisitStatus.EXPIRED " + @Query("update VisitRequest vr set vr.status = com.itcenter.acs.entity.VisitStatus.EXPIRED " +

View File

@@ -21,6 +21,8 @@ public class UserPrincipal implements UserDetails {
private final String username; private final String username;
private final String password; private final String password;
private final String fullName; private final String fullName;
private final String department;
private final String email;
private final boolean mustChangePassword; private final boolean mustChangePassword;
private final boolean enabled; private final boolean enabled;
private final boolean locked; private final boolean locked;
@@ -31,6 +33,8 @@ public class UserPrincipal implements UserDetails {
this.username = user.getUsername(); this.username = user.getUsername();
this.password = user.getPasswordHash(); this.password = user.getPasswordHash();
this.fullName = user.getFullName(); this.fullName = user.getFullName();
this.department = user.getDepartment();
this.email = user.getEmail();
this.mustChangePassword = user.isMustChangePassword(); this.mustChangePassword = user.isMustChangePassword();
this.enabled = user.isEnabled(); this.enabled = user.isEnabled();
this.locked = user.isLocked(); this.locked = user.isLocked();

View File

@@ -21,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -93,23 +94,26 @@ public class AccessService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<InsideVisitorResponse> listInside() { public List<InsideVisitorResponse> listInside() {
return accessEventRepository.findInsideVisitRequestIds().stream() List<Long> ids = accessEventRepository.findInsideVisitRequestIds();
.map(visitRequestRepository::findById) if (ids.isEmpty()) {
.filter(java.util.Optional::isPresent) return List.of();
.map(java.util.Optional::get) }
.map(vr -> { Map<Long, VisitRequest> vrById = byId(ids);
LocalDateTime checkInAt = accessEventRepository // last (latest) event per visit = check-in time for currently-inside visitors
.findFirstByVisitRequestIdOrderByEventAtDesc(vr.getId()) Map<Long, LocalDateTime> checkInAt = new java.util.HashMap<>();
.map(AccessEvent::getEventAt) for (AccessEvent e : accessEventRepository.findByVisitRequestIdInOrderByEventAtAsc(ids)) {
.orElse(null); checkInAt.put(e.getVisitRequest().getId(), e.getEventAt()); // asc → last wins = latest
return new InsideVisitorResponse( }
vr.getId(), return ids.stream()
vr.getVisitor().getName(), .map(vrById::get)
vr.getVisitor().getCompany(), .filter(java.util.Objects::nonNull)
vr.getZoneName(), .map(vr -> new InsideVisitorResponse(
vr.getHost().getFullName(), vr.getId(),
checkInAt); vr.getVisitor().getName(),
}) vr.getVisitor().getCompany(),
vr.getZoneName(),
vr.getHost().getFullName(),
checkInAt.get(vr.getId())))
.toList(); .toList();
} }
@@ -118,19 +122,31 @@ public class AccessService {
public List<AccessRecordResponse> listTodayRecords() { public List<AccessRecordResponse> listTodayRecords() {
LocalDateTime start = LocalDate.now().atStartOfDay(); LocalDateTime start = LocalDate.now().atStartOfDay();
LocalDateTime end = start.plusDays(1); LocalDateTime end = start.plusDays(1);
List<Long> ids = accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end);
if (ids.isEmpty()) {
return List.of();
}
Map<Long, VisitRequest> vrById = byId(ids);
// group today's events per visit in one pass (events already ordered ascending)
Map<Long, List<AccessEvent>> eventsByVr = new java.util.HashMap<>();
for (AccessEvent e : accessEventRepository.findByVisitRequestIdInOrderByEventAtAsc(ids)) {
if (!e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end)) {
eventsByVr.computeIfAbsent(e.getVisitRequest().getId(), k -> new java.util.ArrayList<>()).add(e);
}
}
List<AccessRecordResponse> out = new java.util.ArrayList<>(); List<AccessRecordResponse> out = new java.util.ArrayList<>();
for (Long id : accessEventRepository.findVisitRequestIdsCheckedInBetween(start, end)) { for (Long id : ids) {
VisitRequest vr = visitRequestRepository.findById(id).orElse(null); VisitRequest vr = vrById.get(id);
if (vr == null) { if (vr == null) {
continue; continue;
} }
List<AccessEvent> events = accessEventRepository.findByVisitRequestIdOrderByEventAtAsc(id).stream() List<AccessEvent> events = eventsByVr.getOrDefault(id, List.of());
.filter(e -> !e.getEventAt().isBefore(start) && e.getEventAt().isBefore(end))
.toList();
LocalDateTime checkInAt = events.stream() LocalDateTime checkInAt = events.stream()
.filter(e -> e.getDirection() == Direction.IN) .filter(e -> e.getDirection() == Direction.IN)
.map(AccessEvent::getEventAt).findFirst().orElse(null); .map(AccessEvent::getEventAt).findFirst().orElse(null);
boolean inside = isInside(id); // inside = the latest event today is an entry
boolean inside = !events.isEmpty()
&& events.get(events.size() - 1).getDirection() == Direction.IN;
LocalDateTime checkOutAt = inside ? null : events.stream() LocalDateTime checkOutAt = inside ? null : events.stream()
.filter(e -> e.getDirection() == Direction.OUT) .filter(e -> e.getDirection() == Direction.OUT)
.map(AccessEvent::getEventAt).reduce((a, b) -> b).orElse(null); .map(AccessEvent::getEventAt).reduce((a, b) -> b).orElse(null);
@@ -147,6 +163,15 @@ public class AccessService {
return out; return out;
} }
/** One query to load the given visits with visitor+host, keyed by id. */
private Map<Long, VisitRequest> byId(List<Long> ids) {
Map<Long, VisitRequest> map = new java.util.HashMap<>();
for (VisitRequest vr : visitRequestRepository.findAllWithVisitorAndHostByIdIn(ids)) {
map.put(vr.getId(), vr);
}
return map;
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<VisitRequest> searchApproved(String q) { public List<VisitRequest> searchApproved(String q) {
if (q == null || q.isBlank()) { if (q == null || q.isBlank()) {

View File

@@ -6,7 +6,6 @@ import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.VisitRequest; import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus; import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.exception.ApiException; import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.notification.PassNotifier;
import com.itcenter.acs.repository.ApprovalRepository; import com.itcenter.acs.repository.ApprovalRepository;
import com.itcenter.acs.repository.UserRepository; import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository; import com.itcenter.acs.repository.VisitRequestRepository;
@@ -24,14 +23,11 @@ import java.util.UUID;
@Transactional @Transactional
public class ApprovalService { 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 VisitRequestRepository visitRequestRepository;
private final ApprovalRepository approvalRepository; private final ApprovalRepository approvalRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final QrService qrService; private final PassDeliveryService passDeliveryService;
private final PassNotifier passNotifier; private final AuditService auditService;
public VisitRequest approve(Long visitRequestId, Long approverId, String comment) { public VisitRequest approve(Long visitRequestId, Long approverId, String comment) {
return decide(visitRequestId, approverId, comment, ApprovalDecision.APPROVED); return decide(visitRequestId, approverId, comment, ApprovalDecision.APPROVED);
@@ -67,24 +63,20 @@ public class ApprovalService {
approval.setDecidedAt(LocalDateTime.now()); approval.setDecidedAt(LocalDateTime.now());
approvalRepository.save(approval); approvalRepository.save(approval);
String visitorName = vr.getVisitor() != null ? vr.getVisitor().getName() : "?";
auditService.record(
decision == ApprovalDecision.APPROVED
? com.itcenter.acs.entity.AuditAction.APPROVE
: com.itcenter.acs.entity.AuditAction.REJECT,
"VISIT_REQUEST", vr.getId(),
"방문자=" + visitorName + (comment != null && !comment.isBlank() ? ", 의견=" + comment : ""));
if (decision == ApprovalDecision.APPROVED) { if (decision == ApprovalDecision.APPROVED) {
notifyVisitor(vr); // Delivery records its own outcome (SENT/FAILED) and never throws, so a
// delivery problem cannot roll back the approval; failures are retried later.
passDeliveryService.deliver(vr);
} }
return 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());
}
}
} }

View File

@@ -0,0 +1,55 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.AuditLog;
import com.itcenter.acs.repository.AuditLogRepository;
import com.itcenter.acs.security.SecurityUtils;
import com.itcenter.acs.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Records administrative actions to the audit trail. {@link #record} joins the
* caller's transaction so the action and its audit entry commit atomically.
*/
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditLogRepository auditLogRepository;
@Transactional
public void record(AuditAction action, String targetType, Long targetId, String detail) {
AuditLog log = new AuditLog();
log.setAction(action);
log.setTargetType(targetType);
log.setTargetId(targetId);
log.setDetail(truncate(detail));
// Best-effort actor resolution — some callers (e.g. schedulers) have no principal.
try {
UserPrincipal principal = SecurityUtils.currentPrincipal();
log.setActorId(principal.getId());
log.setActorUsername(principal.getUsername());
} catch (RuntimeException ignored) {
// system-initiated: leave actor null
}
auditLogRepository.save(log);
}
@Transactional(readOnly = true)
public List<AuditLog> recent() {
return auditLogRepository.findTop200ByOrderByCreatedAtDesc();
}
private static String truncate(String s) {
if (s == null) {
return null;
}
return s.length() <= 500 ? s : s.substring(0, 500);
}
}

View File

@@ -1,6 +1,7 @@
package com.itcenter.acs.service; package com.itcenter.acs.service;
import com.itcenter.acs.dto.BlacklistRequest; import com.itcenter.acs.dto.BlacklistRequest;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.Blacklist; import com.itcenter.acs.entity.Blacklist;
import com.itcenter.acs.entity.User; import com.itcenter.acs.entity.User;
import com.itcenter.acs.exception.ApiException; import com.itcenter.acs.exception.ApiException;
@@ -19,6 +20,7 @@ public class BlacklistService {
private final BlacklistRepository blacklistRepository; private final BlacklistRepository blacklistRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<Blacklist> listActive() { public List<Blacklist> listActive() {
@@ -35,7 +37,10 @@ public class BlacklistService {
b.setReason(req.getReason()); b.setReason(req.getReason());
b.setActive(true); b.setActive(true);
b.setCreatedBy(creator); b.setCreatedBy(creator);
return blacklistRepository.save(b); Blacklist saved = blacklistRepository.save(b);
auditService.record(AuditAction.BLACKLIST_ADD, "BLACKLIST", saved.getId(),
"대상=" + saved.getName() + ", 사유=" + saved.getReason());
return saved;
} }
/** Soft-deactivate (lift) a block. */ /** Soft-deactivate (lift) a block. */
@@ -43,6 +48,8 @@ public class BlacklistService {
Blacklist b = blacklistRepository.findById(id) Blacklist b = blacklistRepository.findById(id)
.orElseThrow(() -> ApiException.notFound("차단 항목을 찾을 수 없습니다.")); .orElseThrow(() -> ApiException.notFound("차단 항목을 찾을 수 없습니다."));
b.setActive(false); b.setActive(false);
auditService.record(AuditAction.BLACKLIST_REMOVE, "BLACKLIST", b.getId(),
"대상=" + b.getName());
} }
/** Returns the matching block reason, or null if not blacklisted. */ /** Returns the matching block reason, or null if not blacklisted. */

View File

@@ -15,16 +15,24 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/** /**
* Bulk-imports visit requests from an .xlsx file. * Bulk-imports visit requests from the corporate 방문자명단 .xlsx template
* Columns (row 1 = header, skipped): * (itcas_visitor_template.xlsx). The data sheet is "방문자명단"; the first two rows are
* 0 visitorName | 1 company | 2 contact | 3 email | 4 vehicleNo * group + column headers and are skipped. Columns (0-based):
* 5 zoneName | 6 purpose | 7 visitFrom | 8 visitTo * 0 순번 | 1 출입목적 | 2 작업명 | 3 장소 | 4 출입일자 | 5 출입시간 |
* Dates accept Excel date cells or "yyyy-MM-dd HH:mm" / "yyyy-MM-dd" text. * 6 이름 | 7 소속 | 8 연락처(휴대전화) | 9 차량번호 |
* 10 직원명 | 11 담당팀명 | 12 연락처 (출입통제담당자) |
* 13 이름 | 14 소속 | 15 연락처 (현장감시자1) |
* 16 이름 | 17 소속 | 18 연락처 (현장감시자2)
* Each row is one 장소 → one request/QR ("4층전산실과 5층전산실은 개별 행으로 작성"). Only the
* start date+time are captured; visitTo defaults to 23:59:59 of that day (actual entry/exit
* are tracked by check-in/out). 출입일자 accepts Excel date cells or "yyyy.M.d(요일)" /
* "yyyy-MM-dd" text; 출입시간 accepts Excel time cells, "HH:mm" text, or a day fraction.
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -32,7 +40,9 @@ public class ExcelImportService {
private final VisitRequestService visitRequestService; private final VisitRequestService visitRequestService;
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd[ HH:mm]"); private static final String DATA_SHEET = "방문자명단";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-M-d");
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("H:mm[:ss]");
// Not @Transactional: each row imports in its own transaction (create() is // Not @Transactional: each row imports in its own transaction (create() is
// @Transactional), so a duplicate/invalid row fails independently without // @Transactional), so a duplicate/invalid row fails independently without
@@ -42,10 +52,13 @@ public class ExcelImportService {
int dataRows = 0; int dataRows = 0;
try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) { try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) {
Sheet sheet = workbook.getSheetAt(0); Sheet sheet = workbook.getSheet(DATA_SHEET);
if (sheet == null) {
sheet = workbook.getSheetAt(0);
}
for (Row row : sheet) { for (Row row : sheet) {
if (row.getRowNum() == 0) { if (row.getRowNum() <= 1) {
continue; // header continue; // 그룹 헤더 + 컬럼 헤더
} }
if (isEmptyRow(row)) { if (isEmptyRow(row)) {
continue; continue;
@@ -53,7 +66,7 @@ public class ExcelImportService {
dataRows++; dataRows++;
try { try {
VisitRequestCreateRequest req = parseRow(row); VisitRequestCreateRequest req = parseRow(row);
visitRequestService.create(req, hostUserId); visitRequestService.createRequests(req, hostUserId);
result.setSuccessCount(result.getSuccessCount() + 1); result.setSuccessCount(result.getSuccessCount() + 1);
} catch (Exception e) { } catch (Exception e) {
result.getErrors().add("" + (row.getRowNum() + 1) + ": " + e.getMessage()); result.getErrors().add("" + (row.getRowNum() + 1) + ": " + e.getMessage());
@@ -66,20 +79,57 @@ public class ExcelImportService {
private VisitRequestCreateRequest parseRow(Row row) { private VisitRequestCreateRequest parseRow(Row row) {
VisitRequestCreateRequest req = new VisitRequestCreateRequest(); VisitRequestCreateRequest req = new VisitRequestCreateRequest();
req.setVisitorName(requireString(row.getCell(0), "방문자 이름")); req.setPurpose(requireString(row.getCell(1), "출입목적"));
req.setCompany(getString(row.getCell(1))); req.setWorkName(getString(row.getCell(2)));
req.setContact(requireString(row.getCell(2), "연락처")); req.setServerRooms(parseServerRooms(requireString(row.getCell(3), "장소")));
req.setEmail(getString(row.getCell(3)));
req.setVehicleNo(getString(row.getCell(4))); LocalDate date = requireDate(row.getCell(4), "출입일자");
req.setZoneName(getString(row.getCell(5))); LocalTime time = parseTime(row.getCell(5));
req.setPurpose(requireString(row.getCell(6), "출입 목적")); req.setVisitFrom(date.atTime(time != null ? time : LocalTime.MIDNIGHT));
req.setVisitFrom(requireDateTime(row.getCell(7), "출입 일시")); // 신청 시엔 시작만 입력받고, 종료(퇴실)는 당일 마감으로 둔다 (실제 입·퇴장은 체크인/아웃에서 관리).
req.setVisitTo(requireDateTime(row.getCell(8), "퇴실 일시")); req.setVisitTo(date.atTime(LocalTime.of(23, 59, 59)));
req.setVisitorName(requireString(row.getCell(6), "이름"));
req.setCompany(getString(row.getCell(7)));
req.setContact(requireString(row.getCell(8), "연락처"));
req.setVehicleNo(getString(row.getCell(9)));
// 출입통제담당자 (비면 서비스가 업로드 사용자로 채움)
req.setControlName(getString(row.getCell(10)));
req.setControlTeam(getString(row.getCell(11)));
req.setControlContact(getString(row.getCell(12)));
// 현장감시자1 (비면 서비스가 고정값으로 채움)
req.setWatcher1Name(getString(row.getCell(13)));
req.setWatcher1Team(getString(row.getCell(14)));
req.setWatcher1Contact(getString(row.getCell(15)));
// 현장감시자2
req.setWatcher2Name(getString(row.getCell(16)));
req.setWatcher2Team(getString(row.getCell(17)));
req.setWatcher2Contact(getString(row.getCell(18)));
return req; return req;
} }
/** Splits the 장소 cell into individual zones on comma / semicolon / slash (usually one). */
private List<String> parseServerRooms(String raw) {
if (raw == null || raw.isBlank()) {
return List.of();
}
List<String> rooms = new ArrayList<>();
for (String part : raw.split("[,;/]")) {
String trimmed = part.trim();
if (!trimmed.isEmpty()) {
rooms.add(trimmed);
}
}
return rooms;
}
// A pre-numbered but otherwise blank template row (순번 filled, rest empty) is skipped;
// 순번(0) is ignored so those rows don't count as data.
private static final int[] KEY_COLS = {1, 3, 4, 6, 8};
private boolean isEmptyRow(Row row) { private boolean isEmptyRow(Row row) {
for (int c = 0; c <= 8; c++) { for (int c : KEY_COLS) {
String v = getString(row.getCell(c)); String v = getString(row.getCell(c));
if (v != null && !v.isBlank()) { if (v != null && !v.isBlank()) {
return false; return false;
@@ -118,21 +168,62 @@ public class ExcelImportService {
return v; return v;
} }
private LocalDateTime requireDateTime(Cell cell, String field) { /** Parses 출입일자: Excel date cell, or text like "2026.7.8(수)" / "2026-07-08" / "2026.07.08". */
private LocalDate requireDate(Cell cell, String field) {
if (cell == null) { if (cell == null) {
throw new IllegalArgumentException(field + "은(는) 필수입니다."); throw new IllegalArgumentException(field + "은(는) 필수입니다.");
} }
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue(); return cell.getLocalDateTimeCellValue().toLocalDate();
} }
String text = requireString(cell, field); String text = requireString(cell, field);
String norm = text;
int paren = norm.indexOf('(');
if (paren >= 0) {
norm = norm.substring(0, paren); // "(요일)" 제거
}
norm = norm.trim().replace('.', '-').replace('/', '-').replace(" ", "");
norm = norm.replaceAll("-{2,}", "-").replaceAll("-+$", "");
try { try {
if (text.length() <= 10) { return LocalDate.parse(norm, DATE_FMT);
return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay();
}
return LocalDateTime.parse(text.replace('T', ' '), DT);
} catch (Exception e) { } catch (Exception e) {
throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다 (yyyy-MM-dd HH:mm): " + text); throw new IllegalArgumentException(
field + " 형식이 올바르지 않습니다 (예: 2026-07-08 또는 2026.7.8): " + text);
} }
} }
/** Parses 출입시간 (optional): Excel time cell, "HH:mm" text, or a day fraction (0.625 = 15:00). */
private LocalTime parseTime(Cell cell) {
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.NUMERIC) {
if (DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue().toLocalTime();
}
return fractionToTime(cell.getNumericCellValue());
}
String t = getString(cell);
if (t == null || t.isBlank()) {
return null;
}
t = t.trim();
try {
if (t.matches("\\d{1,2}:\\d{2}(:\\d{2})?")) {
return LocalTime.parse(t, TIME_FMT);
}
double d = Double.parseDouble(t);
return fractionToTime(d);
} catch (Exception e) {
return null; // 해석 불가한 시간 → 시작시간 미지정으로 처리
}
}
private LocalTime fractionToTime(double d) {
double frac = d - Math.floor(d);
if (frac < 0) {
return null;
}
return LocalTime.ofSecondOfDay(Math.round(frac * 86400) % 86400);
}
} }

View File

@@ -0,0 +1,27 @@
package com.itcenter.acs.service;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
/**
* Periodically retries failed pass deliveries recorded in the outbox, up to
* {@code acs.delivery.max-attempts}. Thin wrapper — the send/record logic lives
* in {@link PassDeliveryService}.
*/
@Service
@RequiredArgsConstructor
public class PassDeliveryRetryScheduler {
private final PassDeliveryService passDeliveryService;
@Value("${acs.delivery.max-attempts:5}")
private int maxAttempts;
/** Every 10 minutes by default; override with acs.delivery.retry-cron. */
@Scheduled(cron = "${acs.delivery.retry-cron:0 */10 * * * *}")
public void retryFailed() {
passDeliveryService.retryFailed(maxAttempts);
}
}

View File

@@ -0,0 +1,114 @@
package com.itcenter.acs.service;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.exception.ApiException;
import com.itcenter.acs.notification.PassNotifier;
import com.itcenter.acs.repository.PassDeliveryRepository;
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.util.List;
/**
* Sends the visitor pass and records the outcome in the {@code pass_deliveries}
* outbox. A failed send is persisted (status=FAILED) so it can be retried later
* by {@code PassDeliveryRetryScheduler} or an admin, rather than being lost.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PassDeliveryService {
/** QR pixel size for the pass image. */
private static final int PASS_QR_SIZE = 240;
private final PassDeliveryRepository passDeliveryRepository;
private final VisitRequestRepository visitRequestRepository;
private final QrService qrService;
private final PassNotifier notifier;
/** First delivery attempt, invoked right after approval. Never throws. */
@Transactional
public PassDelivery deliver(VisitRequest vr) {
PassDelivery d = new PassDelivery();
d.setVisitRequestId(vr.getId());
d.setChannel(notifier.channel());
d.setRecipient(notifier.recipient(vr));
d.setAttempts(1);
attempt(d, vr);
return passDeliveryRepository.save(d);
}
/** Retries every failed delivery still under the attempt cap. Returns how many now succeeded. */
@Transactional
public int retryFailed(int maxAttempts) {
List<PassDelivery> failed =
passDeliveryRepository.findByStatusAndAttemptsLessThanOrderByCreatedAtAsc(DeliveryStatus.FAILED, maxAttempts);
int recovered = 0;
for (PassDelivery d : failed) {
if (retry(d)) {
recovered++;
}
}
if (!failed.isEmpty()) {
log.info("[delivery] 재발송 시도 {}건 중 {}건 성공", failed.size(), recovered);
}
return recovered;
}
/** Manually retry a single delivery (admin action). */
@Transactional
public PassDelivery retryOne(Long deliveryId) {
PassDelivery d = passDeliveryRepository.findById(deliveryId)
.orElseThrow(() -> ApiException.notFound("발송 기록을 찾을 수 없습니다."));
retry(d);
return d;
}
@Transactional(readOnly = true)
public List<PassDelivery> listByStatus(DeliveryStatus status) {
return status != null
? passDeliveryRepository.findByStatusOrderByCreatedAtDesc(status)
: passDeliveryRepository.findTop200ByOrderByCreatedAtDesc();
}
/** Re-attempts a managed delivery record (dirty-checked). Returns true if it now succeeded. */
private boolean retry(PassDelivery d) {
VisitRequest vr = visitRequestRepository.findById(d.getVisitRequestId()).orElse(null);
d.setAttempts(d.getAttempts() + 1);
if (vr == null) {
d.setStatus(DeliveryStatus.FAILED);
d.setLastError("방문 신청을 찾을 수 없습니다. (id=" + d.getVisitRequestId() + ")");
return false;
}
attempt(d, vr);
return d.getStatus() == DeliveryStatus.SENT;
}
/** Generates the QR and sends via the notifier, setting status/lastError on the record. */
private void attempt(PassDelivery d, VisitRequest vr) {
try {
byte[] qr = qrService.pngForText(vr.getQrToken(), PASS_QR_SIZE);
notifier.sendPass(vr, qr);
d.setStatus(DeliveryStatus.SENT);
d.setLastError(null);
} catch (Exception e) {
d.setStatus(DeliveryStatus.FAILED);
d.setLastError(truncate(e.getMessage()));
log.warn("[delivery] 출입증 발송 실패 (visitRequestId={}, attempts={}): {}",
d.getVisitRequestId(), d.getAttempts(), e.getMessage());
}
}
private static String truncate(String s) {
if (s == null) {
return null;
}
return s.length() <= 500 ? s : s.substring(0, 500);
}
}

View File

@@ -52,35 +52,42 @@ public class ReportService {
List<VisitRequest> rows = visitRequestRepository List<VisitRequest> rows = visitRequestRepository
.findByVisitFromBetweenOrderByVisitFromAsc(from.atStartOfDay(), to.plusDays(1).atStartOfDay()); .findByVisitFromBetweenOrderByVisitFromAsc(from.atStartOfDay(), to.plusDays(1).atStartOfDay());
String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "출입일시", "퇴실일시", "상태"}; String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "작업명", "출입일시", "퇴실일시", "상태"};
try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Sheet sheet = wb.createSheet("출입기록"); Sheet sheet = wb.createSheet("출입기록");
CellStyle headerStyle = headerStyle(wb); CellStyle headerStyle = headerStyle(wb);
// track the widest displayed content per column (CJK counts double) to size columns
int[] widths = new int[headers.length];
Row head = sheet.createRow(0); Row head = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) { for (int i = 0; i < headers.length; i++) {
Cell c = head.createCell(i); Cell c = head.createCell(i);
c.setCellValue(headers[i]); c.setCellValue(headers[i]);
c.setCellStyle(headerStyle); c.setCellStyle(headerStyle);
widths[i] = displayWidth(headers[i]);
} }
int r = 1; int r = 1;
for (VisitRequest vr : rows) { for (VisitRequest vr : rows) {
Row row = sheet.createRow(r++); Row row = sheet.createRow(r++);
row.createCell(0).setCellValue(vr.getVisitor().getName()); put(row, 0, vr.getVisitor().getName(), widths);
row.createCell(1).setCellValue(nv(vr.getVisitor().getCompany())); put(row, 1, nv(vr.getVisitor().getCompany()), widths);
row.createCell(2).setCellValue(nv(vr.getVisitor().getContact())); put(row, 2, nv(vr.getVisitor().getContact()), widths);
row.createCell(3).setCellValue(nv(vr.getZoneName())); put(row, 3, nv(vr.getZoneName()), widths);
row.createCell(4).setCellValue(vr.getHost().getFullName()); put(row, 4, vr.getHost().getFullName(), widths);
row.createCell(5).setCellValue(nv(vr.getPurpose())); put(row, 5, nv(vr.getPurpose()), widths);
row.createCell(6).setCellValue(fmt(vr.getVisitFrom())); put(row, 6, nv(vr.getWorkName()), widths);
row.createCell(7).setCellValue(fmt(vr.getVisitTo())); put(row, 7, fmt(vr.getVisitFrom()), widths);
row.createCell(8).setCellValue(STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name())); put(row, 8, fmt(vr.getVisitTo()), widths);
put(row, 9, STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()), widths);
} }
// autoSizeColumn under-measures CJK text, so set widths from the content
// (1 char ≈ 256 units; +2 chars padding; capped so long purposes don't explode).
for (int i = 0; i < headers.length; i++) { for (int i = 0; i < headers.length; i++) {
sheet.autoSizeColumn(i); int chars = Math.min(widths[i] + 2, 60);
sheet.setColumnWidth(i, chars * 256);
} }
wb.write(out); wb.write(out);
@@ -90,6 +97,33 @@ public class ReportService {
} }
} }
/** Writes a string cell and grows the column's tracked display width. */
private void put(Row row, int col, String value, int[] widths) {
row.createCell(col).setCellValue(value);
int w = displayWidth(value);
if (w > widths[col]) {
widths[col] = w;
}
}
/** Display width where CJK (Hangul/한자/전각) glyphs count as 2 columns, others as 1. */
private int displayWidth(String s) {
if (s == null) {
return 0;
}
int w = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
boolean wide = (c >= 0xAC00 && c <= 0xD7A3) // Hangul syllables
|| (c >= 0x1100 && c <= 0x11FF) // Hangul Jamo
|| (c >= 0x3130 && c <= 0x318F) // Hangul compatibility Jamo
|| (c >= 0x4E00 && c <= 0x9FFF) // CJK unified ideographs
|| (c >= 0xFF00 && c <= 0xFFEF); // fullwidth forms
w += wide ? 2 : 1;
}
return w;
}
private CellStyle headerStyle(Workbook wb) { private CellStyle headerStyle(Workbook wb) {
CellStyle style = wb.createCellStyle(); CellStyle style = wb.createCellStyle();
Font font = wb.createFont(); Font font = wb.createFont();

View File

@@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Service @Service
@@ -24,15 +25,23 @@ public class VisitRequestService {
private final VisitorRepository visitorRepository; private final VisitorRepository visitorRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
// 현장감시자1은 고정 인원. (실제 지정 인원이 다르면 이 상수만 수정)
private static final String FIXED_WATCHER1_NAME = "류관순";
private static final String FIXED_WATCHER1_TEAM = "IT전략국";
private static final String FIXED_WATCHER1_CONTACT = "313";
/** /**
* Create a pre-registration. Reuses an existing visitor (matched by name+contact) * Create pre-registration(s). Reuses an existing visitor (matched by name+contact)
* or creates a new one. The created request starts in PENDING. * or creates a new one, then issues one PENDING request per resolved access zone —
* so selecting two server rooms produces two requests (and, on approval, two QR passes).
*/ */
public VisitRequest create(VisitRequestCreateRequest req, Long hostUserId) { public List<VisitRequest> createRequests(VisitRequestCreateRequest req, Long hostUserId) {
if (req.getVisitTo().isBefore(req.getVisitFrom())) { if (req.getVisitTo().isBefore(req.getVisitFrom())) {
throw ApiException.badRequest("방문 종료 일시가 시작 일시보다 빠를 수 없습니다."); throw ApiException.badRequest("방문 종료 일시가 시작 일시보다 빠를 수 없습니다.");
} }
List<String> zoneNames = resolveZones(req);
User host = userRepository.findById(hostUserId) User host = userRepository.findById(hostUserId)
.orElseThrow(() -> ApiException.notFound("호스트 사용자를 찾을 수 없습니다.")); .orElseThrow(() -> ApiException.notFound("호스트 사용자를 찾을 수 없습니다."));
@@ -46,20 +55,84 @@ public class VisitRequestService {
visitor.setVehicleNo(req.getVehicleNo()); visitor.setVehicleNo(req.getVehicleNo());
visitor = visitorRepository.save(visitor); visitor = visitorRepository.save(visitor);
if (visitRequestRepository.existsActiveDuplicate( List<VisitRequest> created = new ArrayList<>();
visitor.getId(), req.getVisitFrom(), req.getVisitTo(), req.getZoneName())) { for (String zoneName : zoneNames) {
throw ApiException.conflict("이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복)"); if (visitRequestRepository.existsActiveDuplicate(
visitor.getId(), req.getVisitFrom(), req.getVisitTo(), zoneName)) {
throw ApiException.conflict(
"이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복: " + zoneName + ")");
}
VisitRequest vr = new VisitRequest();
vr.setVisitor(visitor);
vr.setHost(host);
vr.setZoneName(zoneName);
vr.setPurpose(req.getPurpose());
vr.setWorkName(req.getWorkName());
vr.setVisitFrom(req.getVisitFrom());
vr.setVisitTo(req.getVisitTo());
vr.setStatus(VisitStatus.PENDING);
// 출입통제담당자 = 요청값 있으면 사용(엑셀), 없으면 본인(등록한 로그인 사용자) 정보로 채움.
vr.setControlName(orElse(req.getControlName(), host.getFullName()));
vr.setControlTeam(orElse(req.getControlTeam(), host.getDepartment()));
vr.setControlContact(orElse(req.getControlContact(), host.getEmail()));
// 현장감시자1 = 고정값(요청값 비면 고정 상수).
vr.setWatcher1Name(orElse(req.getWatcher1Name(), FIXED_WATCHER1_NAME));
vr.setWatcher1Team(orElse(req.getWatcher1Team(), FIXED_WATCHER1_TEAM));
vr.setWatcher1Contact(orElse(req.getWatcher1Contact(), FIXED_WATCHER1_CONTACT));
// 현장감시자2 = 담당자 입력값 그대로.
vr.setWatcher2Name(trimToNull(req.getWatcher2Name()));
vr.setWatcher2Team(trimToNull(req.getWatcher2Team()));
vr.setWatcher2Contact(trimToNull(req.getWatcher2Contact()));
created.add(visitRequestRepository.save(vr));
}
return created;
}
/** Trimmed value, or null when blank. */
private static String trimToNull(String v) {
if (v == null) {
return null;
}
String t = v.trim();
return t.isEmpty() ? null : t;
}
/** The trimmed value if present, otherwise the fallback. */
private static String orElse(String value, String fallback) {
String v = trimToNull(value);
return v != null ? v : fallback;
}
/**
* Resolves the access-zone labels for a request. Each selected server room (전산실) is a
* zone of its own (its own QR); the detail room (콤보박스) is auxiliary and appended to
* the label. With no server room selected, the detail room becomes the sole zone.
*/
private List<String> resolveZones(VisitRequestCreateRequest req) {
String room = req.getRoomZone() == null ? "" : req.getRoomZone().trim();
List<String> serverRooms = new ArrayList<>();
if (req.getServerRooms() != null) {
for (String s : req.getServerRooms()) {
if (s != null && !s.trim().isEmpty()) {
serverRooms.add(s.trim());
}
}
} }
VisitRequest vr = new VisitRequest(); List<String> zones = new ArrayList<>();
vr.setVisitor(visitor); if (!serverRooms.isEmpty()) {
vr.setHost(host); for (String sr : serverRooms) {
vr.setZoneName(req.getZoneName()); zones.add(room.isEmpty() ? sr : sr + " / " + room);
vr.setPurpose(req.getPurpose()); }
vr.setVisitFrom(req.getVisitFrom()); } else if (!room.isEmpty()) {
vr.setVisitTo(req.getVisitTo()); zones.add(room);
vr.setStatus(VisitStatus.PENDING); } else {
return visitRequestRepository.save(vr); throw ApiException.badRequest("출입 구역을 최소 1개 이상 선택하세요.");
}
return zones;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)

View File

@@ -1,6 +1,11 @@
spring.application.name=acs spring.application.name=acs
server.port=8080 server.port=8080
# ===== Reverse proxy (nginx terminates TLS) =====
# Honor X-Forwarded-Proto/For from the nginx front so request.isSecure() is true behind
# TLS termination — makes the CSRF XSRF-TOKEN cookie Secure and redirects use https.
server.forward-headers-strategy=framework
# ===== Session cookie hardening ===== # ===== Session cookie hardening =====
# SameSite=Lax complements the CSRF token defense. # SameSite=Lax complements the CSRF token defense.
# Secure=true means the cookie is only sent over HTTPS — enable it (ACS_COOKIE_SECURE=true) # Secure=true means the cookie is only sent over HTTPS — enable it (ACS_COOKIE_SECURE=true)

View File

@@ -0,0 +1,16 @@
-- 감사 로그: 관리 행위(승인/반려, 블랙리스트 추가/해제) 추적
-- Hibernate ddl-auto=validate 가 검증하므로 AuditLog 엔티티와 컬럼명·타입이 일치해야 한다.
CREATE TABLE audit_logs (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
actor_id BIGINT,
actor_username VARCHAR(50),
action VARCHAR(30) NOT NULL,
target_type VARCHAR(30),
target_id BIGINT,
detail VARCHAR(500)
);
CREATE INDEX idx_audit_created_at ON audit_logs (created_at);
CREATE INDEX idx_audit_action ON audit_logs (action);

View File

@@ -0,0 +1,16 @@
-- 출입증 발송 outbox: 발송 결과 기록 + 실패건 재발송 추적
-- Hibernate ddl-auto=validate 가 검증하므로 PassDelivery 엔티티와 컬럼명·타입이 일치해야 한다.
CREATE TABLE pass_deliveries (
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,
channel VARCHAR(20),
recipient VARCHAR(120),
status VARCHAR(20) NOT NULL,
attempts INTEGER NOT NULL,
last_error VARCHAR(500)
);
CREATE INDEX idx_pd_status ON pass_deliveries (status);
CREATE INDEX idx_pd_visit_request ON pass_deliveries (visit_request_id);

View File

@@ -0,0 +1,11 @@
-- Visit request detail fields added after the initial schema.
ALTER TABLE visit_requests ADD COLUMN work_name VARCHAR(255);
ALTER TABLE visit_requests ADD COLUMN control_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN control_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN control_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN watcher1_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher1_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher1_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN watcher2_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher2_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher2_contact VARCHAR(60);

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs;
import com.itcenter.acs.dto.AccessRecordResponse;
import com.itcenter.acs.dto.CheckInRequest;
import com.itcenter.acs.dto.InsideVisitorResponse;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import com.itcenter.acs.service.AccessService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
/** Guards the batched (N+1-free) listInside/listTodayRecords against behaviour regressions. */
@SpringBootTest
class AccessQueryTest {
@Autowired AccessService accessService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Test
void insideAndTodayRecordsReflectCheckInThenCheckOut() {
User host = userRepository.findByUsername("host").orElseThrow();
String name = "조회테스트-" + System.nanoTime();
Visitor v = new Visitor();
v.setName(name);
v.setCompany("테스트회사");
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("query");
vr.setZoneName("전산실");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.APPROVED);
Long vrId = visitRequestRepository.save(vr).getId();
CheckInRequest req = new CheckInRequest();
req.setVisitRequestId(vrId);
req.setGateId("TEST");
// check-in → appears inside with a check-in time
accessService.checkIn(req, null);
InsideVisitorResponse inside = accessService.listInside().stream()
.filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow();
assertThat(inside.getVisitorName()).isEqualTo(name);
assertThat(inside.getCheckInAt()).isNotNull();
AccessRecordResponse rec = accessService.listTodayRecords().stream()
.filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow();
assertThat(rec.isInside()).isTrue();
assertThat(rec.getCheckInAt()).isNotNull();
// check-out → no longer inside; today record shows a check-out time
accessService.checkOut(req, null);
assertThat(accessService.listInside().stream().anyMatch(r -> r.getVisitRequestId().equals(vrId)))
.isFalse();
AccessRecordResponse after = accessService.listTodayRecords().stream()
.filter(r -> r.getVisitRequestId().equals(vrId)).findFirst().orElseThrow();
assertThat(after.isInside()).isFalse();
assertThat(after.getCheckOutAt()).isNotNull();
}
}

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs;
import com.itcenter.acs.dto.BlacklistRequest;
import com.itcenter.acs.entity.AuditAction;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.repository.AuditLogRepository;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import com.itcenter.acs.service.ApprovalService;
import com.itcenter.acs.service.BlacklistService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
/** Approving a request and adding a blacklist entry each leave an audit record. */
@SpringBootTest
class AuditLogTest {
@Autowired ApprovalService approvalService;
@Autowired BlacklistService blacklistService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Autowired AuditLogRepository auditLogRepository;
@Test
void approvalIsAudited() {
User host = userRepository.findByUsername("host").orElseThrow();
User admin = userRepository.findByUsername("admin").orElseThrow();
Visitor v = new Visitor();
v.setName("감사테스트-" + System.nanoTime());
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("audit");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.PENDING);
Long vrId = visitRequestRepository.save(vr).getId();
approvalService.approve(vrId, admin.getId(), "확인함");
boolean approveAudited = auditLogRepository.findTop200ByOrderByCreatedAtDesc().stream()
.anyMatch(a -> a.getAction() == AuditAction.APPROVE
&& "VISIT_REQUEST".equals(a.getTargetType())
&& vrId.equals(a.getTargetId()));
assertThat(approveAudited).as("APPROVE audit row for the request").isTrue();
}
@Test
void blacklistAddIsAudited() {
BlacklistRequest req = new BlacklistRequest();
req.setName("차단테스트-" + System.nanoTime());
req.setReason("테스트 차단");
User admin = userRepository.findByUsername("admin").orElseThrow();
var saved = blacklistService.add(req, admin.getId());
boolean added = auditLogRepository.findTop200ByOrderByCreatedAtDesc().stream()
.anyMatch(a -> a.getAction() == AuditAction.BLACKLIST_ADD
&& saved.getId().equals(a.getTargetId()));
assertThat(added).as("BLACKLIST_ADD audit row").isTrue();
}
}

View File

@@ -64,7 +64,7 @@ class EmailPassNotifierTest {
} }
@Test @Test
void skipsWhenVisitorHasNoEmail() { void throwsWhenVisitorHasNoEmail() {
JavaMailSender sender = mock(JavaMailSender.class); JavaMailSender sender = mock(JavaMailSender.class);
EmailPassNotifier notifier = EmailPassNotifier notifier =
new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173"); new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173");
@@ -77,9 +77,9 @@ class EmailPassNotifierTest {
vr.setVisitTo(LocalDateTime.now().plusHours(1)); vr.setVisitTo(LocalDateTime.now().plusHours(1));
vr.setQrToken("t"); vr.setQrToken("t");
notifier.sendPass(vr, new byte[]{1}); // no email → delivery failure is signalled (recorded/retried by PassDeliveryService)
org.assertj.core.api.Assertions.assertThatThrownBy(() -> notifier.sendPass(vr, new byte[]{1}))
// no email → never touches the mail sender .isInstanceOf(RuntimeException.class);
verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class)); verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class));
} }
} }

View File

@@ -0,0 +1,76 @@
package com.itcenter.acs;
import com.itcenter.acs.entity.DeliveryStatus;
import com.itcenter.acs.entity.PassDelivery;
import com.itcenter.acs.entity.User;
import com.itcenter.acs.entity.Visitor;
import com.itcenter.acs.entity.VisitRequest;
import com.itcenter.acs.entity.VisitStatus;
import com.itcenter.acs.notification.PassNotifier;
import com.itcenter.acs.repository.PassDeliveryRepository;
import com.itcenter.acs.repository.UserRepository;
import com.itcenter.acs.repository.VisitRequestRepository;
import com.itcenter.acs.repository.VisitorRepository;
import com.itcenter.acs.service.PassDeliveryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
/** A failed send is recorded as FAILED and later flipped to SENT by the retry batch. */
@SpringBootTest
class PassDeliveryTest {
@MockitoBean PassNotifier notifier;
@Autowired PassDeliveryService passDeliveryService;
@Autowired VisitRequestRepository visitRequestRepository;
@Autowired VisitorRepository visitorRepository;
@Autowired UserRepository userRepository;
@Autowired PassDeliveryRepository passDeliveryRepository;
@Test
void failedDeliveryIsRecordedThenRetriedToSent() {
when(notifier.channel()).thenReturn("test");
User host = userRepository.findByUsername("host").orElseThrow();
Visitor v = new Visitor();
v.setName("발송테스트-" + System.nanoTime());
v.setEmail("visitor@example.com");
visitorRepository.save(v);
LocalDate today = LocalDate.now();
VisitRequest vr = new VisitRequest();
vr.setVisitor(v);
vr.setHost(host);
vr.setPurpose("delivery");
vr.setVisitFrom(today.atStartOfDay());
vr.setVisitTo(today.atTime(23, 59));
vr.setStatus(VisitStatus.APPROVED);
vr.setQrToken(UUID.randomUUID().toString());
visitRequestRepository.save(vr);
// 1) send fails → recorded FAILED
doThrow(new RuntimeException("relay down")).when(notifier).sendPass(any(), any());
PassDelivery d = passDeliveryService.deliver(vr);
assertThat(d.getStatus()).isEqualTo(DeliveryStatus.FAILED);
assertThat(d.getLastError()).contains("relay down");
Long deliveryId = d.getId();
// 2) relay recovers → retry batch flips it to SENT
doNothing().when(notifier).sendPass(any(), any());
int recovered = passDeliveryService.retryFailed(5);
assertThat(recovered).isGreaterThanOrEqualTo(1);
assertThat(passDeliveryRepository.findById(deliveryId).orElseThrow().getStatus())
.isEqualTo(DeliveryStatus.SENT);
}
}

View File

@@ -0,0 +1,81 @@
# ACS 로그인 404 분석 및 조치
> 작성일: 2026-07-14
> 증상: 운영 배포 URL에서 로그인 시도 시 404 발생
> 대상 URL: `https://acs.apps.bokdev.in/login`
## 1. 증상
로그인 화면은 표시되지만, 아이디/비밀번호 입력 후 로그인 버튼을 누르면 404 오류가 발생한다.
## 2. 원인
프론트엔드 로그인 화면은 `POST /api/auth/login`을 호출한다.
현재 Node 배포 서버의 `/api` 라우터에는 `healthRouter`만 연결되어 있어 `/api/auth/login` 경로가 존재하지 않았다.
즉, 404는 `/login` 화면 경로가 아니라 로그인 API 경로인 `/api/auth/login`에서 발생한 것이다.
## 3. 조치
Node 서버에 Auth API를 추가했다.
| API | 조치 내용 |
|---|---|
| `POST /api/auth/login` | 사용자 조회, bcrypt 비밀번호 검증, 세션 저장 |
| `POST /api/auth/logout` | 세션 삭제 |
| `GET /api/auth/me` | 현재 로그인 사용자 반환 |
| `POST /api/auth/change-password` | 기존 비밀번호 검증 후 새 비밀번호 저장 |
세션은 PostgreSQL 기반 `connect-pg-simple` 스토어를 사용하도록 연결했다.
## 4. 확인 결과
| 항목 | 결과 |
|---|---|
| `npm run typecheck` | 성공 |
| `npm run build:server` | 성공 |
| `npm run build` | 성공 |
첫 번째 전체 빌드는 샌드박스 안에서 esbuild 프로세스 생성이 `spawn EPERM`으로 차단되었고, 승인 후 샌드박스 밖에서 재실행하여 성공을 확인했다.
## 5. 남은 확인 사항
운영 DB에 로그인 계정이 적재되어 있어야 한다.
기존 seed 파일 기준 기본 계정은 아래와 같다.
| 계정 | 초기 비밀번호 | 역할 |
|---|---|---|
| `admin` | `ChangeMe123!` | ADMIN |
| `security` | `ChangeMe123!` | SECURITY |
| `host` | `ChangeMe123!` | HOST |
| `a` | `1` | ADMIN |
| `s` | `1` | SECURITY |
| `h` | `1` | HOST |
운영 DB에 계정이 없으면 404는 해결되지만 로그인은 401로 실패한다. 이 경우 `scripts/seed-load.py``scripts/seeds/users.csv` 기준으로 사용자 seed 적재가 필요하다.
원활한 테스트를 위해 `a/1`, `s/1`, `h/1` 단축 계정을 seed CSV에 추가했다.
## 6. 재테스트 기록
2026-07-14 사용자가 `a/1`로 운영 URL에서 다시 로그인 시도했으나 404가 재현되었다.
판단: 운영 서버가 아직 Auth API 추가 코드로 재배포되지 않은 상태다. 로컬 변경분을 원격 저장소에 push하고 Coolify 재배포가 완료된 뒤 다시 테스트해야 한다.
2026-07-14 Auth API 커밋 배포 후 `POST /api/auth/login` 응답이 404에서 500으로 변경되었다.
판단: 라우터는 배포에 반영되었고, 남은 문제는 운영 DB의 사용자 seed 또는 로그인 처리 중 DB 상태 문제로 좁혀졌다. 다음 배포부터 테스트 계정이 자동 보장되도록 `migrations/005_seed_test_users.sql`을 추가한다.
2026-07-14 원격 `main``e02afcd Add auth deployment diagnostics`까지 push했다.
확인 결과 `GET /api/health`가 아직 `{"status":"UP","service":"acs-node"}`만 반환하고, 새 build marker인 `auth-seed-20260714`를 반환하지 않았다.
판단: 최신 커밋이 아직 운영 컨테이너에 반영되지 않았다. Coolify에서 수동 Redeploy 또는 배포 로그 확인이 필요하다.
2026-07-14 Coolify Redeploy 후 `/api/health`에서 `build: auth-seed-20260714` 확인.
다만 `/api/auth/diagnostics`에서 `users`, `user_roles` 테이블이 확인되지 않아 로그인은 500으로 계속 실패했다.
판단: Coolify 실행 명령이 Dockerfile의 `CMD ["npm", "run", "start:deploy"]`를 타지 않으면 migration이 실행되지 않을 수 있다. 이를 보완하기 위해 서버 시작 시 `runMigrations()`를 직접 실행하도록 `server/index.ts`에 startup migration을 추가한다.

280
docs/ACS-test-scenarios.md Normal file
View File

@@ -0,0 +1,280 @@
# ACS 테스트 시나리오
> 작성일: 2026-07-14
> 대상: `C:\ai-dev\workspace\acs`
> 목적: ACS 수동 테스트를 위한 업무 흐름별 시나리오, 기대 결과, 결함 기록 기준 정리
## 1. 테스트 전 확인
### 1.1 실행 대상
현재 ACS 저장소에는 Java/Spring 백엔드와 Node 서버 골격이 함께 존재한다.
| 구분 | 확인 내용 |
|---|---|
| Java 백엔드 | `backend/src/main/java/com/itcenter/acs/controller` 기준 주요 ACS API 존재 |
| Node 서버 | 운영 배포 기준 health/db/static frontend 및 Auth API 연결됨 |
오늘 운영 배포 테스트는 Node 배포 서버 기준으로 진행한다. 단, 방문 신청/승인/출입/리포트 API는 Java 백엔드 기능을 Node API로 이관하는 과정에 있으므로, 각 기능별 API 미구현 여부도 함께 기록한다.
### 1.2 기본 접속 정보
오늘 테스트의 기본 URL은 운영 배포 주소인 `https://acs.apps.bokdev.in`을 기준으로 한다.
| 항목 | 운영 배포 기준 |
|---|---|
| ACS 웹 | `https://acs.apps.bokdev.in` |
| 로그인 | `https://acs.apps.bokdev.in/login` |
| 키오스크 | `https://acs.apps.bokdev.in/kiosk` |
| 공개 출입증 | `https://acs.apps.bokdev.in/pass/:token` |
| Health check | `https://acs.apps.bokdev.in/healthz` |
| DB check | `https://acs.apps.bokdev.in/db` |
로컬 개발 테스트가 필요할 때만 아래 주소를 보조로 사용한다.
| 항목 | 로컬 개발 기준 |
|---|---|
| Java 백엔드 | `http://localhost:8080` |
| Vite 프론트엔드 | `http://localhost:5173` |
| 로컬 로그인 | `http://localhost:5173/login` |
### 1.3 기본 계정
| 계정 | 초기 비밀번호 | 역할 |
|---|---|---|
| `admin` | `ChangeMe123!` | ADMIN |
| `security` | `ChangeMe123!` | SECURITY |
| `host` | `ChangeMe123!` | HOST |
| `a` | `1` | ADMIN |
| `s` | `1` | SECURITY |
| `h` | `1` | HOST |
`a/s/h` 계정은 원활한 테스트용 단축 계정이며, 비밀번호 변경 강제 없이 로그인되도록 seed 기준을 둔다. 운영 DB에 해당 seed가 아직 적재되지 않았다면 로그인은 실패할 수 있다.
### 1.4 공통 판정 기준
| 판정 | 기준 |
|---|---|
| PASS | 화면 표시, API 응답, DB/상태 변화가 기대 결과와 일치 |
| FAIL | 기능 오류, 권한 오류, 화면 오류, 데이터 불일치, 예외 발생 |
| BLOCKED | 선행 환경 또는 데이터 문제로 테스트 불가 |
| N/A | 현재 범위에서 제외 |
## 2. Smoke Test
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| SMK-01 | 애플리케이션 기동 | `https://acs.apps.bokdev.in/healthz`, `/db`, `/login` 접속 | health/db 정상, 로그인 화면 표시 |
| SMK-02 | 로그인 | 우선 `a/1`로 로그인 후 필요 시 `s/1`, `h/1` 확인 | 세션 생성, 역할별 접근 메뉴 표시 |
| SMK-03 | 현재 사용자 확인 | 로그인 후 `/api/auth/me` 호출 또는 새로고침 | 현재 사용자 정보 유지 |
| SMK-04 | 로그아웃 | 로그아웃 실행 후 보호 화면 접근 | 로그인 화면으로 이동 |
| SMK-05 | 새로고침 유지 | 로그인 상태에서 대시보드 새로고침 | 세션 유지 또는 만료 시 로그인 이동 |
## 3. 권한 테스트
| ID | 역할 | 확인 화면/API | 기대 결과 |
|---|---|---|---|
| AUT-01 | 비로그인 | `/dashboard`, `/visit-requests`, `/access` | `/login`으로 이동 |
| AUT-02 | HOST | `/visit-requests`, `/visit-requests/new`, `/access` | 접근 가능 |
| AUT-03 | HOST | `/approvals`, `/blacklist`, `/reports`, `/audit`, `/deliveries` | 권한 없음 표시 또는 접근 차단 |
| AUT-04 | SECURITY | `/access`, `/reports` | 접근 가능 |
| AUT-05 | SECURITY | `/approvals`, `/blacklist`, `/audit`, `/deliveries` | 권한 없음 표시 또는 접근 차단 |
| AUT-06 | ADMIN | 전체 관리 화면 | 접근 가능 |
## 4. 방문 신청 테스트
### 4.1 정상 등록
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| VIS-01 | 단일 방문 신청 | HOST 로그인 → 출입 신청 신규 작성 → 필수값 입력 → 저장 | 신청 목록에 `PENDING` 상태로 표시 |
| VIS-02 | 여러 서버실 선택 | 신규 신청에서 서버실 복수 선택 → 저장 | 선택한 서버실 기준 신청/QR 대상이 의도대로 생성 |
| VIS-03 | 선택 입력값 저장 | 회사, 차량번호, 작업명, 현장감시자2 정보 입력 | 목록/상세/승인 화면에 입력값 유지 |
| VIS-04 | 구역 목록 | 신청 화면 진입 | 출입 구역 목록 정상 로딩 |
### 4.2 입력 검증
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| VIS-05 | 방문자명 누락 | 방문자명 없이 저장 | 저장 차단, 안내 메시지 표시 |
| VIS-06 | 연락처 누락 | 연락처 없이 저장 | 저장 차단, 안내 메시지 표시 |
| VIS-07 | 방문 목적 누락 | 목적 없이 저장 | 저장 차단, 안내 메시지 표시 |
| VIS-08 | 과거 일자 입력 | 과거 출입 일시 입력 | 저장 차단 |
| VIS-09 | 퇴실 시간이 입실보다 빠름 | `visitTo < visitFrom` 입력 | 저장 차단 |
| VIS-10 | 특수문자/한글 입력 | 한글 이름, 회사명, 목적 입력 | 깨짐 없이 저장/표시 |
### 4.3 취소
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| VIS-11 | 신청 취소 | PENDING 또는 APPROVED 신청 취소 | 상태가 `CANCELLED`로 변경되고 출입 불가 |
| VIS-12 | 취소 후 승인 시도 | 취소된 신청을 승인 API 또는 화면에서 처리 시도 | 처리 불가 |
## 5. 승인/반려 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| APR-01 | 승인 대기 목록 | ADMIN 로그인 → 승인 대기 화면 진입 | PENDING 신청만 표시 |
| APR-02 | 승인 | 신청 1건 승인 | 상태 `APPROVED`, `qrToken` 발급, 출입증 접근 가능 |
| APR-03 | 반려 | 신청 1건 반려 및 사유 입력 | 상태 `REJECTED`, 출입 불가 |
| APR-04 | 중복 승인 | 이미 승인된 건 다시 승인 시도 | 중복 처리 차단 |
| APR-05 | 중복 반려 | 이미 반려된 건 다시 반려 시도 | 중복 처리 차단 |
| APR-06 | 승인/반려 감사 로그 | 승인 또는 반려 후 감사 로그 확인 | action과 대상 ID 기록 |
## 6. 출입증/QR 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| PAS-01 | 내부 배지 화면 | 승인된 건의 `/badge/:id` 접속 | 방문자, 회사, 구역, 기간, QR 표시 |
| PAS-02 | 내부 QR 이미지 | `/api/passes/{id}/qr.png` 호출 | PNG 이미지 응답 |
| PAS-03 | 공개 출입증 | 승인 건의 `/pass/:token` 접속 | 로그인 없이 출입증 표시 |
| PAS-04 | 공개 QR 이미지 | `/api/public/passes/{token}/qr.png` 호출 | PNG 이미지 응답 |
| PAS-05 | 잘못된 토큰 | 임의 token으로 공개 출입증 접속 | 오류 안내 또는 접근 차단 |
| PAS-06 | 만료/취소/반려 건 | 각 상태의 token 또는 id로 출입증 접근 | 출입 불가 상태가 명확히 표시 |
## 7. 출입 콘솔 테스트
### 7.1 입장
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| ACC-01 | 이름 검색 | SECURITY 또는 ADMIN 로그인 → 출입 콘솔 → 방문자명 검색 | 승인된 방문자 후보 표시 |
| ACC-02 | 정상 입장 | 승인된 방문자를 입장 처리 | `IN` 이벤트 기록, 게이트 오픈 성공 메시지 |
| ACC-03 | 재실 현황 반영 | 입장 직후 재실 목록 확인 | 해당 방문자가 재실중으로 표시 |
| ACC-04 | 중복 입장 차단 | 이미 입장한 방문자 재입장 시도 | 중복 입장 오류 |
### 7.2 퇴장
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| ACC-05 | 정상 퇴장 | 재실중 방문자 퇴장 처리 | `OUT` 이벤트 기록, 재실 목록에서 제거 |
| ACC-06 | 입장 없는 퇴장 차단 | 입장 기록 없는 방문자 퇴장 시도 | 퇴장 불가 오류 |
| ACC-07 | 당일 재입장 차단 여부 | 입장→퇴장 완료 후 동일 방문자 재입장 시도 | 정책에 따라 차단 또는 허용. 현재 문서 기준은 차단 여부 확인 필요 |
| ACC-08 | 금일 출입기록 | 입장/퇴장 후 오늘 출입기록 확인 | 입장/퇴장 시각과 재실 여부 일치 |
### 7.3 기간/상태 검증
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| ACC-09 | 방문 시작 전 입장 | `visitFrom` 이전 입장 시도 | 입장 차단 |
| ACC-10 | 방문 종료 후 입장 | `visitTo` 이후 입장 시도 | 상태 만료 또는 입장 차단 |
| ACC-11 | PENDING 입장 | 미승인 신청 입장 시도 | 입장 차단 |
| ACC-12 | REJECTED 입장 | 반려 신청 입장 시도 | 입장 차단 |
| ACC-13 | CANCELLED 입장 | 취소 신청 입장 시도 | 입장 차단 |
## 8. 공개 키오스크 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| KIO-01 | 키오스크 접근 | 비로그인 상태로 `/kiosk` 접속 | 키오스크 화면 표시 |
| KIO-02 | QR 스캔 준비 | HTTPS 또는 localhost에서 카메라 권한 허용 | 카메라 스캔 동작 |
| KIO-03 | QR 입장 | 승인된 공개 QR 스캔 후 입장 | 입장 성공, 재실 상태 반영 |
| KIO-04 | QR 퇴장 | 동일 QR로 퇴장 | 퇴장 성공, 재실 상태 해제 |
| KIO-05 | 잘못된 QR | 임의 QR 또는 만료 QR 스캔 | 오류 안내 |
| KIO-06 | 카메라 미지원 | HTTP 일반 IP 또는 권한 거부 상태에서 접속 | 대체 안내 또는 오류가 명확히 표시 |
## 9. 블랙리스트 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| BLK-01 | 등록 | ADMIN 로그인 → 블랙리스트 등록 | 목록에 active 상태로 표시 |
| BLK-02 | 매칭 입장 차단 | 블랙리스트와 이름/연락처가 일치하는 승인 방문자 입장 시도 | 403 또는 차단 메시지 |
| BLK-03 | 삭제 | 블랙리스트 항목 삭제 | 목록에서 제거 또는 inactive 처리 |
| BLK-04 | 삭제 후 입장 | 삭제된 블랙리스트 대상 입장 시도 | 다른 조건이 정상이면 입장 가능 |
| BLK-05 | 감사 로그 | 등록/삭제 후 감사 로그 확인 | `BLACKLIST_ADD`, `BLACKLIST_REMOVE` 기록 |
## 10. 리포트/통계 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| RPT-01 | 대시보드 통계 | 신청/승인/입장 후 대시보드 확인 | 오늘 방문, 승인 대기, 승인, 재실 수치 일치 |
| RPT-02 | 리포트 다운로드 | SECURITY 또는 ADMIN → 리포트 기간 선택 → 다운로드 | `visits_{from}_{to}.xlsx` 다운로드 |
| RPT-03 | 엑셀 내용 | 다운로드 파일 열기 | 방문자, 회사, 구역, 기간, 상태, 출입 기록 확인 가능 |
| RPT-04 | 빈 기간 | 데이터 없는 기간 다운로드 | 빈 파일 또는 안내가 정상 처리 |
| RPT-05 | 권한 검증 | HOST로 리포트 접근 | 접근 차단 |
## 11. 발송함 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| DLV-01 | 승인 후 발송 기록 | 방문 승인 후 ADMIN → 발송함 확인 | 출입증 발송 기록 생성 |
| DLV-02 | 성공/실패 필터 | 상태 필터 변경 | 해당 상태의 발송 기록만 표시 |
| DLV-03 | 재시도 | 실패 발송 건 재시도 | attempts 증가, 상태/오류 갱신 |
| DLV-04 | 발송 실패 영향 | 발송 Provider 오류 유도 후 승인 | 승인은 유지되고 발송 실패만 기록 |
## 12. 엑셀 업로드 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| XLS-01 | 정상 업로드 | 양식에 맞는 엑셀 파일 업로드 | 성공 건수 표시, 신청 생성 |
| XLS-02 | 일부 오류 | 일부 행 필수값 누락 | 성공/실패 건수와 오류 행 표시 |
| XLS-03 | 전체 오류 | 잘못된 양식 업로드 | 생성 없이 오류 표시 |
| XLS-04 | 대량 업로드 | 다수 행 업로드 | 타임아웃 없이 처리, 성공 건수 일치 |
## 13. 보안/세션 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| SEC-01 | CSRF | state-changing API를 CSRF 헤더 없이 호출 | 차단 또는 정책대로 처리 |
| SEC-02 | 세션 만료 | 세션 삭제 후 보호 API 호출 | 401 및 로그인 이동 |
| SEC-03 | 역할 우회 | HOST 세션으로 ADMIN API 직접 호출 | 403 또는 접근 차단 |
| SEC-04 | 공개 API 범위 | 비로그인 상태에서 공개 출입증 API 호출 | 공개 token API만 허용 |
| SEC-05 | 민감정보 노출 | 공개 출입증 화면 확인 | 불필요한 내부 정보 미노출 |
## 14. UI/브라우저 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| UI-01 | 데스크톱 레이아웃 | 1920x1080에서 주요 화면 확인 | 텍스트 겹침 없음 |
| UI-02 | 노트북 레이아웃 | 1366x768에서 주요 화면 확인 | 테이블/버튼 사용 가능 |
| UI-03 | 모바일 공개 출입증 | 모바일 폭에서 `/pass/:token` 확인 | QR과 방문 정보가 잘림 없이 표시 |
| UI-04 | 모바일 키오스크 | 모바일 폭에서 `/kiosk` 확인 | 스캔/입장/퇴장 조작 가능 |
| UI-05 | 한글 표시 | 모든 주요 화면 확인 | 한글 깨짐 없음 |
## 15. 회귀 테스트 묶음
오늘 전체 테스트 시간이 부족하면 아래 순서만 우선 수행한다.
1. `a/1`, `s/1`, `h/1` 로그인
2. HOST 방문 신청
3. ADMIN 승인
4. 공개 출입증 및 QR 확인
5. SECURITY 출입 콘솔 입장
6. 재실 현황 확인
7. 퇴장
8. 리포트 다운로드
9. 블랙리스트 등록 후 입장 차단
10. 감사 로그와 발송함 확인
## 16. 결함 기록 양식
```md
### BUG-YYYYMMDD-번호
- 상태: OPEN / FIXED / RETEST / CLOSED
- 심각도: Critical / Major / Minor / Trivial
- 발견 화면:
- 계정/역할:
- 재현 절차:
- 기대 결과:
- 실제 결과:
- 첨부:
- 관련 API:
- 비고:
```
## 17. 테스트 결과 기록표
| ID | 결과 | 담당 | 일시 | 비고 |
|---|---|---|---|
| SMK-01 | | | | |
| SMK-02 | | | | |
| VIS-01 | | | | |
| APR-02 | | | | |
| PAS-03 | | | | |
| ACC-02 | | | | |
| ACC-05 | | | | |
| KIO-03 | | | | |
| BLK-02 | | | | |
| RPT-02 | | | | |
| DLV-01 | | | | |

View File

@@ -0,0 +1,72 @@
# ACS 출입 신청 화면 UI 수정
> 작성일: 2026-07-14
> 대상 화면: `/visit-requests/new`
> 요청자: 양팀장님
## 반영 내용
1. 화면 콘텐츠 폭을 브라우저 창 기준으로 넓게 사용하도록 조정했다.
2. 출입 신청 화면의 입력 항목과 레이블을 한 줄 배치로 변경했다.
3. 필수 입력 `*` 표시를 빨간색으로 변경했다.
4. 방문자 연락처와 현장감시자2 연락처에 11자리 숫자 입력 시 `000-0000-0000` 형식으로 자동 변환되도록 했다.
5. 입력 영역을 `방문자`, `출입통제담당자`, `현장감시자1과2` 테두리 박스로 구분했다.
6. 범례를 수정했다.
- 출입통제담당자 연락처: `내선번호/휴대폰번호`
- 현장감시자2 연락처: `내선번호/휴대폰번호`
7. 레이블과 개인정보 동의 문구를 수정했다.
- `퇴실 일시 *` -> `퇴실예정일시 *`
- `[개인정보 수집·이용 동의]` -> `[개인정보 수집·이용 동의 확인]`
- 개인정보 동의 거부 안내 문구 수정
## 검증
| 항목 | 결과 |
|---|---|
| `npm run typecheck` | 성공 |
| `npm --prefix frontend run build` | 성공 |
프론트 빌드는 샌드박스 안에서 `spawn EPERM`으로 한 차례 실패했으며, 승인 권한으로 재실행하여 성공을 확인했다.
## 배포 확인
2026-07-14 운영 URL `https://acs.apps.bokdev.in/visit-requests/new`의 HTML을 확인한 결과 아직 이전 asset을 참조하고 있었다.
| 구분 | JS asset | CSS asset |
|---|---|---|
| 운영 현재 | `index-B00mdy5F.js` | `index-teRe_8AF.css` |
| 최신 로컬 빌드 | `index-BteIhXv6.js` | `index-CLolpzdX.css` |
판단: UI 수정 커밋 `7a404cd Update visit request form layout`이 원격 `main`에는 push되었지만, 운영 컨테이너에는 아직 반영되지 않았다. Coolify Redeploy가 필요하다.
## 2차 수정
2026-07-14 추가 요청에 따라 한 화면 표시를 목표로 폼 밀도를 높였다.
| 항목 | 반영 |
|---|---|
| 출입 전산실/추가 구역 | 같은 행에 배치 |
| 출입 목적/작업명 | 같은 행에 배치 |
| 연락처 레이블 | `연락처 *`로 축약 |
| 차량번호 안내 | 레이블에서 제거하고 placeholder로 이동 |
| 출입 전산실 안내 | 레이블에서 제거 |
| 작업명 안내 | 레이블에서 제거하고 placeholder를 `작업 내용을 구체적으로 입력하세요`로 변경 |
| 퇴실예정일시 | `퇴실 예정일시`로 변경 |
| 직원명 | `이름`으로 변경 |
| 출입통제담당자 연락처 | 값/범례를 `내선번호/휴대폰번호`로 변경 |
| 현장감시자1과2 | `현장감시자`로 변경 |
| 현장감시자1 범례 | `IT센터 사무보조원 (자동 지정)`으로 변경 |
2차 수정 검증:
| 항목 | 결과 |
|---|---|
| `npm run typecheck` | 성공 |
| `npm --prefix frontend run build` | 성공 |
2차 최신 로컬 빌드 asset:
| 구분 | asset |
|---|---|
| JS | `index-Cc3Y9nv_.js` |
| CSS | `index-V1wV5EGT.css` |

894
docs/ACS작업일지.md Normal file
View File

@@ -0,0 +1,894 @@
# ACS 작업일지
## 2026-07-10
### 작업 범위 합의
- Codex는 `C:\ai-dev\workspace\acs`의 ACS 개선 및 배포 준비를 담당하기로 함.
- RTGS 프로젝트는 별도 담당자가 작업하므로, 사용자가 명시적으로 요청하지 않는 한 `C:\ai-dev\workspace\rtgs`는 읽기/수정/빌드/배포하지 않기로 함.
- ACS 관련 설치, 구성, 변경, 배포, 검증 내용은 이 파일에 계속 갱신하기로 함.
- 서버/저장소 계정 정보와 비밀번호는 일지에 기록하지 않기로 함.
### 로컬 프로젝트 확인
- ACS 구조 확인:
- 백엔드: Spring Boot 3.4.5, Java 21, Maven, JPA, Flyway, PostgreSQL 운영 구성
- 프론트엔드: React 19, Vite 6, TypeScript
- 배포 구성: `infra/docker-compose.yml`, `infra/docker-compose.tls.yml`, nginx reverse proxy
- Git 원격 저장소는 아직 등록되어 있지 않음을 확인함.
- `.git/index.lock` 파일이 남아 있어, 추후 `git add/commit/push` 전 정리가 필요함.
### 배포 전 빌드/테스트 검증
- 프론트엔드 `npm run build` 성공.
- 백엔드 최초 테스트에서 운영 Flyway 스키마와 엔티티 불일치 발견:
- `visit_requests` 테이블에 `work_name`, `control_*`, `watcher*_ *` 계열 컬럼 누락.
- 신규 Flyway 마이그레이션 추가:
- `backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql`
- 기존 DB에도 적용 가능하도록 `ALTER TABLE ... ADD COLUMN` 방식 사용.
- 테스트용 H2 호환성을 위해 컬럼 추가 구문을 개별 `ALTER TABLE` 문으로 분리함.
- `DataSeeder`의 로컬 계정과 테스트 코드 기대 계정 불일치 수정:
- 기존 단축 계정 `a/s/h` 유지
- 테스트 및 CSV seed와 맞는 `admin/security/host`도 함께 생성하도록 보정
- 백엔드 `mvn -B -ntp test` 성공:
- 9 tests, 0 failures, 0 errors
- 백엔드 `mvn -B -ntp -DskipTests package` 성공:
- `backend/target/acs-0.0.1-SNAPSHOT.jar` 생성 확인
### Docker/Compose 확인
- 로컬 Docker 설치 확인:
- `docker --version` 성공
- `docker compose` 플러그인은 없음
- `docker-compose --version`은 사용 가능
- Compose 설정 문법 확인:
- `docker-compose --env-file infra/.env.example -f infra/docker-compose.yml config` 성공
- `docker-compose --env-file infra/.env.example -f infra/docker-compose.yml -f infra/docker-compose.tls.yml config` 성공
- 운영 배포 URL을 환경변수로 넣은 HTTPS 구성 확인:
- `ACS_PUBLIC_BASE_URL=https://acs.apps.bokdev.in`
- `ACS_COOKIE_SECURE=true`
- Compose config 정상
- Docker 이미지 빌드는 로컬 Docker 빌더 문제로 실패:
- buildx 플러그인 없음
- Docker legacy builder가 Docker API v1.54 build 요청에서 `500 Internal Server Error` 반환
- 코드/Compose 문법 문제보다는 로컬 Docker Desktop/빌더 환경 문제로 판단
### 서버/URL 접근성 확인
- `https://portal.bokdev.in/`:
- HTTPS 접근 가능
- HTTP 200 OK 확인
- `https://acs.apps.bokdev.in`:
- DNS 해석 가능
- 현재 HTTP 503 Service Unavailable 확인
- 아직 앱이 정상 배포/기동되지 않은 상태로 판단
- `https://acs.bokdev.in/0420301/repo`:
- 현재 PC 네트워크에서 `acs.bokdev.in` DNS 해석 실패
- 저장소 원격 등록/푸시 전 네트워크 또는 도메인 확인 필요
### 저장소 URL/DNS 추가 확인
- 저장소 후보 URL `https://acs.bokdev.in/0420301/repo` 확인.
- 로컬 ACS Git remote 확인 결과, 등록된 원격 저장소 없음.
- `git ls-remote https://acs.bokdev.in/0420301/repo` 실행 결과:
- `Could not resolve host: acs.bokdev.in`
- 현재 PC DNS 서버:
- `210.104.132.1`, `210.104.132.2`
- `10.168.198.34`, `10.168.198.35`
- 기본 DNS 및 공개 DNS 확인 결과:
- `portal.bokdev.in``27.96.158.129`로 해석됨.
- `acs.apps.bokdev.in``27.96.158.129`로 해석됨.
- `acs.bokdev.in`은 해석 실패.
- `bokdev.in` 권한 DNS 서버 확인:
- `salvador.ns.porkbun.com`
- `fortaleza.ns.porkbun.com`
- `curitiba.ns.porkbun.com`
- `maceio.ns.porkbun.com`
- 권한 DNS 서버 `curitiba.ns.porkbun.com`에 직접 질의한 결과:
- `portal.bokdev.in` A 레코드 존재: `27.96.158.129`
- `acs.apps.bokdev.in` A 레코드 존재: `27.96.158.129`
- `acs.bokdev.in` A/CNAME 레코드 없음
- `nslookup acs.bokdev.in curitiba.ns.porkbun.com` 결과: `Non-existent domain`
- 결론:
- 현재 저장소 URL의 호스트명 `acs.bokdev.in`은 DNS에 등록되어 있지 않은 것으로 판단.
- 저장소 URL이 잘못 전달되었거나, DNS 레코드 생성이 아직 완료되지 않았을 가능성이 큼.
- 원격 저장소 등록/푸시 전에 정확한 저장소 URL 재확인이 필요.
### AI DEV 매뉴얼 확인
- 참조 파일: `docs/AIdev.md`
- 매뉴얼 기준 주요 서비스:
- 포털: `https://portal.bokdev.in`
- Coder: `https://coder.bokdev.in`
- Gitea: `https://gitea.bokdev.in`
- Kubero: `https://kubero.bokdev.in`
- Coolify: `https://coolify.bokdev.in`
- Gitea 저장소 생성/Push 기준:
- Gitea의 `playground` 조직에 저장소를 생성.
- 저장소 URL 형식은 `https://gitea.bokdev.in/playground/<repo>.git`.
- 예: ACS 저장소명이 `acs`라면 `https://gitea.bokdev.in/playground/acs.git`.
- 배포 기준:
- Gitea 저장소 1개가 배포 앱 1개에 대응.
- Kubero 또는 Coolify 중 하나를 사용.
- Coolify는 Public Repository 방식으로 Gitea 저장소 URL 전체를 입력하고 Dockerfile 빌드를 사용.
- Coolify에서 도메인 생성 시 `https://<repo>.apps.bokdev.in` 형식으로 지정되는 것으로 매뉴얼에 기재되어 있음.
- ACS에 대한 적용 판단:
- 기존 후보 `https://acs.bokdev.in/0420301/repo`는 매뉴얼 기준 저장소 URL 형식이 아님.
- ACS 저장소 URL은 우선 `https://gitea.bokdev.in/playground/acs.git`로 보는 것이 타당.
- 저장소가 아직 없다면 Gitea `playground` 조직에 `acs` repository를 생성해야 함.
- 배포 URL `https://acs.apps.bokdev.in`은 Coolify 도메인 형식과 일치.
### 프로젝트 설정 URL 오류 확인
- 사용자가 포털 프로젝트 수정 화면에서 저장소 URL을 `https://acs.bokdev.in/0420301/repo`로 임의 입력한 사실 확인.
- 해당 값은 Gitea 저장소 URL이 아니므로 수정 필요.
- 후보 저장소 페이지 확인:
- `https://gitea.bokdev.in/playground/acs`: Not found
- `https://gitea.bokdev.in/playground/ACS`: Not found
- `https://gitea.bokdev.in/0420301/acs`: Not found
- `https://gitea.bokdev.in/0420301/ACS`: Not found
- 결론:
- 현재 ACS Gitea 저장소는 아직 생성되지 않았거나, 비공개/다른 이름으로 생성된 상태일 수 있음.
- 매뉴얼 기준으로는 `playground` 조직에 `acs` 저장소를 새로 만들고, 프로젝트 저장소 URL을 `https://gitea.bokdev.in/playground/acs.git`로 설정하는 것이 우선 추천.
- 배포 URL `https://acs.apps.bokdev.in`은 유지 가능.
### Gitea Credential Helper 및 저장소 재확인
- Git 접근 중 Windows `CredentialHelperSelector` 팝업 발생.
- 권장값인 `manager` 선택 완료.
- 이후 `git ls-remote https://gitea.bokdev.in/playground/acs.git` 재시도 시 인증 대기 상태로 타임아웃됨.
- 비대화식 확인:
- `GIT_TERMINAL_PROMPT=0`
- `git -c credential.helper= ls-remote https://gitea.bokdev.in/playground/acs.git`
- 결과: `could not read Username for 'https://gitea.bokdev.in': terminal prompts disabled`
- Gitea API 비교 확인:
- `https://gitea.bokdev.in/api/v1/repos/playground/MANUAL`: 200 OK
- `https://gitea.bokdev.in/api/v1/repos/playground/acs`: `The target couldn't be found`
- 판단:
- Gitea 서버와 API는 정상 접근 가능.
- `playground/acs` 저장소는 비로그인/API 기준으로 존재하지 않거나 private/권한 미승인 상태.
- 포털 프로젝트의 저장소 URL 수정만으로 Gitea 저장소가 자동 생성되는 것은 아니므로, Gitea에서 `playground/acs` repository 생성 여부를 별도로 확인해야 함.
### Gitea ACS 저장소 생성 및 원격 등록
- 사용자가 Gitea에서 ACS 저장소 생성 완료.
- 생성된 저장소 확인:
- 웹 URL: `https://gitea.bokdev.in/0420301/acs`
- Git URL: `https://gitea.bokdev.in/0420301/acs.git`
- `playground/acs`가 아니라 개인 네임스페이스 `0420301/acs`로 생성됨.
- 빈 저장소 상태 확인:
- `git ls-remote https://gitea.bokdev.in/0420301/acs.git` 결과가 비어 있으나 exit code 0으로 정상.
- 이전 타임아웃된 Gitea 확인용 Git 프로세스와 stale lock 파일 정리:
- `.git/config.lock`
- `.git/index.lock`
- 로컬 ACS Git remote 등록 완료:
- `origin https://gitea.bokdev.in/0420301/acs.git`
- 남은 사항:
- push 전 커밋 대상 정리 필요.
- 로그 파일(`backend/backend-run.log`, `frontend/frontend-dev.log`)과 백업 파일(`docs/ACS작업일지.md.bak`)은 커밋 제외 권장.
- 작업트리 변경분 검토 후 최초 commit/push 진행 필요.
### 최초 커밋 및 Gitea Push
- 커밋 대상 정리:
- `.gitignore``*.log`, `*.bak` 제외 규칙 추가.
- `backend/backend-run.log`, `frontend/frontend-dev.log`, `docs/ACS작업일지.md.bak`는 커밋 제외.
- 최초 Gitea push용 커밋 생성:
- 커밋: `01d48fe`
- 메시지: `feat: prepare ACS deployment`
- 포함: ACS 코드 변경, Flyway V4, 문서, 작업일지, AI DEV 매뉴얼 참조 파일.
- 원격 push 완료:
- remote: `origin`
- URL: `https://gitea.bokdev.in/0420301/acs.git`
- branch: `main`
- `origin/main` 추적 설정 완료.
- 원격 검증:
- `git ls-remote origin main` 결과 `01d48fe... refs/heads/main` 확인.
- `https://gitea.bokdev.in/0420301/acs` 웹 페이지 접근 200 OK 확인.
### Gitea 저장소 위치 수정
- 서버담당자/매뉴얼 기준 배포용 저장소는 개인 네임스페이스가 아니라 `playground` 조직 아래여야 함을 확인.
- 기존 개인 저장소:
- `https://gitea.bokdev.in/0420301/acs.git`
- 새 배포용 저장소:
- `https://gitea.bokdev.in/playground/acs.git`
- 사용자가 Gitea `playground` 조직 아래 `acs` 저장소 생성 완료.
- 로컬 ACS Git remote 변경:
- `origin``https://gitea.bokdev.in/playground/acs.git`로 변경.
- `main` 브랜치 push 완료:
- `git push -u origin main`
- 원격 `origin/main` 확인: `4fe4c37... refs/heads/main`
- 포털 프로젝트의 저장소 URL도 `https://gitea.bokdev.in/playground/acs.git`로 맞춰야 함.
### 테스트용 화면 URL 확인
- 프론트 라우트 정의 파일: `frontend/src/App.tsx`
- 관리자/담당자 로그인 화면:
- 운영 배포 기준: `https://acs.apps.bokdev.in/login`
- 로컬 개발 기준: `http://localhost:5173/login`
- 로그인 후 주요 내부 화면:
- 대시보드: `/dashboard`
- 방문 신청 목록: `/visit-requests`
- 방문 신청 등록: `/visit-requests/new`
- 승인 대기: `/approvals`
- 출입 콘솔: `/access`
- 관리자 감사 로그: `/audit`
- 발송 내역: `/deliveries`
- 방문자/출입 QR 관련 공개 화면:
- 방문자 휴대폰 출입증 화면: `/pass/{qrToken}`
- 출입구 키오스크 QR 태깅/스캔 화면: `/kiosk`
- 운영 배포 기준 키오스크 URL: `https://acs.apps.bokdev.in/kiosk`
- QR/출입증 API:
- 공개 출입증 조회: `/api/public/passes/{qrToken}`
- 공개 QR 이미지: `/api/public/passes/{qrToken}/qr.png`
- 키오스크 체크인: `/api/public/passes/{qrToken}/check-in`
- 키오스크 체크아웃: `/api/public/passes/{qrToken}/check-out`
- QR 토큰은 방문 신청 승인 후 `qrToken`으로 발급됨.
- 문자/이메일 발송 링크는 `ACS_PUBLIC_BASE_URL + "/pass/" + qrToken` 형식으로 생성됨.
### 배포 URL 접속 상태 확인
- 운영 배포 후보 URL 확인:
- `https://acs.apps.bokdev.in`
- `https://acs.apps.bokdev.in/login`
- 확인 결과:
- 두 URL 모두 `no available server` 응답.
- 판단:
- Gitea push는 완료되었으나, Coolify 등 배포 플랫폼에서 해당 도메인에 연결된 앱 서버가 아직 생성/기동되지 않았거나 배포가 실패한 상태로 판단.
- 포털의 프로젝트 등록/저장소 URL 설정과 실제 앱 배포는 별도 단계임.
- 다음 확인 필요:
- Coolify에 ACS resource/app 생성 여부.
- Repository URL이 `https://gitea.bokdev.in/0420301/acs.git`로 설정되었는지.
- Branch가 `main`인지.
- 빌드 방식이 Docker Compose 또는 Dockerfile 중 무엇인지.
- 배포 도메인이 `https://acs.apps.bokdev.in`로 연결되었는지.
- Deployments 로그에서 빌드/기동 실패 원인 확인.
### Coolify Docker Compose 리소스 설정 진행
- 사용자가 Coolify `aidev` 프로젝트에서 `+ Add Resource`를 통해 Docker Compose Empty 리소스 생성 화면 진입.
- Docker Compose file 입력 화면에 최초로 `https://acs.apps.bokdev.in`만 입력했으나, 해당 칸은 도메인 입력칸이 아니라 compose YAML 전체를 입력하는 영역임을 안내.
- Coolify용 compose는 repository root 기준으로 `./backend`, `./frontend` build context를 사용하는 형태가 필요하다고 안내.
- 도메인 `https://acs.apps.bokdev.in`은 compose file 영역이 아니라 resource의 Domains 설정에서 별도 입력해야 함.
- 환경변수는 리소스 저장 후 resource 상세 화면의 `Environment Variables`, `Variables`, 또는 `Developer view`에서 입력해야 함.
- Coolify 배포에 필요한 최소 환경변수:
- `POSTGRES_PASSWORD`
- `POSTGRES_USER=acs`
- `POSTGRES_DB=acs`
- `ACS_PUBLIC_BASE_URL=https://acs.apps.bokdev.in`
- `ACS_COOKIE_SECURE=true`
- `ACS_SMS_PROVIDER=dev`
- Docker Compose 입력 화면에서 저장 시 권한 없음 메시지 발생.
- `Teams > aidev > General` 화면 확인:
- 팀 설정 입력칸이 비활성화된 상태로 보임.
- 현재 계정은 `aidev` 팀/프로젝트 조회는 가능하지만 resource 생성/수정 권한이 부족할 가능성이 큼.
- 다음 확인 필요:
- `Teams > aidev > Members`에서 사용자 `0420301`의 역할 확인.
- `aidev` 팀 또는 프로젝트에서 resource create/update/deploy 권한 부여 요청.
- 권한이 없는 경우 권한 있는 관리자가 ACS resource를 생성하거나, 사용자에게 프로젝트 관리자 권한을 부여해야 함.
### 인프라 담당자 피드백 반영 필요
- 인프라 담당자 피드백 요지:
- AI DEV 표준 환경에는 `.project-env`가 미리 정의되어 있음.
- DB를 로컬/compose 내부에 직접 만들고 전체 기술구조를 통째로 배포하는 방식은 표준 흐름과 맞지 않을 수 있음.
- 개발은 PC 로컬보다 포털의 Coder 환경에서 진행하는 것을 권장.
- 배포 앱은 Node.js 기반으로 맞추는 것이 좋다는 의견.
- `CLAUDE.md` 등 프로젝트 작업 규칙 파일이 표준 템플릿에 이미 준비되어 있음.
- 현재 ACS와의 차이:
- 현재 ACS는 Spring Boot 백엔드 + React 프론트 + PostgreSQL + nginx의 다중 컨테이너 구조.
- 기존 compose는 자체 PostgreSQL 컨테이너를 포함함.
- AI DEV 표준은 Coder에서 제공하는 `.project-env``DATABASE_URL` 등 환경값을 사용하고, 앱 소스 중심으로 배포하는 방식으로 보임.
- 대응 방향:
- Coolify Docker Compose 직접 배포 시도를 잠시 중단.
- Coder 환경에서 `playground/acs`를 clone한 뒤, 실제 제공되는 `.project-env`와 sample/템플릿 구조를 먼저 확인.
- 인프라 담당자에게 Java/Spring Boot Dockerfile 배포 허용 여부를 확인.
- Node.js가 필수라면 ACS 백엔드 구조를 Node 기반으로 전환하거나, 최소 배포 가능 범위를 재설계해야 함.
- 자체 DB 컨테이너 대신 플랫폼 제공 PostgreSQL 접속정보(`DATABASE_URL`)를 쓰는 구조로 변경 검토.
- ACS용 `CLAUDE.md` 또는 동등한 작업 규칙 문서를 추가해 Claude/Codex 협업 기준을 명시하는 방안 검토.
### AI DEV 배포 제약 확정
- 인프라 담당자 확인 결과:
- 배포 앱은 Node.js 앱만 허용.
- DB는 반드시 Coder/AI DEV 환경의 `.project-env`에 제공되는 `DATABASE_URL`을 사용해야 함.
- 영향:
- 현재 Spring Boot 백엔드(`backend/`)는 AI DEV 표준 배포 대상이 아님.
- 현재 `infra/docker-compose.yml`의 자체 PostgreSQL 컨테이너 방식은 표준 배포 방식과 맞지 않음.
- ACS를 배포하려면 Node.js 기반 서버/API로 전환하거나, Node.js 배포 앱이 기존 기능을 대체하도록 재구성해야 함.
- 권장 전환 방향:
- React 프론트엔드는 유지 가능.
- 백엔드는 Node.js(Express/Fastify 등)로 재작성 검토.
- DB 접속은 `process.env.DATABASE_URL` 사용.
- 기존 Flyway SQL은 Node 앱 시작/배포 시 적용 가능한 SQL migration 방식으로 재활용 검토.
- 배포 산출물은 단일 Node.js 앱 또는 Node.js + 정적 프론트 서빙 구조로 단순화하는 방향을 우선 검토.
### 인증서 확인
- `infra/certs/fullchain.pem`, `infra/certs/privkey.pem` 존재 확인.
- 현재 인증서는 `CN=localhost`, SAN도 `localhost`, `127.0.0.1`용임.
- 운영 도메인 `acs.apps.bokdev.in`용 인증서가 아니므로 실제 HTTPS 운영 배포에는 부적합.
- 운영 배포 전 포털/플랫폼 인증서 자동 제공 여부 또는 도메인 인증서 교체 필요.
### 배포 정책 추천
- 권장 흐름:
- 로컬 개발
- Git commit/push
- 서버 개발환경 배포
- 서버 개발환경 검증
- 운영 배포 승인
- 서버 운영환경 배포
- 서버에서 직접 코드를 수정하며 개발하는 방식은 비추천.
- 서버 개발환경도 Git으로 받은 검증 대상 환경으로 운영하고, 운영환경에는 검증된 커밋/태그만 배포하는 정책을 권장.
- 권장 브랜치/환경:
- `dev` 또는 `develop`: 서버 개발환경 배포
- `main`: 운영 배포 가능 브랜치
- 운영 배포 시 태그 사용 예: `acs-v0.1.0`
- DB 변경은 Flyway migration으로만 반영하는 정책 유지.
### 남은 작업
- `.git/index.lock` 정리 후 Git 작업 가능 상태 확인.
- ACS 원격 저장소 URL/DNS 문제 확인.
- 원격 저장소 등록 및 최초 push 여부 결정.
- 서버 개발환경과 운영환경을 포털에서 분리 구성할 수 있는지 확인.
- 운영 도메인 인증서 처리 방식 확인.
- 로컬 Docker buildx 또는 Docker Desktop 빌더 문제 해결.
- 실제 배포 전 운영 `.env` 값 확정:
- `POSTGRES_PASSWORD`
- `ACS_PUBLIC_BASE_URL`
- `ACS_COOKIE_SECURE`
- `ACS_SMS_PROVIDER`
- SMTP 또는 사내 메시지 API 설정
## 2026-07-13
### Node.js 전환 착수
- 사용자가 `C:\ai-dev\workspace\acs - 복사본`에 기존 소스 백업이 있으므로, 원본 `C:\ai-dev\workspace\acs`를 AI DEV 표준 배포 구조로 전환하기로 결정.
- 목표:
- Spring Boot 백엔드 배포 방식에서 Node.js 앱 배포 방식으로 전환.
- DB는 자체 PostgreSQL 컨테이너가 아니라 AI DEV `.project-env``DATABASE_URL` 사용.
- 기존 React 프론트와 `/api` 계약은 최대한 유지.
- 추가 문서:
- `docs/node-migration-plan.md`
- 추가된 Node 골격:
- 루트 `package.json`, `tsconfig.json`
- `server/index.ts`
- `server/config/env.ts`
- `server/db/pool.ts`
- `server/db/migrate.ts`
- `server/http/apiResponse.ts`
- `server/http/errors.ts`
- `server/routes/health.ts`
- `server/routes/index.ts`
- Flyway SQL을 Node migration 구조로 1차 이관:
- `migrations/001_init.sql`
- `migrations/002_audit_log.sql`
- `migrations/003_pass_delivery.sql`
- `migrations/004_visit_request_contact_fields.sql`
- 검증:
- `npm install` 완료.
- `npm run typecheck` 성공.
- `npm audit --omit=dev` 결과 운영 의존성 취약점 0건.
- `npm run build` 성공. 단, 로컬 PC 권한 정책상 프론트 `esbuild`는 승인 권한으로 실행 필요.
- `node dist/server/index.js` 기동 후 `GET /api/health` 응답 확인.
- 정적 React build(`/`) 응답 200 확인.
- 다음 작업:
- Auth/session/role middleware 구현.
- PostgreSQL session store 연결.
- 사용자 seed 전략 확정.
- `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/change-password`부터 Spring 기능 parity 구현.
### AIdev.md 배포 가이드 준수 보완
- `docs/AIdev.md` 기준 ACS 배포 필수 항목을 재점검.
- 보완 사항:
- 루트 `Dockerfile` 추가. AI DEV Coolify/Kubero Dockerfile build pack 기준으로 빌드.
- 루트 `.dockerignore` 추가.
- Node engine 기준을 `>=22`로 조정.
- `/healthz` 추가: `{"ok": true}` 응답.
- `/db` 추가: `DATABASE_URL`로 PostgreSQL `SELECT now()` 점검.
- `/s3` 추가: ACS는 S3/MinIO 미사용이므로 skip 응답.
- `npm run db:check` 추가: `.project-env`/`.env` 로드 후 DB 점검.
- `npm run minio:check` 추가: ACS S3 미사용 skip 출력.
- `npm run start:deploy` 추가: migration 적용 후 Node 서버 기동.
- `CLAUDE.md` 추가: AI DEV 배포 제약과 ACS 전환 규칙 명시.
- 검증:
- `npm run typecheck` 성공.
- `npm audit --omit=dev` 운영 의존성 취약점 0건.
- `npm run build` 성공. 이 PC에서는 esbuild 실행 정책 때문에 승인 권한 필요.
- `GET /healthz` 정상.
- `GET /api/health` 정상.
- `GET /s3` skip 응답 정상.
- `GET /db`는 현재 Windows 로컬에 `DATABASE_URL`이 없어 500과 명확한 오류 메시지를 반환. AI DEV/Coder에서 `.project-env` 로드 후 재검증 필요.
- `docker build -t acs-node-guide-check .`는 Docker daemon 미기동(`dockerDesktopLinuxEngine` pipe 없음)으로 실행 전 실패. Docker Desktop 또는 AI DEV/Coder 배포 환경에서 재검증 필요.
### Coder / Gitea / Coolify 배포 검증
- Coder 접속:
- `https://coder.bokdev.in/workspaces`에서 `ws-aidev-0420301` 워크스페이스 확인.
- 최초 VS Code Web 접속 시 `Agent state is "disconnected"` 404가 발생했으나, 잠시 대기 후 VS Code Web 아이콘이 활성화되어 접속 성공.
- VS Code Web 작업 위치: `/home/coder/projects`.
- Gitea 장애:
- 초기에는 Gitea 루트는 200이었으나 저장소 URL은 500 오류:
- `https://gitea.bokdev.in/playground/MANUAL`
- `https://gitea.bokdev.in/playground/acs`
- `https://gitea.bokdev.in/0420301/acs`
- `git ls-remote https://gitea.bokdev.in/playground/acs.git`
- 인프라 확인 후 Gitea가 정상화되어 clone 가능해짐.
- AI DEV 표준 프로젝트 생성:
- 단순 `git clone https://gitea.bokdev.in/playground/acs.git`만 수행하면 `.project-env`가 생성되지 않아 `DATABASE_URL`이 없음.
- `docs/AIdev.md`의 6번 절차에 따라 아래 순서로 표준 프로젝트 재생성:
- `mv acs acs-from-git`
- `new-project acs`
- `cd ~/projects/acs`
- `.project-env` 생성 확인.
- 이후 원격 소스 반영:
- `git init -b main`
- `git remote add origin https://gitea.bokdev.in/playground/acs.git`
- `git fetch origin`
- `git reset --hard origin/main`
- `.project-env`가 유지되었고 `DATABASE_URL` 환경변수 로드 확인.
- Coder 로컬 검증:
- `npm install` 성공.
- `npm --prefix frontend install` 성공.
- `npm run typecheck` 성공.
- `npm run db:check` 성공:
- `DB OK: Mon Jul 13 2026 10:53:51 GMT+0900 (한국 표준시)`
- `npm run minio:check`는 ACS S3 미사용으로 정상 skip:
- `S3 SKIPPED: ACS does not currently use S3/MinIO storage.`
- `npm run build` 성공.
- `npm run migrate` 성공:
- `Applied migration 001_init.sql`
- `Applied migration 002_audit_log.sql`
- `Applied migration 003_pass_delivery.sql`
- `Applied migration 004_visit_request_contact_fields.sql`
- `npm start` 성공:
- `ACS Node app listening on port 3000`
- Coder 로컬 endpoint 확인:
- `curl 127.0.0.1:3000/healthz` -> `{"ok":true}`
- `curl 127.0.0.1:3000/db` -> `{"ok":true,"now":"..."}`
- `curl 127.0.0.1:3000/s3` -> ACS S3 미사용 skip 응답.
- Coder 내 Docker/Podman build 성공:
- `docker build -t acs-node-local .`
- `Successfully tagged localhost/acs-node-local:latest`
- Coolify 리소스 생성:
- Coolify 팀/프로젝트: `aidev / production`.
- Resource type: `Public Repository`.
- Repository URL: `https://gitea.bokdev.in/playground/acs.git`.
- Branch: `main`.
- Build Pack: `Dockerfile`.
- Base Directory: `/`.
- Port: `3000`.
- Static site: 비활성.
- 최초 자동 생성 resource 이름:
- `obedient-ocelot-w6wzpkzzv8qua4uzejznirp`
- 최초 자동 생성 domain:
- `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in`
- Coolify 환경변수 등록:
- `.project-env`에서 제공된 값 중 아래 DB 관련 변수를 등록:
- `DATABASE_URL`
- `PGHOST`
- `PGPORT`
- `PGDATABASE`
- `PGUSER`
- `PGPASSWORD`
- `PGOPTIONS`
- `PORT`
- 추가 ACS 변수:
- `SESSION_SECRET`
- `ACS_PUBLIC_BASE_URL=https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in`
- `ACS_SMS_PROVIDER=dev`
- S3 관련 변수는 ACS 현재 범위에서는 사용하지 않으므로 필수 등록 대상에서 제외.
- Coolify healthcheck 설정:
- Type: `HTTP`
- Method: `GET`
- Scheme: `http`
- Host: `localhost`
- Port: `3000`
- Path: `/healthz`
- Return Code: `200`
- Healthcheck enabled.
- 1차 Coolify 배포 결과:
- Docker image build와 container start는 성공.
- 컨테이너 로그에 `ACS Node app listening on port 3000` 확인.
- 그러나 Coolify healthcheck 실패:
- `/bin/sh: 1: curl: not found`
- `/bin/sh: 1: wget: not found`
- 원인:
- `node:22-bookworm-slim` runtime image에 Coolify가 healthcheck에 사용하는 `curl`/`wget`이 없음.
- healthcheck 실패 조치:
- `Dockerfile` runtime stage에 `curl``ca-certificates` 설치 추가:
- `apt-get update`
- `apt-get install -y --no-install-recommends curl ca-certificates`
- `rm -rf /var/lib/apt/lists/*`
- Coder에서 커밋:
- `Install curl for Coolify healthcheck`
- Coder에서 최초 `git push`는 upstream 미설정으로 실패.
- `git push --set-upstream origin main`은 인증 실패.
- Gitea `Settings > Applications`에서 repository read/write 권한 토큰 생성 후 토큰 URL 방식으로 push 성공.
- 2차 Coolify 배포 결과:
- commit `44a4bc1086ab9d57dd9815e519c2d2c3c2d0d9c4` 기준 배포.
- Docker image build 성공.
- New container started.
- Healthcheck URL:
- `GET http://localhost:3000/healthz`
- Healthcheck passed:
- `Healthcheck status: "healthy"`
- `Return code: 0`
- Rolling update completed.
- Coolify 상태:
- `Running (healthy)`
- 외부 배포 URL 검증:
- `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in/healthz`
- `{"ok":true}`
- `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in/db`
- `{"ok":true,"now":"2026-07-13T05:12:54.842Z"}` 등 정상 응답.
- `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in/login`
- React 로그인 화면 표시 정상.
- 현재 배포 상태:
- AI DEV 표준 Node.js + Dockerfile + `.project-env`/`DATABASE_URL` 기반 배포 파이프라인 성공.
- 현재 Node 서버는 health/db/static frontend 기반까지만 구현됨.
- 기존 ACS 업무 기능은 아직 Spring Boot 백엔드에서 Node API로 이관 전.
- 로그인 화면은 표시되지만 `/api/auth/login` 등 Node Auth API 구현 전이므로 실제 로그인은 다음 단계에서 구현 필요.
- 도메인 정리 및 고정 도메인 전환:
- 최초 Coolify 자동 생성 임시 도메인:
- `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in`
- 인프라팀에 고정 도메인 사용 가능 여부 확인 요청:
- 희망: `https://acs.apps.bokdev.in`
- 또는 AI DEV 표준: `https://acs.playground.bokdev.in`
- 팀장 확인 결과, Coolify에서 사용자가 직접 고정 도메인으로 변경하면 되는 것으로 판단.
- Coolify `Configuration > General`에서 `Domains` 값을 아래로 변경:
- `https://acs.apps.bokdev.in`
- `Set Direction`은 기존 direction 확인/적용용이며, domain 자체 저장은 `General` 제목 옆 `Save` 버튼으로 처리.
- 도메인 저장 직후 `/healthz`에서 일시적으로 `no available server`가 표시되었으나, 재배포/라우팅 반영 후 정상화.
- Coolify `Configuration > Environment Variables`에서 `ACS_PUBLIC_BASE_URL` 값 변경:
- 변경 전: `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in`
- 변경 후: `https://acs.apps.bokdev.in`
- 변경 후 재배포 및 최종 확인 완료:
- `https://acs.apps.bokdev.in/healthz` -> `{"ok":true}`
- `https://acs.apps.bokdev.in/db` -> `{"ok":true,"now":"..."}`
- `https://acs.apps.bokdev.in/login` -> React 로그인 화면 표시 정상.
- AI DEV 포털 프로젝트의 배포 URL도 `https://acs.apps.bokdev.in`로 맞추는 방향으로 정리.
- 다음 작업:
- Node Auth/session/role middleware 구현.
- PostgreSQL session store 구성.
- 사용자 seed 또는 기존 사용자 적재 방식 확정.
- `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/change-password` 구현.
- 이후 방문신청/승인/출입/리포트 API를 순차 이관.
## 2026-07-14
### ACS 테스트 준비 및 문서 산출물
- ACS 수동 테스트 시나리오 문서 작성:
- `docs/ACS-test-scenarios.md`
- 운영 기준 URL을 `https://acs.apps.bokdev.in`로 정리.
- Smoke, 권한, 방문신청, 승인/반려, 출입증/QR, 출입콘솔, 키오스크, 블랙리스트, 리포트, 발송함, 엑셀 업로드, 보안/세션, UI 테스트 항목 작성.
- 로그인 404 분석 문서 작성:
- `docs/ACS-login-404-analysis.md`
- 증상, 원인, 조치, 재테스트 기록, 운영 반영 확인 기준 정리.
- 출입신청 화면 UI 수정 문서 작성:
- `docs/ACS-visit-form-ui-update.md`
- 1차/2차 UI 변경 내용, 빌드 검증, 최신 asset 기준 기록.
### 로그인 404/500 조치
- 운영 `https://acs.apps.bokdev.in/login`에서 로그인 시도 시 `POST /api/auth/login` 404 발생.
- 원인:
- Node 서버의 `/api` 라우터에 health 라우터만 연결되어 있었고 Auth API가 미구현 상태.
- Node Auth API 추가:
- `server/routes/auth.ts`
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `GET /api/auth/me`
- `POST /api/auth/change-password`
- 세션 처리 추가:
- `express-session`
- `connect-pg-simple`
- PostgreSQL 기반 `user_sessions` 세션 스토어 사용.
- 사용자 seed 보장:
- `scripts/seeds/users.csv`에 테스트 단축 계정 추가.
- `migrations/005_seed_test_users.sql` 추가.
- 테스트 계정:
- `a / 1` = ADMIN
- `s / 1` = SECURITY
- `h / 1` = HOST
- 운영 재배포 후 404는 해소되었으나 500 발생.
- `/api/auth/diagnostics` 추가 후 확인 결과, 운영 DB에 `users`, `user_roles` 테이블이 없는 상태 확인.
- Coolify 실행 명령이 Dockerfile `CMD ["npm", "run", "start:deploy"]`를 타지 않을 수 있다고 판단.
- 서버 시작 시 migration을 직접 보장하도록 `server/index.ts``runMigrations()` 추가.
- Coolify Redeploy 후 로그인 성공 확인:
- `a / 1` 로그인 성공.
### 운영 배포 및 커밋
- 원격 `origin/main`에 반영한 주요 커밋:
- `7c8bd37 Add Node auth routes for ACS login`
- `9562766 Seed ACS test login accounts`
- `e02afcd Add auth deployment diagnostics`
- `0ae4b73 Run migrations on server startup`
- `7a404cd Update visit request form layout`
- `2653ed6 Compact visit request form`
- Coolify Redeploy 후 `/api/health`에서 최신 build marker 확인:
- `build: auth-seed-20260714`
- `authApi: true`
### 출입신청 화면 UI 1차 개선
- 요청사항 반영:
- 화면 콘텐츠 폭을 브라우저 창 기준으로 넓게 사용하도록 조정.
- 입력 항목과 레이블을 한 줄 배치로 변경.
- 필수 입력 `*` 표시를 red로 변경.
- 방문자 연락처, 현장감시자2 연락처 11자리 숫자 입력 시 `000-0000-0000` 자동 변환.
- `방문자`, `출입통제담당자`, `현장감시자1과2` 테두리 그룹 박스 적용.
- 범례/레이블/개인정보 수집·이용 동의 문구 수정.
- 검증:
- `npm run typecheck` 성공.
- `npm --prefix frontend run build` 성공.
### 출입신청 화면 UI 2차 압축 개선
- 목표:
- 출입신청 화면의 모든 내용을 수직 스크롤 없이 한 화면에 최대한 보이도록 조정.
- 반영 내용:
- 전체 콘텐츠 padding 축소.
- 폼 그룹/필드/동의 영역 여백 축소.
- 그룹 내부를 3열 기반으로 조정.
- `출입 전산실``추가 구역`을 같은 라인에 배치.
- `출입 목적``작업명`을 같은 라인에 배치.
- 불필요한 레이블 설명을 제거하고 필요한 안내는 placeholder로 이동.
- `퇴실예정일시``퇴실 예정일시`로 수정.
- `직원명``이름`으로 수정.
- `현장감시자1과2``현장감시자`로 수정.
- `현장감시자1 : IT센터 사무보조원 (자동 지정)` 문구 반영.
- 출입통제담당자 연락처 표시를 `내선번호/휴대폰번호` placeholder 기준으로 정리.
- 최신 프론트 빌드 asset:
- JS: `/assets/index-Cc3Y9nv_.js`
- CSS: `/assets/index-V1wV5EGT.css`
- 검증:
- `npm run typecheck` 성공.
- `npm --prefix frontend run build` 성공.
### 현재 상태
- 로그인:
- `a / 1` 로그인 성공.
- 운영 URL:
- `https://acs.apps.bokdev.in`
- 출입신청 UI:
- 최신 UI 커밋은 원격 `main` 반영 완료.
- 운영 반영은 Coolify Redeploy 후 HTML asset 해시 확인 필요.
- 로컬 작업트리:
- 이전 작업 중 생성/수정된 미정리 파일이 일부 남아 있음.
- 배포용 커밋은 임시 worktree를 사용해 원격 최신 `main` 기준으로 선별 push 완료.
### 다음 작업
- Coolify Redeploy 후 운영 HTML이 최신 asset을 참조하는지 확인:
- 기대 JS: `/assets/index-Cc3Y9nv_.js`
- 기대 CSS: `/assets/index-V1wV5EGT.css`
- `/visit-requests/new` 화면 실브라우저 확인:
- 한 화면 표시 여부.
- 레이블/placeholder/그룹 박스/연락처 자동 포맷 확인.
- 방문 신청 저장 API가 아직 Node로 이관되지 않은 경우, 다음 테스트 단계에서 `/api/visit-requests` 구현 필요.
## 2026-07-16
### 출입신청 날짜 검증 및 QR 유효기간 정책
- 출입신청 화면에서 퇴장일이 출입일 다음날 이후인 경우 2건 분리 신청 안내 후 제출 차단.
- 출입일시가 현재 시스템 일시 이전이어도 입력 가능하도록 과거일자 차단 제거.
- 출입일시가 퇴장일시보다 늦은 경우 안내 메시지 후 제출 차단.
- 백엔드 신청 생성 API에도 동일한 역전/익일 퇴장 검증 추가.
- 방문자 QR은 `visitFrom`의 일자 동안만 유효하도록 입장 판정 기준을 `visitTo`가 아닌 `visitFrom.toLocalDate()`로 변경.
- 지난 출입일 QR을 스캔하면 입장을 차단하고 해당 신청을 `EXPIRED`로 전이하도록 조정.
- 매일 00:10 만료 배치도 `visitFrom` 기준으로 지난 승인 건을 만료 처리하도록 변경.
- 검증:
- `npm.cmd run build` 성공.
- `mvn.cmd test` 성공.
### 개인정보 동의 문구 조정
- `[개인정보 수집·이용 동의 확인]``[방문자에 대한 개인정보 수집·이용 동의 확인]`으로 변경.
- 보유·이용 기간 문구를 `전산실 퇴장 등록시 입력된 방문자 이름, 연락처, 이메일, 차량번호는 바로 삭제`로 변경.
- 해당 보유·이용 기간 한 줄만 red 색상으로 표시.
- 검증:
- `npm.cmd run build` 성공.
### 시스템 관리 및 출입목적 코드화
- 관리자 전용 `시스템관리` 메뉴 추가.
- 관리 탭:
- 현장감시자1 정보 수정.
- 출입목적 코드/표시명/사용 여부/기타 입력 허용 관리.
- 사용자별 ADMIN/SECURITY/HOST 권한 관리.
- 출입신청 화면의 출입목적 목록을 고정 상수에서 서버 코드 목록 기반으로 변경.
- 방문신청 저장 시 `purposeCode`, `purposeDetail`을 함께 저장하도록 확장.
- 기타 목적 입력값이 기존 출입목적 코드/표시명과 일치하면 해당 코드로 자동 정규화.
- Flyway `V5__admin_config.sql` 추가:
- `purpose_codes`
- `system_settings`
- `visit_requests.purpose_code`
- `visit_requests.purpose_detail`
- 검증:
- `mvn.cmd test` 성공.
- `npm.cmd run build` 성공.
- 로컬 새 API 확인: `/api/purpose-codes`, `/api/admin/purpose-codes`, `/api/settings/watcher1`, `/api/admin/users` 모두 200.
### 팀 기반 권한관리 개선
- 개발1팀 등 조직 변경이 잦은 운영을 고려해 사용자별 권한 직접 수정만 두지 않고, 팀 기준 권한 템플릿을 추가.
- `시스템관리 > 팀 관리` 탭 추가:
- 팀 코드, 팀명, 사용 여부, 기본권한 관리.
- 기본권한은 ADMIN/SECURITY/HOST 다중 선택 가능.
- 팀별 기본권한을 해당 팀 소속 전체 사용자에게 일괄 적용 가능.
- `시스템관리 > 권한관리` 탭 개선:
- 사용자 검색, 팀 필터 추가.
- 사용자별 소속 팀 변경 가능.
- 팀 변경 시 팀 기본권한을 즉시 적용할지 선택 가능.
- 특정 사용자에게 팀 기본권한만 별도 재적용 가능.
- 기존 `users.department`는 호환성을 위해 유지하되, 신규 `teams` 마스터와 `users.team_id`를 기준으로 운영하도록 확장.
- Flyway `V6__teams.sql` 추가:
- `teams`
- `team_default_roles`
- `users.team_id`
- 기존 IT운영팀/개발1팀/보안팀 및 기본권한 seed.
- 검증:
- `mvn.cmd test` 성공.
- `npm.cmd run build` 성공.
- 로컬 서버 재기동 후 `/api/admin/teams`, `/api/admin/users` 모두 200.
### 팀/권한 엑셀 업로드
- `시스템관리 > 팀 관리`에 엑셀 업로드 기능 추가.
- 컬럼: `팀 코드`, `팀명`, `기본권한`.
- 팀 코드를 기준으로 신규/수정 구분.
- 기본권한은 업로드 값으로 교체.
- 양식 다운로드 제공.
- `시스템관리 > 권한관리`에 엑셀 업로드 기능 추가.
- 컬럼: `아이디`, `이름`, `팀명`, `권한`.
- 기존 사용자만 수정하며 신규 사용자 생성은 제외.
- 아이디 기준으로 사용자 확인, 이름 불일치/없는 팀/잘못된 권한/중복 행은 오류 처리.
- 양식 다운로드 제공.
- 업로드는 즉시 반영하지 않고 `검증`으로 미리보기 결과를 확인한 뒤, 오류가 0건일 때만 `적용` 가능하도록 구성.
- 백엔드에서 Apache POI로 `.xlsx`를 파싱하고 적용 시 `ADMIN_CONFIG_UPDATE` 감사로그 기록.
- 검증:
- `mvn.cmd test` 성공.
- `npm.cmd run build` 성공.
- 로컬 서버 재기동 후 팀 양식 다운로드 및 `/api/admin/teams/upload?dryRun=true` 200 확인.
- 권한 양식 다운로드 및 `/api/admin/users/roles/upload?dryRun=true` 200 확인.
### 권한관리 운영 방식 일원화
- `시스템관리 > 권한관리` 하단 목록의 사용자별 `기본권한 적용` 버튼 제거.
- 사용자별 권한 변경은 팀 선택/권한 체크박스 수정 후 `저장` 버튼으로 반영하도록 변경.
- 팀 변경 시에도 팀 기본권한을 자동 적용하지 않고, 관리자가 화면에서 확인한 권한 체크 상태 그대로 저장.
- 팀 기본권한 적용 기능은 `팀 관리`의 팀원 일괄 적용 또는 권한 엑셀 업로드 방식으로만 유지.
- `/api/admin/**`는 기존 보안 설정상 ADMIN 권한자만 접근 가능함을 확인.
- 검증:
- `npm.cmd run build` 성공.
- `mvn.cmd test` 성공.
- 2026-07-16 배포 준비:
- 서버 배포 구조가 루트 Node Dockerfile 기준임을 확인하고, Spring Boot 변경사항 중 서버에서 필요한 기능을 Node API로 이관.
- 원격 최신 `playground/acs` 기준의 클린 클론(`acs-deploy`)에 Node/프론트/마이그레이션 변경만 적용.
- 클린 클론 검증:
- `npm.cmd run typecheck` 성공.
- `npm.cmd run build` 성공.
- `npm.cmd test` 성공.
- 로컬 DB 접속 정보(`DATABASE_URL`)가 없어 신규 마이그레이션은 서버 기동 시 적용되는 방식으로 확인 예정.
- 원격 `playground/acs` `main`에 배포 커밋 `c6f342a` push 완료.
- 운영 URL 확인:
- `/healthz` 200, `/db` 200, `/login` 200.
- `/api/health`의 build marker가 여전히 `auth-seed-20260714`이고 `/api/purpose-codes`가 JSON이 아니라 SPA HTML을 반환.
- 판단:
- Gitea push는 성공했지만 Coolify가 아직 새 커밋을 재배포하지 않음.
- Public Repository 방식은 webhook 미설정 시 push만으로 자동 배포되지 않으므로 Coolify에서 수동 Redeploy 또는 Gitea webhook 설정 필요.
- 2026-07-16 서버 배포 기능 보완:
- Node 배포본의 `/api/visit-requests/upload` 방문자 명단 엑셀 업로드 stub 제거.
- 기존 Spring `ExcelImportService` 기준으로 `방문자명단` 시트 3행부터 출입목적, 작업명, 장소, 출입일자, 출입시간, 방문자 정보, 통제담당자, 현장감시자 정보를 읽어 출입신청을 생성하도록 이관.
- 엑셀 출입목적 값이 시스템 출입목적 코드명/표시명과 일치하면 `purpose_code`로 정규화 저장하도록 보완.
- 엑셀에 출입통제담당자/현장감시자1 값이 있으면 해당 값을 우선 저장하고, 비어 있으면 기존처럼 로그인 사용자/시스템 설정값을 사용.
- 검증:
- `npm.cmd run typecheck` 성공.
- `npm.cmd run build` 성공.
- `npm.cmd test` 성공.
- 운영 서버 배포 후 검증:
- Coolify Redeploy 완료.
- `/db` 200 OK.
- `/api/purpose-codes` 200 OK.
- `a / 1` 로그인 성공.
- `/api/admin/teams` 200 OK.
- `/api/visit-requests/upload` 테스트 엑셀 업로드 성공: `totalRows=1`, `successCount=1`, `errors=[]`.
- 업로드로 생성된 테스트 신청은 즉시 취소 처리.
- 2026-07-16 출입신청 일시 선택 UI 수정:
- 운영 서버 출입신청 화면에서 출입일시 캘린더의 `[입력]` 버튼 클릭 시 선택값이 확정되지 않는 문제 확인.
- `DateTimePicker`를 확정형 동작으로 변경:
- 달력 내부 선택값은 `draft`로 보관.
- `[입력]` 버튼 클릭 시 `visitFrom`/`visitTo` 값으로 반영 후 팝업 닫기.
- 팝업 open 상태를 컴포넌트 state로 제어해 브라우저별 동작 차이를 줄임.
- 검증:
- `npm.cmd run typecheck` 성공.
- `npm.cmd run build` 성공.
- `npm.cmd test` 성공.
- 2026-07-16 승인 시 방문자 QR 문자 발송 구조 추가:
- 관리자 승인(`POST /api/approvals/{id}/approve`) 시 방문자 연락처로 공개 출입증 링크(`/pass/{qrToken}`)를 SMS 전송하도록 Node 서버에 `sendSms` 어댑터 추가.
- 문자 본문은 방문 승인 안내, 방문자명, 출입일시, 출입증(QR) URL을 포함.
- QR 이미지를 MMS로 직접 첨부하지 않고, 방문자 휴대폰에서 공개 출입증 URL을 열면 QR Code가 표시되는 방식으로 구성.
- 문자 발송 실패가 승인 자체를 롤백하지 않도록 하고, 성공/실패 및 오류는 `pass_deliveries`에 기록.
- 발송내역 화면의 재발송 버튼이 실제 SMS 재전송을 수행하도록 변경.
- SMS 환경변수:
- `ACS_SMS_PROVIDER=dev`: 실제 발송 없이 서버 로그 및 발송내역 성공 처리.
- `ACS_SMS_PROVIDER=http`: `ACS_SMS_API_URL`로 HTTP POST 전송.
- 선택값: `ACS_SMS_API_KEY`, `ACS_SMS_SENDER`, `ACS_SMS_TIMEOUT_MS`.
- 검증:
- `npm.cmd run typecheck` 성공.
- `npm.cmd test` 성공.
- `npm.cmd run build` 성공.
- 2026-07-16 출입신청 일시 선택 UI 재수정:
- 운영 서버에서 출입일시 캘린더 `[입력]` 버튼 흐름이 다시 막히는 현상 확인.
- 달력 팝업 내부 확정 버튼이 브라우저/라이브러리 이벤트와 충돌할 가능성이 있어 `[입력]` 버튼 의존 구조 제거.
- 날짜/시간 선택 즉시 `visitFrom`/`visitTo` 입력값에 반영되도록 단순화.
- 검증:
- `npm.cmd run typecheck` 성공.
- `npm.cmd run build` 성공.
- `npm.cmd test` 성공.
- 2026-07-16 출입신청 일시 적용 버튼 및 관리자 즉시승인:
- 출입일시/퇴실예정일시 입력칸 옆에 `적용` 버튼 추가.
- 캘린더에서 고른 날짜/시간은 임시값으로 유지하고, `적용` 클릭 시 실제 입력값으로 확정되도록 변경.
- ADMIN/SECURITY 권한 사용자가 직접 출입신청을 등록하는 경우 승인대기 없이 즉시 `APPROVED` 처리.
- 즉시승인 시 일반 승인과 동일하게 승인 이력 저장 및 QR 출입증 링크 SMS 발송 로직 실행.
- 검증:
- `npm.cmd run typecheck` 성공.
- `npm.cmd test` 성공.
- `npm.cmd run build` 성공.
- 2026-07-16 사내 SMS API 가이드 반영 및 일시 선택 UI 개선:
- `C:\ai-bok\SMS API가이드.pdf`를 로컬에서만 확인했고, PDF 원문 및 민감 자료는 저장소/서버 배포물에 포함하지 않음.
- 사내 메시지 발송은 네이버 API 직접 호출이 아니라 DMZ 보안 API 호출 방식임을 확인.
- Node SMS 어댑터에 `ACS_SMS_PROVIDER=hanbank`/`bok` provider를 추가:
- `POST /sens/sms`
- 요청 필드: `receive_number`, `content`, `msg_type=LMS`, `reserve_time`
- 응답 `statusCode=202` 또는 `statusName=success`를 성공으로 처리.
- 기존 `ACS_SMS_PROVIDER=dev`는 로컬/서버 모의 발송 검증용으로 유지.
- 출입신청 일시 선택 UI는 입력칸 옆 외부 `적용` 버튼을 제거하고, 캘린더 팝업 우측 상단 `닫기` 버튼으로 닫도록 변경.
- 검증:
- `npm.cmd run typecheck` 성공.
- `npm.cmd run build:frontend` 성공.
- `npm.cmd run build:server` 성공.
- `npm.cmd test` 성공.
- 2026-07-16 시스템관리 SMS 연결 점검 기능 추가:
- Coolify 서버 화면에서 컨테이너 터미널 접근 메뉴가 보이지 않아, 운영자가 화면에서 직접 점검할 수 있도록 `시스템관리 > SMS 점검` 탭 추가.
- ADMIN 전용 `POST /api/admin/sms/diagnostics` 추가.
- 실제 문자 발송 없이 현재 SMS provider/API URL 기준으로 서버 컨테이너에서 `/sens/sms` 연결을 짧게 점검.
- 점검 결과로 provider, URL, 연결 성공/실패, HTTP 상태, 소요시간, 오류 메시지를 표시.
- timeout이면 방화벽/라우팅/허용 IP 문제, HTTP 응답이면 API 서버 도달 성공으로 판단 가능.
- 2026-07-16 시스템관리 Email SMTP 연결 점검 기능 추가:
- Dooray API 가이드는 API 인증/서비스 API 안내 성격이고, 방문자 QR 이메일 발송은 별도 메일 발송 API보다 SMTP 경로를 우선 검증하는 방식이 적합하다고 판단.
- ADMIN 전용 `POST /api/admin/email/diagnostics` 추가.
- `ACS_MAIL_HOST`, `ACS_MAIL_PORT`, `ACS_MAIL_FROM`, `ACS_MAIL_USERNAME`, `ACS_MAIL_PASSWORD`, `ACS_MAIL_SMTP_AUTH`, `ACS_MAIL_STARTTLS`, `ACS_MAIL_TIMEOUT_MS` 환경변수를 서버에서 읽도록 추가.
- 실제 메일 발송 없이 서버 컨테이너에서 SMTP host/port TCP 연결만 점검.
- 시스템관리 화면에 `Email 점검` 탭을 추가하고 host, port, 발신자, 인증/STARTTLS 설정 여부, 계정/비밀번호 설정 여부, 연결 성공/실패, 소요시간, 오류 메시지를 표시.
- 2026-07-22 발표자료 화면 캡처 테스트 및 ACS 운영 흐름 보정:
- 개발/배포 원칙 재확인:
- 소스 변경은 반드시 로컬에서 수정 및 테스트 완료 후 서버 배포.
- 서버에서만 소스 수정하는 방식은 금지.
- 운영 DB 데이터 정리는 필요 시 서버 DB 또는 운영 UI에서 별도 수행.
- 로컬/서버 버전 정렬:
- 서버에 반영된 변경이 로컬과 어긋난 상태를 확인하고, 로컬 저장소를 원격 최신 기준으로 맞춤.
- 이후 모든 변경은 로컬 수정, 로컬 검증, 커밋, push, Coolify Redeploy 순서로 진행.
- 운영 테스트 데이터 정리:
- `시스템관리 > 데이터 정리 > 출입신청 테스트 데이터 초기화` 기능으로 비정상 테스트 데이터를 정리.
- 출입신청, 방문자, 승인 기록, 입/퇴장 기록, 출입증 발송 기록을 삭제하고 사용자/팀/권한/목적 코드/시스템 설정/블랙리스트는 유지.
- 승인대기 화면 UX 보정:
- 승인 버튼 클릭 시 반려 버튼까지 함께 처리 중처럼 보이는 문제 수정.
- 행 단위 busy 상태를 `approve`/`reject` 액션별로 분리.
- 커밋: `ed972a3 fix: align access console status actions`.
- 출입콘솔 상태 및 수기 입장 기능 보정:
- `입장대기` 별도 표시를 제거하고 `승인완료`로 통일.
- 출입콘솔 금일 출입 현황 목록에서도 관리자/담당자가 승인완료 건을 직접 `입장` 처리할 수 있도록 보정.
- 입장/퇴장 처리 성공 메시지에 출입구역을 함께 표시하도록 보정.
- 커밋: `3efe493 fix: show access action zone`.
- 키오스크 QR 처리 보정:
- QR에 토큰만이 아니라 `/pass/{token}` 전체 URL이 들어오는 경우를 처리.
- 키오스크에서 스캔한 전체 URL에서 토큰만 추출하도록 수정.
- public pass API 호출 시 토큰을 URL 인코딩하도록 수정.
- 키오스크에서 입장 후 같은 QR을 다시 입력하면 퇴장 처리 버튼이 표시되는 흐름 확인.
- 커밋: `7c57960 fix: parse kiosk qr pass urls`.
- 대시보드 출입 상태 반영 보정:
- 키오스크/출입콘솔 입퇴장 후 대시보드가 최초 조회 상태에 머무르는 문제 수정.
- 대시보드를 3초 주기로 자동 갱신하도록 변경.
- 커밋: `7cbc660 fix: refresh dashboard access state`.
- 출입신청 목록 상태 반영 보정:
- 신청 자체의 상태만 표시하여 실제 입퇴장 후에도 `승인완료`로 남던 문제 수정.
- 오늘 출입 현황을 함께 조회하고 `재실중`/`퇴장`을 신청 상태보다 우선 표시.
- 3초 주기 자동 갱신 추가.
- 커밋: `dee612f fix: show access state in request list`.
- 보고서 상태 반영 보정:
- 보고서 화면에서도 실제 입퇴장 상태가 아닌 신청 상태만 보이던 문제 수정.
- 오늘 출입 현황을 함께 조회하고 `재실중`/`퇴장`을 우선 표시.
- 커밋: `eec18e7 fix: show access state in reports`.
- 검증:
- 각 수정마다 `npm --prefix frontend exec tsc -- --noEmit` 통과.
- 각 수정마다 `npm run typecheck` 통과.
- Vite/esbuild는 샌드박스에서 `spawn EPERM`이 발생하여 권한 밖에서 `npm run build` 재실행 후 통과.
- Coolify Redeploy 후 서버 화면에서 주요 흐름 재확인.
- 발표자료 캡처:
- `C:\ai-bok\20260729\AIdev2.pptm` 12~45페이지 화면 캡처 완료.
- 비고:
- 서버 URL: `https://acs.apps.bokdev.in`.
- 테스트 중 발견된 화면 상태 불일치는 대시보드, 출입신청 목록, 보고서 순으로 동일 원칙에 맞춰 보정.

349
docs/AIdev.md Normal file
View File

@@ -0,0 +1,349 @@
# AI DEV 개발·배포 매뉴얼 (개발자용)
행번 계정 하나로 **Coder에서 개발**하고, **Gitea에 push**, **Kubero 또는 Coolify로 배포**합니다.
(배포 도구는 Kubero·Coolify 중 검토 중입니다. [9번](#9-배포-gitea--kubero--coolify)에 두 방법을 모두 정리해 두었습니다.)
DB(PostgreSQL)·파일저장소(MinIO)·AI(LiteLLM - key 제외)는 워크스페이스에 미리 연결되어 있습니다.
<!-- 이미지: `![설명](images/파일명.png)` 자리에 맞춰 캡처 후 images/ 에 추가 -->
## 목차
**최초 1회**
[1. 로그인](#1-로그인) → [2. 워크스페이스 만들기](#2-워크스페이스-만들기-최초-1회) → [3. VS Code 열기](#3-vs-code-열기) → [5. AI 키 등록](#5-ai-키-등록-최초-1회)
**앱마다**
[6. 새 프로젝트 시작](#6-새-프로젝트-시작) → [7. 개발](#7-개발) → [8. 로컬 실행·확인](#8-로컬-실행확인) → [9. 배포](#9-배포-gitea--kubero--coolify)
```
[최초 1회] 로그인(1) → 워크스페이스 생성(2) → VS Code(3) → AI 키 등록(5)
[앱마다] new-project + git init(6) → 개발·커밋(7) → 로컬 확인(8) → 레포 생성·push·배포(Kubero/Coolify)(9)
```
## 0. 서비스 주소
| 용도 | 주소 |
|---|---|
| AI DEV 포털 (시작점) | https://portal.bokdev.in |
| Coder (개발 워크스페이스) | https://coder.bokdev.in |
| Gitea (코드 저장소) | https://gitea.bokdev.in |
| Kubero (배포 · 후보) | https://kubero.bokdev.in |
| Coolify (배포 · 후보) | https://coolify.bokdev.in |
| 개발 중 미리보기 | `https://<자동생성>.coder.bokdev.in` |
| 배포된 앱 | `https://<레포명>.playground.bokdev.in` |
모든 서비스는 **행번 계정(SSO)** 으로 로그인합니다.
코드에서 쓰는 접속정보(DB·S3)는 `.project-env` 파일로 자동 제공됩니다. 직접 입력할 값이 없습니다.
## 1. 로그인
1. https://portal.bokdev.in 접속
<!-- TODO: 포털 주소 portal.bokdev.in / backstage.bokdev.in 중 확정 -->
2. 행번 계정으로 로그인
- 아이디: 본인 행번 (예: `2620227`)
- 비밀번호: 본인 비밀번호 (초기 비밀번호: `bok1234!!` + `행번 7자리`)
![포털 로그인 화면](images/01-login.png)
3. 이후 Coder·Gitea·Kubero는 추가 로그인 없이 같은 계정으로 열립니다.
- Kubero의 경우 `OAuth로 로그인하기`를 눌러 SSO 로그인이 가능합니다.
![포털 로그인 화면](images/portal.bokdev.in_.png)
## 2. Coder 워크스페이스 만들기 (최초 1회)
Coder 워크스페이스 = 본인 전용 개발 컨테이너(VS Code + 개발 도구 일체).
> ⚠️**주의**: 같은 브라우저에 다른 계정으로 Gitea 로그인이 남아 있으면 그 계정으로 연동됩니다.
> 승인 전에 Gitea에서 로그아웃했는지 확인하거나, **시크릿 모드**에서 진행합니다.
> Coder와 Gitea의 로그인 계정이 일치하지 않는 경우 Workspace 생성 후 계정 불일치로 Push가 되지 않을 수 있습니다.
1. https://coder.bokdev.in → **Workspaces****Create Workspace** (템플릿: `aidev`)
2. 설정값 입력
- **Name**: 워크스페이스 이름 (예: `ws-aidev-<행번>`)
- **External Authentication**: Gitea — **애플리케이션 승인** 클릭
- **CPU / Memory / Disk**: 기본값(2 Core / 4 GiB / 10 GiB) 사용. 추후 변경 가능.
![Gitea 계정 연동](images/02-coder-gitea.png)
3. **Create Workspace** 클릭
4. 최초 빌드는 2~5분 소요. 상태가 **Running**이 되면 완료.
> **주의**: 워크스페이스는 한 번 만들면 계속 사용합니다.
## 3. VS Code 열기
1. 워크스페이스 화면에서 **VS Code Web** 아이콘 클릭
2. `/home/coder/projects` 폴더가 자동으로 열립니다. ("Yes, I trust the authors" 클릭)
3. 안에 **`sample`** 폴더가 있습니다. DB·S3 연결이 확인된 참조용 예제이며 **직접 수정하지 않습니다**. [6번](#6-새-프로젝트-시작)에서 복사해 사용합니다.
<!-- TODO: 예제 폴더명 sample → connection-validation 변경 반영 여부 확인 (new-project 스크립트 포함) -->
> 작업 파일은 반드시 `/home/coder/projects` 아래에 둡니다. 이 폴더만 워크스페이스 재시작 후에도 보존됩니다.
## 4. 기본 제공 환경
새 워크스페이스에 아래가 설치·연결되어 있습니다.
| 항목 | 내용 |
|---|---|
| 개발 도구 | Java(JDK)/Maven, Node 22, Python 3.12, git, psql |
| 컨테이너 | podman (`docker` 명령도 동일 동작) |
| DB | 본인 전용 PostgreSQL 스키마 (`$DATABASE_URL`) |
| VS Code 확장 | Claude Code, Codex |
| AI CLI | `claude`, `codex` — [5번](#5-ai-키-등록-최초-1회)에서 키 등록 필요 |
## 5. AI 키 등록 (최초 1회)
Claude Code / Codex 는 사내 AI 게이트웨이(LiteLLM)를 사용합니다. 발급받은 본인 virtual key를 한 번만 등록하면 CLI·확장이 모두 공유합니다.
1. 터미널 열기: VS Code 메뉴(좌상단 ☰) → **Terminal → New Terminal**
2. 아래 명령 실행 후 본인 키(`sk-...`) 입력:
```bash
update-litellm-key
```
```
LiteLLM virtual key 입력 (sk-...): sk-본인-키
키 갱신 완료 (len=25). 현재 터미널에 즉시 적용됨.
```
3. 확인:
```bash
echo $ANTHROPIC_BASE_URL # https://litellm.bok.or.kr 이면 정상
claude
```
```bash
echo $OPENAI_BASE_URL # https://litellm.bok.or.kr/v1 이면 정상
codex
```
> **키 등록·변경 후 VS Code(웹)가 응답하지 않을 수 있습니다.**
> 워크스페이스 화면에서 VS Code 서버를 **Stop → Start** 하여 재시작합니다.
키는 워크스페이스의 `~/.env`에만 저장됩니다. 키를 바꿀 때도 같은 명령을 다시 실행합니다.
기본 모델은 게이트웨이에 맞춰 설정되어 있습니다.
| 도구 | 기본 모델 | 설정 파일 |
|---|---|---|
| Claude Code | `claude-opus-4-8` | `~/.claude/settings.json` |
| Codex | `gpt-5.5` | `~/.codex/config.toml` |
## 6. 새 프로젝트 시작
`sample` 예제를 복사해 시작합니다.
**(1) 터미널에서 프로젝트 생성** — 반드시 `~/projects` 에서 실행:
```bash
cd ~/projects
cd sample && git pull && cd .. # 예제 최신화
new-project myapp # 예제를 ~/projects/myapp 으로 복사 + .project-env 자동 생성
```
`myapp`은 예시입니다. 이 이름은 Gitea 레포명으로 설정할 이름과 동일하게 맞추시면 되고, 소문자·숫자·하이픈만 사용합니다.
**(2) git 초기화** — 개발 시작 시점에 합니다. 커밋 이력을 처음부터 관리하기 위함이며, 원격(Gitea) 연결은 배포 단계([9번](#9-배포-gitea--kubero--coolify))에서 합니다:
```bash
cd ~/projects/myapp
git init -b main
git add .
git commit -m "init project"
```
**(3) VS Code로 폴더 열기**: **File → Open Folder…** → `/home/coder/projects/myapp` → OK
왼쪽에 `myapp` 파일 목록이 보이면 완료. 새 터미널은 이 폴더에서 시작됩니다.
**(4) 라이브러리 설치**:
```bash
npm install
```
> **`.project-env`** 는 이 프로젝트의 설정 파일(DB·S3 접속정보)입니다. 폴더에 들어가면(cd) 자동으로 환경변수에 로드됩니다.
> <!-- TODO: 예제 .gitignore 에서 .project-env 제외할지 확인 필요 -->
> LiteLLM 키만 예외로 워크스페이스 공용 `~/.env`([5번](#5-ai-키-등록-최초-1회))에서 관리합니다.
## 7. 개발
- **편집**: 왼쪽 파일 목록에서 파일 선택 → 수정 → Ctrl+S 저장
- **AI 도구**: 프로젝트 폴더 안 터미널에서 `claude` 또는 `codex` 실행. 폴더 밖에서 실행하면 프로젝트 파일을 읽지 못합니다.
- **커밋**: 기능 단위로 수시로 커밋합니다. push는 배포 단계에서.
```bash
git add . && git commit -m "메시지"
```
- **DB 접속**:
```bash
psql "$DATABASE_URL" # 프로젝트 폴더에서 실행 (.project-env 로드 필요)
```
- 코드에서는 `process.env.DATABASE_URL`, `process.env.S3_*` 를 사용합니다.
- **`CLAUDE.md`**: 프로젝트 규칙·주의사항을 적어두면 Claude Code가 자동으로 읽고 따릅니다. 예제에 기본 파일이 포함되어 있습니다.
- AI에게는 구체적으로 지시합니다. 예: "로그인 API 만들어줘" 대신 "`src/`에 POST /login 추가, 검증 실패 시 401 반환". 생성된 코드는 [8번](#8-로컬-실행확인)으로 직접 확인 후 커밋합니다.
### 7-1. bkit 플러그인 (선택)
Claude Code에 계획→설계→구현→검증 절차를 더하는 플러그인. 터미널의 `claude` CLI에서만 동작합니다(VS Code 확장 미지원).
설치(최초 1회, `claude` 실행 후 프롬프트에 입력):
```
/plugin marketplace add popup-studio-ai/bkit-claude-code
/plugin install bkit
```
사용: `/pdca pm <기능이름>` — 기능 하나를 계획부터 검증까지 진행. 세분화 명령은 `/pdca plan` `/pdca design` `/pdca do` `/pdca analyze`.
## 8. 로컬 실행·확인
**(1) 실행**
```bash
cd ~/projects/myapp
npm run dev # 저장 시 자동 재시작
```
`listening on :3000` 이 에러 없이 출력되면 기동 성공.
실패 시 순서대로 확인: ① `npm install` 했는지 ② 코드 문법 오류 ③ 프로젝트 폴더 밖에서 실행(`.project-env` 미로딩).
**(2) 연결 점검** — 앱을 띄우지 않고 DB·S3 연결만 확인:
```bash
npm run db:check # "DB OK: ..." 이면 정상
npm run minio:check # "S3 OK: ..." 이면 정상
```
FAIL이면 `.project-env` 값을 확인합니다. 여기서 통과하면 배포 환경에서도 동일하게 동작합니다.
**(3) 브라우저 미리보기** — 워크스페이스는 클러스터 내부라 `localhost:3000`이 PC 브라우저에서 열리지 않습니다. 포트 포워딩을 사용합니다:
1. VS Code 하단 **PORTS** 탭 → **Forward a Port** → `3000` 입력
2. 포워딩된 포트의 **Open in Browser** 클릭 → `https://<자동생성>.coder.bokdev.in`
![브라우저 미리보기](images/coder-ports-preview.png)
**(4) 엔드포인트 확인**
```bash
curl 127.0.0.1:3000/healthz # {"ok":true} 앱 기동
curl 127.0.0.1:3000/db # {"ok":true,"now":...} DB 연결
curl 127.0.0.1:3000/s3 # {"ok":true,"bucket":...} S3 연결
```
`"ok": false` 이면 함께 출력되는 `error` 메시지가 원인입니다.
미리보기 URL은 본인 전용이며 워크스페이스를 끄면 사라집니다. 정식 배포는 [9번](#9-배포-gitea--kubero--coolify).
## 9. 배포 (Gitea → Kubero / Coolify)
배포 단위: Gitea `playground` 조직의 레포 1개 = 배포 앱 1개.
배포 주소: `https://<레포명>.playground.bokdev.in`
배포 도구는 **Kubero**와 **Coolify** 중 하나를 사용합니다.
**Gitea 레포 생성([9-1](#9-1-gitea-원격-레포-생성-앱당-1회))과 push([9-2](#9-2-push))는 두 도구 공통**이며, 이후 사용하는 도구에 따라 [9-3A(Kubero)](#9-3a-kubero에-앱-추가-앱당-1회) 또는 [9-3B(Coolify)](#9-3b-coolify에-앱-추가-앱당-1회)를 따릅니다.
| 항목 | Kubero | Coolify |
|---|---|---|
| 배포 위치 | `playground` 파이프라인에 앱 추가 | `ai-dev` 팀 → `ai-dev` 프로젝트에 앱 추가 |
| 코드 수정 반영 | push 후 **수동 재빌드** (자동 빌드 미연동) | push 시 **자동 재빌드·배포** (webhook 설정 시, [9-3B](#9-3b-coolify에-앱-추가-앱당-1회)) |
| 환경변수 입력 | `.project-env` 업로드 → 자동 파싱 | `.project-env` 값을 붙여넣기 (Developer view) |
| 빌드 방식 | Dockerfile | Dockerfile |
### 9-1. Gitea 원격 레포 생성 (앱당 1회)
1. https://gitea.bokdev.in/playground → 우측 상단 **`+` → New Repository**
![새 저장소 만들기](images/make-new-repository.png)
2. **Owner: `playground`** 로 변경, Repository Name 입력 (예: `myapp`), public 설정
![새 저장소 옵션 설정](images/make-new-repository-2.png)
3. README / .gitignore / License 는 체크하지 않음(빈 저장소여야 함) → **Create Repository**
### 9-2. push
```bash
cd ~/projects/myapp
git remote add origin https://gitea.bokdev.in/playground/myapp.git
git push -u origin main
```
- 최초 push 시 Gitea 승인 화면이 뜨면 **Authorize** 클릭([2번](#2-워크스페이스-만들기-최초-1회)에서 승인했다면 생략됨).
- 이후 수정 반영: `git add . && git commit -m "..." && git push`
### 9-3A. Kubero에 앱 추가 (앱당 1회)
`playground` pipeline을 사용하시면 되며, 사용자는 그 안에 본인 앱만 추가합니다.
1. https://kubero.bokdev.in 접속
2. **`playground`** 파이프라인 선택
3. **Production** 아래의 `+` 버튼을 클릭해 앱을 추가
4. App Name과 환경 ENVIRONMENT VARIABLES 추가
![Kubero 배포 앱 추가](images/kubero-pipeline.png)
- `.project-env` 파일을 업로드 하면 자동으로 파싱되어 등록됩니다.
> 자동 빌드는 현재 미연동입니다. **코드 수정 후에는 push 하고 Kubero에서 해당 앱의 빌드를 다시 실행합니다.**
### 9-3B. Coolify에 앱 추가 (앱당 1회)
Coolify는 "**push → Dockerfile로 자동 빌드·배포**" 방식입니다. 사용자는 `ai-dev` 프로젝트에 본인 앱만 추가합니다.
1. https://coolify.bokdev.in 접속 → 우측 상단에서 **`aidev`** 팀 선택
2. 좌측 **`Projects` → `aidev`** (서버·DB가 연결된 프로젝트) → **`+ Add Resource`**
![Coolify 팀 선택](images/coolify-team.png)
3. 리소스 종류에서 **`Public Repository`** 선택
![Coolify 리소스 종류 선택](images/coolify-new-resource.png)
4. **Repository URL**에 **전체 주소**를 입력 후 **`Check Repository`**:
`https://gitea.bokdev.in/playground/myapp.git`
(`playground/myapp` 처럼 줄여 쓰면 실패합니다.)
> **비공개(private) 레포일 때** — `Public Repository` 로도 받을 수 있습니다. URL에 Gitea 토큰을 끼워 넣습니다:
> `https://<토큰>@gitea.bokdev.in/playground/myapp.git`
> - 토큰 발급: Gitea → 우측 상단 프로필 → **Settings → Applications → Generate New Token**. 이름 지정 후 **`repository` 읽기 권한(Read)** 만 체크 → 생성. 표시되는 토큰은 **이때 한 번만** 보이므로 복사해 둡니다.
> - 발급한 토큰을 위 URL의 `<토큰>` 자리에 넣고 **`Check Repository`**. (토큰이 URL·Coolify 설정에 저장되므로 읽기 전용 권한만 부여합니다.)
5. **Build Pack: `Dockerfile`**, Branch `main`, Port `3000`.
![Coolify 저장소 연결](images/coolify-repo.png)
6. **Configuration → Domains** 에서 **`Generate Domain`** 클릭 → `https://<레포명>.apps.bokdev.in` 형태로 지정.
7. **Environment Variables** 에 `.project-env` 값 등록:
- **Developer view** 에서 `.project-env` 내용을 그대로 붙여넣으면 일괄 등록됩니다. (`cat ~/projects/myapp/.project-env`)
- `DATABASE_URL` 은 `%20`·`%3D` 인코딩까지 **그대로** 넣습니다(빼면 DB 연결이 깨집니다).
8. **Deploy** 클릭 → **Deployments** 탭에서 빌드 로그 확인. `New container started` / `Deployment finished` 가 보이면 성공.
#### 자동 배포(auto deploy) 설정 (앱당 1회)
Public Repository 방식은 webhook을 걸어야 push가 자동 배포로 이어집니다(Coolify가 push를 스스로 감지하지 못함). Coolify에 별도의 "Auto Deploy" 켜기 단계는 없고, **Webhooks 탭의 URL·Secret을 Gitea에 등록하는 것이 곧 자동 배포 설정**입니다. 앱마다 한 번만 하면 됩니다.
1. **Coolify 앱** → **Configuration → Webhooks** 탭에서, **Gitea** 항목의 **Webhook URL** 과 **Secret** 을 복사합니다. (Secret 칸이 비어 있으면 값을 입력/생성 후 저장)
![Coolify Webhook URL·Secret](images/coolify-webhook.png)
2. **Gitea 레포** → `https://gitea.bokdev.in/playground/myapp` → **Settings → Webhooks → Add Webhook → Gitea** 에 등록:
- **Target URL**: 1번의 Webhook URL
- **Secret**: 1번의 Secret
- **Content Type**: `application/json`
- **Trigger**: Push events, **Active** 체크 → **Add Webhook**
![Gitea Webhook 등록](images/gitea-webhook.png)
3. Gitea webhook 화면의 **Test Delivery** 를 누르거나 실제로 `git push` → Coolify **Deployments** 에 새 빌드가 자동으로 뜨면 완료.
> push가 배포를 트리거할지는 앱 **Advanced** 탭의 **`Auto Deploy`** 옵션이 결정하며, **기본값이 켜짐**이라 따로 켤 필요는 없습니다(자동 배포를 끄고 싶을 때만 여기서 해제).
> 설정 후에는 코드를 고쳐 **`git push` 하면 자동으로 다시 빌드·배포**됩니다(Deployments에서 새 빌드 로그 확인). webhook을 걸지 않았다면 앱 화면에서 **Deploy** 를 눌러 수동 배포합니다.
### 9-4. 확인
배포·재시작 직후 약 1~2분은 초기화(코드 다운로드·설치) 시간입니다. 일시적으로 404가 나오는 경우, 잠시 기다린 후 Ctrl + Shift + R로 강력 새로고침 후 확인해주세요.
```bash
curl https://<레포명>.playground.bokdev.in/healthz # {"ok":true}
curl https://<레포명>.playground.bokdev.in/db
curl https://<레포명>.playground.bokdev.in/s3
```
![배포된 앱](images/08-deployed-app.png)
문제가 있으면 Kubero에서 해당 앱의 빌드/배포 로그를 확인합니다. 로그에 `listening on :3000` 이 보이면 기동 성공입니다.
## FAQ
- Coder Workspace 켜고 끄기
- Coder workspace 재기동이 필요한 경우: Coder 워크스페이스 화면에서 **Stop**
- 다시 사용: **Start** (VS Code Web 아이콘이 뜰 때까지 대기)
- `~/projects` 만 보존됩니다. 그 외 경로의 파일은 사라질 수 있습니다.
- **로그인을 서비스마다 해야 하나요** → 아니요. 행번 계정 SSO 하나로 전부 로그인됩니다.
- **Coder에서 파일이 사라졌어요** → `~/projects` 밖에 저장한 경우 파일이 유실될 수 있습니다([3번](#3-vs-code-열기)).
- **AI 도구 401 오류** → `update-litellm-key` 재실행([5번](#5-ai-키-등록-최초-1회)). 키가 `sk-`로 시작하는지 확인.
- **AI 도구 400 (Invalid model)** → [5번](#5-ai-키-등록-최초-1회) 표의 기본 모델명 사용.
- **키 등록 후 VS Code가 먹통** → VS Code 서버 Stop → Start([5번](#5-ai-키-등록-최초-1회)).
- **`$DATABASE_URL` 이 비어 있음** → 프로젝트 폴더 안에서 실행해야 `.project-env` 가 로드됩니다([6번](#6-새-프로젝트-시작)).
- **`npm run dev` 가 `Cannot find package ...`** → `npm install` 미실행([6번](#6-새-프로젝트-시작)).
- **push 인증을 물어봄** → Gitea 승인을 아직 안 한 경우. 승인 화면에서 Authorize([9-2](#9-2-push)).
- **다른 계정으로 push/연동됨** → 브라우저에 남아 있던 Gitea 로그인 세션 때문입니다. Gitea 로그아웃 후 재승인하거나 시크릿 창 사용([2번](#2-워크스페이스-만들기-최초-1회)).
- **배포 주소가 404** → ① 배포 직후 1~2분 대기 ② `/healthz` 확인 ③ Kubero/Coolify 배포 로그 확인([9-4](#9-4-확인)).
- **`/healthz` 는 되는데 `/db`·`/s3` 가 500** → 먼저 로컬에서 `npm run db:check` / `minio:check` 통과 확인([8번](#8-로컬-실행확인)). 로컬에서 되면 배포 로그의 에러 메시지 확인. Coolify는 `.project-env` 값(특히 `DATABASE_URL` 의 `%20`/`%3D`)이 그대로인지도 확인.
- **배포가 옛날 코드** → push 됐는지 먼저 확인. Kubero는 빌드 재실행([9-3A](#9-3a-kubero에-앱-추가-앱당-1회)), Coolify는 push 시 자동 재배포([9-3B](#9-3b-coolify에-앱-추가-앱당-1회)).
- **DB가 비어 있음** → 정상입니다. 빈 전용 스키마가 제공되며 테이블은 직접 생성합니다.
- **K8s에 직접 접근하고 싶어요** → 직원은 K8s에 직접 접근하지 않습니다. Coder·Gitea·Kubero로 개발·배포가 완결됩니다.
## 문의
- IT 전략국 클라우드팀 김창록 팀장
- IT 전략국 정보시스템개발팀 박성록 과장
- IT 전략국 클라우드팀 이혜민 조사역

View File

@@ -7,5 +7,8 @@ IT센터 출입자관리시스템 관련 문서 모음.
| [workflow.md](workflow.md) | 전체 업무 워크플로우 (서술형) — 역할, 상태 머신, 신청→승인→입·출입→통계 흐름, 배포/운영 | | [workflow.md](workflow.md) | 전체 업무 워크플로우 (서술형) — 역할, 상태 머신, 신청→승인→입·출입→통계 흐름, 배포/운영 |
| [workflow-sequence.md](workflow-sequence.md) | 워크플로우 시퀀스/상태 다이어그램 (Mermaid) | | [workflow-sequence.md](workflow-sequence.md) | 워크플로우 시퀀스/상태 다이어그램 (Mermaid) |
| [issues-and-guidelines.md](issues-and-guidelines.md) | 기획·개발·테스트·수정 단계 이슈 정리 및 유의사항(규칙) | | [issues-and-guidelines.md](issues-and-guidelines.md) | 기획·개발·테스트·수정 단계 이슈 정리 및 유의사항(규칙) |
| [ACS-test-scenarios.md](ACS-test-scenarios.md) | ACS 운영 테스트 시나리오 및 결과 기록표 |
| [ACS-login-404-analysis.md](ACS-login-404-analysis.md) | 로그인 404/500 원인 분석, Auth API·migration 조치 기록 |
| [ACS-visit-form-ui-update.md](ACS-visit-form-ui-update.md) | 출입신청 화면 UI 개선 내역 및 빌드/배포 asset 확인 |
> 프로젝트 개요·실행 방법·API 요약은 상위 [README.md](../README.md) 참조. > 프로젝트 개요·실행 방법·API 요약은 상위 [README.md](../README.md) 참조.

BIN
docs/form_sample.xlsx Normal file

Binary file not shown.

View File

@@ -1,7 +1,7 @@
# ACS 개발 이슈 정리 및 유의사항(규칙) # ACS 개발 이슈 정리 및 유의사항(규칙)
> 문서 작성일: 2026-07-03 > 문서 작성일: 2026-07-03
> 대상: IT센터 출입자관리시스템 (`C:\ai-dev\workspace\access-control-system`) > 대상: IT센터 출입자관리시스템 (`C:\ai-dev\workspace\acs`)
> 목적: 기획·개발·테스트·수정 단계에서 실제로 겪은 이슈를 정리하고, 재발 방지를 위한 **규칙**으로 제안한다. > 목적: 기획·개발·테스트·수정 단계에서 실제로 겪은 이슈를 정리하고, 재발 방지를 위한 **규칙**으로 제안한다.
> 표기: 각 항목은 **[이슈] → [규칙]** 형태. 규칙 요약은 문서 끝 §6 체크리스트 참조. > 표기: 각 항목은 **[이슈] → [규칙]** 형태. 규칙 요약은 문서 끝 §6 체크리스트 참조.

176
docs/node-migration-plan.md Normal file
View File

@@ -0,0 +1,176 @@
# ACS Node.js Migration Plan
## Goal
Convert ACS from the current Spring Boot backend deployment model to an AI DEV compliant Node.js application.
The target deployment must:
- Run as a Node.js app.
- Run on port `3000`.
- Use `process.env.DATABASE_URL` supplied by `.project-env` / AI DEV.
- Avoid self-managed PostgreSQL containers in the deployment path.
- Build through the root `Dockerfile`.
- Preserve the existing React frontend where possible.
- Preserve the current `/api` contract and response envelope:
```json
{ "code": 200, "message": "OK", "data": {} }
```
## Target Architecture
```text
Node.js app
├─ /api/* Express API
├─ /assets, /index.html Static React build from frontend/dist
├─ PostgreSQL process.env.DATABASE_URL
└─ migrations SQL files adapted from existing Flyway migrations
```
Recommended stack:
- Runtime: Node.js + TypeScript
- HTTP: Express
- Database: `pg`
- Session: `express-session` with PostgreSQL-backed store
- Password hashing: `bcrypt`
- QR generation: `qrcode`
- Excel import/export: `multer` + `exceljs`
- Frontend: existing React/Vite app
## Migration Principles
1. Keep the frontend API surface stable.
2. Reuse the current PostgreSQL schema as much as possible.
3. Convert by feature slice, not by framework layer.
4. Keep Spring Boot code available as the behavior reference until parity is verified.
5. Make AI DEV deployment simple: `npm install`, `npm run build`, `npm start`.
## Feature Migration Order
### Phase 1. Node Foundation
- Add root Node package and TypeScript config.
- Add `server/` source tree.
- Add env loader for local `.project-env` compatibility.
- Add PostgreSQL connection pool using `DATABASE_URL`.
- Add migration runner using SQL files in `migrations/`.
- Add health check endpoint: `GET /api/health`.
- Add AI DEV health check endpoint: `GET /healthz`.
- Add AI DEV DB check endpoint: `GET /db`.
- Add S3 status endpoint: `GET /s3` with an explicit skip response because ACS does not use S3/MinIO.
- Serve `frontend/dist` for non-API routes.
### Phase 2. Auth and Common Infrastructure
- Implement API response helper.
- Implement error handler.
- Implement session middleware.
- Implement role guard middleware.
- Implement:
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `GET /api/auth/me`
- `POST /api/auth/change-password`
### Phase 3. Read-First Business APIs
- `GET /api/zones`
- `GET /api/visit-requests`
- `GET /api/visit-requests/pending`
- `GET /api/visit-requests/:id`
- `GET /api/stats/summary`
### Phase 4. Visit Request and Approval Workflow
- `POST /api/visit-requests`
- `POST /api/visit-requests/:id/cancel`
- `POST /api/approvals/:id/approve`
- `POST /api/approvals/:id/reject`
- Generate `qr_token` on approval.
- Insert approval and audit log records.
- Preserve "notification failure must not rollback approval" behavior.
### Phase 5. Pass, QR, and Access Control
- `GET /api/passes/:id`
- `GET /api/passes/:id/qr.png`
- `GET /api/public/passes/:token`
- `GET /api/public/passes/:token/qr.png`
- `POST /api/access/check-in`
- `POST /api/access/check-out`
- `GET /api/access/inside`
- `GET /api/access/today`
- Public kiosk check-in/out endpoints.
### Phase 6. Admin Features
- Blacklist CRUD.
- Audit log list.
- Delivery outbox list and retry.
- Excel upload for visit requests.
- XLSX visit report download.
### Phase 7. Deployment Cleanup
- Update README and AI DEV run instructions.
- Mark Spring Boot backend and Docker Compose deployment as legacy.
- Keep or remove legacy files after user confirmation.
- Verify deployment in AI DEV with real `.project-env`.
## API Compatibility Rules
- Keep `/api` prefix.
- Keep frontend DTO field names in camelCase.
- Keep HTTP status behavior close to the Spring implementation:
- 400 validation error
- 401 unauthenticated
- 403 forbidden
- 404 missing resource
- 409 business conflict
- State-changing APIs must require an authenticated session unless they are public token endpoints.
- Public pass endpoints must not require login.
## Database Migration Strategy
Existing files:
- `V1__init.sql`
- `V2__audit_log.sql`
- `V3__pass_delivery.sql`
- `V4__visit_request_contact_fields.sql`
Node target:
- Copy SQL into `migrations/001_init.sql` etc.
- Create a `schema_migrations` table.
- Apply migrations in filename order.
- Do not create or manage a PostgreSQL container.
- Use only `DATABASE_URL`.
## Verification Checklist
- `npm run typecheck`
- `npm run build`
- `npm run db:check` in AI DEV/Coder with `.project-env` loaded
- `npm run minio:check` returns a documented skip because ACS does not use S3
- `npm start`
- `GET /healthz`
- `GET /db`
- `GET /api/health`
- Login with seeded admin user.
- Create visit request.
- Approve request and confirm QR token.
- Open public pass page.
- Check in and check out.
- Confirm inside/today access views.
- Confirm blacklist blocks check-in.
- Download report XLSX.
## Open Decisions
- Exact AI DEV Node version.
- Whether AI DEV automatically runs `npm run build` or only `npm start`.
- Whether `.project-env` exists in the repository root or must be sourced by the shell before startup.
- Whether the production app should seed initial users automatically or require an explicit seed command.

View File

@@ -1,7 +1,7 @@
# IT센터 출입자관리시스템(ACS) 워크플로우 # IT센터 출입자관리시스템(ACS) 워크플로우
> 문서 작성일: 2026-07-03 > 문서 작성일: 2026-07-03
> 대상: `C:\ai-dev\workspace\access-control-system` (Spring Boot 3.4.5 / Java 21 · React 19 · Vite 6) > 대상: `C:\ai-dev\workspace\acs` (Spring Boot 3.4.5 / Java 21 · React 19 · Vite 6)
> 목적: 방문자 사전신청 → 승인 → 출입증 발송 → 입·출입 체크 → 재실현황/리포트까지의 전체 업무 흐름 정리 > 목적: 방문자 사전신청 → 승인 → 출입증 발송 → 입·출입 체크 → 재실현황/리포트까지의 전체 업무 흐름 정리
--- ---

View File

@@ -0,0 +1,6 @@
회사의 이메일 API 사용법
https://helpdesk.dooray.com/share/pages/9wWo-xwiR66BO5LGshgVTg/2937064454837487755
https://helpdesk.dooray.com/share/pages/9wWo-xwiR66BO5LGshgVTg/2939991731086319521

43
frontend/nginx-tls.conf Normal file
View File

@@ -0,0 +1,43 @@
# HTTPS variant of nginx.conf — used by the docker-compose.tls.yml overlay.
# nginx terminates TLS and reverse-proxies /api to the backend over the internal network.
# Mount a certificate at /etc/nginx/certs/{fullchain.pem,privkey.pem}.
# Redirect all plain HTTP to HTTPS.
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name _;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
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).
# X-Forwarded-Proto=https lets the app (server.forward-headers-strategy=framework)
# know the original request was secure → Secure cookies + https redirects.
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 / /;
}
}

View File

@@ -12,9 +12,13 @@ import { ApprovalQueuePage } from './pages/ApprovalQueuePage';
import { AccessConsolePage } from './pages/AccessConsolePage'; import { AccessConsolePage } from './pages/AccessConsolePage';
import { BadgePage } from './pages/BadgePage'; import { BadgePage } from './pages/BadgePage';
import { PublicPassPage } from './pages/PublicPassPage'; import { PublicPassPage } from './pages/PublicPassPage';
import { PublicVisitApplicationPage } from './pages/PublicVisitApplicationPage';
import { KioskPage } from './pages/KioskPage'; import { KioskPage } from './pages/KioskPage';
import { BlacklistPage } from './pages/BlacklistPage'; import { BlacklistPage } from './pages/BlacklistPage';
import { ReportPage } from './pages/ReportPage'; import { ReportPage } from './pages/ReportPage';
import { AuditLogPage } from './pages/AuditLogPage';
import { DeliveryOutboxPage } from './pages/DeliveryOutboxPage';
import { AdminManagementPage } from './pages/AdminManagementPage';
/** Requires a logged-in user; optionally one of the given roles. */ /** Requires a logged-in user; optionally one of the given roles. */
const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => { const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => {
@@ -42,6 +46,7 @@ export default function App() {
<Route path="/change-password" element={<ChangePasswordPage />} /> <Route path="/change-password" element={<ChangePasswordPage />} />
{/* Public visitor pass — opened from the SMS link, no login. */} {/* Public visitor pass — opened from the SMS link, no login. */}
<Route path="/pass/:token" element={<PublicPassPage />} /> <Route path="/pass/:token" element={<PublicPassPage />} />
<Route path="/visit" element={<PublicVisitApplicationPage />} />
{/* Public entrance kiosk — visitor self check-in/out, no login. */} {/* Public entrance kiosk — visitor self check-in/out, no login. */}
<Route path="/kiosk" element={<KioskPage />} /> <Route path="/kiosk" element={<KioskPage />} />
@@ -81,6 +86,30 @@ export default function App() {
</Protected> </Protected>
} }
/> />
<Route
path="/audit"
element={
<Protected roles={['ADMIN']}>
<AuditLogPage />
</Protected>
}
/>
<Route
path="/deliveries"
element={
<Protected roles={['ADMIN']}>
<DeliveryOutboxPage />
</Protected>
}
/>
<Route
path="/admin"
element={
<Protected roles={['ADMIN']}>
<AdminManagementPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />

View File

@@ -1,18 +1,36 @@
import { import {
AccessAction, AccessAction,
AccessRecord, AccessRecord,
AdminExcelImportResult,
ApiResponse, ApiResponse,
AuditLog,
BlacklistCreate, BlacklistCreate,
BlacklistItem, BlacklistItem,
ChangePasswordRequest, ChangePasswordRequest,
CurrentUser, CurrentUser,
DeliveryStatus,
EmailDiagnosticsResult,
ExcelImportResult, ExcelImportResult,
InsideVisitor, InsideVisitor,
LoginRequest, LoginRequest,
PassDelivery,
PurposeCode,
ReportVisitView,
Role,
SmsDiagnosticsResult,
StatsSummary, StatsSummary,
PublicPass, PublicPass,
Team,
VisitorApplicationCreate,
VisitorApplicationView,
VisitorVerificationConfirmResult,
VisitorVerificationMethod,
VisitorVerificationStartResult,
VisitRequestCreate, VisitRequestCreate,
VisitRequestResetResult,
VisitRequestView, VisitRequestView,
AdminUser,
Watcher1Settings,
Zone, Zone,
} from './types'; } from './types';
@@ -82,6 +100,23 @@ export const changePassword = (req: ChangePasswordRequest) =>
// ===== Zones ===== // ===== Zones =====
export const listZones = () => request<Zone[]>('/zones'); export const listZones = () => request<Zone[]>('/zones');
export const listPurposeCodes = () => request<PurposeCode[]>('/purpose-codes');
// ===== Public visitor application =====
export const startVisitorVerification = (method: VisitorVerificationMethod, target: string) =>
request<VisitorVerificationStartResult>('/public/visitor-verifications', jsonInit('POST', { method, target }));
export const confirmVisitorVerification = (verificationId: string, code: string) =>
request<VisitorVerificationConfirmResult>(
`/public/visitor-verifications/${encodeURIComponent(verificationId)}/confirm`,
jsonInit('POST', { code }),
);
export const createVisitorApplication = (payload: VisitorApplicationCreate) =>
request<VisitorApplicationView>('/public/visitor-applications', jsonInit('POST', payload));
export const listVisitorApplications = (q?: string) =>
request<VisitorApplicationView[]>(`/visitor-applications${q ? `?q=${encodeURIComponent(q)}` : ''}`);
// ===== Visit requests ===== // ===== Visit requests =====
export const listVisitRequests = () => export const listVisitRequests = () =>
@@ -94,11 +129,14 @@ export const getVisitRequest = (id: number) =>
request<VisitRequestView>(`/visit-requests/${id}`); request<VisitRequestView>(`/visit-requests/${id}`);
export const createVisitRequest = (req: VisitRequestCreate) => export const createVisitRequest = (req: VisitRequestCreate) =>
request<VisitRequestView>('/visit-requests', jsonInit('POST', req)); request<VisitRequestView[]>('/visit-requests', jsonInit('POST', req));
export const cancelVisitRequest = (id: number) => export const cancelVisitRequest = (id: number) =>
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' }); request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
export const deleteVisitRequest = (id: number) =>
request<{ deleted: boolean }>(`/visit-requests/${id}`, { method: 'DELETE' });
export const uploadVisitRequests = (file: File) => { export const uploadVisitRequests = (file: File) => {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
@@ -108,6 +146,8 @@ export const uploadVisitRequests = (file: File) => {
}); });
}; };
export const visitRequestTemplateUrl = () => '/api/visit-requests/template';
// ===== Approvals ===== // ===== Approvals =====
export const approveRequest = (id: number, comment?: string) => export const approveRequest = (id: number, comment?: string) =>
request<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment })); request<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment }));
@@ -133,12 +173,12 @@ export const getPass = (id: number) => request<VisitRequestView>(`/passes/${id}`
export const passQrUrl = (id: number) => `/api/passes/${id}/qr.png`; export const passQrUrl = (id: number) => `/api/passes/${id}/qr.png`;
// ===== Public pass + self-service kiosk (no login, token-based) ===== // ===== Public pass + self-service kiosk (no login, token-based) =====
export const getPublicPass = (token: string) => request<PublicPass>(`/public/passes/${token}`); export const getPublicPass = (token: string) => request<PublicPass>(`/public/passes/${encodeURIComponent(token)}`);
export const publicPassQrUrl = (token: string) => `/api/public/passes/${token}/qr.png`; export const publicPassQrUrl = (token: string) => `/api/public/passes/${encodeURIComponent(token)}/qr.png`;
export const publicCheckIn = (token: string) => export const publicCheckIn = (token: string) =>
request<AccessAction>(`/public/passes/${token}/check-in`, { method: 'POST' }); request<AccessAction>(`/public/passes/${encodeURIComponent(token)}/check-in`, { method: 'POST' });
export const publicCheckOut = (token: string) => export const publicCheckOut = (token: string) =>
request<AccessAction>(`/public/passes/${token}/check-out`, { method: 'POST' }); request<AccessAction>(`/public/passes/${encodeURIComponent(token)}/check-out`, { method: 'POST' });
// ===== Stats ===== // ===== Stats =====
export const getStatsSummary = () => request<StatsSummary>('/stats/summary'); export const getStatsSummary = () => request<StatsSummary>('/stats/summary');
@@ -151,5 +191,65 @@ export const deleteBlacklist = (id: number) =>
request<string>(`/blacklist/${id}`, { method: 'DELETE' }); request<string>(`/blacklist/${id}`, { method: 'DELETE' });
// ===== Reports ===== // ===== Reports =====
export const listReportVisits = (from: string, to: string) =>
request<ReportVisitView[]>(`/reports/visits?from=${from}&to=${to}`);
export const reportDownloadUrl = (from: string, to: string) => export const reportDownloadUrl = (from: string, to: string) =>
`/api/reports/visits.xlsx?from=${from}&to=${to}`; `/api/reports/visits.xlsx?from=${from}&to=${to}`;
// ===== Audit log (ADMIN) =====
export const listAudit = () => request<AuditLog[]>('/admin/audit');
// ===== Pass delivery outbox (ADMIN) =====
export const listDeliveries = (status?: DeliveryStatus) =>
request<PassDelivery[]>(`/admin/deliveries${status ? `?status=${status}` : ''}`);
export const retryDelivery = (id: number) =>
request<PassDelivery>(`/admin/deliveries/${id}/retry`, { method: 'POST' });
// ===== Admin management =====
export const listAdminPurposeCodes = () => request<PurposeCode[]>('/admin/purpose-codes');
export const createAdminPurposeCode = (payload: Omit<PurposeCode, 'id'>) =>
request<PurposeCode>('/admin/purpose-codes', jsonInit('POST', payload));
export const updateAdminPurposeCode = (id: number, payload: Omit<PurposeCode, 'id'>) =>
request<PurposeCode>(`/admin/purpose-codes/${id}`, jsonInit('PUT', payload));
export const getWatcher1Settings = () => request<Watcher1Settings>('/settings/watcher1');
export const updateWatcher1Settings = (payload: Watcher1Settings) =>
request<Watcher1Settings>('/admin/settings/watcher1', jsonInit('PUT', payload));
export const runSmsDiagnostics = () =>
request<SmsDiagnosticsResult>('/admin/sms/diagnostics', { method: 'POST' });
export const runEmailDiagnostics = () =>
request<EmailDiagnosticsResult>('/admin/email/diagnostics', { method: 'POST' });
export const resetVisitRequestTestData = (confirm: string) =>
request<VisitRequestResetResult>('/admin/visit-requests/reset-test-data', jsonInit('POST', { confirm }));
export const listAdminUsers = () => request<AdminUser[]>('/admin/users');
export const updateAdminUserRoles = (id: number, roles: Role[]) =>
request<AdminUser>(`/admin/users/${id}/roles`, jsonInit('PUT', { roles }));
export const listAdminTeams = () => request<Team[]>('/admin/teams');
export const createAdminTeam = (payload: Omit<Team, 'id'>) =>
request<Team>('/admin/teams', jsonInit('POST', payload));
export const updateAdminTeam = (id: number, payload: Omit<Team, 'id'>) =>
request<Team>(`/admin/teams/${id}`, jsonInit('PUT', payload));
export const updateAdminUserTeam = (id: number, teamId: number, applyDefaultRoles: boolean) =>
request<AdminUser>(`/admin/users/${id}/team`, jsonInit('PUT', { teamId, applyDefaultRoles }));
export const applyTeamDefaultRolesToUser = (id: number) =>
request<AdminUser>(`/admin/users/${id}/apply-team-default-roles`, { method: 'POST' });
export const applyTeamDefaultRolesToMembers = (teamId: number) =>
request<string>(`/admin/teams/${teamId}/apply-default-roles`, { method: 'POST' });
export const uploadAdminTeams = (file: File, dryRun: boolean) => {
const form = new FormData();
form.append('file', file);
return request<AdminExcelImportResult>(`/admin/teams/upload?dryRun=${dryRun}`, {
method: 'POST',
body: form,
});
};
export const uploadAdminUserRoles = (file: File, dryRun: boolean) => {
const form = new FormData();
form.append('file', file);
return request<AdminExcelImportResult>(`/admin/users/roles/upload?dryRun=${dryRun}`, {
method: 'POST',
body: form,
});
};
export const adminTeamTemplateUrl = () => '/api/admin/teams/template';
export const adminUserRolesTemplateUrl = () => '/api/admin/users/roles/template';

View File

@@ -0,0 +1,43 @@
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 {
/** date string in "YYYY-MM-DD" (the format the back-end expects). */
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
const pad = (n: number) => String(n).padStart(2, '0');
/** Date → "YYYY-MM-DD" (local). */
function toISODate(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/**
* Korean-localized date-only picker. Displays the value as YYYY.MM.DD (which the
* native <input type="date"> cannot force — it follows the OS locale) while the
* bound value stays "YYYY-MM-DD" for the API.
*/
export const DatePickerField: React.FC<Props> = ({ value, onChange, placeholder }) => {
const ref = useRef<DatePicker>(null);
return (
<DatePicker
ref={ref}
selected={value ? new Date(`${value}T00:00:00`) : null}
onChange={(d: Date | null) => d && onChange(toISODate(d))}
dateFormat="yyyy.MM.dd"
dateFormatCalendar="yyyy.M월"
locale="ko"
placeholderText={placeholder ?? '날짜를 선택하세요'}
className="dt-input"
popperClassName="acs-datepicker"
/>
);
};

View File

@@ -14,42 +14,50 @@ interface Props {
const pad = (n: number) => String(n).padStart(2, '0'); 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 { function toLocalString(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
/** function fromLocalString(value: string): Date | null {
* Korean-localized date+time picker that replaces the browser-native return value ? new Date(value) : null;
* <input type="datetime-local"> (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<Props> = ({ value, onChange, placeholder }) => { export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }) => {
const ref = useRef<DatePicker>(null); const pickerRef = useRef<DatePicker>(null);
return ( return (
<DatePicker <DatePicker
ref={ref} ref={pickerRef}
selected={value ? new Date(value) : null} selected={fromLocalString(value)}
onChange={(d: Date | null) => d && onChange(toLocalString(d))} onChange={(d: Date | null) => d && onChange(toLocalString(d))}
showTimeSelect showTimeSelect
timeIntervals={5} timeIntervals={5}
timeCaption="시간" timeCaption="시간"
timeFormat="a K:mm" timeFormat="a h:mm"
dateFormat="yyyy.MM.dd (eee) a K:mm" dateFormat="yyyy.MM.dd (eee) a h:mm"
dateFormatCalendar="yyyy.M" dateFormatCalendar="yyyy.MM"
locale="ko" locale="ko"
shouldCloseOnSelect={false} shouldCloseOnSelect={false}
placeholderText={placeholder ?? '날짜와 시간을 선택하세요'} placeholderText={placeholder ?? '날짜와 시간을 선택하세요'}
className="dt-input" className="dt-input"
popperClassName="acs-datepicker" popperClassName="acs-datepicker"
> popperPlacement="bottom-end"
<div className="dt-actions"> renderCustomHeader={({ date, decreaseMonth, increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled }) => (
<button type="button" className="btn-primary dt-confirm" onClick={() => ref.current?.setOpen(false)}> <div className="dt-picker-header">
<button type="button" className="dt-picker-nav" onClick={decreaseMonth} disabled={prevMonthButtonDisabled} aria-label="이전 달">
</button>
</div> </button>
</DatePicker> <span className="dt-picker-title">{date.getFullYear()}.{pad(date.getMonth() + 1)}</span>
<div className="dt-picker-header-actions">
<button type="button" className="dt-picker-nav" onClick={increaseMonth} disabled={nextMonthButtonDisabled} aria-label="다음 달">
</button>
<button type="button" className="dt-picker-close" onClick={() => pickerRef.current?.setOpen(false)} aria-label="닫기" title="닫기">
×
</button>
</div>
</div>
)}
/>
); );
}; };

View File

@@ -32,12 +32,14 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<NavLink to="/visit-requests"></NavLink> <NavLink to="/visit-requests"></NavLink>
{hasRole('ADMIN') && <NavLink to="/approvals"></NavLink>} {hasRole('ADMIN') && <NavLink to="/approvals"></NavLink>}
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access"></NavLink>} {hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access"></NavLink>}
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports"></NavLink>} {hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/blacklist"></NavLink>} {hasRole('ADMIN') && <NavLink to="/blacklist"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/admin"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/deliveries"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/audit"></NavLink>}
</nav> </nav>
<div className="user-box"> <div className="user-box">
<span className="user-name"> <span className="user-name">
{user?.fullName}
<span className="role-tags"> <span className="role-tags">
{user?.roles.map((r) => ( {user?.roles.map((r) => (
<span key={r} className="role-tag">{ROLE_LABEL[r] ?? r}</span> <span key={r} className="role-tag">{ROLE_LABEL[r] ?? r}</span>

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react';
import { getVisitRequest, listTodayAccess } from '../api';
import { AccessRecord, VisitRequestView } from '../types';
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
interface Props {
requestId: number;
onClose: () => void;
}
export const VisitRequestDetailDialog: React.FC<Props> = ({ requestId, onClose }) => {
const [request, setRequest] = useState<VisitRequestView | null>(null);
const [access, setAccess] = useState<AccessRecord | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
Promise.all([
getVisitRequest(requestId),
listTodayAccess().catch(() => [] as AccessRecord[]),
])
.then(([detail, records]) => {
if (!alive) return;
setRequest(detail);
setAccess(records.find((r) => r.visitRequestId === requestId) ?? null);
})
.catch((e) => {
if (!alive) return;
setError(e instanceof Error ? e.message : '상세 조회에 실패했습니다.');
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [requestId]);
const status = getDisplayStatus(request, access);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-box detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="detail-head">
<div>
<h3 className="modal-title"> </h3>
{request && <p className="modal-message">{request.visitorName} / {request.zoneName || '-'}</p>}
</div>
<button className="btn-ghost" onClick={onClose}></button>
</div>
{loading ? (
<p className="muted"> ...</p>
) : error ? (
<div className="alert alert-error">{error}</div>
) : request ? (
<>
<div className="detail-status">
<span className={`badge badge-${status.className}`}>{status.label}</span>
</div>
<div className="form-grid visit-form detail-form">
<fieldset className="form-group span-2">
<legend></legend>
<div className="group-grid">
<ReadOnlyField label="방문자 이름" value={request.visitorName} />
<ReadOnlyField label="회사/소속" value={request.company} />
<ReadOnlyField label="연락처" value={request.contact} />
<ReadOnlyField label="이메일" value={request.email} />
<div className="field">
<span> </span>
<div className="checkbox-row">
{['4층전산실', '5층전산실'].map((room) => (
<label key={room} className="checkbox-inline">
<input type="checkbox" checked={request.zoneName?.includes(room) ?? false} readOnly />
<span>{room}</span>
</label>
))}
</div>
</div>
<ReadOnlyField label="추가 구역" value={extraZone(request.zoneName)} />
<ReadOnlyField label="차량번호" value={request.vehicleNo} />
<ReadOnlyField label="출입 목적" value={request.purpose} />
<ReadOnlyField label="작업명" value={request.workName} />
<ReadOnlyField label="출입 일시" value={formatDateTime(request.visitFrom)} />
<ReadOnlyField label="퇴실 예정일시" value={formatDateTime(request.visitTo)} />
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="group-grid">
<ReadOnlyField label="이름" value={request.controlName || request.hostName} />
<ReadOnlyField label="담당팀" value={request.controlTeam || request.hostDepartment} />
<ReadOnlyField label="연락처" value={request.controlContact} />
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="subsection-title">1 <em className="hint-inline">: IT센터 </em></div>
<div className="group-grid">
<ReadOnlyField label="이름" value={request.watcher1Name} />
<ReadOnlyField label="소속" value={request.watcher1Team} />
<ReadOnlyField label="연락처" value={request.watcher1Contact} />
</div>
<div className="subsection-title">2 <em className="hint-inline">: </em></div>
<div className="group-grid">
<ReadOnlyField label="이름" value={request.watcher2Name} />
<ReadOnlyField label="소속" value={request.watcher2Team} />
<ReadOnlyField label="연락처" value={request.watcher2Contact} />
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend> </legend>
<div className="group-grid">
<ReadOnlyField label="상태" value={status.label} />
<ReadOnlyField label="실제 입장" value={formatDateTime(access?.checkInAt)} />
<ReadOnlyField label="실제 퇴장" value={formatDateTime(access?.checkOutAt)} />
</div>
</fieldset>
</div>
</>
) : null}
</div>
</div>
);
};
const ReadOnlyField: React.FC<{ label: string; value?: string | null }> = ({ label, value }) => (
<label className="field">
<span>{label}</span>
<input value={value || '-'} readOnly />
</label>
);
function extraZone(zoneName?: string): string {
if (!zoneName) return '-';
const parts = zoneName.split('/').map((part) => part.trim()).filter(Boolean);
if (parts.length > 1) return parts.slice(1).join(' / ');
if (zoneName.includes('전산실')) return '-';
return zoneName;
}
function getDisplayStatus(request: VisitRequestView | null, access: AccessRecord | null) {
if (access?.inside) {
return { label: '재실중', className: 'green' };
}
if (access?.checkOutAt) {
return { label: '퇴실', className: 'gray' };
}
if (!request) {
return { label: '-', className: 'gray' };
}
return { label: STATUS_LABEL[request.status], className: STATUS_CLASS[request.status] };
}

View File

@@ -6,7 +6,27 @@ import {
searchApprovedForCheckIn, searchApprovedForCheckIn,
} from '../api'; } from '../api';
import { AccessRecord, VisitRequestView } from '../types'; import { AccessRecord, VisitRequestView } from '../types';
import { formatShort } from '../status'; import { STATUS_CLASS, STATUS_LABEL, formatShort } from '../status';
import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog';
const hasEnteredToday = (r: AccessRecord) => Boolean(r.checkInAt);
const hasExitedToday = (r: AccessRecord) => Boolean(r.checkOutAt);
const actionLabel = (visitorName: string, zoneName?: string) =>
zoneName ? `${visitorName} (${zoneName})` : visitorName;
function accessBadge(record: AccessRecord): { className: string; label: string } {
if (record.inside) {
return { className: 'green', label: '재실중' };
}
if (hasExitedToday(record)) {
return { className: 'gray', label: '퇴실' };
}
const status = record.status ?? 'APPROVED';
if (status === 'APPROVED') {
return { className: STATUS_CLASS.APPROVED, label: STATUS_LABEL.APPROVED };
}
return { className: STATUS_CLASS[status], label: STATUS_LABEL[status] };
}
/** /**
* Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out, * Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out,
@@ -19,6 +39,7 @@ export const AccessConsolePage: React.FC = () => {
const [records, setRecords] = useState<AccessRecord[]>([]); const [records, setRecords] = useState<AccessRecord[]>([]);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [detailId, setDetailId] = useState<number | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const loadRecords = () => { const loadRecords = () => {
@@ -34,7 +55,7 @@ export const AccessConsolePage: React.FC = () => {
const insideIds = new Set(records.filter((r) => r.inside).map((r) => r.visitRequestId)); const insideIds = new Set(records.filter((r) => r.inside).map((r) => r.visitRequestId));
// Entered and already left today → no re-entry allowed. // Entered and already left today → no re-entry allowed.
const exitedIds = new Set(records.filter((r) => !r.inside).map((r) => r.visitRequestId)); const exitedIds = new Set(records.filter(hasExitedToday).map((r) => r.visitRequestId));
const wrap = async (fn: () => Promise<void>) => { const wrap = async (fn: () => Promise<void>) => {
setError(null); setError(null);
@@ -59,14 +80,14 @@ export const AccessConsolePage: React.FC = () => {
const forceCheckIn = (id: number) => const forceCheckIn = (id: number) =>
wrap(async () => { wrap(async () => {
const res = await checkIn({ visitRequestId: id, gateId: 'STAFF' }); const res = await checkIn({ visitRequestId: id, gateId: 'STAFF' });
setNotice(`${res.visitorName}${res.message}`); setNotice(`${actionLabel(res.visitorName, res.zoneName)}${res.message}`);
loadRecords(); loadRecords();
}); });
const forceCheckOut = (id: number) => const forceCheckOut = (id: number) =>
wrap(async () => { wrap(async () => {
const res = await checkOut({ visitRequestId: id }); const res = await checkOut({ visitRequestId: id });
setNotice(`${res.visitorName}${res.message}`); setNotice(`${actionLabel(res.visitorName, res.zoneName)}${res.message}`);
loadRecords(); loadRecords();
}); });
@@ -91,21 +112,52 @@ export const AccessConsolePage: React.FC = () => {
{results.length > 0 && ( {results.length > 0 && (
<table className="table" style={{ marginTop: 12 }}> <table className="table" style={{ marginTop: 12 }}>
<thead> <thead>
<tr><th></th><th></th><th></th><th></th></tr> <tr><th></th><th></th><th></th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{results.map((r) => ( {results.map((r) => (
<tr key={r.id}> <tr
key={r.id}
className="clickable-row"
tabIndex={0}
onClick={() => setDetailId(r.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDetailId(r.id);
}
}}
>
<td>{r.visitorName}</td> <td>{r.visitorName}</td>
<td>{r.company || '-'}</td> <td>{r.company || '-'}</td>
<td>{r.zoneName || '-'}</td> <td>{r.zoneName || '-'}</td>
<td> <td>
{insideIds.has(r.id) ? ( {insideIds.has(r.id) ? (
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.id)}></button> <span className="status-cell">
<span className="badge badge-green"></span>
<button
className="btn-danger"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
forceCheckOut(r.id);
}}
></button>
</span>
) : exitedIds.has(r.id) ? ( ) : exitedIds.has(r.id) ? (
<span className="muted"> </span> <span className="badge badge-gray"></span>
) : ( ) : (
<button className="btn-success" disabled={busy} onClick={() => forceCheckIn(r.id)}></button> <span className="status-cell">
<span className={`badge badge-${STATUS_CLASS.APPROVED}`}>{STATUS_LABEL.APPROVED}</span>
<button
className="btn-success"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
forceCheckIn(r.id);
}}
></button>
</span>
)} )}
</td> </td>
</tr> </tr>
@@ -131,7 +183,18 @@ export const AccessConsolePage: React.FC = () => {
</thead> </thead>
<tbody> <tbody>
{records.map((r) => ( {records.map((r) => (
<tr key={r.visitRequestId}> <tr
key={r.visitRequestId}
className="clickable-row"
tabIndex={0}
onClick={() => setDetailId(r.visitRequestId)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDetailId(r.visitRequestId);
}
}}
>
<td>{r.visitorName}</td> <td>{r.visitorName}</td>
<td>{r.company || '-'}</td> <td>{r.company || '-'}</td>
<td>{r.zoneName || '-'}</td> <td>{r.zoneName || '-'}</td>
@@ -140,13 +203,32 @@ export const AccessConsolePage: React.FC = () => {
<td>{formatShort(r.checkInAt)}</td> <td>{formatShort(r.checkInAt)}</td>
<td>{formatShort(r.checkOutAt)}</td> <td>{formatShort(r.checkOutAt)}</td>
<td> <td>
<span className={`badge badge-${r.inside ? 'green' : 'gray'}`}> {(() => {
{r.inside ? '재실중' : '퇴실'} const badge = accessBadge(r);
</span> return <span className={`badge badge-${badge.className}`}>{badge.label}</span>;
})()}
</td> </td>
<td> <td>
{r.inside && ( {r.inside && hasEnteredToday(r) ? (
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.visitRequestId)}></button> <button
className="btn-danger"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
forceCheckOut(r.visitRequestId);
}}
></button>
) : r.status === 'APPROVED' && !hasExitedToday(r) ? (
<button
className="btn-success"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
forceCheckIn(r.visitRequestId);
}}
></button>
) : (
'-'
)} )}
</td> </td>
</tr> </tr>
@@ -155,6 +237,10 @@ export const AccessConsolePage: React.FC = () => {
</table> </table>
)} )}
</div> </div>
{detailId != null && (
<VisitRequestDetailDialog requestId={detailId} onClose={() => setDetailId(null)} />
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,739 @@
import React, { useEffect, useState } from 'react';
import {
adminTeamTemplateUrl,
adminUserRolesTemplateUrl,
applyTeamDefaultRolesToMembers,
createAdminTeam,
createAdminPurposeCode,
listAdminPurposeCodes,
listAdminTeams,
listAdminUsers,
updateAdminTeam,
updateAdminPurposeCode,
updateAdminUserTeam,
updateAdminUserRoles,
runEmailDiagnostics,
runSmsDiagnostics,
resetVisitRequestTestData,
uploadAdminTeams,
uploadAdminUserRoles,
updateWatcher1Settings,
getWatcher1Settings,
} from '../api';
import { AdminExcelImportResult, AdminUser, EmailDiagnosticsResult, PurposeCode, Role, SmsDiagnosticsResult, Team, VisitRequestResetResult, Watcher1Settings } from '../types';
type Tab = 'watcher' | 'purpose' | 'teams' | 'roles' | 'sms' | 'email' | 'data';
const EMPTY_PURPOSE = {
code: '',
name: '',
sortOrder: 100,
active: true,
customAllowed: false,
};
const ALL_ROLES: Role[] = ['ADMIN', 'SECURITY', 'HOST'];
const EMPTY_TEAM: Omit<Team, 'id'> = {
code: '',
name: '',
active: true,
defaultRoles: ['HOST'],
};
const statusLabel = (status: string) => {
if (status === 'CREATE') return '신규';
if (status === 'UPDATE') return '수정';
if (status === 'ERROR') return '오류';
return status;
};
interface UploadPanelProps {
title: string;
templateUrl: string;
file: File | null;
result: AdminExcelImportResult | null;
busy: boolean;
onFileChange: (file: File | null) => void;
onPreview: () => void;
onApply: () => void;
}
const ExcelUploadPanel: React.FC<UploadPanelProps> = ({
title,
templateUrl,
file,
result,
busy,
onFileChange,
onPreview,
onApply,
}) => (
<div className="card admin-upload-panel">
<div className="admin-upload-head">
<h3>{title}</h3>
<a className="btn-ghost" href={templateUrl}> </a>
</div>
<div className="admin-upload-controls">
<input
type="file"
accept=".xlsx"
onChange={(e) => onFileChange(e.target.files?.[0] ?? null)}
/>
<button className="btn-secondary" type="button" disabled={busy || !file} onClick={onPreview}></button>
<button
className="btn-primary"
type="button"
disabled={busy || !file || !result?.success}
onClick={onApply}
>
</button>
</div>
{result && (
<div className="admin-upload-result">
<div className="admin-upload-summary">
<span> {result.totalRows}</span>
<span> {result.createCount}</span>
<span> {result.updateCount}</span>
<span className={result.errorCount > 0 ? 'text-danger' : ''}> {result.errorCount}</span>
<span> {result.warningCount}</span>
{result.applied && <span> </span>}
</div>
{result.rows.length > 0 && (
<table className="table admin-preview-table">
<thead><tr><th></th><th></th><th></th><th></th><th> </th></tr></thead>
<tbody>
{result.rows.map((row) => (
<tr key={`${row.rowNumber}-${row.key}`} className={row.errors.length > 0 ? 'row-error' : ''}>
<td>{row.rowNumber}</td>
<td>{statusLabel(row.status)}</td>
<td>{row.key || '-'}</td>
<td>{row.summary || '-'}</td>
<td>
{row.errors.length === 0 && row.warnings.length === 0 && '정상'}
{row.errors.map((message) => <div key={message} className="text-danger">{message}</div>)}
{row.warnings.map((message) => <div key={message}>{message}</div>)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
export const AdminManagementPage: React.FC = () => {
const [tab, setTab] = useState<Tab>('watcher');
const [watcher, setWatcher] = useState<Watcher1Settings>({ name: '', team: '', contact: '' });
const [purposes, setPurposes] = useState<PurposeCode[]>([]);
const [purposeForm, setPurposeForm] = useState<Omit<PurposeCode, 'id'>>(EMPTY_PURPOSE);
const [editingPurposeId, setEditingPurposeId] = useState<number | null>(null);
const [teams, setTeams] = useState<Team[]>([]);
const [teamForm, setTeamForm] = useState<Omit<Team, 'id'>>(EMPTY_TEAM);
const [editingTeamId, setEditingTeamId] = useState<number | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
const [userQuery, setUserQuery] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [teamUploadFile, setTeamUploadFile] = useState<File | null>(null);
const [teamUploadResult, setTeamUploadResult] = useState<AdminExcelImportResult | null>(null);
const [userRoleUploadFile, setUserRoleUploadFile] = useState<File | null>(null);
const [userRoleUploadResult, setUserRoleUploadResult] = useState<AdminExcelImportResult | null>(null);
const [smsDiagnostics, setSmsDiagnostics] = useState<SmsDiagnosticsResult | null>(null);
const [emailDiagnostics, setEmailDiagnostics] = useState<EmailDiagnosticsResult | null>(null);
const [resetConfirm, setResetConfirm] = useState('');
const [resetResult, setResetResult] = useState<VisitRequestResetResult | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = () => {
setError(null);
Promise.all([getWatcher1Settings(), listAdminPurposeCodes(), listAdminTeams(), listAdminUsers()])
.then(([w, p, t, u]) => {
setWatcher(w);
setPurposes(p);
setTeams(t);
setUsers(u);
})
.catch((e) => setError(e instanceof Error ? e.message : '관리 정보를 불러오지 못했습니다.'));
};
useEffect(load, []);
const saveWatcher = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
setWatcher(await updateWatcher1Settings(watcher));
setNotice('현장감시자1 정보가 저장되었습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : '저장 실패');
} finally {
setBusy(false);
}
};
const checkSms = async () => {
setBusy(true);
setError(null);
setNotice(null);
try {
const result = await runSmsDiagnostics();
setSmsDiagnostics(result);
setNotice(result.reachable ? 'SMS API 연결 점검이 완료되었습니다.' : 'SMS API 연결 점검에 실패했습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : 'SMS API 연결 점검 실패');
} finally {
setBusy(false);
}
};
const checkEmail = async () => {
setBusy(true);
setError(null);
setNotice(null);
try {
const result = await runEmailDiagnostics();
setEmailDiagnostics(result);
setNotice(result.reachable ? 'Email SMTP 연결 점검이 완료되었습니다.' : 'Email SMTP 연결 점검에 실패했습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Email SMTP 연결 점검 실패');
} finally {
setBusy(false);
}
};
const resetVisitData = async () => {
const normalizedConfirm = resetConfirm.replace(/\s/g, '').trim();
if (normalizedConfirm !== '초기화') {
setError('확인 입력란에 "초기화"를 입력하세요.');
return;
}
setBusy(true);
setError(null);
setNotice(null);
setResetResult(null);
try {
const result = await resetVisitRequestTestData(normalizedConfirm);
setResetResult(result);
setResetConfirm('');
setNotice(`출입신청 테스트 데이터 초기화 완료: 신청 ${result.visitRequests}건 삭제`);
} catch (err) {
setError(err instanceof Error ? err.message : '테스트 데이터 초기화 실패');
} finally {
setBusy(false);
}
};
const editPurpose = (p: PurposeCode) => {
setEditingPurposeId(p.id);
setPurposeForm({
code: p.code,
name: p.name,
sortOrder: p.sortOrder,
active: p.active,
customAllowed: p.customAllowed,
});
};
const resetPurposeForm = () => {
setEditingPurposeId(null);
setPurposeForm(EMPTY_PURPOSE);
};
const editTeam = (team: Team) => {
setEditingTeamId(team.id);
setTeamForm({
code: team.code,
name: team.name,
active: team.active,
defaultRoles: team.defaultRoles,
});
};
const resetTeamForm = () => {
setEditingTeamId(null);
setTeamForm(EMPTY_TEAM);
};
const toggleTeamDefaultRole = (role: Role) => {
const next = teamForm.defaultRoles.includes(role)
? teamForm.defaultRoles.filter((r) => r !== role)
: [...teamForm.defaultRoles, role];
setTeamForm({ ...teamForm, defaultRoles: next });
};
const saveTeam = async (e: React.FormEvent) => {
e.preventDefault();
if (teamForm.defaultRoles.length === 0) {
setError('팀 기본권한은 최소 1개 이상 필요합니다.');
return;
}
setBusy(true);
setError(null);
try {
const saved = editingTeamId == null
? await createAdminTeam(teamForm)
: await updateAdminTeam(editingTeamId, teamForm);
setTeams((prev) => {
const others = prev.filter((t) => t.id !== saved.id);
return [...others, saved].sort((a, b) => a.name.localeCompare(b.name));
});
resetTeamForm();
setNotice('팀 정보가 저장되었습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : '저장 실패');
} finally {
setBusy(false);
}
};
const savePurpose = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const saved = editingPurposeId == null
? await createAdminPurposeCode(purposeForm)
: await updateAdminPurposeCode(editingPurposeId, purposeForm);
setPurposes((prev) => {
const others = prev.filter((p) => p.id !== saved.id);
return [...others, saved].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
});
resetPurposeForm();
setNotice('출입목적 코드가 저장되었습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : '저장 실패');
} finally {
setBusy(false);
}
};
const toggleRole = (user: AdminUser, role: Role) => {
const next = user.roles.includes(role)
? user.roles.filter((r) => r !== role)
: [...user.roles, role];
if (next.length === 0) {
setError('권한은 최소 1개 이상 필요합니다.');
return;
}
setError(null);
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, roles: next } : u)));
};
const changeUserTeam = (user: AdminUser, teamId: number) => {
const team = teams.find((t) => t.id === teamId);
setError(null);
setUsers((prev) => prev.map((u) => (u.id === user.id
? { ...u, teamId, teamCode: team?.code, teamName: team?.name, department: team?.name ?? u.department }
: u)));
};
const saveUser = async (user: AdminUser) => {
if (user.roles.length === 0) {
setError('권한은 최소 1개 이상 필요합니다.');
return;
}
setBusy(true);
setError(null);
try {
let updated = user;
if (user.teamId) {
updated = await updateAdminUserTeam(user.id, user.teamId, false);
}
updated = await updateAdminUserRoles(user.id, user.roles);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
setNotice(`${user.username} 정보가 저장되었습니다.`);
} catch (err) {
setError(err instanceof Error ? err.message : '사용자 권한 저장 실패');
} finally {
setBusy(false);
}
};
const applyTeamDefaults = async (team: Team) => {
setBusy(true);
setError(null);
try {
const message = await applyTeamDefaultRolesToMembers(team.id);
setUsers(await listAdminUsers());
setNotice(message);
} catch (err) {
setError(err instanceof Error ? err.message : '기본권한 적용 실패');
} finally {
setBusy(false);
}
};
const previewTeamUpload = async () => {
if (!teamUploadFile) {
setError('업로드할 엑셀 파일을 선택하세요.');
return;
}
setBusy(true);
setError(null);
try {
setTeamUploadResult(await uploadAdminTeams(teamUploadFile, true));
setNotice('팀 엑셀 검증이 완료되었습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : '팀 엑셀 검증 실패');
} finally {
setBusy(false);
}
};
const applyTeamUpload = async () => {
if (!teamUploadFile || !teamUploadResult || !teamUploadResult.success) {
setError('오류가 없는 미리보기 결과가 있어야 적용할 수 있습니다.');
return;
}
setBusy(true);
setError(null);
try {
const result = await uploadAdminTeams(teamUploadFile, false);
setTeamUploadResult(result);
setTeams(await listAdminTeams());
setUsers(await listAdminUsers());
setNotice(`팀 엑셀 적용 완료: 신규 ${result.createCount}건, 수정 ${result.updateCount}`);
} catch (err) {
setError(err instanceof Error ? err.message : '팀 엑셀 적용 실패');
} finally {
setBusy(false);
}
};
const previewUserRoleUpload = async () => {
if (!userRoleUploadFile) {
setError('업로드할 엑셀 파일을 선택하세요.');
return;
}
setBusy(true);
setError(null);
try {
setUserRoleUploadResult(await uploadAdminUserRoles(userRoleUploadFile, true));
setNotice('권한 엑셀 검증이 완료되었습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : '권한 엑셀 검증 실패');
} finally {
setBusy(false);
}
};
const applyUserRoleUpload = async () => {
if (!userRoleUploadFile || !userRoleUploadResult || !userRoleUploadResult.success) {
setError('오류가 없는 미리보기 결과가 있어야 적용할 수 있습니다.');
return;
}
setBusy(true);
setError(null);
try {
const result = await uploadAdminUserRoles(userRoleUploadFile, false);
setUserRoleUploadResult(result);
setTeams(await listAdminTeams());
setUsers(await listAdminUsers());
setNotice(`권한 엑셀 적용 완료: 수정 ${result.updateCount}`);
} catch (err) {
setError(err instanceof Error ? err.message : '권한 엑셀 적용 실패');
} finally {
setBusy(false);
}
};
const filteredUsers = users.filter((u) => {
const q = userQuery.trim().toLowerCase();
const matchesQuery = !q
|| u.username.toLowerCase().includes(q)
|| u.fullName.toLowerCase().includes(q)
|| (u.teamName ?? u.department ?? '').toLowerCase().includes(q);
const matchesTeam = !teamFilter || String(u.teamId ?? '') === teamFilter;
return matchesQuery && matchesTeam;
});
return (
<div>
<div className="page-head"><h2> </h2></div>
{notice && <div className="alert alert-info">{notice}</div>}
{error && <div className="alert alert-error">{error}</div>}
<div className="admin-tabs">
<button className={tab === 'watcher' ? 'tab-active' : ''} onClick={() => setTab('watcher')}>1</button>
<button className={tab === 'purpose' ? 'tab-active' : ''} onClick={() => setTab('purpose')}> </button>
<button className={tab === 'teams' ? 'tab-active' : ''} onClick={() => setTab('teams')}> </button>
<button className={tab === 'roles' ? 'tab-active' : ''} onClick={() => setTab('roles')}></button>
<button className={tab === 'sms' ? 'tab-active' : ''} onClick={() => setTab('sms')}>SMS </button>
<button className={tab === 'email' ? 'tab-active' : ''} onClick={() => setTab('email')}>Email </button>
<button className={tab === 'data' ? 'tab-active' : ''} onClick={() => setTab('data')}> </button>
</div>
{tab === 'watcher' && (
<form className="card form-grid" onSubmit={saveWatcher}>
<label className="field"><span></span><input value={watcher.name} onChange={(e) => setWatcher({ ...watcher, name: e.target.value })} /></label>
<label className="field"><span></span><input value={watcher.team} onChange={(e) => setWatcher({ ...watcher, team: e.target.value })} /></label>
<label className="field"><span></span><input value={watcher.contact} onChange={(e) => setWatcher({ ...watcher, contact: e.target.value })} /></label>
<div className="form-actions span-2">
<button className="btn-primary" disabled={busy}></button>
</div>
</form>
)}
{tab === 'purpose' && (
<>
<form className="card form-grid" onSubmit={savePurpose}>
<label className="field"><span></span><input className="ime-en" value={purposeForm.code} onChange={(e) => setPurposeForm({ ...purposeForm, code: e.target.value })} placeholder="WORK" /></label>
<label className="field"><span></span><input className="ime-ko" value={purposeForm.name} onChange={(e) => setPurposeForm({ ...purposeForm, name: e.target.value })} placeholder="작업" /></label>
<label className="field"><span></span><input type="number" value={purposeForm.sortOrder} onChange={(e) => setPurposeForm({ ...purposeForm, sortOrder: Number(e.target.value) })} /></label>
<label className="checkbox-inline"><input type="checkbox" checked={purposeForm.active} onChange={(e) => setPurposeForm({ ...purposeForm, active: e.target.checked })} /><span></span></label>
<label className="checkbox-inline"><input type="checkbox" checked={purposeForm.customAllowed} onChange={(e) => setPurposeForm({ ...purposeForm, customAllowed: e.target.checked })} /><span> </span></label>
<div className="form-actions span-2">
<button type="button" className="btn-ghost" onClick={resetPurposeForm}></button>
<button className="btn-primary" disabled={busy}>{editingPurposeId == null ? '추가' : '저장'}</button>
</div>
</form>
<div className="card">
<table className="table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{purposes.map((p) => (
<tr key={p.id}>
<td>{p.code}</td>
<td>{p.name}</td>
<td>{p.sortOrder}</td>
<td>{p.active ? 'Y' : 'N'}</td>
<td>{p.customAllowed ? 'Y' : 'N'}</td>
<td><button className="btn-link" onClick={() => editPurpose(p)}></button></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{tab === 'teams' && (
<>
<ExcelUploadPanel
title="팀 엑셀 업로드"
templateUrl={adminTeamTemplateUrl()}
file={teamUploadFile}
result={teamUploadResult}
busy={busy}
onFileChange={(file) => {
setTeamUploadFile(file);
setTeamUploadResult(null);
}}
onPreview={() => void previewTeamUpload()}
onApply={() => void applyTeamUpload()}
/>
<form className="card form-grid" onSubmit={saveTeam}>
<label className="field"><span> </span><input className="ime-en" value={teamForm.code} onChange={(e) => setTeamForm({ ...teamForm, code: e.target.value })} placeholder="DEV1" /></label>
<label className="field"><span></span><input className="ime-ko" value={teamForm.name} onChange={(e) => setTeamForm({ ...teamForm, name: e.target.value })} placeholder="개발1팀" /></label>
<label className="checkbox-inline"><input type="checkbox" checked={teamForm.active} onChange={(e) => setTeamForm({ ...teamForm, active: e.target.checked })} /><span></span></label>
<div className="field span-2">
<span></span>
<div className="checkbox-row">
{ALL_ROLES.map((role) => (
<label key={role} className="checkbox-inline">
<input type="checkbox" checked={teamForm.defaultRoles.includes(role)} onChange={() => toggleTeamDefaultRole(role)} />
<span>{role === 'HOST' ? 'USER' : role}</span>
</label>
))}
</div>
</div>
<div className="form-actions span-2">
<button type="button" className="btn-ghost" onClick={resetTeamForm}></button>
<button className="btn-primary" disabled={busy}>{editingTeamId == null ? '추가' : '저장'}</button>
</div>
</form>
<div className="card">
<table className="table">
<thead><tr><th> </th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{teams.map((team) => (
<tr key={team.id}>
<td>{team.code}</td>
<td>{team.name}</td>
<td>{team.defaultRoles.map((r) => (r === 'HOST' ? 'USER' : r)).join(', ') || '-'}</td>
<td>{team.active ? 'Y' : 'N'}</td>
<td><button className="btn-link" onClick={() => editTeam(team)}></button></td>
<td><button className="btn-link" disabled={busy} onClick={() => void applyTeamDefaults(team)}> </button></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{tab === 'roles' && (
<>
<ExcelUploadPanel
title="권한 엑셀 업로드"
templateUrl={adminUserRolesTemplateUrl()}
file={userRoleUploadFile}
result={userRoleUploadResult}
busy={busy}
onFileChange={(file) => {
setUserRoleUploadFile(file);
setUserRoleUploadResult(null);
}}
onPreview={() => void previewUserRoleUpload()}
onApply={() => void applyUserRoleUpload()}
/>
<div className="card">
<div className="admin-filter-row">
<input value={userQuery} onChange={(e) => setUserQuery(e.target.value)} placeholder="아이디, 이름, 팀 검색" />
<select value={teamFilter} onChange={(e) => setTeamFilter(e.target.value)}>
<option value=""> </option>
{teams.map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
</select>
</div>
<table className="table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={u.id}>
<td>{u.username}</td>
<td>{u.fullName}</td>
<td>
<select
value={u.teamId ?? ''}
disabled={busy}
onChange={(e) => {
const teamId = Number(e.target.value);
if (teamId) changeUserTeam(u, teamId);
}}
>
<option value=""> </option>
{teams.map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
</select>
</td>
<td>
<div className="checkbox-row">
{ALL_ROLES.map((role) => (
<label key={role} className="checkbox-inline">
<input
type="checkbox"
disabled={busy}
checked={u.roles.includes(role)}
onChange={() => toggleRole(u, role)}
/>
<span>{role === 'HOST' ? 'USER' : role}</span>
</label>
))}
</div>
</td>
<td>
<button className="btn-link" disabled={busy || u.roles.length === 0} onClick={() => void saveUser(u)}>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{tab === 'data' && (
<div className="card">
<div className="admin-upload-head">
<h3> </h3>
</div>
<p className="muted">
, , / , . , , , , , .
</p>
<div className="admin-upload-controls">
<input
value={resetConfirm}
onChange={(e) => setResetConfirm(e.target.value)}
placeholder="초기화"
/>
<button
className="btn-danger"
type="button"
disabled={busy}
onClick={() => void resetVisitData()}
>
</button>
</div>
{resetResult && (
<table className="table diagnostic-table">
<tbody>
<tr><th></th><td>{resetResult.visitRequests}</td></tr>
<tr><th></th><td>{resetResult.visitors}</td></tr>
<tr><th> </th><td>{resetResult.approvals}</td></tr>
<tr><th>/ </th><td>{resetResult.accessEvents}</td></tr>
<tr><th> </th><td>{resetResult.passDeliveries}</td></tr>
</tbody>
</table>
)}
</div>
)}
{tab === 'sms' && (
<div className="card">
<div className="admin-upload-head">
<h3>SMS API </h3>
<button className="btn-primary" type="button" disabled={busy} onClick={() => void checkSms()}>
</button>
</div>
{smsDiagnostics ? (
<table className="table diagnostic-table">
<tbody>
<tr><th>Provider</th><td>{smsDiagnostics.provider}</td></tr>
<tr><th>URL</th><td>{smsDiagnostics.url || '-'}</td></tr>
<tr><th></th><td className={smsDiagnostics.reachable ? '' : 'text-danger'}>{smsDiagnostics.reachable ? '성공' : '실패'}</td></tr>
<tr><th>HTTP </th><td>{smsDiagnostics.httpStatus ?? '-'}</td></tr>
<tr><th></th><td>{smsDiagnostics.elapsedMs} ms</td></tr>
<tr><th></th><td>{new Date(smsDiagnostics.checkedAt).toLocaleString()}</td></tr>
<tr><th></th><td className={smsDiagnostics.error ? 'text-danger' : ''}>{smsDiagnostics.error ?? '-'}</td></tr>
</tbody>
</table>
) : (
<p className="muted"> SMS API URL로 .</p>
)}
</div>
)}
{tab === 'email' && (
<div className="card">
<div className="admin-upload-head">
<h3>Email SMTP </h3>
<button className="btn-primary" type="button" disabled={busy} onClick={() => void checkEmail()}>
</button>
</div>
{emailDiagnostics ? (
<table className="table diagnostic-table">
<tbody>
<tr><th>Host</th><td>{emailDiagnostics.host || '-'}</td></tr>
<tr><th>Port</th><td>{emailDiagnostics.port}</td></tr>
<tr><th>From</th><td>{emailDiagnostics.from || '-'}</td></tr>
<tr><th>SMTP Auth</th><td>{emailDiagnostics.smtpAuth ? 'Y' : 'N'}</td></tr>
<tr><th>STARTTLS</th><td>{emailDiagnostics.startTls ? 'Y' : 'N'}</td></tr>
<tr><th> </th><td>{emailDiagnostics.usernameConfigured ? 'Y' : 'N'}</td></tr>
<tr><th> </th><td>{emailDiagnostics.passwordConfigured ? 'Y' : 'N'}</td></tr>
<tr><th></th><td className={emailDiagnostics.reachable ? '' : 'text-danger'}>{emailDiagnostics.reachable ? '성공' : '실패'}</td></tr>
<tr><th></th><td>{emailDiagnostics.elapsedMs} ms</td></tr>
<tr><th></th><td>{new Date(emailDiagnostics.checkedAt).toLocaleString()}</td></tr>
<tr><th></th><td className={emailDiagnostics.error ? 'text-danger' : ''}>{emailDiagnostics.error ?? '-'}</td></tr>
</tbody>
</table>
) : (
<p className="muted"> Email SMTP host/port로 .</p>
)}
</div>
)}
</div>
);
};

View File

@@ -5,6 +5,7 @@ import { formatVisitRange } from '../status';
import { Dialog } from '../components/Dialog'; import { Dialog } from '../components/Dialog';
type SortKey = 'visitorName' | 'company' | 'zoneName' | 'purpose' | 'visitFrom'; type SortKey = 'visitorName' | 'company' | 'zoneName' | 'purpose' | 'visitFrom';
type BusyAction = 'approve' | 'reject';
const COLUMNS: { key: SortKey; label: string }[] = [ const COLUMNS: { key: SortKey; label: string }[] = [
{ key: 'visitorName', label: '방문자' }, { key: 'visitorName', label: '방문자' },
@@ -18,7 +19,7 @@ export const ApprovalQueuePage: React.FC = () => {
const [items, setItems] = useState<VisitRequestView[]>([]); const [items, setItems] = useState<VisitRequestView[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null); const [busyAction, setBusyAction] = useState<{ id: number; action: BusyAction } | null>(null);
const [sortKey, setSortKey] = useState<SortKey | null>(null); const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [rejectingId, setRejectingId] = useState<number | null>(null); const [rejectingId, setRejectingId] = useState<number | null>(null);
@@ -56,14 +57,14 @@ export const ApprovalQueuePage: React.FC = () => {
const approve = async (id: number) => { const approve = async (id: number) => {
setError(null); setError(null);
setBusyId(id); setBusyAction({ id, action: 'approve' });
try { try {
await approveRequest(id); await approveRequest(id);
load(); load();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '처리 실패'); setError(e instanceof Error ? e.message : '처리 실패');
} finally { } finally {
setBusyId(null); setBusyAction(null);
} }
}; };
@@ -72,14 +73,14 @@ export const ApprovalQueuePage: React.FC = () => {
setRejectingId(null); setRejectingId(null);
if (id == null) return; if (id == null) return;
setError(null); setError(null);
setBusyId(id); setBusyAction({ id, action: 'reject' });
try { try {
await rejectRequest(id, comment); await rejectRequest(id, comment);
load(); load();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '처리 실패'); setError(e instanceof Error ? e.message : '처리 실패');
} finally { } finally {
setBusyId(null); setBusyAction(null);
} }
}; };
@@ -119,8 +120,26 @@ export const ApprovalQueuePage: React.FC = () => {
<td>{r.purpose}</td> <td>{r.purpose}</td>
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td> <td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
<td className="action-cell"> <td className="action-cell">
<button className="btn-success" disabled={busyId === r.id} onClick={() => approve(r.id)}></button> <button
<button className="btn-danger" disabled={busyId === r.id} onClick={() => setRejectingId(r.id)}></button> className="btn-success"
disabled={busyAction?.id === r.id && busyAction.action === 'approve'}
onClick={() => {
if (busyAction?.id === r.id) return;
approve(r.id);
}}
>
{busyAction?.id === r.id && busyAction.action === 'approve' ? '처리 중...' : '승인'}
</button>
<button
className="btn-danger"
disabled={busyAction?.id === r.id && busyAction.action === 'reject'}
onClick={() => {
if (busyAction?.id === r.id) return;
setRejectingId(r.id);
}}
>
{busyAction?.id === r.id && busyAction.action === 'reject' ? '처리 중...' : '반려'}
</button>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -0,0 +1,72 @@
import React, { useEffect, useState } from 'react';
import { listAudit } from '../api';
import { AuditLog } from '../types';
import { formatDateTime } from '../status';
const ACTION_LABEL: Record<AuditLog['action'], string> = {
APPROVE: '승인',
REJECT: '반려',
DELETE: '삭제',
BLACKLIST_ADD: '블랙리스트 등록',
BLACKLIST_REMOVE: '블랙리스트 해제',
ADMIN_CONFIG_UPDATE: '시스템 설정',
};
const ACTION_CLASS: Record<AuditLog['action'], string> = {
APPROVE: 'green',
REJECT: 'red',
DELETE: 'red',
BLACKLIST_ADD: 'red',
BLACKLIST_REMOVE: 'gray',
ADMIN_CONFIG_UPDATE: 'blue',
};
export const AuditLogPage: React.FC = () => {
const [items, setItems] = useState<AuditLog[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
listAudit()
.then(setItems)
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
.finally(() => setLoading(false));
};
useEffect(load, []);
return (
<div>
<div className="page-head">
<h2> </h2>
<button className="btn-ghost" onClick={load} disabled={loading}></button>
</div>
{error && <div className="alert alert-error">{error}</div>}
<div className="card">
<h3> ({items.length})</h3>
{items.length === 0 ? (
<p className="muted">{loading ? '불러오는 중…' : '기록이 없습니다.'}</p>
) : (
<table className="table">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{items.map((a) => (
<tr key={a.id}>
<td>{formatDateTime(a.at)}</td>
<td>{a.actorUsername || '시스템'}</td>
<td><span className={`badge badge-${ACTION_CLASS[a.action]}`}>{ACTION_LABEL[a.action] ?? a.action}</span></td>
<td>{a.targetType ? `${a.targetType}${a.targetId != null ? ` #${a.targetId}` : ''}` : '-'}</td>
<td>{a.detail || '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};

View File

@@ -1,24 +1,65 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getStatsSummary, listVisitRequests } from '../api'; import { deleteVisitRequest, getStatsSummary, listInside, listTodayAccess, listVisitRequests } from '../api';
import { StatsSummary, VisitRequestView } from '../types'; import { StatsSummary, VisitRequestView } from '../types';
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status'; import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog';
import { useAuth } from '../auth/AuthContext';
export const DashboardPage: React.FC = () => { export const DashboardPage: React.FC = () => {
const [items, setItems] = useState<VisitRequestView[]>([]); const [items, setItems] = useState<VisitRequestView[]>([]);
const [stats, setStats] = useState<StatsSummary | null>(null); const [stats, setStats] = useState<StatsSummary | null>(null);
const [insideIds, setInsideIds] = useState<Set<number>>(new Set());
const [exitedIds, setExitedIds] = useState<Set<number>>(new Set());
const [checkOutById, setCheckOutById] = useState<Map<number, string>>(new Map());
const [detailId, setDetailId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { hasRole } = useAuth();
useEffect(() => { const loadDashboard = () => {
getStatsSummary().then(setStats).catch(() => setStats(null)); getStatsSummary().then(setStats).catch(() => setStats(null));
listInside()
.then((rows) => setInsideIds(new Set(rows.map((r) => r.visitRequestId))))
.catch(() => setInsideIds(new Set()));
listTodayAccess()
.then((rows) => {
setExitedIds(new Set(rows
.filter((r) => !r.inside && r.checkOutAt)
.map((r) => r.visitRequestId)));
setCheckOutById(new Map(rows
.filter((r) => r.checkOutAt)
.map((r) => [r.visitRequestId, r.checkOutAt as string])));
})
.catch(() => {
setExitedIds(new Set());
setCheckOutById(new Map());
});
listVisitRequests() listVisitRequests()
.then(setItems) .then(setItems)
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패')) .catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
};
useEffect(() => {
loadDashboard();
const t = setInterval(loadDashboard, 3000);
return () => clearInterval(t);
}, []); }, []);
const recent = items.slice(0, 8); const deleteCancelled = async (target: VisitRequestView) => {
if (target.status !== 'CANCELLED') return;
setError(null);
setNotice(null);
try {
await deleteVisitRequest(target.id);
setNotice(`${target.visitorName} 신청을 삭제했습니다.`);
loadDashboard();
} catch (e) {
setError(e instanceof Error ? e.message : '삭제 실패');
}
};
return ( return (
<div> <div>
@@ -28,41 +69,80 @@ export const DashboardPage: React.FC = () => {
</div> </div>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{notice && <div className="alert alert-info">{notice}</div>}
<div className="stat-grid"> <div className="stat-grid">
<StatCard label="오늘 출입 예정" value={stats?.todayVisits ?? 0} accent="blue" /> <StatCard label="오늘 출입 예정" value={stats?.todayVisits ?? 0} accent="blue" />
<StatCard label="현재 재실" value={stats?.currentlyInside ?? 0} accent="green" /> <StatCard label="현재 재실" value={stats?.currentlyInside ?? 0} accent="green" />
<StatCard label="승인 대기" value={stats?.pending ?? 0} accent="amber" /> <StatCard label="승인 대기" value={stats?.pending ?? 0} accent="amber" />
<StatCard label="승인완료" value={stats?.approved ?? 0} accent="green" />
<StatCard label="전체 신청" value={stats?.total ?? 0} accent="gray" /> <StatCard label="전체 신청" value={stats?.total ?? 0} accent="gray" />
</div> </div>
<div className="card"> <div className="card">
<h3> </h3> <h3> ({items.length})</h3>
{loading ? ( {loading ? (
<p className="muted"> </p> <p className="muted"> </p>
) : recent.length === 0 ? ( ) : items.length === 0 ? (
<p className="muted"> .</p> <p className="muted"> .</p>
) : ( ) : (
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th></th><th></th><th></th><th> </th><th></th> <th></th><th></th><th></th><th> </th><th> </th><th></th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{recent.map((r) => ( {items.map((r) => (
<tr key={r.id}> <tr
key={r.id}
className="clickable-row"
tabIndex={0}
onClick={() => setDetailId(r.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDetailId(r.id);
}
}}
>
<td>{r.visitorName}</td> <td>{r.visitorName}</td>
<td>{r.company || '-'}</td> <td>{r.company || '-'}</td>
<td>{r.zoneName || '-'}</td> <td>{r.zoneName || '-'}</td>
<td>{formatDateTime(r.visitFrom)}</td> <td>{formatDateTime(r.visitFrom)}</td>
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td> <td>{checkOutById.has(r.id) ? formatDateTime(checkOutById.get(r.id)!) : '-'}</td>
<td>
{insideIds.has(r.id) ? (
<span className="badge badge-green"></span>
) : exitedIds.has(r.id) ? (
<span className="badge badge-gray"></span>
) : (
<span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span>
)}
</td>
<td className="row-actions">
{hasRole('ADMIN') && r.status === 'CANCELLED' && (
<button
className="btn-link-danger"
onClick={(e) => {
e.stopPropagation();
deleteCancelled(r);
}}
>
</button>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)} )}
</div> </div>
{detailId != null && (
<VisitRequestDetailDialog requestId={detailId} onClose={() => setDetailId(null)} />
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { listDeliveries, retryDelivery } from '../api';
import { DeliveryStatus, PassDelivery } from '../types';
import { formatDateTime } from '../status';
type Filter = 'ALL' | DeliveryStatus;
export const DeliveryOutboxPage: React.FC = () => {
const [items, setItems] = useState<PassDelivery[]>([]);
const [filter, setFilter] = useState<Filter>('FAILED');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [retryingId, setRetryingId] = useState<number | null>(null);
const load = () => {
setLoading(true);
listDeliveries(filter === 'ALL' ? undefined : filter)
.then(setItems)
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
.finally(() => setLoading(false));
};
useEffect(load, [filter]);
const onRetry = async (id: number) => {
setError(null);
setRetryingId(id);
try {
await retryDelivery(id);
load();
} catch (err) {
setError(err instanceof Error ? err.message : '재발송 실패');
} finally {
setRetryingId(null);
}
};
return (
<div>
<div className="page-head">
<h2> </h2>
<div className="row-gap">
<select value={filter} onChange={(e) => setFilter(e.target.value as Filter)}>
<option value="FAILED"></option>
<option value="SENT"></option>
<option value="ALL"></option>
</select>
<button className="btn-ghost" onClick={load} disabled={loading}></button>
</div>
</div>
{error && <div className="alert alert-error">{error}</div>}
<div className="card">
<h3> ({items.length})</h3>
{items.length === 0 ? (
<p className="muted">{loading ? '불러오는 중…' : '해당 조건의 발송 기록이 없습니다.'}</p>
) : (
<table className="table">
<thead>
<tr>
<th></th><th></th><th></th><th></th>
<th></th><th></th><th></th><th></th>
</tr>
</thead>
<tbody>
{items.map((d) => (
<tr key={d.id}>
<td>{formatDateTime(d.updatedAt || d.createdAt)}</td>
<td>#{d.visitRequestId}</td>
<td>{d.channel || '-'}</td>
<td>{d.recipient || '-'}</td>
<td><span className={`badge badge-${d.status === 'SENT' ? 'green' : 'red'}`}>
{d.status === 'SENT' ? '성공' : '실패'}</span></td>
<td>{d.attempts}</td>
<td className="cell-error" title={d.lastError || ''}>{d.lastError || '-'}</td>
<td>
{d.status === 'FAILED' && (
<button className="btn-link" onClick={() => onRetry(d.id)} disabled={retryingId === d.id}>
{retryingId === d.id ? '재발송 중…' : '재발송'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};

View File

@@ -5,6 +5,30 @@ import { formatVisitRange } from '../status';
import { useQrScanner } from '../useQrScanner'; import { useQrScanner } from '../useQrScanner';
import { playChime } from '../chime'; import { playChime } from '../chime';
const actionLabel = (visitorName: string, zoneName?: string) =>
zoneName ? `${visitorName} (${zoneName})` : visitorName;
function extractQrToken(raw: string): string {
const text = raw.trim();
if (!text) return '';
try {
const url = new URL(text);
const passMatch = url.pathname.match(/\/pass\/([^/?#]+)/);
if (passMatch?.[1]) return decodeURIComponent(passMatch[1]);
const apiMatch = url.pathname.match(/\/public\/passes\/([^/?#]+)/);
if (apiMatch?.[1]) return decodeURIComponent(apiMatch[1]);
} catch {
// Not a URL; fall through to path/token parsing.
}
const pathMatch = text.match(/\/pass\/([^/?#]+)/) ?? text.match(/\/public\/passes\/([^/?#]+)/);
if (pathMatch?.[1]) return decodeURIComponent(pathMatch[1]);
return text;
}
/** /**
* Public entrance kiosk (no login). The visitor scans their phone QR, the * Public entrance kiosk (no login). The visitor scans their phone QR, the
* approved pass is shown, and they tap 입장/퇴장 to self check-in/out. * approved pass is shown, and they tap 입장/퇴장 to self check-in/out.
@@ -19,7 +43,7 @@ export const KioskPage: React.FC = () => {
const [manual, setManual] = useState(''); const [manual, setManual] = useState('');
const resolveToken = async (raw: string) => { const resolveToken = async (raw: string) => {
const t = raw.trim(); const t = extractQrToken(raw);
if (!t || busy || token) return; if (!t || busy || token) return;
setError(null); setError(null);
scanner.stop(); scanner.stop();
@@ -44,7 +68,7 @@ export const KioskPage: React.FC = () => {
try { try {
const r = dir === 'in' ? await publicCheckIn(token) : await publicCheckOut(token); const r = dir === 'in' ? await publicCheckIn(token) : await publicCheckOut(token);
playChime(); playChime();
setResult(`${r.visitorName}${r.message}`); setResult(`${actionLabel(r.visitorName, r.zoneName)}${r.message}`);
setToken(null); setToken(null);
setPass(null); setPass(null);
} catch (e) { } catch (e) {

View File

@@ -58,7 +58,6 @@ export const LoginPage: React.FC = () => {
{busy ? '로그인 중…' : '로그인'} {busy ? '로그인 중…' : '로그인'}
</button> </button>
<p className="hint"> 계정: admin / security / host ( ChangeMe123!)</p>
</form> </form>
</div> </div>
); );

View File

@@ -0,0 +1,387 @@
import React, { useEffect, useRef, useState } from 'react';
import {
confirmVisitorVerification,
createVisitorApplication,
listPurposeCodes,
startVisitorVerification,
} from '../api';
import { DateTimePicker } from '../components/DateTimePicker';
import { PurposeCode, VisitorVerificationMethod } from '../types';
import bokBadge from '../assets/bok-badge.png';
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
const ROOM_OPTIONS = ['4층종합상황실', '4층CMT실', '3층사무실', '기타'];
const FALLBACK_PURPOSE_CODES: PurposeCode[] = [
{ id: 1, code: 'INSPECTION', name: '점검', sortOrder: 10, active: true, customAllowed: false },
{ id: 2, code: 'WORK', name: '작업', sortOrder: 20, active: true, customAllowed: false },
{ id: 3, code: 'TOUR', name: '견학', sortOrder: 30, active: true, customAllowed: false },
{ id: 4, code: 'MEETING', name: '회의', sortOrder: 40, active: true, customAllowed: false },
{ id: 5, code: 'CLEANING', name: '청소', sortOrder: 50, active: true, customAllowed: false },
{ id: 6, code: 'ETC', name: '기타', sortOrder: 900, active: true, customAllowed: true },
];
const DEFAULT_WATCHER1 = { name: '류관순', team: 'IT전략국', contact: '313' };
const AFFILIATION_OPTIONS = [
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
'금융IT인프라팀', '정보인프라팀', 'AI플랫폼팀', '보안운영팀', '보안관제반',
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const normalizePhoneText = (value: string): string =>
value
.replace(/[-]/g, (char) => String(char.charCodeAt(0) - 0xff10))
.replace(/[-ー–—]/g, '-')
.replace(/\s+/g, '');
const formatPhoneLike = (value: string): string => {
const compact = normalizePhoneText(value);
const digits = compact.replace(/\D/g, '');
if (!/^[\d-]*$/.test(compact)) return compact;
if (digits.length === 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
if (digits.length === 10) return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
return compact;
};
const sanitizePhoneInput = (value: string): string =>
normalizePhoneText(value).replace(/[^\d-]/g, '').slice(0, 20);
const sameDate = (a: Date, b: Date): boolean =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const phoneDigits = (value: string): string => normalizePhoneText(value).replace(/\D/g, '');
export const PublicVisitApplicationPage: React.FC = () => {
const [form, setForm] = useState({
visitorName: '',
company: '',
contact: '',
email: '',
vehicleNo: '',
serverRooms: [] as string[],
room: '',
roomEtc: '',
purpose: '',
purposeEtc: '',
workName: '',
controlName: '',
controlTeam: '',
controlContact: '',
watcher1Name: DEFAULT_WATCHER1.name,
watcher1Team: DEFAULT_WATCHER1.team,
watcher1Contact: DEFAULT_WATCHER1.contact,
watcher2Name: '',
watcher2Team: '',
watcher2Contact: '',
visitFrom: '',
visitTo: '',
});
const [purposeCodes, setPurposeCodes] = useState<PurposeCode[]>(FALLBACK_PURPOSE_CODES);
const [verificationMethod, setVerificationMethod] = useState<VisitorVerificationMethod>('PHONE');
const [verificationId, setVerificationId] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [verificationToken, setVerificationToken] = useState('');
const [verificationHint, setVerificationHint] = useState('');
const [consent, setConsent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [busy, setBusy] = useState(false);
const contactInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
listPurposeCodes().then(setPurposeCodes).catch(() => setPurposeCodes(FALLBACK_PURPOSE_CODES));
}, []);
const selectedPurpose = purposeCodes.find((p) => p.code === form.purpose);
const update = (k: keyof typeof form) => (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => setForm({ ...form, [k]: e.target.value });
const updateContact = (k: 'contact' | 'controlContact' | 'watcher2Contact') => (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const next = sanitizePhoneInput(e.target.value);
setForm({ ...form, [k]: next });
if (k === 'contact' && verificationMethod === 'PHONE') {
setVerificationToken('');
setVerificationId('');
}
};
const clearContactVerification = () => {
if (verificationMethod === 'PHONE') {
setVerificationToken('');
setVerificationId('');
}
};
const currentVisitorContact = (): string => sanitizePhoneInput(contactInputRef.current?.value ?? form.contact);
const formatVisitorContactInput = () => {
if (!contactInputRef.current) return;
contactInputRef.current.value = formatPhoneLike(contactInputRef.current.value);
};
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({
...f,
serverRooms: e.target.checked ? [...f.serverRooms, room] : f.serverRooms.filter((r) => r !== room),
}));
const validate = (): string | null => {
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
if (!form.company.trim()) return '회사/소속을 입력하세요.';
if (!currentVisitorContact()) return '연락처를 입력하세요.';
if (form.email.trim() && !EMAIL_RE.test(form.email.trim())) return '이메일 형식을 확인하세요.';
if (verificationMethod === 'EMAIL' && !form.email.trim()) return '이메일 인증을 선택한 경우 이메일이 필요합니다.';
if (!verificationToken) return '본인확인을 완료하세요.';
if (form.serverRooms.length === 0 && !form.room) return '방문 구역을 선택하세요.';
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 구역을 입력하세요.';
if (!form.purpose) return '방문 목적을 선택하세요.';
if (selectedPurpose?.customAllowed && !form.purposeEtc.trim()) return '기타 방문 목적을 입력하세요.';
if (!form.controlName.trim()) return '출입통제담당자 이름을 입력하세요.';
if (!form.controlTeam.trim()) return '출입통제담당자 소속을 선택하세요.';
if (!form.visitFrom) return '방문 일시를 선택하세요.';
if (!form.visitTo) return '퇴실 예정일시를 선택하세요.';
const from = new Date(form.visitFrom);
const to = new Date(form.visitTo);
if (from > to) return '퇴실 예정일시는 방문 일시 이후여야 합니다.';
if (!sameDate(from, to)) return '퇴실 예정일이 다음 날이면 날짜별로 나누어 신청하세요.';
if (!consent) return '개인정보 수집 및 이용에 동의해야 신청할 수 있습니다.';
return null;
};
const requestVerification = async () => {
setError(null);
setVerificationToken('');
const currentContact = currentVisitorContact();
formatVisitorContactInput();
const target = verificationMethod === 'PHONE' ? phoneDigits(currentContact) : form.email.trim();
if (!target) {
setError(verificationMethod === 'PHONE' ? '휴대폰번호를 입력하세요.' : '이메일을 입력하세요.');
return;
}
if (verificationMethod === 'PHONE' && target.length < 10) {
setError(`휴대폰번호를 확인하세요. 현재 숫자 ${target.length}자리입니다.`);
return;
}
if (verificationMethod === 'EMAIL' && !EMAIL_RE.test(form.email.trim())) {
setError('이메일 형식을 확인하세요.');
return;
}
setBusy(true);
try {
const result = await startVisitorVerification(verificationMethod, target);
setVerificationId(result.verificationId);
setVerificationHint(result.devCode ? `리허설 인증번호: ${result.devCode}` : '인증번호를 발송했습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : '인증번호 요청에 실패했습니다.');
} finally {
setBusy(false);
}
};
const confirmVerification = async () => {
setError(null);
if (!verificationId) {
setError('인증번호를 먼저 요청하세요.');
return;
}
setBusy(true);
try {
const result = await confirmVisitorVerification(verificationId, verificationCode);
setVerificationToken(result.verificationToken);
setVerificationHint('본인확인이 완료되었습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : '인증번호 확인에 실패했습니다.');
} finally {
setBusy(false);
}
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const message = validate();
if (message) {
setError(message);
return;
}
setBusy(true);
setError(null);
try {
await createVisitorApplication({
visitorName: form.visitorName.trim(),
company: form.company.trim(),
contact: formatPhoneLike(currentVisitorContact()),
email: form.email.trim() || undefined,
vehicleNo: form.vehicleNo.trim() || undefined,
zoneName: form.serverRooms[0] || undefined,
roomZone: form.room === '기타' ? form.roomEtc.trim() : form.room || undefined,
purpose: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : selectedPurpose?.name ?? form.purpose,
purposeCode: selectedPurpose?.code ?? form.purpose,
purposeDetail: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : undefined,
workName: form.workName.trim() || undefined,
controlName: form.controlName.trim() || undefined,
controlTeam: form.controlTeam.trim() || undefined,
controlContact: formatPhoneLike(form.controlContact.trim()) || undefined,
watcher1Name: form.watcher1Name,
watcher1Team: form.watcher1Team,
watcher1Contact: form.watcher1Contact,
watcher2Name: form.watcher2Name.trim() || undefined,
watcher2Team: form.watcher2Team.trim() || undefined,
watcher2Contact: formatPhoneLike(form.watcher2Contact.trim()) || undefined,
visitFrom: form.visitFrom,
visitTo: form.visitTo,
verificationMethod,
verificationToken,
});
setDone(true);
} catch (err) {
setError(err instanceof Error ? err.message : '방문신청 접수에 실패했습니다.');
} finally {
setBusy(false);
}
};
if (done) {
return (
<main className="public-visit-shell">
<section className="public-visit-card public-visit-complete">
<img src={bokBadge} alt="" className="public-visit-badge" />
<h1> .</h1>
<p> .</p>
</section>
</main>
);
}
return (
<main className="public-visit-shell">
<form className="public-visit-card visit-form" onSubmit={onSubmit} noValidate>
<div className="public-visit-head">
<img src={bokBadge} alt="" className="public-visit-badge" />
<div>
<h1></h1>
<p>IT센터 </p>
</div>
</div>
<fieldset className="form-group span-2">
<legend></legend>
<div className="group-grid">
<label className="field"><span> <b className="required">*</b></span><input value={form.visitorName} onChange={update('visitorName')} autoFocus /></label>
<label className="field"><span>/ <b className="required">*</b></span><input value={form.company} onChange={update('company')} /></label>
<label className="field">
<span> <b className="required">*</b></span>
<input
ref={contactInputRef}
className="phone-input"
type="text"
inputMode="numeric"
autoComplete="off"
defaultValue={form.contact}
onChange={clearContactVerification}
onBlur={(e) => { e.currentTarget.value = formatPhoneLike(e.currentTarget.value); }}
placeholder="010-0000-0000"
/>
</label>
<label className="field"><span></span><input type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" /></label>
<label className="field"><span></span><input value={form.vehicleNo} onChange={update('vehicleNo')} /></label>
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="public-verification-grid">
<label className="radio-inline"><input type="radio" checked={verificationMethod === 'PHONE'} onChange={() => setVerificationMethod('PHONE')} /> </label>
<label className="radio-inline"><input type="radio" checked={verificationMethod === 'EMAIL'} onChange={() => setVerificationMethod('EMAIL')} /> </label>
<button type="button" className="btn-ghost" onClick={requestVerification} disabled={busy || Boolean(verificationToken)}> </button>
<input value={verificationCode} onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="인증번호 6자리" />
<button type="button" className="btn-ghost" onClick={confirmVerification} disabled={busy || Boolean(verificationToken)}></button>
{verificationHint && <span className="verification-hint">{verificationHint}</span>}
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend> </legend>
<div className="group-grid">
<div className="field">
<span> </span>
<div className="checkbox-row">
{SERVER_ROOM_OPTIONS.map((z) => (
<label key={z} className="checkbox-inline">
<input type="checkbox" checked={form.serverRooms.includes(z)} onChange={toggleServerRoom(z)} />
<span>{z}</span>
</label>
))}
</div>
</div>
<label className="field"><span> </span><select value={form.room} onChange={update('room')}><option value=""></option>{ROOM_OPTIONS.map((r) => <option key={r} value={r}>{r}</option>)}</select></label>
{form.room === '기타' && <label className="field"><span> <b className="required">*</b></span><input value={form.roomEtc} onChange={update('roomEtc')} /></label>}
<label className="field"><span> <b className="required">*</b></span><select value={form.purpose} onChange={update('purpose')}><option value=""></option>{purposeCodes.map((p) => <option key={p.code} value={p.code}>{p.name}</option>)}</select></label>
{selectedPurpose?.customAllowed && <label className="field"><span> <b className="required">*</b></span><input value={form.purposeEtc} onChange={update('purposeEtc')} /></label>}
<label className="field"><span></span><input value={form.workName} onChange={update('workName')} /></label>
<label className="field"><span> <b className="required">*</b></span><DateTimePicker value={form.visitFrom} onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))} placeholder="방문 일시 선택" /></label>
<label className="field"><span> <b className="required">*</b></span><DateTimePicker value={form.visitTo} onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))} placeholder="퇴실 예정일시 선택" /></label>
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend>()</legend>
<div className="group-grid">
<label className="field"><span> <b className="required">*</b></span><input value={form.controlName} onChange={update('controlName')} /></label>
<label className="field">
<span> <b className="required">*</b></span>
<select value={form.controlTeam} onChange={update('controlTeam')}>
<option value=""></option>
{AFFILIATION_OPTIONS.map((team) => <option key={team} value={team}>{team}</option>)}
</select>
</label>
<label className="field"><span></span><input className="phone-input" type="text" inputMode="tel" value={form.controlContact} onChange={updateContact('controlContact')} placeholder="내선번호/휴대폰번호" /></label>
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="subsection-title">1 <em className="hint-inline">: IT센터 </em></div>
<div className="group-grid">
<label className="field"><span></span><input value={form.watcher1Name} readOnly /></label>
<label className="field"><span></span><input value={form.watcher1Team} readOnly /></label>
<label className="field"><span></span><input className="phone-input" value={form.watcher1Contact} readOnly /></label>
</div>
<div className="subsection-title">2 <em className="hint-inline">: </em></div>
<div className="group-grid">
<label className="field"><span></span><input value={form.watcher2Name} onChange={update('watcher2Name')} /></label>
<label className="field">
<span></span>
<select value={form.watcher2Team} onChange={update('watcher2Team')}>
<option value=""></option>
{AFFILIATION_OPTIONS.map((team) => <option key={team} value={team}>{team}</option>)}
</select>
</label>
<label className="field"><span></span><input className="phone-input" type="text" inputMode="tel" value={form.watcher2Contact} onChange={updateContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" /></label>
</div>
</fieldset>
<div className="span-2 consent-box">
<label className="consent-label">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
<span>
<b>[ · <span className="required">*</span>]</b><br />
항목: 이름, , , <br />
목적: IT센터 <br />
<span className="consent-retention-warning"> 기간: 출입 </span>
</span>
</label>
</div>
<div className="form-actions span-2">
{error && <span className="form-error" role="alert">{error}</span>}
<button type="submit" className="btn-primary" disabled={busy}>{busy ? '처리 중...' : '방문신청'}</button>
</div>
</form>
</main>
);
};

View File

@@ -1,18 +1,72 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { reportDownloadUrl } from '../api'; import { listReportVisits, listTodayAccess, reportDownloadUrl } from '../api';
import { DatePickerField } from '../components/DatePickerField';
import { AccessRecord, ReportVisitView } from '../types';
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
const pad = (n: number) => String(n).padStart(2, '0');
/** Today in YYYY-MM-DD (local). */
function todayISO(): string { function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
function monthAgoISO(): string {
const d = new Date(); const d = new Date();
d.setMonth(d.getMonth() - 1); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return d.toISOString().slice(0, 10); }
/** First day of the current month in YYYY-MM-DD (local). */
function firstOfMonthISO(): string {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`;
} }
export const ReportPage: React.FC = () => { export const ReportPage: React.FC = () => {
const [from, setFrom] = useState(monthAgoISO()); const [from, setFrom] = useState(firstOfMonthISO());
const [to, setTo] = useState(todayISO()); const [to, setTo] = useState(todayISO());
const [items, setItems] = useState<ReportVisitView[]>([]);
const [accessRecords, setAccessRecords] = useState<AccessRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = () => {
setLoading(true);
setError(null);
Promise.all([
listReportVisits(from, to),
listTodayAccess().catch(() => [] as AccessRecord[]),
])
.then(([visits, records]) => {
setItems(visits);
setAccessRecords(records);
})
.catch((e) => {
setItems([]);
setAccessRecords([]);
setError(e instanceof Error ? e.message : '보고서 조회에 실패했습니다.');
})
.finally(() => setLoading(false));
};
const accessById = new Map(accessRecords.map((r) => [r.visitRequestId, r]));
const statusBadge = (r: ReportVisitView) => {
const access = accessById.get(r.id);
if (r.reportStatusLabel) {
return {
className: r.reportStatusLabel === '재실중' ? 'green' : r.reportStatusLabel === '퇴실' ? 'gray' : STATUS_CLASS[r.status],
label: r.reportStatusLabel,
};
}
if (access?.inside) {
return { className: 'green', label: '재실중' };
}
if (access?.checkOutAt) {
return { className: 'gray', label: '퇴실' };
}
return { className: STATUS_CLASS[r.status], label: STATUS_LABEL[r.status] };
};
useEffect(() => {
load();
// Load the default month range once; explicit 조회 handles later date changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onDownload = () => { const onDownload = () => {
// Trigger a download in-place via a temporary anchor. Using window.open left // Trigger a download in-place via a temporary anchor. Using window.open left
@@ -29,22 +83,74 @@ export const ReportPage: React.FC = () => {
return ( return (
<div> <div>
<div className="page-head"><h2> </h2></div> <div className="page-head"><h2> </h2></div>
<div className="card"> <div className="card">
<p className="muted"> (.xlsx) . ( )</p>
<div className="report-row"> <div className="report-row">
<label className="field"> <label className="field">
<span></span> <span></span>
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} /> <DatePickerField value={from} onChange={setFrom} />
</label> </label>
<label className="field"> <label className="field">
<span></span> <span></span>
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} /> <DatePickerField value={to} onChange={setTo} />
</label> </label>
<button className="btn-ghost" onClick={load} disabled={loading}></button>
<button className="btn-primary" onClick={onDownload}> </button> <button className="btn-primary" onClick={onDownload}> </button>
</div> </div>
</div> </div>
{error && <div className="alert alert-error">{error}</div>}
<div className="card">
<div className="page-head">
<h3> ({items.length})</h3>
</div>
{loading ? (
<p className="muted"> ...</p>
) : items.length === 0 ? (
<p className="muted"> .</p>
) : (
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((r) => (
<tr key={r.id}>
<td>{r.visitorName}</td>
<td>{r.company || '-'}</td>
<td>{r.contact || '-'}</td>
<td>{r.zoneName || '-'}</td>
<td>{r.hostName}</td>
<td>{r.purpose || '-'}</td>
<td>{r.workName || '-'}</td>
<td>{formatDateTime(r.visitFrom)}</td>
<td>{formatDateTime(r.visitTo)}</td>
<td>
{(() => {
const badge = statusBadge(r);
return <span className={`badge badge-${badge.className}`}>{badge.label}</span>;
})()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -1,53 +1,179 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { createVisitRequest } from '../api'; import { createVisitRequest, getWatcher1Settings, listPurposeCodes, listVisitorApplications } from '../api';
import { DateTimePicker } from '../components/DateTimePicker'; import { DateTimePicker } from '../components/DateTimePicker';
import { useAuth } from '../auth/AuthContext';
import { PurposeCode, VisitorApplicationView, Watcher1Settings } from '../types';
const ZONE_OPTIONS = [ // 코드 시트 목록을 콤보/체크박스에 반영.
'4층전산실', '5층전산실', '3층사무실', '4층사무실', '5층사무실', // 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
'종합상황실', 'BMT실', '의사결정실', '기타', const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
// 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력.
const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타'];
const FALLBACK_PURPOSE_CODES: PurposeCode[] = [
{ id: 1, code: 'INSPECTION', name: '점검', sortOrder: 10, active: true, customAllowed: false },
{ id: 2, code: 'WORK', name: '작업', sortOrder: 20, active: true, customAllowed: false },
{ id: 3, code: 'TOUR', name: '견학', sortOrder: 30, active: true, customAllowed: false },
{ id: 4, code: 'MEETING', name: '회의', sortOrder: 40, active: true, customAllowed: false },
{ id: 5, code: 'CLEANING', name: '청소', sortOrder: 50, active: true, customAllowed: false },
{ id: 6, code: 'ETC', name: '기타', sortOrder: 900, active: true, customAllowed: true },
]; ];
const PURPOSE_OPTIONS = ['유지점검', '장비반입', '업무협의', '공사', '기타']; // 소속(코드 시트) — 내부 팀. 담당자·감시자 팀 콤보에 사용.
const AFFILIATION_OPTIONS = [
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
'금융IT인프라팀', '정보인프라팀', 'AI플랫폼팀', '보안운영팀', '보안관제반',
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
];
// 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지).
const FALLBACK_WATCHER1: Watcher1Settings = { name: '류관순', team: 'IT전략국', contact: '313' };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const NEXT_DAY_EXIT_MESSAGE = `퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다
예컨대, 2026.07.16 18:00~2026.07.17 04:00 이라면, 아래와 같이 2건으로 등록해야 합니다.
1건) 2026.07.16 18:00~2026.07.16 24:00
2건) 2026.07.17 00:00~2026.07.17 04:00`;
const EXIT_BEFORE_ENTRY_MESSAGE = '퇴장일시는 출입일시 이후로 입력해야 합니다.';
const shouldShowValidationAlert = (message: string): boolean =>
message === NEXT_DAY_EXIT_MESSAGE || message === EXIT_BEFORE_ENTRY_MESSAGE;
const isLaterCalendarDate = (later: Date, earlier: Date): boolean => {
const laterDay = new Date(later.getFullYear(), later.getMonth(), later.getDate());
const earlierDay = new Date(earlier.getFullYear(), earlier.getMonth(), earlier.getDate());
return laterDay > earlierDay;
};
const formatPhoneLike = (value: string): string => {
const compact = value.replace(/\s+/g, '');
const digits = compact.replace(/\D/g, '');
if (!/^[\d\s-]*$/.test(value)) return value;
if (digits.length === 11) {
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
}
if (digits.length === 10) {
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
}
return compact;
};
const sanitizePhoneInput = (value: string): string =>
value.replace(/\s+/g, '').replace(/[^\d-]/g, '').slice(0, 20);
export const VisitRequestFormPage: React.FC = () => { export const VisitRequestFormPage: React.FC = () => {
const [form, setForm] = useState({ const [form, setForm] = useState({
sourceApplicationId: undefined as number | undefined,
visitorName: '', visitorName: '',
company: '', company: '',
contact: '', contact: '',
email: '', email: '',
vehicleNo: '', vehicleNo: '',
zone: '', serverRooms: [] as string[],
zoneEtc: '', room: '',
roomEtc: '',
purpose: '', purpose: '',
purposeEtc: '', purposeEtc: '',
workName: '',
watcher2Name: '',
watcher2Team: '',
watcher2Contact: '',
visitFrom: '', visitFrom: '',
visitTo: '', visitTo: '',
}); });
const [purposeCodes, setPurposeCodes] = useState<PurposeCode[]>(FALLBACK_PURPOSE_CODES);
const [watcher1, setWatcher1] = useState<Watcher1Settings>(FALLBACK_WATCHER1);
const [consent, setConsent] = useState(false); const [consent, setConsent] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importQuery, setImportQuery] = useState('');
const [importCandidates, setImportCandidates] = useState<VisitorApplicationView[]>([]);
const [importLoading, setImportLoading] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
useEffect(() => {
listPurposeCodes().then(setPurposeCodes).catch(() => setPurposeCodes(FALLBACK_PURPOSE_CODES));
getWatcher1Settings().then(setWatcher1).catch(() => setWatcher1(FALLBACK_WATCHER1));
}, []);
const selectedPurpose = purposeCodes.find((p) => p.code === form.purpose);
const update = (k: keyof typeof form) => ( const update = (k: keyof typeof form) => (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>, e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => setForm({ ...form, [k]: e.target.value }); ) => setForm({ ...form, [k]: e.target.value });
const updateFormattedContact = (k: 'contact' | 'watcher2Contact') => (
e: React.ChangeEvent<HTMLInputElement>,
) => setForm({ ...form, [k]: sanitizePhoneInput(e.target.value) });
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({
...f,
serverRooms: e.target.checked
? [...f.serverRooms, room]
: f.serverRooms.filter((r) => r !== room),
}));
const loadVisitorApplications = async () => {
setImportLoading(true);
setError(null);
try {
setImportCandidates(await listVisitorApplications(importQuery.trim() || undefined));
} catch (err) {
setError(err instanceof Error ? err.message : '방문신청 목록을 불러오지 못했습니다.');
} finally {
setImportLoading(false);
}
};
const applyVisitorApplication = (item: VisitorApplicationView) => {
const serverRooms = item.zoneName && SERVER_ROOM_OPTIONS.includes(item.zoneName) ? [item.zoneName] : [];
const etcRoom = ROOM_OPTIONS[ROOM_OPTIONS.length - 1];
const roomValue = item.roomZone && ROOM_OPTIONS.includes(item.roomZone) ? item.roomZone : item.roomZone ? etcRoom : '';
setForm((f) => ({
...f,
sourceApplicationId: item.id,
visitorName: item.visitorName,
company: item.company ?? '',
contact: item.contact ?? '',
email: item.email ?? '',
vehicleNo: item.vehicleNo ?? '',
serverRooms,
room: roomValue,
roomEtc: roomValue === etcRoom ? item.roomZone ?? '' : '',
purpose: item.purposeCode ?? '',
purposeEtc: item.purposeDetail ?? '',
workName: item.workName ?? '',
watcher2Name: item.watcher2Name ?? '',
watcher2Team: item.watcher2Team ?? '',
watcher2Contact: item.watcher2Contact ?? '',
visitFrom: item.visitFrom.slice(0, 16),
visitTo: item.visitTo.slice(0, 16),
}));
setConsent(true);
setShowImport(false);
setError(null);
};
/** Returns the first Korean validation error, or null if valid. */ /** Returns the first Korean validation error, or null if valid. */
const validate = (): string | null => { const validate = (): string | null => {
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.'; if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
if (!form.contact.trim()) return '방문자 연락처를 입력하세요.'; if (!form.contact.trim()) return '방문자 연락처를 입력하세요.';
if (form.email.trim() && !EMAIL_RE.test(form.email.trim())) if (form.email.trim() && !EMAIL_RE.test(form.email.trim()))
return '이메일 형식이 올바르지 않습니다. (예: name@example.com)'; return '이메일 형식이 올바르지 않습니다. (예: name@example.com)';
if (!form.zone) return '출입 구역을 선택하세요.'; if (form.serverRooms.length === 0 && !form.room)
if (form.zone === '기타' && !form.zoneEtc.trim()) return '기타 출입 구역을 입력하세요.'; return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.';
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.';
if (!form.purpose) return '출입 목적을 선택하세요.'; if (!form.purpose) return '출입 목적을 선택하세요.';
if (form.purpose === '기타' && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.'; if (selectedPurpose?.customAllowed && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.';
if (!form.visitFrom) return '출입 일시를 입력하세요.'; if (!form.visitFrom) return '출입 일시를 입력하세요.';
if (!form.visitTo) return '퇴실 일시를 입력하세요.'; if (!form.visitTo) return '퇴실 일시를 입력하세요.';
if (new Date(form.visitTo) < new Date(form.visitFrom)) const visitFrom = new Date(form.visitFrom);
return '퇴실 일시는 출입 일시보다 빠를 수 없습니다.'; const visitTo = new Date(form.visitTo);
if (visitFrom > visitTo) return EXIT_BEFORE_ENTRY_MESSAGE;
if (isLaterCalendarDate(visitTo, visitFrom)) return NEXT_DAY_EXIT_MESSAGE;
if (!consent) return '개인정보 사용 및 저장에 동의해야 신청할 수 있습니다.'; if (!consent) return '개인정보 사용 및 저장에 동의해야 신청할 수 있습니다.';
return null; return null;
}; };
@@ -57,19 +183,33 @@ export const VisitRequestFormPage: React.FC = () => {
const message = validate(); const message = validate();
if (message) { if (message) {
setError(message); setError(message);
if (shouldShowValidationAlert(message)) {
window.alert(message);
}
return; return;
} }
setError(null); setError(null);
setBusy(true); setBusy(true);
try { try {
const roomZone = form.room === '기타' ? form.roomEtc.trim() : form.room;
await createVisitRequest({ await createVisitRequest({
sourceApplicationId: form.sourceApplicationId,
visitorName: form.visitorName.trim(), visitorName: form.visitorName.trim(),
company: form.company.trim() || undefined, company: form.company.trim() || undefined,
contact: form.contact.trim(), contact: formatPhoneLike(form.contact.trim()),
email: form.email.trim() || undefined, email: form.email.trim() || undefined,
vehicleNo: form.vehicleNo.trim() || undefined, vehicleNo: form.vehicleNo.trim() || undefined,
zoneName: form.zone === '기타' ? form.zoneEtc.trim() : form.zone, serverRooms: form.serverRooms,
purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose, roomZone: roomZone || undefined,
purpose: selectedPurpose?.customAllowed
? form.purposeEtc.trim()
: selectedPurpose?.name ?? form.purpose,
purposeCode: selectedPurpose?.code ?? form.purpose,
purposeDetail: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : undefined,
workName: form.workName.trim() || undefined,
watcher2Name: form.watcher2Name.trim() || undefined,
watcher2Team: form.watcher2Team.trim() || undefined,
watcher2Contact: formatPhoneLike(form.watcher2Contact.trim()) || undefined,
visitFrom: form.visitFrom, visitFrom: form.visitFrom,
visitTo: form.visitTo, visitTo: form.visitTo,
}); });
@@ -83,92 +223,215 @@ export const VisitRequestFormPage: React.FC = () => {
return ( return (
<div> <div>
<div className="page-head"><h2> </h2></div> <div className="page-head">
{error && <div className="alert alert-error">{error}</div>} <h2> </h2>
<div className="head-actions">
{form.sourceApplicationId && <span className="badge badge-blue"> #{form.sourceApplicationId}</span>}
<button type="button" className="btn-ghost" onClick={() => setShowImport((v) => !v)}>
</button>
</div>
</div>
{showImport && (
<section className="card import-panel">
<div className="inline-form">
<input
value={importQuery}
onChange={(e) => setImportQuery(e.target.value)}
placeholder="이름, 회사, 연락처, 이메일 검색"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void loadVisitorApplications();
}
}}
/>
<button type="button" className="btn-primary" onClick={loadVisitorApplications} disabled={importLoading}>
{importLoading ? '조회 중' : '조회'}
</button>
</div>
<div className="import-list">
{importCandidates.map((item) => (
<button key={item.id} type="button" className="import-item" onClick={() => applyVisitorApplication(item)}>
<strong>{item.visitorName}</strong>
<span>{item.company ?? '-'} / {item.contact ?? item.email ?? '-'}</span>
<span>{item.zoneName ?? item.roomZone ?? '-'} / {new Date(item.visitFrom).toLocaleString('ko-KR')}</span>
</button>
))}
{!importLoading && importCandidates.length === 0 && <p className="muted"> .</p>}
</div>
</section>
)}
{/* noValidate: use our Korean messages instead of the browser's native popups */} {/* noValidate: use our Korean messages instead of the browser's native popups */}
<form className="card form-grid" onSubmit={onSubmit} noValidate> <form className="card form-grid visit-form" onSubmit={onSubmit} noValidate>
<label className="field"> <fieldset className="form-group span-2">
<span> *</span> <legend></legend>
<input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus /> <div className="group-grid">
</label> <label className="field">
<label className="field"> <span> <b className="required">*</b></span>
<span>/</span> <input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus />
<input value={form.company} onChange={update('company')} /> </label>
</label> <label className="field">
<label className="field"> <span>/</span>
<span> * <em className="hint-inline">: 010-0000-0000 .</em></span> <input value={form.company} onChange={update('company')} />
<input type="tel" value={form.contact} onChange={update('contact')} placeholder="010-0000-0000" /> </label>
</label> <label className="field">
<label className="field"> <span> <b className="required">*</b></span>
<span></span> <input className="phone-input" type="text" inputMode="tel" autoComplete="off" value={form.contact} onChange={updateFormattedContact('contact')} placeholder="010-0000-0000" />
<input className="ime-en" type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" /> </label>
</label> <label className="field">
<span></span>
<input className="ime-en" type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" />
</label>
<label className="field span-2"> <div className="field">
<span> <em className="hint-inline">: 5 .</em></span> <span> </span>
<input className="ime-ko" value={form.vehicleNo} onChange={update('vehicleNo')} /> <div className="checkbox-row">
</label> {SERVER_ROOM_OPTIONS.map((z) => (
<label key={z} className="checkbox-inline">
<input
type="checkbox"
checked={form.serverRooms.includes(z)}
onChange={toggleServerRoom(z)}
/>
<span>{z}</span>
</label>
))}
</div>
</div>
<label className="field"> <label className="field">
<span> *</span> <span> </span>
<select value={form.zone} onChange={update('zone')}> <select value={form.room} onChange={update('room')}>
<option value=""></option> <option value=""></option>
{ZONE_OPTIONS.map((z) => <option key={z} value={z}>{z}</option>)} {ROOM_OPTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select> </select>
</label> </label>
{form.zone === '기타' ? ( {form.room === '기타' ? (
<label className="field"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<input value={form.zoneEtc} onChange={update('zoneEtc')} placeholder="출입 구역을 입력하세요" /> <input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" />
</label> </label>
) : <div />} ) : null}
<label className="field"> <label className="field">
<span> *</span> <span></span>
<select value={form.purpose} onChange={update('purpose')}> <input className="ime-ko" value={form.vehicleNo} onChange={update('vehicleNo')} placeholder="차량번호가 5부제에 해당될 경우 출입이 제한됩니다." />
<option value=""></option> </label>
{PURPOSE_OPTIONS.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
{form.purpose === '기타' ? (
<label className="field">
<span> *</span>
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
</label>
) : <div />}
<label className="field"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<DateTimePicker <select value={form.purpose} onChange={update('purpose')}>
value={form.visitFrom} <option value=""></option>
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))} {purposeCodes.map((p) => <option key={p.code} value={p.code}>{p.name}</option>)}
placeholder="출입 일시 선택" </select>
/> </label>
</label> {selectedPurpose?.customAllowed ? (
<label className="field"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<DateTimePicker <input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
value={form.visitTo} </label>
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))} ) : null}
placeholder="퇴실 일시 선택"
/> <label className="field">
</label> <span></span>
<input className="ime-ko" value={form.workName} onChange={update('workName')} placeholder="작업 내용을 구체적으로 입력하세요" />
</label>
<label className="field">
<span> <b className="required">*</b></span>
<DateTimePicker
value={form.visitFrom}
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))}
placeholder="출입 일시 선택"
/>
</label>
<label className="field">
<span> <b className="required">*</b></span>
<DateTimePicker
value={form.visitTo}
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))}
placeholder="퇴실 예정일시 선택"
/>
</label>
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="group-grid">
<label className="field">
<span></span>
<input value={user?.fullName ?? ''} readOnly />
</label>
<label className="field">
<span></span>
<input value={user?.department ?? ''} readOnly />
</label>
<label className="field">
<span></span>
<input className="phone-input" value="" placeholder="내선번호/휴대폰번호" readOnly />
</label>
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="subsection-title">1 <em className="hint-inline">: IT센터 ( )</em></div>
<div className="group-grid">
<label className="field">
<span></span>
<input value={watcher1.name} readOnly />
</label>
<label className="field">
<span></span>
<input value={watcher1.team} readOnly />
</label>
<label className="field">
<span></span>
<input className="phone-input" value={watcher1.contact} readOnly />
</label>
</div>
<div className="subsection-title">2 <em className="hint-inline">: </em></div>
<div className="group-grid">
<label className="field">
<span></span>
<input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} />
</label>
<label className="field">
<span></span>
<select value={form.watcher2Team} onChange={update('watcher2Team')}>
<option value=""></option>
{AFFILIATION_OPTIONS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</label>
<label className="field">
<span></span>
<input className="phone-input" type="text" inputMode="tel" value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" />
</label>
</div>
</fieldset>
<div className="span-2 consent-box"> <div className="span-2 consent-box">
<label className="consent-label"> <label className="consent-label">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} /> <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
<span> <span>
<b>[ · ]</b><br /> <b>[ · ]</b><br />
· 항목: 이름, , , <br /> · 항목: 이름, , , <br />
· · 목적: IT센터 <br /> · · 목적: IT센터 <br />
· · 기간: 수집일로부터 1 ( )<br /> <span className="consent-retention-warning">
· · , . · · 기간: 전산실 , , ,
</span><br />
· · ,
</span> </span>
</label> </label>
</div> </div>
<div className="form-actions span-2"> <div className="form-actions span-2">
{error && <span className="form-error" role="alert">{error}</span>}
<button type="button" className="btn-ghost" onClick={() => navigate(-1)}></button> <button type="button" className="btn-ghost" onClick={() => navigate(-1)}></button>
<button type="submit" className="btn-primary" disabled={busy}> <button type="submit" className="btn-primary" disabled={busy}>
{busy ? '신청 중…' : '출입 신청'} {busy ? '신청 중…' : '출입 신청'}

View File

@@ -1,28 +1,60 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { cancelVisitRequest, listVisitRequests, uploadVisitRequests } from '../api'; import {
import { VisitRequestView } from '../types'; cancelVisitRequest,
deleteVisitRequest,
listTodayAccess,
listVisitRequests,
uploadVisitRequests,
visitRequestTemplateUrl,
} from '../api';
import { AccessRecord, VisitRequestView } from '../types';
import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status'; import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status';
import { Dialog } from '../components/Dialog'; import { Dialog } from '../components/Dialog';
import { useAuth } from '../auth/AuthContext';
export const VisitRequestListPage: React.FC = () => { export const VisitRequestListPage: React.FC = () => {
const [items, setItems] = useState<VisitRequestView[]>([]); const [items, setItems] = useState<VisitRequestView[]>([]);
const [accessRecords, setAccessRecords] = useState<AccessRecord[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [cancelingId, setCancelingId] = useState<number | null>(null); const [cancelingId, setCancelingId] = useState<number | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth();
const load = () => { const load = () => {
setLoading(true); setLoading(true);
listVisitRequests() Promise.all([
.then(setItems) listVisitRequests(),
listTodayAccess().catch(() => [] as AccessRecord[]),
])
.then(([requests, records]) => {
setItems(requests);
setAccessRecords(records);
})
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패')) .catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
useEffect(load, []); useEffect(() => {
load();
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, []);
const accessById = new Map(accessRecords.map((r) => [r.visitRequestId, r]));
const statusBadge = (r: VisitRequestView) => {
const access = accessById.get(r.id);
if (access?.inside) {
return { className: 'green', label: '재실중' };
}
if (access?.checkOutAt) {
return { className: 'gray', label: '퇴실' };
}
return { className: STATUS_CLASS[r.status], label: STATUS_LABEL[r.status] };
};
const confirmCancel = async () => { const confirmCancel = async () => {
const id = cancelingId; const id = cancelingId;
@@ -36,6 +68,19 @@ export const VisitRequestListPage: React.FC = () => {
} }
}; };
const deleteCancelled = async (target: VisitRequestView) => {
if (target.status !== 'CANCELLED') return;
setError(null);
setNotice(null);
try {
await deleteVisitRequest(target.id);
setNotice(`${target.visitorName} 신청을 삭제했습니다.`);
load();
} catch (e) {
setError(e instanceof Error ? e.message : '삭제 실패');
}
};
const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -59,7 +104,8 @@ export const VisitRequestListPage: React.FC = () => {
<div className="page-head"> <div className="page-head">
<h2> </h2> <h2> </h2>
<div className="head-actions"> <div className="head-actions">
<button className="btn-ghost" onClick={() => fileRef.current?.click()}> </button> <a className="btn-ghost" href={visitRequestTemplateUrl()}> </a>
<button className="btn-ghost" onClick={() => fileRef.current?.click()}> </button>
<input ref={fileRef} type="file" accept=".xlsx" hidden onChange={onUpload} /> <input ref={fileRef} type="file" accept=".xlsx" hidden onChange={onUpload} />
<Link className="btn-primary" to="/visit-requests/new">+ </Link> <Link className="btn-primary" to="/visit-requests/new">+ </Link>
</div> </div>
@@ -77,7 +123,7 @@ export const VisitRequestListPage: React.FC = () => {
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th></th><th></th><th></th><th></th> <th></th><th></th><th></th><th></th><th></th>
<th></th><th></th><th></th> <th></th><th></th><th></th>
</tr> </tr>
</thead> </thead>
@@ -88,8 +134,14 @@ export const VisitRequestListPage: React.FC = () => {
<td>{r.company || '-'}</td> <td>{r.company || '-'}</td>
<td>{r.zoneName || '-'}</td> <td>{r.zoneName || '-'}</td>
<td>{r.purpose || '-'}</td> <td>{r.purpose || '-'}</td>
<td>{r.workName || '-'}</td>
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td> <td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td> <td>
{(() => {
const badge = statusBadge(r);
return <span className={`badge badge-${badge.className}`}>{badge.label}</span>;
})()}
</td>
<td className="row-actions"> <td className="row-actions">
{r.status === 'APPROVED' && ( {r.status === 'APPROVED' && (
<button className="btn-link" onClick={() => navigate(`/badge/${r.id}`)}></button> <button className="btn-link" onClick={() => navigate(`/badge/${r.id}`)}></button>
@@ -97,6 +149,9 @@ export const VisitRequestListPage: React.FC = () => {
{(r.status === 'PENDING' || r.status === 'APPROVED') && ( {(r.status === 'PENDING' || r.status === 'APPROVED') && (
<button className="btn-link-danger" onClick={() => setCancelingId(r.id)}></button> <button className="btn-link-danger" onClick={() => setCancelingId(r.id)}></button>
)} )}
{hasRole('ADMIN') && r.status === 'CANCELLED' && (
<button className="btn-link-danger" onClick={() => deleteCancelled(r)}></button>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -115,6 +170,7 @@ export const VisitRequestListPage: React.FC = () => {
onCancel={() => setCancelingId(null)} onCancel={() => setCancelingId(null)}
/> />
)} )}
</div> </div>
); );
}; };

View File

@@ -76,7 +76,7 @@ a { color: inherit; text-decoration: none; }
padding: 2px 6px; border-radius: 6px; padding: 2px 6px; border-radius: 6px;
} }
.content { max-width: 1080px; margin: 0 auto; padding: 28px 24px; } .content { width: 100%; max-width: none; margin: 0; padding: 12px 18px; }
/* ===== Center (auth) ===== */ /* ===== Center (auth) ===== */
.center-screen { .center-screen {
@@ -108,30 +108,52 @@ a { color: inherit; text-decoration: none; }
.card h3 { margin-top: 0; } .card h3 { margin-top: 0; }
.page-head { .page-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px; margin-bottom: 10px;
} }
.page-head h2 { margin: 0; } .page-head h2 { margin: 0; font-size: 22px; }
.head-actions { display: flex; gap: 8px; } .head-actions { display: flex; gap: 8px; }
/* ===== Fields / forms ===== */ /* ===== Fields / forms ===== */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; } .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field > span { font-size: 13px; font-weight: 600; color: #334155; } .field > span { font-size: 13px; font-weight: 600; color: #334155; }
.field input, .field select, .field textarea { .field input, .field select, .field textarea {
padding: 10px 12px; padding: 7px 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 6px;
font-size: 14px; font-size: 13px;
font-family: inherit;
letter-spacing: 0;
background: #fff; background: #fff;
} }
.field input[type="tel"],
.phone-input {
font-family: 'Malgun Gothic', 'Segoe UI', system-ui, sans-serif !important;
font-size: 13px !important;
font-weight: 400 !important;
font-stretch: normal !important;
font-variant-numeric: proportional-nums !important;
font-feature-settings: "tnum" 0 !important;
letter-spacing: 0 !important;
word-spacing: 0 !important;
direction: ltr !important;
unicode-bidi: plaintext !important;
text-align: left !important;
}
.field input:focus, .field select:focus { outline: 2px solid #bfdbfe; border-color: var(--primary); } .field input:focus, .field select:focus { outline: 2px solid #bfdbfe; border-color: var(--primary); }
/* react-datepicker: make the input fill the field like the native ones */ /* react-datepicker: make the input fill the field like the native ones */
.field .react-datepicker-wrapper { width: 100%; } .field .react-datepicker-wrapper { width: 100%; }
.field .react-datepicker__input-container input { width: 100%; box-sizing: border-box; } .field .react-datepicker__input-container input { width: 100%; box-sizing: border-box; }
/* clearer popup border + shadow, and a full-width [입력] footer button */ /* clearer popup border + shadow */
.acs-datepicker .react-datepicker { border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); } .acs-datepicker .react-datepicker { border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
.dt-actions { padding: 8px; border-top: 1px solid var(--border); } .dt-picker-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 8px 4px; }
.dt-confirm { width: 100%; } .dt-picker-title { font-weight: 700; color: var(--text); }
.dt-picker-header-actions { display: flex; align-items: center; gap: 4px; }
.dt-picker-nav,
.dt-picker-close { width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: #fff; color: var(--text); font-size: 18px; line-height: 1; cursor: pointer; }
.dt-picker-nav:disabled { opacity: 0.4; cursor: not-allowed; }
.dt-picker-close { color: #b42318; font-size: 22px; font-weight: 800; display: inline-flex; align-items: center; justify-content: center; }
.dt-picker-close:hover { background: #fee2e2; border-color: #fecaca; }
/* In-app modal dialog (replaces window.prompt/confirm) */ /* In-app modal dialog (replaces window.prompt/confirm) */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; } .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; }
@@ -140,6 +162,48 @@ a { color: inherit; text-decoration: none; }
.modal-message { margin: 0 0 12px; color: var(--muted); font-size: 14px; } .modal-message { margin: 0 0 12px; color: var(--muted); font-size: 14px; }
.modal-input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; resize: vertical; } .modal-input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; resize: vertical; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.detail-modal {
width: min(1120px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
}
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 12px; }
.detail-status { margin-bottom: 12px; }
.detail-form {
width: 100%;
min-width: 0;
}
.detail-form .form-group {
min-width: 0;
}
.detail-form .group-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.detail-form .field {
grid-template-columns: 104px minmax(0, 1fr);
}
.detail-form .field input {
width: 100%;
min-width: 0;
}
.detail-form .checkbox-row {
min-width: 0;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
}
.detail-item {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.detail-item span { color: var(--muted); font-size: 12px; font-weight: 700; }
.detail-item strong { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
/* Public entrance kiosk */ /* Public entrance kiosk */
.kiosk { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--bg, #f1f5f9); } .kiosk { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--bg, #f1f5f9); }
@@ -173,7 +237,63 @@ a { color: inherit; text-decoration: none; }
gap: 0 18px; gap: 0 18px;
} }
.form-grid .span-2 { grid-column: 1 / -1; } .form-grid .span-2 { grid-column: 1 / -1; }
.visit-form { gap: 8px; padding: 12px; margin-bottom: 0; }
.form-group {
border: 2px solid #94a3b8;
border-radius: 8px;
padding: 10px 12px 0;
margin: 0;
background: #fff;
}
.form-group > legend {
padding: 0 8px;
font-size: 14px;
font-weight: 800;
color: #0f172a;
}
.group-grid {
display: grid;
grid-template-columns: repeat(3, minmax(280px, 1fr));
gap: 0 14px;
}
.group-grid .span-2 { grid-column: 1 / -1; }
.visit-form .field {
display: grid;
grid-template-columns: 116px minmax(0, 1fr);
align-items: center;
column-gap: 8px;
margin-bottom: 8px;
}
.visit-form .field > span {
min-width: 0;
line-height: 1.35;
}
.visit-form .field.span-2 {
grid-template-columns: 116px minmax(0, 1fr);
}
.required { color: var(--red); }
.subsection-title {
margin: 2px 0 8px;
padding-top: 8px;
border-top: 1px solid #e2e8f0;
font-weight: 700;
color: #334155;
font-size: 13px;
}
.form-group .subsection-title:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } .form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
/* Inline validation message shown at the left of the action row, beside the 취소 button. */
.form-actions .form-error { margin-right: auto; align-self: center; color: #b91c1c; font-size: 14px; font-weight: 600; }
/* Status badge + action button shown together in a table cell (access console). */
.status-cell { display: inline-flex; align-items: center; gap: 8px; }
/* Sub-section heading inside a form grid (담당자/감시자 등). */
.form-section { grid-column: 1 / -1; margin: 6px 0 -2px; padding-top: 10px; border-top: 1px solid #e2e8f0; font-weight: 700; color: #1e293b; font-size: 14px; }
/* Read-only auto-filled fields (본인/고정값). */
.field input[readonly] { background: #f1f5f9; color: #475569; cursor: default; }
/* faint inline helper next to a label */ /* faint inline helper next to a label */
.hint-inline { font-weight: 400; font-style: normal; color: var(--muted); font-size: 12px; } .hint-inline { font-weight: 400; font-style: normal; color: var(--muted); font-size: 12px; }
@@ -183,11 +303,17 @@ a { color: inherit; text-decoration: none; }
background: #f8fafc; background: #f8fafc;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
padding: 12px 14px; padding: 8px 10px;
margin-bottom: 8px; margin-bottom: 0;
} }
.consent-label { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 13px; color: #334155; line-height: 1.5; } .consent-label { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 12px; color: #334155; line-height: 1.35; }
.consent-label input { margin-top: 2px; width: 16px; height: 16px; flex-shrink: 0; } .consent-label input { margin-top: 2px; width: 16px; height: 16px; flex-shrink: 0; }
.consent-retention-warning { color: var(--red); }
/* Inline checkbox group (e.g. 전산실 다중 선택) */
.checkbox-row { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 2px; }
.checkbox-inline { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: #334155; }
.checkbox-inline input { width: 16px; height: 16px; flex-shrink: 0; }
/* ===== Buttons ===== */ /* ===== Buttons ===== */
button { font-family: inherit; cursor: pointer; } button { font-family: inherit; cursor: pointer; }
@@ -207,7 +333,7 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.action-cell { display: flex; gap: 8px; } .action-cell { display: flex; gap: 8px; }
/* ===== Stats ===== */ /* ===== Stats ===== */
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; } .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 20px; }
.stat-card { .stat-card {
background: var(--surface); border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 18px 20px; border-radius: 12px; padding: 18px 20px;
@@ -229,6 +355,8 @@ button:disabled { opacity: .55; cursor: not-allowed; }
} }
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: none; } .table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: none; }
.table tbody tr:hover { background: #f8fafc; } .table tbody tr:hover { background: #f8fafc; }
.clickable-row { cursor: pointer; }
.clickable-row:focus-visible { outline: 2px solid #bfdbfe; outline-offset: -2px; }
/* ===== Badges ===== */ /* ===== Badges ===== */
.badge { .badge {
@@ -239,6 +367,7 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.badge-amber { background: #fef3c7; color: #b45309; } .badge-amber { background: #fef3c7; color: #b45309; }
.badge-red { background: #fee2e2; color: #b91c1c; } .badge-red { background: #fee2e2; color: #b91c1c; }
.badge-gray { background: #e2e8f0; color: #475569; } .badge-gray { background: #e2e8f0; color: #475569; }
.badge-blue { background: #dbeafe; color: #1d4ed8; }
/* ===== Alerts ===== */ /* ===== Alerts ===== */
.alert { padding: 12px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; } .alert { padding: 12px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
@@ -247,6 +376,143 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
/* ===== Public visitor application ===== */
.public-visit-shell {
min-height: 100vh;
padding: 24px 18px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
}
.public-visit-card {
width: min(1160px, 100%);
margin: 0 auto;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
}
.public-visit-card.visit-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 18px;
padding: 16px;
}
.public-visit-card .span-2 {
grid-column: 1 / -1;
}
.public-visit-card input,
.public-visit-card select,
.public-visit-card textarea,
.public-visit-card button {
font-family: 'Segoe UI', 'Malgun Gothic', system-ui, sans-serif;
font-size: 13px;
font-weight: 400;
font-variant-numeric: proportional-nums;
font-feature-settings: "tnum" 0;
letter-spacing: 0 !important;
word-spacing: 0;
}
.public-visit-card .btn-primary,
.public-visit-card .btn-ghost {
font-weight: 600;
}
.public-visit-head {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 12px;
padding: 4px 4px 8px;
}
.public-visit-head h1 {
margin: 0;
font-size: 24px;
}
.public-visit-head p {
margin: 2px 0 0;
color: var(--muted);
font-size: 13px;
}
.public-visit-badge {
width: 40px;
height: 40px;
object-fit: contain;
}
.public-verification-grid {
display: grid;
grid-template-columns: repeat(2, minmax(120px, max-content)) minmax(120px, auto) minmax(160px, 1fr) auto;
gap: 8px;
align-items: center;
}
.radio-inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
.public-verification-grid input {
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.verification-hint {
grid-column: 1 / -1;
color: var(--primary-dark);
font-size: 13px;
font-weight: 700;
}
.public-visit-complete {
max-width: 520px;
padding: 32px;
text-align: center;
}
.public-visit-complete .public-visit-badge {
width: 64px;
height: 64px;
}
.public-visit-complete h1 {
margin: 14px 0 8px;
font-size: 24px;
}
.public-visit-complete p {
margin: 0;
color: var(--muted);
}
/* ===== Visitor application import ===== */
.import-panel {
padding: 12px;
}
.import-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 8px;
margin-top: 10px;
}
.import-item {
display: grid;
gap: 4px;
text-align: left;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
padding: 10px 12px;
}
.import-item:hover {
border-color: #93c5fd;
background: #f8fbff;
}
.import-item strong {
font-size: 14px;
}
.import-item span {
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
/* ===== Access console ===== */ /* ===== Access console ===== */
.console-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; } .console-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
.inline-form { display: flex; gap: 8px; } .inline-form { display: flex; gap: 8px; }
@@ -264,6 +530,87 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.btn-link { background: none; border: none; color: var(--primary); font-weight: 600; } .btn-link { background: none; border: none; color: var(--primary); font-weight: 600; }
.row-actions { display: flex; gap: 10px; } .row-actions { display: flex; gap: 10px; }
/* ===== Admin management ===== */
.admin-tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.admin-tabs button {
border: 0;
background: transparent;
padding: 10px 12px;
color: var(--muted);
font-weight: 700;
}
.admin-tabs button.tab-active {
color: var(--primary);
border-bottom: 2px solid var(--primary);
}
.admin-filter-row {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.admin-filter-row input,
.admin-filter-row select,
.table select {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
background: #fff;
}
.admin-filter-row input {
min-width: 260px;
}
.admin-upload-panel {
margin-bottom: 12px;
}
.admin-upload-head,
.admin-upload-controls,
.admin-upload-summary {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.admin-upload-head {
justify-content: space-between;
margin-bottom: 10px;
}
.admin-upload-head h3 {
margin: 0;
font-size: 16px;
}
.admin-upload-controls input[type="file"] {
min-width: 280px;
}
.admin-upload-summary {
margin: 12px 0;
color: var(--muted);
font-size: 13px;
}
.admin-preview-table {
margin-top: 8px;
}
.admin-preview-table td {
vertical-align: top;
}
.diagnostic-table th {
width: 140px;
}
.diagnostic-table td {
word-break: break-word;
}
.row-error {
background: #fff7f7;
}
.text-danger {
color: var(--red);
}
/* ===== Report ===== */ /* ===== Report ===== */
.report-row { display: flex; align-items: flex-end; gap: 16px; } .report-row { display: flex; align-items: flex-end; gap: 16px; }
.report-row .field { margin-bottom: 0; } .report-row .field { margin-bottom: 0; }
@@ -288,7 +635,7 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.badge-qr { width: 200px; height: 200px; image-rendering: pixelated; } .badge-qr { width: 200px; height: 200px; image-rendering: pixelated; }
.badge-meta { text-align: center; margin: 16px 0; font-size: 13px; } .badge-meta { text-align: center; margin: 16px 0; font-size: 13px; }
.badge-meta > div { padding: 4px 0; border-bottom: 1px dashed var(--border); } .badge-meta > div { padding: 4px 0; border-bottom: 1px dashed var(--border); }
.badge-foot { font-size: 12px; color: var(--muted); margin-top: 8px; } .badge-foot { font-size: 11px; color: var(--muted); margin-top: 8px; white-space: nowrap; }
@media print { @media print {
.topbar, .no-print { display: none !important; } .topbar, .no-print { display: none !important; }
@@ -300,5 +647,25 @@ button:disabled { opacity: .55; cursor: not-allowed; }
@media (max-width: 720px) { @media (max-width: 720px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); } .stat-grid { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.public-visit-shell { padding: 12px 8px; }
.public-visit-card.visit-form { grid-template-columns: 1fr; padding: 10px; }
.public-verification-grid { grid-template-columns: 1fr; }
.public-verification-grid .btn-ghost { width: 100%; }
.group-grid { grid-template-columns: 1fr; }
.detail-grid,
.detail-item {
grid-template-columns: 1fr;
}
.visit-form .field,
.visit-form .field.span-2 {
grid-template-columns: 1fr;
align-items: stretch;
}
.nav { display: none; } .nav { display: none; }
} }
/* inline row of controls (e.g. filter + refresh in page-head) */
.row-gap { display: flex; gap: 8px; align-items: center; }
/* long delivery error text: keep the row compact, reveal full text on hover (title attr) */
.cell-error { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--red, #c0392b); }

View File

@@ -10,6 +10,8 @@ export interface CurrentUser {
id: number; id: number;
username: string; username: string;
fullName: string; fullName: string;
department?: string;
email?: string;
roles: Role[]; roles: Role[];
mustChangePassword: boolean; mustChangePassword: boolean;
} }
@@ -31,6 +33,69 @@ export interface Zone {
securityLevel: number; securityLevel: number;
} }
export interface PurposeCode {
id: number;
code: string;
name: string;
sortOrder: number;
active: boolean;
customAllowed: boolean;
}
export interface Watcher1Settings {
name: string;
team: string;
contact: string;
}
export interface SmsDiagnosticsResult {
provider: string;
url: string;
ok: boolean;
reachable: boolean;
httpStatus: number | null;
elapsedMs: number;
checkedAt: string;
error: string | null;
}
export interface EmailDiagnosticsResult {
host: string;
port: number;
from: string;
smtpAuth: boolean;
startTls: boolean;
usernameConfigured: boolean;
passwordConfigured: boolean;
ok: boolean;
reachable: boolean;
elapsedMs: number;
checkedAt: string;
error: string | null;
}
export interface Team {
id: number;
code: string;
name: string;
active: boolean;
defaultRoles: Role[];
}
export interface AdminUser {
id: number;
username: string;
fullName: string;
department?: string;
teamId?: number;
teamCode?: string;
teamName?: string;
email?: string;
enabled: boolean;
locked: boolean;
roles: Role[];
}
export type VisitStatus = export type VisitStatus =
| 'DRAFT' | 'DRAFT'
| 'PENDING' | 'PENDING'
@@ -40,28 +105,108 @@ export type VisitStatus =
| 'EXPIRED'; | 'EXPIRED';
export interface VisitRequestCreate { export interface VisitRequestCreate {
sourceApplicationId?: number;
visitorName: string; visitorName: string;
company?: string; company?: string;
contact: string; contact: string;
email?: string; email?: string;
vehicleNo?: string; vehicleNo?: string;
zoneName?: string; /** 전산실 checkboxes; each selected room yields its own request/QR. */
serverRooms?: string[];
/** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */
roomZone?: string;
purpose: string; purpose: string;
purposeCode?: string;
purposeDetail?: string;
/** 작업명 — optional concrete task detail, stored separately from purpose. */
workName?: string;
/** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */
watcher2Name?: string;
watcher2Team?: string;
watcher2Contact?: string;
visitFrom: string; // ISO local datetime visitFrom: string; // ISO local datetime
visitTo: string; visitTo: string;
} }
export type VisitorVerificationMethod = 'PHONE' | 'EMAIL';
export interface VisitorVerificationStart {
method: VisitorVerificationMethod;
target: string;
}
export interface VisitorVerificationStartResult {
verificationId: string;
expiresAt: string;
deliveryStatus: string;
devCode?: string;
}
export interface VisitorVerificationConfirmResult {
verificationToken: string;
}
export interface VisitorApplicationCreate {
visitorName: string;
company?: string;
contact?: string;
email?: string;
vehicleNo?: string;
zoneName?: string;
roomZone?: string;
purpose: string;
purposeCode?: string;
purposeDetail?: string;
workName?: string;
controlName?: string;
controlTeam?: string;
controlContact?: string;
watcher1Name?: string;
watcher1Team?: string;
watcher1Contact?: string;
watcher2Name?: string;
watcher2Team?: string;
watcher2Contact?: string;
visitFrom: string;
visitTo: string;
verificationMethod: VisitorVerificationMethod;
verificationToken: string;
}
export interface VisitorApplicationView extends Omit<VisitorApplicationCreate, 'verificationToken'> {
id: number;
createdAt: string;
status: string;
verificationTarget: string;
verifiedAt?: string;
importedVisitRequestId?: number;
importedAt?: string;
}
export interface VisitRequestView { export interface VisitRequestView {
id: number; id: number;
visitorName: string; visitorName: string;
company?: string; company?: string;
contact?: string; contact?: string;
email?: string;
vehicleNo?: string; vehicleNo?: string;
hostId: number; hostId: number;
hostName: string; hostName: string;
hostDepartment?: string; hostDepartment?: string;
zoneName?: string; zoneName?: string;
purpose: string; purpose: string;
purposeCode?: string;
purposeDetail?: string;
workName?: string;
controlName?: string;
controlTeam?: string;
controlContact?: string;
watcher1Name?: string;
watcher1Team?: string;
watcher1Contact?: string;
watcher2Name?: string;
watcher2Team?: string;
watcher2Contact?: string;
visitFrom: string; visitFrom: string;
visitTo: string; visitTo: string;
status: VisitStatus; status: VisitStatus;
@@ -69,6 +214,13 @@ export interface VisitRequestView {
createdAt: string; createdAt: string;
} }
export interface ReportVisitView extends VisitRequestView {
checkInAt?: string;
checkOutAt?: string;
inside?: boolean;
reportStatusLabel?: string;
}
export interface PublicPass { export interface PublicPass {
visitorName: string; visitorName: string;
company?: string; company?: string;
@@ -88,6 +240,34 @@ export interface ExcelImportResult {
success: boolean; success: boolean;
} }
export interface AdminExcelImportRow {
rowNumber: number;
status: 'CREATE' | 'UPDATE' | 'ERROR' | string;
key?: string;
summary?: string;
errors: string[];
warnings: string[];
}
export interface AdminExcelImportResult {
totalRows: number;
createCount: number;
updateCount: number;
errorCount: number;
warningCount: number;
applied: boolean;
success: boolean;
rows: AdminExcelImportRow[];
}
export interface VisitRequestResetResult {
visitRequests: number;
visitors: number;
approvals: number;
accessEvents: number;
passDeliveries: number;
}
export interface InsideVisitor { export interface InsideVisitor {
visitRequestId: number; visitRequestId: number;
visitorName: string; visitorName: string;
@@ -104,6 +284,7 @@ export interface AccessRecord {
zoneName?: string; zoneName?: string;
visitFrom: string; visitFrom: string;
visitTo: string; visitTo: string;
status?: VisitStatus;
checkInAt?: string; checkInAt?: string;
checkOutAt?: string; checkOutAt?: string;
inside: boolean; inside: boolean;
@@ -112,6 +293,7 @@ export interface AccessRecord {
export interface AccessAction { export interface AccessAction {
visitRequestId: number; visitRequestId: number;
visitorName: string; visitorName: string;
zoneName?: string;
direction: 'IN' | 'OUT'; direction: 'IN' | 'OUT';
eventAt: string; eventAt: string;
gateOpened: boolean; gateOpened: boolean;
@@ -143,3 +325,28 @@ export interface BlacklistCreate {
contact?: string; contact?: string;
reason: string; reason: string;
} }
export interface AuditLog {
id: number;
at: string;
actorId?: number;
actorUsername?: string;
action: 'APPROVE' | 'REJECT' | 'DELETE' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE' | 'ADMIN_CONFIG_UPDATE';
targetType?: string;
targetId?: number;
detail?: string;
}
export type DeliveryStatus = 'SENT' | 'FAILED';
export interface PassDelivery {
id: number;
visitRequestId: number;
channel?: string;
recipient?: string;
status: DeliveryStatus;
attempts: number;
lastError?: string;
createdAt: string;
updatedAt: string;
}

5
infra/certs/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# TLS certificates/keys are environment-specific secrets — never commit them.
*.pem
*.key
*.crt
!.gitignore

View File

@@ -0,0 +1,21 @@
# HTTPS overlay. Use together with the base compose file:
# docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --build
#
# Requires a TLS certificate under ./certs/{fullchain.pem,privkey.pem}.
# For a quick internal/test cert (self-signed):
# openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
# -keyout certs/privkey.pem -out certs/fullchain.pem -subj "/CN=<your-host>"
# For production, drop in the cert issued for your internal domain (corporate CA / public CA).
services:
web:
ports:
- "${WEB_TLS_PORT:-443}:443" # base file already maps WEB_PORT:80 (used for the redirect)
volumes:
- ./certs:/etc/nginx/certs:ro
- ../frontend/nginx-tls.conf:/etc/nginx/conf.d/default.conf:ro
app:
environment:
# The public link in the pass email/SMS + the secure-cookie switch.
ACS_PUBLIC_BASE_URL: ${ACS_PUBLIC_BASE_URL:-https://localhost}
ACS_COOKIE_SECURE: "true"

106
migrations/001_init.sql Normal file
View File

@@ -0,0 +1,106 @@
-- Ported from backend/src/main/resources/db/migration/V1__init.sql
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);
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
);
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);
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);
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
);
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);
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);
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);

View File

@@ -0,0 +1,15 @@
-- Ported from backend/src/main/resources/db/migration/V2__audit_log.sql
CREATE TABLE audit_logs (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
actor_id BIGINT,
actor_username VARCHAR(50),
action VARCHAR(30) NOT NULL,
target_type VARCHAR(30),
target_id BIGINT,
detail VARCHAR(500)
);
CREATE INDEX idx_audit_created_at ON audit_logs (created_at);
CREATE INDEX idx_audit_action ON audit_logs (action);

View File

@@ -0,0 +1,15 @@
-- Ported from backend/src/main/resources/db/migration/V3__pass_delivery.sql
CREATE TABLE pass_deliveries (
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,
channel VARCHAR(20),
recipient VARCHAR(120),
status VARCHAR(20) NOT NULL,
attempts INTEGER NOT NULL,
last_error VARCHAR(500)
);
CREATE INDEX idx_pd_status ON pass_deliveries (status);
CREATE INDEX idx_pd_visit_request ON pass_deliveries (visit_request_id);

View File

@@ -0,0 +1,12 @@
-- Ported from backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql
ALTER TABLE visit_requests ADD COLUMN work_name VARCHAR(255);
ALTER TABLE visit_requests ADD COLUMN control_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN control_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN control_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN watcher1_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher1_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher1_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN watcher2_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher2_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN watcher2_contact VARCHAR(60);

View File

@@ -0,0 +1,48 @@
-- Ensure baseline ACS test accounts exist in the Node/PostgreSQL deployment.
-- Passwords:
-- admin/security/host = ChangeMe123!
-- a/s/h = 1
WITH upserted AS (
INSERT INTO users (
created_at,
updated_at,
username,
password_hash,
full_name,
email,
department,
must_change_password,
enabled,
locked
)
VALUES
(now(), now(), 'admin', '$2a$12$7FBM5I4IOmKQHmBbAm2AAu3HQ3FvOkazzuBk1Hnd.gr68UekEusEa', 'Admin', 'admin@itcenter.local', 'IT Ops', TRUE, TRUE, FALSE),
(now(), now(), 'security', '$2a$12$7FBM5I4IOmKQHmBbAm2AAu3HQ3FvOkazzuBk1Hnd.gr68UekEusEa', 'Security', 'security@itcenter.local', 'Security', TRUE, TRUE, FALSE),
(now(), now(), 'host', '$2a$12$7FBM5I4IOmKQHmBbAm2AAu3HQ3FvOkazzuBk1Hnd.gr68UekEusEa', 'Host', 'host@itcenter.local', 'Dev Team', TRUE, TRUE, FALSE),
(now(), now(), 'a', '$2a$12$aQ84.8Z4w/9GLG4gJkBu7OAHU3v9/BR5ESLimxpnETZ.pxnzylgkW', 'Admin', 'a@itcenter.local', 'IT Ops', FALSE, TRUE, FALSE),
(now(), now(), 's', '$2a$12$aQ84.8Z4w/9GLG4gJkBu7OAHU3v9/BR5ESLimxpnETZ.pxnzylgkW', 'Security', 's@itcenter.local', 'Security', FALSE, TRUE, FALSE),
(now(), now(), 'h', '$2a$12$aQ84.8Z4w/9GLG4gJkBu7OAHU3v9/BR5ESLimxpnETZ.pxnzylgkW', 'Host', 'h@itcenter.local', 'Dev Team', FALSE, TRUE, FALSE)
ON CONFLICT (username) DO UPDATE
SET updated_at = now(),
password_hash = EXCLUDED.password_hash,
full_name = EXCLUDED.full_name,
email = EXCLUDED.email,
department = EXCLUDED.department,
must_change_password = EXCLUDED.must_change_password,
enabled = TRUE,
locked = FALSE
RETURNING id, username
)
DELETE FROM user_roles
WHERE user_id IN (SELECT id FROM upserted);
INSERT INTO user_roles (user_id, role)
SELECT id, role
FROM (
SELECT id, 'ADMIN' AS role FROM users WHERE username IN ('admin', 'a')
UNION ALL
SELECT id, 'SECURITY' AS role FROM users WHERE username IN ('security', 's')
UNION ALL
SELECT id, 'HOST' AS role FROM users WHERE username IN ('host', 'h')
) seeded_roles;

View File

@@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS purpose_codes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
code VARCHAR(40) NOT NULL UNIQUE,
name VARCHAR(80) NOT NULL,
sort_order INTEGER NOT NULL,
active BOOLEAN NOT NULL,
custom_allowed BOOLEAN NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_purpose_code_active_sort ON purpose_codes (active, sort_order);
CREATE TABLE IF NOT EXISTS system_settings (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
setting_key VARCHAR(80) NOT NULL UNIQUE,
setting_value VARCHAR(255)
);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS work_name VARCHAR(255);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS control_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS control_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS control_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher1_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher1_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher1_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher2_name VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher2_team VARCHAR(80);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher2_contact VARCHAR(60);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS purpose_code VARCHAR(40);
ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS purpose_detail VARCHAR(255);
INSERT INTO purpose_codes (created_at, updated_at, code, name, sort_order, active, custom_allowed)
VALUES
(now(), now(), 'WORK', '작업', 10, TRUE, FALSE),
(now(), now(), 'INSPECTION', '점검', 20, TRUE, FALSE),
(now(), now(), 'DELIVERY', '납품', 30, TRUE, FALSE),
(now(), now(), 'MEETING', '회의', 40, TRUE, FALSE),
(now(), now(), 'VISIT', '방문', 50, TRUE, FALSE),
(now(), now(), 'ETC', '기타', 100, TRUE, TRUE)
ON CONFLICT (code) DO NOTHING;
INSERT INTO system_settings (created_at, updated_at, setting_key, setting_value)
VALUES
(now(), now(), 'watcher1.name', '류관순'),
(now(), now(), 'watcher1.team', 'IT전략국'),
(now(), now(), 'watcher1.contact', '313')
ON CONFLICT (setting_key) DO NOTHING;

43
migrations/007_teams.sql Normal file
View File

@@ -0,0 +1,43 @@
CREATE TABLE IF NOT EXISTS teams (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
code VARCHAR(40) NOT NULL UNIQUE,
name VARCHAR(80) NOT NULL UNIQUE,
active BOOLEAN NOT NULL
);
CREATE TABLE IF NOT EXISTS team_default_roles (
team_id BIGINT NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
PRIMARY KEY (team_id, role)
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS team_id BIGINT REFERENCES teams (id);
INSERT INTO teams (created_at, updated_at, code, name, active)
VALUES
(now(), now(), 'ITOPS', 'IT운영팀', TRUE),
(now(), now(), 'DEV1', '개발1팀', TRUE),
(now(), now(), 'SEC', '보안팀', TRUE)
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name,
active = EXCLUDED.active,
updated_at = now();
INSERT INTO team_default_roles (team_id, role)
SELECT id, 'ADMIN' FROM teams WHERE code = 'ITOPS'
ON CONFLICT DO NOTHING;
INSERT INTO team_default_roles (team_id, role)
SELECT id, 'HOST' FROM teams WHERE code = 'DEV1'
ON CONFLICT DO NOTHING;
INSERT INTO team_default_roles (team_id, role)
SELECT id, 'SECURITY' FROM teams WHERE code = 'SEC'
ON CONFLICT DO NOTHING;
UPDATE users SET team_id = (SELECT id FROM teams WHERE code = 'ITOPS'), department = 'IT운영팀'
WHERE username IN ('admin', 'a') AND team_id IS NULL;
UPDATE users SET team_id = (SELECT id FROM teams WHERE code = 'DEV1'), department = '개발1팀'
WHERE username IN ('host', 'h') AND team_id IS NULL;
UPDATE users SET team_id = (SELECT id FROM teams WHERE code = 'SEC'), department = '보안팀'
WHERE username IN ('security', 's') AND team_id IS NULL;

View File

@@ -0,0 +1,43 @@
CREATE TABLE IF NOT EXISTS visitor_verifications (
id VARCHAR(36) PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT now(),
verification_method VARCHAR(10) NOT NULL,
target VARCHAR(120) NOT NULL,
code_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMP NOT NULL,
verified_at TIMESTAMP,
verification_token VARCHAR(64)
);
CREATE INDEX IF NOT EXISTS idx_visitor_verifications_token ON visitor_verifications (verification_token);
CREATE TABLE IF NOT EXISTS visitor_applications (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
status VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED',
visitor_name VARCHAR(80) NOT NULL,
company VARCHAR(120),
contact VARCHAR(40),
email VARCHAR(120),
vehicle_no VARCHAR(20),
zone_name VARCHAR(80),
room_zone VARCHAR(80),
purpose VARCHAR(255) NOT NULL,
purpose_code VARCHAR(40),
purpose_detail VARCHAR(255),
work_name VARCHAR(255),
watcher2_name VARCHAR(80),
watcher2_team VARCHAR(80),
watcher2_contact VARCHAR(60),
visit_from TIMESTAMP NOT NULL,
visit_to TIMESTAMP NOT NULL,
verification_method VARCHAR(10) NOT NULL,
verification_target VARCHAR(120) NOT NULL,
verification_id VARCHAR(36),
verified_at TIMESTAMP,
imported_visit_request_id BIGINT,
imported_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_visitor_applications_status ON visitor_applications (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_visitor_applications_contact ON visitor_applications (contact);
CREATE INDEX IF NOT EXISTS idx_visitor_applications_email ON visitor_applications (email);

View File

@@ -0,0 +1,7 @@
ALTER TABLE visitor_applications
ADD COLUMN IF NOT EXISTS control_name VARCHAR(80),
ADD COLUMN IF NOT EXISTS control_team VARCHAR(80),
ADD COLUMN IF NOT EXISTS control_contact VARCHAR(60),
ADD COLUMN IF NOT EXISTS watcher1_name VARCHAR(80),
ADD COLUMN IF NOT EXISTS watcher1_team VARCHAR(80),
ADD COLUMN IF NOT EXISTS watcher1_contact VARCHAR(60);

3188
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "acs-node",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "npm run build:frontend && npm run build:server",
"build:frontend": "npm --prefix frontend run build",
"build:server": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "npm run test:node",
"test:node": "npm run build:server && node --test --test-isolation=none dist/server/**/*.test.js",
"deploy:check": "node scripts/deployment-check.mjs",
"db:check": "node scripts/check-db.mjs",
"minio:check": "node scripts/check-minio.mjs",
"dev": "tsx watch server/index.ts",
"migrate": "tsx server/db/migrate.ts",
"start": "node dist/server/index.js",
"start:deploy": "node dist/server/db/migrate.js && node dist/server/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"exceljs": "^4.4.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"multer": "^2.0.2",
"pg": "^8.13.1",
"qrcode": "^1.5.4",
"uuid": "^11.0.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.1",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=22"
}
}

30
scripts/check-db.mjs Normal file
View File

@@ -0,0 +1,30 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import dotenv from 'dotenv';
import pg from 'pg';
const projectEnvPath = path.resolve(process.cwd(), '.project-env');
const dotEnvPath = path.resolve(process.cwd(), '.env');
if (existsSync(projectEnvPath)) {
dotenv.config({ path: projectEnvPath });
} else if (existsSync(dotEnvPath)) {
dotenv.config({ path: dotEnvPath });
}
if (!process.env.DATABASE_URL) {
console.error('DB FAIL: DATABASE_URL is not set. Run inside the AI DEV project folder with .project-env loaded.');
process.exit(1);
}
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
try {
const result = await pool.query('SELECT now() AS now');
console.log(`DB OK: ${result.rows[0]?.now}`);
} catch (error) {
console.error(`DB FAIL: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
} finally {
await pool.end();
}

1
scripts/check-minio.mjs Normal file
View File

@@ -0,0 +1 @@
console.log('S3 SKIPPED: ACS does not currently use S3/MinIO storage.');

View File

@@ -0,0 +1,186 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
const DEFAULT_BASE = 'https://acs.apps.bokdev.in';
const projectRoot = process.cwd();
function argValue(name, fallback) {
const prefix = `--${name}=`;
const inline = process.argv.find((arg) => arg.startsWith(prefix));
if (inline) return inline.slice(prefix.length);
const index = process.argv.indexOf(`--${name}`);
if (index >= 0 && process.argv[index + 1]) return process.argv[index + 1];
return fallback;
}
function ok(message) {
console.log(`[ok] ${message}`);
}
function fail(message) {
console.error(`[fail] ${message}`);
process.exitCode = 1;
}
function warn(message) {
console.warn(`[warn] ${message}`);
}
function readLocal(relativePath) {
return readFileSync(path.join(projectRoot, relativePath), 'utf8');
}
function git(args) {
return execFileSync('git', args, {
cwd: projectRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
async function checkJson(base, route) {
const url = new URL(route, base).toString();
try {
const response = await fetch(url);
const body = await response.text();
if (!response.ok) {
fail(`${route} returned HTTP ${response.status}: ${body.slice(0, 160)}`);
return;
}
const parsed = JSON.parse(body);
if (parsed?.ok !== true) {
fail(`${route} response did not include ok=true: ${body.slice(0, 160)}`);
return;
}
ok(`${route} ${response.status}`);
} catch (error) {
fail(`${route} check failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
function cookieHeaderFrom(response) {
if (typeof response.headers.getSetCookie === 'function') {
return response.headers.getSetCookie().map((cookie) => cookie.split(';')[0]).join('; ');
}
const cookie = response.headers.get('set-cookie');
return cookie ? cookie.split(',').map((part) => part.split(';')[0]).join('; ') : '';
}
async function login(base, username, password) {
if (!username || !password) return '';
const url = new URL('/api/auth/login', base).toString();
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const body = await response.text();
if (!response.ok) {
fail(`/api/auth/login returned HTTP ${response.status}: ${body.slice(0, 160)}`);
return '';
}
ok('/api/auth/login 200');
return cookieHeaderFrom(response);
} catch (error) {
fail(`/api/auth/login check failed: ${error instanceof Error ? error.message : String(error)}`);
return '';
}
}
async function checkTemplate(base, cookie) {
const route = '/api/visit-requests/template';
const url = new URL(route, base).toString();
try {
const response = await fetch(url, cookie ? { headers: { cookie } } : undefined);
const contentType = response.headers.get('content-type') ?? '';
const disposition = response.headers.get('content-disposition') ?? '';
if (!response.ok) {
const body = await response.text();
fail(`${route} returned HTTP ${response.status}: ${body.slice(0, 200)}`);
return;
}
if (!contentType.includes('spreadsheet') && !disposition.includes('.xlsx')) {
fail(`${route} did not look like an xlsx download: content-type=${contentType || '-'}, content-disposition=${disposition || '-'}`);
return;
}
ok(`${route} xlsx download`);
} catch (error) {
fail(`${route} check failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
function checkLocalTemplate() {
const templatePath = 'docs/form_sample.xlsx';
if (!existsSync(path.join(projectRoot, templatePath))) {
fail(`${templatePath} is missing locally`);
return;
}
ok(`${templatePath} exists locally`);
try {
git(['ls-files', '--error-unmatch', templatePath]);
ok(`${templatePath} is tracked by git`);
} catch {
fail(`${templatePath} is not tracked by git`);
}
}
function checkDockerPackaging() {
const dockerignore = readLocal('.dockerignore');
const dockerfile = readLocal('Dockerfile');
if (!dockerignore.includes('!docs/form_sample.xlsx')) {
fail('.dockerignore does not allow docs/form_sample.xlsx into the build context');
} else {
ok('.dockerignore allows docs/form_sample.xlsx');
}
if (!dockerfile.includes('COPY docs/form_sample.xlsx ./docs/form_sample.xlsx')) {
fail('Dockerfile does not copy docs/form_sample.xlsx into the runtime image');
} else {
ok('Dockerfile copies docs/form_sample.xlsx into the runtime image');
}
}
function checkGitState() {
try {
const head = git(['rev-parse', '--short', 'HEAD']);
ok(`local HEAD ${head}`);
const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
const aheadBehind = git(['rev-list', '--left-right', '--count', `${upstream}...HEAD`]).split(/\s+/);
const behind = Number(aheadBehind[0] ?? 0);
const ahead = Number(aheadBehind[1] ?? 0);
if (ahead || behind) {
warn(`branch differs from ${upstream}: ahead ${ahead}, behind ${behind}`);
} else {
ok(`branch matches ${upstream}`);
}
} catch (error) {
warn(`git state check skipped: ${error instanceof Error ? error.message : String(error)}`);
}
}
const base = argValue('base', DEFAULT_BASE).replace(/\/+$/, '/');
const username = argValue('username', process.env.ACS_CHECK_USER ?? '');
const password = argValue('password', process.env.ACS_CHECK_PASSWORD ?? '');
console.log(`[acs-deploy-check] base=${base}`);
checkLocalTemplate();
checkDockerPackaging();
checkGitState();
await checkJson(base, '/healthz');
await checkJson(base, '/db');
const cookie = await login(base, username, password);
await checkTemplate(base, cookie);
if (process.exitCode) {
console.error('[acs-deploy-check] failed');
process.exit(process.exitCode);
}
console.log('[acs-deploy-check] passed');

View File

@@ -2,3 +2,6 @@ username,full_name,email,department,password,must_change_password,roles
admin,관리자,admin@itcenter.local,IT운영팀,ChangeMe123!,true,ADMIN admin,관리자,admin@itcenter.local,IT운영팀,ChangeMe123!,true,ADMIN
security,보안담당,security@itcenter.local,보안팀,ChangeMe123!,true,SECURITY security,보안담당,security@itcenter.local,보안팀,ChangeMe123!,true,SECURITY
host,홍길동,host@itcenter.local,개발1팀,ChangeMe123!,true,HOST host,홍길동,host@itcenter.local,개발1팀,ChangeMe123!,true,HOST
a,관리자,a@itcenter.local,IT운영팀,1,false,ADMIN
s,보안담당,s@itcenter.local,보안팀,1,false,SECURITY
h,홍길동,h@itcenter.local,개발1팀,1,false,HOST
1 username full_name email department password must_change_password roles
2 admin 관리자 admin@itcenter.local IT운영팀 ChangeMe123! true ADMIN
3 security 보안담당 security@itcenter.local 보안팀 ChangeMe123! true SECURITY
4 host 홍길동 host@itcenter.local 개발1팀 ChangeMe123! true HOST
5 a 관리자 a@itcenter.local IT운영팀 1 false ADMIN
6 s 보안담당 s@itcenter.local 보안팀 1 false SECURITY
7 h 홍길동 h@itcenter.local 개발1팀 1 false HOST

43
server/config/env.ts Normal file
View File

@@ -0,0 +1,43 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import dotenv from 'dotenv';
const projectEnvPath = path.resolve(process.cwd(), '.project-env');
const dotEnvPath = path.resolve(process.cwd(), '.env');
if (existsSync(projectEnvPath)) {
dotenv.config({ path: projectEnvPath });
} else if (existsSync(dotEnvPath)) {
dotenv.config({ path: dotEnvPath });
}
export const env = {
nodeEnv: process.env.NODE_ENV ?? 'development',
port: Number(process.env.PORT ?? 3000),
databaseUrl: process.env.DATABASE_URL,
sessionSecret: process.env.SESSION_SECRET ?? 'dev-only-change-me',
publicBaseUrl: process.env.ACS_PUBLIC_BASE_URL ?? 'http://localhost:3000',
smsProvider: process.env.ACS_SMS_PROVIDER ?? 'dev',
smsApiUrl: process.env.ACS_SMS_API_URL,
smsApiKey: process.env.ACS_SMS_API_KEY,
smsSender: process.env.ACS_SMS_SENDER,
smsTimeoutMs: Number(process.env.ACS_SMS_TIMEOUT_MS ?? 10000),
mailHost: process.env.ACS_MAIL_HOST,
mailPort: Number(process.env.ACS_MAIL_PORT ?? 25),
mailFrom: process.env.ACS_MAIL_FROM,
mailUsername: process.env.ACS_MAIL_USERNAME,
mailPassword: process.env.ACS_MAIL_PASSWORD,
mailSmtpAuth: (process.env.ACS_MAIL_SMTP_AUTH ?? '').toLowerCase() === 'true',
mailStartTls: (process.env.ACS_MAIL_STARTTLS ?? '').toLowerCase() === 'true',
mailTimeoutMs: Number(process.env.ACS_MAIL_TIMEOUT_MS ?? 10000),
cookieSecure:
(process.env.ACS_COOKIE_SECURE ?? '').toLowerCase() === 'true' ||
(process.env.ACS_PUBLIC_BASE_URL ?? '').startsWith('https://'),
};
export function requireDatabaseUrl(): string {
if (!env.databaseUrl) {
throw new Error('DATABASE_URL is required. In AI DEV it should be supplied by .project-env.');
}
return env.databaseUrl;
}

56
server/db/migrate.ts Normal file
View File

@@ -0,0 +1,56 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { closePool, query } from './pool.js';
const migrationsDir = path.resolve(process.cwd(), 'migrations');
async function ensureMigrationTable(): Promise<void> {
await query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
}
async function appliedMigrations(): Promise<Set<string>> {
const result = await query<{ filename: string }>('SELECT filename FROM schema_migrations');
return new Set(result.rows.map((row) => row.filename));
}
export async function runMigrations(): Promise<void> {
await ensureMigrationTable();
const applied = await appliedMigrations();
const files = (await readdir(migrationsDir))
.filter((file) => file.endsWith('.sql'))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = await readFile(path.join(migrationsDir, file), 'utf8');
await query('BEGIN');
try {
await query(sql);
await query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
await query('COMMIT');
console.log(`Applied migration ${file}`);
} catch (error) {
await query('ROLLBACK');
throw error;
}
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
runMigrations()
.then(async () => {
await closePool();
})
.catch(async (error: unknown) => {
console.error(error);
await closePool();
process.exitCode = 1;
});
}

23
server/db/pool.ts Normal file
View File

@@ -0,0 +1,23 @@
import pg from 'pg';
import { requireDatabaseUrl } from '../config/env.js';
let pool: pg.Pool | undefined;
export function getPool(): pg.Pool {
pool ??= new pg.Pool({
connectionString: requireDatabaseUrl(),
});
return pool;
}
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[],
): Promise<pg.QueryResult<T>> {
return getPool().query<T>(text, params);
}
export async function closePool(): Promise<void> {
await pool?.end();
pool = undefined;
}

View File

@@ -0,0 +1,15 @@
import type { Response } from 'express';
export interface ApiResponse<T> {
code: number;
message: string;
data: T | null;
}
export function ok<T>(res: Response, data: T, message = 'OK'): void {
res.status(200).json({ code: 200, message, data } satisfies ApiResponse<T>);
}
export function created<T>(res: Response, data: T, message = 'CREATED'): void {
res.status(201).json({ code: 201, message, data } satisfies ApiResponse<T>);
}

33
server/http/errors.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { NextFunction, Request, Response } from 'express';
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
}
}
export function errorHandler(
error: unknown,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (error instanceof ApiError) {
res.status(error.status).json({
code: error.status,
message: error.message,
data: null,
});
return;
}
console.error(error);
res.status(500).json({
code: 500,
message: 'Internal server error',
data: null,
});
}

Some files were not shown because too many files have changed in this diff Show More