From 01d48fe808a4750908277047790f5fc442206827 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 10 Jul 2026 15:18:29 +0900 Subject: [PATCH] feat: prepare ACS deployment --- .gitignore | 2 + .../com/itcenter/acs/config/DataSeeder.java | 13 +- .../acs/controller/AuthController.java | 2 + .../controller/VisitRequestController.java | 23 +- .../itcenter/acs/dto/CurrentUserResponse.java | 2 + .../acs/dto/VisitRequestCreateRequest.java | 34 +- .../acs/dto/VisitRequestResponse.java | 20 + .../com/itcenter/acs/entity/VisitRequest.java | 28 ++ .../itcenter/acs/security/UserPrincipal.java | 4 + .../acs/service/ExcelImportService.java | 147 ++++++-- .../itcenter/acs/service/ReportService.java | 9 +- .../acs/service/VisitRequestService.java | 103 +++++- .../V4__visit_request_contact_fields.sql | 11 + docs/ACS작업일지.md | 195 ++++++++++ docs/AIdev.md | 349 ++++++++++++++++++ docs/issues-and-guidelines.md | 2 +- docs/workflow.md | 2 +- docs/회사의 이메일 API 사용법.txt | 6 + frontend/src/api.ts | 2 +- frontend/src/pages/AccessConsolePage.tsx | 14 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/VisitRequestFormPage.tsx | 144 +++++++- frontend/src/pages/VisitRequestListPage.tsx | 3 +- frontend/src/styles/common.css | 13 + frontend/src/types.ts | 23 +- 25 files changed, 1070 insertions(+), 83 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql create mode 100644 docs/ACS작업일지.md create mode 100644 docs/AIdev.md create mode 100644 docs/회사의 이메일 API 사용법.txt diff --git a/.gitignore b/.gitignore index 5629338..d16c748 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ frontend/.vite/ *.iml .DS_Store Thumbs.db +*.log +*.bak # Local env infra/.env diff --git a/backend/src/main/java/com/itcenter/acs/config/DataSeeder.java b/backend/src/main/java/com/itcenter/acs/config/DataSeeder.java index c9cf5a4..945dc23 100644 --- a/backend/src/main/java/com/itcenter/acs/config/DataSeeder.java +++ b/backend/src/main/java/com/itcenter/acs/config/DataSeeder.java @@ -29,13 +29,18 @@ public class DataSeeder implements CommandLineRunner { private final ZoneRepository zoneRepository; private final PasswordEncoder passwordEncoder; - private static final String DEFAULT_PASSWORD = "ChangeMe123!"; + // 적용(운영 반영) 전까지 신속한 테스트를 위한 단축 계정. dev(비-prod)에서만 시드된다. + // 운영 전환 시 이 시드는 무시되고 Flyway/CSV 시더가 실제 계정을 관리한다. + private static final String TEST_PASSWORD = "1"; @Override public void run(String... args) { seedUser("admin", "관리자", "IT운영팀", Set.of(RoleType.ADMIN)); seedUser("security", "보안담당", "보안팀", Set.of(RoleType.SECURITY)); seedUser("host", "홍길동", "개발1팀", Set.of(RoleType.HOST)); + seedUser("a", "관리자", "IT운영팀", Set.of(RoleType.ADMIN)); + seedUser("s", "보안담당", "보안팀", Set.of(RoleType.SECURITY)); + seedUser("h", "홍길동", "개발1팀", Set.of(RoleType.HOST)); seedZone("LOBBY", "로비", 1); seedZone("OFFICE", "사무공간", 2); @@ -48,14 +53,14 @@ public class DataSeeder implements CommandLineRunner { } User user = new User(); user.setUsername(username); - user.setPasswordHash(passwordEncoder.encode(DEFAULT_PASSWORD)); + user.setPasswordHash(passwordEncoder.encode(TEST_PASSWORD)); user.setFullName(fullName); user.setDepartment(department); user.setEmail(username + "@itcenter.local"); user.setRoles(roles); - user.setMustChangePassword(true); + user.setMustChangePassword(false); // 테스트 편의: 최초 로그인 시 비번변경 강제하지 않음 userRepository.save(user); - log.info("[seed] user '{}' created (default password: {})", username, DEFAULT_PASSWORD); + log.info("[seed] test user '{}' created (password: {})", username, TEST_PASSWORD); } private void seedZone(String code, String name, int level) { diff --git a/backend/src/main/java/com/itcenter/acs/controller/AuthController.java b/backend/src/main/java/com/itcenter/acs/controller/AuthController.java index 48ab381..6742495 100644 --- a/backend/src/main/java/com/itcenter/acs/controller/AuthController.java +++ b/backend/src/main/java/com/itcenter/acs/controller/AuthController.java @@ -103,6 +103,8 @@ public class AuthController { principal.getId(), principal.getUsername(), principal.getFullName(), + principal.getDepartment(), + principal.getEmail(), roles, principal.isMustChangePassword()); } diff --git a/backend/src/main/java/com/itcenter/acs/controller/VisitRequestController.java b/backend/src/main/java/com/itcenter/acs/controller/VisitRequestController.java index e3c0b9d..8078385 100644 --- a/backend/src/main/java/com/itcenter/acs/controller/VisitRequestController.java +++ b/backend/src/main/java/com/itcenter/acs/controller/VisitRequestController.java @@ -6,6 +6,7 @@ import com.itcenter.acs.dto.VisitRequestCreateRequest; import com.itcenter.acs.dto.VisitRequestResponse; import com.itcenter.acs.entity.VisitRequest; import com.itcenter.acs.security.SecurityUtils; +import com.itcenter.acs.service.ApprovalService; import com.itcenter.acs.service.ExcelImportService; import com.itcenter.acs.service.VisitRequestService; import jakarta.validation.Valid; @@ -31,13 +32,27 @@ public class VisitRequestController { private final VisitRequestService visitRequestService; private final ExcelImportService excelImportService; + private final ApprovalService approvalService; - /** Create a single pre-registration; current user becomes the host. */ + /** + * Create pre-registration(s); current user becomes the host. One request is issued per + * selected server room, so the response may contain more than one (e.g. 4층+5층전산실 → 2건). + * When the registrant already holds approval authority (ADMIN/SECURITY), each request is + * self-approved immediately (QR issued) — HOST registrations stay PENDING. + */ @PostMapping - public ResponseEntity> create( + public ResponseEntity>> create( @Valid @RequestBody VisitRequestCreateRequest request) { - VisitRequest created = visitRequestService.create(request, SecurityUtils.currentUserId()); - return ResponseEntity.ok(ApiResponse.success(VisitRequestResponse.from(created))); + Long userId = SecurityUtils.currentUserId(); + List created = visitRequestService.createRequests(request, userId); + if (SecurityUtils.hasRole("ADMIN") || SecurityUtils.hasRole("SECURITY")) { + List approved = new java.util.ArrayList<>(created.size()); + for (VisitRequest vr : created) { + approved.add(approvalService.approve(vr.getId(), userId, "본인 등록 자동승인")); + } + created = approved; + } + return ResponseEntity.ok(ApiResponse.success(toResponses(created))); } /** ADMIN/SECURITY see all; HOST sees only their own requests. */ diff --git a/backend/src/main/java/com/itcenter/acs/dto/CurrentUserResponse.java b/backend/src/main/java/com/itcenter/acs/dto/CurrentUserResponse.java index 8943f3a..bb64c97 100644 --- a/backend/src/main/java/com/itcenter/acs/dto/CurrentUserResponse.java +++ b/backend/src/main/java/com/itcenter/acs/dto/CurrentUserResponse.java @@ -11,6 +11,8 @@ public class CurrentUserResponse { private Long id; private String username; private String fullName; + private String department; + private String email; private Set roles; private boolean mustChangePassword; } diff --git a/backend/src/main/java/com/itcenter/acs/dto/VisitRequestCreateRequest.java b/backend/src/main/java/com/itcenter/acs/dto/VisitRequestCreateRequest.java index 7e232f1..9c0eed0 100644 --- a/backend/src/main/java/com/itcenter/acs/dto/VisitRequestCreateRequest.java +++ b/backend/src/main/java/com/itcenter/acs/dto/VisitRequestCreateRequest.java @@ -1,11 +1,11 @@ package com.itcenter.acs.dto; -import jakarta.validation.constraints.Future; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; @Data public class VisitRequestCreateRequest { @@ -20,12 +20,40 @@ public class VisitRequestCreateRequest { private String email; private String vehicleNo; - /** Access zone label (fixed list value or "기타" free text). */ - private String zoneName; + /** + * Server rooms (전산실) selected via checkboxes, e.g. ["4층전산실", "5층전산실"]. + * Each selected room becomes its own visit request / QR pass. + */ + private List serverRooms; + + /** + * Optional detail room (콤보박스, 기타 선택 시 자유 입력값). Auxiliary info — carried + * alongside the server room on the zone label but never issues its own QR. When no + * server room is selected, this becomes the sole access zone. + */ + private String roomZone; @NotBlank(message = "출입 목적을 입력하세요.") private String purpose; + /** 작업명 — optional concrete task detail, stored separately from purpose. */ + private String workName; + + // 출입통제담당자(본인) — 웹은 서버가 로그인 사용자로 채움. 엑셀은 파일값 사용. + private String controlName; + private String controlTeam; + private String controlContact; + + // 현장감시자1(고정) — 웹은 서버가 고정 상수로 채움. 엑셀은 파일값(비면 고정). + private String watcher1Name; + private String watcher1Team; + private String watcher1Contact; + + // 현장감시자2 — 담당자가 입력. + private String watcher2Name; + private String watcher2Team; + private String watcher2Contact; + @NotNull(message = "방문 시작 일시를 입력하세요.") private LocalDateTime visitFrom; diff --git a/backend/src/main/java/com/itcenter/acs/dto/VisitRequestResponse.java b/backend/src/main/java/com/itcenter/acs/dto/VisitRequestResponse.java index fff599e..fdb2703 100644 --- a/backend/src/main/java/com/itcenter/acs/dto/VisitRequestResponse.java +++ b/backend/src/main/java/com/itcenter/acs/dto/VisitRequestResponse.java @@ -17,6 +17,16 @@ public class VisitRequestResponse { private String hostDepartment; private String zoneName; private String purpose; + private String workName; + private String controlName; + private String controlTeam; + private String controlContact; + private String watcher1Name; + private String watcher1Team; + private String watcher1Contact; + private String watcher2Name; + private String watcher2Team; + private String watcher2Contact; private LocalDateTime visitFrom; private LocalDateTime visitTo; private String status; @@ -35,6 +45,16 @@ public class VisitRequestResponse { r.hostDepartment = vr.getHost().getDepartment(); r.zoneName = vr.getZoneName(); r.purpose = vr.getPurpose(); + r.workName = vr.getWorkName(); + r.controlName = vr.getControlName(); + r.controlTeam = vr.getControlTeam(); + r.controlContact = vr.getControlContact(); + r.watcher1Name = vr.getWatcher1Name(); + r.watcher1Team = vr.getWatcher1Team(); + r.watcher1Contact = vr.getWatcher1Contact(); + r.watcher2Name = vr.getWatcher2Name(); + r.watcher2Team = vr.getWatcher2Team(); + r.watcher2Contact = vr.getWatcher2Contact(); r.visitFrom = vr.getVisitFrom(); r.visitTo = vr.getVisitTo(); r.status = vr.getStatus().name(); diff --git a/backend/src/main/java/com/itcenter/acs/entity/VisitRequest.java b/backend/src/main/java/com/itcenter/acs/entity/VisitRequest.java index 307d57d..5eab61b 100644 --- a/backend/src/main/java/com/itcenter/acs/entity/VisitRequest.java +++ b/backend/src/main/java/com/itcenter/acs/entity/VisitRequest.java @@ -46,6 +46,34 @@ public class VisitRequest extends BaseEntity { @Column(nullable = false, length = 255) private String purpose; + /** 작업명 — concrete task detail, kept separate from the simple purpose category. */ + @Column(name = "work_name", length = 255) + private String workName; + + // ===== 출입통제담당자 (본인 = 등록한 내부 직원) 스냅샷 ===== + @Column(name = "control_name", length = 80) + private String controlName; + @Column(name = "control_team", length = 80) + private String controlTeam; + @Column(name = "control_contact", length = 60) + private String controlContact; + + // ===== 현장감시자1 (고정) ===== + @Column(name = "watcher1_name", length = 80) + private String watcher1Name; + @Column(name = "watcher1_team", length = 80) + private String watcher1Team; + @Column(name = "watcher1_contact", length = 60) + private String watcher1Contact; + + // ===== 현장감시자2 (담당자가 입력) ===== + @Column(name = "watcher2_name", length = 80) + private String watcher2Name; + @Column(name = "watcher2_team", length = 80) + private String watcher2Team; + @Column(name = "watcher2_contact", length = 60) + private String watcher2Contact; + @Column(nullable = false) private LocalDateTime visitFrom; diff --git a/backend/src/main/java/com/itcenter/acs/security/UserPrincipal.java b/backend/src/main/java/com/itcenter/acs/security/UserPrincipal.java index 08188ca..2b1e378 100644 --- a/backend/src/main/java/com/itcenter/acs/security/UserPrincipal.java +++ b/backend/src/main/java/com/itcenter/acs/security/UserPrincipal.java @@ -21,6 +21,8 @@ public class UserPrincipal implements UserDetails { private final String username; private final String password; private final String fullName; + private final String department; + private final String email; private final boolean mustChangePassword; private final boolean enabled; private final boolean locked; @@ -31,6 +33,8 @@ public class UserPrincipal implements UserDetails { this.username = user.getUsername(); this.password = user.getPasswordHash(); this.fullName = user.getFullName(); + this.department = user.getDepartment(); + this.email = user.getEmail(); this.mustChangePassword = user.isMustChangePassword(); this.enabled = user.isEnabled(); this.locked = user.isLocked(); diff --git a/backend/src/main/java/com/itcenter/acs/service/ExcelImportService.java b/backend/src/main/java/com/itcenter/acs/service/ExcelImportService.java index 0475e14..fefeeaa 100644 --- a/backend/src/main/java/com/itcenter/acs/service/ExcelImportService.java +++ b/backend/src/main/java/com/itcenter/acs/service/ExcelImportService.java @@ -15,16 +15,24 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; /** - * Bulk-imports visit requests from an .xlsx file. - * Columns (row 1 = header, skipped): - * 0 visitorName | 1 company | 2 contact | 3 email | 4 vehicleNo - * 5 zoneName | 6 purpose | 7 visitFrom | 8 visitTo - * Dates accept Excel date cells or "yyyy-MM-dd HH:mm" / "yyyy-MM-dd" text. + * Bulk-imports visit requests from the corporate 방문자명단 .xlsx template + * (itcas_visitor_template.xlsx). The data sheet is "방문자명단"; the first two rows are + * group + column headers and are skipped. Columns (0-based): + * 0 순번 | 1 출입목적 | 2 작업명 | 3 장소 | 4 출입일자 | 5 출입시간 | + * 6 이름 | 7 소속 | 8 연락처(휴대전화) | 9 차량번호 | + * 10 직원명 | 11 담당팀명 | 12 연락처 (출입통제담당자) | + * 13 이름 | 14 소속 | 15 연락처 (현장감시자1) | + * 16 이름 | 17 소속 | 18 연락처 (현장감시자2) + * Each row is one 장소 → one request/QR ("4층전산실과 5층전산실은 개별 행으로 작성"). Only the + * start date+time are captured; visitTo defaults to 23:59:59 of that day (actual entry/exit + * are tracked by check-in/out). 출입일자 accepts Excel date cells or "yyyy.M.d(요일)" / + * "yyyy-MM-dd" text; 출입시간 accepts Excel time cells, "HH:mm" text, or a day fraction. */ @Service @RequiredArgsConstructor @@ -32,7 +40,9 @@ public class ExcelImportService { private final VisitRequestService visitRequestService; - private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd[ HH:mm]"); + private static final String DATA_SHEET = "방문자명단"; + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-M-d"); + private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("H:mm[:ss]"); // Not @Transactional: each row imports in its own transaction (create() is // @Transactional), so a duplicate/invalid row fails independently without @@ -42,10 +52,13 @@ public class ExcelImportService { int dataRows = 0; try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) { - Sheet sheet = workbook.getSheetAt(0); + Sheet sheet = workbook.getSheet(DATA_SHEET); + if (sheet == null) { + sheet = workbook.getSheetAt(0); + } for (Row row : sheet) { - if (row.getRowNum() == 0) { - continue; // header + if (row.getRowNum() <= 1) { + continue; // 그룹 헤더 + 컬럼 헤더 } if (isEmptyRow(row)) { continue; @@ -53,7 +66,7 @@ public class ExcelImportService { dataRows++; try { VisitRequestCreateRequest req = parseRow(row); - visitRequestService.create(req, hostUserId); + visitRequestService.createRequests(req, hostUserId); result.setSuccessCount(result.getSuccessCount() + 1); } catch (Exception e) { result.getErrors().add("행 " + (row.getRowNum() + 1) + ": " + e.getMessage()); @@ -66,20 +79,57 @@ public class ExcelImportService { private VisitRequestCreateRequest parseRow(Row row) { VisitRequestCreateRequest req = new VisitRequestCreateRequest(); - req.setVisitorName(requireString(row.getCell(0), "방문자 이름")); - req.setCompany(getString(row.getCell(1))); - req.setContact(requireString(row.getCell(2), "연락처")); - req.setEmail(getString(row.getCell(3))); - req.setVehicleNo(getString(row.getCell(4))); - req.setZoneName(getString(row.getCell(5))); - req.setPurpose(requireString(row.getCell(6), "출입 목적")); - req.setVisitFrom(requireDateTime(row.getCell(7), "출입 일시")); - req.setVisitTo(requireDateTime(row.getCell(8), "퇴실 일시")); + req.setPurpose(requireString(row.getCell(1), "출입목적")); + req.setWorkName(getString(row.getCell(2))); + req.setServerRooms(parseServerRooms(requireString(row.getCell(3), "장소"))); + + LocalDate date = requireDate(row.getCell(4), "출입일자"); + LocalTime time = parseTime(row.getCell(5)); + req.setVisitFrom(date.atTime(time != null ? time : LocalTime.MIDNIGHT)); + // 신청 시엔 시작만 입력받고, 종료(퇴실)는 당일 마감으로 둔다 (실제 입·퇴장은 체크인/아웃에서 관리). + req.setVisitTo(date.atTime(LocalTime.of(23, 59, 59))); + + req.setVisitorName(requireString(row.getCell(6), "이름")); + req.setCompany(getString(row.getCell(7))); + req.setContact(requireString(row.getCell(8), "연락처")); + req.setVehicleNo(getString(row.getCell(9))); + + // 출입통제담당자 (비면 서비스가 업로드 사용자로 채움) + req.setControlName(getString(row.getCell(10))); + req.setControlTeam(getString(row.getCell(11))); + req.setControlContact(getString(row.getCell(12))); + // 현장감시자1 (비면 서비스가 고정값으로 채움) + req.setWatcher1Name(getString(row.getCell(13))); + req.setWatcher1Team(getString(row.getCell(14))); + req.setWatcher1Contact(getString(row.getCell(15))); + // 현장감시자2 + req.setWatcher2Name(getString(row.getCell(16))); + req.setWatcher2Team(getString(row.getCell(17))); + req.setWatcher2Contact(getString(row.getCell(18))); return req; } + /** Splits the 장소 cell into individual zones on comma / semicolon / slash (usually one). */ + private List parseServerRooms(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + List rooms = new ArrayList<>(); + for (String part : raw.split("[,;/]")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + rooms.add(trimmed); + } + } + return rooms; + } + + // A pre-numbered but otherwise blank template row (순번 filled, rest empty) is skipped; + // 순번(0) is ignored so those rows don't count as data. + private static final int[] KEY_COLS = {1, 3, 4, 6, 8}; + private boolean isEmptyRow(Row row) { - for (int c = 0; c <= 8; c++) { + for (int c : KEY_COLS) { String v = getString(row.getCell(c)); if (v != null && !v.isBlank()) { return false; @@ -118,21 +168,62 @@ public class ExcelImportService { return v; } - private LocalDateTime requireDateTime(Cell cell, String field) { + /** Parses 출입일자: Excel date cell, or text like "2026.7.8(수)" / "2026-07-08" / "2026.07.08". */ + private LocalDate requireDate(Cell cell, String field) { if (cell == null) { throw new IllegalArgumentException(field + "은(는) 필수입니다."); } if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { - return cell.getLocalDateTimeCellValue(); + return cell.getLocalDateTimeCellValue().toLocalDate(); } String text = requireString(cell, field); + String norm = text; + int paren = norm.indexOf('('); + if (paren >= 0) { + norm = norm.substring(0, paren); // "(요일)" 제거 + } + norm = norm.trim().replace('.', '-').replace('/', '-').replace(" ", ""); + norm = norm.replaceAll("-{2,}", "-").replaceAll("-+$", ""); try { - if (text.length() <= 10) { - return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay(); - } - return LocalDateTime.parse(text.replace('T', ' '), DT); + return LocalDate.parse(norm, DATE_FMT); } catch (Exception e) { - throw new IllegalArgumentException(field + " 형식이 올바르지 않습니다 (yyyy-MM-dd HH:mm): " + text); + throw new IllegalArgumentException( + field + " 형식이 올바르지 않습니다 (예: 2026-07-08 또는 2026.7.8): " + text); } } + + /** Parses 출입시간 (optional): Excel time cell, "HH:mm" text, or a day fraction (0.625 = 15:00). */ + private LocalTime parseTime(Cell cell) { + if (cell == null) { + return null; + } + if (cell.getCellType() == CellType.NUMERIC) { + if (DateUtil.isCellDateFormatted(cell)) { + return cell.getLocalDateTimeCellValue().toLocalTime(); + } + return fractionToTime(cell.getNumericCellValue()); + } + String t = getString(cell); + if (t == null || t.isBlank()) { + return null; + } + t = t.trim(); + try { + if (t.matches("\\d{1,2}:\\d{2}(:\\d{2})?")) { + return LocalTime.parse(t, TIME_FMT); + } + double d = Double.parseDouble(t); + return fractionToTime(d); + } catch (Exception e) { + return null; // 해석 불가한 시간 → 시작시간 미지정으로 처리 + } + } + + private LocalTime fractionToTime(double d) { + double frac = d - Math.floor(d); + if (frac < 0) { + return null; + } + return LocalTime.ofSecondOfDay(Math.round(frac * 86400) % 86400); + } } diff --git a/backend/src/main/java/com/itcenter/acs/service/ReportService.java b/backend/src/main/java/com/itcenter/acs/service/ReportService.java index 402bedd..82102b6 100644 --- a/backend/src/main/java/com/itcenter/acs/service/ReportService.java +++ b/backend/src/main/java/com/itcenter/acs/service/ReportService.java @@ -52,7 +52,7 @@ public class ReportService { List rows = visitRequestRepository .findByVisitFromBetweenOrderByVisitFromAsc(from.atStartOfDay(), to.plusDays(1).atStartOfDay()); - String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "출입일시", "퇴실일시", "상태"}; + String[] headers = {"방문자", "회사", "연락처", "출입구역", "호스트", "출입목적", "작업명", "출입일시", "퇴실일시", "상태"}; try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { Sheet sheet = wb.createSheet("출입기록"); @@ -77,9 +77,10 @@ public class ReportService { put(row, 3, nv(vr.getZoneName()), widths); put(row, 4, vr.getHost().getFullName(), widths); put(row, 5, nv(vr.getPurpose()), widths); - put(row, 6, fmt(vr.getVisitFrom()), widths); - put(row, 7, fmt(vr.getVisitTo()), widths); - put(row, 8, STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()), widths); + put(row, 6, nv(vr.getWorkName()), widths); + put(row, 7, fmt(vr.getVisitFrom()), widths); + put(row, 8, fmt(vr.getVisitTo()), widths); + put(row, 9, STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()), widths); } // autoSizeColumn under-measures CJK text, so set widths from the content diff --git a/backend/src/main/java/com/itcenter/acs/service/VisitRequestService.java b/backend/src/main/java/com/itcenter/acs/service/VisitRequestService.java index 911a59c..bae47d1 100644 --- a/backend/src/main/java/com/itcenter/acs/service/VisitRequestService.java +++ b/backend/src/main/java/com/itcenter/acs/service/VisitRequestService.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; @Service @@ -24,15 +25,23 @@ public class VisitRequestService { private final VisitorRepository visitorRepository; private final UserRepository userRepository; + // 현장감시자1은 고정 인원. (실제 지정 인원이 다르면 이 상수만 수정) + private static final String FIXED_WATCHER1_NAME = "류관순"; + private static final String FIXED_WATCHER1_TEAM = "IT전략국"; + private static final String FIXED_WATCHER1_CONTACT = "313"; + /** - * Create a pre-registration. Reuses an existing visitor (matched by name+contact) - * or creates a new one. The created request starts in PENDING. + * Create pre-registration(s). Reuses an existing visitor (matched by name+contact) + * or creates a new one, then issues one PENDING request per resolved access zone — + * so selecting two server rooms produces two requests (and, on approval, two QR passes). */ - public VisitRequest create(VisitRequestCreateRequest req, Long hostUserId) { + public List createRequests(VisitRequestCreateRequest req, Long hostUserId) { if (req.getVisitTo().isBefore(req.getVisitFrom())) { throw ApiException.badRequest("방문 종료 일시가 시작 일시보다 빠를 수 없습니다."); } + List zoneNames = resolveZones(req); + User host = userRepository.findById(hostUserId) .orElseThrow(() -> ApiException.notFound("호스트 사용자를 찾을 수 없습니다.")); @@ -46,20 +55,84 @@ public class VisitRequestService { visitor.setVehicleNo(req.getVehicleNo()); visitor = visitorRepository.save(visitor); - if (visitRequestRepository.existsActiveDuplicate( - visitor.getId(), req.getVisitFrom(), req.getVisitTo(), req.getZoneName())) { - throw ApiException.conflict("이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복)"); + List created = new ArrayList<>(); + for (String zoneName : zoneNames) { + if (visitRequestRepository.existsActiveDuplicate( + visitor.getId(), req.getVisitFrom(), req.getVisitTo(), zoneName)) { + throw ApiException.conflict( + "이미 동일한 방문 신청이 존재합니다. (방문자·기간·구역 중복: " + zoneName + ")"); + } + VisitRequest vr = new VisitRequest(); + vr.setVisitor(visitor); + vr.setHost(host); + vr.setZoneName(zoneName); + vr.setPurpose(req.getPurpose()); + vr.setWorkName(req.getWorkName()); + vr.setVisitFrom(req.getVisitFrom()); + vr.setVisitTo(req.getVisitTo()); + vr.setStatus(VisitStatus.PENDING); + + // 출입통제담당자 = 요청값 있으면 사용(엑셀), 없으면 본인(등록한 로그인 사용자) 정보로 채움. + vr.setControlName(orElse(req.getControlName(), host.getFullName())); + vr.setControlTeam(orElse(req.getControlTeam(), host.getDepartment())); + vr.setControlContact(orElse(req.getControlContact(), host.getEmail())); + // 현장감시자1 = 고정값(요청값 비면 고정 상수). + vr.setWatcher1Name(orElse(req.getWatcher1Name(), FIXED_WATCHER1_NAME)); + vr.setWatcher1Team(orElse(req.getWatcher1Team(), FIXED_WATCHER1_TEAM)); + vr.setWatcher1Contact(orElse(req.getWatcher1Contact(), FIXED_WATCHER1_CONTACT)); + // 현장감시자2 = 담당자 입력값 그대로. + vr.setWatcher2Name(trimToNull(req.getWatcher2Name())); + vr.setWatcher2Team(trimToNull(req.getWatcher2Team())); + vr.setWatcher2Contact(trimToNull(req.getWatcher2Contact())); + + created.add(visitRequestRepository.save(vr)); + } + return created; + } + + /** Trimmed value, or null when blank. */ + private static String trimToNull(String v) { + if (v == null) { + return null; + } + String t = v.trim(); + return t.isEmpty() ? null : t; + } + + /** The trimmed value if present, otherwise the fallback. */ + private static String orElse(String value, String fallback) { + String v = trimToNull(value); + return v != null ? v : fallback; + } + + /** + * Resolves the access-zone labels for a request. Each selected server room (전산실) is a + * zone of its own (its own QR); the detail room (콤보박스) is auxiliary and appended to + * the label. With no server room selected, the detail room becomes the sole zone. + */ + private List resolveZones(VisitRequestCreateRequest req) { + String room = req.getRoomZone() == null ? "" : req.getRoomZone().trim(); + + List serverRooms = new ArrayList<>(); + if (req.getServerRooms() != null) { + for (String s : req.getServerRooms()) { + if (s != null && !s.trim().isEmpty()) { + serverRooms.add(s.trim()); + } + } } - VisitRequest vr = new VisitRequest(); - vr.setVisitor(visitor); - vr.setHost(host); - vr.setZoneName(req.getZoneName()); - vr.setPurpose(req.getPurpose()); - vr.setVisitFrom(req.getVisitFrom()); - vr.setVisitTo(req.getVisitTo()); - vr.setStatus(VisitStatus.PENDING); - return visitRequestRepository.save(vr); + List zones = new ArrayList<>(); + if (!serverRooms.isEmpty()) { + for (String sr : serverRooms) { + zones.add(room.isEmpty() ? sr : sr + " / " + room); + } + } else if (!room.isEmpty()) { + zones.add(room); + } else { + throw ApiException.badRequest("출입 구역을 최소 1개 이상 선택하세요."); + } + return zones; } @Transactional(readOnly = true) diff --git a/backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql b/backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql new file mode 100644 index 0000000..d35ec6c --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql @@ -0,0 +1,11 @@ +-- Visit request detail fields added after the initial schema. +ALTER TABLE visit_requests ADD COLUMN work_name VARCHAR(255); +ALTER TABLE visit_requests ADD COLUMN control_name VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN control_team VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN control_contact VARCHAR(60); +ALTER TABLE visit_requests ADD COLUMN watcher1_name VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN watcher1_team VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN watcher1_contact VARCHAR(60); +ALTER TABLE visit_requests ADD COLUMN watcher2_name VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN watcher2_team VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN watcher2_contact VARCHAR(60); diff --git a/docs/ACS작업일지.md b/docs/ACS작업일지.md new file mode 100644 index 0000000..5b967a9 --- /dev/null +++ b/docs/ACS작업일지.md @@ -0,0 +1,195 @@ +# ACS 작업일지 + +## 2026-07-10 + +### 작업 범위 합의 +- Codex는 `C:\ai-dev\workspace\acs`의 ACS 개선 및 배포 준비를 담당하기로 함. +- RTGS 프로젝트는 별도 담당자가 작업하므로, 사용자가 명시적으로 요청하지 않는 한 `C:\ai-dev\workspace\rtgs`는 읽기/수정/빌드/배포하지 않기로 함. +- ACS 관련 설치, 구성, 변경, 배포, 검증 내용은 이 파일에 계속 갱신하기로 함. +- 서버/저장소 계정 정보와 비밀번호는 일지에 기록하지 않기로 함. + +### 로컬 프로젝트 확인 +- ACS 구조 확인: + - 백엔드: Spring Boot 3.4.5, Java 21, Maven, JPA, Flyway, PostgreSQL 운영 구성 + - 프론트엔드: React 19, Vite 6, TypeScript + - 배포 구성: `infra/docker-compose.yml`, `infra/docker-compose.tls.yml`, nginx reverse proxy +- Git 원격 저장소는 아직 등록되어 있지 않음을 확인함. +- `.git/index.lock` 파일이 남아 있어, 추후 `git add/commit/push` 전 정리가 필요함. + +### 배포 전 빌드/테스트 검증 +- 프론트엔드 `npm run build` 성공. +- 백엔드 최초 테스트에서 운영 Flyway 스키마와 엔티티 불일치 발견: + - `visit_requests` 테이블에 `work_name`, `control_*`, `watcher*_ *` 계열 컬럼 누락. +- 신규 Flyway 마이그레이션 추가: + - `backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql` + - 기존 DB에도 적용 가능하도록 `ALTER TABLE ... ADD COLUMN` 방식 사용. +- 테스트용 H2 호환성을 위해 컬럼 추가 구문을 개별 `ALTER TABLE` 문으로 분리함. +- `DataSeeder`의 로컬 계정과 테스트 코드 기대 계정 불일치 수정: + - 기존 단축 계정 `a/s/h` 유지 + - 테스트 및 CSV seed와 맞는 `admin/security/host`도 함께 생성하도록 보정 +- 백엔드 `mvn -B -ntp test` 성공: + - 9 tests, 0 failures, 0 errors +- 백엔드 `mvn -B -ntp -DskipTests package` 성공: + - `backend/target/acs-0.0.1-SNAPSHOT.jar` 생성 확인 + +### Docker/Compose 확인 +- 로컬 Docker 설치 확인: + - `docker --version` 성공 + - `docker compose` 플러그인은 없음 + - `docker-compose --version`은 사용 가능 +- Compose 설정 문법 확인: + - `docker-compose --env-file infra/.env.example -f infra/docker-compose.yml config` 성공 + - `docker-compose --env-file infra/.env.example -f infra/docker-compose.yml -f infra/docker-compose.tls.yml config` 성공 +- 운영 배포 URL을 환경변수로 넣은 HTTPS 구성 확인: + - `ACS_PUBLIC_BASE_URL=https://acs.apps.bokdev.in` + - `ACS_COOKIE_SECURE=true` + - Compose config 정상 +- Docker 이미지 빌드는 로컬 Docker 빌더 문제로 실패: + - buildx 플러그인 없음 + - Docker legacy builder가 Docker API v1.54 build 요청에서 `500 Internal Server Error` 반환 + - 코드/Compose 문법 문제보다는 로컬 Docker Desktop/빌더 환경 문제로 판단 + +### 서버/URL 접근성 확인 +- `https://portal.bokdev.in/`: + - HTTPS 접근 가능 + - HTTP 200 OK 확인 +- `https://acs.apps.bokdev.in`: + - DNS 해석 가능 + - 현재 HTTP 503 Service Unavailable 확인 + - 아직 앱이 정상 배포/기동되지 않은 상태로 판단 +- `https://acs.bokdev.in/0420301/repo`: + - 현재 PC 네트워크에서 `acs.bokdev.in` DNS 해석 실패 + - 저장소 원격 등록/푸시 전 네트워크 또는 도메인 확인 필요 + +### 저장소 URL/DNS 추가 확인 +- 저장소 후보 URL `https://acs.bokdev.in/0420301/repo` 확인. +- 로컬 ACS Git remote 확인 결과, 등록된 원격 저장소 없음. +- `git ls-remote https://acs.bokdev.in/0420301/repo` 실행 결과: + - `Could not resolve host: acs.bokdev.in` +- 현재 PC DNS 서버: + - `210.104.132.1`, `210.104.132.2` + - `10.168.198.34`, `10.168.198.35` +- 기본 DNS 및 공개 DNS 확인 결과: + - `portal.bokdev.in`은 `27.96.158.129`로 해석됨. + - `acs.apps.bokdev.in`은 `27.96.158.129`로 해석됨. + - `acs.bokdev.in`은 해석 실패. +- `bokdev.in` 권한 DNS 서버 확인: + - `salvador.ns.porkbun.com` + - `fortaleza.ns.porkbun.com` + - `curitiba.ns.porkbun.com` + - `maceio.ns.porkbun.com` +- 권한 DNS 서버 `curitiba.ns.porkbun.com`에 직접 질의한 결과: + - `portal.bokdev.in` A 레코드 존재: `27.96.158.129` + - `acs.apps.bokdev.in` A 레코드 존재: `27.96.158.129` + - `acs.bokdev.in` A/CNAME 레코드 없음 + - `nslookup acs.bokdev.in curitiba.ns.porkbun.com` 결과: `Non-existent domain` +- 결론: + - 현재 저장소 URL의 호스트명 `acs.bokdev.in`은 DNS에 등록되어 있지 않은 것으로 판단. + - 저장소 URL이 잘못 전달되었거나, DNS 레코드 생성이 아직 완료되지 않았을 가능성이 큼. + - 원격 저장소 등록/푸시 전에 정확한 저장소 URL 재확인이 필요. + +### AI DEV 매뉴얼 확인 +- 참조 파일: `docs/AIdev.md` +- 매뉴얼 기준 주요 서비스: + - 포털: `https://portal.bokdev.in` + - Coder: `https://coder.bokdev.in` + - Gitea: `https://gitea.bokdev.in` + - Kubero: `https://kubero.bokdev.in` + - Coolify: `https://coolify.bokdev.in` +- Gitea 저장소 생성/Push 기준: + - Gitea의 `playground` 조직에 저장소를 생성. + - 저장소 URL 형식은 `https://gitea.bokdev.in/playground/.git`. + - 예: ACS 저장소명이 `acs`라면 `https://gitea.bokdev.in/playground/acs.git`. +- 배포 기준: + - Gitea 저장소 1개가 배포 앱 1개에 대응. + - Kubero 또는 Coolify 중 하나를 사용. + - Coolify는 Public Repository 방식으로 Gitea 저장소 URL 전체를 입력하고 Dockerfile 빌드를 사용. + - Coolify에서 도메인 생성 시 `https://.apps.bokdev.in` 형식으로 지정되는 것으로 매뉴얼에 기재되어 있음. +- ACS에 대한 적용 판단: + - 기존 후보 `https://acs.bokdev.in/0420301/repo`는 매뉴얼 기준 저장소 URL 형식이 아님. + - ACS 저장소 URL은 우선 `https://gitea.bokdev.in/playground/acs.git`로 보는 것이 타당. + - 저장소가 아직 없다면 Gitea `playground` 조직에 `acs` repository를 생성해야 함. + - 배포 URL `https://acs.apps.bokdev.in`은 Coolify 도메인 형식과 일치. + +### 프로젝트 설정 URL 오류 확인 +- 사용자가 포털 프로젝트 수정 화면에서 저장소 URL을 `https://acs.bokdev.in/0420301/repo`로 임의 입력한 사실 확인. +- 해당 값은 Gitea 저장소 URL이 아니므로 수정 필요. +- 후보 저장소 페이지 확인: + - `https://gitea.bokdev.in/playground/acs`: Not found + - `https://gitea.bokdev.in/playground/ACS`: Not found + - `https://gitea.bokdev.in/0420301/acs`: Not found + - `https://gitea.bokdev.in/0420301/ACS`: Not found +- 결론: + - 현재 ACS Gitea 저장소는 아직 생성되지 않았거나, 비공개/다른 이름으로 생성된 상태일 수 있음. + - 매뉴얼 기준으로는 `playground` 조직에 `acs` 저장소를 새로 만들고, 프로젝트 저장소 URL을 `https://gitea.bokdev.in/playground/acs.git`로 설정하는 것이 우선 추천. + - 배포 URL `https://acs.apps.bokdev.in`은 유지 가능. + +### Gitea Credential Helper 및 저장소 재확인 +- Git 접근 중 Windows `CredentialHelperSelector` 팝업 발생. +- 권장값인 `manager` 선택 완료. +- 이후 `git ls-remote https://gitea.bokdev.in/playground/acs.git` 재시도 시 인증 대기 상태로 타임아웃됨. +- 비대화식 확인: + - `GIT_TERMINAL_PROMPT=0` + - `git -c credential.helper= ls-remote https://gitea.bokdev.in/playground/acs.git` + - 결과: `could not read Username for 'https://gitea.bokdev.in': terminal prompts disabled` +- Gitea API 비교 확인: + - `https://gitea.bokdev.in/api/v1/repos/playground/MANUAL`: 200 OK + - `https://gitea.bokdev.in/api/v1/repos/playground/acs`: `The target couldn't be found` +- 판단: + - Gitea 서버와 API는 정상 접근 가능. + - `playground/acs` 저장소는 비로그인/API 기준으로 존재하지 않거나 private/권한 미승인 상태. + - 포털 프로젝트의 저장소 URL 수정만으로 Gitea 저장소가 자동 생성되는 것은 아니므로, Gitea에서 `playground/acs` repository 생성 여부를 별도로 확인해야 함. + +### Gitea ACS 저장소 생성 및 원격 등록 +- 사용자가 Gitea에서 ACS 저장소 생성 완료. +- 생성된 저장소 확인: + - 웹 URL: `https://gitea.bokdev.in/0420301/acs` + - Git URL: `https://gitea.bokdev.in/0420301/acs.git` + - `playground/acs`가 아니라 개인 네임스페이스 `0420301/acs`로 생성됨. +- 빈 저장소 상태 확인: + - `git ls-remote https://gitea.bokdev.in/0420301/acs.git` 결과가 비어 있으나 exit code 0으로 정상. +- 이전 타임아웃된 Gitea 확인용 Git 프로세스와 stale lock 파일 정리: + - `.git/config.lock` + - `.git/index.lock` +- 로컬 ACS Git remote 등록 완료: + - `origin https://gitea.bokdev.in/0420301/acs.git` +- 남은 사항: + - push 전 커밋 대상 정리 필요. + - 로그 파일(`backend/backend-run.log`, `frontend/frontend-dev.log`)과 백업 파일(`docs/ACS작업일지.md.bak`)은 커밋 제외 권장. + - 작업트리 변경분 검토 후 최초 commit/push 진행 필요. + +### 인증서 확인 +- `infra/certs/fullchain.pem`, `infra/certs/privkey.pem` 존재 확인. +- 현재 인증서는 `CN=localhost`, SAN도 `localhost`, `127.0.0.1`용임. +- 운영 도메인 `acs.apps.bokdev.in`용 인증서가 아니므로 실제 HTTPS 운영 배포에는 부적합. +- 운영 배포 전 포털/플랫폼 인증서 자동 제공 여부 또는 도메인 인증서 교체 필요. + +### 배포 정책 추천 +- 권장 흐름: + - 로컬 개발 + - Git commit/push + - 서버 개발환경 배포 + - 서버 개발환경 검증 + - 운영 배포 승인 + - 서버 운영환경 배포 +- 서버에서 직접 코드를 수정하며 개발하는 방식은 비추천. +- 서버 개발환경도 Git으로 받은 검증 대상 환경으로 운영하고, 운영환경에는 검증된 커밋/태그만 배포하는 정책을 권장. +- 권장 브랜치/환경: + - `dev` 또는 `develop`: 서버 개발환경 배포 + - `main`: 운영 배포 가능 브랜치 + - 운영 배포 시 태그 사용 예: `acs-v0.1.0` +- DB 변경은 Flyway migration으로만 반영하는 정책 유지. + +### 남은 작업 +- `.git/index.lock` 정리 후 Git 작업 가능 상태 확인. +- ACS 원격 저장소 URL/DNS 문제 확인. +- 원격 저장소 등록 및 최초 push 여부 결정. +- 서버 개발환경과 운영환경을 포털에서 분리 구성할 수 있는지 확인. +- 운영 도메인 인증서 처리 방식 확인. +- 로컬 Docker buildx 또는 Docker Desktop 빌더 문제 해결. +- 실제 배포 전 운영 `.env` 값 확정: + - `POSTGRES_PASSWORD` + - `ACS_PUBLIC_BASE_URL` + - `ACS_COOKIE_SECURE` + - `ACS_SMS_PROVIDER` + - SMTP 또는 사내 메시지 API 설정 diff --git a/docs/AIdev.md b/docs/AIdev.md new file mode 100644 index 0000000..ac3585c --- /dev/null +++ b/docs/AIdev.md @@ -0,0 +1,349 @@ +# AI DEV 개발·배포 매뉴얼 (개발자용) + +행번 계정 하나로 **Coder에서 개발**하고, **Gitea에 push**, **Kubero 또는 Coolify로 배포**합니다. +(배포 도구는 Kubero·Coolify 중 검토 중입니다. [9번](#9-배포-gitea--kubero--coolify)에 두 방법을 모두 정리해 두었습니다.) +DB(PostgreSQL)·파일저장소(MinIO)·AI(LiteLLM - key 제외)는 워크스페이스에 미리 연결되어 있습니다. + + + +## 목차 + +**최초 1회** +[1. 로그인](#1-로그인) → [2. 워크스페이스 만들기](#2-워크스페이스-만들기-최초-1회) → [3. VS Code 열기](#3-vs-code-열기) → [5. AI 키 등록](#5-ai-키-등록-최초-1회) + +**앱마다** +[6. 새 프로젝트 시작](#6-새-프로젝트-시작) → [7. 개발](#7-개발) → [8. 로컬 실행·확인](#8-로컬-실행확인) → [9. 배포](#9-배포-gitea--kubero--coolify) + +``` +[최초 1회] 로그인(1) → 워크스페이스 생성(2) → VS Code(3) → AI 키 등록(5) +[앱마다] new-project + git init(6) → 개발·커밋(7) → 로컬 확인(8) → 레포 생성·push·배포(Kubero/Coolify)(9) +``` + +## 0. 서비스 주소 + +| 용도 | 주소 | +|---|---| +| AI DEV 포털 (시작점) | https://portal.bokdev.in | +| Coder (개발 워크스페이스) | https://coder.bokdev.in | +| Gitea (코드 저장소) | https://gitea.bokdev.in | +| Kubero (배포 · 후보) | https://kubero.bokdev.in | +| Coolify (배포 · 후보) | https://coolify.bokdev.in | +| 개발 중 미리보기 | `https://<자동생성>.coder.bokdev.in` | +| 배포된 앱 | `https://<레포명>.playground.bokdev.in` | + +모든 서비스는 **행번 계정(SSO)** 으로 로그인합니다. + +코드에서 쓰는 접속정보(DB·S3)는 `.project-env` 파일로 자동 제공됩니다. 직접 입력할 값이 없습니다. + +## 1. 로그인 + +1. https://portal.bokdev.in 접속 + +2. 행번 계정으로 로그인 + - 아이디: 본인 행번 (예: `2620227`) + - 비밀번호: 본인 비밀번호 (초기 비밀번호: `bok1234!!` + `행번 7자리`) + ![포털 로그인 화면](images/01-login.png) + +3. 이후 Coder·Gitea·Kubero는 추가 로그인 없이 같은 계정으로 열립니다. + - Kubero의 경우 `OAuth로 로그인하기`를 눌러 SSO 로그인이 가능합니다. + ![포털 로그인 화면](images/portal.bokdev.in_.png) + +## 2. Coder 워크스페이스 만들기 (최초 1회) + +Coder 워크스페이스 = 본인 전용 개발 컨테이너(VS Code + 개발 도구 일체). + +> ⚠️**주의**: 같은 브라우저에 다른 계정으로 Gitea 로그인이 남아 있으면 그 계정으로 연동됩니다. +> 승인 전에 Gitea에서 로그아웃했는지 확인하거나, **시크릿 모드**에서 진행합니다. +> Coder와 Gitea의 로그인 계정이 일치하지 않는 경우 Workspace 생성 후 계정 불일치로 Push가 되지 않을 수 있습니다. + +1. https://coder.bokdev.in → **Workspaces** → **Create Workspace** (템플릿: `aidev`) +2. 설정값 입력 + - **Name**: 워크스페이스 이름 (예: `ws-aidev-<행번>`) + - **External Authentication**: Gitea — **애플리케이션 승인** 클릭 + - **CPU / Memory / Disk**: 기본값(2 Core / 4 GiB / 10 GiB) 사용. 추후 변경 가능. + + ![Gitea 계정 연동](images/02-coder-gitea.png) + +3. **Create Workspace** 클릭 +4. 최초 빌드는 2~5분 소요. 상태가 **Running**이 되면 완료. + +> **주의**: 워크스페이스는 한 번 만들면 계속 사용합니다. + +## 3. VS Code 열기 + +1. 워크스페이스 화면에서 **VS Code Web** 아이콘 클릭 +2. `/home/coder/projects` 폴더가 자동으로 열립니다. ("Yes, I trust the authors" 클릭) +3. 안에 **`sample`** 폴더가 있습니다. DB·S3 연결이 확인된 참조용 예제이며 **직접 수정하지 않습니다**. [6번](#6-새-프로젝트-시작)에서 복사해 사용합니다. + + +> 작업 파일은 반드시 `/home/coder/projects` 아래에 둡니다. 이 폴더만 워크스페이스 재시작 후에도 보존됩니다. + +## 4. 기본 제공 환경 + +새 워크스페이스에 아래가 설치·연결되어 있습니다. + +| 항목 | 내용 | +|---|---| +| 개발 도구 | Java(JDK)/Maven, Node 22, Python 3.12, git, psql | +| 컨테이너 | podman (`docker` 명령도 동일 동작) | +| DB | 본인 전용 PostgreSQL 스키마 (`$DATABASE_URL`) | +| VS Code 확장 | Claude Code, Codex | +| AI CLI | `claude`, `codex` — [5번](#5-ai-키-등록-최초-1회)에서 키 등록 필요 | + +## 5. AI 키 등록 (최초 1회) + +Claude Code / Codex 는 사내 AI 게이트웨이(LiteLLM)를 사용합니다. 발급받은 본인 virtual key를 한 번만 등록하면 CLI·확장이 모두 공유합니다. + +1. 터미널 열기: VS Code 메뉴(좌상단 ☰) → **Terminal → New Terminal** +2. 아래 명령 실행 후 본인 키(`sk-...`) 입력: + ```bash + update-litellm-key + ``` + ``` + LiteLLM virtual key 입력 (sk-...): sk-본인-키 + 키 갱신 완료 (len=25). 현재 터미널에 즉시 적용됨. + ``` +3. 확인: + ```bash + echo $ANTHROPIC_BASE_URL # https://litellm.bok.or.kr 이면 정상 + claude + ``` + ```bash + echo $OPENAI_BASE_URL # https://litellm.bok.or.kr/v1 이면 정상 + codex + ``` + +> **키 등록·변경 후 VS Code(웹)가 응답하지 않을 수 있습니다.** +> 워크스페이스 화면에서 VS Code 서버를 **Stop → Start** 하여 재시작합니다. + +키는 워크스페이스의 `~/.env`에만 저장됩니다. 키를 바꿀 때도 같은 명령을 다시 실행합니다. + +기본 모델은 게이트웨이에 맞춰 설정되어 있습니다. + +| 도구 | 기본 모델 | 설정 파일 | +|---|---|---| +| Claude Code | `claude-opus-4-8` | `~/.claude/settings.json` | +| Codex | `gpt-5.5` | `~/.codex/config.toml` | + +## 6. 새 프로젝트 시작 + +`sample` 예제를 복사해 시작합니다. + +**(1) 터미널에서 프로젝트 생성** — 반드시 `~/projects` 에서 실행: +```bash +cd ~/projects +cd sample && git pull && cd .. # 예제 최신화 +new-project myapp # 예제를 ~/projects/myapp 으로 복사 + .project-env 자동 생성 +``` +`myapp`은 예시입니다. 이 이름은 Gitea 레포명으로 설정할 이름과 동일하게 맞추시면 되고, 소문자·숫자·하이픈만 사용합니다. + +**(2) git 초기화** — 개발 시작 시점에 합니다. 커밋 이력을 처음부터 관리하기 위함이며, 원격(Gitea) 연결은 배포 단계([9번](#9-배포-gitea--kubero--coolify))에서 합니다: +```bash +cd ~/projects/myapp +git init -b main +git add . +git commit -m "init project" +``` + +**(3) VS Code로 폴더 열기**: **File → Open Folder…** → `/home/coder/projects/myapp` → OK +왼쪽에 `myapp` 파일 목록이 보이면 완료. 새 터미널은 이 폴더에서 시작됩니다. + +**(4) 라이브러리 설치**: +```bash +npm install +``` + +> **`.project-env`** 는 이 프로젝트의 설정 파일(DB·S3 접속정보)입니다. 폴더에 들어가면(cd) 자동으로 환경변수에 로드됩니다. +> +> LiteLLM 키만 예외로 워크스페이스 공용 `~/.env`([5번](#5-ai-키-등록-최초-1회))에서 관리합니다. + +## 7. 개발 + +- **편집**: 왼쪽 파일 목록에서 파일 선택 → 수정 → Ctrl+S 저장 +- **AI 도구**: 프로젝트 폴더 안 터미널에서 `claude` 또는 `codex` 실행. 폴더 밖에서 실행하면 프로젝트 파일을 읽지 못합니다. +- **커밋**: 기능 단위로 수시로 커밋합니다. push는 배포 단계에서. + ```bash + git add . && git commit -m "메시지" + ``` +- **DB 접속**: + ```bash + psql "$DATABASE_URL" # 프로젝트 폴더에서 실행 (.project-env 로드 필요) + ``` +- 코드에서는 `process.env.DATABASE_URL`, `process.env.S3_*` 를 사용합니다. +- **`CLAUDE.md`**: 프로젝트 규칙·주의사항을 적어두면 Claude Code가 자동으로 읽고 따릅니다. 예제에 기본 파일이 포함되어 있습니다. +- AI에게는 구체적으로 지시합니다. 예: "로그인 API 만들어줘" 대신 "`src/`에 POST /login 추가, 검증 실패 시 401 반환". 생성된 코드는 [8번](#8-로컬-실행확인)으로 직접 확인 후 커밋합니다. + +### 7-1. bkit 플러그인 (선택) + +Claude Code에 계획→설계→구현→검증 절차를 더하는 플러그인. 터미널의 `claude` CLI에서만 동작합니다(VS Code 확장 미지원). + +설치(최초 1회, `claude` 실행 후 프롬프트에 입력): +``` +/plugin marketplace add popup-studio-ai/bkit-claude-code +/plugin install bkit +``` + +사용: `/pdca pm <기능이름>` — 기능 하나를 계획부터 검증까지 진행. 세분화 명령은 `/pdca plan` `/pdca design` `/pdca do` `/pdca analyze`. + +## 8. 로컬 실행·확인 + +**(1) 실행** +```bash +cd ~/projects/myapp +npm run dev # 저장 시 자동 재시작 +``` +`listening on :3000` 이 에러 없이 출력되면 기동 성공. +실패 시 순서대로 확인: ① `npm install` 했는지 ② 코드 문법 오류 ③ 프로젝트 폴더 밖에서 실행(`.project-env` 미로딩). + +**(2) 연결 점검** — 앱을 띄우지 않고 DB·S3 연결만 확인: +```bash +npm run db:check # "DB OK: ..." 이면 정상 +npm run minio:check # "S3 OK: ..." 이면 정상 +``` +FAIL이면 `.project-env` 값을 확인합니다. 여기서 통과하면 배포 환경에서도 동일하게 동작합니다. + +**(3) 브라우저 미리보기** — 워크스페이스는 클러스터 내부라 `localhost:3000`이 PC 브라우저에서 열리지 않습니다. 포트 포워딩을 사용합니다: +1. VS Code 하단 **PORTS** 탭 → **Forward a Port** → `3000` 입력 +2. 포워딩된 포트의 **Open in Browser** 클릭 → `https://<자동생성>.coder.bokdev.in` + +![브라우저 미리보기](images/coder-ports-preview.png) + +**(4) 엔드포인트 확인** +```bash +curl 127.0.0.1:3000/healthz # {"ok":true} 앱 기동 +curl 127.0.0.1:3000/db # {"ok":true,"now":...} DB 연결 +curl 127.0.0.1:3000/s3 # {"ok":true,"bucket":...} S3 연결 +``` +`"ok": false` 이면 함께 출력되는 `error` 메시지가 원인입니다. + +미리보기 URL은 본인 전용이며 워크스페이스를 끄면 사라집니다. 정식 배포는 [9번](#9-배포-gitea--kubero--coolify). + +## 9. 배포 (Gitea → Kubero / Coolify) + +배포 단위: Gitea `playground` 조직의 레포 1개 = 배포 앱 1개. +배포 주소: `https://<레포명>.playground.bokdev.in` + +배포 도구는 **Kubero**와 **Coolify** 중 하나를 사용합니다. +**Gitea 레포 생성([9-1](#9-1-gitea-원격-레포-생성-앱당-1회))과 push([9-2](#9-2-push))는 두 도구 공통**이며, 이후 사용하는 도구에 따라 [9-3A(Kubero)](#9-3a-kubero에-앱-추가-앱당-1회) 또는 [9-3B(Coolify)](#9-3b-coolify에-앱-추가-앱당-1회)를 따릅니다. + +| 항목 | Kubero | Coolify | +|---|---|---| +| 배포 위치 | `playground` 파이프라인에 앱 추가 | `ai-dev` 팀 → `ai-dev` 프로젝트에 앱 추가 | +| 코드 수정 반영 | push 후 **수동 재빌드** (자동 빌드 미연동) | push 시 **자동 재빌드·배포** (webhook 설정 시, [9-3B](#9-3b-coolify에-앱-추가-앱당-1회)) | +| 환경변수 입력 | `.project-env` 업로드 → 자동 파싱 | `.project-env` 값을 붙여넣기 (Developer view) | +| 빌드 방식 | Dockerfile | Dockerfile | + +### 9-1. Gitea 원격 레포 생성 (앱당 1회) + +1. https://gitea.bokdev.in/playground → 우측 상단 **`+` → New Repository** + ![새 저장소 만들기](images/make-new-repository.png) +2. **Owner: `playground`** 로 변경, Repository Name 입력 (예: `myapp`), public 설정 + ![새 저장소 옵션 설정](images/make-new-repository-2.png) +3. README / .gitignore / License 는 체크하지 않음(빈 저장소여야 함) → **Create Repository** + +### 9-2. push + +```bash +cd ~/projects/myapp +git remote add origin https://gitea.bokdev.in/playground/myapp.git +git push -u origin main +``` +- 최초 push 시 Gitea 승인 화면이 뜨면 **Authorize** 클릭([2번](#2-워크스페이스-만들기-최초-1회)에서 승인했다면 생략됨). +- 이후 수정 반영: `git add . && git commit -m "..." && git push` + +### 9-3A. Kubero에 앱 추가 (앱당 1회) + +`playground` pipeline을 사용하시면 되며, 사용자는 그 안에 본인 앱만 추가합니다. + +1. https://kubero.bokdev.in 접속 +2. **`playground`** 파이프라인 선택 +3. **Production** 아래의 `+` 버튼을 클릭해 앱을 추가 +4. App Name과 환경 ENVIRONMENT VARIABLES 추가 + ![Kubero 배포 앱 추가](images/kubero-pipeline.png) + - `.project-env` 파일을 업로드 하면 자동으로 파싱되어 등록됩니다. + +> 자동 빌드는 현재 미연동입니다. **코드 수정 후에는 push 하고 Kubero에서 해당 앱의 빌드를 다시 실행합니다.** + +### 9-3B. Coolify에 앱 추가 (앱당 1회) + +Coolify는 "**push → Dockerfile로 자동 빌드·배포**" 방식입니다. 사용자는 `ai-dev` 프로젝트에 본인 앱만 추가합니다. + +1. https://coolify.bokdev.in 접속 → 우측 상단에서 **`aidev`** 팀 선택 +2. 좌측 **`Projects` → `aidev`** (서버·DB가 연결된 프로젝트) → **`+ Add Resource`** + ![Coolify 팀 선택](images/coolify-team.png) +3. 리소스 종류에서 **`Public Repository`** 선택 + ![Coolify 리소스 종류 선택](images/coolify-new-resource.png) +4. **Repository URL**에 **전체 주소**를 입력 후 **`Check Repository`**: + `https://gitea.bokdev.in/playground/myapp.git` + (`playground/myapp` 처럼 줄여 쓰면 실패합니다.) + > **비공개(private) 레포일 때** — `Public Repository` 로도 받을 수 있습니다. URL에 Gitea 토큰을 끼워 넣습니다: + > `https://<토큰>@gitea.bokdev.in/playground/myapp.git` + > - 토큰 발급: Gitea → 우측 상단 프로필 → **Settings → Applications → Generate New Token**. 이름 지정 후 **`repository` 읽기 권한(Read)** 만 체크 → 생성. 표시되는 토큰은 **이때 한 번만** 보이므로 복사해 둡니다. + > - 발급한 토큰을 위 URL의 `<토큰>` 자리에 넣고 **`Check Repository`**. (토큰이 URL·Coolify 설정에 저장되므로 읽기 전용 권한만 부여합니다.) +5. **Build Pack: `Dockerfile`**, Branch `main`, Port `3000`. + ![Coolify 저장소 연결](images/coolify-repo.png) +6. **Configuration → Domains** 에서 **`Generate Domain`** 클릭 → `https://<레포명>.apps.bokdev.in` 형태로 지정. +7. **Environment Variables** 에 `.project-env` 값 등록: + - **Developer view** 에서 `.project-env` 내용을 그대로 붙여넣으면 일괄 등록됩니다. (`cat ~/projects/myapp/.project-env`) + - `DATABASE_URL` 은 `%20`·`%3D` 인코딩까지 **그대로** 넣습니다(빼면 DB 연결이 깨집니다). +8. **Deploy** 클릭 → **Deployments** 탭에서 빌드 로그 확인. `New container started` / `Deployment finished` 가 보이면 성공. + +#### 자동 배포(auto deploy) 설정 (앱당 1회) + +Public Repository 방식은 webhook을 걸어야 push가 자동 배포로 이어집니다(Coolify가 push를 스스로 감지하지 못함). Coolify에 별도의 "Auto Deploy" 켜기 단계는 없고, **Webhooks 탭의 URL·Secret을 Gitea에 등록하는 것이 곧 자동 배포 설정**입니다. 앱마다 한 번만 하면 됩니다. + +1. **Coolify 앱** → **Configuration → Webhooks** 탭에서, **Gitea** 항목의 **Webhook URL** 과 **Secret** 을 복사합니다. (Secret 칸이 비어 있으면 값을 입력/생성 후 저장) + ![Coolify Webhook URL·Secret](images/coolify-webhook.png) +2. **Gitea 레포** → `https://gitea.bokdev.in/playground/myapp` → **Settings → Webhooks → Add Webhook → Gitea** 에 등록: + - **Target URL**: 1번의 Webhook URL + - **Secret**: 1번의 Secret + - **Content Type**: `application/json` + - **Trigger**: Push events, **Active** 체크 → **Add Webhook** + ![Gitea Webhook 등록](images/gitea-webhook.png) +3. Gitea webhook 화면의 **Test Delivery** 를 누르거나 실제로 `git push` → Coolify **Deployments** 에 새 빌드가 자동으로 뜨면 완료. + +> push가 배포를 트리거할지는 앱 **Advanced** 탭의 **`Auto Deploy`** 옵션이 결정하며, **기본값이 켜짐**이라 따로 켤 필요는 없습니다(자동 배포를 끄고 싶을 때만 여기서 해제). +> 설정 후에는 코드를 고쳐 **`git push` 하면 자동으로 다시 빌드·배포**됩니다(Deployments에서 새 빌드 로그 확인). webhook을 걸지 않았다면 앱 화면에서 **Deploy** 를 눌러 수동 배포합니다. + +### 9-4. 확인 + +배포·재시작 직후 약 1~2분은 초기화(코드 다운로드·설치) 시간입니다. 일시적으로 404가 나오는 경우, 잠시 기다린 후 Ctrl + Shift + R로 강력 새로고침 후 확인해주세요. + +```bash +curl https://<레포명>.playground.bokdev.in/healthz # {"ok":true} +curl https://<레포명>.playground.bokdev.in/db +curl https://<레포명>.playground.bokdev.in/s3 +``` + +![배포된 앱](images/08-deployed-app.png) + +문제가 있으면 Kubero에서 해당 앱의 빌드/배포 로그를 확인합니다. 로그에 `listening on :3000` 이 보이면 기동 성공입니다. + + +## FAQ + +- Coder Workspace 켜고 끄기 + - Coder workspace 재기동이 필요한 경우: Coder 워크스페이스 화면에서 **Stop** + - 다시 사용: **Start** (VS Code Web 아이콘이 뜰 때까지 대기) + - `~/projects` 만 보존됩니다. 그 외 경로의 파일은 사라질 수 있습니다. + +- **로그인을 서비스마다 해야 하나요** → 아니요. 행번 계정 SSO 하나로 전부 로그인됩니다. +- **Coder에서 파일이 사라졌어요** → `~/projects` 밖에 저장한 경우 파일이 유실될 수 있습니다([3번](#3-vs-code-열기)). +- **AI 도구 401 오류** → `update-litellm-key` 재실행([5번](#5-ai-키-등록-최초-1회)). 키가 `sk-`로 시작하는지 확인. +- **AI 도구 400 (Invalid model)** → [5번](#5-ai-키-등록-최초-1회) 표의 기본 모델명 사용. +- **키 등록 후 VS Code가 먹통** → VS Code 서버 Stop → Start([5번](#5-ai-키-등록-최초-1회)). +- **`$DATABASE_URL` 이 비어 있음** → 프로젝트 폴더 안에서 실행해야 `.project-env` 가 로드됩니다([6번](#6-새-프로젝트-시작)). +- **`npm run dev` 가 `Cannot find package ...`** → `npm install` 미실행([6번](#6-새-프로젝트-시작)). +- **push 인증을 물어봄** → Gitea 승인을 아직 안 한 경우. 승인 화면에서 Authorize([9-2](#9-2-push)). +- **다른 계정으로 push/연동됨** → 브라우저에 남아 있던 Gitea 로그인 세션 때문입니다. Gitea 로그아웃 후 재승인하거나 시크릿 창 사용([2번](#2-워크스페이스-만들기-최초-1회)). +- **배포 주소가 404** → ① 배포 직후 1~2분 대기 ② `/healthz` 확인 ③ Kubero/Coolify 배포 로그 확인([9-4](#9-4-확인)). +- **`/healthz` 는 되는데 `/db`·`/s3` 가 500** → 먼저 로컬에서 `npm run db:check` / `minio:check` 통과 확인([8번](#8-로컬-실행확인)). 로컬에서 되면 배포 로그의 에러 메시지 확인. Coolify는 `.project-env` 값(특히 `DATABASE_URL` 의 `%20`/`%3D`)이 그대로인지도 확인. +- **배포가 옛날 코드** → push 됐는지 먼저 확인. Kubero는 빌드 재실행([9-3A](#9-3a-kubero에-앱-추가-앱당-1회)), Coolify는 push 시 자동 재배포([9-3B](#9-3b-coolify에-앱-추가-앱당-1회)). +- **DB가 비어 있음** → 정상입니다. 빈 전용 스키마가 제공되며 테이블은 직접 생성합니다. +- **K8s에 직접 접근하고 싶어요** → 직원은 K8s에 직접 접근하지 않습니다. Coder·Gitea·Kubero로 개발·배포가 완결됩니다. + + +## 문의 +- IT 전략국 클라우드팀 김창록 팀장 +- IT 전략국 정보시스템개발팀 박성록 과장 +- IT 전략국 클라우드팀 이혜민 조사역 \ No newline at end of file diff --git a/docs/issues-and-guidelines.md b/docs/issues-and-guidelines.md index e633185..e2058bf 100644 --- a/docs/issues-and-guidelines.md +++ b/docs/issues-and-guidelines.md @@ -1,7 +1,7 @@ # ACS 개발 이슈 정리 및 유의사항(규칙) > 문서 작성일: 2026-07-03 -> 대상: IT센터 출입자관리시스템 (`C:\ai-dev\workspace\access-control-system`) +> 대상: IT센터 출입자관리시스템 (`C:\ai-dev\workspace\acs`) > 목적: 기획·개발·테스트·수정 단계에서 실제로 겪은 이슈를 정리하고, 재발 방지를 위한 **규칙**으로 제안한다. > 표기: 각 항목은 **[이슈] → [규칙]** 형태. 규칙 요약은 문서 끝 §6 체크리스트 참조. diff --git a/docs/workflow.md b/docs/workflow.md index 76d4ac8..da78912 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -1,7 +1,7 @@ # IT센터 출입자관리시스템(ACS) 워크플로우 > 문서 작성일: 2026-07-03 -> 대상: `C:\ai-dev\workspace\access-control-system` (Spring Boot 3.4.5 / Java 21 · React 19 · Vite 6) +> 대상: `C:\ai-dev\workspace\acs` (Spring Boot 3.4.5 / Java 21 · React 19 · Vite 6) > 목적: 방문자 사전신청 → 승인 → 출입증 발송 → 입·출입 체크 → 재실현황/리포트까지의 전체 업무 흐름 정리 --- diff --git a/docs/회사의 이메일 API 사용법.txt b/docs/회사의 이메일 API 사용법.txt new file mode 100644 index 0000000..16efa71 --- /dev/null +++ b/docs/회사의 이메일 API 사용법.txt @@ -0,0 +1,6 @@ +회사의 이메일 API 사용법 + +https://helpdesk.dooray.com/share/pages/9wWo-xwiR66BO5LGshgVTg/2937064454837487755 + + +https://helpdesk.dooray.com/share/pages/9wWo-xwiR66BO5LGshgVTg/2939991731086319521 \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ff8727b..bbaa355 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -97,7 +97,7 @@ export const getVisitRequest = (id: number) => request(`/visit-requests/${id}`); export const createVisitRequest = (req: VisitRequestCreate) => - request('/visit-requests', jsonInit('POST', req)); + request('/visit-requests', jsonInit('POST', req)); export const cancelVisitRequest = (id: number) => request(`/visit-requests/${id}/cancel`, { method: 'POST' }); diff --git a/frontend/src/pages/AccessConsolePage.tsx b/frontend/src/pages/AccessConsolePage.tsx index de70f49..f73613c 100644 --- a/frontend/src/pages/AccessConsolePage.tsx +++ b/frontend/src/pages/AccessConsolePage.tsx @@ -91,7 +91,7 @@ export const AccessConsolePage: React.FC = () => { {results.length > 0 && ( - + {results.map((r) => ( @@ -101,11 +101,17 @@ export const AccessConsolePage: React.FC = () => { diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 1623f34..8089d21 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -58,7 +58,7 @@ export const LoginPage: React.FC = () => { {busy ? '로그인 중…' : '로그인'} -

