diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-06 17:44:59 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-06 17:44:59 +0900 |
| commit | 08b73d56c2d887931cecdf2b0af6b277381763e6 (patch) | |
| tree | e2a1e466445c718dad79c100241048684b8a1923 /lib/approval/approval-preview-dialog.tsx | |
| parent | ba43cd261d10c6b0c5218a9da3f946993b21de6e (diff) | |
(김준회) 결재 프리뷰 공통컴포넌트 작성 및 index.ts --> client.ts 분리 (서버사이드 코드가 번들링되어 클라측에서 실행되는 문제 해결 목적)
Diffstat (limited to 'lib/approval/approval-preview-dialog.tsx')
| -rw-r--r-- | lib/approval/approval-preview-dialog.tsx | 435 |
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> + ); +} + |
