summaryrefslogtreecommitdiff
path: root/lib/approval/approval-preview-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/approval/approval-preview-dialog.tsx')
-rw-r--r--lib/approval/approval-preview-dialog.tsx435
1 files changed, 435 insertions, 0 deletions
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<string, string>;
+ /** 결재 제목 */
+ 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<void>;
+ /** 제목 수정 가능 여부 (기본: true) */
+ allowTitleEdit?: boolean;
+ /** 설명 수정 가능 여부 (기본: true) */
+ allowDescriptionEdit?: boolean;
+}
+
+/**
+ * 결재 미리보기 다이얼로그 컴포넌트
+ *
+ * **주요 기능:**
+ * 1. 템플릿 실시간 미리보기 (변수 치환)
+ * 2. 결재선 선택 (ApprovalLineSelector 활용)
+ * 3. 제목/설명 수정
+ * 4. 반응형 UI (Desktop: Dialog, Mobile: Drawer)
+ *
+ * **사용 예시:**
+ * ```tsx
+ * <ApprovalPreviewDialog
+ * open={isOpen}
+ * onOpenChange={setIsOpen}
+ * templateName="벤더 가입 승인 요청"
+ * variables={{ "업체명": "ABC 협력업체" }}
+ * title="협력업체 가입 승인"
+ * currentUser={{ id: 1, epId: "EP001", name: "홍길동" }}
+ * onConfirm={async ({ approvers }) => {
+ * 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<ApprovalLine[]>([]);
+ const [previewHtml, setPreviewHtml] = React.useState<string>("");
+
+ // 템플릿 로딩 및 미리보기 생성
+ 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 = () => (
+ <div className="space-y-6">
+ {/* 탭: 미리보기 / 결재선 설정 */}
+ <Tabs defaultValue="preview" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="preview" className="gap-2">
+ <Eye className="size-4" />
+ 미리보기
+ </TabsTrigger>
+ <TabsTrigger value="approvers" className="gap-2">
+ <Send className="size-4" />
+ 결재선 설정
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 미리보기 탭 */}
+ <TabsContent value="preview" className="space-y-4 mt-4">
+ {/* 제목 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="title">결재 제목</Label>
+ <Input
+ id="title"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ placeholder="결재 제목을 입력하세요"
+ disabled={!allowTitleEdit || isSubmitting}
+ />
+ </div>
+
+ {/* 설명 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="description">결재 설명 (선택사항)</Label>
+ <Textarea
+ id="description"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ placeholder="결재 설명을 입력하세요"
+ rows={3}
+ disabled={!allowDescriptionEdit || isSubmitting}
+ />
+ </div>
+
+ {/* 템플릿 미리보기 */}
+ <div className="space-y-2">
+ <Label>문서 미리보기</Label>
+ <ScrollArea className="h-[300px] w-full rounded-md border bg-gray-50 p-4">
+ {isLoadingTemplate ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">
+ 템플릿을 불러오는 중...
+ </span>
+ </div>
+ ) : (
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{ __html: previewHtml }}
+ />
+ )}
+ </ScrollArea>
+ </div>
+ </TabsContent>
+
+ {/* 결재선 설정 탭 */}
+ <TabsContent value="approvers" className="space-y-4 mt-4">
+ <div className="space-y-2">
+ <Label>결재선</Label>
+ <p className="text-sm text-muted-foreground">
+ 결재자를 검색하여 추가하고, 결재 순서를 설정하세요.
+ </p>
+ </div>
+
+ <ApprovalLineSelector
+ value={approvalLines}
+ onChange={handleApprovalLinesChange}
+ placeholder="결재자를 검색하세요..."
+ maxSelections={10}
+ domainFilter={{ type: "exclude", domains: ["partners"] }}
+ />
+
+ {/* 결재선 요약 */}
+ <div className="rounded-md bg-blue-50 p-3 border border-blue-200">
+ <p className="text-sm font-medium text-blue-900 mb-1">
+ 📋 결재 경로
+ </p>
+ <p className="text-sm text-blue-700">
+ {approvalLines
+ .filter((line) => line.seq !== "0")
+ .sort((a, b) => parseInt(a.seq) - parseInt(b.seq))
+ .map((line) => line.name)
+ .join(" → ") || "결재자를 선택해주세요"}
+ </p>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+
+ // Desktop: Dialog
+ if (isDesktop) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>결재 미리보기</DialogTitle>
+ <DialogDescription>
+ 결재 문서를 확인하고 결재선을 설정한 후 상신하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto">
+ <FormContent />
+ </div>
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <Button
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isSubmitting}
+ >
+ <X className="size-4 mr-2" />
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isSubmitting || isLoadingTemplate}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="size-4 mr-2 animate-spin" />
+ 상신 중...
+ </>
+ ) : (
+ <>
+ <Send className="size-4 mr-2" />
+ 결재 상신
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ // Mobile: Drawer
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[90vh]">
+ <DrawerHeader>
+ <DrawerTitle>결재 미리보기</DrawerTitle>
+ <DrawerDescription>
+ 결재 문서를 확인하고 결재선을 설정한 후 상신하세요.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4 overflow-y-auto">
+ <FormContent />
+ </div>
+
+ <DrawerFooter className="gap-2">
+ <Button
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isSubmitting}
+ >
+ <X className="size-4 mr-2" />
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isSubmitting || isLoadingTemplate}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="size-4 mr-2 animate-spin" />
+ 상신 중...
+ </>
+ ) : (
+ <>
+ <Send className="size-4 mr-2" />
+ 결재 상신
+ </>
+ )}
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ );
+}
+