diff --git a/src/backend/build.gradle b/src/backend/build.gradle index bc0fa6c..68f221b 100644 --- a/src/backend/build.gradle +++ b/src/backend/build.gradle @@ -46,6 +46,9 @@ dependencies { // --- 메일 발송(자체 Postfix SMTP — 옥션 개설 통지·EDM·비밀번호 재설정) --- implementation 'org.springframework.boot:spring-boot-starter-mail' + // --- 서류 PDF 생성(M6 G-05) — HTML→PDF 경량 렌더러. NanumGothic 폰트 임베드(한글 보존). --- + implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10' + // --- JWT (행사 단위 RBAC 인증) --- implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentController.java b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentController.java index 50b053e..774ba9d 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentController.java @@ -5,8 +5,14 @@ import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.document.dto.DocumentReviewDto; import com.zioinfo.kintex.document.dto.DocumentTransitionRequest; +import com.zioinfo.kintex.document.dto.GeneratedDocDto; import com.zioinfo.kintex.document.dto.MilestoneDto; +import com.zioinfo.kintex.document.dto.ReportPdfRequest; import com.zioinfo.kintex.document.dto.RequiredDocumentDto; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -15,21 +21,24 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.nio.charset.StandardCharsets; import java.util.List; /** * M6 서류·마일스톤 API (SCR-22/23). 행사 RBAC 가드. - * HWP/PDF 렌더(파일 큐)는 이번 스코프 제외 — 엔드포인트 미노출. + * PDF 생성/다운로드는 실구현(G-05). HWP 렌더는 스코프 밖(후속) — 엔드포인트 미노출. */ @RestController @RequestMapping("/api/events/{eventId}") public class DocumentController { private final DocumentService service; + private final DocumentPdfService pdfService; private final EventAccessGuard guard; - public DocumentController(DocumentService service, EventAccessGuard guard) { + public DocumentController(DocumentService service, DocumentPdfService pdfService, EventAccessGuard guard) { this.service = service; + this.pdfService = pdfService; this.guard = guard; } @@ -67,4 +76,43 @@ public class DocumentController { String action = request == null ? null : request.action(); return ApiResponse.ok(service.transition(eventId, docType, action)); } + + // ── PDF 생성/다운로드 (G-05) ────────────────────────────────────── + + /** POST /documents/{docType}/pdf — SCR-23 웹폼 → 안전관리 계획서 PDF 생성(저장) → 다운로드 메타 반환. */ + @PostMapping("/documents/{docType}/pdf") + public ApiResponse generateReportPdf(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @PathVariable String docType, + @RequestBody(required = false) ReportPdfRequest form) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(pdfService.generateReport(eventId, docType, form)); + } + + /** POST /document-summary/pdf — SCR-22 서류·마일스톤 현황 요약 PDF(서버 저장 데이터로 조립). */ + @PostMapping("/document-summary/pdf") + public ApiResponse generateSummaryPdf(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(pdfService.generateSummary(eventId)); + } + + /** GET /documents/files/{fileId} — 생성된 PDF 다운로드(인증 + 행사 가드). 바이트 스트림. */ + @GetMapping("/documents/files/{fileId}") + public ResponseEntity downloadPdf(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @PathVariable String fileId, + @org.springframework.web.bind.annotation.RequestParam(required = false) String name) { + guard.requireEventAccess(principal, eventId); + byte[] pdf = pdfService.load(eventId, fileId); + String downloadName = (name == null || name.isBlank()) ? fileId : name; + // 한글 파일명은 RFC 5987(filename*=UTF-8'') 로 인코딩 — 스택트레이스/민감정보 미포함. + ContentDisposition cd = ContentDisposition.attachment() + .filename(downloadName, StandardCharsets.UTF_8) + .build(); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString()) + .contentType(MediaType.APPLICATION_PDF) + .body(pdf); + } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentPdfService.java b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentPdfService.java new file mode 100644 index 0000000..bede04f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentPdfService.java @@ -0,0 +1,383 @@ +package com.zioinfo.kintex.document; + +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.document.dto.GeneratedDocDto; +import com.zioinfo.kintex.document.dto.ReportPdfRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * M6 서류 PDF 생성/서빙 서비스(G-05) — 웹폼 데이터를 A4 서식 HTML 로 조립해 PDF 로 렌더한다. + * + *

렌더러: openhtmltopdf(PDFBox). 한글은 리소스 동봉 폰트({@code /fonts/NanumGothic.ttf})를 임베드해 보존한다 + * (서버에 시스템 폰트가 없어도 안정). 파일 저장은 로그인 슬라이드/아바타와 동일 컨벤션: + * {@code {kintex.upload.dir}/documents/{eventId}/} + 서버 UUID 파일명. 원본/클라이언트 경로는 신뢰하지 + * 않으므로 경로 순회는 구조적으로 불가능하다. 다운로드는 인증 엔드포인트(GET)로 바이트 스트림한다. + * + *

HWP 는 스코프 밖(후속). 사용자 입력은 모두 HTML escape 하여 서식에 주입한다(XSS/서식 훼손 방지). + */ +@Service +public class DocumentPdfService { + + private static final Logger log = LoggerFactory.getLogger(DocumentPdfService.class); + + /** 리소스 동봉 한글 폰트(임베드). 정적 상수 폰트 패밀리로 서식 전체에 적용. */ + private static final String FONT_RESOURCE = "/fonts/NanumGothic.ttf"; + private static final String FONT_FAMILY = "Nanum Gothic"; + + private final DocumentMapper mapper; + private final Path documentsDir; + + public DocumentPdfService(DocumentMapper mapper, + @Value("${kintex.upload.dir:./data/uploads}") String uploadDir) { + this.mapper = mapper; + this.documentsDir = Paths.get(uploadDir).toAbsolutePath().normalize().resolve("documents"); + } + + // ── 생성: 신고서류(안전관리 계획서) ───────────────────────────────── + + /** SCR-23 웹폼 → 안전관리 계획서 PDF. form 은 미완성(빈 값 다수)이어도 미리보기 PDF 를 발급한다. */ + public GeneratedDocDto generateReport(String eventId, String docType, ReportPdfRequest form) { + String title = blankTo(form == null ? null : form.docTitle(), "재해대처계획서"); + String html = buildReportHtml(title, form == null ? new ReportPdfRequest( + null, null, null, null, null, null, null, null, null, null, null, null) : form); + byte[] pdf = renderPdf(html); + String fileName = safeFileBase(title) + "_" + shortDate() + ".pdf"; + return store(eventId, pdf, fileName); + } + + // ── 생성: 서류·마일스톤 현황 요약 ───────────────────────────────── + + /** SCR-22 "자동 문서 생성" → 서류 준비 현황 요약 PDF(서버 저장 데이터로 조립, 폼 입력 불필요). */ + public GeneratedDocDto generateSummary(String eventId) { + List> milestones = mapper.findMilestones(eventId); + List> documents = mapper.findDocuments(eventId); + List> issues = mapper.findReviewIssues(eventId); + String html = buildSummaryHtml(milestones, documents, issues); + byte[] pdf = renderPdf(html); + String fileName = "서류_준비현황_요약_" + shortDate() + ".pdf"; + return store(eventId, pdf, fileName); + } + + // ── 서빙(다운로드) ──────────────────────────────────────────────── + + /** 저장된 PDF 바이트 로드. fileId 는 서버 UUID(.pdf) — 안전성 검증 후 실재 파일만 통과. 없으면 404. */ + public byte[] load(String eventId, String fileId) { + if (!isSafeName(fileId) || !fileId.toLowerCase().endsWith(".pdf")) { + throw new ApiException(ErrorCode.NOT_FOUND, "요청한 문서를 찾을 수 없습니다."); + } + Path dir = eventDir(eventId); + Path target = dir.resolve(fileId).normalize(); + if (!target.startsWith(dir) || !Files.isRegularFile(target)) { + throw new ApiException(ErrorCode.NOT_FOUND, "요청한 문서를 찾을 수 없습니다."); + } + try { + return Files.readAllBytes(target); + } catch (IOException e) { + log.warn("document pdf load failed: {}", e.getMessage()); + throw new ApiException(ErrorCode.NOT_FOUND, "요청한 문서를 찾을 수 없습니다."); + } + } + + // ── 렌더/저장 ───────────────────────────────────────────────────── + + private byte[] renderPdf(String html) { + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + PdfRendererBuilder builder = new PdfRendererBuilder(); + builder.useFont(() -> fontStream(), FONT_FAMILY); + builder.withHtmlContent(html, null); + builder.toStream(os); + builder.run(); + return os.toByteArray(); + } catch (Exception e) { + // 스택트레이스 미노출 — 요약만 전달(보안 불변). + log.error("document pdf render failed: {}", e.getMessage()); + throw new ApiException(ErrorCode.INTERNAL, "PDF 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."); + } + } + + private InputStream fontStream() { + InputStream in = DocumentPdfService.class.getResourceAsStream(FONT_RESOURCE); + if (in == null) { + throw new ApiException(ErrorCode.INTERNAL, "PDF 서식 폰트를 불러올 수 없습니다."); + } + return in; + } + + private GeneratedDocDto store(String eventId, byte[] pdf, String fileName) { + String fileId = UUID.randomUUID() + ".pdf"; // 서버 발급 식별자(원본명 미신뢰) + try { + Path dir = eventDir(eventId); + Files.createDirectories(dir); + Path target = dir.resolve(fileId).normalize(); + if (!target.startsWith(dir)) { // UUID라 사실상 불변식이나 방어적 확인 + throw new ApiException(ErrorCode.INTERNAL, "잘못된 저장 경로입니다."); + } + Files.write(target, pdf); + } catch (IOException e) { + log.error("document pdf store failed: {}", e.getMessage()); + throw new ApiException(ErrorCode.INTERNAL, "PDF 저장에 실패했습니다."); + } + String url = "/api/events/" + eventId + "/documents/files/" + fileId; + return new GeneratedDocDto(fileId, fileName, url); + } + + private Path eventDir(String eventId) { + // eventId 는 경로 세그먼트로 쓰이므로 안전성 확인(순회/구분자 차단). + if (!isSafeName(eventId)) { + throw new ApiException(ErrorCode.VALIDATION, "잘못된 행사 식별자입니다."); + } + return documentsDir.resolve(eventId).normalize(); + } + + // ── HTML 서식 ───────────────────────────────────────────────────── + + private String buildReportHtml(String title, ReportPdfRequest f) { + String visitors = numOrDash(f.visitors()); + StringBuilder b = new StringBuilder(); + openHtml(b, esc(title)); + b.append("

").append(esc(title)).append("

"); + + b.append(""); + b.append(""); + b.append(""); + b.append("") + .append(""); + b.append("
행 사 명").append(dash(f.eventName())).append("
일 시").append(fmtDate(f.eventDate())) + .append("장 소").append(dash(f.venue())).append("
주최/주관주식회사 킨텍스 조직위원회예상인원").append(visitors.equals("—") ? "—" : visitors + "명").append("
"); + + b.append("

1. 안전관리 조직체계

"); + b.append("총괄 안전책임자 ").append(dash2(f.safetyManager(), "(미지정)")); + if (f.safetyPhone() != null && !f.safetyPhone().isBlank()) { + b.append(" (").append(esc(f.safetyPhone())).append(")"); + } + b.append(" 지휘 하에 현장 안전요원 ").append(dash2(f.guardCount(), "0")) + .append("인을 배치한다. 각 홀 출입구에 안전요원을 상주시켜 밀집도를 관리하며, ") + .append("정기 장내 방송으로 안전 수칙을 안내한다.
"); + + b.append("

2. 비상 연락 체계

    "); + b.append("
  • 관할 소방서: ").append(dash(f.fireStation())).append("
  • "); + b.append("
  • 관할 경찰서: ").append(dash(f.policeStation())).append("
  • "); + b.append("
  • 의무·응급: ").append(dash(f.medical())).append("
  • "); + b.append("
"); + + b.append("

3. 주요 재해대처 방안

    "); + b.append("
  • 화재 시: 소방시설 즉시 가동 및 안내 방송을 통한 관람객 대피 유도
  • "); + b.append("
  • 응급환자 발생 시: 현장 의무실 이송 및 119 구급대 협조 요청
  • "); + b.append("
  • 정전 시: 비상 발전기 가동 및 유도등 점등 확인
  • "); + b.append("
"); + + b.append("

4. 위험물 관리

") + .append(dash2(f.hazardous(), "해당 없음")).append("
"); + + b.append("
").append(fmtDate(f.eventDate())).append("
"); + b.append("
주식회사 킨텍스 대표이사") + .append("KINTEX
직인
"); + + closeHtml(b); + return b.toString(); + } + + private String buildSummaryHtml(List> milestones, + List> documents, + List> issues) { + StringBuilder b = new StringBuilder(); + openHtml(b, "서류 준비 현황 요약"); + b.append("

서류 준비 현황 요약

"); + b.append("

생성일: ").append(LocalDate.now()).append("

"); + + // 마일스톤 + b.append("

1. 전시 마일스톤

"); + if (milestones == null || milestones.isEmpty()) { + b.append("
구성된 마일스톤이 없습니다.
"); + } else { + b.append(""); + for (Map m : milestones) { + b.append(""); + } + b.append("
단계상태기한
").append(dash(str(m.get("label")))) + .append("").append(stateLabel(str(m.get("state")))) + .append("").append(dash(str(m.get("dueDate")))).append("
"); + } + + // 서류 체크리스트 + 공정률 + int total = documents == null ? 0 : documents.size(); + long done = documents == null ? 0 : documents.stream() + .filter(d -> !"pending".equals(String.valueOf(d.get("status")))).count(); + int pct = total == 0 ? 0 : (int) Math.round(done * 100.0 / total); + b.append("

2. 신고서류 체크리스트 (진척 ").append(pct).append("%)

"); + if (total == 0) { + b.append("
구성된 서류가 없습니다.
"); + } else { + b.append(""); + for (Map d : documents) { + b.append(""); + } + b.append("
서류상태기한
").append(dash(str(d.get("name")))) + .append("").append(docStatusLabel(str(d.get("status")))) + .append("").append(dash(str(d.get("dueDate")))).append("
"); + } + + // AI 검수 이슈 + b.append("

3. AI 서류 검수

"); + if (issues == null || issues.isEmpty()) { + b.append("
검출된 불일치·누락이 없습니다.
"); + } else { + b.append("
    "); + for (Map i : issues) { + b.append("
  • ").append(dash(str(i.get("title")))).append(" — ") + .append(dash(str(i.get("description")))).append("
  • "); + } + b.append("
"); + } + + b.append("

본 문서는 킨텍스 AI 전시·행사시스템이 자동 생성한 준비 현황 요약입니다. ") + .append("공식 제출은 각 서류를 kxwp 시스템에 업로드하는 방식입니다.

"); + closeHtml(b); + return b.toString(); + } + + private void openHtml(StringBuilder b, String docTitle) { + b.append(""); + b.append("").append(docTitle).append(""); + b.append(""); + } + + private void closeHtml(StringBuilder b) { + b.append(""); + } + + // ── 파생/포맷 헬퍼 ──────────────────────────────────────────────── + + private static String fmtDate(String v) { + if (v == null || v.isBlank()) { + return "—"; + } + String[] p = v.split("-"); + if (p.length == 3) { + return esc(p[0] + ". " + p[1] + ". " + p[2] + "."); + } + return esc(v); + } + + private static String stateLabel(String state) { + return switch (state == null ? "" : state) { + case "done" -> "완료"; + case "active" -> "진행중"; + default -> "대기"; + }; + } + + private static String docStatusLabel(String status) { + return switch (status == null ? "" : status) { + case "approved" -> "승인"; + case "submitted" -> "제출"; + case "draft" -> "작성중"; + case "rejected" -> "반려"; + default -> "준비중"; + }; + } + + private static String shortDate() { + return LocalDate.now().toString().replace("-", ""); + } + + /** 파일명 base(한글 허용, 경로/구분자·제어문자만 제거). */ + private static String safeFileBase(String s) { + if (s == null || s.isBlank()) { + return "document"; + } + String cleaned = s.replaceAll("[\\\\/:*?\"<>|\\r\\n\\t]", "").trim(); + return cleaned.isEmpty() ? "document" : cleaned; + } + + private static boolean isSafeName(String name) { + return name != null && !name.isBlank() + && !name.contains("/") && !name.contains("\\") && !name.contains(".."); + } + + private static String blankTo(String v, String def) { + return (v == null || v.isBlank()) ? def : v; + } + + /** escape + 빈값 → "—". */ + private static String dash(String v) { + return (v == null || v.isBlank()) ? "—" : esc(v); + } + + /** escape + 빈값 → 지정 기본값. */ + private static String dash2(String v, String def) { + return (v == null || v.isBlank()) ? esc(def) : esc(v); + } + + private static String numOrDash(String v) { + if (v == null || v.isBlank()) { + return "—"; + } + try { + return String.format("%,d", Long.parseLong(v.trim())); + } catch (NumberFormatException e) { + return esc(v); + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + /** HTML/XML escape — 사용자 입력을 서식에 주입하기 전 필수(XHTML well-formed 보존). */ + private static String esc(String s) { + if (s == null) { + return ""; + } + StringBuilder out = new StringBuilder(s.length() + 16); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '&' -> out.append("&"); + case '<' -> out.append("<"); + case '>' -> out.append(">"); + case '"' -> out.append("""); + case '\'' -> out.append("'"); + default -> out.append(c); + } + } + return out.toString(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/dto/GeneratedDocDto.java b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/GeneratedDocDto.java new file mode 100644 index 0000000..40bfec4 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/GeneratedDocDto.java @@ -0,0 +1,15 @@ +package com.zioinfo.kintex.document.dto; + +/** + * 서류 PDF 생성 결과(M6 G-05). 프론트는 downloadUrl 을 인증 fetch 하여 Blob 다운로드한다. + * + * @param documentId 서버 발급 파일 식별자(UUID.pdf) — 경로 순회 불가 + * @param fileName 다운로드 파일명(한글 포함, Content-Disposition 에 인코딩되어 부착) + * @param downloadUrl 인증 다운로드 경로(GET /api/events/{eventId}/documents/files/{documentId}) + */ +public record GeneratedDocDto( + String documentId, + String fileName, + String downloadUrl +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/dto/ReportPdfRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/ReportPdfRequest.java new file mode 100644 index 0000000..93e908e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/ReportPdfRequest.java @@ -0,0 +1,22 @@ +package com.zioinfo.kintex.document.dto; + +/** + * SCR-23 신고서류 작성 → PDF 생성 요청(M6 G-05). 프론트 웹폼(ReportForm)과 1:1 필드 매핑. + * 모든 값은 선택(미입력 시 서비스가 "—"/기본 문구로 대체) — 임시저장·미완성 상태에서도 미리보기 PDF 발급 가능. + * 서버는 이 값들을 A4 안전관리 계획서 서식 HTML→PDF 로 렌더한다. HTML 은 서비스에서 escape 한다(주입 방지). + */ +public record ReportPdfRequest( + String docTitle, + String eventName, + String eventDate, + String venue, + String visitors, + String safetyManager, + String safetyPhone, + String guardCount, + String fireStation, + String policeStation, + String medical, + String hazardous +) { +} diff --git a/src/backend/src/main/resources/fonts/NanumGothic.ttf b/src/backend/src/main/resources/fonts/NanumGothic.ttf new file mode 100644 index 0000000..c14ce88 Binary files /dev/null and b/src/backend/src/main/resources/fonts/NanumGothic.ttf differ diff --git a/src/frontend/src/screens/docs/DocsMilestonePage.tsx b/src/frontend/src/screens/docs/DocsMilestonePage.tsx index c16339c..53f6c80 100644 --- a/src/frontend/src/screens/docs/DocsMilestonePage.tsx +++ b/src/frontend/src/screens/docs/DocsMilestonePage.tsx @@ -4,7 +4,8 @@ * 구성: ①상단 마일스톤 진행 바(D-150→D-0) ②좌 신고서류 체크리스트(상태·D-데이·액션) * ③우 AI 서류 검수 카드(불일치·누락·kxwp 안내) + 전체 공정률. * ★ 실 API 배선(useQuery, 로딩/빈/에러 3상태) — GET /milestones · /documents · /documents/review. - * HWP/PDF 생성은 파일 큐 후속 → 해당 버튼 disabled + 툴팁 유지. + * 현황 요약 PDF 자동 생성은 실구현(POST /document-summary/pdf → 인증 다운로드). + * HWP 생성은 스코프 밖(후속) → 해당 버튼 disabled + 툴팁 유지. */ import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; @@ -18,8 +19,9 @@ import { IconTrendUp, IconWarning, } from '../../components/ui/icons'; +import { ApiRequestError } from '../../api/client'; import { useResolvedEventId } from '../../hooks/useResolvedEventId'; -import { docsApi, type RequiredDocRow } from './docsApi'; +import { docsApi, downloadDoc, type RequiredDocRow } from './docsApi'; import './docs.css'; type DocAction = 'view' | 'hwp' | 'write' | 'fix'; @@ -31,7 +33,7 @@ const ACTION_LABEL: Record = { fix: '수정', }; -const RENDER_TOOLTIP = 'HWP/PDF 생성은 파일 큐 연동 후속(준비중)'; +const HWP_TOOLTIP = 'HWP 생성은 후속 연동(준비중) — 현황 요약은 PDF 로 제공됩니다'; /** 서류 상태 → 리스트 액션 파생. pending 은 액션 없음(준비중 표기). */ function actionFor(status: RequiredDocRow['status']): DocAction | undefined { @@ -56,6 +58,24 @@ function actionFor(status: RequiredDocRow['status']): DocAction | undefined { export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docType: string) => void }) { const eventId = useResolvedEventId(); const [toast, setToast] = useState(null); + const [summaryBusy, setSummaryBusy] = useState(false); + + async function generateSummary() { + if (!eventId) { + setToast('대상 행사를 확인할 수 없습니다.'); + return; + } + setSummaryBusy(true); + try { + const doc = await docsApi.generateSummaryPdf(eventId); + await downloadDoc(doc); + setToast('현황 요약 PDF 생성 완료 — 다운로드를 시작합니다.'); + } catch (e) { + setToast(e instanceof ApiRequestError ? e.message : '문서 생성에 실패했습니다.'); + } finally { + setSummaryBusy(false); + } + } const milestonesQ = useQuery({ queryKey: ['docs-milestones', eventId], @@ -240,10 +260,10 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docT variant="ai" block leadingIcon={} - disabled - title={RENDER_TOOLTIP} + onClick={generateSummary} + disabled={summaryBusy} > - 자동 문서 생성하기 + {summaryBusy ? '문서 생성 중…' : '자동 문서 생성하기 (PDF)'} @@ -327,7 +347,7 @@ function DocRow({ - -