summaryrefslogtreecommitdiff
path: root/components/approval
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-23 18:13:41 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-23 18:13:41 +0900
commit78c471eec35182959e0029ded18f144974ccaca2 (patch)
tree914cdf1c8f406ca3e2aa639b8bb774f7f4e87023 /components/approval
parent0be8940580c4a4a4e098b649d198160f9b60420c (diff)
(김준회) 결재 템플릿 에디터 및 결재 워크플로 공통함수 작성, 실사의뢰 결재 연결 예시 작성
Diffstat (limited to 'components/approval')
-rw-r--r--components/approval/ApprovalPreviewDialog.tsx211
1 files changed, 211 insertions, 0 deletions
diff --git a/components/approval/ApprovalPreviewDialog.tsx b/components/approval/ApprovalPreviewDialog.tsx
new file mode 100644
index 00000000..7739bf54
--- /dev/null
+++ b/components/approval/ApprovalPreviewDialog.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Loader2, FileText, AlertCircle } from "lucide-react";
+import { toast } from "sonner";
+import { Separator } from "@/components/ui/separator";
+
+import ApprovalLineSelector, {
+ type ApprovalLineItem,
+} from "@/components/knox/approval/ApprovalLineSelector";
+import { getApprovalTemplateByName, replaceTemplateVariables } from "@/lib/approval/template-utils";
+import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils";
+
+interface ApprovalPreviewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ templateName: string;
+ variables: Record<string, string>;
+ title: string;
+ description?: string;
+ currentUser: { id: number; epId: string | null; name?: string | null; email?: string };
+ onSubmit: (approvers: ApprovalLineItem[]) => Promise<void>;
+}
+
+export function ApprovalPreviewDialog({
+ open,
+ onOpenChange,
+ templateName,
+ variables,
+ title,
+ description,
+ currentUser,
+ onSubmit,
+}: ApprovalPreviewDialogProps) {
+ const [approvalLines, setApprovalLines] = React.useState<ApprovalLineItem[]>([]);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const [templateContent, setTemplateContent] = React.useState<string>("");
+ const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false);
+ const [templateError, setTemplateError] = React.useState<string | null>(null);
+
+ // 상신자 초기화
+ React.useEffect(() => {
+ if (open && currentUser.epId) {
+ const drafterLine: ApprovalLineItem = {
+ id: `drafter-${Date.now()}`,
+ epId: currentUser.epId,
+ userId: currentUser.id.toString(),
+ emailAddress: currentUser.email,
+ name: currentUser.name || undefined,
+ role: "0", // 기안
+ seq: "0",
+ opinion: "",
+ };
+ setApprovalLines([drafterLine]);
+ }
+ }, [open, currentUser]);
+
+ // 템플릿 로드 및 변수 치환
+ React.useEffect(() => {
+ if (!open) return;
+
+ const loadTemplate = async () => {
+ setIsLoadingTemplate(true);
+ setTemplateError(null);
+
+ try {
+ const template = await getApprovalTemplateByName(templateName);
+
+ if (!template) {
+ setTemplateError(`템플릿 "${templateName}"을 찾을 수 없습니다.`);
+ setTemplateContent(`<p class="text-gray-500">${description || "결재 요청"}</p>`);
+ } else {
+ // 변수 치환
+ const replaced = await replaceTemplateVariables(template.content, variables);
+ setTemplateContent(replaced);
+ }
+ } catch (error) {
+ console.error("Template load error:", error);
+ setTemplateError("템플릿을 불러오는 중 오류가 발생했습니다.");
+ setTemplateContent(`<p class="text-gray-500">${description || "결재 요청"}</p>`);
+ } finally {
+ setIsLoadingTemplate(false);
+ }
+ };
+
+ loadTemplate();
+ }, [open, templateName, variables, description]);
+
+ const handleSubmit = async () => {
+ debugLog('[ApprovalPreviewDialog] 결재 제출 시작', {
+ templateName,
+ approvalLineCount: approvalLines.length,
+ });
+
+ // 결재자가 있는지 확인 (상신자 제외)
+ const approvers = approvalLines.filter((line) => line.seq !== "0");
+ if (approvers.length === 0) {
+ debugError('[ApprovalPreviewDialog] 결재자가 없음');
+ toast.error("결재자를 최소 1명 이상 추가해주세요.");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ debugLog('[ApprovalPreviewDialog] onSubmit 호출', {
+ approversCount: approvers.length,
+ });
+
+ await onSubmit(approvalLines);
+
+ debugSuccess('[ApprovalPreviewDialog] 결재 요청 성공');
+ toast.success("결재가 성공적으로 요청되었습니다.");
+ onOpenChange(false);
+ } catch (error) {
+ debugError('[ApprovalPreviewDialog] 결재 요청 실패', error);
+ const errorMessage = error instanceof Error ? error.message : "결재 요청에 실패했습니다.";
+ toast.error(errorMessage);
+ // 에러 발생 시 dialog를 닫지 않음
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ {title}
+ </DialogTitle>
+ {description && <DialogDescription>{description}</DialogDescription>}
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 템플릿 미리보기 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>결재 내용 미리보기</CardTitle>
+ <CardDescription>
+ 템플릿: <span className="font-mono text-sm">{templateName}</span>
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {isLoadingTemplate ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
+ <span className="ml-2 text-gray-500">템플릿을 불러오는 중...</span>
+ </div>
+ ) : templateError ? (
+ <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
+ <div className="flex items-center gap-2 text-yellow-700">
+ <AlertCircle className="w-4 h-4" />
+ <span className="font-medium">경고</span>
+ </div>
+ <p className="text-sm text-yellow-600 mt-1">{templateError}</p>
+ <p className="text-xs text-yellow-500 mt-2">
+ 기본 내용으로 대체되었습니다. 결재는 정상적으로 진행됩니다.
+ </p>
+ </div>
+ ) : null}
+
+ <div
+ className="border rounded-lg p-4 min-h-[200px] [&_table]:w-full [&_table]:border-collapse [&_table]:border [&_table]:border-border [&_th]:border [&_th]:border-border [&_th]:bg-muted/50 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_tr:hover]:bg-muted/30"
+ dangerouslySetInnerHTML={{ __html: templateContent }}
+ />
+ </CardContent>
+ </Card>
+
+ <Separator />
+
+ {/* 결재선 선택 */}
+ <div>
+ <ApprovalLineSelector
+ value={approvalLines}
+ onChange={setApprovalLines}
+ placeholder="결재자를 검색하세요..."
+ maxSelections={10}
+ />
+ </div>
+ </div>
+
+ <DialogFooter className="gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="button" onClick={handleSubmit} disabled={isSubmitting || isLoadingTemplate}>
+ {isSubmitting && <Loader2 className="mr-2 w-4 h-4 animate-spin" />}
+ 결재 요청
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+