Compare commits
46 Commits
4fe4c37fa4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7239c21a7d | ||
|
|
7ff45fb4ec | ||
|
|
05068877db | ||
|
|
d4e59fbf9f | ||
|
|
6c511bf355 | ||
|
|
eaecfae330 | ||
|
|
eec18e7179 | ||
|
|
dee612fc5c | ||
|
|
7cbc660679 | ||
|
|
3efe493f9f | ||
|
|
7c579608a0 | ||
|
|
ed972a318d | ||
|
|
e01beea64c | ||
|
|
c45c47109c | ||
|
|
7617c9f371 | ||
|
|
10da3ff62d | ||
|
|
5ba8c5db5e | ||
|
|
3ba95a0c02 | ||
|
|
e4caed8773 | ||
|
|
85f94ba4f2 | ||
|
|
e6bc4cf734 | ||
|
|
104d2f531a | ||
|
|
6180161225 | ||
|
|
9c4dac9a70 | ||
|
|
ae1f30592d | ||
|
|
bd3d933a40 | ||
|
|
cff1a1bcad | ||
|
|
a1c82d819d | ||
|
|
cc79abde44 | ||
|
|
b094241408 | ||
|
|
d2c90dff0e | ||
|
|
0852995598 | ||
|
|
c6f342a861 | ||
|
|
a25872394a | ||
|
|
2653ed64bc | ||
|
|
7a404cd836 | ||
|
|
0ae4b73592 | ||
|
|
e02afcda7d | ||
|
|
9562766320 | ||
|
|
7c8bd37b12 | ||
| ce82cf5a26 | |||
| 44a4bc1086 | |||
|
|
b90bfe35ca | ||
|
|
6abcee3727 | ||
|
|
98b7f117b5 | ||
|
|
6a8b40730a |
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||||
@@ -19,6 +21,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# 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
32
CLAUDE.md
Normal 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
38
Dockerfile
Normal 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"]
|
||||||
@@ -37,15 +37,15 @@ public class VisitRequestController {
|
|||||||
/**
|
/**
|
||||||
* Create pre-registration(s); current user becomes the host. One request is issued per
|
* 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건).
|
* selected server room, so the response may contain more than one (e.g. 4층+5층전산실 → 2건).
|
||||||
* When the registrant already holds approval authority (ADMIN/SECURITY), each request is
|
* When the registrant is ADMIN, each request is self-approved immediately
|
||||||
* self-approved immediately (QR issued) — HOST registrations stay PENDING.
|
* (QR issued) — SECURITY/HOST registrations stay PENDING.
|
||||||
*/
|
*/
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<ApiResponse<List<VisitRequestResponse>>> create(
|
public ResponseEntity<ApiResponse<List<VisitRequestResponse>>> create(
|
||||||
@Valid @RequestBody VisitRequestCreateRequest request) {
|
@Valid @RequestBody VisitRequestCreateRequest request) {
|
||||||
Long userId = SecurityUtils.currentUserId();
|
Long userId = SecurityUtils.currentUserId();
|
||||||
List<VisitRequest> created = visitRequestService.createRequests(request, userId);
|
List<VisitRequest> created = visitRequestService.createRequests(request, userId);
|
||||||
if (SecurityUtils.hasRole("ADMIN") || SecurityUtils.hasRole("SECURITY")) {
|
if (SecurityUtils.hasRole("ADMIN")) {
|
||||||
List<VisitRequest> approved = new java.util.ArrayList<>(created.size());
|
List<VisitRequest> approved = new java.util.ArrayList<>(created.size());
|
||||||
for (VisitRequest vr : created) {
|
for (VisitRequest vr : created) {
|
||||||
approved.add(approvalService.approve(vr.getId(), userId, "본인 등록 자동승인"));
|
approved.add(approvalService.approve(vr.getId(), userId, "본인 등록 자동승인"));
|
||||||
|
|||||||
81
docs/ACS-login-404-analysis.md
Normal file
81
docs/ACS-login-404-analysis.md
Normal 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
280
docs/ACS-test-scenarios.md
Normal 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 | | | | |
|
||||||
72
docs/ACS-visit-form-ui-update.md
Normal file
72
docs/ACS-visit-form-ui-update.md
Normal 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` |
|
||||||
618
docs/ACS작업일지.md
618
docs/ACS작업일지.md
@@ -175,6 +175,20 @@
|
|||||||
- `git ls-remote origin main` 결과 `01d48fe... refs/heads/main` 확인.
|
- `git ls-remote origin main` 결과 `01d48fe... refs/heads/main` 확인.
|
||||||
- `https://gitea.bokdev.in/0420301/acs` 웹 페이지 접근 200 OK 확인.
|
- `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 확인
|
### 테스트용 화면 URL 확인
|
||||||
- 프론트 라우트 정의 파일: `frontend/src/App.tsx`
|
- 프론트 라우트 정의 파일: `frontend/src/App.tsx`
|
||||||
- 관리자/담당자 로그인 화면:
|
- 관리자/담당자 로그인 화면:
|
||||||
@@ -239,6 +253,40 @@
|
|||||||
- `aidev` 팀 또는 프로젝트에서 resource create/update/deploy 권한 부여 요청.
|
- `aidev` 팀 또는 프로젝트에서 resource create/update/deploy 권한 부여 요청.
|
||||||
- 권한이 없는 경우 권한 있는 관리자가 ACS resource를 생성하거나, 사용자에게 프로젝트 관리자 권한을 부여해야 함.
|
- 권한이 없는 경우 권한 있는 관리자가 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` 존재 확인.
|
- `infra/certs/fullchain.pem`, `infra/certs/privkey.pem` 존재 확인.
|
||||||
- 현재 인증서는 `CN=localhost`, SAN도 `localhost`, `127.0.0.1`용임.
|
- 현재 인증서는 `CN=localhost`, SAN도 `localhost`, `127.0.0.1`용임.
|
||||||
@@ -274,3 +322,573 @@
|
|||||||
- `ACS_COOKIE_SECURE`
|
- `ACS_COOKIE_SECURE`
|
||||||
- `ACS_SMS_PROVIDER`
|
- `ACS_SMS_PROVIDER`
|
||||||
- SMTP 또는 사내 메시지 API 설정
|
- 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`.
|
||||||
|
- 테스트 중 발견된 화면 상태 불일치는 대시보드, 출입신청 목록, 보고서 순으로 동일 원칙에 맞춰 보정.
|
||||||
|
|||||||
@@ -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
BIN
docs/form_sample.xlsx
Normal file
Binary file not shown.
176
docs/node-migration-plan.md
Normal file
176
docs/node-migration-plan.md
Normal 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.
|
||||||
@@ -12,11 +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 { AuditLogPage } from './pages/AuditLogPage';
|
||||||
import { DeliveryOutboxPage } from './pages/DeliveryOutboxPage';
|
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 }) => {
|
||||||
@@ -44,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 />} />
|
||||||
|
|
||||||
@@ -99,6 +102,14 @@ export default function App() {
|
|||||||
</Protected>
|
</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 />} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AccessAction,
|
AccessAction,
|
||||||
AccessRecord,
|
AccessRecord,
|
||||||
|
AdminExcelImportResult,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
BlacklistCreate,
|
BlacklistCreate,
|
||||||
@@ -8,14 +9,28 @@ import {
|
|||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
DeliveryStatus,
|
DeliveryStatus,
|
||||||
|
EmailDiagnosticsResult,
|
||||||
ExcelImportResult,
|
ExcelImportResult,
|
||||||
InsideVisitor,
|
InsideVisitor,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
PassDelivery,
|
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';
|
||||||
|
|
||||||
@@ -85,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 = () =>
|
||||||
@@ -102,6 +134,9 @@ export const createVisitRequest = (req: VisitRequestCreate) =>
|
|||||||
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);
|
||||||
@@ -111,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 }));
|
||||||
@@ -136,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');
|
||||||
@@ -154,6 +191,9 @@ 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}`;
|
||||||
|
|
||||||
@@ -165,3 +205,51 @@ export const listDeliveries = (status?: DeliveryStatus) =>
|
|||||||
request<PassDelivery[]>(`/admin/deliveries${status ? `?status=${status}` : ''}`);
|
request<PassDelivery[]>(`/admin/deliveries${status ? `?status=${status}` : ''}`);
|
||||||
export const retryDelivery = (id: number) =>
|
export const retryDelivery = (id: number) =>
|
||||||
request<PassDelivery>(`/admin/deliveries/${id}/retry`, { method: 'POST' });
|
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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DatePicker>
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
{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="/deliveries">발송내역</NavLink>}
|
||||||
{hasRole('ADMIN') && <NavLink to="/audit">감사로그</NavLink>}
|
{hasRole('ADMIN') && <NavLink to="/audit">감사로그</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
162
frontend/src/components/VisitRequestDetailDialog.tsx
Normal file
162
frontend/src/components/VisitRequestDetailDialog.tsx
Normal 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] };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,7 +116,18 @@ export const AccessConsolePage: React.FC = () => {
|
|||||||
</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>
|
||||||
@@ -103,14 +135,28 @@ export const AccessConsolePage: React.FC = () => {
|
|||||||
{insideIds.has(r.id) ? (
|
{insideIds.has(r.id) ? (
|
||||||
<span className="status-cell">
|
<span className="status-cell">
|
||||||
<span className="badge badge-green">재실중</span>
|
<span className="badge badge-green">재실중</span>
|
||||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.id)}>퇴장</button>
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
forceCheckOut(r.id);
|
||||||
|
}}
|
||||||
|
>퇴장</button>
|
||||||
</span>
|
</span>
|
||||||
) : exitedIds.has(r.id) ? (
|
) : exitedIds.has(r.id) ? (
|
||||||
<span className="badge badge-gray">금일완료</span>
|
<span className="badge badge-gray">금일완료</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="status-cell">
|
<span className="status-cell">
|
||||||
<span className="badge badge-amber">입장대기</span>
|
<span className={`badge badge-${STATUS_CLASS.APPROVED}`}>{STATUS_LABEL.APPROVED}</span>
|
||||||
<button className="btn-success" disabled={busy} onClick={() => forceCheckIn(r.id)}>입장</button>
|
<button
|
||||||
|
className="btn-success"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
forceCheckIn(r.id);
|
||||||
|
}}
|
||||||
|
>입장</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -137,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>
|
||||||
@@ -146,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>
|
||||||
@@ -161,6 +237,10 @@ export const AccessConsolePage: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detailId != null && (
|
||||||
|
<VisitRequestDetailDialog requestId={detailId} onClose={() => setDetailId(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
739
frontend/src/pages/AdminManagementPage.tsx
Normal file
739
frontend/src/pages/AdminManagementPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ import { formatDateTime } from '../status';
|
|||||||
const ACTION_LABEL: Record<AuditLog['action'], string> = {
|
const ACTION_LABEL: Record<AuditLog['action'], string> = {
|
||||||
APPROVE: '승인',
|
APPROVE: '승인',
|
||||||
REJECT: '반려',
|
REJECT: '반려',
|
||||||
|
DELETE: '삭제',
|
||||||
BLACKLIST_ADD: '블랙리스트 등록',
|
BLACKLIST_ADD: '블랙리스트 등록',
|
||||||
BLACKLIST_REMOVE: '블랙리스트 해제',
|
BLACKLIST_REMOVE: '블랙리스트 해제',
|
||||||
|
ADMIN_CONFIG_UPDATE: '시스템 설정',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
||||||
APPROVE: 'green',
|
APPROVE: 'green',
|
||||||
REJECT: 'red',
|
REJECT: 'red',
|
||||||
|
DELETE: 'red',
|
||||||
BLACKLIST_ADD: 'red',
|
BLACKLIST_ADD: 'red',
|
||||||
BLACKLIST_REMOVE: 'gray',
|
BLACKLIST_REMOVE: 'gray',
|
||||||
|
ADMIN_CONFIG_UPDATE: 'blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AuditLogPage: React.FC = () => {
|
export const AuditLogPage: React.FC = () => {
|
||||||
|
|||||||
@@ -1,29 +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, listInside, 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 [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));
|
||||||
// currently-inside visits → show "재실중" instead of the plain approved status
|
|
||||||
listInside()
|
listInside()
|
||||||
.then((rows) => setInsideIds(new Set(rows.map((r) => r.visitRequestId))))
|
.then((rows) => setInsideIds(new Set(rows.map((r) => r.visitRequestId))))
|
||||||
.catch(() => setInsideIds(new Set()));
|
.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>
|
||||||
@@ -33,47 +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>{checkOutById.has(r.id) ? formatDateTime(checkOutById.get(r.id)!) : '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
{insideIds.has(r.id) ? (
|
{insideIds.has(r.id) ? (
|
||||||
<span className="badge badge-green">재실중</span>
|
<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>
|
<span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export const LoginPage: React.FC = () => {
|
|||||||
{busy ? '로그인 중…' : '로그인'}
|
{busy ? '로그인 중…' : '로그인'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="hint">테스트 계정: a(관리자) / s(보안) / h(호스트) · 비밀번호 1</p>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
387
frontend/src/pages/PublicVisitApplicationPage.tsx
Normal file
387
frontend/src/pages/PublicVisitApplicationPage.tsx
Normal 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(/[0-9]/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
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 { 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');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
@@ -18,6 +20,53 @@ function firstOfMonthISO(): string {
|
|||||||
export const ReportPage: React.FC = () => {
|
export const ReportPage: React.FC = () => {
|
||||||
const [from, setFrom] = useState(firstOfMonthISO());
|
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
|
||||||
@@ -37,7 +86,6 @@ export const ReportPage: React.FC = () => {
|
|||||||
<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>
|
||||||
@@ -47,9 +95,62 @@ export const ReportPage: React.FC = () => {
|
|||||||
<span>종료일</span>
|
<span>종료일</span>
|
||||||
<DatePickerField value={to} onChange={setTo} />
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
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 { useAuth } from '../auth/AuthContext';
|
||||||
|
import { PurposeCode, VisitorApplicationView, Watcher1Settings } from '../types';
|
||||||
|
|
||||||
// 코드 시트 목록을 콤보/체크박스에 반영.
|
// 코드 시트 목록을 콤보/체크박스에 반영.
|
||||||
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
|
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
|
||||||
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
|
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
|
||||||
// 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력.
|
// 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력.
|
||||||
const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타'];
|
const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타'];
|
||||||
const PURPOSE_OPTIONS = ['점검', '작업', '견학', '회의', '청소', '기타'];
|
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 AFFILIATION_OPTIONS = [
|
const AFFILIATION_OPTIONS = [
|
||||||
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
|
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
|
||||||
@@ -17,22 +25,45 @@ const AFFILIATION_OPTIONS = [
|
|||||||
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
|
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
|
||||||
];
|
];
|
||||||
// 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지).
|
// 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지).
|
||||||
const FIXED_WATCHER1 = { name: '류관순', team: 'IT전략국', contact: '313' };
|
const FALLBACK_WATCHER1: Watcher1Settings = { name: '류관순', team: 'IT전략국', contact: '313' };
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
/** True if the datetime's calendar date is before today (time-of-day ignored). */
|
const NEXT_DAY_EXIT_MESSAGE = `퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다
|
||||||
const isPastDate = (iso: string): boolean => {
|
예컨대, 2026.07.16 18:00~2026.07.17 04:00 이라면, 아래와 같이 2건으로 등록해야 합니다.
|
||||||
const d = new Date(iso);
|
1건) 2026.07.16 18:00~2026.07.16 24:00
|
||||||
if (Number.isNaN(d.getTime())) return false;
|
2건) 2026.07.17 00:00~2026.07.17 04:00`;
|
||||||
const day = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
||||||
const today = new Date();
|
const EXIT_BEFORE_ENTRY_MESSAGE = '퇴장일시는 출입일시 이후로 입력해야 합니다.';
|
||||||
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
||||||
return day < todayStart;
|
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: '',
|
||||||
@@ -50,16 +81,33 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
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();
|
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>) =>
|
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
...f,
|
...f,
|
||||||
@@ -68,6 +116,47 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
: f.serverRooms.filter((r) => r !== 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 '방문자 이름을 입력하세요.';
|
||||||
@@ -78,13 +167,13 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.';
|
return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.';
|
||||||
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.';
|
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 (isPastDate(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;
|
||||||
};
|
};
|
||||||
@@ -94,6 +183,9 @@ 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);
|
||||||
@@ -101,18 +193,23 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const roomZone = form.room === '기타' ? form.roomEtc.trim() : form.room;
|
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,
|
||||||
serverRooms: form.serverRooms,
|
serverRooms: form.serverRooms,
|
||||||
roomZone: roomZone || undefined,
|
roomZone: roomZone || undefined,
|
||||||
purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose,
|
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,
|
workName: form.workName.trim() || undefined,
|
||||||
watcher2Name: form.watcher2Name.trim() || undefined,
|
watcher2Name: form.watcher2Name.trim() || undefined,
|
||||||
watcher2Team: form.watcher2Team.trim() || undefined,
|
watcher2Team: form.watcher2Team.trim() || undefined,
|
||||||
watcher2Contact: form.watcher2Contact.trim() || undefined,
|
watcher2Contact: formatPhoneLike(form.watcher2Contact.trim()) || undefined,
|
||||||
visitFrom: form.visitFrom,
|
visitFrom: form.visitFrom,
|
||||||
visitTo: form.visitTo,
|
visitTo: form.visitTo,
|
||||||
});
|
});
|
||||||
@@ -126,12 +223,54 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-head"><h2>출입 신청</h2></div>
|
<div className="page-head">
|
||||||
|
<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>
|
||||||
|
<fieldset className="form-group span-2">
|
||||||
|
<legend>방문자</legend>
|
||||||
|
<div className="group-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>방문자 이름 *</span>
|
<span>방문자 이름 <b className="required">*</b></span>
|
||||||
<input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus />
|
<input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -139,21 +278,16 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
<input value={form.company} onChange={update('company')} />
|
<input value={form.company} onChange={update('company')} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>연락처 * <em className="hint-inline">: 입력형식은 010-0000-0000 로 작성해 주세요.</em></span>
|
<span>연락처 <b className="required">*</b></span>
|
||||||
<input type="tel" value={form.contact} onChange={update('contact')} placeholder="010-0000-0000" />
|
<input className="phone-input" type="text" inputMode="tel" autoComplete="off" value={form.contact} onChange={updateFormattedContact('contact')} placeholder="010-0000-0000" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>이메일</span>
|
<span>이메일</span>
|
||||||
<input className="ime-en" type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" />
|
<input className="ime-en" type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" />
|
||||||
</label>
|
</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')} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="field span-2">
|
|
||||||
<span>출입 전산실 <em className="hint-inline">: 선택한 전산실마다 출입증(QR)이 각각 발급됩니다.</em></span>
|
|
||||||
<div className="checkbox-row">
|
<div className="checkbox-row">
|
||||||
{SERVER_ROOM_OPTIONS.map((z) => (
|
{SERVER_ROOM_OPTIONS.map((z) => (
|
||||||
<label key={z} className="checkbox-inline">
|
<label key={z} className="checkbox-inline">
|
||||||
@@ -177,32 +311,37 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
{form.room === '기타' ? (
|
{form.room === '기타' ? (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>기타 구역 입력 *</span>
|
<span>기타 구역 입력 <b className="required">*</b></span>
|
||||||
<input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" />
|
<input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" />
|
||||||
</label>
|
</label>
|
||||||
) : <div />}
|
) : null}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>출입 목적 *</span>
|
<span>차량번호</span>
|
||||||
|
<input className="ime-ko" value={form.vehicleNo} onChange={update('vehicleNo')} placeholder="차량번호가 5부제에 해당될 경우 출입이 제한됩니다." />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>출입 목적 <b className="required">*</b></span>
|
||||||
<select value={form.purpose} onChange={update('purpose')}>
|
<select value={form.purpose} onChange={update('purpose')}>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
{PURPOSE_OPTIONS.map((p) => <option key={p} value={p}>{p}</option>)}
|
{purposeCodes.map((p) => <option key={p.code} value={p.code}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{form.purpose === '기타' ? (
|
{selectedPurpose?.customAllowed ? (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>기타 목적 입력 *</span>
|
<span>기타 목적 입력 <b className="required">*</b></span>
|
||||||
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
|
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
|
||||||
</label>
|
</label>
|
||||||
) : <div />}
|
) : null}
|
||||||
|
|
||||||
<label className="field span-2">
|
<label className="field">
|
||||||
<span>작업명 <em className="hint-inline">: 구체적인 작업 내용 (예: Active-Active 회의)</em></span>
|
<span>작업명</span>
|
||||||
<input className="ime-ko" value={form.workName} onChange={update('workName')} placeholder="작업 내용을 입력하세요 (선택)" />
|
<input className="ime-ko" value={form.workName} onChange={update('workName')} placeholder="작업 내용을 구체적으로 입력하세요" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>출입 일시 *</span>
|
<span>출입 일시 <b className="required">*</b></span>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={form.visitFrom}
|
value={form.visitFrom}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))}
|
||||||
@@ -210,19 +349,21 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>퇴실 일시 *</span>
|
<span>퇴실 예정일시 <b className="required">*</b></span>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={form.visitTo}
|
value={form.visitTo}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))}
|
||||||
placeholder="퇴실 일시 선택"
|
placeholder="퇴실 예정일시 선택"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="form-section span-2">
|
|
||||||
출입통제담당자 <em className="hint-inline">: 로그인한 본인 정보로 자동 등록됩니다.</em>
|
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="form-group span-2">
|
||||||
|
<legend>출입통제담당자</legend>
|
||||||
|
<div className="group-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>직원명</span>
|
<span>이름</span>
|
||||||
<input value={user?.fullName ?? ''} readOnly />
|
<input value={user?.fullName ?? ''} readOnly />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -231,26 +372,31 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>연락처</span>
|
<span>연락처</span>
|
||||||
<input value={user?.email ?? ''} readOnly />
|
<input className="phone-input" value="" placeholder="내선번호/휴대폰번호" readOnly />
|
||||||
</label>
|
</label>
|
||||||
<div />
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div className="form-section span-2">현장감시자1 <em className="hint-inline">: 고정 인원 (자동 지정)</em></div>
|
<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">
|
<label className="field">
|
||||||
<span>이름</span>
|
<span>이름</span>
|
||||||
<input value={FIXED_WATCHER1.name} readOnly />
|
<input value={watcher1.name} readOnly />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>소속</span>
|
<span>소속</span>
|
||||||
<input value={FIXED_WATCHER1.team} readOnly />
|
<input value={watcher1.team} readOnly />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>연락처</span>
|
<span>연락처</span>
|
||||||
<input value={FIXED_WATCHER1.contact} readOnly />
|
<input className="phone-input" value={watcher1.contact} readOnly />
|
||||||
</label>
|
</label>
|
||||||
<div />
|
</div>
|
||||||
|
|
||||||
<div className="form-section span-2">현장감시자2 <em className="hint-inline">: 작업을 입회할 상주직원</em></div>
|
<div className="subsection-title">현장감시자2 <em className="hint-inline">: 작업을 입회할 상주직원</em></div>
|
||||||
|
<div className="group-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>이름</span>
|
<span>이름</span>
|
||||||
<input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} />
|
<input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} />
|
||||||
@@ -264,19 +410,22 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>연락처</span>
|
<span>연락처</span>
|
||||||
<input value={form.watcher2Contact} onChange={update('watcher2Contact')} placeholder="내선/휴대번호" />
|
<input className="phone-input" type="text" inputMode="tel" value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" />
|
||||||
</label>
|
</label>
|
||||||
<div />
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -90,7 +136,12 @@ export const VisitRequestListPage: React.FC = () => {
|
|||||||
<td>{r.purpose || '-'}</td>
|
<td>{r.purpose || '-'}</td>
|
||||||
<td>{r.workName || '-'}</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>
|
||||||
@@ -98,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>
|
||||||
))}
|
))}
|
||||||
@@ -116,6 +170,7 @@ export const VisitRequestListPage: React.FC = () => {
|
|||||||
onCancel={() => setCancelingId(null)}
|
onCancel={() => setCancelingId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,6 +237,54 @@ 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. */
|
/* 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; }
|
.form-actions .form-error { margin-right: auto; align-self: center; color: #b91c1c; font-size: 14px; font-weight: 600; }
|
||||||
@@ -191,15 +303,16 @@ 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. 전산실 다중 선택) */
|
/* Inline checkbox group (e.g. 전산실 다중 선택) */
|
||||||
.checkbox-row { display: flex; flex-wrap: wrap; gap: 18px; padding: 8px 2px; }
|
.checkbox-row { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 2px; }
|
||||||
.checkbox-inline { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: #334155; }
|
.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; }
|
.checkbox-inline input { width: 16px; height: 16px; flex-shrink: 0; }
|
||||||
|
|
||||||
/* ===== Buttons ===== */
|
/* ===== Buttons ===== */
|
||||||
@@ -220,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;
|
||||||
@@ -242,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 {
|
||||||
@@ -252,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; }
|
||||||
@@ -260,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; }
|
||||||
@@ -277,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; }
|
||||||
@@ -313,6 +647,20 @@ 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,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'
|
||||||
@@ -42,6 +105,7 @@ 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;
|
||||||
@@ -52,6 +116,8 @@ export interface VisitRequestCreate {
|
|||||||
/** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */
|
/** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */
|
||||||
roomZone?: string;
|
roomZone?: string;
|
||||||
purpose: string;
|
purpose: string;
|
||||||
|
purposeCode?: string;
|
||||||
|
purposeDetail?: string;
|
||||||
/** 작업명 — optional concrete task detail, stored separately from purpose. */
|
/** 작업명 — optional concrete task detail, stored separately from purpose. */
|
||||||
workName?: string;
|
workName?: string;
|
||||||
/** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */
|
/** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */
|
||||||
@@ -62,17 +128,75 @@ export interface VisitRequestCreate {
|
|||||||
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;
|
workName?: string;
|
||||||
controlName?: string;
|
controlName?: string;
|
||||||
controlTeam?: string;
|
controlTeam?: string;
|
||||||
@@ -90,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;
|
||||||
@@ -109,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;
|
||||||
@@ -125,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;
|
||||||
@@ -133,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;
|
||||||
@@ -170,7 +331,7 @@ export interface AuditLog {
|
|||||||
at: string;
|
at: string;
|
||||||
actorId?: number;
|
actorId?: number;
|
||||||
actorUsername?: string;
|
actorUsername?: string;
|
||||||
action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE';
|
action: 'APPROVE' | 'REJECT' | 'DELETE' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE' | 'ADMIN_CONFIG_UPDATE';
|
||||||
targetType?: string;
|
targetType?: string;
|
||||||
targetId?: number;
|
targetId?: number;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
|||||||
106
migrations/001_init.sql
Normal file
106
migrations/001_init.sql
Normal 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);
|
||||||
15
migrations/002_audit_log.sql
Normal file
15
migrations/002_audit_log.sql
Normal 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);
|
||||||
15
migrations/003_pass_delivery.sql
Normal file
15
migrations/003_pass_delivery.sql
Normal 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);
|
||||||
12
migrations/004_visit_request_contact_fields.sql
Normal file
12
migrations/004_visit_request_contact_fields.sql
Normal 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);
|
||||||
48
migrations/005_seed_test_users.sql
Normal file
48
migrations/005_seed_test_users.sql
Normal 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;
|
||||||
49
migrations/006_admin_config.sql
Normal file
49
migrations/006_admin_config.sql
Normal 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
43
migrations/007_teams.sql
Normal 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;
|
||||||
43
migrations/008_visitor_applications.sql
Normal file
43
migrations/008_visitor_applications.sql
Normal 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);
|
||||||
7
migrations/009_visitor_application_staff.sql
Normal file
7
migrations/009_visitor_application_staff.sql
Normal 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
3188
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal 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
30
scripts/check-db.mjs
Normal 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
1
scripts/check-minio.mjs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
console.log('S3 SKIPPED: ACS does not currently use S3/MinIO storage.');
|
||||||
186
scripts/deployment-check.mjs
Normal file
186
scripts/deployment-check.mjs
Normal 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');
|
||||||
@@ -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
|
||||||
|
|||||||
|
43
server/config/env.ts
Normal file
43
server/config/env.ts
Normal 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
56
server/db/migrate.ts
Normal 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
23
server/db/pool.ts
Normal 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;
|
||||||
|
}
|
||||||
15
server/http/apiResponse.ts
Normal file
15
server/http/apiResponse.ts
Normal 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
33
server/http/errors.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
57
server/index.ts
Normal file
57
server/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import session from 'express-session';
|
||||||
|
import connectPgSimple from 'connect-pg-simple';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { apiRouter } from './routes/index.js';
|
||||||
|
import { platformStatusRouter } from './routes/health.js';
|
||||||
|
import { errorHandler } from './http/errors.js';
|
||||||
|
import { getPool } from './db/pool.js';
|
||||||
|
import { runMigrations } from './db/migrate.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const frontendDist = path.resolve(process.cwd(), 'frontend', 'dist');
|
||||||
|
const PgSession = connectPgSimple(session);
|
||||||
|
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(session({
|
||||||
|
name: 'acs.sid',
|
||||||
|
secret: env.sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
store: new PgSession({
|
||||||
|
pool: getPool(),
|
||||||
|
tableName: 'user_sessions',
|
||||||
|
createTableIfMissing: true,
|
||||||
|
}),
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: env.cookieSecure,
|
||||||
|
maxAge: 1000 * 60 * 60 * 8,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(platformStatusRouter);
|
||||||
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
|
app.use(express.static(frontendDist));
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(frontendDist, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runMigrations();
|
||||||
|
app.listen(env.port, () => {
|
||||||
|
console.log(`ACS Node app listening on port ${env.port}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ACS startup failed', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
746
server/routes/admin.ts
Normal file
746
server/routes/admin.ts
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import { ok } from '../http/apiResponse.js';
|
||||||
|
import { ApiError } from '../http/errors.js';
|
||||||
|
import { query } from '../db/pool.js';
|
||||||
|
import { diagnoseEmailConnectivity } from '../services/mail.js';
|
||||||
|
import { diagnoseSmsConnectivity } from '../services/sms.js';
|
||||||
|
import { parseRole, parseRoles, requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface TeamRow extends Row {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
default_roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurposeRow extends Row {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort_order: number;
|
||||||
|
active: boolean;
|
||||||
|
custom_allowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminUserRow extends Row {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
full_name: string;
|
||||||
|
department: string | null;
|
||||||
|
team_id: string | null;
|
||||||
|
team_code: string | null;
|
||||||
|
team_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportRow {
|
||||||
|
rowNumber: number;
|
||||||
|
status: 'CREATE' | 'UPDATE' | 'ERROR';
|
||||||
|
key?: string;
|
||||||
|
summary?: string;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
totalRows: number;
|
||||||
|
createCount: number;
|
||||||
|
updateCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
warningCount: number;
|
||||||
|
applied: boolean;
|
||||||
|
success: boolean;
|
||||||
|
rows: ImportRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisitRequestResetResult {
|
||||||
|
visitRequests: number;
|
||||||
|
visitors: number;
|
||||||
|
approvals: number;
|
||||||
|
accessEvents: number;
|
||||||
|
passDeliveries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminRouterDeps {
|
||||||
|
query: QueryFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
handler(req, res, next).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTeam(row: TeamRow) {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
active: row.active,
|
||||||
|
defaultRoles: row.default_roles ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPurpose(row: PurposeRow) {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
sortOrder: row.sort_order,
|
||||||
|
active: row.active,
|
||||||
|
customAllowed: row.custom_allowed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAdminUser(row: AdminUserRow) {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
username: row.username,
|
||||||
|
fullName: row.full_name,
|
||||||
|
department: row.department ?? undefined,
|
||||||
|
teamId: row.team_id ? Number(row.team_id) : undefined,
|
||||||
|
teamCode: row.team_code ?? undefined,
|
||||||
|
teamName: row.team_name ?? undefined,
|
||||||
|
email: row.email ?? undefined,
|
||||||
|
enabled: row.enabled,
|
||||||
|
locked: row.locked,
|
||||||
|
roles: row.roles ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function audit(dbQuery: QueryFn, actor: UserRow, targetType: string, targetId: number | null, detail: string) {
|
||||||
|
await dbQuery(
|
||||||
|
`
|
||||||
|
INSERT INTO audit_logs (created_at, updated_at, actor_id, actor_username, action, target_type, target_id, detail)
|
||||||
|
VALUES (now(), now(), $1, $2, 'ADMIN_CONFIG_UPDATE', $3, $4, $5)
|
||||||
|
`,
|
||||||
|
[Number(actor.id), actor.username, targetType, targetId, detail],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTeams(dbQuery: QueryFn) {
|
||||||
|
const result = await dbQuery<TeamRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.code,
|
||||||
|
t.name,
|
||||||
|
t.active,
|
||||||
|
COALESCE(array_agg(tdr.role ORDER BY tdr.role) FILTER (WHERE tdr.role IS NOT NULL), '{}') AS default_roles
|
||||||
|
FROM teams t
|
||||||
|
LEFT JOIN team_default_roles tdr ON tdr.team_id = t.id
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.name ASC
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
return result.rows.map(toTeam);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUsers(dbQuery: QueryFn) {
|
||||||
|
const result = await dbQuery<AdminUserRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.full_name,
|
||||||
|
u.department,
|
||||||
|
u.team_id,
|
||||||
|
t.code AS team_code,
|
||||||
|
t.name AS team_name,
|
||||||
|
u.email,
|
||||||
|
u.enabled,
|
||||||
|
u.locked,
|
||||||
|
COALESCE(array_agg(ur.role ORDER BY ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN teams t ON t.id = u.team_id
|
||||||
|
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
GROUP BY u.id, t.id
|
||||||
|
ORDER BY u.username ASC
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
return result.rows.map(toAdminUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setTeamRoles(dbQuery: QueryFn, teamId: number, roles: string[]) {
|
||||||
|
await dbQuery('DELETE FROM team_default_roles WHERE team_id = $1', [teamId]);
|
||||||
|
for (const role of roles) {
|
||||||
|
await dbQuery('INSERT INTO team_default_roles (team_id, role) VALUES ($1, $2)', [teamId, role]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setUserRoles(dbQuery: QueryFn, userId: number, roles: string[]) {
|
||||||
|
await dbQuery('DELETE FROM user_roles WHERE user_id = $1', [userId]);
|
||||||
|
for (const role of roles) {
|
||||||
|
await dbQuery('INSERT INTO user_roles (user_id, role) VALUES ($1, $2)', [userId, role]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTeamById(dbQuery: QueryFn, id: number): Promise<TeamRow | undefined> {
|
||||||
|
const result = await dbQuery<TeamRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.id, t.code, t.name, t.active,
|
||||||
|
COALESCE(array_agg(tdr.role ORDER BY tdr.role) FILTER (WHERE tdr.role IS NOT NULL), '{}') AS default_roles
|
||||||
|
FROM teams t
|
||||||
|
LEFT JOIN team_default_roles tdr ON tdr.team_id = t.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
GROUP BY t.id
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUserRowById(dbQuery: QueryFn, id: number): Promise<AdminUserRow | undefined> {
|
||||||
|
const result = await dbQuery<AdminUserRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.id, u.username, u.full_name, u.department, u.team_id,
|
||||||
|
t.code AS team_code, t.name AS team_name,
|
||||||
|
u.email, u.enabled, u.locked,
|
||||||
|
COALESCE(array_agg(ur.role ORDER BY ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN teams t ON t.id = u.team_id
|
||||||
|
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
WHERE u.id = $1
|
||||||
|
GROUP BY u.id, t.id
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value: unknown, label: string): string {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new ApiError(400, `${label}은(는) 필수입니다.`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleDisplay(roles: string[]) {
|
||||||
|
return `[${roles.join(', ')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workbookFromBuffer(file?: Express.Multer.File): Promise<ExcelJS.Workbook> {
|
||||||
|
if (!file) {
|
||||||
|
throw new ApiError(400, '업로드할 파일을 선택하세요.');
|
||||||
|
}
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(file.buffer as unknown as ExcelJS.Buffer);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(row: ExcelJS.Row, index: number): string {
|
||||||
|
const value = row.getCell(index).value;
|
||||||
|
if (value == null) return '';
|
||||||
|
if (typeof value === 'object' && 'text' in value) return String(value.text ?? '').trim();
|
||||||
|
if (typeof value === 'object' && 'result' in value) return String(value.result ?? '').trim();
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRoles(raw: string, errors: string[]): string[] {
|
||||||
|
const roles: string[] = [];
|
||||||
|
for (const token of raw.split(/[,;/\s]+/)) {
|
||||||
|
if (!token.trim()) continue;
|
||||||
|
try {
|
||||||
|
roles.push(parseRole(token));
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(roles)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamImportResult(dbQuery: QueryFn, file?: Express.Multer.File): Promise<{
|
||||||
|
result: ImportResult;
|
||||||
|
rows: Array<{ rowNumber: number; code: string; name: string; roles: string[]; errors: string[] }>;
|
||||||
|
}> {
|
||||||
|
const workbook = await workbookFromBuffer(file);
|
||||||
|
const sheet = workbook.worksheets[0];
|
||||||
|
const parsed: Array<{ rowNumber: number; code: string; name: string; roles: string[]; errors: string[] }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
sheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber === 1) return;
|
||||||
|
const code = cellText(row, 1).toUpperCase();
|
||||||
|
const name = cellText(row, 2);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const roles = splitRoles(cellText(row, 3), errors);
|
||||||
|
if (!code && !name && roles.length === 0) return;
|
||||||
|
if (!code) errors.push('팀 코드은(는) 필수입니다.');
|
||||||
|
if (!name) errors.push('팀명은(는) 필수입니다.');
|
||||||
|
if (roles.length === 0) errors.push('기본권한은 최소 1개 이상 필요합니다.');
|
||||||
|
if (seen.has(code)) errors.push('파일 안에 같은 팀 코드가 중복되었습니다.');
|
||||||
|
seen.add(code);
|
||||||
|
parsed.push({ rowNumber, code, name, roles, errors });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: ImportResult = {
|
||||||
|
totalRows: parsed.length,
|
||||||
|
createCount: 0,
|
||||||
|
updateCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
warningCount: 0,
|
||||||
|
applied: false,
|
||||||
|
success: true,
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of parsed) {
|
||||||
|
const codeResult = row.code
|
||||||
|
? await dbQuery<{ id: string }>('SELECT id FROM teams WHERE upper(code) = upper($1)', [row.code])
|
||||||
|
: { rows: [] };
|
||||||
|
const nameResult = row.name
|
||||||
|
? await dbQuery<{ code: string }>('SELECT code FROM teams WHERE name = $1 AND upper(code) <> upper($2)', [row.name, row.code])
|
||||||
|
: { rows: [] };
|
||||||
|
if (nameResult.rows[0]) {
|
||||||
|
row.errors.push(`이미 다른 팀 코드에서 사용하는 팀명입니다: ${nameResult.rows[0].code}`);
|
||||||
|
}
|
||||||
|
const exists = codeResult.rows.length > 0;
|
||||||
|
if (row.errors.length === 0) {
|
||||||
|
if (exists) result.updateCount += 1;
|
||||||
|
else result.createCount += 1;
|
||||||
|
}
|
||||||
|
result.rows.push({
|
||||||
|
rowNumber: row.rowNumber,
|
||||||
|
status: row.errors.length > 0 ? 'ERROR' : exists ? 'UPDATE' : 'CREATE',
|
||||||
|
key: row.code,
|
||||||
|
summary: `${row.name} / ${roleDisplay(row.roles)}`,
|
||||||
|
errors: row.errors,
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.errorCount = result.rows.reduce((sum, row) => sum + row.errors.length, 0);
|
||||||
|
result.success = result.errorCount === 0;
|
||||||
|
if (!result.success) {
|
||||||
|
result.createCount = 0;
|
||||||
|
result.updateCount = 0;
|
||||||
|
}
|
||||||
|
return { result, rows: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userRoleImportResult(dbQuery: QueryFn, file?: Express.Multer.File): Promise<{
|
||||||
|
result: ImportResult;
|
||||||
|
rows: Array<{ rowNumber: number; username: string; fullName: string; teamName: string; roles: string[]; errors: string[] }>;
|
||||||
|
}> {
|
||||||
|
const workbook = await workbookFromBuffer(file);
|
||||||
|
const sheet = workbook.worksheets[0];
|
||||||
|
const parsed: Array<{ rowNumber: number; username: string; fullName: string; teamName: string; roles: string[]; errors: string[] }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
sheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber === 1) return;
|
||||||
|
const username = cellText(row, 1);
|
||||||
|
const fullName = cellText(row, 2);
|
||||||
|
const teamName = cellText(row, 3);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const roles = splitRoles(cellText(row, 4), errors);
|
||||||
|
if (!username && !fullName && !teamName && roles.length === 0) return;
|
||||||
|
if (!username) errors.push('아이디은(는) 필수입니다.');
|
||||||
|
if (!fullName) errors.push('이름은(는) 필수입니다.');
|
||||||
|
if (!teamName) errors.push('팀명은(는) 필수입니다.');
|
||||||
|
if (roles.length === 0) errors.push('권한은 최소 1개 이상 필요합니다.');
|
||||||
|
if (seen.has(username.toLowerCase())) errors.push('파일 안에 같은 아이디가 중복되었습니다.');
|
||||||
|
seen.add(username.toLowerCase());
|
||||||
|
parsed.push({ rowNumber, username, fullName, teamName, roles, errors });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: ImportResult = {
|
||||||
|
totalRows: parsed.length,
|
||||||
|
createCount: 0,
|
||||||
|
updateCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
warningCount: 0,
|
||||||
|
applied: false,
|
||||||
|
success: true,
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of parsed) {
|
||||||
|
const userResult = row.username
|
||||||
|
? await dbQuery<{ id: string; full_name: string }>('SELECT id, full_name FROM users WHERE username = $1', [row.username])
|
||||||
|
: { rows: [] };
|
||||||
|
const teamResult = row.teamName
|
||||||
|
? await dbQuery<{ id: string; active: boolean }>('SELECT id, active FROM teams WHERE name = $1', [row.teamName])
|
||||||
|
: { rows: [] };
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
const team = teamResult.rows[0];
|
||||||
|
if (!user) row.errors.push('존재하지 않는 아이디입니다.');
|
||||||
|
else if (user.full_name !== row.fullName) row.errors.push(`등록된 이름과 일치하지 않습니다: ${user.full_name}`);
|
||||||
|
if (!team) row.errors.push('존재하지 않는 팀명입니다.');
|
||||||
|
else if (!team.active) row.errors.push('사용 중지된 팀입니다.');
|
||||||
|
if (row.errors.length === 0) result.updateCount += 1;
|
||||||
|
result.rows.push({
|
||||||
|
rowNumber: row.rowNumber,
|
||||||
|
status: row.errors.length > 0 ? 'ERROR' : 'UPDATE',
|
||||||
|
key: row.username,
|
||||||
|
summary: `${row.fullName} / ${row.teamName} / ${roleDisplay(row.roles)}`,
|
||||||
|
errors: row.errors,
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.errorCount = result.rows.reduce((sum, row) => sum + row.errors.length, 0);
|
||||||
|
result.success = result.errorCount === 0;
|
||||||
|
if (!result.success) result.updateCount = 0;
|
||||||
|
return { result, rows: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTemplate(res: Response, filename: string, headers: string[], samples: string[][]) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('template');
|
||||||
|
sheet.addRow(headers);
|
||||||
|
for (const sample of samples) sheet.addRow(sample);
|
||||||
|
sheet.getRow(1).font = { bold: true };
|
||||||
|
sheet.columns.forEach((column) => {
|
||||||
|
column.width = 18;
|
||||||
|
});
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
|
.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
||||||
|
.send(Buffer.from(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminRouter(deps: AdminRouterDeps = { query }): Router {
|
||||||
|
const router = Router();
|
||||||
|
const dbQuery = deps.query;
|
||||||
|
|
||||||
|
router.get('/purpose-codes', asyncRoute(async (req, res) => {
|
||||||
|
await requireAdmin(dbQuery, req);
|
||||||
|
const result = await dbQuery<PurposeRow>('SELECT * FROM purpose_codes ORDER BY sort_order ASC, name ASC');
|
||||||
|
ok(res, result.rows.map(toPurpose));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/purpose-codes', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const code = requiredString(req.body?.code, '코드').toUpperCase();
|
||||||
|
const name = requiredString(req.body?.name, '표시명');
|
||||||
|
const sortOrder = Number(req.body?.sortOrder ?? 100);
|
||||||
|
const active = Boolean(req.body?.active);
|
||||||
|
const customAllowed = Boolean(req.body?.customAllowed);
|
||||||
|
const result = await dbQuery<PurposeRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO purpose_codes (created_at, updated_at, code, name, sort_order, active, custom_allowed)
|
||||||
|
VALUES (now(), now(), $1, $2, $3, $4, $5)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[code, name, sortOrder, active, customAllowed],
|
||||||
|
);
|
||||||
|
await audit(dbQuery, actor, 'PURPOSE_CODE', Number(result.rows[0].id), `출입목적 코드 추가: ${code}/${name}`);
|
||||||
|
ok(res, toPurpose(result.rows[0]));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/purpose-codes/:id', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const code = requiredString(req.body?.code, '코드').toUpperCase();
|
||||||
|
const name = requiredString(req.body?.name, '표시명');
|
||||||
|
const result = await dbQuery<PurposeRow>(
|
||||||
|
`
|
||||||
|
UPDATE purpose_codes
|
||||||
|
SET code = $1, name = $2, sort_order = $3, active = $4, custom_allowed = $5, updated_at = now()
|
||||||
|
WHERE id = $6
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[code, name, Number(req.body?.sortOrder ?? 100), Boolean(req.body?.active), Boolean(req.body?.customAllowed), id],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) throw new ApiError(404, '출입목적 코드를 찾을 수 없습니다.');
|
||||||
|
await audit(dbQuery, actor, 'PURPOSE_CODE', id, `출입목적 코드 수정: ${code}/${name}`);
|
||||||
|
ok(res, toPurpose(result.rows[0]));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/settings/watcher1', asyncRoute(async (req, res) => {
|
||||||
|
await requireAdmin(dbQuery, req);
|
||||||
|
ok(res, await watcher1(dbQuery));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/settings/watcher1', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
await setSetting(dbQuery, 'watcher1.name', requiredString(req.body?.name, '이름'));
|
||||||
|
await setSetting(dbQuery, 'watcher1.team', requiredString(req.body?.team, '소속'));
|
||||||
|
await setSetting(dbQuery, 'watcher1.contact', requiredString(req.body?.contact, '연락처'));
|
||||||
|
await audit(dbQuery, actor, 'SYSTEM_SETTING', null, `현장감시자1 설정 수정: ${req.body.name}/${req.body.team}`);
|
||||||
|
ok(res, await watcher1(dbQuery));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/sms/diagnostics', asyncRoute(async (req, res) => {
|
||||||
|
await requireAdmin(dbQuery, req);
|
||||||
|
ok(res, await diagnoseSmsConnectivity());
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/email/diagnostics', asyncRoute(async (req, res) => {
|
||||||
|
await requireAdmin(dbQuery, req);
|
||||||
|
ok(res, await diagnoseEmailConnectivity());
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/visit-requests/reset-test-data', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
if (String(req.body?.confirm ?? '').replace(/\s/g, '').trim() !== '초기화') {
|
||||||
|
throw new ApiError(400, '확인 입력란에 "초기화"를 입력하세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRequests = await dbQuery<{ id: string; visitor_id: string }>(
|
||||||
|
'SELECT id, visitor_id FROM visit_requests',
|
||||||
|
);
|
||||||
|
const visitorIds = [...new Set(targetRequests.rows.map((row) => Number(row.visitor_id)))];
|
||||||
|
|
||||||
|
const passDeliveries = await dbQuery<{ id: string }>('DELETE FROM pass_deliveries RETURNING id');
|
||||||
|
const approvals = await dbQuery<{ id: string }>('DELETE FROM approvals RETURNING id');
|
||||||
|
const accessEvents = await dbQuery<{ id: string }>('DELETE FROM access_events RETURNING id');
|
||||||
|
const visitRequests = await dbQuery<{ id: string }>('DELETE FROM visit_requests RETURNING id');
|
||||||
|
const visitors = visitorIds.length > 0
|
||||||
|
? await dbQuery<{ id: string }>(
|
||||||
|
`
|
||||||
|
DELETE FROM visitors v
|
||||||
|
WHERE v.id = ANY($1::bigint[])
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM visit_requests vr WHERE vr.visitor_id = v.id)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[visitorIds],
|
||||||
|
)
|
||||||
|
: { rows: [] };
|
||||||
|
|
||||||
|
const result: VisitRequestResetResult = {
|
||||||
|
visitRequests: visitRequests.rows.length,
|
||||||
|
visitors: visitors.rows.length,
|
||||||
|
approvals: approvals.rows.length,
|
||||||
|
accessEvents: accessEvents.rows.length,
|
||||||
|
passDeliveries: passDeliveries.rows.length,
|
||||||
|
};
|
||||||
|
await audit(
|
||||||
|
dbQuery,
|
||||||
|
actor,
|
||||||
|
'VISIT_REQUEST',
|
||||||
|
null,
|
||||||
|
`출입신청 테스트 데이터 초기화: 신청 ${result.visitRequests}건, 방문자 ${result.visitors}건, 승인 ${result.approvals}건, 입퇴장 ${result.accessEvents}건, 발송 ${result.passDeliveries}건`,
|
||||||
|
);
|
||||||
|
ok(res, result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/teams', asyncRoute(async (req, res) => {
|
||||||
|
await requireAdmin(dbQuery, req);
|
||||||
|
ok(res, await listTeams(dbQuery));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/teams', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const code = requiredString(req.body?.code, '팀 코드').toUpperCase();
|
||||||
|
const name = requiredString(req.body?.name, '팀명');
|
||||||
|
const roles = parseRoles(req.body?.defaultRoles);
|
||||||
|
const result = await dbQuery<TeamRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO teams (created_at, updated_at, code, name, active)
|
||||||
|
VALUES (now(), now(), $1, $2, $3)
|
||||||
|
RETURNING id, code, name, active, '{}'::varchar[] AS default_roles
|
||||||
|
`,
|
||||||
|
[code, name, Boolean(req.body?.active)],
|
||||||
|
);
|
||||||
|
await setTeamRoles(dbQuery, Number(result.rows[0].id), roles);
|
||||||
|
await audit(dbQuery, actor, 'TEAM', Number(result.rows[0].id), `팀 추가: ${code}/${name}`);
|
||||||
|
ok(res, toTeam((await findTeamById(dbQuery, Number(result.rows[0].id)))!));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/teams/:id', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const code = requiredString(req.body?.code, '팀 코드').toUpperCase();
|
||||||
|
const name = requiredString(req.body?.name, '팀명');
|
||||||
|
const roles = parseRoles(req.body?.defaultRoles);
|
||||||
|
const old = await findTeamById(dbQuery, id);
|
||||||
|
if (!old) throw new ApiError(404, '팀을 찾을 수 없습니다.');
|
||||||
|
const result = await dbQuery<TeamRow>(
|
||||||
|
`
|
||||||
|
UPDATE teams SET code = $1, name = $2, active = $3, updated_at = now()
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING id, code, name, active, '{}'::varchar[] AS default_roles
|
||||||
|
`,
|
||||||
|
[code, name, Boolean(req.body?.active), id],
|
||||||
|
);
|
||||||
|
await setTeamRoles(dbQuery, id, roles);
|
||||||
|
if (old.name !== name) {
|
||||||
|
await dbQuery('UPDATE users SET department = $1, updated_at = now() WHERE team_id = $2', [name, id]);
|
||||||
|
}
|
||||||
|
await audit(dbQuery, actor, 'TEAM', id, `팀 수정: ${code}/${name}`);
|
||||||
|
ok(res, toTeam((await findTeamById(dbQuery, Number(result.rows[0].id)))!));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/teams/:id/apply-default-roles', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const team = await findTeamById(dbQuery, id);
|
||||||
|
if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.');
|
||||||
|
const roles = team.default_roles ?? [];
|
||||||
|
if (roles.length === 0) throw new ApiError(400, '팀 기본권한이 비어 있습니다.');
|
||||||
|
const users = await dbQuery<{ id: string }>('SELECT id FROM users WHERE team_id = $1 ORDER BY username', [id]);
|
||||||
|
for (const user of users.rows) {
|
||||||
|
await setUserRoles(dbQuery, Number(user.id), roles);
|
||||||
|
}
|
||||||
|
await audit(dbQuery, actor, 'TEAM', id, `팀원 전체 기본권한 적용: ${team.name} (${users.rows.length}명)`);
|
||||||
|
ok(res, `${users.rows.length}명의 팀원에게 기본권한을 적용했습니다.`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/teams/template', asyncRoute(async (req, res) => {
|
||||||
|
await requireAdmin(dbQuery, req);
|
||||||
|
await sendTemplate(res, 'admin-teams-template.xlsx', ['팀 코드', '팀명', '기본권한'], [
|
||||||
|
['DEV2', '개발2팀', 'HOST'],
|
||||||
|
['SEC', '보안팀', 'SECURITY'],
|
||||||
|
]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/teams/upload', upload.single('file'), asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const dryRun = String(req.query.dryRun ?? 'true') !== 'false';
|
||||||
|
const { result, rows } = await teamImportResult(dbQuery, req.file);
|
||||||
|
if (!dryRun && result.success) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = await dbQuery<{ id: string }>('SELECT id FROM teams WHERE upper(code) = upper($1)', [row.code]);
|
||||||
|
let teamId: number;
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
teamId = Number(existing.rows[0].id);
|
||||||
|
await dbQuery('UPDATE teams SET name = $1, updated_at = now() WHERE id = $2', [row.name, teamId]);
|
||||||
|
} else {
|
||||||
|
const inserted = await dbQuery<{ id: string }>(
|
||||||
|
'INSERT INTO teams (created_at, updated_at, code, name, active) VALUES (now(), now(), $1, $2, TRUE) RETURNING id',
|
||||||
|
[row.code, row.name],
|
||||||
|
);
|
||||||
|
teamId = Number(inserted.rows[0].id);
|
||||||
|
}
|
||||||
|
await setTeamRoles(dbQuery, teamId, row.roles);
|
||||||
|
}
|
||||||
|
result.applied = true;
|
||||||
|
await audit(dbQuery, actor, 'TEAM', null, `팀 엑셀 업로드: 신규 ${result.createCount}건, 수정 ${result.updateCount}건`);
|
||||||
|
}
|
||||||
|
ok(res, result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/users', asyncRoute(async (req, res) => {
|
||||||
|
await requireAdmin(dbQuery, req);
|
||||||
|
ok(res, await listUsers(dbQuery));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/users/:id/team', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const teamId = Number(req.body?.teamId);
|
||||||
|
const team = await findTeamById(dbQuery, teamId);
|
||||||
|
if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.');
|
||||||
|
await dbQuery('UPDATE users SET team_id = $1, department = $2, updated_at = now() WHERE id = $3', [teamId, team.name, id]);
|
||||||
|
if (Boolean(req.body?.applyDefaultRoles)) await setUserRoles(dbQuery, id, team.default_roles ?? []);
|
||||||
|
await audit(dbQuery, actor, 'USER', id, `팀 수정: ${id} -> ${team.name}`);
|
||||||
|
ok(res, toAdminUser((await findUserRowById(dbQuery, id))!));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/users/:id/roles', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const roles = parseRoles(req.body?.roles);
|
||||||
|
await setUserRoles(dbQuery, id, roles);
|
||||||
|
await audit(dbQuery, actor, 'USER', id, `권한 수정: ${id} -> ${roles.join(',')}`);
|
||||||
|
ok(res, toAdminUser((await findUserRowById(dbQuery, id))!));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/users/:id/apply-team-default-roles', asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const user = await findUserRowById(dbQuery, Number(req.params.id));
|
||||||
|
if (!user || !user.team_id) throw new ApiError(400, '사용자에게 팀이 지정되어 있지 않습니다.');
|
||||||
|
const team = await findTeamById(dbQuery, Number(user.team_id));
|
||||||
|
if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.');
|
||||||
|
await setUserRoles(dbQuery, Number(user.id), team.default_roles ?? []);
|
||||||
|
await audit(dbQuery, actor, 'USER', Number(user.id), `팀 기본권한 적용: ${user.username} -> ${team.name}`);
|
||||||
|
ok(res, toAdminUser((await findUserRowById(dbQuery, Number(user.id)))!));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/users/roles/template', asyncRoute(async (req, res) => {
|
||||||
|
await requireAdmin(dbQuery, req);
|
||||||
|
await sendTemplate(res, 'admin-user-roles-template.xlsx', ['아이디', '이름', '팀명', '권한'], [
|
||||||
|
['h', '홍길동', '개발1팀', 'HOST'],
|
||||||
|
['s', '보안담당', '보안팀', 'SECURITY'],
|
||||||
|
]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/users/roles/upload', upload.single('file'), asyncRoute(async (req, res) => {
|
||||||
|
const actor = await requireAdmin(dbQuery, req);
|
||||||
|
const dryRun = String(req.query.dryRun ?? 'true') !== 'false';
|
||||||
|
const { result, rows } = await userRoleImportResult(dbQuery, req.file);
|
||||||
|
if (!dryRun && result.success) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const user = await dbQuery<{ id: string }>('SELECT id FROM users WHERE username = $1', [row.username]);
|
||||||
|
const team = await dbQuery<{ id: string }>('SELECT id FROM teams WHERE name = $1', [row.teamName]);
|
||||||
|
await dbQuery('UPDATE users SET team_id = $1, department = $2, updated_at = now() WHERE id = $3', [
|
||||||
|
Number(team.rows[0].id),
|
||||||
|
row.teamName,
|
||||||
|
Number(user.rows[0].id),
|
||||||
|
]);
|
||||||
|
await setUserRoles(dbQuery, Number(user.rows[0].id), row.roles);
|
||||||
|
}
|
||||||
|
result.applied = true;
|
||||||
|
await audit(dbQuery, actor, 'USER', null, `권한 엑셀 업로드: 수정 ${result.updateCount}건`);
|
||||||
|
}
|
||||||
|
ok(res, result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setting(dbQuery: QueryFn, key: string, fallback: string): Promise<string> {
|
||||||
|
const result = await dbQuery<{ setting_value: string | null }>('SELECT setting_value FROM system_settings WHERE setting_key = $1', [key]);
|
||||||
|
return result.rows[0]?.setting_value || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSetting(dbQuery: QueryFn, key: string, value: string) {
|
||||||
|
await dbQuery(
|
||||||
|
`
|
||||||
|
INSERT INTO system_settings (created_at, updated_at, setting_key, setting_value)
|
||||||
|
VALUES (now(), now(), $1, $2)
|
||||||
|
ON CONFLICT (setting_key)
|
||||||
|
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = now()
|
||||||
|
`,
|
||||||
|
[key, value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function watcher1(dbQuery: QueryFn) {
|
||||||
|
return {
|
||||||
|
name: await setting(dbQuery, 'watcher1.name', '류관순'),
|
||||||
|
team: await setting(dbQuery, 'watcher1.team', 'IT전략국'),
|
||||||
|
contact: await setting(dbQuery, 'watcher1.contact', '313'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConfigRouter(deps: AdminRouterDeps = { query }): Router {
|
||||||
|
const router = Router();
|
||||||
|
const dbQuery = deps.query;
|
||||||
|
|
||||||
|
router.get('/purpose-codes', asyncRoute(async (_req, res) => {
|
||||||
|
const result = await dbQuery<PurposeRow>(
|
||||||
|
'SELECT * FROM purpose_codes WHERE active = TRUE ORDER BY sort_order ASC, name ASC',
|
||||||
|
);
|
||||||
|
ok(res, result.rows.map(toPurpose));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/settings/watcher1', asyncRoute(async (req, res) => {
|
||||||
|
await requireCurrentUser(dbQuery, req);
|
||||||
|
ok(res, await watcher1(dbQuery));
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminRouter = createAdminRouter();
|
||||||
|
export const configRouter = createConfigRouter();
|
||||||
195
server/routes/auth.test.ts
Normal file
195
server/routes/auth.test.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import test from 'node:test';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import express from 'express';
|
||||||
|
import { createAuthRouter } from './auth.js';
|
||||||
|
import { errorHandler } from '../http/errors.js';
|
||||||
|
|
||||||
|
type QueryFn = NonNullable<Parameters<typeof createAuthRouter>[0]>['query'];
|
||||||
|
type JsonBody = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string | null;
|
||||||
|
department: string | null;
|
||||||
|
must_change_password: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryResult<T>(rows: T[]) {
|
||||||
|
return {
|
||||||
|
command: 'SELECT',
|
||||||
|
rowCount: rows.length,
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(query: QueryFn, sessionData: Record<string, unknown> = {}) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.session = {
|
||||||
|
...sessionData,
|
||||||
|
destroy(callback: (error?: Error) => void) {
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
} as typeof req.session;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/auth', createAuthRouter({ query }));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(
|
||||||
|
app: ReturnType<typeof buildApp>,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
) {
|
||||||
|
const server = app.listen(0);
|
||||||
|
try {
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
const response = await fetch(`http://127.0.0.1:${address.port}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
body: await response.json() as JsonBody,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQuery(user: UserRow, calls: Array<{ text: string; params?: unknown[] }>): QueryFn {
|
||||||
|
return (async (text, params) => {
|
||||||
|
const sql = String(text);
|
||||||
|
calls.push({ text: sql, params });
|
||||||
|
|
||||||
|
if (sql.includes('WHERE u.username = $1')) {
|
||||||
|
return queryResult(params?.[0] === user.username ? [user] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('WHERE u.id = $1')) {
|
||||||
|
return queryResult(Number(params?.[0]) === Number(user.id) ? [user] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('UPDATE users')) {
|
||||||
|
return queryResult([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected query: ${sql}`);
|
||||||
|
}) as QueryFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('login trims username and returns the current user shape', async () => {
|
||||||
|
const user: UserRow = {
|
||||||
|
id: '1',
|
||||||
|
username: 'a',
|
||||||
|
password_hash: await bcrypt.hash('1', 4),
|
||||||
|
full_name: 'ACS Admin',
|
||||||
|
email: 'admin@example.test',
|
||||||
|
department: 'IT',
|
||||||
|
must_change_password: false,
|
||||||
|
enabled: true,
|
||||||
|
locked: false,
|
||||||
|
roles: ['ADMIN'],
|
||||||
|
};
|
||||||
|
const calls: Array<{ text: string; params?: unknown[] }> = [];
|
||||||
|
const app = buildApp(makeQuery(user, calls));
|
||||||
|
|
||||||
|
const response = await requestJson(app, 'POST', '/auth/login', {
|
||||||
|
username: ' a ',
|
||||||
|
password: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.body.data.username, 'a');
|
||||||
|
assert.equal(response.body.data.fullName, 'ACS Admin');
|
||||||
|
assert.deepEqual(response.body.data.roles, ['ADMIN']);
|
||||||
|
assert.deepEqual(calls[0]?.params, ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login rejects disabled accounts even with a valid password', async () => {
|
||||||
|
const user: UserRow = {
|
||||||
|
id: '2',
|
||||||
|
username: 'disabled',
|
||||||
|
password_hash: await bcrypt.hash('1', 4),
|
||||||
|
full_name: 'Disabled User',
|
||||||
|
email: null,
|
||||||
|
department: null,
|
||||||
|
must_change_password: false,
|
||||||
|
enabled: false,
|
||||||
|
locked: false,
|
||||||
|
roles: ['HOST'],
|
||||||
|
};
|
||||||
|
const app = buildApp(makeQuery(user, []));
|
||||||
|
|
||||||
|
const response = await requestJson(app, 'POST', '/auth/login', {
|
||||||
|
username: 'disabled',
|
||||||
|
password: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
assert.equal(response.body.data, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('me returns 401 without a session user', async () => {
|
||||||
|
const app = buildApp((async () => {
|
||||||
|
throw new Error('query should not run for unauthenticated /me');
|
||||||
|
}) as QueryFn);
|
||||||
|
|
||||||
|
const response = await requestJson(app, 'GET', '/auth/me');
|
||||||
|
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
assert.equal(response.body.data, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('change-password updates the password hash and clears must_change_password', async () => {
|
||||||
|
const user: UserRow = {
|
||||||
|
id: '3',
|
||||||
|
username: 'host',
|
||||||
|
password_hash: await bcrypt.hash('OldPassword123!', 4),
|
||||||
|
full_name: 'Host User',
|
||||||
|
email: null,
|
||||||
|
department: null,
|
||||||
|
must_change_password: true,
|
||||||
|
enabled: true,
|
||||||
|
locked: false,
|
||||||
|
roles: ['HOST'],
|
||||||
|
};
|
||||||
|
const calls: Array<{ text: string; params?: unknown[] }> = [];
|
||||||
|
const app = buildApp(makeQuery(user, calls), { userId: 3 });
|
||||||
|
|
||||||
|
const response = await requestJson(app, 'POST', '/auth/change-password', {
|
||||||
|
oldPassword: 'OldPassword123!',
|
||||||
|
newPassword: 'NewPassword123!',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const updateCall = calls.find((call) => call.text.includes('UPDATE users'));
|
||||||
|
assert.ok(updateCall);
|
||||||
|
assert.equal(updateCall.params?.[1], 3);
|
||||||
|
assert.equal(await bcrypt.compare('NewPassword123!', String(updateCall.params?.[0])), true);
|
||||||
|
assert.notEqual(updateCall.params?.[0], user.password_hash);
|
||||||
|
});
|
||||||
199
server/routes/auth.ts
Normal file
199
server/routes/auth.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { ok } from '../http/apiResponse.js';
|
||||||
|
import { ApiError } from '../http/errors.js';
|
||||||
|
import { query } from '../db/pool.js';
|
||||||
|
import { findUserById, findUserByUsername, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
|
||||||
|
|
||||||
|
interface CurrentUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
fullName: string;
|
||||||
|
department?: string;
|
||||||
|
email?: string;
|
||||||
|
roles: string[];
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthRouterDeps {
|
||||||
|
query: QueryFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asyncRoute(
|
||||||
|
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
|
||||||
|
) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
handler(req, res, next).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCurrentUser(user: UserRow): CurrentUser {
|
||||||
|
return {
|
||||||
|
id: Number(user.id),
|
||||||
|
username: user.username,
|
||||||
|
fullName: user.full_name,
|
||||||
|
department: user.department ?? undefined,
|
||||||
|
email: user.email ?? undefined,
|
||||||
|
roles: user.roles,
|
||||||
|
mustChangePassword: user.must_change_password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthRouter(deps: AuthRouterDeps = { query }): Router {
|
||||||
|
const router = Router();
|
||||||
|
const dbQuery = deps.query;
|
||||||
|
|
||||||
|
router.post('/login', asyncRoute(async (req, res) => {
|
||||||
|
const username = String(req.body?.username ?? '').trim();
|
||||||
|
const password = String(req.body?.password ?? '');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new ApiError(400, '아이디와 비밀번호를 입력하세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await findUserByUsername(dbQuery, username);
|
||||||
|
const matches = user ? await bcrypt.compare(password, user.password_hash) : false;
|
||||||
|
if (!user || !matches || !user.enabled || user.locked) {
|
||||||
|
throw new ApiError(401, '아이디 또는 비밀번호가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.userId = Number(user.id);
|
||||||
|
ok(res, toCurrentUser(user));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/logout', (req, res, next) => {
|
||||||
|
req.session.destroy((error) => {
|
||||||
|
if (error) {
|
||||||
|
next(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.clearCookie('connect.sid');
|
||||||
|
ok(res, '로그아웃되었습니다.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', asyncRoute(async (req, res) => {
|
||||||
|
const user = await requireCurrentUser(dbQuery, req);
|
||||||
|
ok(res, toCurrentUser(user));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/change-password', asyncRoute(async (req, res) => {
|
||||||
|
const user = await requireCurrentUser(dbQuery, req);
|
||||||
|
const oldPassword = String(req.body?.oldPassword ?? '');
|
||||||
|
const newPassword = String(req.body?.newPassword ?? '');
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
throw new ApiError(400, '새 비밀번호는 8자 이상이어야 합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await bcrypt.compare(oldPassword, user.password_hash))) {
|
||||||
|
throw new ApiError(400, '현재 비밀번호가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await bcrypt.compare(newPassword, user.password_hash)) {
|
||||||
|
throw new ApiError(400, '기존 비밀번호와 다른 비밀번호를 입력하세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await dbQuery(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = $1,
|
||||||
|
must_change_password = FALSE,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
`,
|
||||||
|
[passwordHash, Number(user.id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
ok(res, '비밀번호가 변경되었습니다.');
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/diagnostics', asyncRoute(async (_req, res) => {
|
||||||
|
const tableResult = await dbQuery<{
|
||||||
|
current_schema: string;
|
||||||
|
users_table: string | null;
|
||||||
|
user_roles_table: string | null;
|
||||||
|
sessions_table: string | null;
|
||||||
|
migrations_table: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
current_schema() AS current_schema,
|
||||||
|
to_regclass('users')::text AS users_table,
|
||||||
|
to_regclass('user_roles')::text AS user_roles_table,
|
||||||
|
to_regclass('user_sessions')::text AS sessions_table,
|
||||||
|
to_regclass('schema_migrations')::text AS migrations_table
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const tables = tableResult.rows[0];
|
||||||
|
let users: Array<{
|
||||||
|
username: string;
|
||||||
|
enabled: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
must_change_password: boolean;
|
||||||
|
password_hash_length: number;
|
||||||
|
roles: string[];
|
||||||
|
}> = [];
|
||||||
|
let userQueryError: string | undefined;
|
||||||
|
let migrations: string[] = [];
|
||||||
|
let migrationQueryError: string | undefined;
|
||||||
|
|
||||||
|
if (tables?.users_table && tables.user_roles_table) {
|
||||||
|
try {
|
||||||
|
const userResult = await dbQuery<{
|
||||||
|
username: string;
|
||||||
|
enabled: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
must_change_password: boolean;
|
||||||
|
password_hash_length: number;
|
||||||
|
roles: string[];
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.username,
|
||||||
|
u.enabled,
|
||||||
|
u.locked,
|
||||||
|
u.must_change_password,
|
||||||
|
length(u.password_hash) AS password_hash_length,
|
||||||
|
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
WHERE u.username IN ('admin', 'security', 'host', 'a', 's', 'h')
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.username
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
users = userResult.rows;
|
||||||
|
} catch (error) {
|
||||||
|
userQueryError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tables?.migrations_table) {
|
||||||
|
try {
|
||||||
|
const migrationResult = await dbQuery<{ filename: string }>(
|
||||||
|
'SELECT filename FROM schema_migrations ORDER BY filename',
|
||||||
|
);
|
||||||
|
migrations = migrationResult.rows.map((row) => row.filename);
|
||||||
|
} catch (error) {
|
||||||
|
migrationQueryError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
authApi: true,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
tables,
|
||||||
|
users,
|
||||||
|
userQueryError,
|
||||||
|
migrations,
|
||||||
|
migrationQueryError,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authRouter = createAuthRouter();
|
||||||
1402
server/routes/business.ts
Normal file
1402
server/routes/business.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
server/routes/health.ts
Normal file
40
server/routes/health.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { ok } from '../http/apiResponse.js';
|
||||||
|
import { query } from '../db/pool.js';
|
||||||
|
|
||||||
|
export const healthRouter = Router();
|
||||||
|
|
||||||
|
export const platformStatusRouter = Router();
|
||||||
|
|
||||||
|
healthRouter.get('/health', (_req, res) => {
|
||||||
|
ok(res, {
|
||||||
|
status: 'UP',
|
||||||
|
service: 'acs-node',
|
||||||
|
build: 'auth-seed-20260714',
|
||||||
|
authApi: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
platformStatusRouter.get('/healthz', (_req, res) => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
platformStatusRouter.get('/db', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await query<{ now: Date }>('SELECT now() AS now');
|
||||||
|
res.json({ ok: true, now: result.rows[0]?.now });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
platformStatusRouter.get('/s3', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
skipped: true,
|
||||||
|
reason: 'ACS does not currently use S3/MinIO storage.',
|
||||||
|
});
|
||||||
|
});
|
||||||
13
server/routes/index.ts
Normal file
13
server/routes/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { adminRouter, configRouter } from './admin.js';
|
||||||
|
import { authRouter } from './auth.js';
|
||||||
|
import { businessRouter } from './business.js';
|
||||||
|
import { healthRouter } from './health.js';
|
||||||
|
|
||||||
|
export const apiRouter = Router();
|
||||||
|
|
||||||
|
apiRouter.use(healthRouter);
|
||||||
|
apiRouter.use('/auth', authRouter);
|
||||||
|
apiRouter.use(configRouter);
|
||||||
|
apiRouter.use('/admin', adminRouter);
|
||||||
|
apiRouter.use(businessRouter);
|
||||||
119
server/routes/security.ts
Normal file
119
server/routes/security.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
import { ApiError } from '../http/errors.js';
|
||||||
|
import { query } from '../db/pool.js';
|
||||||
|
|
||||||
|
export interface UserRow {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string | null;
|
||||||
|
department: string | null;
|
||||||
|
team_id?: string | null;
|
||||||
|
team_code?: string | null;
|
||||||
|
team_name?: string | null;
|
||||||
|
must_change_password: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryFn = typeof query;
|
||||||
|
|
||||||
|
export async function findUserByUsername(dbQuery: QueryFn, username: string): Promise<UserRow | undefined> {
|
||||||
|
const result = await dbQuery<UserRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.password_hash,
|
||||||
|
u.full_name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.team_id,
|
||||||
|
t.code AS team_code,
|
||||||
|
t.name AS team_name,
|
||||||
|
u.must_change_password,
|
||||||
|
u.enabled,
|
||||||
|
u.locked,
|
||||||
|
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN teams t ON t.id = u.team_id
|
||||||
|
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
WHERE u.username = $1
|
||||||
|
GROUP BY u.id, t.id
|
||||||
|
`,
|
||||||
|
[username],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findUserById(dbQuery: QueryFn, id: number): Promise<UserRow | undefined> {
|
||||||
|
const result = await dbQuery<UserRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.password_hash,
|
||||||
|
u.full_name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.team_id,
|
||||||
|
t.code AS team_code,
|
||||||
|
t.name AS team_name,
|
||||||
|
u.must_change_password,
|
||||||
|
u.enabled,
|
||||||
|
u.locked,
|
||||||
|
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN teams t ON t.id = u.team_id
|
||||||
|
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||||
|
WHERE u.id = $1
|
||||||
|
GROUP BY u.id, t.id
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireCurrentUser(dbQuery: QueryFn, req: Request): Promise<UserRow> {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
if (!userId) {
|
||||||
|
throw new ApiError(401, '로그인이 필요합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await findUserById(dbQuery, userId);
|
||||||
|
if (!user || !user.enabled || user.locked) {
|
||||||
|
req.session.userId = undefined;
|
||||||
|
throw new ApiError(401, '로그인이 필요합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin(dbQuery: QueryFn, req: Request): Promise<UserRow> {
|
||||||
|
const user = await requireCurrentUser(dbQuery, req);
|
||||||
|
if (!user.roles.includes('ADMIN')) {
|
||||||
|
throw new ApiError(403, '접근 권한이 없습니다.');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRole(value: string): string {
|
||||||
|
const role = value.trim().toUpperCase() === 'USER' ? 'HOST' : value.trim().toUpperCase();
|
||||||
|
if (!['ADMIN', 'SECURITY', 'HOST'].includes(role)) {
|
||||||
|
throw new ApiError(400, `알 수 없는 권한입니다: ${value}`);
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoles(values: unknown): string[] {
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
throw new ApiError(400, '권한은 배열로 입력해야 합니다.');
|
||||||
|
}
|
||||||
|
const roles = [...new Set(values.map((value) => parseRole(String(value))))];
|
||||||
|
if (roles.length === 0) {
|
||||||
|
throw new ApiError(400, '권한은 최소 1개 이상 필요합니다.');
|
||||||
|
}
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
123
server/services/mail.ts
Normal file
123
server/services/mail.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import net from 'node:net';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailHost(): string {
|
||||||
|
return String(process.env.ACS_MAIL_HOST ?? env.mailHost ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailPort(): number {
|
||||||
|
const port = Number(process.env.ACS_MAIL_PORT ?? env.mailPort);
|
||||||
|
return Number.isFinite(port) && port > 0 ? port : 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailFrom(): string {
|
||||||
|
return String(process.env.ACS_MAIL_FROM ?? env.mailFrom ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailUsername(): string {
|
||||||
|
return String(process.env.ACS_MAIL_USERNAME ?? env.mailUsername ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailPassword(): string {
|
||||||
|
return String(process.env.ACS_MAIL_PASSWORD ?? env.mailPassword ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailSmtpAuth(): boolean {
|
||||||
|
return (process.env.ACS_MAIL_SMTP_AUTH ?? String(env.mailSmtpAuth)).toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailStartTls(): boolean {
|
||||||
|
return (process.env.ACS_MAIL_STARTTLS ?? String(env.mailStartTls)).toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailTimeoutMs(): number {
|
||||||
|
const timeout = Number(process.env.ACS_MAIL_TIMEOUT_MS ?? env.mailTimeoutMs);
|
||||||
|
return Number.isFinite(timeout) && timeout > 0 ? timeout : 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(partial: Partial<EmailDiagnosticsResult>): EmailDiagnosticsResult {
|
||||||
|
return {
|
||||||
|
host: mailHost(),
|
||||||
|
port: mailPort(),
|
||||||
|
from: mailFrom(),
|
||||||
|
smtpAuth: mailSmtpAuth(),
|
||||||
|
startTls: mailStartTls(),
|
||||||
|
usernameConfigured: Boolean(mailUsername()),
|
||||||
|
passwordConfigured: Boolean(mailPassword()),
|
||||||
|
ok: false,
|
||||||
|
reachable: false,
|
||||||
|
elapsedMs: 0,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSmtp(host: string, port: number, timeoutMs: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.createConnection({ host, port });
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (error?: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
socket.once('connect', () => finish());
|
||||||
|
socket.once('timeout', () => finish(new Error(`SMTP connection timed out after ${timeoutMs} ms`)));
|
||||||
|
socket.once('error', (error) => finish(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diagnoseEmailConnectivity(): Promise<EmailDiagnosticsResult> {
|
||||||
|
const host = mailHost();
|
||||||
|
const port = mailPort();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return result({
|
||||||
|
ok: false,
|
||||||
|
reachable: false,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
error: 'ACS_MAIL_HOST가 설정되지 않았습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectSmtp(host, port, mailTimeoutMs());
|
||||||
|
return result({
|
||||||
|
ok: true,
|
||||||
|
reachable: true,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return result({
|
||||||
|
ok: false,
|
||||||
|
reachable: false,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
error: message.slice(0, 500),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/services/sms.test.ts
Normal file
37
server/services/sms.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('hanbank provider posts the internal SMS API payload shape', async () => {
|
||||||
|
process.env.ACS_SMS_PROVIDER = 'hanbank';
|
||||||
|
process.env.ACS_SMS_API_URL = 'http://sms.example.test:8000';
|
||||||
|
|
||||||
|
const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input, init) => {
|
||||||
|
calls.push({ input, init });
|
||||||
|
return new Response(JSON.stringify({ statusCode: '202', statusName: 'success' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { sendSms } = await import('./sms.js');
|
||||||
|
const result = await sendSms({
|
||||||
|
to: '010-1234-5678',
|
||||||
|
text: '[ACS] QR 출입증 링크',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'SENT');
|
||||||
|
assert.equal(result.channel, 'HANBANK');
|
||||||
|
assert.equal(String(calls[0]?.input), 'http://sms.example.test:8000/sens/sms');
|
||||||
|
assert.deepEqual(JSON.parse(String(calls[0]?.init?.body)), {
|
||||||
|
receive_number: '01012345678',
|
||||||
|
content: '[ACS] QR 출입증 링크',
|
||||||
|
msg_type: 'LMS',
|
||||||
|
reserve_time: '',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
237
server/services/sms.ts
Normal file
237
server/services/sms.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export interface SmsSendResult {
|
||||||
|
channel: string;
|
||||||
|
recipient: string;
|
||||||
|
status: 'SENT' | 'FAILED';
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsMessage {
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsDiagnosticsResult {
|
||||||
|
provider: string;
|
||||||
|
url: string;
|
||||||
|
ok: boolean;
|
||||||
|
reachable: boolean;
|
||||||
|
httpStatus: number | null;
|
||||||
|
elapsedMs: number;
|
||||||
|
checkedAt: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmsApiResponse = {
|
||||||
|
requestId?: string;
|
||||||
|
requestTime?: string;
|
||||||
|
statusCode?: string | number;
|
||||||
|
statusName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePhone(value: string | null | undefined): string {
|
||||||
|
return String(value ?? '').replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(channel: string, recipient: string, error: unknown): SmsSendResult {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
recipient,
|
||||||
|
status: 'FAILED',
|
||||||
|
lastError: message.slice(0, 500),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function smsProvider(): string {
|
||||||
|
return process.env.ACS_SMS_PROVIDER ?? env.smsProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smsApiUrl(): string | undefined {
|
||||||
|
return process.env.ACS_SMS_API_URL ?? env.smsApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smsApiKey(): string | undefined {
|
||||||
|
return process.env.ACS_SMS_API_KEY ?? env.smsApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smsSender(): string | undefined {
|
||||||
|
return process.env.ACS_SMS_SENDER ?? env.smsSender;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smsTimeoutMs(): number {
|
||||||
|
return Number(process.env.ACS_SMS_TIMEOUT_MS ?? env.smsTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSms(message: SmsMessage): Promise<SmsSendResult> {
|
||||||
|
const configuredProvider = smsProvider();
|
||||||
|
const provider = configuredProvider.toLowerCase();
|
||||||
|
const recipient = normalizePhone(message.to);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return fail(provider.toUpperCase(), recipient, '수신자 휴대폰 번호가 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'dev') {
|
||||||
|
console.log('[sms:dev]', { to: recipient, text: message.text });
|
||||||
|
return { channel: 'DEV', recipient, status: 'SENT', lastError: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'hanbank' || provider === 'bok') {
|
||||||
|
return sendHanbankSms(recipient, message.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider !== 'http') {
|
||||||
|
return fail(provider.toUpperCase(), recipient, `지원하지 않는 SMS provider입니다: ${configuredProvider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = smsApiUrl();
|
||||||
|
if (!apiUrl) {
|
||||||
|
return fail('SMS', recipient, 'ACS_SMS_API_URL이 설정되지 않았습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), smsTimeoutMs());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKey = smsApiKey();
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: recipient,
|
||||||
|
from: smsSender(),
|
||||||
|
message: message.text,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`SMS API HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channel: 'SMS', recipient, status: 'SENT', lastError: null };
|
||||||
|
} catch (error) {
|
||||||
|
return fail('SMS', recipient, error);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHanbankSmsUrl(): string {
|
||||||
|
const baseUrl = smsApiUrl() ?? 'http://210.104.132.59:8000';
|
||||||
|
if (/\/sens\/sms\/?$/i.test(baseUrl)) {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
return `${baseUrl.replace(/\/+$/, '')}/sens/sms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diagnoseSmsConnectivity(): Promise<SmsDiagnosticsResult> {
|
||||||
|
const provider = smsProvider();
|
||||||
|
const url = provider.toLowerCase() === 'hanbank' || provider.toLowerCase() === 'bok'
|
||||||
|
? resolveHanbankSmsUrl()
|
||||||
|
: smsApiUrl() ?? '';
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
url,
|
||||||
|
ok: false,
|
||||||
|
reachable: false,
|
||||||
|
httpStatus: null,
|
||||||
|
elapsedMs: 0,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
error: 'ACS_SMS_API_URL이 설정되지 않았습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), smsTimeoutMs());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
url,
|
||||||
|
ok: true,
|
||||||
|
reachable: true,
|
||||||
|
httpStatus: response.status,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
url,
|
||||||
|
ok: false,
|
||||||
|
reachable: false,
|
||||||
|
httpStatus: null,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
error: message.slice(0, 500),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSuccessResponse(response: SmsApiResponse): boolean {
|
||||||
|
const statusCode = String(response.statusCode ?? '').trim();
|
||||||
|
const statusName = String(response.statusName ?? '').trim().toLowerCase();
|
||||||
|
return statusCode === '202' || statusName === 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendHanbankSms(recipient: string, text: string): Promise<SmsSendResult> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), smsTimeoutMs());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(resolveHanbankSmsUrl(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
receive_number: recipient,
|
||||||
|
content: text,
|
||||||
|
msg_type: 'LMS',
|
||||||
|
reserve_time: '',
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawBody = await response.text().catch(() => '');
|
||||||
|
let body: SmsApiResponse = {};
|
||||||
|
if (rawBody) {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(rawBody) as SmsApiResponse;
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || (body.statusCode !== undefined && !isSuccessResponse(body))) {
|
||||||
|
const detail = rawBody ? `: ${rawBody.slice(0, 300)}` : '';
|
||||||
|
throw new Error(`사내 SMS API 발송 실패 HTTP ${response.status}${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channel: 'HANBANK', recipient, status: 'SENT', lastError: null };
|
||||||
|
} catch (error) {
|
||||||
|
return fail('HANBANK', recipient, error);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
server/types/session.d.ts
vendored
Normal file
7
server/types/session.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import 'express-session';
|
||||||
|
|
||||||
|
declare module 'express-session' {
|
||||||
|
interface SessionData {
|
||||||
|
userId?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["server/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "frontend", "backend", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user