From 08b73d56c2d887931cecdf2b0af6b277381763e6 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 6 Nov 2025 17:44:59 +0900 Subject: (김준회) 결재 프리뷰 공통컴포넌트 작성 및 index.ts --> client.ts 분리 (서버사이드 코드가 번들링되어 클라측에서 실행되는 문제 해결 목적) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/approval/approval-preview-dialog.tsx | 435 +++++++++++++++++++++ lib/approval/client.ts | 23 ++ lib/approval/index.ts | 3 + lib/approval/template-utils.ts | 30 +- ...\271\354\235\270 \354\232\224\354\262\255.html" | 233 +++++++++++ ...\230\353\242\260 \354\232\224\354\262\255.html" | 316 +++++++++++++++ 6 files changed, 1025 insertions(+), 15 deletions(-) create mode 100644 lib/approval/approval-preview-dialog.tsx create mode 100644 lib/approval/client.ts create mode 100644 "lib/approval/templates/\353\262\244\353\215\224 \352\260\200\354\236\205 \354\212\271\354\235\270 \354\232\224\354\262\255.html" create mode 100644 "lib/approval/templates/\354\213\244\354\202\254\354\235\230\353\242\260 \353\260\217 \354\213\244\354\202\254\354\236\254\354\235\230\353\242\260 \354\232\224\354\262\255.html" (limited to 'lib/approval') diff --git a/lib/approval/approval-preview-dialog.tsx b/lib/approval/approval-preview-dialog.tsx new file mode 100644 index 00000000..bc5a4f65 --- /dev/null +++ b/lib/approval/approval-preview-dialog.tsx @@ -0,0 +1,435 @@ +"use client"; + +import * as React from "react"; +import { Loader2, Eye, Send, X } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useMediaQuery } from "@/hooks/use-media-query"; + +import { ApprovalLineSelector } from "@/components/knox/approval/ApprovalLineSelector"; +import { + getApprovalTemplateByName, + replaceTemplateVariables +} from "./template-utils"; + +import type { ApprovalLine } from "@/components/knox/approval/types"; + +/** + * 결재 미리보기 다이얼로그 Props + */ +export interface ApprovalPreviewDialogProps { + /** 다이얼로그 열림 상태 */ + open: boolean; + /** 다이얼로그 열림 상태 변경 핸들러 */ + onOpenChange: (open: boolean) => void; + /** 템플릿 이름 (DB에서 조회) */ + templateName: string; + /** 템플릿 변수 ({{변수명}} 형태로 치환) */ + variables: Record; + /** 결재 제목 */ + title: string; + /** 결재 설명 (선택사항) */ + description?: string; + /** 현재 사용자 정보 */ + currentUser: { + id: number; + epId: string; + name?: string; + email?: string; + deptName?: string; + }; + /** 초기 결재선 (선택사항) */ + defaultApprovers?: string[]; + /** 확인 버튼 클릭 시 콜백 */ + onConfirm: (data: { + approvers: string[]; + title: string; + description?: string; + }) => Promise; + /** 제목 수정 가능 여부 (기본: true) */ + allowTitleEdit?: boolean; + /** 설명 수정 가능 여부 (기본: true) */ + allowDescriptionEdit?: boolean; +} + +/** + * 결재 미리보기 다이얼로그 컴포넌트 + * + * **주요 기능:** + * 1. 템플릿 실시간 미리보기 (변수 치환) + * 2. 결재선 선택 (ApprovalLineSelector 활용) + * 3. 제목/설명 수정 + * 4. 반응형 UI (Desktop: Dialog, Mobile: Drawer) + * + * **사용 예시:** + * ```tsx + * { + * await submitApproval(approvers); + * }} + * /> + * ``` + */ +export function ApprovalPreviewDialog({ + open, + onOpenChange, + templateName, + variables, + title: initialTitle, + description: initialDescription, + currentUser, + defaultApprovers = [], + onConfirm, + allowTitleEdit = true, + allowDescriptionEdit = true, +}: ApprovalPreviewDialogProps) { + const isDesktop = useMediaQuery("(min-width: 768px)"); + + // 로딩 상태 + const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 폼 상태 + const [title, setTitle] = React.useState(initialTitle); + const [description, setDescription] = React.useState(initialDescription || ""); + const [approvalLines, setApprovalLines] = React.useState([]); + const [previewHtml, setPreviewHtml] = React.useState(""); + + // 템플릿 로딩 및 미리보기 생성 + React.useEffect(() => { + if (!open) return; + + async function loadTemplatePreview() { + try { + setIsLoadingTemplate(true); + + // 1. 템플릿 조회 + const template = await getApprovalTemplateByName(templateName); + if (!template) { + toast.error(`템플릿을 찾을 수 없습니다: ${templateName}`); + return; + } + + // 2. 변수 치환 + const renderedHtml = await replaceTemplateVariables( + template.content || "", + variables + ); + + setPreviewHtml(renderedHtml); + } catch (error) { + console.error("[ApprovalPreviewDialog] 템플릿 로딩 실패:", error); + toast.error("템플릿을 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoadingTemplate(false); + } + } + + loadTemplatePreview(); + }, [open, templateName, variables]); + + // 초기 결재선 설정 + React.useEffect(() => { + if (!open) return; + + // 상신자 추가 + const submitter: ApprovalLine = { + id: `submitter-${currentUser.id}`, + epId: currentUser.epId, + userId: currentUser.id.toString(), + emailAddress: currentUser.email || "", + name: currentUser.name || "상신자", + deptName: currentUser.deptName, + role: "0", // 상신자 + seq: "0", + opinion: "", + }; + + // 기본 결재자들 추가 (있는 경우) + const defaultLines: ApprovalLine[] = defaultApprovers.map((epId, index) => ({ + id: `approver-${index}`, + epId: epId, + userId: "", // EP ID로만 식별 + emailAddress: "", + name: `결재자 ${index + 1}`, + role: "1", // 결재 + seq: (index + 1).toString(), + opinion: "", + })); + + setApprovalLines([submitter, ...defaultLines]); + }, [open, currentUser, defaultApprovers]); + + // 결재선 변경 핸들러 + const handleApprovalLinesChange = (lines: ApprovalLine[]) => { + setApprovalLines(lines); + }; + + // 제출 핸들러 + const handleSubmit = async () => { + try { + // 검증: 결재선 확인 + const approvers = approvalLines + .filter((line) => line.role === "1" && line.seq !== "0") + .sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); + + if (approvers.length === 0) { + toast.error("최소 1명의 결재자를 선택해주세요."); + return; + } + + // 검증: 제목 확인 + if (!title.trim()) { + toast.error("결재 제목을 입력해주세요."); + return; + } + + setIsSubmitting(true); + + // EP ID 목록 추출 + const approverEpIds = approvers + .map((line) => line.epId) + .filter((epId): epId is string => !!epId); + + // 상위 컴포넌트로 데이터 전달 + await onConfirm({ + approvers: approverEpIds, + title: title.trim(), + description: description.trim() || undefined, + }); + + // 성공 시 다이얼로그 닫기 + onOpenChange(false); + } catch (error) { + console.error("[ApprovalPreviewDialog] 제출 실패:", error); + // 에러는 상위 컴포넌트에서 처리 (toast 등) + } finally { + setIsSubmitting(false); + } + }; + + // 취소 핸들러 + const handleCancel = () => { + onOpenChange(false); + }; + + // 폼 내용 + const FormContent = () => ( +
+ {/* 탭: 미리보기 / 결재선 설정 */} + + + + + 미리보기 + + + + 결재선 설정 + + + + {/* 미리보기 탭 */} + + {/* 제목 입력 */} +
+ + setTitle(e.target.value)} + placeholder="결재 제목을 입력하세요" + disabled={!allowTitleEdit || isSubmitting} + /> +
+ + {/* 설명 입력 */} +
+ +