feat: add Node ACS admin workflows
This commit is contained in:
@@ -68,8 +68,14 @@ Node 서버에 Auth API를 추가했다.
|
||||
|
||||
판단: 라우터는 배포에 반영되었고, 남은 문제는 운영 DB의 사용자 seed 또는 로그인 처리 중 DB 상태 문제로 좁혀졌다. 다음 배포부터 테스트 계정이 자동 보장되도록 `migrations/005_seed_test_users.sql`을 추가한다.
|
||||
|
||||
2026-07-14 after Coolify redeploy, `/api/health` returned `build: auth-seed-20260714`.
|
||||
2026-07-14 원격 `main`에 `e02afcd Add auth deployment diagnostics`까지 push했다.
|
||||
|
||||
However, `/api/auth/diagnostics` reported no `users` or `user_roles` table, and login still returned 500.
|
||||
확인 결과 `GET /api/health`가 아직 `{"status":"UP","service":"acs-node"}`만 반환하고, 새 build marker인 `auth-seed-20260714`를 반환하지 않았다.
|
||||
|
||||
Conclusion: runtime migration is not guaranteed when Coolify overrides Dockerfile CMD. Added startup migration in `server/index.ts` so `runMigrations()` executes before the Express server starts.
|
||||
판단: 최신 커밋이 아직 운영 컨테이너에 반영되지 않았다. 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을 추가한다.
|
||||
|
||||
252
docs/ACS작업일지.md
252
docs/ACS작업일지.md
@@ -384,6 +384,159 @@
|
||||
- `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 테스트 준비 및 문서 산출물
|
||||
@@ -492,3 +645,102 @@
|
||||
- 한 화면 표시 여부.
|
||||
- 레이블/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`)가 없어 신규 마이그레이션은 서버 기동 시 적용되는 방식으로 확인 예정.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { BlacklistPage } from './pages/BlacklistPage';
|
||||
import { ReportPage } from './pages/ReportPage';
|
||||
import { AuditLogPage } from './pages/AuditLogPage';
|
||||
import { DeliveryOutboxPage } from './pages/DeliveryOutboxPage';
|
||||
import { AdminManagementPage } from './pages/AdminManagementPage';
|
||||
|
||||
/** Requires a logged-in user; optionally one of the given roles. */
|
||||
const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => {
|
||||
@@ -99,6 +100,14 @@ export default function App() {
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<Protected roles={['ADMIN']}>
|
||||
<AdminManagementPage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AccessAction,
|
||||
AccessRecord,
|
||||
AdminExcelImportResult,
|
||||
ApiResponse,
|
||||
AuditLog,
|
||||
BlacklistCreate,
|
||||
@@ -12,10 +13,15 @@ import {
|
||||
InsideVisitor,
|
||||
LoginRequest,
|
||||
PassDelivery,
|
||||
PurposeCode,
|
||||
Role,
|
||||
StatsSummary,
|
||||
PublicPass,
|
||||
Team,
|
||||
VisitRequestCreate,
|
||||
VisitRequestView,
|
||||
AdminUser,
|
||||
Watcher1Settings,
|
||||
Zone,
|
||||
} from './types';
|
||||
|
||||
@@ -85,6 +91,7 @@ export const changePassword = (req: ChangePasswordRequest) =>
|
||||
|
||||
// ===== Zones =====
|
||||
export const listZones = () => request<Zone[]>('/zones');
|
||||
export const listPurposeCodes = () => request<PurposeCode[]>('/purpose-codes');
|
||||
|
||||
// ===== Visit requests =====
|
||||
export const listVisitRequests = () =>
|
||||
@@ -154,6 +161,9 @@ export const deleteBlacklist = (id: number) =>
|
||||
request<string>(`/blacklist/${id}`, { method: 'DELETE' });
|
||||
|
||||
// ===== Reports =====
|
||||
export const listReportVisits = (from: string, to: string) =>
|
||||
request<VisitRequestView[]>(`/reports/visits?from=${from}&to=${to}`);
|
||||
|
||||
export const reportDownloadUrl = (from: string, to: string) =>
|
||||
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|
||||
|
||||
@@ -165,3 +175,45 @@ export const listDeliveries = (status?: DeliveryStatus) =>
|
||||
request<PassDelivery[]>(`/admin/deliveries${status ? `?status=${status}` : ''}`);
|
||||
export const retryDelivery = (id: number) =>
|
||||
request<PassDelivery>(`/admin/deliveries/${id}/retry`, { method: 'POST' });
|
||||
|
||||
// ===== Admin management =====
|
||||
export const listAdminPurposeCodes = () => request<PurposeCode[]>('/admin/purpose-codes');
|
||||
export const createAdminPurposeCode = (payload: Omit<PurposeCode, 'id'>) =>
|
||||
request<PurposeCode>('/admin/purpose-codes', jsonInit('POST', payload));
|
||||
export const updateAdminPurposeCode = (id: number, payload: Omit<PurposeCode, 'id'>) =>
|
||||
request<PurposeCode>(`/admin/purpose-codes/${id}`, jsonInit('PUT', payload));
|
||||
export const getWatcher1Settings = () => request<Watcher1Settings>('/settings/watcher1');
|
||||
export const updateWatcher1Settings = (payload: Watcher1Settings) =>
|
||||
request<Watcher1Settings>('/admin/settings/watcher1', jsonInit('PUT', payload));
|
||||
export const 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';
|
||||
|
||||
@@ -29,11 +29,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
IT센터 출입자관리
|
||||
</Link>
|
||||
<nav className="nav">
|
||||
<NavLink to="/visit-requests">출입신청</NavLink>
|
||||
<NavLink to="/visit-requests/new">출입신청</NavLink>
|
||||
{hasRole('ADMIN') && <NavLink to="/approvals">승인대기</NavLink>}
|
||||
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access">출입콘솔</NavLink>}
|
||||
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports">보고서</NavLink>}
|
||||
{hasRole('ADMIN') && <NavLink to="/blacklist">블랙리스트</NavLink>}
|
||||
{hasRole('ADMIN') && <NavLink to="/admin">시스템관리</NavLink>}
|
||||
{hasRole('ADMIN') && <NavLink to="/deliveries">발송내역</NavLink>}
|
||||
{hasRole('ADMIN') && <NavLink to="/audit">감사로그</NavLink>}
|
||||
</nav>
|
||||
|
||||
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] };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../api';
|
||||
import { AccessRecord, VisitRequestView } from '../types';
|
||||
import { formatShort } from '../status';
|
||||
import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog';
|
||||
|
||||
/**
|
||||
* Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out,
|
||||
@@ -19,6 +20,7 @@ export const AccessConsolePage: React.FC = () => {
|
||||
const [records, setRecords] = useState<AccessRecord[]>([]);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loadRecords = () => {
|
||||
@@ -95,7 +97,18 @@ export const AccessConsolePage: React.FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
@@ -103,14 +116,28 @@ export const AccessConsolePage: React.FC = () => {
|
||||
{insideIds.has(r.id) ? (
|
||||
<span className="status-cell">
|
||||
<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>
|
||||
) : exitedIds.has(r.id) ? (
|
||||
<span className="badge badge-gray">금일완료</span>
|
||||
) : (
|
||||
<span className="status-cell">
|
||||
<span className="badge badge-amber">입장대기</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>
|
||||
)}
|
||||
</td>
|
||||
@@ -137,7 +164,18 @@ export const AccessConsolePage: React.FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
@@ -152,7 +190,14 @@ export const AccessConsolePage: React.FC = () => {
|
||||
</td>
|
||||
<td>
|
||||
{r.inside && (
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -161,6 +206,10 @@ export const AccessConsolePage: React.FC = () => {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailId != null && (
|
||||
<VisitRequestDetailDialog requestId={detailId} onClose={() => setDetailId(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
584
frontend/src/pages/AdminManagementPage.tsx
Normal file
584
frontend/src/pages/AdminManagementPage.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
adminTeamTemplateUrl,
|
||||
adminUserRolesTemplateUrl,
|
||||
applyTeamDefaultRolesToMembers,
|
||||
createAdminTeam,
|
||||
createAdminPurposeCode,
|
||||
listAdminPurposeCodes,
|
||||
listAdminTeams,
|
||||
listAdminUsers,
|
||||
updateAdminTeam,
|
||||
updateAdminPurposeCode,
|
||||
updateAdminUserTeam,
|
||||
updateAdminUserRoles,
|
||||
uploadAdminTeams,
|
||||
uploadAdminUserRoles,
|
||||
updateWatcher1Settings,
|
||||
getWatcher1Settings,
|
||||
} from '../api';
|
||||
import { AdminExcelImportResult, AdminUser, PurposeCode, Role, Team, Watcher1Settings } from '../types';
|
||||
|
||||
type Tab = 'watcher' | 'purpose' | 'teams' | 'roles';
|
||||
|
||||
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 [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 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>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const ACTION_LABEL: Record<AuditLog['action'], string> = {
|
||||
REJECT: '반려',
|
||||
BLACKLIST_ADD: '블랙리스트 등록',
|
||||
BLACKLIST_REMOVE: '블랙리스트 해제',
|
||||
ADMIN_CONFIG_UPDATE: '시스템 설정',
|
||||
};
|
||||
|
||||
const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
||||
@@ -15,6 +16,7 @@ const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
||||
REJECT: 'red',
|
||||
BLACKLIST_ADD: 'red',
|
||||
BLACKLIST_REMOVE: 'gray',
|
||||
ADMIN_CONFIG_UPDATE: 'blue',
|
||||
};
|
||||
|
||||
export const AuditLogPage: React.FC = () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getStatsSummary, listInside, listVisitRequests } from '../api';
|
||||
import { getStatsSummary, listInside, listTodayAccess, listVisitRequests } from '../api';
|
||||
import { StatsSummary, VisitRequestView } from '../types';
|
||||
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
||||
import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null);
|
||||
const [insideIds, setInsideIds] = useState<Set<number>>(new Set());
|
||||
const [exitedIds, setExitedIds] = useState<Set<number>>(new Set());
|
||||
const [checkOutById, setCheckOutById] = useState<Map<number, string>>(new Map());
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -17,6 +21,19 @@ export const DashboardPage: React.FC = () => {
|
||||
listInside()
|
||||
.then((rows) => setInsideIds(new Set(rows.map((r) => r.visitRequestId))))
|
||||
.catch(() => setInsideIds(new Set()));
|
||||
listTodayAccess()
|
||||
.then((rows) => {
|
||||
setExitedIds(new Set(rows
|
||||
.filter((r) => !r.inside && r.checkOutAt)
|
||||
.map((r) => r.visitRequestId)));
|
||||
setCheckOutById(new Map(rows
|
||||
.filter((r) => r.checkOutAt)
|
||||
.map((r) => [r.visitRequestId, r.checkOutAt as string])));
|
||||
})
|
||||
.catch(() => {
|
||||
setExitedIds(new Set());
|
||||
setCheckOutById(new Map());
|
||||
});
|
||||
listVisitRequests()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||
@@ -51,19 +68,33 @@ export const DashboardPage: React.FC = () => {
|
||||
<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><th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.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.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{formatDateTime(r.visitFrom)}</td>
|
||||
<td>{checkOutById.has(r.id) ? formatDateTime(checkOutById.get(r.id)!) : '-'}</td>
|
||||
<td>
|
||||
{insideIds.has(r.id) ? (
|
||||
<span className="badge badge-green">재실중</span>
|
||||
) : exitedIds.has(r.id) ? (
|
||||
<span className="badge badge-gray">퇴장</span>
|
||||
) : (
|
||||
<span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span>
|
||||
)}
|
||||
@@ -74,6 +105,10 @@ export const DashboardPage: React.FC = () => {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailId != null && (
|
||||
<VisitRequestDetailDialog requestId={detailId} onClose={() => setDetailId(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ export const LoginPage: React.FC = () => {
|
||||
<button className="btn-primary full" type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { reportDownloadUrl } from '../api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { listReportVisits, reportDownloadUrl } from '../api';
|
||||
import { DatePickerField } from '../components/DatePickerField';
|
||||
import { VisitRequestView } from '../types';
|
||||
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
@@ -18,6 +20,27 @@ function firstOfMonthISO(): string {
|
||||
export const ReportPage: React.FC = () => {
|
||||
const [from, setFrom] = useState(firstOfMonthISO());
|
||||
const [to, setTo] = useState(todayISO());
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
listReportVisits(from, to)
|
||||
.then(setItems)
|
||||
.catch((e) => {
|
||||
setItems([]);
|
||||
setError(e instanceof Error ? e.message : '보고서 조회에 실패했습니다.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Load the default month range once; explicit 조회 handles later date changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onDownload = () => {
|
||||
// Trigger a download in-place via a temporary anchor. Using window.open left
|
||||
@@ -37,7 +60,6 @@ export const ReportPage: React.FC = () => {
|
||||
<div className="page-head"><h2>출입관리 보고서</h2></div>
|
||||
|
||||
<div className="card">
|
||||
<p className="muted">기간을 선택하고 [엑셀 다운로드] 버튼을 클릭하면, 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)</p>
|
||||
<div className="report-row">
|
||||
<label className="field">
|
||||
<span>시작일</span>
|
||||
@@ -47,9 +69,57 @@ export const ReportPage: React.FC = () => {
|
||||
<span>종료일</span>
|
||||
<DatePickerField value={to} onChange={setTo} />
|
||||
</label>
|
||||
<button className="btn-ghost" onClick={load} disabled={loading}>조회</button>
|
||||
<button className="btn-primary" onClick={onDownload}>엑셀 다운로드</button>
|
||||
</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><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createVisitRequest } from '../api';
|
||||
import { createVisitRequest, getWatcher1Settings, listPurposeCodes } from '../api';
|
||||
import { DateTimePicker } from '../components/DateTimePicker';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PurposeCode, Watcher1Settings } from '../types';
|
||||
|
||||
// 코드 시트 목록을 콤보/체크박스에 반영.
|
||||
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
|
||||
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
|
||||
// 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력.
|
||||
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 = [
|
||||
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
|
||||
@@ -17,18 +25,24 @@ const AFFILIATION_OPTIONS = [
|
||||
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
|
||||
];
|
||||
// 현장감시자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@]+$/;
|
||||
|
||||
/** True if the datetime's calendar date is before today (time-of-day ignored). */
|
||||
const isPastDate = (iso: string): boolean => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
const day = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const today = new Date();
|
||||
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
return day < todayStart;
|
||||
const NEXT_DAY_EXIT_MESSAGE = `퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다
|
||||
예컨대, 2026.07.16 18:00~2026.07.17 04:00 이라면, 아래와 같이 2건으로 등록해야 합니다.
|
||||
1건) 2026.07.16 18:00~2026.07.16 24:00
|
||||
2건) 2026.07.17 00:00~2026.07.17 04:00`;
|
||||
|
||||
const EXIT_BEFORE_ENTRY_MESSAGE = '퇴장일시는 출입일시 이후로 입력해야 합니다.';
|
||||
|
||||
const shouldShowValidationAlert = (message: string): boolean =>
|
||||
message === NEXT_DAY_EXIT_MESSAGE || message === EXIT_BEFORE_ENTRY_MESSAGE;
|
||||
|
||||
const isLaterCalendarDate = (later: Date, earlier: Date): boolean => {
|
||||
const laterDay = new Date(later.getFullYear(), later.getMonth(), later.getDate());
|
||||
const earlierDay = new Date(earlier.getFullYear(), earlier.getMonth(), earlier.getDate());
|
||||
return laterDay > earlierDay;
|
||||
};
|
||||
|
||||
const formatPhoneLike = (value: string): string => {
|
||||
@@ -58,12 +72,21 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
visitFrom: '',
|
||||
visitTo: '',
|
||||
});
|
||||
const [purposeCodes, setPurposeCodes] = useState<PurposeCode[]>(FALLBACK_PURPOSE_CODES);
|
||||
const [watcher1, setWatcher1] = useState<Watcher1Settings>(FALLBACK_WATCHER1);
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
listPurposeCodes().then(setPurposeCodes).catch(() => setPurposeCodes(FALLBACK_PURPOSE_CODES));
|
||||
getWatcher1Settings().then(setWatcher1).catch(() => setWatcher1(FALLBACK_WATCHER1));
|
||||
}, []);
|
||||
|
||||
const selectedPurpose = purposeCodes.find((p) => p.code === form.purpose);
|
||||
|
||||
const update = (k: keyof typeof form) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => setForm({ ...form, [k]: e.target.value });
|
||||
@@ -90,13 +113,13 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.';
|
||||
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.';
|
||||
if (!form.purpose) return '출입 목적을 선택하세요.';
|
||||
if (form.purpose === '기타' && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.';
|
||||
if (selectedPurpose?.customAllowed && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.';
|
||||
if (!form.visitFrom) return '출입 일시를 입력하세요.';
|
||||
// 오늘 이전(전일자)은 불가. 같은 날 안에서 현재보다 이른 시각은 허용(날짜만 비교).
|
||||
if (isPastDate(form.visitFrom)) return '과거일자는 입력이 안됩니다.';
|
||||
if (!form.visitTo) return '퇴실 일시를 입력하세요.';
|
||||
if (new Date(form.visitTo) < new Date(form.visitFrom))
|
||||
return '퇴실 일시는 출입 일시보다 빠를 수 없습니다.';
|
||||
const visitFrom = new Date(form.visitFrom);
|
||||
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 '개인정보 사용 및 저장에 동의해야 신청할 수 있습니다.';
|
||||
return null;
|
||||
};
|
||||
@@ -106,6 +129,9 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
const message = validate();
|
||||
if (message) {
|
||||
setError(message);
|
||||
if (shouldShowValidationAlert(message)) {
|
||||
window.alert(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -120,7 +146,11 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
vehicleNo: form.vehicleNo.trim() || undefined,
|
||||
serverRooms: form.serverRooms,
|
||||
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,
|
||||
watcher2Name: form.watcher2Name.trim() || undefined,
|
||||
watcher2Team: form.watcher2Team.trim() || undefined,
|
||||
@@ -201,10 +231,10 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
<span>출입 목적 <b className="required">*</b></span>
|
||||
<select value={form.purpose} onChange={update('purpose')}>
|
||||
<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>
|
||||
</label>
|
||||
{form.purpose === '기타' ? (
|
||||
{selectedPurpose?.customAllowed ? (
|
||||
<label className="field">
|
||||
<span>기타 목적 입력 <b className="required">*</b></span>
|
||||
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
|
||||
@@ -259,15 +289,15 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
<div className="group-grid">
|
||||
<label className="field">
|
||||
<span>이름</span>
|
||||
<input value={FIXED_WATCHER1.name} readOnly />
|
||||
<input value={watcher1.name} readOnly />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>소속</span>
|
||||
<input value={FIXED_WATCHER1.team} readOnly />
|
||||
<input value={watcher1.team} readOnly />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처</span>
|
||||
<input value={FIXED_WATCHER1.contact} readOnly />
|
||||
<input value={watcher1.contact} readOnly />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -285,7 +315,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처 <em className="hint-inline">: 내선번호/휴대폰번호</em></span>
|
||||
<span>연락처</span>
|
||||
<input value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" />
|
||||
</label>
|
||||
</div>
|
||||
@@ -295,10 +325,12 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
<label className="consent-label">
|
||||
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
|
||||
<span>
|
||||
<b>[개인정보 수집·이용 동의 확인]</b><br />
|
||||
<b>[방문자에 대한 개인정보 수집·이용 동의 확인]</b><br />
|
||||
· 수집 항목: 이름, 연락처, 이메일, 차량번호<br />
|
||||
· 수집·이용 목적: IT센터 출입 신청 접수 및 출입자 관리<br />
|
||||
· 보유·이용 기간: 수집일로부터 1년 (기간 경과 시 지체 없이 파기)<br />
|
||||
<span className="consent-retention-warning">
|
||||
· 보유·이용 기간: 전산실 퇴장 등록시 입력된 방문자 이름, 연락처, 이메일, 차량번호는 바로 삭제
|
||||
</span><br />
|
||||
· 방문자 개인정보 수집·이용에 동의를 거부할 권리가 있으며, 동의하지 않을 경우 출입 신청이 제한됨을 고지
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -140,6 +140,48 @@ a { color: inherit; text-decoration: none; }
|
||||
.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-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 */
|
||||
.kiosk { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--bg, #f1f5f9); }
|
||||
@@ -175,7 +217,7 @@ a { color: inherit; text-decoration: none; }
|
||||
.form-grid .span-2 { grid-column: 1 / -1; }
|
||||
.visit-form { gap: 8px; padding: 12px; margin-bottom: 0; }
|
||||
.form-group {
|
||||
border: 1px solid #cbd5e1;
|
||||
border: 2px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px 0;
|
||||
margin: 0;
|
||||
@@ -244,6 +286,7 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.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-retention-warning { color: var(--red); }
|
||||
|
||||
/* Inline checkbox group (e.g. 전산실 다중 선택) */
|
||||
.checkbox-row { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 2px; }
|
||||
@@ -290,6 +333,8 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
}
|
||||
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: none; }
|
||||
.table tbody tr:hover { background: #f8fafc; }
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row:focus-visible { outline: 2px solid #bfdbfe; outline-offset: -2px; }
|
||||
|
||||
/* ===== Badges ===== */
|
||||
.badge {
|
||||
@@ -325,6 +370,81 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.btn-link { background: none; border: none; color: var(--primary); font-weight: 600; }
|
||||
.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;
|
||||
}
|
||||
.row-error {
|
||||
background: #fff7f7;
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ===== Report ===== */
|
||||
.report-row { display: flex; align-items: flex-end; gap: 16px; }
|
||||
.report-row .field { margin-bottom: 0; }
|
||||
@@ -362,6 +482,10 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.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;
|
||||
|
||||
@@ -33,6 +33,43 @@ export interface Zone {
|
||||
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 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 =
|
||||
| 'DRAFT'
|
||||
| 'PENDING'
|
||||
@@ -52,6 +89,8 @@ export interface VisitRequestCreate {
|
||||
/** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */
|
||||
roomZone?: string;
|
||||
purpose: string;
|
||||
purposeCode?: string;
|
||||
purposeDetail?: string;
|
||||
/** 작업명 — optional concrete task detail, stored separately from purpose. */
|
||||
workName?: string;
|
||||
/** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */
|
||||
@@ -67,12 +106,15 @@ export interface VisitRequestView {
|
||||
visitorName: string;
|
||||
company?: string;
|
||||
contact?: string;
|
||||
email?: string;
|
||||
vehicleNo?: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
hostDepartment?: string;
|
||||
zoneName?: string;
|
||||
purpose: string;
|
||||
purposeCode?: string;
|
||||
purposeDetail?: string;
|
||||
workName?: string;
|
||||
controlName?: string;
|
||||
controlTeam?: string;
|
||||
@@ -109,6 +151,26 @@ export interface ExcelImportResult {
|
||||
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 InsideVisitor {
|
||||
visitRequestId: number;
|
||||
visitorName: string;
|
||||
@@ -170,7 +232,7 @@ export interface AuditLog {
|
||||
at: string;
|
||||
actorId?: number;
|
||||
actorUsername?: string;
|
||||
action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE';
|
||||
action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE' | 'ADMIN_CONFIG_UPDATE';
|
||||
targetType?: string;
|
||||
targetId?: number;
|
||||
detail?: string;
|
||||
|
||||
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;
|
||||
@@ -8,6 +8,8 @@
|
||||
"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",
|
||||
"db:check": "node scripts/check-db.mjs",
|
||||
"minio:check": "node scripts/check-minio.mjs",
|
||||
"dev": "tsx watch server/index.ts",
|
||||
|
||||
682
server/routes/admin.ts
Normal file
682
server/routes/admin.ts
Normal file
@@ -0,0 +1,682 @@
|
||||
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 { 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 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.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);
|
||||
});
|
||||
@@ -4,19 +4,7 @@ import { Router } from 'express';
|
||||
import { ok } from '../http/apiResponse.js';
|
||||
import { ApiError } from '../http/errors.js';
|
||||
import { query } from '../db/pool.js';
|
||||
|
||||
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[];
|
||||
}
|
||||
import { findUserById, findUserByUsername, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
|
||||
|
||||
interface CurrentUser {
|
||||
id: number;
|
||||
@@ -28,7 +16,9 @@ interface CurrentUser {
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
interface AuthRouterDeps {
|
||||
query: QueryFn;
|
||||
}
|
||||
|
||||
function asyncRoute(
|
||||
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
|
||||
@@ -38,54 +28,6 @@ function asyncRoute(
|
||||
};
|
||||
}
|
||||
|
||||
async function findUserByUsername(username: string): Promise<UserRow | undefined> {
|
||||
const result = await query<UserRow>(
|
||||
`
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.password_hash,
|
||||
u.full_name,
|
||||
u.email,
|
||||
u.department,
|
||||
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 user_roles ur ON ur.user_id = u.id
|
||||
WHERE u.username = $1
|
||||
GROUP BY u.id
|
||||
`,
|
||||
[username],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function findUserById(id: number): Promise<UserRow | undefined> {
|
||||
const result = await query<UserRow>(
|
||||
`
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.password_hash,
|
||||
u.full_name,
|
||||
u.email,
|
||||
u.department,
|
||||
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 user_roles ur ON ur.user_id = u.id
|
||||
WHERE u.id = $1
|
||||
GROUP BY u.id
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
function toCurrentUser(user: UserRow): CurrentUser {
|
||||
return {
|
||||
id: Number(user.id),
|
||||
@@ -98,20 +40,9 @@ function toCurrentUser(user: UserRow): CurrentUser {
|
||||
};
|
||||
}
|
||||
|
||||
async function requireCurrentUser(req: Request): Promise<UserRow> {
|
||||
const userId = req.session.userId;
|
||||
if (!userId) {
|
||||
throw new ApiError(401, '로그인이 필요합니다.');
|
||||
}
|
||||
|
||||
const user = await findUserById(userId);
|
||||
if (!user || !user.enabled || user.locked) {
|
||||
req.session.userId = undefined;
|
||||
throw new ApiError(401, '로그인이 필요합니다.');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
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();
|
||||
@@ -121,7 +52,7 @@ router.post('/login', asyncRoute(async (req, res) => {
|
||||
throw new ApiError(400, '아이디와 비밀번호를 입력하세요.');
|
||||
}
|
||||
|
||||
const user = await findUserByUsername(username);
|
||||
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, '아이디 또는 비밀번호가 올바르지 않습니다.');
|
||||
@@ -143,12 +74,12 @@ router.post('/logout', (req, res, next) => {
|
||||
});
|
||||
|
||||
router.get('/me', asyncRoute(async (req, res) => {
|
||||
const user = await requireCurrentUser(req);
|
||||
const user = await requireCurrentUser(dbQuery, req);
|
||||
ok(res, toCurrentUser(user));
|
||||
}));
|
||||
|
||||
router.post('/change-password', asyncRoute(async (req, res) => {
|
||||
const user = await requireCurrentUser(req);
|
||||
const user = await requireCurrentUser(dbQuery, req);
|
||||
const oldPassword = String(req.body?.oldPassword ?? '');
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
|
||||
@@ -165,7 +96,7 @@ router.post('/change-password', asyncRoute(async (req, res) => {
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await query(
|
||||
await dbQuery(
|
||||
`
|
||||
UPDATE users
|
||||
SET password_hash = $1,
|
||||
@@ -180,7 +111,7 @@ router.post('/change-password', asyncRoute(async (req, res) => {
|
||||
}));
|
||||
|
||||
router.post('/diagnostics', asyncRoute(async (_req, res) => {
|
||||
const tableResult = await query<{
|
||||
const tableResult = await dbQuery<{
|
||||
current_schema: string;
|
||||
users_table: string | null;
|
||||
user_roles_table: string | null;
|
||||
@@ -211,7 +142,7 @@ router.post('/diagnostics', asyncRoute(async (_req, res) => {
|
||||
|
||||
if (tables?.users_table && tables.user_roles_table) {
|
||||
try {
|
||||
const userResult = await query<{
|
||||
const userResult = await dbQuery<{
|
||||
username: string;
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
@@ -242,7 +173,7 @@ router.post('/diagnostics', asyncRoute(async (_req, res) => {
|
||||
|
||||
if (tables?.migrations_table) {
|
||||
try {
|
||||
const migrationResult = await query<{ filename: string }>(
|
||||
const migrationResult = await dbQuery<{ filename: string }>(
|
||||
'SELECT filename FROM schema_migrations ORDER BY filename',
|
||||
);
|
||||
migrations = migrationResult.rows.map((row) => row.filename);
|
||||
@@ -262,4 +193,7 @@ router.post('/diagnostics', asyncRoute(async (_req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
export const authRouter = router;
|
||||
return router;
|
||||
}
|
||||
|
||||
export const authRouter = createAuthRouter();
|
||||
|
||||
706
server/routes/business.ts
Normal file
706
server/routes/business.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import ExcelJS from 'exceljs';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ok } from '../http/apiResponse.js';
|
||||
import { ApiError } from '../http/errors.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { query } from '../db/pool.js';
|
||||
import { requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
|
||||
import { watcher1 } from './admin.js';
|
||||
|
||||
interface BusinessRouterDeps {
|
||||
query: QueryFn;
|
||||
}
|
||||
|
||||
interface VisitRow {
|
||||
id: string;
|
||||
visitor_name: string;
|
||||
company: string | null;
|
||||
contact: string | null;
|
||||
email: string | null;
|
||||
vehicle_no: string | null;
|
||||
host_id: string;
|
||||
host_name: string;
|
||||
host_department: string | null;
|
||||
zone_name: string | null;
|
||||
purpose: string;
|
||||
purpose_code: string | null;
|
||||
purpose_detail: string | null;
|
||||
work_name: string | null;
|
||||
control_name: string | null;
|
||||
control_team: string | null;
|
||||
control_contact: string | null;
|
||||
watcher1_name: string | null;
|
||||
watcher1_team: string | null;
|
||||
watcher1_contact: string | null;
|
||||
watcher2_name: string | null;
|
||||
watcher2_team: string | null;
|
||||
watcher2_contact: string | null;
|
||||
visit_from: string | Date;
|
||||
visit_to: string | Date;
|
||||
status: string;
|
||||
qr_token: string | null;
|
||||
created_at: string | Date;
|
||||
}
|
||||
|
||||
interface AccessRow {
|
||||
visit_request_id: string;
|
||||
visitor_name: string;
|
||||
company: string | null;
|
||||
zone_name: string | null;
|
||||
host_name: string;
|
||||
visit_from: string | Date;
|
||||
visit_to: string | Date;
|
||||
check_in_at: string | Date | null;
|
||||
check_out_at: string | Date | null;
|
||||
}
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 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 toIso(value: string | Date | null | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function toVisit(row: VisitRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
visitorName: row.visitor_name,
|
||||
company: row.company ?? undefined,
|
||||
contact: row.contact ?? undefined,
|
||||
email: row.email ?? undefined,
|
||||
vehicleNo: row.vehicle_no ?? undefined,
|
||||
hostId: Number(row.host_id),
|
||||
hostName: row.host_name,
|
||||
hostDepartment: row.host_department ?? undefined,
|
||||
zoneName: row.zone_name ?? undefined,
|
||||
purpose: row.purpose,
|
||||
purposeCode: row.purpose_code ?? undefined,
|
||||
purposeDetail: row.purpose_detail ?? undefined,
|
||||
workName: row.work_name ?? undefined,
|
||||
controlName: row.control_name ?? undefined,
|
||||
controlTeam: row.control_team ?? undefined,
|
||||
controlContact: row.control_contact ?? undefined,
|
||||
watcher1Name: row.watcher1_name ?? undefined,
|
||||
watcher1Team: row.watcher1_team ?? undefined,
|
||||
watcher1Contact: row.watcher1_contact ?? undefined,
|
||||
watcher2Name: row.watcher2_name ?? undefined,
|
||||
watcher2Team: row.watcher2_team ?? undefined,
|
||||
watcher2Contact: row.watcher2_contact ?? undefined,
|
||||
visitFrom: toIso(row.visit_from)!,
|
||||
visitTo: toIso(row.visit_to)!,
|
||||
status: row.status,
|
||||
qrToken: row.qr_token ?? undefined,
|
||||
createdAt: toIso(row.created_at)!,
|
||||
};
|
||||
}
|
||||
|
||||
function required(value: unknown, label: string): string {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) throw new ApiError(400, `${label}을(를) 입력하세요.`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function sameDate(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
async function visitById(dbQuery: QueryFn, id: number): Promise<VisitRow | undefined> {
|
||||
const result = await dbQuery<VisitRow>(
|
||||
`
|
||||
SELECT
|
||||
vr.id,
|
||||
v.name AS visitor_name,
|
||||
v.company,
|
||||
v.contact,
|
||||
v.email,
|
||||
v.vehicle_no,
|
||||
h.id AS host_id,
|
||||
h.full_name AS host_name,
|
||||
h.department AS host_department,
|
||||
vr.zone_name,
|
||||
vr.purpose,
|
||||
vr.purpose_code,
|
||||
vr.purpose_detail,
|
||||
vr.work_name,
|
||||
vr.control_name,
|
||||
vr.control_team,
|
||||
vr.control_contact,
|
||||
vr.watcher1_name,
|
||||
vr.watcher1_team,
|
||||
vr.watcher1_contact,
|
||||
vr.watcher2_name,
|
||||
vr.watcher2_team,
|
||||
vr.watcher2_contact,
|
||||
vr.visit_from,
|
||||
vr.visit_to,
|
||||
vr.status,
|
||||
vr.qr_token,
|
||||
vr.created_at
|
||||
FROM visit_requests vr
|
||||
JOIN visitors v ON v.id = vr.visitor_id
|
||||
JOIN users h ON h.id = vr.host_id
|
||||
WHERE vr.id = $1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function listVisitRows(dbQuery: QueryFn, where = '', params: unknown[] = []) {
|
||||
const result = await dbQuery<VisitRow>(
|
||||
`
|
||||
SELECT
|
||||
vr.id,
|
||||
v.name AS visitor_name,
|
||||
v.company,
|
||||
v.contact,
|
||||
v.email,
|
||||
v.vehicle_no,
|
||||
h.id AS host_id,
|
||||
h.full_name AS host_name,
|
||||
h.department AS host_department,
|
||||
vr.zone_name,
|
||||
vr.purpose,
|
||||
vr.purpose_code,
|
||||
vr.purpose_detail,
|
||||
vr.work_name,
|
||||
vr.control_name,
|
||||
vr.control_team,
|
||||
vr.control_contact,
|
||||
vr.watcher1_name,
|
||||
vr.watcher1_team,
|
||||
vr.watcher1_contact,
|
||||
vr.watcher2_name,
|
||||
vr.watcher2_team,
|
||||
vr.watcher2_contact,
|
||||
vr.visit_from,
|
||||
vr.visit_to,
|
||||
vr.status,
|
||||
vr.qr_token,
|
||||
vr.created_at
|
||||
FROM visit_requests vr
|
||||
JOIN visitors v ON v.id = vr.visitor_id
|
||||
JOIN users h ON h.id = vr.host_id
|
||||
${where}
|
||||
ORDER BY vr.created_at DESC
|
||||
`,
|
||||
params,
|
||||
);
|
||||
return result.rows.map(toVisit);
|
||||
}
|
||||
|
||||
async function audit(dbQuery: QueryFn, actor: UserRow, action: string, 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, $3, $4, $5, $6)
|
||||
`,
|
||||
[Number(actor.id), actor.username, action, targetType, targetId, detail],
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpose: string, detail?: string) {
|
||||
if (code) {
|
||||
const result = await dbQuery<{ code: string; name: string; custom_allowed: boolean; active: boolean }>(
|
||||
'SELECT code, name, custom_allowed, active FROM purpose_codes WHERE upper(code) = upper($1)',
|
||||
[code],
|
||||
);
|
||||
const item = result.rows[0];
|
||||
if (!item || !item.active) throw new ApiError(400, `존재하지 않는 출입목적 코드입니다: ${code}`);
|
||||
if (!item.custom_allowed) return { code: item.code, name: item.name, detail: null, display: item.name };
|
||||
const text = String(detail || purpose || '').trim();
|
||||
if (!text || text === item.name) throw new ApiError(400, '기타 출입 목적을 입력하세요.');
|
||||
const matched = await dbQuery<{ code: string; name: string }>(
|
||||
'SELECT code, name FROM purpose_codes WHERE active = TRUE AND custom_allowed = FALSE AND (upper(code) = upper($1) OR name = $1)',
|
||||
[text],
|
||||
);
|
||||
if (matched.rows[0]) return { code: matched.rows[0].code, name: matched.rows[0].name, detail: null, display: matched.rows[0].name };
|
||||
return { code: item.code, name: text, detail: text, display: text };
|
||||
}
|
||||
const text = required(purpose, '출입 목적');
|
||||
return { code: null, name: text, detail: null, display: text };
|
||||
}
|
||||
|
||||
async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<string, unknown>, zoneName: string) {
|
||||
const visitorName = required(body.visitorName, '방문자 이름');
|
||||
const contact = required(body.contact, '방문자 연락처');
|
||||
const visitFrom = new Date(required(body.visitFrom, '출입 일시'));
|
||||
const visitTo = new Date(required(body.visitTo, '퇴실 일시'));
|
||||
if (visitFrom > visitTo) throw new ApiError(400, '퇴장일시는 출입일시 이후로 입력해야 합니다.');
|
||||
if (!sameDate(visitFrom, visitTo)) {
|
||||
throw new ApiError(400, '퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다');
|
||||
}
|
||||
const purpose = await resolvePurpose(
|
||||
dbQuery,
|
||||
body.purposeCode ? String(body.purposeCode) : undefined,
|
||||
String(body.purpose ?? ''),
|
||||
body.purposeDetail ? String(body.purposeDetail) : undefined,
|
||||
);
|
||||
const watcher = await watcher1(dbQuery);
|
||||
const visitor = await dbQuery<{ id: string }>(
|
||||
`
|
||||
INSERT INTO visitors (created_at, updated_at, name, company, contact, email, vehicle_no)
|
||||
VALUES (now(), now(), $1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
visitorName,
|
||||
body.company ? String(body.company).trim() : null,
|
||||
contact,
|
||||
body.email ? String(body.email).trim() : null,
|
||||
body.vehicleNo ? String(body.vehicleNo).trim() : null,
|
||||
],
|
||||
);
|
||||
const qrToken = randomUUID().replaceAll('-', '');
|
||||
const created = await dbQuery<{ id: string }>(
|
||||
`
|
||||
INSERT INTO visit_requests (
|
||||
created_at, updated_at, visitor_id, host_id, zone_name, purpose, purpose_code, purpose_detail,
|
||||
work_name, visit_from, visit_to, status, qr_token,
|
||||
control_name, control_team, control_contact,
|
||||
watcher1_name, watcher1_team, watcher1_contact,
|
||||
watcher2_name, watcher2_team, watcher2_contact
|
||||
)
|
||||
VALUES (
|
||||
now(), now(), $1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, 'PENDING', $10,
|
||||
$11, $12, $13,
|
||||
$14, $15, $16,
|
||||
$17, $18, $19
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
Number(visitor.rows[0].id),
|
||||
Number(actor.id),
|
||||
zoneName,
|
||||
purpose.display,
|
||||
purpose.code,
|
||||
purpose.detail,
|
||||
body.workName ? String(body.workName).trim() : null,
|
||||
visitFrom,
|
||||
visitTo,
|
||||
qrToken,
|
||||
actor.full_name,
|
||||
actor.department,
|
||||
null,
|
||||
watcher.name,
|
||||
watcher.team,
|
||||
watcher.contact,
|
||||
body.watcher2Name ? String(body.watcher2Name).trim() : null,
|
||||
body.watcher2Team ? String(body.watcher2Team).trim() : null,
|
||||
body.watcher2Contact ? String(body.watcher2Contact).trim() : null,
|
||||
],
|
||||
);
|
||||
return toVisit((await visitById(dbQuery, Number(created.rows[0].id)))!);
|
||||
}
|
||||
|
||||
export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Router {
|
||||
const router = Router();
|
||||
const dbQuery = deps.query;
|
||||
|
||||
router.get('/zones', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const result = await dbQuery('SELECT id, code, name, security_level FROM zones WHERE active = TRUE ORDER BY id');
|
||||
ok(res, result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
securityLevel: row.security_level,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.get('/visit-requests', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
ok(res, await listVisitRows(dbQuery));
|
||||
}));
|
||||
|
||||
router.get('/visit-requests/pending', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'PENDING'"));
|
||||
}));
|
||||
|
||||
router.get('/visit-requests/:id', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const visit = await visitById(dbQuery, Number(req.params.id));
|
||||
if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.');
|
||||
ok(res, toVisit(visit));
|
||||
}));
|
||||
|
||||
router.post('/visit-requests', asyncRoute(async (req, res) => {
|
||||
const actor = await requireCurrentUser(dbQuery, req);
|
||||
const rooms = Array.isArray(req.body?.serverRooms) ? req.body.serverRooms.map(String).filter(Boolean) : [];
|
||||
const roomZone = req.body?.roomZone ? String(req.body.roomZone).trim() : '';
|
||||
const zones = rooms.length > 0 ? rooms : roomZone ? [roomZone] : [];
|
||||
if (zones.length === 0) throw new ApiError(400, '출입 구역을 선택하세요.');
|
||||
const created = [];
|
||||
for (const zone of zones) {
|
||||
created.push(await createOneVisit(dbQuery, actor, req.body, zone));
|
||||
}
|
||||
ok(res, created);
|
||||
}));
|
||||
|
||||
router.post('/visit-requests/:id/cancel', asyncRoute(async (req, res) => {
|
||||
const actor = await requireCurrentUser(dbQuery, req);
|
||||
await dbQuery("UPDATE visit_requests SET status = 'CANCELLED', updated_at = now() WHERE id = $1 AND status IN ('PENDING', 'APPROVED')", [Number(req.params.id)]);
|
||||
await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', Number(req.params.id), '신청 취소');
|
||||
ok(res, toVisit((await visitById(dbQuery, Number(req.params.id)))!));
|
||||
}));
|
||||
|
||||
router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (_req, res) => {
|
||||
ok(res, { totalRows: 0, successCount: 0, errors: ['Node 배포본의 방문자 명단 엑셀 업로드는 다음 단계에서 이관 예정입니다.'], success: false });
|
||||
}));
|
||||
|
||||
router.post('/approvals/:id/approve', asyncRoute(async (req, res) => {
|
||||
const actor = await requireAdmin(dbQuery, req);
|
||||
const id = Number(req.params.id);
|
||||
await dbQuery("UPDATE visit_requests SET status = 'APPROVED', updated_at = now() WHERE id = $1", [id]);
|
||||
await dbQuery(
|
||||
"INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'APPROVED', $3, now())",
|
||||
[id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null],
|
||||
);
|
||||
await dbQuery(
|
||||
"INSERT INTO pass_deliveries (created_at, updated_at, visit_request_id, channel, recipient, status, attempts, last_error) VALUES (now(), now(), $1, 'DEV', NULL, 'SENT', 1, NULL)",
|
||||
[id],
|
||||
);
|
||||
await audit(dbQuery, actor, 'APPROVE', 'VISIT_REQUEST', id, '승인');
|
||||
ok(res, toVisit((await visitById(dbQuery, id))!));
|
||||
}));
|
||||
|
||||
router.post('/approvals/:id/reject', asyncRoute(async (req, res) => {
|
||||
const actor = await requireAdmin(dbQuery, req);
|
||||
const id = Number(req.params.id);
|
||||
await dbQuery("UPDATE visit_requests SET status = 'REJECTED', updated_at = now() WHERE id = $1", [id]);
|
||||
await dbQuery(
|
||||
"INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'REJECTED', $3, now())",
|
||||
[id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null],
|
||||
);
|
||||
await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', id, '반려');
|
||||
ok(res, toVisit((await visitById(dbQuery, id))!));
|
||||
}));
|
||||
|
||||
router.get('/passes/:id', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const visit = await visitById(dbQuery, Number(req.params.id));
|
||||
if (!visit) throw new ApiError(404, '출입증을 찾을 수 없습니다.');
|
||||
ok(res, toVisit(visit));
|
||||
}));
|
||||
|
||||
router.get('/passes/:id/qr.png', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const visit = await visitById(dbQuery, Number(req.params.id));
|
||||
if (!visit?.qr_token) throw new ApiError(404, 'QR을 찾을 수 없습니다.');
|
||||
const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${visit.qr_token}`, { type: 'png', width: 220 });
|
||||
res.type('png').send(png);
|
||||
}));
|
||||
|
||||
router.get('/public/passes/:token', asyncRoute(async (req, res) => {
|
||||
const result = await dbQuery<VisitRow & { inside: boolean; completed_today: boolean }>(
|
||||
`
|
||||
SELECT
|
||||
vr.*, v.name AS visitor_name, v.company, v.contact, v.email, v.vehicle_no,
|
||||
h.id AS host_id, h.full_name AS host_name, h.department AS host_department,
|
||||
EXISTS (
|
||||
SELECT 1 FROM access_events ae
|
||||
WHERE ae.visit_request_id = vr.id AND ae.direction = 'IN'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM access_events out_ae
|
||||
WHERE out_ae.visit_request_id = vr.id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at
|
||||
)
|
||||
) AS inside,
|
||||
EXISTS (
|
||||
SELECT 1 FROM access_events ae
|
||||
WHERE ae.visit_request_id = vr.id AND ae.direction = 'OUT' AND ae.event_at::date = now()::date
|
||||
) AS completed_today
|
||||
FROM visit_requests vr
|
||||
JOIN visitors v ON v.id = vr.visitor_id
|
||||
JOIN users h ON h.id = vr.host_id
|
||||
WHERE vr.qr_token = $1
|
||||
`,
|
||||
[req.params.token],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new ApiError(404, '출입증을 찾을 수 없습니다.');
|
||||
ok(res, {
|
||||
visitorName: row.visitor_name,
|
||||
company: row.company ?? undefined,
|
||||
zoneName: row.zone_name ?? undefined,
|
||||
visitFrom: toIso(row.visit_from)!,
|
||||
visitTo: toIso(row.visit_to)!,
|
||||
inside: row.inside,
|
||||
completedToday: row.completed_today,
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/public/passes/:token/qr.png', asyncRoute(async (req, res) => {
|
||||
const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${req.params.token}`, { type: 'png', width: 220 });
|
||||
res.type('png').send(png);
|
||||
}));
|
||||
|
||||
async function accessAction(req: Request, res: Response, direction: 'IN' | 'OUT') {
|
||||
const actor = req.path.startsWith('/public/') ? null : await requireCurrentUser(dbQuery, req);
|
||||
const token = req.params.token || req.body?.qrToken;
|
||||
let id = req.body?.visitRequestId ? Number(req.body.visitRequestId) : undefined;
|
||||
if (!id && token) {
|
||||
const found = await dbQuery<{ id: string }>('SELECT id FROM visit_requests WHERE qr_token = $1', [String(token)]);
|
||||
id = found.rows[0] ? Number(found.rows[0].id) : undefined;
|
||||
}
|
||||
if (!id) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.');
|
||||
const visit = await visitById(dbQuery, id);
|
||||
if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.');
|
||||
if (visit.status !== 'APPROVED') throw new ApiError(400, '승인된 방문 신청이 아닙니다.');
|
||||
const today = new Date();
|
||||
if (!sameDate(today, new Date(visit.visit_from))) {
|
||||
await dbQuery("UPDATE visit_requests SET status = 'EXPIRED', updated_at = now() WHERE id = $1", [id]);
|
||||
throw new ApiError(400, 'QR 유효기간이 지났습니다.');
|
||||
}
|
||||
await dbQuery(
|
||||
'INSERT INTO access_events (created_at, updated_at, visit_request_id, direction, gate_id, operator_id, event_at) VALUES (now(), now(), $1, $2, $3, $4, now())',
|
||||
[id, direction, req.body?.gateId ? String(req.body.gateId) : null, actor ? Number(actor.id) : null],
|
||||
);
|
||||
if (direction === 'OUT') {
|
||||
await dbQuery(
|
||||
"UPDATE visitors SET name='***', contact='***', email='***', vehicle_no='***', updated_at = now() WHERE id = (SELECT visitor_id FROM visit_requests WHERE id = $1)",
|
||||
[id],
|
||||
);
|
||||
}
|
||||
ok(res, {
|
||||
visitRequestId: id,
|
||||
visitorName: visit.visitor_name,
|
||||
direction,
|
||||
eventAt: new Date().toISOString(),
|
||||
gateOpened: true,
|
||||
message: direction === 'IN' ? '입장 처리되었습니다.' : '퇴장 처리되었습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
router.post('/access/check-in', asyncRoute(async (req, res) => accessAction(req, res, 'IN')));
|
||||
router.post('/access/check-out', asyncRoute(async (req, res) => accessAction(req, res, 'OUT')));
|
||||
router.post('/public/passes/:token/check-in', asyncRoute(async (req, res) => accessAction(req, res, 'IN')));
|
||||
router.post('/public/passes/:token/check-out', asyncRoute(async (req, res) => accessAction(req, res, 'OUT')));
|
||||
|
||||
router.get('/access/search', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const q = `%${String(req.query.q ?? '').trim()}%`;
|
||||
ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'APPROVED' AND (v.name ILIKE $1 OR v.company ILIKE $1 OR vr.zone_name ILIKE $1)", [q]));
|
||||
}));
|
||||
|
||||
router.get('/access/inside', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const result = await dbQuery<AccessRow>(
|
||||
`
|
||||
SELECT DISTINCT ON (vr.id)
|
||||
vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name,
|
||||
vr.visit_from, vr.visit_to, ae.event_at AS check_in_at, NULL::timestamp AS check_out_at
|
||||
FROM visit_requests vr
|
||||
JOIN visitors v ON v.id = vr.visitor_id
|
||||
JOIN users h ON h.id = vr.host_id
|
||||
JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.direction = 'IN'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM access_events out_ae
|
||||
WHERE out_ae.visit_request_id = vr.id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at
|
||||
)
|
||||
ORDER BY vr.id, ae.event_at DESC
|
||||
`,
|
||||
);
|
||||
ok(res, result.rows.map((row) => ({
|
||||
visitRequestId: Number(row.visit_request_id),
|
||||
visitorName: row.visitor_name,
|
||||
company: row.company ?? undefined,
|
||||
zoneName: row.zone_name ?? undefined,
|
||||
hostName: row.host_name,
|
||||
checkInAt: toIso(row.check_in_at)!,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.get('/access/today', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const result = await dbQuery<AccessRow & { inside: boolean }>(
|
||||
`
|
||||
SELECT
|
||||
vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name,
|
||||
vr.visit_from, vr.visit_to,
|
||||
min(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at,
|
||||
max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at,
|
||||
(max(ae.event_at) FILTER (WHERE ae.direction = 'IN')) > COALESCE(max(ae.event_at) FILTER (WHERE ae.direction = 'OUT'), 'epoch') AS inside
|
||||
FROM visit_requests vr
|
||||
JOIN visitors v ON v.id = vr.visitor_id
|
||||
JOIN users h ON h.id = vr.host_id
|
||||
LEFT JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.event_at::date = now()::date
|
||||
WHERE vr.visit_from::date = now()::date
|
||||
GROUP BY vr.id, v.id, h.id
|
||||
ORDER BY vr.visit_from
|
||||
`,
|
||||
);
|
||||
ok(res, result.rows.map((row) => ({
|
||||
visitRequestId: Number(row.visit_request_id),
|
||||
visitorName: row.visitor_name,
|
||||
company: row.company ?? undefined,
|
||||
zoneName: row.zone_name ?? undefined,
|
||||
visitFrom: toIso(row.visit_from)!,
|
||||
visitTo: toIso(row.visit_to)!,
|
||||
checkInAt: toIso(row.check_in_at),
|
||||
checkOutAt: toIso(row.check_out_at),
|
||||
inside: row.inside,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.get('/stats/summary', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const result = await dbQuery<{ today_visits: string; pending: string; approved: string; total: string }>(
|
||||
`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE visit_from::date = now()::date) AS today_visits,
|
||||
count(*) FILTER (WHERE status = 'PENDING') AS pending,
|
||||
count(*) FILTER (WHERE status = 'APPROVED') AS approved,
|
||||
count(*) AS total
|
||||
FROM visit_requests
|
||||
`,
|
||||
);
|
||||
const inside = await dbQuery<{ count: string }>(
|
||||
`
|
||||
SELECT count(DISTINCT ae.visit_request_id)
|
||||
FROM access_events ae
|
||||
WHERE ae.direction = 'IN'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM access_events out_ae
|
||||
WHERE out_ae.visit_request_id = ae.visit_request_id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at
|
||||
)
|
||||
`,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
ok(res, {
|
||||
todayVisits: Number(row.today_visits),
|
||||
pending: Number(row.pending),
|
||||
approved: Number(row.approved),
|
||||
currentlyInside: Number(inside.rows[0].count),
|
||||
total: Number(row.total),
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/blacklist', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
const result = await dbQuery(
|
||||
`
|
||||
SELECT b.*, u.full_name AS created_by_name
|
||||
FROM blacklist b
|
||||
LEFT JOIN users u ON u.id = b.created_by
|
||||
ORDER BY b.created_at DESC
|
||||
`,
|
||||
);
|
||||
ok(res, result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
company: row.company ?? undefined,
|
||||
contact: row.contact ?? undefined,
|
||||
reason: row.reason,
|
||||
active: row.active,
|
||||
createdByName: row.created_by_name ?? undefined,
|
||||
createdAt: toIso(row.created_at)!,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.post('/blacklist', asyncRoute(async (req, res) => {
|
||||
const actor = await requireAdmin(dbQuery, req);
|
||||
const result = await dbQuery<{ id: string }>(
|
||||
'INSERT INTO blacklist (created_at, updated_at, name, company, contact, reason, active, created_by) VALUES (now(), now(), $1, $2, $3, $4, TRUE, $5) RETURNING id',
|
||||
[required(req.body?.name, '이름'), req.body?.company || null, req.body?.contact || null, required(req.body?.reason, '사유'), Number(actor.id)],
|
||||
);
|
||||
await audit(dbQuery, actor, 'BLACKLIST_ADD', 'BLACKLIST', Number(result.rows[0].id), '블랙리스트 추가');
|
||||
ok(res, (await dbQuery('SELECT * FROM blacklist WHERE id = $1', [Number(result.rows[0].id)])).rows[0]);
|
||||
}));
|
||||
|
||||
router.delete('/blacklist/:id', asyncRoute(async (req, res) => {
|
||||
const actor = await requireAdmin(dbQuery, req);
|
||||
await dbQuery('UPDATE blacklist SET active = FALSE, updated_at = now() WHERE id = $1', [Number(req.params.id)]);
|
||||
await audit(dbQuery, actor, 'BLACKLIST_REMOVE', 'BLACKLIST', Number(req.params.id), '블랙리스트 해제');
|
||||
ok(res, '삭제되었습니다.');
|
||||
}));
|
||||
|
||||
router.get('/reports/visits', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
ok(res, await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]));
|
||||
}));
|
||||
|
||||
router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => {
|
||||
await requireCurrentUser(dbQuery, req);
|
||||
const visits = await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('visits');
|
||||
sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']);
|
||||
for (const visit of visits) {
|
||||
sheet.addRow([visit.visitorName, visit.company ?? '', visit.zoneName ?? '', visit.purpose, visit.workName ?? '', visit.status, visit.visitFrom, visit.visitTo]);
|
||||
}
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));
|
||||
}));
|
||||
|
||||
router.get('/admin/audit', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
const result = await dbQuery('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 300');
|
||||
ok(res, result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
at: toIso(row.created_at)!,
|
||||
actorId: row.actor_id ? Number(row.actor_id) : undefined,
|
||||
actorUsername: row.actor_username ?? undefined,
|
||||
action: row.action,
|
||||
targetType: row.target_type ?? undefined,
|
||||
targetId: row.target_id ? Number(row.target_id) : undefined,
|
||||
detail: row.detail ?? undefined,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.get('/admin/deliveries', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
const status = req.query.status ? String(req.query.status) : null;
|
||||
const result = await dbQuery(
|
||||
`SELECT * FROM pass_deliveries ${status ? 'WHERE status = $1' : ''} ORDER BY created_at DESC LIMIT 300`,
|
||||
status ? [status] : [],
|
||||
);
|
||||
ok(res, result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
visitRequestId: Number(row.visit_request_id),
|
||||
channel: row.channel ?? undefined,
|
||||
recipient: row.recipient ?? undefined,
|
||||
status: row.status,
|
||||
attempts: Number(row.attempts),
|
||||
lastError: row.last_error ?? undefined,
|
||||
createdAt: toIso(row.created_at)!,
|
||||
updatedAt: toIso(row.updated_at)!,
|
||||
})));
|
||||
}));
|
||||
|
||||
router.post('/admin/deliveries/:id/retry', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
const result = await dbQuery(
|
||||
"UPDATE pass_deliveries SET attempts = attempts + 1, status = 'SENT', last_error = NULL, updated_at = now() WHERE id = $1 RETURNING *",
|
||||
[Number(req.params.id)],
|
||||
);
|
||||
ok(res, {
|
||||
id: Number(result.rows[0].id),
|
||||
visitRequestId: Number(result.rows[0].visit_request_id),
|
||||
channel: result.rows[0].channel ?? undefined,
|
||||
recipient: result.rows[0].recipient ?? undefined,
|
||||
status: result.rows[0].status,
|
||||
attempts: Number(result.rows[0].attempts),
|
||||
lastError: result.rows[0].last_error ?? undefined,
|
||||
createdAt: toIso(result.rows[0].created_at)!,
|
||||
updatedAt: toIso(result.rows[0].updated_at)!,
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export const businessRouter = createBusinessRouter();
|
||||
@@ -1,8 +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;
|
||||
}
|
||||
Reference in New Issue
Block a user