summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-06 17:44:59 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-06 17:44:59 +0900
commit08b73d56c2d887931cecdf2b0af6b277381763e6 (patch)
treee2a1e466445c718dad79c100241048684b8a1923
parentba43cd261d10c6b0c5218a9da3f946993b21de6e (diff)
(김준회) 결재 프리뷰 공통컴포넌트 작성 및 index.ts --> client.ts 분리 (서버사이드 코드가 번들링되어 클라측에서 실행되는 문제 해결 목적)
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx2
-rw-r--r--lib/approval-template/editor/approval-template-editor.tsx358
-rw-r--r--lib/approval/approval-preview-dialog.tsx435
-rw-r--r--lib/approval/client.ts23
-rw-r--r--lib/approval/index.ts3
-rw-r--r--lib/approval/template-utils.ts30
-rw-r--r--lib/approval/templates/벤더 가입 승인 요청.html233
-rw-r--r--lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html316
-rw-r--r--lib/vendors/approval-actions.ts177
-rw-r--r--lib/vendors/blacklist-check.ts170
-rw-r--r--lib/vendors/table/approve-vendor-dialog.tsx185
11 files changed, 1599 insertions, 333 deletions
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx
index 4ce13b42..3ea0fb09 100644
--- a/app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx
@@ -5,7 +5,6 @@ import { notFound } from "next/navigation"
import { getApprovalTemplate } from "@/lib/approval-template/service"
import { getApprovalLineOptions } from "@/lib/approval-line/service"
import { ApprovalTemplateEditor } from "@/lib/approval-template/editor/approval-template-editor"
-import { variables as configVariables } from "./config"
interface ApprovalTemplateDetailPageProps {
params: Promise<{
@@ -46,7 +45,6 @@ export default async function ApprovalTemplateDetailPage({ params }: ApprovalTem
<ApprovalTemplateEditor
templateId={id}
initialTemplate={template}
- staticVariables={configVariables as unknown as Array<{ variableName: string }>}
approvalLineOptions={approvalLineOptions}
/>
)}
diff --git a/lib/approval-template/editor/approval-template-editor.tsx b/lib/approval-template/editor/approval-template-editor.tsx
index 242504e7..7fe7c700 100644
--- a/lib/approval-template/editor/approval-template-editor.tsx
+++ b/lib/approval-template/editor/approval-template-editor.tsx
@@ -2,16 +2,13 @@
import * as React from "react"
import { toast } from "sonner"
-import { Loader, Save, ArrowLeft, Code2, Eye } from "lucide-react"
+import { Loader, Save, ArrowLeft, Eye } from "lucide-react"
import Link from "next/link"
-import dynamic from "next/dynamic"
-import type { Editor as ToastEditor } from "@toast-ui/editor"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Separator } from "@/components/ui/separator"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -24,51 +21,20 @@ import { getActiveApprovalTemplateCategories, type ApprovalTemplateCategory } fr
import { useSession } from "next-auth/react"
import { useRouter, usePathname } from "next/navigation"
-// Toast UI Editor를 dynamic import로 불러옴 (SSR 방지)
-const Editor = dynamic(
- async () => {
- // CSS를 먼저 로드
- // @ts-expect-error - CSS 파일은 타입 선언이 없지만 런타임에 정상 작동
- await import("@toast-ui/editor/dist/toastui-editor.css")
- const mod = await import("@toast-ui/react-editor")
- return mod.Editor
- },
- {
- ssr: false,
- loading: () => <div className="flex items-center justify-center h-[400px] text-muted-foreground">에디터 로드 중...</div>,
- }
-)
-
interface ApprovalTemplateEditorProps {
templateId: string
initialTemplate: ApprovalTemplate
- staticVariables?: Array<{ variableName: string }>
approvalLineOptions: Array<{ id: string; name: string }>
}
-export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVariables = [], approvalLineOptions }: ApprovalTemplateEditorProps) {
- const { data: session } = useSession()
- const router = useRouter()
- const pathname = usePathname()
- const editorRef = React.useRef<ToastEditor | null>(null)
+export function ApprovalTemplateEditor({ templateId, initialTemplate, approvalLineOptions }: ApprovalTemplateEditorProps) {
+ const { data: session } = useSession()
+ const router = useRouter()
+ const pathname = usePathname()
const [template, setTemplate] = React.useState(initialTemplate)
const [isSaving, startSaving] = React.useTransition()
- const [previewContent, setPreviewContent] = React.useState(initialTemplate.content)
- const [editMode, setEditMode] = React.useState<"wysiwyg" | "html">("wysiwyg")
- const [htmlSource, setHtmlSource] = React.useState(initialTemplate.content)
- const [editorKey, setEditorKey] = React.useState(0) // 에디터 재마운트를 위한 키
-
- // 편집기에 전달할 변수 목록
- // 템플릿(DB) 변수 + 정적(config) 변수 병합
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const dbVariables: string[] = Array.isArray((template as any).variables)
- ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (template as any).variables.map((v: any) => v.variableName)
- : []
-
- const mergedVariables = Array.from(new Set([...dbVariables, ...staticVariables.map((v) => v.variableName)]))
-
- const variableOptions = mergedVariables.map((name) => ({ label: name, html: `{{${name}}}` }))
+ const [htmlContent, setHtmlContent] = React.useState(initialTemplate.content)
+ const [previewKey, setPreviewKey] = React.useState(0) // 미리보기 업데이트용
const [form, setForm] = React.useState({
name: template.name,
@@ -140,18 +106,10 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
setForm((prev) => ({ ...prev, [name]: value }))
}
- // 편집 모드 토글
- function toggleEditMode() {
- if (editMode === "wysiwyg") {
- // WYSIWYG → HTML: 에디터에서 HTML 가져오기
- const currentHtml = editorRef.current?.getHTML() || ""
- setHtmlSource(currentHtml)
- setEditMode("html")
- } else {
- // HTML → WYSIWYG: 에디터를 재마운트하여 수정된 HTML 반영
- setEditorKey(prev => prev + 1)
- setEditMode("wysiwyg")
- }
+ // 미리보기 새로고침
+ function refreshPreview() {
+ setPreviewKey((prev) => prev + 1)
+ toast.success("미리보기를 업데이트했습니다")
}
function handleSave() {
@@ -161,15 +119,10 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
return
}
- // 현재 모드에 따라 HTML 가져오기
- const content = editMode === "wysiwyg"
- ? editorRef.current?.getHTML() || ""
- : htmlSource
-
const { success, error, data } = await updateApprovalTemplateAction(templateId, {
name: form.name,
subject: form.subject,
- content,
+ content: htmlContent,
description: form.description,
category: form.category || undefined,
approvalLineId: form.approvalLineId ? form.approvalLineId : null,
@@ -215,6 +168,9 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
</div>
<p className="text-sm text-muted-foreground">{template.description || "결재 템플릿 편집"}</p>
</div>
+ <Button onClick={refreshPreview} variant="outline" size="sm">
+ <Eye className="mr-2 h-4 w-4" /> 미리보기 새로고침
+ </Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" /> 저장
@@ -223,187 +179,115 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
<Separator />
- <Tabs
- defaultValue="editor"
- className="flex-1"
- onValueChange={(value) => {
- if (value === "preview") {
- const currentHtml = editMode === "wysiwyg"
- ? editorRef.current?.getHTML() || ""
- : htmlSource
- setPreviewContent(currentHtml)
- }
- }}
- >
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="editor">편집</TabsTrigger>
- <TabsTrigger value="preview">미리보기</TabsTrigger>
- </TabsList>
-
- <TabsContent value="editor" className="mt-4 flex flex-col gap-4">
- <Card>
- <CardHeader>
- <CardTitle>기본 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <label className="text-sm font-medium">이름</label>
- <Input name="name" value={form.name} onChange={handleChange} />
- </div>
- <div className="space-y-2">
- <label className="text-sm font-medium">카테고리</label>
- <Select
- value={category || "none"}
- onValueChange={(value) => setCategory(value === "none" ? "" : value)}
- disabled={isLoadingCategories}
- >
- <SelectTrigger>
- <SelectValue placeholder={isLoadingCategories ? "카테고리 로드 중..." : "카테고리를 선택하세요"} />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="none">선택 안함</SelectItem>
- {categories
- .sort((a, b) => a.sortOrder - b.sortOrder)
- .map((cat) => (
- <SelectItem key={`category-${cat.id}`} value={cat.name}>
- {cat.name}
- {cat.description && (
- <span className="text-muted-foreground ml-2">({cat.description})</span>
- )}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
- <div className="space-y-2">
- <label className="text-sm font-medium">설명 (선택)</label>
- <Input name="description" value={form.description} onChange={handleChange} />
- </div>
- <div className="space-y-2">
- <label className="text-sm font-medium">제목</label>
- <Input name="subject" value={form.subject} onChange={handleChange} />
- </div>
- <div className="space-y-2">
- <label className="text-sm font-medium">결재선</label>
- <Select
- value={form.approvalLineId}
- onValueChange={(value) => setForm((prev) => ({ ...prev, approvalLineId: value }))}
- disabled={!category || isLoadingLines}
- >
- <SelectTrigger>
- <SelectValue placeholder={category ? (isLoadingLines ? "불러오는 중..." : "결재선을 선택하세요") : "카테고리를 먼저 선택하세요"} />
- </SelectTrigger>
- <SelectContent>
- {lineOptions.map((opt) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const label = opt.aplns ? `${opt.name} — ${formatApprovalLine(opt.aplns as any)}` : opt.name
- return (
- <SelectItem key={opt.id} value={opt.id}>
- {label}
- </SelectItem>
- )
- })}
- </SelectContent>
- </Select>
- </div>
- </CardContent>
- </Card>
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <label className="text-sm font-medium">이름</label>
+ <Input name="name" value={form.name} onChange={handleChange} />
+ </div>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">카테고리</label>
+ <Select
+ value={category || "none"}
+ onValueChange={(value) => setCategory(value === "none" ? "" : value)}
+ disabled={isLoadingCategories}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={isLoadingCategories ? "카테고리 로드 중..." : "카테고리를 선택하세요"} />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="none">선택 안함</SelectItem>
+ {categories
+ .sort((a, b) => a.sortOrder - b.sortOrder)
+ .map((cat) => (
+ <SelectItem key={`category-${cat.id}`} value={cat.name}>
+ {cat.name}
+ {cat.description && (
+ <span className="text-muted-foreground ml-2">({cat.description})</span>
+ )}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">설명 (선택)</label>
+ <Input name="description" value={form.description} onChange={handleChange} />
+ </div>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">제목</label>
+ <Input name="subject" value={form.subject} onChange={handleChange} />
+ </div>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">결재선</label>
+ <Select
+ value={form.approvalLineId}
+ onValueChange={(value) => setForm((prev) => ({ ...prev, approvalLineId: value }))}
+ disabled={!category || isLoadingLines}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={category ? (isLoadingLines ? "불러오는 중..." : "결재선을 선택하세요") : "카테고리를 먼저 선택하세요"} />
+ </SelectTrigger>
+ <SelectContent>
+ {lineOptions.map((opt) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const label = opt.aplns ? `${opt.name} — ${formatApprovalLine(opt.aplns as any)}` : opt.name
+ return (
+ <SelectItem key={opt.id} value={opt.id}>
+ {label}
+ </SelectItem>
+ )
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+ </CardContent>
+ </Card>
- <Card>
- <CardHeader>
- <div className="flex items-center justify-between">
- <div>
- <CardTitle>HTML 내용</CardTitle>
- <CardDescription>표, 이미지, 변수 등을 자유롭게 편집하세요.</CardDescription>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={toggleEditMode}
- className="flex items-center gap-2"
- >
- {editMode === "wysiwyg" ? (
- <>
- <Code2 className="h-4 w-4" />
- HTML로 수정
- </>
- ) : (
- <>
- <Eye className="h-4 w-4" />
- 에디터로 수정
- </>
- )}
- </Button>
+ {/* 2컬럼 레이아웃: 왼쪽 HTML 편집기, 오른쪽 미리보기 */}
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1">
+ {/* 왼쪽: HTML 편집기 */}
+ <Card className="flex flex-col">
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>HTML 편집기</CardTitle>
+ <CardDescription>HTML 코드를 직접 편집하세요</CardDescription>
</div>
- </CardHeader>
- <CardContent>
- {variableOptions.length > 0 && (
- <div className="mb-4 flex flex-wrap gap-2">
- {variableOptions.map((v: { label: string; html: string }) => (
- <Button
- key={v.label}
- variant="outline"
- size="sm"
- onClick={() => {
- if (editMode === "wysiwyg" && editorRef.current) {
- editorRef.current.insertText(v.html)
- } else if (editMode === "html") {
- // HTML 모드에서는 텍스트 끝에 추가
- setHtmlSource((prev) => prev + v.html)
- }
- }}
- >
- {`{{${v.label}}}`}
- </Button>
- ))}
- </div>
- )}
-
- {editMode === "wysiwyg" ? (
- <Editor
- key={editorKey}
- initialValue={htmlSource}
- previewStyle="vertical"
- height="500px"
- initialEditType="wysiwyg"
- useCommandShortcut={true}
- hideModeSwitch={true}
- toolbarItems={[
- ["heading", "bold", "italic", "strike"],
- ["hr", "quote"],
- ["ul", "ol", "task"],
- ["table", "link"],
- ["code", "codeblock"],
- ]}
- onLoad={(editor) => {
- editorRef.current = editor
- }}
- />
- ) : (
- <Textarea
- value={htmlSource}
- onChange={(e) => setHtmlSource(e.target.value)}
- className="font-mono text-sm min-h-[500px] resize-y"
- placeholder="HTML 소스를 직접 편집하세요..."
- />
- )}
- </CardContent>
- </Card>
- </TabsContent>
+ </div>
+ </CardHeader>
+ <CardContent className="flex-1 flex flex-col">
+ {/* HTML 입력 영역 */}
+ <Textarea
+ value={htmlContent}
+ onChange={(e) => setHtmlContent(e.target.value)}
+ className="font-mono text-sm flex-1 min-h-[600px] resize-none"
+ placeholder="HTML 소스를 입력하세요..."
+ />
+ </CardContent>
+ </Card>
- <TabsContent value="preview" className="mt-4">
- <Card>
- <CardHeader>
- <CardTitle>미리보기</CardTitle>
- </CardHeader>
- <CardContent className="border rounded-md p-4 overflow-auto bg-background">
- <div dangerouslySetInnerHTML={{ __html: previewContent }} />
- </CardContent>
- </Card>
- </TabsContent>
- </Tabs>
+ {/* 오른쪽: 미리보기 */}
+ <Card className="flex flex-col">
+ <CardHeader>
+ <CardTitle>미리보기</CardTitle>
+ <CardDescription>HTML이 실시간으로 렌더링됩니다</CardDescription>
+ </CardHeader>
+ <CardContent className="flex-1 overflow-auto">
+ <div
+ key={previewKey}
+ className="border rounded-md p-4 bg-background min-h-[600px]"
+ dangerouslySetInnerHTML={{ __html: htmlContent }}
+ />
+ </CardContent>
+ </Card>
+ </div>
</div>
)
}
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>
+ );
+}
+
diff --git a/lib/approval/client.ts b/lib/approval/client.ts
new file mode 100644
index 00000000..4f62ddfc
--- /dev/null
+++ b/lib/approval/client.ts
@@ -0,0 +1,23 @@
+/**
+ * 결재 시스템 - 클라이언트 컴포넌트 Export
+ *
+ * ⚠️ 중요: 이 파일은 클라이언트 컴포넌트만 export합니다.
+ *
+ * 서버 전용 함수(Saga, 템플릿 유틸 등)와 분리하여
+ * 클라이언트 번들에 서버 전용 코드가 포함되는 것을 방지합니다.
+ *
+ * 사용법:
+ * ```typescript
+ * // ✅ 올바른 방법
+ * import { ApprovalPreviewDialog } from '@/lib/approval/client';
+ *
+ * // ❌ 잘못된 방법 (서버 코드가 클라이언트 번들에 포함됨)
+ * import { ApprovalPreviewDialog } from '@/lib/approval';
+ * ```
+ */
+
+export {
+ ApprovalPreviewDialog,
+ type ApprovalPreviewDialogProps,
+} from './approval-preview-dialog';
+
diff --git a/lib/approval/index.ts b/lib/approval/index.ts
index 943ada81..a2750805 100644
--- a/lib/approval/index.ts
+++ b/lib/approval/index.ts
@@ -55,3 +55,6 @@ export {
revalidateAllApprovalCaches,
revalidateApprovalDetail,
} from './cache-utils';
+
+// ⚠️ 주의: 클라이언트 컴포넌트는 '@/lib/approval/client'에서 import 하세요
+// export { ApprovalPreviewDialog } from './approval-preview-dialog'; // 제거됨 \ No newline at end of file
diff --git a/lib/approval/template-utils.ts b/lib/approval/template-utils.ts
index a39f8ac4..0607f289 100644
--- a/lib/approval/template-utils.ts
+++ b/lib/approval/template-utils.ts
@@ -108,13 +108,13 @@ export async function htmlTableConverter(
columns: Array<{ key: string; label: string }>
): Promise<string> {
if (!data || data.length === 0) {
- return '<p class="text-gray-500">데이터가 없습니다.</p>';
+ return '<p style="color: #6b7280;">데이터가 없습니다.</p>';
}
const headerRow = columns
.map(
(col) =>
- `<th class="border border-gray-300 px-4 py-2 bg-gray-100 font-semibold text-left">${col.label}</th>`
+ `<th style="border: 1px solid #d1d5db; padding: 8px 16px; background-color: #f3f4f6; font-weight: 600; text-align: left;">${col.label}</th>`
)
.join('');
@@ -125,7 +125,7 @@ export async function htmlTableConverter(
const value = row[col.key];
const displayValue =
value !== undefined && value !== null ? String(value) : '-';
- return `<td class="border border-gray-300 px-4 py-2">${displayValue}</td>`;
+ return `<td style="border: 1px solid #d1d5db; padding: 8px 16px;">${displayValue}</td>`;
})
.join('');
return `<tr>${cells}</tr>`;
@@ -133,7 +133,7 @@ export async function htmlTableConverter(
.join('');
return `
- <table class="w-full border-collapse border border-gray-300 my-4">
+ <table style="width: 100%; border-collapse: collapse; border: 1px solid #d1d5db; margin: 16px 0;">
<thead>
<tr>${headerRow}</tr>
</thead>
@@ -162,19 +162,19 @@ export async function htmlListConverter(
ordered: boolean = false
): Promise<string> {
if (!items || items.length === 0) {
- return '<p class="text-gray-500">항목이 없습니다.</p>';
+ return '<p style="color: #6b7280;">항목이 없습니다.</p>';
}
const listItems = items
- .map((item) => `<li class="mb-1">${item}</li>`)
+ .map((item) => `<li style="margin-bottom: 4px;">${item}</li>`)
.join('');
const tag = ordered ? 'ol' : 'ul';
- const listClass = ordered
- ? 'list-decimal list-inside my-4'
- : 'list-disc list-inside my-4';
+ const listStyle = ordered
+ ? 'list-style-type: decimal; list-style-position: inside; margin: 16px 0; padding-left: 20px;'
+ : 'list-style-type: disc; list-style-position: inside; margin: 16px 0; padding-left: 20px;';
- return `<${tag} class="${listClass}">${listItems}</${tag}>`;
+ return `<${tag} style="${listStyle}">${listItems}</${tag}>`;
}
/**
@@ -196,20 +196,20 @@ export async function htmlDescriptionList(
items: Array<{ label: string; value: string }>
): Promise<string> {
if (!items || items.length === 0) {
- return '<p class="text-gray-500">정보가 없습니다.</p>';
+ return '<p style="color: #6b7280;">정보가 없습니다.</p>';
}
const listItems = items
.map(
(item) => `
- <div class="flex border-b border-gray-200 py-2">
- <dt class="w-1/3 font-semibold text-gray-700">${item.label}</dt>
- <dd class="w-2/3 text-gray-900">${item.value}</dd>
+ <div style="display: flex; border-bottom: 1px solid #e5e7eb; padding: 8px 0;">
+ <dt style="width: 33.333%; font-weight: 600; color: #374151;">${item.label}</dt>
+ <dd style="width: 66.667%; color: #111827;">${item.value}</dd>
</div>
`
)
.join('');
- return `<dl class="my-4">${listItems}</dl>`;
+ return `<dl style="margin: 16px 0;">${listItems}</dl>`;
}
diff --git a/lib/approval/templates/벤더 가입 승인 요청.html b/lib/approval/templates/벤더 가입 승인 요청.html
new file mode 100644
index 00000000..f3e216aa
--- /dev/null
+++ b/lib/approval/templates/벤더 가입 승인 요청.html
@@ -0,0 +1,233 @@
+<!-- 벤더 가입 승인 요청 템플릿 -->
+<!-- 이 템플릿은 approvalTemplates 테이블에 저장됩니다 -->
+<!-- 템플릿명: 벤더 가입 승인 요청 -->
+
+<div
+ style="
+ max-width: 900px;
+ margin: 0 auto;
+ font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
+ color: #333;
+ line-height: 1.6;
+ "
+>
+ <!-- 헤더 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 2px solid #000;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="3"
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 20px;
+ text-align: center;
+ font-size: 24px;
+ font-weight: 700;
+ "
+ >
+ 벤더 가입 승인 요청
+ </th>
+ </tr>
+ </thead>
+ </table>
+
+ <!-- 요청 정보 테이블 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="2"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ 요청 정보
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ width: 150px;
+ border: 1px solid #ccc;
+ "
+ >
+ 요청일
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{요청일}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 요청자
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{요청자}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 승인 대상
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ {{업체수}}개 업체
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 승인 대상 업체 목록 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ 승인 대상 업체
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 15px;
+ border: 1px solid #ccc;
+ line-height: 1.8;
+ "
+ >
+ {{업체목록}}
+ </td>
+ </tr>
+ <tr>
+ <td style="background-color: #fff; padding: 0; border: 1px solid #ccc">
+ {{업체목록테이블}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 업체 상세 정보 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ 업체 상세 정보
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 20px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{업체상세정보}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>
diff --git a/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html b/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html
new file mode 100644
index 00000000..e2a7cf6d
--- /dev/null
+++ b/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html
@@ -0,0 +1,316 @@
+<div
+ style="
+ max-width: 900px;
+ margin: 0 auto;
+ font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
+ color: #333;
+ line-height: 1.6;
+ "
+>
+ <!-- 헤더 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 2px solid #000;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="3"
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 20px;
+ text-align: center;
+ font-size: 24px;
+ font-weight: 700;
+ "
+ >
+ VENDOR 실사의뢰 (재의뢰)
+ </th>
+ </tr>
+ </thead>
+ </table>
+
+ <!-- 실사요청 개요 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="2"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 실사요청 개요
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ width: 150px;
+ border: 1px solid #ccc;
+ "
+ >
+ 협력사명
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력사명}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 실사요청일
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{실사요청일}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 실사예정일
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{실사예정일}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 실사장소
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{실사장소}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ QM담당자
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{QM담당자}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 실사사유목적
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{실사사유목적}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ ISO 9000S 보유여부
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ 비워둠
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 담당자 연락처 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 담당자 연락처
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 15px;
+ border: 1px solid #ccc;
+ line-height: 1.8;
+ "
+ >
+ {{담당자연락처}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 실사품목 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 실사품목
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 15px;
+ border: 1px solid #ccc;
+ line-height: 1.8;
+ "
+ >
+ 해당 정보 없음...
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>
diff --git a/lib/vendors/approval-actions.ts b/lib/vendors/approval-actions.ts
index 69d09caa..1ce96078 100644
--- a/lib/vendors/approval-actions.ts
+++ b/lib/vendors/approval-actions.ts
@@ -2,9 +2,132 @@
import { ApprovalSubmissionSaga } from '@/lib/approval'
import type { ApprovalConfig } from '@/lib/approval/types'
+import { htmlTableConverter, htmlDescriptionList } from '@/lib/approval'
import db from '@/db/db'
-import { vendors } from '@/db/schema/vendors'
-import { inArray } from 'drizzle-orm'
+import { vendors, vendorTypes } from '@/db/schema/vendors'
+import { inArray, eq } from 'drizzle-orm'
+
+/**
+ * 벤더 승인 결재 템플릿 변수 생성
+ *
+ * @param vendorIds - 벤더 ID 목록
+ * @param currentUserEmail - 현재 사용자 이메일
+ * @returns 템플릿 변수 객체 및 벤더 정보
+ */
+export async function prepareVendorApprovalVariables(
+ vendorIds: number[],
+ currentUserEmail?: string
+) {
+ // 벤더 정보 조회
+ const vendorRecords = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ email: vendors.email,
+ status: vendors.status,
+ taxId: vendors.taxId,
+ country: vendors.country,
+ representativeName: vendors.representativeName,
+ phone: vendors.phone,
+ website: vendors.website,
+ representativeEmail: vendors.representativeEmail,
+ postalCode: vendors.postalCode,
+ address: vendors.address,
+ addressDetail: vendors.addressDetail,
+ corporateRegistrationNumber: vendors.corporateRegistrationNumber,
+ businessSize: vendors.businessSize,
+ vendorTypeId: vendors.vendorTypeId,
+ vendorTypeName: vendorTypes.nameKo,
+ })
+ .from(vendors)
+ .leftJoin(vendorTypes, eq(vendors.vendorTypeId, vendorTypes.id))
+ .where(inArray(vendors.id, vendorIds))
+
+ if (vendorRecords.length === 0) {
+ throw new Error(`벤더를 찾을 수 없습니다: ${vendorIds.join(', ')}`)
+ }
+
+ // PENDING_REVIEW 상태가 아닌 벤더 확인
+ const invalidVendors = vendorRecords.filter(v => v.status !== 'PENDING_REVIEW')
+ if (invalidVendors.length > 0) {
+ throw new Error(
+ `가입 신청 중(PENDING_REVIEW) 상태의 벤더만 승인할 수 있습니다. ` +
+ `잘못된 상태: ${invalidVendors.map(v => `${v.vendorName}(${v.status})`).join(', ')}`
+ )
+ }
+
+ // 업체규모 매핑
+ const businessSizeMap: Record<string, string> = {
+ 'A': '대기업',
+ 'B': '중견기업',
+ 'C': '중소기업',
+ 'D': '소기업',
+ }
+
+ // 벤더 목록 테이블 생성
+ const vendorListTable = await htmlTableConverter(
+ vendorRecords.map(v => ({
+ vendorName: v.vendorName || '-',
+ representativeName: v.representativeName || '-',
+ vendorType: v.vendorTypeName || '-',
+ businessSize: v.businessSize ? businessSizeMap[v.businessSize] || v.businessSize : '-',
+ phone: v.phone || '-',
+ email: v.representativeEmail || '-',
+ })),
+ [
+ { key: 'vendorName', label: '업체명' },
+ { key: 'representativeName', label: '대표자명' },
+ { key: 'vendorType', label: '업체유형' },
+ { key: 'businessSize', label: '기업규모' },
+ { key: 'phone', label: '전화번호' },
+ { key: 'email', label: '이메일' },
+ ]
+ )
+
+ // 벤더별 상세 정보
+ const vendorDetailsHtml = (
+ await Promise.all(
+ vendorRecords.map(async (v) => {
+ const details = await htmlDescriptionList([
+ { label: '업체명', value: v.vendorName || '-' },
+ { label: '대표자명', value: v.representativeName || '-' },
+ { label: '업체유형', value: v.vendorTypeName || '-' },
+ { label: '기업규모', value: v.businessSize ? businessSizeMap[v.businessSize] || v.businessSize : '-' },
+ { label: '국가', value: v.country || '-' },
+ { label: '우편번호', value: v.postalCode || '-' },
+ { label: '주소', value: v.address || '-' },
+ { label: '상세주소', value: v.addressDetail || '-' },
+ { label: '전화번호', value: v.phone || '-' },
+ { label: '이메일', value: v.representativeEmail || '-' },
+ { label: '홈페이지', value: v.website || '-' },
+ { label: '사업자등록번호', value: v.taxId || '-' },
+ { label: '법인등록번호', value: v.corporateRegistrationNumber || '-' },
+ ])
+ return `<div style="margin-bottom: 30px;"><h3 style="font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #333;">${v.vendorName}</h3>${details}</div>`
+ })
+ )
+ ).join('')
+
+ const variables: Record<string, string> = {
+ '업체수': String(vendorRecords.length),
+ '업체목록': vendorRecords.map(v => v.vendorName).join(', '),
+ '업체목록테이블': vendorListTable,
+ '업체상세정보': vendorDetailsHtml,
+ '요청일': new Date().toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }),
+ '요청자': currentUserEmail || '시스템',
+ }
+
+ return {
+ variables,
+ vendorRecords,
+ vendorNames: vendorRecords.map(v => v.vendorName).join(', '),
+ }
+}
/**
* 벤더 가입 승인 결재 상신
@@ -33,49 +156,15 @@ export async function approveVendorsWithApproval(input: {
throw new Error('승인할 벤더를 선택해주세요.')
}
- // 2. 벤더 정보 조회
- const vendorRecords = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- email: vendors.email,
- status: vendors.status,
- taxId: vendors.taxId,
- country: vendors.country,
- })
- .from(vendors)
- .where(inArray(vendors.id, input.vendorIds))
-
- if (vendorRecords.length === 0) {
- throw new Error(`벤더를 찾을 수 없습니다: ${input.vendorIds.join(', ')}`)
- }
-
- // 3. PENDING_REVIEW 상태가 아닌 벤더 확인
- const invalidVendors = vendorRecords.filter(v => v.status !== 'PENDING_REVIEW')
- if (invalidVendors.length > 0) {
- throw new Error(
- `가입 신청 중(PENDING_REVIEW) 상태의 벤더만 승인할 수 있습니다. ` +
- `잘못된 상태: ${invalidVendors.map(v => `${v.vendorName}(${v.status})`).join(', ')}`
- )
- }
-
- console.log(`[Vendor Approval Action] ${vendorRecords.length}개 벤더 조회 완료`)
-
- // 4. 템플릿 변수 준비 (TODO: 실제 템플릿에 맞게 수정 필요)
- const variables: Record<string, string> = {
- // TODO: 다음 대화에서 제공될 템플릿에 맞게 변수 매핑
- '업체수': String(vendorRecords.length),
- '업체목록': vendorRecords.map(v =>
- `${v.vendorName} (${v.vendorCode || '코드 미할당'})`
- ).join('\n'),
- '요청일': new Date().toLocaleDateString('ko-KR'),
- '요청자': input.currentUser.email || 'Unknown',
- }
+ // 2. 템플릿 변수 준비
+ const { variables, vendorRecords, vendorNames } = await prepareVendorApprovalVariables(
+ input.vendorIds,
+ input.currentUser.email
+ )
- console.log(`[Vendor Approval Action] 템플릿 변수 준비 완료`)
+ console.log(`[Vendor Approval Action] ${vendorRecords.length}개 벤더 조회 및 템플릿 변수 준비 완료`)
- // 5. 결재 상신 (Saga 패턴)
+ // 3. 결재 상신 (Saga 패턴)
const saga = new ApprovalSubmissionSaga(
'vendor_approval', // 핸들러 타입 (handlers-registry에 등록될 키)
{
@@ -84,7 +173,7 @@ export async function approveVendorsWithApproval(input: {
},
{
title: `벤더 가입 승인 요청 - ${vendorRecords.length}개 업체`,
- description: `${vendorRecords.map(v => v.vendorName).join(', ')} 의 가입을 승인합니다.`,
+ description: `${vendorNames} 의 가입을 승인합니다.`,
templateName: '벤더 가입 승인 요청',
variables,
approvers: input.approvers,
diff --git a/lib/vendors/blacklist-check.ts b/lib/vendors/blacklist-check.ts
new file mode 100644
index 00000000..c72bb1bf
--- /dev/null
+++ b/lib/vendors/blacklist-check.ts
@@ -0,0 +1,170 @@
+"use server";
+
+import crypto from 'crypto';
+import { oracleKnex } from '@/lib/oracle-db/db';
+
+/**
+ * 문자열을 SHA-1으로 해시하여 대문자로 반환
+ */
+function sha1Hash(text: string): string {
+ return crypto
+ .createHash('sha1')
+ .update(text, 'utf8')
+ .digest('hex')
+ .toUpperCase();
+}
+
+/**
+ * 생년월일에서 숫자만 추출하고 YYMMDD 형식(6자리)로 변환
+ * 예: "1999-01-01" -> "990101"
+ * 예: "99-01-01" -> "990101"
+ * 예: "1988-02-06" -> "880206"
+ */
+function processBirthDate(birthDate: string | null | undefined): string {
+ if (!birthDate) {
+ throw new Error('생년월일 정보가 없습니다.');
+ }
+
+ // 숫자만 추출
+ const numbersOnly = birthDate.replace(/\D/g, '');
+
+ if (numbersOnly.length === 6) {
+ // 6자리(YYMMDD)면 그대로 사용
+ return numbersOnly;
+ } else if (numbersOnly.length === 8) {
+ // 8자리(YYYYMMDD)면 앞 2자리 제거하여 YYMMDD로 변환
+ return numbersOnly.substring(2);
+ } else {
+ throw new Error(`생년월일 형식이 올바르지 않습니다. (입력값: ${birthDate}, 숫자 길이: ${numbersOnly.length})`);
+ }
+}
+
+/**
+ * 블랙리스트 검사 결과 타입
+ */
+export interface BlacklistCheckResult {
+ isBlacklisted: boolean;
+ message: string;
+ count?: number;
+}
+
+/**
+ * 단일 벤더의 블랙리스트 여부 확인
+ */
+export async function checkVendorBlacklist(
+ representativeName: string | null | undefined,
+ representativeBirth: string | null | undefined
+): Promise<BlacklistCheckResult> {
+ try {
+ // 필수 정보 검증
+ if (!representativeName || !representativeBirth) {
+ return {
+ isBlacklisted: false,
+ message: '대표자 이름 또는 생년월일 정보가 없어 블랙리스트 검사를 진행할 수 없습니다.',
+ };
+ }
+
+ // 이름 해시값 생성
+ const nameHash = sha1Hash(representativeName);
+
+ // 생년월일 처리 (YYMMDD 6자리로 변환)
+ let birthProcessed: string;
+ try {
+ birthProcessed = processBirthDate(representativeBirth);
+ } catch (error) {
+ return {
+ isBlacklisted: false,
+ message: error instanceof Error ? error.message : '생년월일 처리 중 오류가 발생했습니다.',
+ };
+ }
+
+ // YYMMDD 전체를 해시 계산
+ // 예: "880206" → SHA1("880206")
+ const birthHash = sha1Hash(birthProcessed);
+
+ console.log('🔍 [블랙리스트 검사]', {
+ input: representativeBirth,
+ processed: birthProcessed,
+ nameHash,
+ birthHash
+ });
+
+ // Oracle DB 조회
+ const result = await oracleKnex
+ .select(oracleKnex.raw('COUNT(*) as cnt'))
+ .from('SHIVND.AMG0070')
+ .where('NM', nameHash)
+ .andWhere('BRDT', birthHash)
+ .first() as unknown as { cnt?: number; CNT?: number } | undefined;
+
+ const count = Number(result?.cnt || result?.CNT || 0);
+ const isBlacklisted = count > 0;
+
+ if (isBlacklisted) {
+ return {
+ isBlacklisted: true,
+ message: '블랙리스트에 등록된 대표자입니다. 가입 승인을 진행할 수 없습니다.',
+ count,
+ };
+ }
+
+ return {
+ isBlacklisted: false,
+ message: '블랙리스트 검사 통과',
+ count: 0,
+ };
+ } catch (error) {
+ console.error('블랙리스트 검사 오류:', error);
+ throw new Error('블랙리스트 검사 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 여러 벤더의 블랙리스트 여부 일괄 확인
+ */
+export async function checkVendorsBlacklist(
+ vendors: Array<{
+ id: string;
+ name: string;
+ representativeName: string | null;
+ representativeBirth: string | null;
+ }>
+): Promise<{
+ success: boolean;
+ blacklistedVendors: Array<{ id: string; name: string; message: string }>;
+ checkedCount: number;
+}> {
+ const blacklistedVendors: Array<{ id: string; name: string; message: string }> = [];
+
+ for (const vendor of vendors) {
+ try {
+ const result = await checkVendorBlacklist(
+ vendor.representativeName,
+ vendor.representativeBirth
+ );
+
+ if (result.isBlacklisted) {
+ blacklistedVendors.push({
+ id: vendor.id,
+ name: vendor.name,
+ message: result.message,
+ });
+ }
+ } catch (error) {
+ // 개별 벤더 검사 실패 시에도 계속 진행
+ console.error(`벤더 ${vendor.name} 블랙리스트 검사 실패:`, error);
+ blacklistedVendors.push({
+ id: vendor.id,
+ name: vendor.name,
+ message: '블랙리스트 검사 중 오류가 발생했습니다.',
+ });
+ }
+ }
+
+ return {
+ success: blacklistedVendors.length === 0,
+ blacklistedVendors,
+ checkedCount: vendors.length,
+ };
+}
+
diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx
index 786399a4..fea5a006 100644
--- a/lib/vendors/table/approve-vendor-dialog.tsx
+++ b/lib/vendors/table/approve-vendor-dialog.tsx
@@ -29,8 +29,10 @@ import {
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
import { rejectVendors } from "../service"
-import { approveVendorsWithApproval } from "../approval-actions"
+import { approveVendorsWithApproval, prepareVendorApprovalVariables } from "../approval-actions"
import { useSession } from "next-auth/react"
+import { checkVendorsBlacklist } from "../blacklist-check"
+import { ApprovalPreviewDialog } from "@/lib/approval/client"
interface VendorDecisionDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -50,6 +52,17 @@ export function VendorDecisionDialog({
const isDesktop = useMediaQuery("(min-width: 640px)")
const { data: session } = useSession()
+ // 결재 미리보기 다이얼로그 상태
+ const [showPreview, setShowPreview] = React.useState(false)
+ const [previewData, setPreviewData] = React.useState<{
+ variables: Record<string, string>
+ title: string
+ description: string
+ } | null>(null)
+
+ /**
+ * 승인 버튼 클릭 - 미리보기 다이얼로그 열기
+ */
function onApprove() {
if (!session?.user?.id) {
toast.error("사용자 인증 정보를 찾을 수 없습니다.")
@@ -63,37 +76,97 @@ export function VendorDecisionDialog({
startApproveTransition(async () => {
try {
- console.log("🔍 [DEBUG] 결재 상신 시작 - vendors:", vendors.map(v => ({ id: v.id, vendorName: v.vendorName, email: v.email })));
- console.log("🔍 [DEBUG] 세션 정보:", { userId: session.user.id, epId: session.user.epId });
+ // 1. 블랙리스트 검사 (최우선 처리)
+ const vendorsToCheck = vendors.map(v => ({
+ id: String(v.id),
+ name: v.vendorName || '',
+ representativeName: v.representativeName,
+ representativeBirth: v.representativeBirth,
+ }));
+
+ const blacklistCheckResult = await checkVendorsBlacklist(vendorsToCheck);
- const result = await approveVendorsWithApproval({
- vendorIds: vendors.map((vendor) => vendor.id),
- currentUser: {
- id: Number(session.user.id),
- epId: session.user.epId as string, // 위에서 검증했으므로 타입 단언
- email: session.user.email || undefined,
- },
- // TODO: 필요시 approvers 배열 추가
- // approvers: ['EP001', 'EP002'],
- })
-
- if (!result.success) {
- console.error("🚨 [DEBUG] 결재 상신 에러:", result.message);
- toast.error(result.message || "결재 상신에 실패했습니다.")
- return
+ if (!blacklistCheckResult.success) {
+ // 블랙리스트에 있는 벤더 목록 표시
+ const blacklistedNames = blacklistCheckResult.blacklistedVendors
+ .map(v => `• ${v.name}: ${v.message}`)
+ .join('\n');
+
+ toast.error(
+ `문제가 있는 데이터가 있습니다:\n${blacklistedNames}`,
+ { duration: 10000 }
+ );
+ return;
}
- console.log("✅ [DEBUG] 결재 상신 성공:", result);
- props.onOpenChange?.(false)
- toast.success(`결재가 상신되었습니다. (결재ID: ${result.approvalId})`)
- onSuccess?.()
+ // 2. 템플릿 변수 준비
+ const { variables, vendorRecords, vendorNames } = await prepareVendorApprovalVariables(
+ vendors.map(v => v.id),
+ session.user.email || undefined
+ );
+
+ // 3. 미리보기 데이터 설정
+ setPreviewData({
+ variables,
+ title: `벤더 가입 승인 요청 - ${vendorRecords.length}개 업체`,
+ description: `${vendorNames} 의 가입을 승인합니다.`,
+ });
+
+ // 4. 미리보기 다이얼로그 열기
+ setShowPreview(true);
+
} catch (error) {
- console.error("🚨 [DEBUG] 예상치 못한 에러:", error);
- toast.error("예상치 못한 오류가 발생했습니다.")
+ console.error("🚨 [Vendor Decision] 미리보기 준비 실패:", error);
+ toast.error(error instanceof Error ? error.message : "미리보기를 준비하는 중 오류가 발생했습니다.")
}
})
}
+ /**
+ * 결재 미리보기에서 확인 클릭 - 실제 결재 상신
+ */
+ async function handleApprovalConfirm(approvalData: {
+ approvers: string[]
+ title: string
+ description?: string
+ }) {
+ if (!session?.user?.id || !session?.user?.epId) {
+ toast.error("사용자 인증 정보가 없습니다.")
+ return
+ }
+
+ try {
+ const result = await approveVendorsWithApproval({
+ vendorIds: vendors.map((vendor) => vendor.id),
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ email: session.user.email || undefined,
+ },
+ approvers: approvalData.approvers, // 미리보기에서 설정한 결재선
+ })
+
+ if (!result.success) {
+ console.error("🚨 [Vendor Decision] 결재 상신 에러:", result.message);
+ toast.error(result.message || "결재 상신에 실패했습니다.")
+ return
+ }
+
+ console.log("✅ [Vendor Decision] 결재 상신 성공:", result);
+
+ // 다이얼로그 모두 닫기
+ setShowPreview(false)
+ props.onOpenChange?.(false)
+
+ toast.success(`결재가 상신되었습니다. (결재ID: ${result.approvalId})`)
+ onSuccess?.()
+
+ } catch (error) {
+ console.error("🚨 [Vendor Decision] 예상치 못한 에러:", error);
+ toast.error("예상치 못한 오류가 발생했습니다.")
+ }
+ }
+
function onReject() {
if (!session?.user?.id) {
toast.error("사용자 인증 정보를 찾을 수 없습니다.")
@@ -129,16 +202,17 @@ export function VendorDecisionDialog({
if (isDesktop) {
return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <Check className="size-4" aria-hidden="true" />
- 가입 결정 ({vendors.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent className="max-w-2xl">
+ <>
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ 가입 결정 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>협력업체 가입 결정</DialogTitle>
<DialogDescription>
@@ -199,11 +273,32 @@ export function VendorDecisionDialog({
</DialogFooter>
</DialogContent>
</Dialog>
+
+ {/* 결재 미리보기 다이얼로그 */}
+ {previewData && session?.user?.epId && (
+ <ApprovalPreviewDialog
+ open={showPreview}
+ onOpenChange={setShowPreview}
+ templateName="벤더 가입 승인 요청"
+ variables={previewData.variables}
+ title={previewData.title}
+ description={previewData.description}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalConfirm}
+ />
+ )}
+ </>
)
}
return (
- <Drawer {...props}>
+ <>
+ <Drawer {...props}>
{showTrigger ? (
<DrawerTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
@@ -272,5 +367,25 @@ export function VendorDecisionDialog({
</DrawerFooter>
</DrawerContent>
</Drawer>
+
+ {/* 결재 미리보기 다이얼로그 */}
+ {previewData && session?.user?.epId && (
+ <ApprovalPreviewDialog
+ open={showPreview}
+ onOpenChange={setShowPreview}
+ templateName="벤더 가입 승인 요청"
+ variables={previewData.variables}
+ title={previewData.title}
+ description={previewData.description}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalConfirm}
+ />
+ )}
+ </>
)
} \ No newline at end of file