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 | |
| parent | ba43cd261d10c6b0c5218a9da3f946993b21de6e (diff) | |
(김준회) 결재 프리뷰 공통컴포넌트 작성 및 index.ts --> client.ts 분리 (서버사이드 코드가 번들링되어 클라측에서 실행되는 문제 해결 목적)
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx | 2 | ||||
| -rw-r--r-- | lib/approval-template/editor/approval-template-editor.tsx | 358 | ||||
| -rw-r--r-- | lib/approval/approval-preview-dialog.tsx | 435 | ||||
| -rw-r--r-- | lib/approval/client.ts | 23 | ||||
| -rw-r--r-- | lib/approval/index.ts | 3 | ||||
| -rw-r--r-- | lib/approval/template-utils.ts | 30 | ||||
| -rw-r--r-- | lib/approval/templates/벤더 가입 승인 요청.html | 233 | ||||
| -rw-r--r-- | lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html | 316 | ||||
| -rw-r--r-- | lib/vendors/approval-actions.ts | 177 | ||||
| -rw-r--r-- | lib/vendors/blacklist-check.ts | 170 | ||||
| -rw-r--r-- | lib/vendors/table/approve-vendor-dialog.tsx | 185 |
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 |
