From 1d6b38a6a66fdde9cb2e85313eb7c8175696194f Mon Sep 17 00:00:00 2001 From: shin Date: Wed, 11 Mar 2026 13:48:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EA=B0=84=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=ED=8C=9D=EC=98=A4=EB=B2=84=20?= =?UTF-8?q?=EC=A7=A4=EB=A6=BC=20=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - absolute → fixed 포지셔닝으로 변경하여 부모 overflow 영향 제거 - 필터 버튼 중앙 기준으로 팝오버 위치 계산 - 뷰포트 경계(8px 여백) 보호 로직 추가 - datePopoverRef 추가로 캘린더 내부 클릭 시 팝오버 닫힘 방지 Made-with: Cursor --- .../admin/screenMng/reportList/page.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/app/(main)/admin/screenMng/reportList/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/page.tsx index 6775a2c3..d4293f57 100644 --- a/frontend/app/(main)/admin/screenMng/reportList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/reportList/page.tsx @@ -47,6 +47,7 @@ export default function ReportManagementPage() { const [tempStartDate, setTempStartDate] = useState(undefined); const [tempEndDate, setTempEndDate] = useState(undefined); const filterRef = useRef(null); + const datePopoverRef = useRef(null); const { reports, @@ -70,7 +71,10 @@ export default function ReportManagementPage() { useEffect(() => { const handleClickOutside = (e: MouseEvent) => { - if (filterRef.current && !filterRef.current.contains(e.target as Node)) { + const target = e.target as Node; + const isInsideFilter = filterRef.current?.contains(target); + const isInsideDatePopover = datePopoverRef.current?.contains(target); + if (!isInsideFilter && !isInsideDatePopover) { setFilterOpen(false); setDatePopoverOpen(false); } @@ -223,8 +227,21 @@ export default function ReportManagementPage() { {filterOpen && datePopoverOpen && (
e.stopPropagation()} + ref={datePopoverRef} + className="fixed z-50 rounded-lg border border-gray-200 bg-white shadow-lg" + style={(() => { + const triggerRect = filterRef.current?.getBoundingClientRect(); + if (!triggerRect) return { top: 0, left: 0 }; + const popoverWidth = 620; + const triggerCenter = triggerRect.left + triggerRect.width / 2; + const top = triggerRect.bottom + 6; + let left = triggerCenter - popoverWidth / 2; + if (left < 8) left = 8; + if (left + popoverWidth > window.innerWidth - 8) { + left = window.innerWidth - popoverWidth - 8; + } + return { top, left }; + })()} >
+ +
+
+ + + {/* Document Container */} +
+
+ {children} +
+
+ + ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/components/StatusBadge.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/components/StatusBadge.tsx new file mode 100644 index 00000000..f53c962f --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/components/StatusBadge.tsx @@ -0,0 +1,31 @@ +type StatusType = "합격" | "불합격" | "보류" | "발주완료" | "검토중" | "취소" | "완료" | "승인대기"; + +interface StatusBadgeProps { + status: StatusType; + size?: "sm" | "md" | "lg"; +} + +const COLOR_MAP: Record = { + 합격: "bg-white text-[#16A34A] border-[#16A34A]", + 완료: "bg-white text-[#16A34A] border-[#16A34A]", + 발주완료: "bg-white text-[#16A34A] border-[#16A34A]", + 불합격: "bg-[#DC2626] text-white border-[#DC2626]", + 취소: "bg-[#DC2626] text-white border-[#DC2626]", + 보류: "bg-[#D97706] text-white border-[#D97706]", + 검토중: "bg-[#D97706] text-white border-[#D97706]", + 승인대기: "bg-[#2563EB] text-white border-[#2563EB]", +}; + +const SIZE_MAP = { + sm: "px-2 py-0.5 text-xs", + md: "px-3 py-1 text-sm", + lg: "px-8 py-3 text-2xl", +}; + +export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) { + return ( + + {status} + + ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/inspection/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/inspection/page.tsx new file mode 100644 index 00000000..0d905409 --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/inspection/page.tsx @@ -0,0 +1,207 @@ +"use client"; + +import DocumentLayout from "../components/DocumentLayout"; +import StatusBadge from "../components/StatusBadge"; + +const INSPECTION_ITEMS = [ + { + no: 1, + item: "외관상태", + subItem: "ee", + method: "육안 및 뒤틀림이 없을 것", + standard: "A", + measured: ["A", "A", "A", "A", "A", "A", "A", "A"], + result: "합격" as const, + }, + { + no: 2, + item: "표면 및 표시", + subItem: "ff", + method: "100표에서 1시간 방치", + standard: "O", + measured: ["O", "O", "O", "O", "O", "O", "O", "O"], + result: "합격" as const, + }, + { + no: 3, + item: "치수 yy", + subItem: "yy", + method: "길이", + standard: "453.9±0.9", + measured: ["453.6", "453.6", "454.4", "453.5", "453.1", "454.1", "454.3", "454.7"], + result: "합격" as const, + }, + { + no: 4, + item: "치수 hhh", + subItem: "hhh", + method: "폭", + standard: "177.3±0.5", + measured: ["177.4", "177.1", "177.5", "177.6", "177.3", "176.9", "177.7", "176.8"], + result: "합격" as const, + }, + { + no: 5, + item: "외관상태", + subItem: "", + method: "ff", + standard: "A", + measured: ["A", "A", "A", "A", "A", "A", "A", "A"], + result: "합격" as const, + }, +]; + +// ── 정보 카드 (CardRenderer 구조를 참고한 정적 구현) ──────────────────────── + +function InfoCard({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

▣ {title}

+
+
{children}
+
+ ); +} + +function InfoRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ── 결재란 ──────────────────────────────────────────────────────────────────── + +function ApprovalSection({ columns }: { columns: string[] }) { + return ( +
+
+
+ {columns.map((col, i) => ( +
+
{col}
+
+
+ ))} +
+
+
+ ); +} + +export default function InspectionReportPage() { + return ( + +
+ {/* ── 헤더 ── */} +
+
+

검 사 보 고 서

+

INSPECTION REPORT

+
+
+
문서번호: IR-2026-00123
+ +
+
+ + {/* ── 기본 정보 (2열 카드) ── */} +
+ + + + + + + + + + + + + + + + + +
+ + {/* ── 검사 항목 테이블 ── */} +
+
+

▣ 검사/시험 측정값

+
+
+ + + + + + + + + + + {["X1", "X2", "X3", "X4", "X5", "X6", "X7", "X8"].map((x) => ( + + ))} + + + + {INSPECTION_ITEMS.map((item, idx) => ( + + + + {item.measured.map((val, i) => ( + + ))} + + + + ))} + +
검사항목 + 시험 및 검사대응
(검사기준) +
+ 검사/시험 측정값 + 합격 판정
{x}
+
{item.item}
+ {item.subItem &&
{item.subItem}
} +
+
{item.method}
+ {(item.method === "길이" || item.method === "폭") && ( +
{item.standard}
+ )} +
{val}8 + +
+
+ + {/* 범례 */} +
+
+ 비 고 + [범례] A : Accept, R : Reject, H : Hold +
+
+ 중량판정 + ■ 합 격 +
+
+
+ + {/* ── 결재란 ── */} + + + {/* ── 푸터 ── */} +
+
양식번호 : QF-805-2 (Rev.0)
+
A4(210mm×297mm)
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/page.tsx new file mode 100644 index 00000000..3f30d56a --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, ClipboardCheck, FileText, ShoppingCart } from "lucide-react"; + +const SAMPLES = [ + { + title: "검사 보고서", + titleEng: "Inspection Report", + description: "품질 검사 결과를 기록하고 관리하는 문서입니다. 검사 항목, 측정값, 합격/불합격 판정을 포함합니다.", + path: "/admin/screenMng/reportList/samples/inspection", + icon: ClipboardCheck, + docNo: "IR-2026-XXXX", + }, + { + title: "견적서", + titleEng: "Quotation", + description: "고객에게 제공하는 견적 문서입니다. 품목별 단가, 수량, 공급가액, 세액을 포함합니다.", + path: "/admin/screenMng/reportList/samples/quotation", + icon: FileText, + docNo: "QT-2026-XXXX", + }, + { + title: "발주서", + titleEng: "Purchase Order", + description: "공급업체에 발주하는 공식 문서입니다. 발주처 정보, 발주 내역, 납기일 등을 포함합니다.", + path: "/admin/screenMng/reportList/samples/purchase-order", + icon: ShoppingCart, + docNo: "PO-2026-XXXX", + }, +]; + +export default function SamplesPage() { + return ( +
+ {/* Header */} +
+
+ + + 리포트 목록 + +
+

리포트 디자인 샘플

+
+
+ +
+
+ {/* Title Section */} +
+

+ WACE PLM — 문서 양식 샘플 +

+

+ 리포트 디자이너에서 활용 가능한 표준 문서 양식 샘플입니다. +
+ 카드(정보패널), 테이블, 결재란 등 기본 컴포넌트로 구성되었습니다. +

+
+ + {/* Sample Cards */} +
+ {SAMPLES.map((sample) => ( + +
+ +

{sample.docNo}

+
+
+

+ {sample.title} +

+

{sample.titleEng}

+

+ {sample.description} +

+
+ + 샘플 보기 → + +
+
+ + ))} +
+ +
+

A4 인쇄 최적화 · WACE PLM 리포트 디자이너 v2.0

+
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/purchase-order/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/purchase-order/page.tsx new file mode 100644 index 00000000..7e75181a --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/purchase-order/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import DocumentLayout from "../components/DocumentLayout"; +import StatusBadge from "../components/StatusBadge"; + +const ITEMS = [ + { no: 1, code: "P-001", name: "원자재 A", spec: "KS-100", unit: "KG", qty: 500, price: 5000 }, + { no: 2, code: "P-002", name: "부품 B", spec: "ISO-200", unit: "EA", qty: 1000, price: 3000 }, + { no: 3, code: "P-003", name: "자재 C", spec: "JIS-300", unit: "M", qty: 200, price: 8000 }, +]; + +const EMPTY_ROWS = 10; + +// ── 발주처 정보 테이블 행 ───────────────────────────────────────────────────── + +function InfoRow({ + label, + children, + highlight, + colSpan, +}: { + label: string; + children?: React.ReactNode; + highlight?: boolean; + colSpan?: number; +}) { + const labelBg = highlight ? "bg-yellow-100" : "bg-[#EFF6FF]"; + return ( + <> + {label} + + {children} + + + ); +} + +export default function PurchaseOrderPage() { + const totalAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0); + const tax = Math.round(totalAmount * 0.1); + const grandTotal = totalAmount + tax; + + return ( + +
+ {/* ── 헤더 ── */} +
+
+
+

발 주 서

+

PURCHASE ORDER

+
+
+ + {/* 결재란 인라인 */} +
+
+ {["담당", "부서장", "임원", "사장"].map((col, i) => ( +
+ {col} +
+ ))} +
+
+
+
+
+ + {/* ── 문서 번호 ── */} +
+
+
발주번호: PO-2026-00789
+
+
+ + {/* ── 발주처 정보 카드 ── */} +
+
+ ▣ 발주처 정보 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ TEL + 담당 +
+ + FAX +
+ TEL + 담당 +
+ + FAX +
+ TEL + 현장담당 +
+ + FAX +
20___년 ___월 ___일인도조건 +
+
+
+ + {/* ── 발주 내역 테이블 ── */} +
+
+ ▣ 발주 내역 +
+
+ + + + {["NO", "품 명", "규격", "단위", "수량", "단가", "금액", "비고"].map((h, i) => ( + + ))} + + + + {ITEMS.map((item, idx) => ( + + + + + + + + + + ))} + {Array.from({ length: EMPTY_ROWS }).map((_, idx) => ( + + + + ))} + +
+ {h} +
{item.no}{item.name}{item.spec}{item.unit}{item.qty.toLocaleString()}{item.price.toLocaleString()}{(item.qty * item.price).toLocaleString()} +
{ITEMS.length + idx + 1} + + + + + + +
+
+
+ + {/* ── 금액 요약 ── */} +
+ + + + + + + + + + + +
공급가액₩ {totalAmount.toLocaleString()}부가세액₩ {tax.toLocaleString()}합계금액₩ {grandTotal.toLocaleString()}
+
+ + {/* ── 안내문 ── */} +
+

상기 자재를 발주하오니 납기를 준수하여 인도 바랍니다.

+
+ + {/* ── 푸터 ── */} +
+
양식번호: PO-001 (Rev.2)
+
문의: TEL 000-0000-0000 / FAX 000-0000-0000
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/quotation/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/quotation/page.tsx new file mode 100644 index 00000000..46fef567 --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/quotation/page.tsx @@ -0,0 +1,204 @@ +"use client"; + +import DocumentLayout from "../components/DocumentLayout"; + +const ITEMS = [ + { no: 1, name: "프리미엄 제품 A", spec: "Model-X1000", qty: 50, unit: "EA", price: 150000 }, + { no: 2, name: "스탠다드 제품 B", spec: "Model-S500", qty: 100, unit: "EA", price: 80000 }, + { no: 3, name: "베이직 제품 C", spec: "Model-B200", qty: 200, unit: "EA", price: 45000 }, +]; + +const EMPTY_ROWS = 5; + +function ApprovalSection({ columns }: { columns: string[] }) { + return ( +
+
+
+ {columns.map((col, i) => ( +
+
{col}
+
+
+ ))} +
+
+
+ ); +} + +export default function QuotationPage() { + const supplyAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0); + const tax = Math.round(supplyAmount * 0.1); + const total = supplyAmount + tax; + + return ( + +
+ {/* ── 헤더 ── */} +
+
+

견 적 서

+

QUOTATION

+
+
+ + {/* ── 문서 번호 ── */} +
+
+
문서번호: QT-2026-01234
+
+
+ + {/* ── 날짜 / 수신 ── */} +
+
+
+ 2026 + + 03 + + 09 + +
+
+ (주) ○○○○ + 귀하 +
+
+
+ + {/* ── 견적명 / 공급자 (2열 카드) ── */} +
+
+
+ 견 적 명 +
+
+
+
+
+ 공 급 자 +
+
+
+ 등록번호 + 상호(법인명) / 성명 +
+
+ 업태 / 업종 + 주소 +
+
+ 전화번호       팩스 +
+
+
+
+ + {/* ── 합계금액 ── */} +
+
합 계 금 액
+
+ ₩ {total.toLocaleString()} 원정 +
+
+ + {/* ── 품목 테이블 ── */} +
+ + + + {["품번", "품명", "규격", "수량", "단가", "공급가액", "세액", "비고"].map((h, i) => ( + + ))} + + + + {ITEMS.map((item, idx) => { + const amount = item.qty * item.price; + const itemTax = Math.round(amount * 0.1); + return ( + + + + + + + + + + ); + })} + {Array.from({ length: EMPTY_ROWS }).map((_, idx) => ( + + + + ))} + {/* 합계 행 */} + + + + + + +
{h}
{item.no}{item.name}{item.spec}{item.qty.toLocaleString()}{item.price.toLocaleString()}{amount.toLocaleString()}{itemTax.toLocaleString()} +
{ITEMS.length + idx + 1} + + + + + + +
합 계 + + {supplyAmount.toLocaleString()}{tax.toLocaleString()} +
+
+ + {/* ── 금액 요약 (우측 정렬) ── */} +
+
+ + + + + + + + + + + + + + + +
공급가액₩ {supplyAmount.toLocaleString()}
부가세 (10%)₩ {tax.toLocaleString()}
합계금액₩ {total.toLocaleString()}
+
+
+ + {/* ── 안내문 ── */} +
+

위와 같이 견적합니다.

+

상기 견적서의 품목과 금액을 확인해 주시기 바랍니다.

+

감사합니다.

+
+ + {/* ── 결재란 ── */} + + + {/* ── 푸터 ── */} +
+
+
본 견적서의 유효기간은 견적일로부터 7일입니다.
+
+
+
결제계좌: (예금주: )
+
문의: TEL 000-0000-0000 / FAX 000-0000-0000
+
+
+
+ + ); +} diff --git a/frontend/components/common/UnsavedChangesGuard.tsx b/frontend/components/common/UnsavedChangesGuard.tsx new file mode 100644 index 00000000..b255d3cb --- /dev/null +++ b/frontend/components/common/UnsavedChangesGuard.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; + +interface UseUnsavedChangesGuardOptions { + hasChanges: () => boolean; + onClose: () => void; + title?: string; + description?: string; +} + +interface UnsavedChangesGuard { + handleOpenChange: (open: boolean) => void; + tryClose: () => void; + doClose: () => void; + showDialog: boolean; + confirmClose: () => void; + cancelClose: () => void; + title: string; + description: string; +} + +export function useUnsavedChangesGuard({ + hasChanges, + onClose, + title = "변경사항이 있습니다", + description = "저장하지 않은 변경사항이 사라집니다. 정말 닫으시겠습니까?", +}: UseUnsavedChangesGuardOptions): UnsavedChangesGuard { + const [showDialog, setShowDialog] = useState(false); + const hasChangesRef = useRef(hasChanges); + hasChangesRef.current = hasChanges; + + const attemptClose = useCallback(() => { + if (hasChangesRef.current()) { + setShowDialog(true); + } else { + onClose(); + } + }, [onClose]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + attemptClose(); + } + }, + [attemptClose], + ); + + const confirmClose = useCallback(() => { + setShowDialog(false); + onClose(); + }, [onClose]); + + const cancelClose = useCallback(() => { + setShowDialog(false); + }, []); + + const doClose = useCallback(() => { + setShowDialog(false); + onClose(); + }, [onClose]); + + return { + handleOpenChange, + tryClose: attemptClose, + doClose, + showDialog, + confirmClose, + cancelClose, + title, + description, + }; +} + +interface UnsavedChangesDialogProps { + guard: UnsavedChangesGuard; +} + +export function UnsavedChangesDialog({ guard }: UnsavedChangesDialogProps) { + return ( + !open && guard.cancelClose()}> + + + + {guard.title} + + + {guard.description} + + + + + 취소 + + + 닫기 + + + + + ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index a9e01016..1cae8eed 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -253,7 +253,7 @@ function DynamicAdminLoader({ url, params }: { url: string; params?: Record; if (!Component) return ; - if (params) return ; + if (params) return ; return ; } diff --git a/frontend/components/report/ReportCopyModal.tsx b/frontend/components/report/ReportCopyModal.tsx new file mode 100644 index 00000000..75dca97f --- /dev/null +++ b/frontend/components/report/ReportCopyModal.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { ReportMaster } from "@/types/report"; +import { reportApi } from "@/lib/api/reportApi"; +import { useToast } from "@/hooks/use-toast"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +interface ReportCopyModalProps { + report: ReportMaster; + onClose: () => void; + onSuccess: () => void; +} + +export function ReportCopyModal({ report, onClose, onSuccess }: ReportCopyModalProps) { + const [newName, setNewName] = useState(`${report.report_name_kor} (복사)`); + const [isCopying, setIsCopying] = useState(false); + const initialNameRef = useRef(`${report.report_name_kor} (복사)`); + const { toast } = useToast(); + + const guard = useUnsavedChangesGuard({ + hasChanges: () => !isCopying && newName !== initialNameRef.current, + onClose, + title: "입력된 내용이 있습니다", + description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?", + }); + + const handleCopy = async () => { + const trimmed = newName.trim(); + if (!trimmed) { + toast({ title: "오류", description: "리포트 이름을 입력해주세요.", variant: "destructive" }); + return; + } + + setIsCopying(true); + try { + const response = await reportApi.copyReport(report.report_id, trimmed); + if (response.success) { + toast({ title: "성공", description: "리포트가 복사되었습니다." }); + onSuccess(); + } + } catch (error: any) { + toast({ + title: "오류", + description: error.message || "리포트 복사에 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsCopying(false); + } + }; + + return ( + <> + + + + 리포트 복사 + +
+
+ + setNewName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !isCopying && handleCopy()} + placeholder="리포트 이름 입력" + className="h-11 text-base" + autoFocus + /> +
+
+ + + + +
+
+ + + + ); +} diff --git a/frontend/components/report/ReportCreateModal.tsx b/frontend/components/report/ReportCreateModal.tsx index c51dd982..61203a6e 100644 --- a/frontend/components/report/ReportCreateModal.tsx +++ b/frontend/components/report/ReportCreateModal.tsx @@ -1,22 +1,46 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useRouter } from "next/navigation"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Loader2 } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Loader2, LayoutTemplate, Check, ChevronsUpDown, Plus, Tag } from "lucide-react"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; -import { CreateReportRequest, ReportTemplate } from "@/types/report"; +import { REPORT_TYPE_OPTIONS, getTypeIcon, getTypeLabel } from "@/lib/reportTypeColors"; +import { ReportTemplate } from "@/types/report"; +import { cn } from "@/lib/utils"; interface ReportCreateModalProps { isOpen: boolean; @@ -24,59 +48,137 @@ interface ReportCreateModalProps { onSuccess: () => void; } +const TEMPLATE_NONE = "__none__"; + export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) { - const [formData, setFormData] = useState({ - reportNameKor: "", - reportNameEng: "", - templateId: undefined, - reportType: "BASIC", - description: "", - }); - const [templates, setTemplates] = useState([]); + const router = useRouter(); + const [reportName, setReportName] = useState(""); + const [reportType, setReportType] = useState(""); + const [customCategory, setCustomCategory] = useState(""); + const [categoryOpen, setCategoryOpen] = useState(false); + const [description, setDescription] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState(TEMPLATE_NONE); const [isLoading, setIsLoading] = useState(false); const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); + const [isLoadingCategories, setIsLoadingCategories] = useState(false); + const [systemTemplates, setSystemTemplates] = useState([]); + const [customTemplates, setCustomTemplates] = useState([]); + const [existingCategories, setExistingCategories] = useState([]); const { toast } = useToast(); - // 템플릿 목록 불러오기 useEffect(() => { - if (isOpen) { - fetchTemplates(); - } + if (!isOpen) return; + + const fetchTemplates = async () => { + setIsLoadingTemplates(true); + try { + const response = await reportApi.getTemplates(); + if (response.success && response.data) { + setSystemTemplates(response.data.system || []); + setCustomTemplates(response.data.custom || []); + } + } catch { + // 템플릿 로딩 실패 시 빈 목록으로 진행 + } finally { + setIsLoadingTemplates(false); + } + }; + + const fetchCategories = async () => { + setIsLoadingCategories(true); + try { + const response = await reportApi.getCategories(); + if (response.success && response.data) { + setExistingCategories(response.data); + } + } catch { + // 카테고리 로딩 실패 시 빈 목록으로 진행 + } finally { + setIsLoadingCategories(false); + } + }; + + fetchTemplates(); + fetchCategories(); }, [isOpen]); - const fetchTemplates = async () => { - setIsLoadingTemplates(true); - try { - const response = await reportApi.getTemplates(); - if (response.success && response.data) { - setTemplates([...response.data.system, ...response.data.custom]); - } - } catch (error: any) { - toast({ - title: "오류", - description: "템플릿 목록을 불러오는데 실패했습니다.", - variant: "destructive", - }); - } finally { - setIsLoadingTemplates(false); + const hasTemplates = useMemo( + () => systemTemplates.length > 0 || customTemplates.length > 0, + [systemTemplates, customTemplates], + ); + + const allCategories = useMemo(() => { + const defaultTypes = REPORT_TYPE_OPTIONS.map((opt) => opt.value); + const merged = new Set([...defaultTypes, ...existingCategories]); + return Array.from(merged).sort(); + }, [existingCategories]); + + const effectiveCategory = useMemo(() => { + return customCategory.trim() || reportType; + }, [customCategory, reportType]); + + const categoryDisplayLabel = useMemo(() => { + if (customCategory.trim()) return customCategory.trim(); + if (reportType) return getTypeLabel(reportType); + return ""; + }, [customCategory, reportType]); + + const hasInputData = useCallback(() => { + return reportName.trim() !== "" || + reportType !== "" || + customCategory.trim() !== "" || + description.trim() !== "" || + selectedTemplateId !== TEMPLATE_NONE; + }, [reportName, reportType, customCategory, description, selectedTemplateId]); + + const resetForm = useCallback(() => { + setReportName(""); + setReportType(""); + setCustomCategory(""); + setDescription(""); + setSelectedTemplateId(TEMPLATE_NONE); + }, []); + + const guard = useUnsavedChangesGuard({ + hasChanges: () => !isLoading && hasInputData(), + onClose: () => { + resetForm(); + onClose(); + }, + title: "입력된 내용이 있습니다", + description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?", + }); + + const handleCategorySelect = (value: string) => { + setReportType(value); + setCustomCategory(""); + setCategoryOpen(false); + }; + + const handleCustomCategoryAdd = () => { + const trimmed = customCategory.trim(); + if (trimmed) { + setReportType(""); + setCategoryOpen(false); } }; const handleSubmit = async () => { - // 유효성 검증 - if (!formData.reportNameKor.trim()) { + const trimmed = reportName.trim(); + if (!trimmed) { toast({ title: "입력 오류", - description: "리포트명(한글)을 입력해주세요.", + description: "리포트명을 입력해주세요.", variant: "destructive", }); return; } - if (!formData.reportType) { + const finalCategory = effectiveCategory; + if (!finalCategory) { toast({ title: "입력 오류", - description: "리포트 타입을 선택해주세요.", + description: "카테고리를 선택하거나 입력해주세요.", variant: "destructive", }); return; @@ -84,144 +186,223 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo setIsLoading(true); try { - const response = await reportApi.createReport(formData); - if (response.success) { - toast({ - title: "성공", - description: "리포트가 생성되었습니다.", - }); - handleClose(); - onSuccess(); - } - } catch (error: any) { - toast({ - title: "오류", - description: error.message || "리포트 생성에 실패했습니다.", - variant: "destructive", + const response = await reportApi.createReport({ + reportNameKor: trimmed, + reportType: finalCategory, + description: description.trim() || undefined, + templateId: selectedTemplateId !== TEMPLATE_NONE ? selectedTemplateId : undefined, }); + + if (response.success && response.data) { + toast({ title: "성공", description: "리포트가 생성되었습니다." }); + guard.doClose(); + onSuccess(); + router.push(`/admin/screenMng/reportList/designer/${response.data.reportId}`); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : "리포트 생성에 실패했습니다."; + toast({ title: "오류", description: msg, variant: "destructive" }); } finally { setIsLoading(false); } }; - const handleClose = () => { - setFormData({ - reportNameKor: "", - reportNameEng: "", - templateId: undefined, - reportType: "BASIC", - description: "", - }); - onClose(); - }; - return ( - - - - 새 리포트 생성 - 새로운 리포트를 생성합니다. 필수 항목을 입력해주세요. - + <> + + + + 새 리포트 생성 + + 리포트명과 카테고리를 입력한 후 디자이너에서 상세 설계를 진행합니다. + + -
- {/* 리포트명 (한글) */} -
- - setFormData({ ...formData, reportNameKor: e.target.value })} - /> -
+
+
+ + setReportName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !isLoading && handleSubmit()} + className="h-11 text-base" + autoFocus + /> +
- {/* 리포트명 (영문) */} -
- - setFormData({ ...formData, reportNameEng: e.target.value })} - /> -
+
+ + + + + + + + + + {customCategory.trim() && !allCategories.includes(customCategory.trim()) && ( + + + + + "{customCategory.trim()}" 새로 추가 + + + + )} + + 일치하는 카테고리가 없습니다. +
+ 위에 입력한 값으로 새 카테고리를 추가할 수 있습니다. +
+ + {allCategories.map((cat) => { + const Icon = getTypeIcon(cat); + const label = getTypeLabel(cat); + return ( + handleCategorySelect(cat)} + className="text-base" + > + + + {label} + {cat !== label && ( + ({cat}) + )} + + ); + })} + +
+
+
+
+

+ 기존 카테고리를 선택하거나 새로운 카테고리를 직접 입력할 수 있습니다. +

+
- {/* 템플릿 선택 */} -
- - + + + + + + 템플릿 없이 시작 - ))} - - + {systemTemplates.length > 0 && ( + <> +
시스템 템플릿
+ {systemTemplates.map((t) => ( + +
+ + {t.template_name_kor} +
+
+ ))} + + )} + {customTemplates.length > 0 && ( + <> +
사용자 템플릿
+ {customTemplates.map((t) => ( + +
+ + {t.template_name_kor} +
+
+ ))} + + )} + {!isLoadingTemplates && !hasTemplates && ( +
등록된 템플릿이 없습니다
+ )} + + +

템플릿을 선택하면 레이아웃이 자동으로 적용됩니다.

+
+ +
+ +