초기 계정: admin / security / host (비밀번호 ChangeMe123!)

+

테스트 계정: a(관리자) / s(보안) / h(호스트) · 비밀번호 1

); diff --git a/frontend/src/pages/VisitRequestFormPage.tsx b/frontend/src/pages/VisitRequestFormPage.tsx index b542487..46b5432 100644 --- a/frontend/src/pages/VisitRequestFormPage.tsx +++ b/frontend/src/pages/VisitRequestFormPage.tsx @@ -2,15 +2,35 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { createVisitRequest } from '../api'; import { DateTimePicker } from '../components/DateTimePicker'; +import { useAuth } from '../auth/AuthContext'; -const ZONE_OPTIONS = [ - '4층전산실', '5층전산실', '3층사무실', '4층사무실', '5층사무실', - '종합상황실', 'BMT실', '의사결정실', '기타', +// 코드 시트 목록을 콤보/체크박스에 반영. +// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다. +const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실']; +// 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력. +const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타']; +const PURPOSE_OPTIONS = ['점검', '작업', '견학', '회의', '청소', '기타']; +// 소속(코드 시트) — 내부 팀. 담당자·감시자 팀 콤보에 사용. +const AFFILIATION_OPTIONS = [ + 'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀', + '금융IT인프라팀', '정보인프라팀', 'AI플랫폼팀', '보안운영팀', '보안관제반', + 'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국', ]; -const PURPOSE_OPTIONS = ['유지점검', '장비반입', '업무협의', '공사', '기타']; +// 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지). +const FIXED_WATCHER1 = { 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; +}; + export const VisitRequestFormPage: React.FC = () => { const [form, setForm] = useState({ visitorName: '', @@ -18,10 +38,15 @@ export const VisitRequestFormPage: React.FC = () => { contact: '', email: '', vehicleNo: '', - zone: '', - zoneEtc: '', + serverRooms: [] as string[], + room: '', + roomEtc: '', purpose: '', purposeEtc: '', + workName: '', + watcher2Name: '', + watcher2Team: '', + watcher2Contact: '', visitFrom: '', visitTo: '', }); @@ -29,22 +54,34 @@ export const VisitRequestFormPage: React.FC = () => { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const navigate = useNavigate(); + const { user } = useAuth(); const update = (k: keyof typeof form) => ( e: React.ChangeEvent, ) => setForm({ ...form, [k]: e.target.value }); + const toggleServerRoom = (room: string) => (e: React.ChangeEvent) => + setForm((f) => ({ + ...f, + serverRooms: e.target.checked + ? [...f.serverRooms, room] + : f.serverRooms.filter((r) => r !== room), + })); + /** Returns the first Korean validation error, or null if valid. */ const validate = (): string | null => { if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.'; if (!form.contact.trim()) return '방문자 연락처를 입력하세요.'; if (form.email.trim() && !EMAIL_RE.test(form.email.trim())) return '이메일 형식이 올바르지 않습니다. (예: name@example.com)'; - if (!form.zone) return '출입 구역을 선택하세요.'; - if (form.zone === '기타' && !form.zoneEtc.trim()) return '기타 출입 구역을 입력하세요.'; + if (form.serverRooms.length === 0 && !form.room) + return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.'; + if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.'; if (!form.purpose) return '출입 목적을 선택하세요.'; if (form.purpose === '기타' && !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 '퇴실 일시는 출입 일시보다 빠를 수 없습니다.'; @@ -62,14 +99,20 @@ export const VisitRequestFormPage: React.FC = () => { setError(null); setBusy(true); try { + const roomZone = form.room === '기타' ? form.roomEtc.trim() : form.room; await createVisitRequest({ visitorName: form.visitorName.trim(), company: form.company.trim() || undefined, contact: form.contact.trim(), email: form.email.trim() || undefined, vehicleNo: form.vehicleNo.trim() || undefined, - zoneName: form.zone === '기타' ? form.zoneEtc.trim() : form.zone, + serverRooms: form.serverRooms, + roomZone: roomZone || undefined, purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose, + workName: form.workName.trim() || undefined, + watcher2Name: form.watcher2Name.trim() || undefined, + watcher2Team: form.watcher2Team.trim() || undefined, + watcher2Contact: form.watcher2Contact.trim() || undefined, visitFrom: form.visitFrom, visitTo: form.visitTo, }); @@ -84,7 +127,6 @@ export const VisitRequestFormPage: React.FC = () => { return (

출입 신청

- {error &&
{error}
} {/* noValidate: use our Korean messages instead of the browser's native popups */}
@@ -110,17 +152,33 @@ export const VisitRequestFormPage: React.FC = () => { +
+ 출입 전산실 : 선택한 전산실마다 출입증(QR)이 각각 발급됩니다. +
+ {SERVER_ROOM_OPTIONS.map((z) => ( + + ))} +
+
+ - {form.zone === '기타' ? ( + {form.room === '기타' ? ( ) :
} @@ -138,6 +196,11 @@ export const VisitRequestFormPage: React.FC = () => { ) :
} + + +
+ 출입통제담당자 : 로그인한 본인 정보로 자동 등록됩니다. +
+ + + +
+ +
현장감시자1 : 고정 인원 (자동 지정)
+ + + +
+ +
현장감시자2 : 작업을 입회할 상주직원
+ + + +
+
+ {error && {error}}
방문자회사구역처리
방문자회사구역상태
{r.zoneName || '-'} {insideIds.has(r.id) ? ( - + + 재실중 + + ) : exitedIds.has(r.id) ? ( - 금일 완료 + 금일완료 ) : ( - + + 입장대기 + + )}
- + @@ -88,6 +88,7 @@ export const VisitRequestListPage: React.FC = () => { +
방문자회사출입구역출입목적방문자회사출입구역출입목적작업명 출입기간상태
{r.company || '-'} {r.zoneName || '-'} {r.purpose || '-'}{r.workName || '-'} {formatVisitRange(r.visitFrom, r.visitTo)} {STATUS_LABEL[r.status]} diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index 92e2c79..36e530c 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -174,6 +174,14 @@ a { color: inherit; text-decoration: none; } } .form-grid .span-2 { grid-column: 1 / -1; } .form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } +/* Inline validation message shown at the left of the action row, beside the 취소 button. */ +.form-actions .form-error { margin-right: auto; align-self: center; color: #b91c1c; font-size: 14px; font-weight: 600; } +/* Status badge + action button shown together in a table cell (access console). */ +.status-cell { display: inline-flex; align-items: center; gap: 8px; } +/* Sub-section heading inside a form grid (담당자/감시자 등). */ +.form-section { grid-column: 1 / -1; margin: 6px 0 -2px; padding-top: 10px; border-top: 1px solid #e2e8f0; font-weight: 700; color: #1e293b; font-size: 14px; } +/* Read-only auto-filled fields (본인/고정값). */ +.field input[readonly] { background: #f1f5f9; color: #475569; cursor: default; } /* faint inline helper next to a label */ .hint-inline { font-weight: 400; font-style: normal; color: var(--muted); font-size: 12px; } @@ -189,6 +197,11 @@ a { color: inherit; text-decoration: none; } .consent-label { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 13px; color: #334155; line-height: 1.5; } .consent-label input { margin-top: 2px; width: 16px; height: 16px; flex-shrink: 0; } +/* Inline checkbox group (e.g. 전산실 다중 선택) */ +.checkbox-row { display: flex; flex-wrap: wrap; gap: 18px; padding: 8px 2px; } +.checkbox-inline { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: #334155; } +.checkbox-inline input { width: 16px; height: 16px; flex-shrink: 0; } + /* ===== Buttons ===== */ button { font-family: inherit; cursor: pointer; } .btn-primary, .btn-ghost, .btn-success, .btn-danger { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index adfd516..98091ce 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -10,6 +10,8 @@ export interface CurrentUser { id: number; username: string; fullName: string; + department?: string; + email?: string; roles: Role[]; mustChangePassword: boolean; } @@ -45,8 +47,17 @@ export interface VisitRequestCreate { contact: string; email?: string; vehicleNo?: string; - zoneName?: string; + /** 전산실 checkboxes; each selected room yields its own request/QR. */ + serverRooms?: string[]; + /** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */ + roomZone?: string; purpose: string; + /** 작업명 — optional concrete task detail, stored separately from purpose. */ + workName?: string; + /** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */ + watcher2Name?: string; + watcher2Team?: string; + watcher2Contact?: string; visitFrom: string; // ISO local datetime visitTo: string; } @@ -62,6 +73,16 @@ export interface VisitRequestView { hostDepartment?: string; zoneName?: string; purpose: string; + workName?: string; + controlName?: string; + controlTeam?: string; + controlContact?: string; + watcher1Name?: string; + watcher1Team?: string; + watcher1Contact?: string; + watcher2Name?: string; + watcher2Team?: string; + watcher2Contact?: string; visitFrom: string; visitTo: string; status: VisitStatus;