diff options
Diffstat (limited to 'lib')
34 files changed, 8674 insertions, 364 deletions
diff --git a/lib/basic-contract/gtc-vendor/bulk-update-gtc-clauses-dialog.tsx b/lib/basic-contract/gtc-vendor/bulk-update-gtc-clauses-dialog.tsx new file mode 100644 index 00000000..a9ef0f0e --- /dev/null +++ b/lib/basic-contract/gtc-vendor/bulk-update-gtc-clauses-dialog.tsx @@ -0,0 +1,276 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Loader, Edit, AlertCircle } from "lucide-react" +import { toast } from "sonner" + +import { bulkUpdateGtcClausesSchema, type BulkUpdateGtcClausesSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { bulkUpdateGtcClauses } from "@/lib/gtc-contract/gtc-clauses/service" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" + +interface BulkUpdateGtcClausesDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + selectedClauses: GtcClauseTreeView[] +} + +export function BulkUpdateGtcClausesDialog({ + selectedClauses, + ...props +}: BulkUpdateGtcClausesDialogProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const { data: session } = useSession() + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + const form = useForm<BulkUpdateGtcClausesSchema>({ + resolver: zodResolver(bulkUpdateGtcClausesSchema), + defaultValues: { + clauseIds: selectedClauses.map(clause => clause.id), + updates: { + category: "", + isActive: true, + }, + editReason: "", + }, + }) + + React.useEffect(() => { + if (selectedClauses.length > 0) { + form.setValue("clauseIds", selectedClauses.map(clause => clause.id)) + } + }, [selectedClauses, form]) + + async function onSubmit(data: BulkUpdateGtcClausesSchema) { + startUpdateTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + try { + const result = await bulkUpdateGtcClauses({ + ...data, + updatedById: currentUserId + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success(`${selectedClauses.length}개의 조항이 수정되었습니다.`) + } catch (error) { + toast.error("조항 일괄 수정 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + props.onOpenChange?.(nextOpen) + } + + // 선택된 조항들의 통계 + const categoryCounts = React.useMemo(() => { + const counts: Record<string, number> = {} + selectedClauses.forEach(clause => { + const category = clause.category || "미분류" + counts[category] = (counts[category] || 0) + 1 + }) + return counts + }, [selectedClauses]) + + const activeCount = selectedClauses.filter(clause => clause.isActive).length + const inactiveCount = selectedClauses.length - activeCount + + if (selectedClauses.length === 0) { + return null + } + + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Edit className="h-5 w-5" /> + 조항 일괄 수정 + </DialogTitle> + <DialogDescription> + 선택한 {selectedClauses.length}개 조항의 공통 속성을 일괄 수정합니다. + </DialogDescription> + </DialogHeader> + + {/* 선택된 조항 요약 */} + <div className="space-y-4 p-4 bg-muted/50 rounded-lg"> + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">선택된 조항 정보</span> + </div> + + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <div className="font-medium text-muted-foreground mb-1">총 조항 수</div> + <div>{selectedClauses.length}개</div> + </div> + + <div> + <div className="font-medium text-muted-foreground mb-1">상태</div> + <div className="flex gap-2"> + <Badge variant="default">{activeCount}개 활성</Badge> + {inactiveCount > 0 && ( + <Badge variant="secondary">{inactiveCount}개 비활성</Badge> + )} + </div> + </div> + </div> + + {/* 분류별 통계 */} + <div> + <div className="font-medium text-muted-foreground mb-2">현재 분류 현황</div> + <div className="flex flex-wrap gap-1"> + {Object.entries(categoryCounts).map(([category, count]) => ( + <Badge key={category} variant="outline" className="text-xs"> + {category}: {count}개 + </Badge> + ))} + </div> + </div> + + {/* 조항 미리보기 (최대 5개) */} + <div> + <div className="font-medium text-muted-foreground mb-2">포함된 조항 (일부)</div> + <div className="space-y-1 max-h-24 overflow-y-auto"> + {selectedClauses.slice(0, 5).map(clause => ( + <div key={clause.id} className="flex items-center gap-2 text-xs"> + <Badge variant="outline">{clause.itemNumber}</Badge> + <span className="truncate">{clause.subtitle}</span> + </div> + ))} + {selectedClauses.length > 5 && ( + <div className="text-xs text-muted-foreground"> + ... 외 {selectedClauses.length - 5}개 조항 + </div> + )} + </div> + </div> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4"> + {/* 분류 수정 */} + <FormField + control={form.control} + name="updates.category" + render={({ field }) => ( + <FormItem> + <FormLabel>분류 변경 (선택사항)</FormLabel> + <FormControl> + <Input + placeholder="새로운 분류명을 입력하세요 (빈칸으로 두면 변경하지 않음)" + {...field} + /> + </FormControl> + <FormDescription> + 모든 선택된 조항의 분류가 동일한 값으로 변경됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 활성 상태 변경 */} + <FormField + control={form.control} + name="updates.isActive" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base">활성 상태</FormLabel> + <FormDescription> + 선택된 모든 조항의 활성 상태를 설정합니다. + </FormDescription> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + + {/* 편집 사유 */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 *</FormLabel> + <FormControl> + <Textarea + placeholder="일괄 수정 사유를 입력하세요..." + {...field} + rows={3} + /> + </FormControl> + <FormDescription> + 일괄 수정의 이유를 명확히 기록해주세요. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={() => props.onOpenChange?.(false)} + disabled={isUpdatePending} + > + Cancel + </Button> + <Button type="submit" disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Update {selectedClauses.length} Clauses + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx b/lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx new file mode 100644 index 00000000..f979f0ea --- /dev/null +++ b/lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx @@ -0,0 +1,570 @@ +"use client" + +import React, { + useState, + useEffect, + useRef, + SetStateAction, + Dispatch, +} from "react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { Loader2 } from "lucide-react" +import { toast } from "sonner" + +import { type GtcClauseTreeView } from "@/db/schema/gtc" + +interface ClausePreviewViewerProps { + clauses: GtcClauseTreeView[] + document: any + instance: WebViewerInstance | null + setInstance: Dispatch<SetStateAction<WebViewerInstance | null>> + onSuccess?: () => void + onError?: () => void +} + +export function ClausePreviewViewer({ + clauses, + document, + instance, + setInstance, + onSuccess, + onError, +}: ClausePreviewViewerProps) { + const [fileLoading, setFileLoading] = useState<boolean>(true) + const [loadingStage, setLoadingStage] = useState<string>("뷰어 준비 중...") + const viewer = useRef<HTMLDivElement>(null) + const initialized = useRef(false) + const isCancelled = useRef(false) + + // WebViewer 초기화 (단계별) + useEffect(() => { + if (!initialized.current && viewer.current) { + initialized.current = true + isCancelled.current = false + + initializeViewerStepByStep() + } + + return () => { + if (instance) { + try { + instance.UI.dispose() + } catch (error) { + console.warn("뷰어 정리 중 오류:", error) + } + } + isCancelled.current = true; + setTimeout(() => cleanupHtmlStyle(), 500); + } + }, []) + + const initializeViewerStepByStep = async () => { + try { + setLoadingStage("라이브러리 로딩 중...") + + // 1단계: 라이브러리 동적 import (지연 추가) + await new Promise(resolve => setTimeout(resolve, 300)) + const { default: WebViewer } = await import("@pdftron/webviewer") + + if (isCancelled.current || !viewer.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + setLoadingStage("뷰어 초기화 중...") + + // 2단계: WebViewer 인스턴스 생성 + const webviewerInstance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + l: "ko", + enableReadOnlyMode: false, + }, + viewer.current + ) + + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + setInstance(webviewerInstance) + setLoadingStage("UI 설정 중...") + + // 3단계: UI 설정 (약간의 지연 후) + await new Promise(resolve => setTimeout(resolve, 500)) + await configureViewerUI(webviewerInstance) + + setLoadingStage("문서 생성 중...") + + // 4단계: 문서 생성 (충분한 지연 후) + await new Promise(resolve => setTimeout(resolve, 800)) + await generateDocumentFromClauses(webviewerInstance, clauses, document) + + } catch (error) { + console.error("❌ WebViewer 단계별 초기화 실패:", error) + setFileLoading(false) + onError?.() // 초기화 실패 콜백 호출 + toast.error(`뷰어 초기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + } + + const configureViewerUI = async (webviewerInstance: WebViewerInstance) => { + try { + const { disableElements, enableElements, setToolbarGroup } = webviewerInstance.UI + + // 미리보기에 필요한 도구만 활성화 + enableElements([ + "toolbarGroup-View", + "zoomInButton", + "zoomOutButton", + "fitButton", + "rotateCounterClockwiseButton", + "rotateClockwiseButton", + ]) + + // 편집 도구는 비활성화 + disableElements([ + "toolbarGroup-Edit", + "toolbarGroup-Insert", + "toolbarGroup-Annotate", + "toolbarGroup-Shapes", + "toolbarGroup-Forms", + ]) + + setToolbarGroup("toolbarGroup-View") + + console.log("✅ UI 설정 완료") + } catch (uiError) { + console.warn("⚠️ UI 설정 중 오류:", uiError) + // UI 설정 실패해도 계속 진행 + } + } + + // 문서 생성 함수 (재시도 로직 포함) + const generateDocumentFromClauses = async ( + webviewerInstance: WebViewerInstance, + clauses: GtcClauseTreeView[], + document: any, + retryCount = 0 + ) => { + const MAX_RETRIES = 3 + + try { + console.log("📄 조항 기반 DOCX 문서 생성 시작:", clauses.length) + + // 활성화된 조항만 필터링하고 정렬 + const activeClauses = clauses + .filter(clause => clause.isActive !== false) + .sort((a, b) => { + if (a.sortOrder && b.sortOrder) { + return parseFloat(a.sortOrder) - parseFloat(b.sortOrder) + } + return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true }) + }) + + if (activeClauses.length === 0) { + throw new Error("활성화된 조항이 없습니다.") + } + + setLoadingStage(`문서 생성 중... (${activeClauses.length}개 조항 처리)`) + + // DOCX 문서 생성 (재시도 로직 포함) + const docxBlob = await generateDocxDocumentWithRetry(activeClauses, document) + + // 파일 생성 + const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + }) + + setLoadingStage("문서 로딩 중...") + + // WebViewer가 완전히 준비된 상태인지 확인 + await waitForViewerReady(webviewerInstance) + + // DOCX 문서 로드 (재시도 포함) + await loadDocumentWithRetry(webviewerInstance, docxFile) + + console.log("✅ DOCX 기반 문서 생성 완료") + toast.success("Word 문서 미리보기가 생성되었습니다.") + setFileLoading(false) + onSuccess?.() // 성공 콜백 호출 + + } catch (err) { + console.error(`❌ DOCX 문서 생성 중 오류 (시도 ${retryCount + 1}/${MAX_RETRIES + 1}):`, err) + + if (retryCount < MAX_RETRIES) { + console.log(`🔄 ${(retryCount + 1) * 1000}ms 후 재시도...`) + setLoadingStage(`재시도 중... (${retryCount + 1}/${MAX_RETRIES})`) + + await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000)) + + if (!isCancelled.current) { + return generateDocumentFromClauses(webviewerInstance, clauses, document, retryCount + 1) + } + } else { + setFileLoading(false) + onError?.() // 실패 콜백 호출 + toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) + } + } + } + + // WebViewer 준비 상태 확인 + const waitForViewerReady = async (webviewerInstance: WebViewerInstance, timeout = 5000) => { + const startTime = Date.now() + + while (Date.now() - startTime < timeout) { + try { + // UI가 준비되었는지 확인 + if (webviewerInstance.UI && webviewerInstance.Core) { + console.log("✅ WebViewer 준비 완료") + return + } + } catch (error) { + // 아직 준비되지 않음 + } + + await new Promise(resolve => setTimeout(resolve, 100)) + } + + throw new Error("WebViewer 준비 시간 초과") + } + + // 문서 로드 재시도 함수 + const loadDocumentWithRetry = async ( + webviewerInstance: WebViewerInstance, + file: File, + retryCount = 0 + ) => { + const MAX_LOAD_RETRIES = 2 + + try { + await webviewerInstance.UI.loadDocument(file, { + filename: file.name, + enableOfficeEditing: true, + }) + console.log("✅ 문서 로드 성공") + } catch (error) { + console.error(`문서 로드 실패 (시도 ${retryCount + 1}):`, error) + + if (retryCount < MAX_LOAD_RETRIES) { + await new Promise(resolve => setTimeout(resolve, 1000)) + return loadDocumentWithRetry(webviewerInstance, file, retryCount + 1) + } else { + throw new Error(`문서 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + } + } + + // DOCX 생성 재시도 함수 + const generateDocxDocumentWithRetry = async ( + clauses: GtcClauseTreeView[], + document: any, + retryCount = 0 + ): Promise<Blob> => { + try { + return await generateDocxDocument(clauses, document) + } catch (error) { + console.error(`DOCX 생성 실패 (시도 ${retryCount + 1}):`, error) + + if (retryCount < 2) { + await new Promise(resolve => setTimeout(resolve, 500)) + return generateDocxDocumentWithRetry(clauses, document, retryCount + 1) + } else { + throw error + } + } + } + + return ( + <div className="relative w-full h-full overflow-hidden"> + <div + ref={viewer} + className="w-full h-full" + style={{ + position: 'relative', + overflow: 'hidden', + contain: 'layout style paint', + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-90 z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">{loadingStage}</p> + <p className="text-xs text-muted-foreground mt-1"> + {clauses.filter(c => c.isActive !== false).length}개 조항 처리 중 + </p> + <div className="mt-3 text-xs text-gray-400"> + 초기화에 시간이 걸릴 수 있습니다... + </div> + </div> + )} + </div> + </div> + ) +} + +// ===== 유틸리티 함수들 ===== + +// data URL 판별 및 디코딩 유틸 +function isDataUrl(url: string) { + return /^data:/.test(url); +} + +function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } { + // 형식: data:<mime>;base64,<payload> + const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/); + if (!match) { + // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원 + throw new Error("지원하지 않는 data URL 형식입니다."); + } + const mime = match[1]; + const base64 = match[2]; + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); + return { bytes, mime }; +} + +// 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원) +async function fetchImageData(url: string, maxWidthPx = 500) { + let blob: Blob; + let bytes: Uint8Array; + + if (isDataUrl(url)) { + // data URL → Uint8Array, Blob + const { bytes: arr, mime } = dataUrlToUint8Array(url); + bytes = arr; + blob = new Blob([bytes], { type: mime }); + } else { + // http(s) 또는 상대 경로 + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`); + blob = await res.blob(); + const arrayBuffer = await blob.arrayBuffer(); + bytes = new Uint8Array(arrayBuffer); + } + + // 원본 크기 파악 (공통) + const dims = await new Promise<{ width: number; height: number }>((resolve) => { + const img = new Image(); + const objectUrl = URL.createObjectURL(blob); + img.onload = () => { + const width = img.naturalWidth || 800; + const height = img.naturalHeight || 600; + URL.revokeObjectURL(objectUrl); + resolve({ width, height }); + }; + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + resolve({ width: 800, height: 600 }); // 실패 시 기본값 + }; + img.src = objectUrl; + }); + + // 비율 유지 축소 + const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx)); + const width = Math.round((dims.width || maxWidthPx) * scale); + const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale); + + return { data: bytes, width, height }; +} + +// DOCX 문서 생성 (docx 라이브러리 사용) +async function generateDocxDocument( + clauses: GtcClauseTreeView[], + document: any +): Promise<Blob> { + const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx"); + + function textToParagraphs(text: string, indentLeft: number) { + const lines = text.split("\n"); + return [ + new Paragraph({ + children: lines + .map((line, i) => [ + new TextRun({ text: line }), + ...(i < lines.length - 1 ? [new TextRun({ break: 1 })] : []), + ]) + .flat(), + indent: { left: indentLeft }, + }), + ]; + } + + const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087] + + async function pushContentWithInlineImages( + content: string, + indentLeft: number, + children: any[], + imageMap: Map<string, any> + ) { + let lastIndex = 0; + for (const match of content.matchAll(IMG_TOKEN)) { + const start = match.index ?? 0; + const end = start + match[0].length; + const imageId = match[1]; + + // 앞부분 텍스트 + if (start > lastIndex) { + const txt = content.slice(lastIndex, start); + children.push(...textToParagraphs(txt, indentLeft)); + } + + // 이미지 삽입 + const imgMeta = imageMap.get(imageId); + if (imgMeta?.url) { + try { + const { data, width, height } = await fetchImageData(imgMeta.url, 520); + children.push( + new Paragraph({ + children: [ + new ImageRun({ + data, + transformation: { width, height }, + }), + ], + indent: { left: indentLeft }, + }) + ); + // 사용된 이미지 표시(뒤에서 중복 추가 방지) + imageMap.delete(imageId); + } catch (imgError) { + console.warn("이미지 로드 실패:", imgMeta, imgError); + // 이미지 로드 실패시 텍스트로 대체 + children.push( + new Paragraph({ + children: [new TextRun({ text: `[이미지 로드 실패: ${imgMeta.fileName || imageId}]`, color: "999999" })], + indent: { left: indentLeft }, + }) + ); + } + } + // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) + + lastIndex = end; + } + + // 남은 꼬리 텍스트 + if (lastIndex < content.length) { + const tail = content.slice(lastIndex); + children.push(...textToParagraphs(tail, indentLeft)); + } + } + + const documentTitle = document?.title || "GTC 계약서"; + const currentDate = new Date().toLocaleDateString("ko-KR"); + + // depth 추정/정렬 + const structuredClauses = organizeClausesByHierarchy(clauses); + + const children: any[] = [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: documentTitle, bold: true, size: 32 })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })], + }), + new Paragraph({ text: "" }), + new Paragraph({ text: "" }), + ]; + + for (const clause of structuredClauses) { + const depth = Math.min(clause.estimatedDepth || 0, 3); + const indentLeft = depth * 400; // 번호/제목 + const indentContent = indentLeft + 200; // 본문/이미지 + + // 번호 + 제목 + children.push( + new Paragraph({ + children: [ + new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }), + ...(clause.subtitle + ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })] + : []), + ], + indent: { left: indentLeft }, + }) + ); + + const imageMap = new Map( + Array.isArray((clause as any).images) + ? (clause as any).images.map((im: any) => [String(im.id), im]) + : [] + ); + + // 내용 + const hasContent = clause.content && clause.content.trim(); + if (hasContent) { + await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap); + } + + // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) + for (const [, imgMeta] of imageMap) { + try { + const { data, width, height } = await fetchImageData(imgMeta.url, 520); + children.push( + new Paragraph({ + children: [new ImageRun({ data, transformation: { width, height } })], + indent: { left: indentContent }, + }) + ); + } catch (e) { + children.push( + new Paragraph({ + children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })], + indent: { left: indentContent }, + }) + ); + console.warn("이미지 로드 실패(잔여):", imgMeta, e); + } + } + + // 조항 간 간격 + children.push(new Paragraph({ text: "" })); + } + + const doc = new Document({ + sections: [{ properties: {}, children }], + }); + + return await Packer.toBlob(doc); +} + +// 조항들을 계층구조로 정리 +function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) { + // depth가 없는 경우 itemNumber로 depth 추정 + return clauses.map(clause => ({ + ...clause, + estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber) + })).sort((a, b) => { + // itemNumber 기준 자연 정렬 + return a.itemNumber.localeCompare(b.itemNumber, undefined, { + numeric: true, + sensitivity: 'base' + }) + }) +} + +// itemNumber로부터 depth 추정 +function estimateDepthFromItemNumber(itemNumber: string): number { + const parts = itemNumber.split('.') + return Math.max(0, parts.length - 1) +} + +// WebViewer 정리 함수 +const cleanupHtmlStyle = () => { + // iframe 스타일 정리 (WebViewer가 추가한 스타일) + const elements = document.querySelectorAll('.Document_container'); + elements.forEach((elem) => { + elem.remove(); + }); +};
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/clause-table.tsx b/lib/basic-contract/gtc-vendor/clause-table.tsx new file mode 100644 index 00000000..a9230cd4 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/clause-table.tsx @@ -0,0 +1,261 @@ +"use client" + +import * as React from "react" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import type { + getGtcClauses, + getUsersForFilter, + getVendorClausesForDocument +} from "@/lib/gtc-contract/gtc-clauses/service" +import { getColumns } from "./gtc-clauses-table-columns" +import { GtcClausesTableToolbarActions } from "./gtc-clauses-table-toolbar-actions" +import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog" +import { UpdateGtcClauseSheet } from "./update-gtc-clause-sheet" +import { CreateVendorGtcClauseDialog } from "./create-gtc-clause-dialog" +import { DuplicateGtcClauseDialog } from "./duplicate-gtc-clause-dialog" + +interface GtcClausesTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getGtcClauses>>, + Awaited<ReturnType<typeof getUsersForFilter>>, + Awaited<ReturnType<typeof getVendorClausesForDocument>>, + ] + > + , + documentId: number + document: any + vendorId?: number + vendorName?: string +} + +export function GtcClausesVendorTable({ + promises, + documentId, + document, + vendorId, + vendorName +}: GtcClausesTableProps) { + const [{ data, pageCount }, users, vendorData] = React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<GtcClauseTreeView> | null>(null) + + const dataWithVendor = React.useMemo(() => { + if (!vendorData?.vendorClauseMap) return data + + return data.map(clause => { + const vendorClause = vendorData.vendorClauseMap.get(clause.id) + + return { + ...clause, + vendorInfo: vendorClause || null, + } + }) + }, [data, vendorData]) + + + console.log(dataWithVendor,"dataWithVendor") + console.log(vendorData,"vendorData") + + const columns = React.useMemo( + () => getColumns({ + setRowAction, + documentId, + hasVendorInfo: !!vendorId && !!vendorData?.vendorDocument + }), + [setRowAction, documentId, vendorData] + ) + /** + * Filter fields for the data table. + */ + const filterFields: DataTableFilterField<GtcClauseTreeView>[] = [ + { + id: "itemNumber", + label: "채번", + placeholder: "채번으로 검색...", + }, + { + id: "subtitle", + label: "소제목", + placeholder: "소제목으로 검색...", + }, + { + id: "content", + label: "상세항목", + placeholder: "상세항목으로 검색...", + }, + ] + + /** + * Advanced filter fields for the data table. + */ + const advancedFilterFields: DataTableAdvancedFilterField<GtcClauseTreeView>[] = [ + { + id: "itemNumber", + label: "채번", + type: "text", + }, + { + id: "category", + label: "분류", + type: "text", + }, + { + id: "subtitle", + label: "소제목", + type: "text", + }, + { + id: "content", + label: "상세항목", + type: "text", + }, + { + id: "depth", + label: "계층 깊이", + type: "multi-select", + options: [ + { label: "1단계", value: "0" }, + { label: "2단계", value: "1" }, + { label: "3단계", value: "2" }, + { label: "4단계", value: "3" }, + { label: "5단계", value: "4" }, + ], + }, + { + id: "createdByName", + label: "작성자", + type: "multi-select", + options: users.map((user) => ({ + label: user.name, + value: user.name, + })), + }, + { + id: "updatedByName", + label: "수정자", + type: "multi-select", + options: users.map((user) => ({ + label: user.name, + value: user.name, + })), + }, + { + id: "createdAt", + label: "작성일", + type: "date", + }, + { + id: "updatedAt", + label: "수정일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data: dataWithVendor, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ "id": "itemNumber", "desc": false }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.id}`, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + // floatingBar={<GtcClausesTableFloatingBar table={table} />} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <GtcClausesTableToolbarActions + table={table} + documentId={documentId} + document={document} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 삭제 다이얼로그 */} + <DeleteGtcClausesDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + gtcClauses={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + {/* 수정 시트 */} + <UpdateGtcClauseSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + gtcClause={rowAction?.row.original ?? null} + vendorInfo={rowAction?.row.original?.vendorInfo ?? null} + documentId={documentId} + vendorId={vendorId} + vendorName={vendorName} + /> + + {/* 생성 다이얼로그 */} + {/* <CreateVendorGtcClauseDialog + key="main-create" + documentId={documentId} + document={document} + /> */} + + {/* 하위 조항 추가 다이얼로그 */} + <CreateVendorGtcClauseDialog + key={`sub-create-${rowAction?.row.original.id || 'none'}`} + documentId={documentId} + document={document} + parentClause={rowAction?.type === "addSubClause" ? rowAction.row.original : null} + open={rowAction?.type === "addSubClause"} + onOpenChange={(open) => { + if (!open) { + setRowAction(null) + } + }} + showTrigger={false} + onSuccess={() => { + setRowAction(null) + // 테이블 리프레시 로직 + }} + /> + + {/* 조항 복제 다이얼로그 */} + <DuplicateGtcClauseDialog + open={rowAction?.type === "duplicate"} + onOpenChange={() => setRowAction(null)} + sourceClause={rowAction?.row.original ?? null} + onSuccess={() => { + // 테이블 리프레시 로직 + }} + /> + + + </> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/clause-variable-settings-dialog.tsx b/lib/basic-contract/gtc-vendor/clause-variable-settings-dialog.tsx new file mode 100644 index 00000000..36d47403 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/clause-variable-settings-dialog.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Loader, Settings2, Wand2, Copy, Eye } from "lucide-react" +import { toast } from "sonner" +import { cn } from "@/lib/utils" + +import { updateGtcClauseSchema, type UpdateGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { updateGtcClause } from "@/lib/gtc-contract/gtc-clauses/service" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" + +interface ClauseVariableSettingsDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + clause: GtcClauseTreeView | null + onSuccess?: () => void +} + +export function ClauseVariableSettingsDialog({ + clause, + onSuccess, + ...props +}: ClauseVariableSettingsDialogProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [showPreview, setShowPreview] = React.useState(false) + const { data: session } = useSession() + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + const form = useForm<UpdateGtcClauseSchema>({ + resolver: zodResolver(updateGtcClauseSchema), + defaultValues: { + numberVariableName: "", + subtitleVariableName: "", + contentVariableName: "", + editReason: "", + }, + }) + + // clause가 변경될 때 폼 데이터 설정 + React.useEffect(() => { + if (clause) { + form.reset({ + numberVariableName: clause.numberVariableName || "", + subtitleVariableName: clause.subtitleVariableName || "", + contentVariableName: clause.contentVariableName || "", + editReason: "", + }) + } + }, [clause, form]) + + const generateAutoVariableNames = () => { + if (!clause) return + + const fullPath = clause.fullPath || clause.itemNumber + + console.log(clause.fullPath,fullPath,"fullPath") + console.log(clause, "clause") + + const prefix = "CLAUSE_" + fullPath.replace(/\./g, "_") + + form.setValue("numberVariableName", `${prefix}_NUMBER`) + form.setValue("subtitleVariableName", `${prefix}_SUBTITLE`) + form.setValue("contentVariableName", `${prefix}_CONTENT`) + + toast.success("변수명이 자동 생성되었습니다.") + } + + const copyCurrentVariableNames = () => { + if (!clause) return + + const currentVars = { + number: clause.autoNumberVariable, + subtitle: clause.autoSubtitleVariable, + content: clause.autoContentVariable, + } + + form.setValue("numberVariableName", currentVars.number) + form.setValue("subtitleVariableName", currentVars.subtitle) + form.setValue("contentVariableName", currentVars.content) + + toast.success("현재 변수명이 복사되었습니다.") + } + + async function onSubmit(data: UpdateGtcClauseSchema) { + startUpdateTransition(async () => { + if (!clause || !currentUserId) { + toast.error("조항 정보를 찾을 수 없습니다.") + return + } + + try { + const result = await updateGtcClause(clause.id, { + numberVariableName: data.numberVariableName, + subtitleVariableName: data.subtitleVariableName, + contentVariableName: data.contentVariableName, + editReason: data.editReason || "PDFTron 변수명 설정", + updatedById: currentUserId, + }) + + if (result.error) { + toast.error(result.error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("변수명이 설정되었습니다!") + onSuccess?.() + } catch (error) { + toast.error("변수명 설정 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setShowPreview(false) + } + props.onOpenChange?.(nextOpen) + } + + const currentNumberVar = form.watch("numberVariableName") + const currentSubtitleVar = form.watch("subtitleVariableName") + const currentContentVar = form.watch("contentVariableName") + + const hasAllVariables = currentNumberVar && currentSubtitleVar && currentContentVar + + if (!clause) { + return null + } + + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="flex items-center gap-2"> + <Settings2 className="h-5 w-5" /> + PDFTron 변수명 설정 + </DialogTitle> + <DialogDescription> + 조항의 PDFTron 변수명을 설정하여 문서 생성에 사용합니다. + </DialogDescription> + </DialogHeader> + + {/* 조항 정보 */} + <div className="p-3 bg-muted/50 rounded-lg text-sm flex-shrink-0"> + <div className="font-medium mb-2">대상 조항</div> + <div className="space-y-1 text-muted-foreground"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{clause.itemNumber}</Badge> + <span>{clause.subtitle}</span> + <Badge variant={clause.hasAllVariableNames ? "default" : "destructive"}> + {clause.hasAllVariableNames ? "설정됨" : "미설정"} + </Badge> + </div> + {clause.fullPath && ( + <div className="text-xs">경로: {clause.fullPath}</div> + )} + {clause.category && ( + <div className="text-xs">분류: {clause.category}</div> + )} + </div> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + <div className="flex-1 overflow-y-auto px-1"> + <div className="space-y-4 py-4"> + {/* 자동 생성 버튼들 */} + <div className="flex gap-2"> + <Button + type="button" + variant="outline" + size="sm" + onClick={generateAutoVariableNames} + className="flex items-center gap-2" + > + <Wand2 className="h-4 w-4" /> + 자동 생성 + </Button> + + <Button + type="button" + variant="outline" + size="sm" + onClick={copyCurrentVariableNames} + className="flex items-center gap-2" + > + <Copy className="h-4 w-4" /> + 현재값 복사 + </Button> + + <Button + type="button" + variant="outline" + size="sm" + onClick={() => setShowPreview(!showPreview)} + className="flex items-center gap-2" + > + <Eye className="h-4 w-4" /> + {showPreview ? "미리보기 숨기기" : "미리보기"} + </Button> + </div> + + {/* 현재 설정된 변수명 표시 */} + {clause.hasAllVariableNames && ( + <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg"> + <div className="text-sm font-medium text-blue-900 mb-2">현재 설정된 변수명</div> + <div className="space-y-1 text-xs"> + <div><code className="bg-blue-100 px-1 rounded">{clause.numberVariableName}</code></div> + <div><code className="bg-blue-100 px-1 rounded">{clause.subtitleVariableName}</code></div> + <div><code className="bg-blue-100 px-1 rounded">{clause.contentVariableName}</code></div> + </div> + </div> + )} + + {/* 변수명 입력 필드들 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="numberVariableName" + render={({ field }) => ( + <FormItem> + <FormLabel>채번 변수명 *</FormLabel> + <FormControl> + <Input + placeholder="예: CLAUSE_1_NUMBER, HEADER_1_NUM 등" + {...field} + /> + </FormControl> + <FormDescription> + 문서에서 조항 번호를 표시할 변수명입니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="subtitleVariableName" + render={({ field }) => ( + <FormItem> + <FormLabel>소제목 변수명 *</FormLabel> + <FormControl> + <Input + placeholder="예: CLAUSE_1_SUBTITLE, HEADER_1_TITLE 등" + {...field} + /> + </FormControl> + <FormDescription> + 문서에서 조항 제목을 표시할 변수명입니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contentVariableName" + render={({ field }) => ( + <FormItem> + <FormLabel>상세항목 변수명 *</FormLabel> + <FormControl> + <Input + placeholder="예: CLAUSE_1_CONTENT, BODY_1_TEXT 등" + {...field} + /> + </FormControl> + <FormDescription> + 문서에서 조항 내용을 표시할 변수명입니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 미리보기 */} + {showPreview && hasAllVariables && ( + <div className="p-3 bg-gray-50 border rounded-lg"> + <div className="text-sm font-medium mb-2">PDFTron 템플릿 미리보기</div> + <div className="space-y-2 text-xs font-mono bg-white p-2 rounded border"> + <div className="text-blue-600">{"{{" + currentNumberVar + "}}"}. {"{{" + currentSubtitleVar + "}}"}</div> + <div className="text-gray-600 ml-4">{"{{" + currentContentVar + "}}"}</div> + </div> + <div className="text-xs text-muted-foreground mt-2"> + 실제 문서에서 위와 같은 형태로 표시됩니다. + </div> + </div> + )} + + {/* 편집 사유 */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="변수명 설정 사유를 입력하세요..." + {...field} + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4"> + <Button + type="button" + variant="outline" + onClick={() => props.onOpenChange?.(false)} + disabled={isUpdatePending} + > + Cancel + </Button> + <Button + type="submit" + disabled={isUpdatePending || !hasAllVariables} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + <Settings2 className="mr-2 h-4 w-4" /> + Save Variables + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx b/lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx new file mode 100644 index 00000000..3c98ee4d --- /dev/null +++ b/lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx @@ -0,0 +1,625 @@ +// create-vendor-gtc-clause-dialog.tsx +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Check, ChevronsUpDown, Loader, Plus, Info } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +import { createVendorGtcClauseSchema, type CreateVendorGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { createVendorGtcClause, getGtcClausesTree } from "@/lib/gtc-contract/gtc-clauses/service" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" +import { MarkdownImageEditor } from "./markdown-image-editor" + +interface ClauseImage { + id: string + url: string + fileName: string + size: number +} + +interface CreateVendorGtcClauseDialogProps { + documentId: number + document: any + vendorDocumentId: number + vendorId: number + vendorName?: string + baseClauseId?: number // Optional - if creating a new vendor clause from scratch + baseClause?: GtcClauseTreeView | null // Original clause to modify, if available + parentClause?: GtcClauseTreeView | null + onSuccess?: () => void + open?: boolean + onOpenChange?: (open: boolean) => void + showTrigger?: boolean +} + +export function CreateVendorGtcClauseDialog({ + documentId, + document, + vendorDocumentId, + vendorId, + vendorName, + baseClauseId, + baseClause, + parentClause = null, + onSuccess, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + showTrigger = true +}: CreateVendorGtcClauseDialogProps) { + const [internalOpen, setInternalOpen] = React.useState(false) + + // controlled vs uncontrolled 모드 + const isControlled = controlledOpen !== undefined + const open = isControlled ? controlledOpen : internalOpen + const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen + const [parentClauses, setParentClauses] = React.useState<GtcClauseTreeView[]>([]) + const [isCreatePending, startCreateTransition] = React.useTransition() + const { data: session } = useSession() + const [images, setImages] = React.useState<ClauseImage[]>([]) + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + React.useEffect(() => { + if (open) { + loadParentClauses() + } + }, [open, documentId]) + + const loadParentClauses = async () => { + try { + const tree = await getGtcClausesTree(documentId) + setParentClauses(flattenTree(tree)) + } catch (error) { + console.error("Error loading parent clauses:", error) + } + } + + const form = useForm<CreateVendorGtcClauseSchema>({ + resolver: zodResolver(createVendorGtcClauseSchema), + defaultValues: { + documentId, + vendorDocumentId, + baseClauseId: baseClauseId || null, + parentId: parentClause?.id || null, + modifiedItemNumber: baseClause?.itemNumber || "", + modifiedCategory: baseClause?.category || "", + modifiedSubtitle: baseClause?.subtitle || "", + modifiedContent: baseClause?.content || "", + isNumberModified: false, + isCategoryModified: false, + isSubtitleModified: false, + isContentModified: false, + sortOrder: 0, + reviewStatus: "draft", + negotiationNote: "", + isExcluded: false, + editReason: "", + }, + }) + + const handleContentImageChange = (content: string, newImages: ClauseImage[]) => { + form.setValue("modifiedContent", content) + setImages(newImages) + } + + async function onSubmit(data: CreateVendorGtcClauseSchema) { + startCreateTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + try { + // 사용자 정보 추가 + const result = await createVendorGtcClause({ + ...data, + images: images, + createdById: currentUserId, + vendorId, // 벤더 ID 추가 + actorName: session?.user?.name || null, + actorEmail: session?.user?.email || null, + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + setImages([]) + setOpen(false) + toast.success("벤더 GTC 조항이 생성되었습니다.") + onSuccess?.() + } catch (error) { + toast.error("벤더 조항 생성 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setImages([]) + } + setOpen(nextOpen) + } + + const selectedParent = parentClauses.find(c => c.id === form.watch("parentId")) + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {showTrigger && ( + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + {baseClause + ? "조항 협의 생성" + : parentClause + ? "하위 조항 추가" + : "조항 추가"} + </Button> + </DialogTrigger> + )} + + <DialogContent className="max-w-4xl h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle> + {baseClause + ? `${baseClause.subtitle} 조항 협의 생성` + : parentClause + ? "하위 벤더 조항 생성" + : "새 벤더 조항 생성"} + </DialogTitle> + <DialogDescription> + {vendorName + ? `${vendorName}과(와)의 GTC 조항 협의 내용을 입력하세요.` + : '벤더별 GTC 조항 정보를 입력하세요.'} + </DialogDescription> + </DialogHeader> + + {/* 기존 조항 정보 (있는 경우) */} + {baseClause && ( + <div className="p-3 bg-muted/50 rounded-lg text-sm flex-shrink-0 mb-4"> + <div className="font-medium mb-1">원본 조항 정보</div> + <div className="text-muted-foreground space-y-1"> + <div>채번: {baseClause.itemNumber}</div> + {baseClause.category && <div>분류: {baseClause.category}</div>} + <div>소제목: {baseClause.subtitle}</div> + {baseClause.content && <div>내용: {baseClause.content.substring(0, 80)}...</div>} + </div> + </div> + )} + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + <div className="flex-1 overflow-y-auto px-1"> + <div className="space-y-4 py-4"> + {/* 협의 상태 */} + <FormField + control={form.control} + name="reviewStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>협의 상태</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="협의 상태를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="draft">초안</SelectItem> + <SelectItem value="pending">협의 대기</SelectItem> + <SelectItem value="reviewing">협의 중</SelectItem> + <SelectItem value="approved">승인됨</SelectItem> + <SelectItem value="rejected">거부됨</SelectItem> + <SelectItem value="revised">수정됨</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 제외 여부 */} + <FormField + control={form.control} + name="isExcluded" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel> + 이 조항을 벤더 계약에서 제외 + </FormLabel> + <FormDescription> + 체크하면 최종 계약서에서 이 조항이 제외됩니다 + </FormDescription> + </div> + </FormItem> + )} + /> + + {/* 부모 조항 선택 (벤더 조항에만 필요한 경우) */} + {!baseClause && ( + <FormField + control={form.control} + name="parentId" + render={({ field }) => { + const [popoverOpen, setPopoverOpen] = React.useState(false) + + return ( + <FormItem> + <FormLabel>부모 조항 (선택사항)</FormLabel> + <FormControl> + <Popover + open={popoverOpen} + onOpenChange={setPopoverOpen} + modal={true} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {selectedParent + ? `${selectedParent.itemNumber} - ${selectedParent.subtitle}` + : "부모 조항을 선택하세요... (최상위 조항인 경우 선택 안함)"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="부모 조항 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty>조항을 찾을 수 없습니다.</CommandEmpty> + <CommandGroup> + <CommandItem + value="none" + onSelect={() => { + field.onChange(null) + setPopoverOpen(false) + }} + > + 최상위 조항 + <Check + className={cn( + "ml-auto h-4 w-4", + !field.value ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + + {parentClauses.map((clause) => ( + <CommandItem + key={clause.id} + value={`${clause.itemNumber} - ${clause.subtitle}`} + onSelect={() => { + field.onChange(clause.id) + setPopoverOpen(false) + }} + > + <div className="flex items-center w-full"> + <span style={{ marginLeft: `${clause.depth * 12}px` }}> + {`${clause.itemNumber} - ${clause.subtitle}`} + </span> + </div> + <Check + className={cn( + "ml-auto h-4 w-4", + selectedParent?.id === clause.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + }} + /> + )} + + {/* 채번 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isNumberModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 채번 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isNumberModified") && ( + <FormField + control={form.control} + name="modifiedItemNumber" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 채번 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 분류 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isCategoryModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 분류 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isCategoryModified") && ( + <FormField + control={form.control} + name="modifiedCategory" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 분류 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 소제목 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isSubtitleModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 소제목 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isSubtitleModified") && ( + <FormField + control={form.control} + name="modifiedSubtitle" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 소제목 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 내용 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isContentModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 상세항목 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isContentModified") && ( + <FormField + control={form.control} + name="modifiedContent" + render={({ field }) => ( + <FormItem> + <FormControl> + <MarkdownImageEditor + content={field.value || ""} + images={images} + onChange={handleContentImageChange} + placeholder="수정할 내용을 입력하세요..." + rows={6} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 협의 노트 */} + <FormField + control={form.control} + name="negotiationNote" + render={({ field }) => ( + <FormItem> + <FormLabel>협의 메모</FormLabel> + <FormControl> + <Textarea + placeholder="협의 과정에서의 메모나 특이사항을 기록하세요..." + {...field} + rows={3} + /> + </FormControl> + <FormDescription> + 벤더와의 협의 내용이나 변경 사유를 기록합니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 편집 사유 */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="조항 생성 사유를 입력하세요..." + {...field} + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4"> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isCreatePending} + > + 취소 + </Button> + <Button type="submit" disabled={isCreatePending}> + {isCreatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 생성 + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} + +// 트리를 평면 배열로 변환하는 유틸리티 함수 +function flattenTree(tree: any[]): any[] { + const result: any[] = [] + + function traverse(nodes: any[]) { + for (const node of nodes) { + result.push(node) + if (node.children && node.children.length > 0) { + traverse(node.children) + } + } + } + + traverse(tree) + return result +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/delete-gtc-clauses-dialog.tsx b/lib/basic-contract/gtc-vendor/delete-gtc-clauses-dialog.tsx new file mode 100644 index 00000000..885a78e0 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/delete-gtc-clauses-dialog.tsx @@ -0,0 +1,175 @@ +"use client" + +import * as React from "react" +import { Loader, Trash2, AlertTriangle } from "lucide-react" +import { toast } from "sonner" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" + +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" +// import { deleteGtcClauses } from "../service" + +interface DeleteGtcClausesDialogProps + extends React.ComponentPropsWithRef<typeof AlertDialog> { + gtcClauses: GtcClauseTreeView[] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteGtcClausesDialog({ + gtcClauses, + showTrigger = true, + onSuccess, + ...props +}: DeleteGtcClausesDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const { data: session } = useSession() + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + function onDelete() { + startDeleteTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + try { + // ✅ 한 번에 모든 조항 삭제 (배열로 전달) + const ids = gtcClauses.map(clause => clause.id) + const result = await deleteGtcClauses(ids) + + if (result.error) { + toast.error(`삭제 중 오류가 발생했습니다: ${result.error}`) + return + } + + props.onOpenChange?.(false) + toast.success( + `${result.deletedCount}개의 조항이 삭제되었습니다.` + ) + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("조항 삭제 중 오류가 발생했습니다.") + } + }) + } + + const clausesWithChildren = gtcClauses.filter(clause => clause.childrenCount > 0) + const hasChildrenWarning = clausesWithChildren.length > 0 + + // 총 삭제될 하위 조항 수 계산 + const totalChildrenCount = clausesWithChildren.reduce((sum, clause) => sum + clause.childrenCount, 0) + + return ( + <AlertDialog {...props}> + {showTrigger && ( + <AlertDialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 ({gtcClauses.length}) + </Button> + </AlertDialogTrigger> + )} + + <AlertDialogContent className="max-w-md"> + <AlertDialogHeader> + <div className="flex items-center gap-2"> + <AlertTriangle className="h-5 w-5 text-destructive" /> + <AlertDialogTitle>조항 삭제</AlertDialogTitle> + </div> + <AlertDialogDescription asChild> + <div className="space-y-3"> + <p> + 선택한 {gtcClauses.length}개의 조항을 <strong>완전히 삭제</strong>하시겠습니까? + 이 작업은 되돌릴 수 없습니다. + </p> + + {/* 삭제할 조항 목록 */} + <div className="space-y-2"> + <div className="text-sm font-medium">삭제할 조항:</div> + <div className="max-h-32 overflow-y-auto space-y-1"> + {gtcClauses.map((clause) => ( + <div key={clause.id} className="flex items-center gap-2 text-sm p-2 bg-muted/50 rounded"> + <Badge variant="outline" className="text-xs"> + {clause.itemNumber} + </Badge> + <span className="flex-1 truncate">{clause.subtitle}</span> + {clause.childrenCount > 0 && ( + <Badge variant="destructive" className="text-xs"> + 하위 {clause.childrenCount}개 + </Badge> + )} + </div> + ))} + </div> + </div> + + {/* 하위 조항 경고 */} + {hasChildrenWarning && ( + <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md"> + <div className="flex items-center gap-2 text-destructive text-sm font-medium mb-2"> + <AlertTriangle className="h-4 w-4" /> + 중요: 하위 조항 포함 삭제 + </div> + <div className="space-y-1 text-sm text-destructive/80"> + <p>하위 조항이 있는 조항을 삭제하면 모든 하위 조항도 함께 삭제됩니다.</p> + <p className="font-medium"> + 총 삭제될 조항: {gtcClauses.length + totalChildrenCount}개 + <span className="text-xs ml-1"> + (선택한 {gtcClauses.length}개 + 하위 {totalChildrenCount}개) + </span> + </p> + </div> + <div className="mt-2 text-xs text-destructive/70"> + 영향받는 조항: {clausesWithChildren.map(c => c.itemNumber).join(', ')} + </div> + </div> + )} + + <div className="p-2 bg-amber-50 border border-amber-200 rounded text-xs text-amber-800"> + ⚠️ <strong>실제 삭제</strong>: 데이터베이스에서 완전히 제거되며 복구할 수 없습니다. + </div> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + + <AlertDialogFooter> + <AlertDialogCancel disabled={isDeletePending}> + Cancel + </AlertDialogCancel> + <AlertDialogAction + onClick={onDelete} + disabled={isDeletePending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + <Trash2 className="mr-2 h-4 w-4" /> + Delete Permanently + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/duplicate-gtc-clause-dialog.tsx b/lib/basic-contract/gtc-vendor/duplicate-gtc-clause-dialog.tsx new file mode 100644 index 00000000..cb5ac81d --- /dev/null +++ b/lib/basic-contract/gtc-vendor/duplicate-gtc-clause-dialog.tsx @@ -0,0 +1,372 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Loader, Copy, Info } from "lucide-react" +import { toast } from "sonner" + +import { createGtcClauseSchema, type CreateGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { createGtcClause } from "@/lib/gtc-contract/gtc-clauses/service" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" + +interface DuplicateGtcClauseDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + sourceClause: GtcClauseTreeView | null + onSuccess?: () => void +} + +export function DuplicateGtcClauseDialog({ + sourceClause, + onSuccess, + ...props +}: DuplicateGtcClauseDialogProps) { + const [isCreatePending, startCreateTransition] = React.useTransition() + const { data: session } = useSession() + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + + const form = useForm<CreateGtcClauseSchema>({ + resolver: zodResolver(createGtcClauseSchema), + defaultValues: { + documentId: 0, + parentId: null, + itemNumber: "", + category: "", + subtitle: "", + content: "", + sortOrder: 0, + // numberVariableName: "", + // subtitleVariableName: "", + // contentVariableName: "", + editReason: "", + }, + }) + + // sourceClause가 변경될 때 폼 데이터 설정 + React.useEffect(() => { + if (sourceClause) { + // 새로운 채번 생성 (원본에 "_copy" 추가) + const newItemNumber = `${sourceClause.itemNumber}_copy` + + form.reset({ + documentId: sourceClause.documentId, + parentId: sourceClause.parentId, + itemNumber: newItemNumber, + category: sourceClause.category || "", + subtitle: `${sourceClause.subtitle} (복제)`, + content: sourceClause.content || "", + sortOrder:parseFloat(sourceClause.sortOrder) + 0.1, // 원본 바로 다음에 위치 + // numberVariableName: "", + // subtitleVariableName: "", + // contentVariableName: "", + editReason: `조항 복제 (원본: ${sourceClause.itemNumber})`, + }) + + // 자동 변수명 생성 + // generateVariableNames(newItemNumber, sourceClause.parentId) + } + }, [sourceClause, form]) + + // const generateVariableNames = (itemNumber: string, parentId: number | null) => { + // if (!sourceClause) return + + // let fullPath = itemNumber + + // if (parentId && sourceClause.fullPath) { + // const parentPath = sourceClause.fullPath.split('.').slice(0, -1).join('.') + // if (parentPath) { + // fullPath = `${parentPath}.${itemNumber}` + // } + // } + + // const prefix = "CLAUSE_" + fullPath.replace(/\./g, "_") + + // form.setValue("numberVariableName", `${prefix}_NUMBER`) + // form.setValue("subtitleVariableName", `${prefix}_SUBTITLE`) + // form.setValue("contentVariableName", `${prefix}_CONTENT`) + // } + + async function onSubmit(data: CreateGtcClauseSchema) { + startCreateTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + try { + const result = await createGtcClause({ + ...data, + createdById: currentUserId + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("조항이 복제되었습니다.") + onSuccess?.() + } catch (error) { + toast.error("조항 복제 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + props.onOpenChange?.(nextOpen) + } + + if (!sourceClause) { + return null + } + + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + <DialogContent className="max-w-2xl h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="flex items-center gap-2"> + <Copy className="h-5 w-5" /> + 조항 복제 + </DialogTitle> + <DialogDescription> + 기존 조항을 복제하여 새로운 조항을 생성합니다. + </DialogDescription> + </DialogHeader> + + {/* 원본 조항 정보 */} + <div className="p-3 bg-muted/50 rounded-lg text-sm flex-shrink-0"> + <div className="flex items-center gap-2 mb-2"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">복제할 원본 조항</span> + </div> + <div className="space-y-1 text-muted-foreground"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{sourceClause.itemNumber}</Badge> + <span>{sourceClause.subtitle}</span> + </div> + {sourceClause.category && ( + <div>분류: {sourceClause.category}</div> + )} + {sourceClause.content && ( + <div className="text-xs"> + 내용: {sourceClause.content.substring(0, 100)} + {sourceClause.content.length > 100 && "..."} + </div> + )} + </div> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + <div className="flex-1 overflow-y-auto px-1"> + <div className="space-y-4 py-4"> + {/* 새 채번 */} + <FormField + control={form.control} + name="itemNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>새 채번 *</FormLabel> + <FormControl> + <Input + placeholder="예: 1_copy, 1.1_v2, 2.3.1_new 등" + {...field} + /> + </FormControl> + <FormDescription> + 복제된 조항의 새로운 번호입니다. 원본과 다른 번호를 사용해주세요. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 분류 */} + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>분류</FormLabel> + <FormControl> + <Input + placeholder="분류를 수정하거나 그대로 두세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 소제목 */} + <FormField + control={form.control} + name="subtitle" + render={({ field }) => ( + <FormItem> + <FormLabel>소제목 *</FormLabel> + <FormControl> + <Input + placeholder="소제목을 수정하세요" + {...field} + /> + </FormControl> + <FormDescription> + 복제 시 "(복제)" 접미사가 자동으로 추가됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 상세항목 */} + <FormField + control={form.control} + name="content" + render={({ field }) => ( + <FormItem> + <FormLabel>상세항목</FormLabel> + <FormControl> + <Textarea + placeholder="내용을 수정하거나 그대로 두세요" + {...field} + rows={6} + /> + </FormControl> + <FormDescription> + 원본 조항의 내용이 복사됩니다. 필요시 수정하세요. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* PDFTron 변수명 섹션 */} + {/* <div className="space-y-3 p-3 border rounded-lg"> + <div className="flex items-center gap-2"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">PDFTron 변수명 (자동 생성)</span> + </div> + + <div className="grid grid-cols-1 gap-3"> + <FormField + control={form.control} + name="numberVariableName" + render={({ field }) => ( + <FormItem> + <FormLabel>채번 변수명</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="subtitleVariableName" + render={({ field }) => ( + <FormItem> + <FormLabel>소제목 변수명</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contentVariableName" + render={({ field }) => ( + <FormItem> + <FormLabel>상세항목 변수명</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="text-xs text-muted-foreground"> + 새 채번을 기반으로 변수명이 자동 생성됩니다. + </div> + </div> */} + + {/* 편집 사유 */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>복제 사유</FormLabel> + <FormControl> + <Textarea + placeholder="조항 복제 사유를 입력하세요..." + {...field} + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4"> + <Button + type="button" + variant="outline" + onClick={() => props.onOpenChange?.(false)} + disabled={isCreatePending} + > + Cancel + </Button> + <Button type="submit" disabled={isCreatePending}> + {isCreatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + <Copy className="mr-2 h-4 w-4" /> + Duplicate + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/excel-import.tsx b/lib/basic-contract/gtc-vendor/excel-import.tsx new file mode 100644 index 00000000..d8f435f7 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/excel-import.tsx @@ -0,0 +1,340 @@ +import { ExcelColumnDef } from "@/lib/export" +import ExcelJS from "exceljs" + +/** + * Excel 템플릿 다운로드 함수 + */ +export async function downloadExcelTemplate( + columns: ExcelColumnDef[], + { + filename = "template", + includeExampleData = true, + useGroupHeader = true, + }: { + filename?: string + includeExampleData?: boolean + useGroupHeader?: boolean + } = {} +): Promise<void> { + let sheetData: any[][] + + if (useGroupHeader) { + // 2줄 헤더 생성 + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + row1.push(col.group ?? "") + row2.push(col.header) + }) + + sheetData = [row1, row2] + + // 예시 데이터 추가 + if (includeExampleData) { + // 빈 행 3개 추가 (사용자가 데이터 입력할 공간) + for (let i = 0; i < 3; i++) { + const exampleRow = columns.map((col) => { + // 컬럼 타입에 따른 예시 데이터 + if (col.id === "itemNumber") return i === 0 ? `1.${i + 1}` : i === 1 ? "2.1" : "" + if (col.id === "subtitle") return i === 0 ? "예시 조항 소제목" : i === 1 ? "하위 조항 예시" : "" + if (col.id === "content") return i === 0 ? "조항의 상세 내용을 입력합니다." : i === 1 ? "하위 조항의 내용" : "" + if (col.id === "category") return i === 0 ? "일반조항" : i === 1 ? "특별조항" : "" + if (col.id === "sortOrder") return i === 0 ? "10" : i === 1 ? "20" : "" + if (col.id === "parentId") return i === 1 ? "1" : "" + if (col.id === "isActive") return i === 0 ? "활성" : i === 1 ? "활성" : "" + if (col.id === "editReason") return i === 0 ? "신규 작성" : "" + return "" + }) + sheetData.push(exampleRow) + } + } + } else { + // 1줄 헤더 + const headerRow = columns.map((col) => col.header) + sheetData = [headerRow] + + if (includeExampleData) { + // 예시 데이터 행 추가 + const exampleRow = columns.map(() => "") + sheetData.push(exampleRow) + } + } + + // ExcelJS로 워크북 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("GTC조항템플릿") + + // 데이터 추가 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE6F3FF" }, // 연한 파란색 + } + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + } + }) + } + } else { + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE6F3FF" }, + } + }) + } + } + + // 예시 데이터 행 스타일 + if (includeExampleData && idx === (useGroupHeader ? 2 : 1)) { + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFEAA7" }, // 연한 노란색 + } + cell.font = { italic: true, color: { argb: "FF666666" } } + }) + } + }) + + // 그룹 헤더 병합 + if (useGroupHeader) { + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + let start = 1 + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1) + } + start = c + prevValue = cellVal + } + } + + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length) + } + } + + // 컬럼 너비 자동 조정 + columns.forEach((col, idx) => { + let width = Math.max(col.header.length + 5, 15) + + // 특정 컬럼은 더 넓게 + if (col.id === "content" || col.id === "subtitle") { + width = 30 + } else if (col.id === "itemNumber") { + width = 15 + } else if (col.id === "editReason") { + width = 20 + } + + worksheet.getColumn(idx + 1).width = width + }) + + // 사용 안내 시트 추가 + const instructionSheet = workbook.addWorksheet("사용안내") + const instructions = [ + ["GTC 조항 Excel 가져오기 사용 안내"], + [""], + ["1. 기본 규칙"], + [" - 첫 번째 시트(GTC조항템플릿)에 데이터를 입력하세요"], + [" - 헤더 행은 수정하지 마세요"], + [" - 예시 데이터(노란색 행)는 삭제하고 실제 데이터를 입력하세요"], + [""], + ["2. 필수 입력 항목"], + [" - 채번: 필수 입력 (예: 1.1, 2.3.1)"], + [" - 소제목: 필수 입력"], + [""], + ["3. 선택 입력 항목"], + [" - 상세항목: 조항의 구체적인 내용"], + [" - 분류: 조항의 카테고리 (예: 일반조항, 특별조항)"], + [" - 순서: 숫자 (기본값: 10, 20, 30...)"], + [" - 상위 조항 ID: 계층 구조를 만들 때 사용"], + [" - 활성 상태: '활성' 또는 '비활성' (기본값: 활성)"], + [" - 편집 사유: 작성/수정 이유"], + [""], + ["4. 자동 처리 항목"], + [" - ID, 생성일, 수정일: 시스템에서 자동 생성"], + [" - 계층 깊이: 상위 조항 ID를 기반으로 자동 계산"], + [" - 전체 경로: 시스템에서 자동 생성"], + [""], + ["5. 채번 규칙"], + [" - 같은 부모 하에서 채번은 유일해야 합니다"], + [" - 예: 상위 조항이 같으면 1.1, 1.2는 가능하지만 1.1이 중복되면 오류"], + [""], + ["6. 계층 구조 만들기"], + [" - 상위 조항 ID: 기존 조항의 ID를 입력"], + [" - 예: ID가 5인 조항 하위에 조항을 만들려면 상위 조항 ID에 5 입력"], + [" - 최상위 조항은 상위 조항 ID를 비워두세요"], + [""], + ["7. 주의사항"], + [" - 순서는 숫자로 입력하세요 (소수점 가능: 10, 15.5, 20)"], + [" - 상위 조항 ID는 반드시 존재하는 조항의 ID여야 합니다"], + [" - 파일 저장 시 .xlsx 형식으로 저장하세요"], + ] + + instructions.forEach((instruction, idx) => { + const row = instructionSheet.addRow(instruction) + if (idx === 0) { + row.font = { bold: true, size: 14 } + row.alignment = { horizontal: "center" } + } else if (instruction[0]?.match(/^\d+\./)) { + row.font = { bold: true } + } + }) + + instructionSheet.getColumn(1).width = 80 + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + +/** + * Excel 파일에서 데이터 파싱 + */ +export async function parseExcelFile<TData>( + file: File, + columns: ExcelColumnDef[], + { + hasGroupHeader = true, + sheetName = "GTC조항템플릿", + }: { + hasGroupHeader?: boolean + sheetName?: string + } = {} +): Promise<{ + data: Partial<TData>[] + errors: string[] +}> { + const errors: string[] = [] + const data: Partial<TData>[] = [] + + try { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.getWorksheet(sheetName) || workbook.worksheets[0] + + if (!worksheet) { + errors.push("워크시트를 찾을 수 없습니다.") + return { data, errors } + } + + // 헤더 행 인덱스 결정 + const headerRowIndex = hasGroupHeader ? 2 : 1 + const dataStartRowIndex = headerRowIndex + 1 + + // 헤더 검증 + const headerRow = worksheet.getRow(headerRowIndex) + const expectedHeaders = columns.map(col => col.header) + + for (let i = 0; i < expectedHeaders.length; i++) { + const cellValue = headerRow.getCell(i + 1).value?.toString() || "" + if (cellValue !== expectedHeaders[i]) { + errors.push(`헤더가 일치하지 않습니다. 예상: "${expectedHeaders[i]}", 실제: "${cellValue}"`) + } + } + + if (errors.length > 0) { + return { data, errors } + } + + // 데이터 파싱 + let rowIndex = dataStartRowIndex + while (rowIndex <= worksheet.actualRowCount) { + const row = worksheet.getRow(rowIndex) + + // 빈 행 체크 (모든 셀이 비어있으면 스킵) + const isEmpty = columns.every((col, colIndex) => { + const cellValue = row.getCell(colIndex + 1).value + return !cellValue || cellValue.toString().trim() === "" + }) + + if (isEmpty) { + rowIndex++ + continue + } + + const rowData: Partial<TData> = {} + let hasError = false + + columns.forEach((col, colIndex) => { + const cellValue = row.getCell(colIndex + 1).value + let processedValue: any = cellValue + + // 데이터 타입별 처리 + if (cellValue !== null && cellValue !== undefined) { + const strValue = cellValue.toString().trim() + + // 특별한 처리가 필요한 컬럼들 + if (col.id === "isActive") { + processedValue = strValue === "활성" + } else if (col.id === "sortOrder") { + const numValue = parseFloat(strValue) + processedValue = isNaN(numValue) ? null : numValue + } else if (col.id === "parentId") { + const numValue = parseInt(strValue) + processedValue = isNaN(numValue) ? null : numValue + } else { + processedValue = strValue + } + } + + // 필수 필드 검증 + if ((col.id === "itemNumber" || col.id === "subtitle") && (!processedValue || processedValue === "")) { + errors.push(`${rowIndex}행: ${col.header}은(는) 필수 입력 항목입니다.`) + hasError = true + } + + if (processedValue !== null && processedValue !== undefined && processedValue !== "") { + (rowData as any)[col.id] = processedValue + } + }) + + if (!hasError) { + data.push(rowData) + } + + rowIndex++ + } + + } catch (error) { + errors.push(`파일 파싱 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`) + } + + return { data, errors } +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/generate-variable-names-dialog.tsx b/lib/basic-contract/gtc-vendor/generate-variable-names-dialog.tsx new file mode 100644 index 00000000..ef4ed9f9 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/generate-variable-names-dialog.tsx @@ -0,0 +1,348 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Loader, Wand2, Info, Eye } from "lucide-react" +import { toast } from "sonner" + +import { generateVariableNamesSchema, type GenerateVariableNamesSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { generateVariableNames, getGtcClausesTree } from "@/lib/gtc-contract/gtc-clauses/service" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" + +interface GenerateVariableNamesDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + documentId: number + document: any +} + +export function GenerateVariableNamesDialog({ + documentId, + document, + ...props +}: GenerateVariableNamesDialogProps) { + const [isGenerating, startGenerating] = React.useTransition() + const [clauses, setClauses] = React.useState<GtcClauseTreeView[]>([]) + const [previewClauses, setPreviewClauses] = React.useState<GtcClauseTreeView[]>([]) + const [showPreview, setShowPreview] = React.useState(false) + const { data: session } = useSession() + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + React.useEffect(() => { + if (props.open && documentId) { + loadClauses() + } + }, [props.open, documentId]) + + const loadClauses = async () => { + try { + const tree = await getGtcClausesTree(documentId) + const flatClauses = flattenTree(tree) + setClauses(flatClauses) + } catch (error) { + console.error("Error loading clauses:", error) + } + } + + const form = useForm<GenerateVariableNamesSchema>({ + resolver: zodResolver(generateVariableNamesSchema), + defaultValues: { + documentId, + prefix: "CLAUSE", + includeVendorCode: false, + vendorCode: "", + }, + }) + + const watchedPrefix = form.watch("prefix") + const watchedIncludeVendorCode = form.watch("includeVendorCode") + const watchedVendorCode = form.watch("vendorCode") + + // 미리보기 생성 + React.useEffect(() => { + if (clauses.length > 0) { + generatePreview() + } + }, [clauses, watchedPrefix, watchedIncludeVendorCode, watchedVendorCode]) + + const generatePreview = () => { + const basePrefix = watchedIncludeVendorCode && watchedVendorCode + ? `${watchedVendorCode}_${watchedPrefix}` + : watchedPrefix + + console.log(basePrefix,"basePrefix") + + const updated = clauses.slice(0, 5).map(clause => { + console.log(clause.fullPath,"clause.fullPath") + + const pathPrefix = clause.fullPath?.replace(/\./g, "_") || clause.itemNumber.replace(/\./g, "_") + const varPrefix = `${basePrefix}_${pathPrefix}` + + return { + ...clause, + previewNumberVar: `${varPrefix}_NUMBER`, + previewSubtitleVar: `${varPrefix}_SUBTITLE`, + previewContentVar: `${varPrefix}_CONTENT`, + } + }) + + setPreviewClauses(updated as any) + } + + async function onSubmit(data: GenerateVariableNamesSchema) { + startGenerating(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + try { + const result = await generateVariableNames({ + ...data, + updatedById: currentUserId + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("PDFTron 변수명이 생성되었습니다.") + } catch (error) { + toast.error("변수명 생성 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setShowPreview(false) + } + props.onOpenChange?.(nextOpen) + } + + const clausesWithoutVariables = clauses.filter(clause => !clause.hasAllVariableNames) + const clausesWithVariables = clauses.filter(clause => clause.hasAllVariableNames) + + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Wand2 className="h-5 w-5" /> + PDFTron 변수명 자동 생성 + </DialogTitle> + <DialogDescription> + 문서의 모든 조항에 대해 PDFTron 변수명을 자동으로 생성합니다. + </DialogDescription> + </DialogHeader> + + {/* 문서 및 조항 현황 */} + <div className="space-y-4 p-4 bg-muted/50 rounded-lg"> + <div className="flex items-center gap-2"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">문서 및 조항 현황</span> + </div> + + <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> + <div> + <div className="font-medium text-muted-foreground mb-1">문서 타입</div> + <Badge variant={document?.type === "standard" ? "default" : "secondary"}> + {document?.type === "standard" ? "표준" : "프로젝트"} + </Badge> + </div> + + <div> + <div className="font-medium text-muted-foreground mb-1">총 조항 수</div> + <div>{clauses.length}개</div> + </div> + + <div> + <div className="font-medium text-muted-foreground mb-1">변수명 설정 완료</div> + <Badge variant="default">{clausesWithVariables.length}개</Badge> + </div> + + <div> + <div className="font-medium text-muted-foreground mb-1">변수명 미설정</div> + <Badge variant="destructive">{clausesWithoutVariables.length}개</Badge> + </div> + </div> + + {document?.project && ( + <div className="text-sm"> + <span className="font-medium text-muted-foreground">프로젝트: </span> + {document.project.name} ({document.project.code}) + </div> + )} + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4"> + {/* 기본 접두사 */} + <FormField + control={form.control} + name="prefix" + render={({ field }) => ( + <FormItem> + <FormLabel>기본 접두사</FormLabel> + <FormControl> + <Input + placeholder="CLAUSE" + {...field} + /> + </FormControl> + <FormDescription> + 모든 변수명의 시작에 사용될 접두사입니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더 코드 포함 여부 */} + <FormField + control={form.control} + name="includeVendorCode" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base">벤더 코드 포함</FormLabel> + <FormDescription> + 변수명에 벤더 코드를 포함시킵니다. (벤더별 GTC 용) + </FormDescription> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + + {/* 벤더 코드 입력 */} + {watchedIncludeVendorCode && ( + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더 코드</FormLabel> + <FormControl> + <Input + placeholder="예: VENDOR_A, ABC_CORP 등" + {...field} + /> + </FormControl> + <FormDescription> + 변수명에 포함될 벤더 식별 코드입니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + )} + /> + + {/* 미리보기 토글 */} + <div className="flex items-center justify-between"> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => setShowPreview(!showPreview)} + > + <Eye className="mr-2 h-4 w-4" /> + {showPreview ? "미리보기 숨기기" : "미리보기 보기"} + </Button> + </div> + + {/* 미리보기 */} + {showPreview && ( + <div className="space-y-3 p-4 border rounded-lg bg-muted/30"> + <div className="text-sm font-medium">변수명 미리보기 (상위 5개 조항)</div> + <div className="space-y-2 max-h-64 overflow-y-auto"> + {previewClauses.map((clause: any) => ( + <div key={clause.id} className="p-2 bg-background rounded border text-xs"> + <div className="flex items-center gap-2 mb-1"> + <Badge variant="outline">{clause.itemNumber}</Badge> + <span className="font-medium truncate">{clause.subtitle}</span> + </div> + <div className="space-y-1 text-muted-foreground"> + <div>채번: <code className="text-foreground">{clause.previewNumberVar}</code></div> + <div>소제목: <code className="text-foreground">{clause.previewSubtitleVar}</code></div> + <div>상세항목: <code className="text-foreground">{clause.previewContentVar}</code></div> + </div> + </div> + ))} + </div> + </div> + )} + </div> + + <DialogFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={() => props.onOpenChange?.(false)} + disabled={isGenerating} + > + Cancel + </Button> + <Button type="submit" disabled={isGenerating}> + {isGenerating && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Generate Variables + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} + +// 트리를 평면 배열로 변환하는 유틸리티 함수 +function flattenTree(tree: any[]): any[] { + const result: any[] = [] + + function traverse(nodes: any[]) { + for (const node of nodes) { + result.push(node) + if (node.children && node.children.length > 0) { + traverse(node.children) + } + } + } + + traverse(tree) + return result +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/gtc-clauses-table-columns.tsx b/lib/basic-contract/gtc-vendor/gtc-clauses-table-columns.tsx new file mode 100644 index 00000000..b8f92fab --- /dev/null +++ b/lib/basic-contract/gtc-vendor/gtc-clauses-table-columns.tsx @@ -0,0 +1,409 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Edit, Trash2, Plus, Copy } from "lucide-react" +import { cn, compareItemNumber, formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { toast } from "sonner" +import { useSession } from "next-auth/react" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { type GtcClauseTreeView } from "@/db/schema/gtc" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GtcClauseTreeView> | null>> + documentId: number + hasVendorInfo?: boolean + +} + +export function getColumns({ setRowAction, documentId, hasVendorInfo }: GetColumnsProps): ColumnDef<any>[] { + // 1) select + const selectColumn: ColumnDef<GtcClauseTreeView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // 2) 조항 정보 + const clauseInfoColumns: ColumnDef<GtcClauseTreeView>[] = [ + { + accessorKey: "itemNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="채번" />, + cell: ({ row }) => { + const itemNumber = row.getValue("itemNumber") as string + const depth = row.original.depth + const childrenCount = row.original.childrenCount + const isModified = row.original.vendorInfo?.isNumberModified + + return ( + <div className="flex items-center gap-2"> + <div style={{ marginLeft: `${depth * 20}px` }} className="flex items-center gap-1"> + <span + className={cn( + "font-mono text-sm font-medium", + isModified && "text-red-500 dark:text-red-400" + )} + > + {itemNumber} + </span> + {childrenCount > 0 && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge variant="outline" className="h-5 px-1 text-xs"> + {childrenCount} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p>{childrenCount}개의 하위 조항</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + </div> + ) + }, + size: 100, + enableResizing: true, + sortingFn: (rowA, rowB, colId) => + compareItemNumber(rowA.getValue<string>(colId), rowB.getValue<string>(colId)), + meta: { excelHeader: "채번" }, + }, + { + accessorKey: "category", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="분류" />, + cell: ({ row }) => { + const category = row.getValue("category") as string + const isModified = row.original.vendorInfo?.isCategoryModified + return category ? ( + <Badge + variant="secondary" + className={cn("text-xs", isModified && "bg-red-100 text-red-600")} + > + {category} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 100, + enableResizing: true, + meta: { excelHeader: "분류" }, + }, + { + accessorKey: "subtitle", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="소제목" />, + cell: ({ row }) => { + const subtitle = row.getValue("subtitle") as string + const depth = row.original.depth + const isModified = row.original.vendorInfo?.isSubtitleModified + + return ( + <div className="flex flex-col min-w-0"> + <span + className={cn( + "font-medium truncate", + depth === 0 && "text-base", + depth === 1 && "text-sm", + depth >= 2 && "text-sm text-muted-foreground", + isModified && "text-red-500 dark:text-red-400" + )} + title={subtitle} + > + {subtitle} + </span> + </div> + ) + }, + size: 150, + enableResizing: true, + meta: { excelHeader: "소제목" }, + }, + { + accessorKey: "content", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상세항목" />, + cell: ({ row }) => { + + const modifiedContent = row.original.vendorInfo?.modifiedContent + + const content = modifiedContent ? modifiedContent : row.getValue("content") as string | null + if (!content) { + return ( + <div className="flex items-center gap-2"> + <Badge variant="outline" className="text-xs">그룹핑 조항</Badge> + <span className="text-xs text-muted-foreground">상세내용 없음</span> + </div> + ) + } + const truncated = content.length > 100 ? `${content.substring(0, 100)}...` : content + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className=""> + {/* <p className="text-sm line-clamp-2 text-muted-foreground">{content}</p> */} + <p + className={cn( + "text-sm line-clamp-2", + modifiedContent ? "text-red-500 dark:text-red-400" : "text-muted-foreground" + )} + > + {content} + </p> + </div> + </TooltipTrigger> + <TooltipContent className="max-w-sm"> + <p className="whitespace-pre-wrap">{content}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) + }, + size: 200, + maxSize: 500, + enableResizing: true, + meta: { excelHeader: "상세항목" }, + }, + ] + + // 3) 등록/수정 정보 + const auditColumns: ColumnDef<GtcClauseTreeView>[] = [ + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="작성일" />, + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date + return date ? formatDate(date, "KR") : "-" + }, + size: 120, + enableResizing: true, + meta: { excelHeader: "작성일" }, + }, + { + accessorKey: "createdByName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="작성자" />, + cell: ({ row }) => { + const v = row.getValue("createdByName") as string + return v ? <span className="text-sm">{v}</span> : <span className="text-muted-foreground">-</span> + }, + size: 80, + enableResizing: true, + meta: { excelHeader: "작성자" }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />, + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date + return <span className="text-sm">{date ? formatDate(date, "KR") : "-"}</span> + }, + size: 120, + enableResizing: true, + meta: { excelHeader: "수정일" }, + }, + { + accessorKey: "updatedByName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정자" />, + cell: ({ row }) => { + const v = row.getValue("updatedByName") as string + return v ? <span className="text-sm">{v}</span> : <span className="text-muted-foreground">-</span> + }, + size: 80, + enableResizing: true, + meta: { excelHeader: "수정자" }, + }, + ] + + // 벤더 관련 칼럼 추가 + const vendorColumns: ColumnDef<any>[] = hasVendorInfo ? [ + { + id: "vendorReviewStatus", + accessorFn: (row) => row.vendorInfo?.reviewStatus, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="협의 상태" />, + cell: ({ row }) => { + const status = row.original.vendorInfo?.reviewStatus + if (!status) return <span className="text-muted-foreground">-</span> + + const statusMap = { + draft: { label: "초안", variant: "secondary" }, + pending: { label: "대기", variant: "outline" }, + reviewing: { label: "협의중", variant: "default" }, + approved: { label: "승인", variant: "success" }, + rejected: { label: "거부", variant: "destructive" }, + revised: { label: "수정", variant: "warning" }, + } + + const config = statusMap[status as keyof typeof statusMap] + return <Badge variant={config.variant as any}>{config.label}</Badge> + }, + size: 80, + }, + { + id: "vendorComment", + accessorFn: (row) => row.vendorInfo?.latestComment, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="협의 코멘트" />, + cell: ({ row }) => { + const comment = row.original.vendorInfo?.latestComment + const history = row.original.vendorInfo?.negotiationHistory + + if (!comment) return <span className="text-muted-foreground">-</span> + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-1"> + <span className="text-sm truncate max-w-[200px]">{comment}</span> + {history && history.length > 1 && ( + <Badge variant="outline" className="h-5 px-1 text-xs"> + {history.length} + </Badge> + )} + </div> + </TooltipTrigger> + <TooltipContent className="max-w-md"> + <div className="space-y-2"> + {history?.slice(0, 3).map((h: any, idx: number) => ( + <div key={idx} className="border-b last:border-0 pb-2 last:pb-0"> + <p className="text-xs text-muted-foreground"> + {h.actorName} • {formatDate(h.createdAt, "KR")} + </p> + <p className="text-sm">{h.comment}</p> + </div> + ))} + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) + }, + size: 200, + }, + { + id: "vendorModifications", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정 항목" />, + cell: ({ row }) => { + const info = row.original.vendorInfo + if (!info) return <span className="text-muted-foreground">-</span> + + const mods = [] + if (info.isNumberModified) mods.push("채번") + if (info.isCategoryModified) mods.push("분류") + if (info.isSubtitleModified) mods.push("소제목") + if (info.isContentModified) mods.push("내용") + + if (mods.length === 0) return <span className="text-muted-foreground">없음</span> + + return ( + <div className="flex flex-wrap gap-1"> + {mods.map((mod) => ( + <Badge key={mod} variant="outline" className="text-xs"> + {mod} + </Badge> + ))} + </div> + ) + }, + size: 150, + }, + ] : [] + + // 4) actions + const actionsColumn: ColumnDef<GtcClauseTreeView> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const { data: session } = useSession() + const gtcClause = row.original + const currentUserId = React.useMemo( + () => (session?.user?.id ? Number(session.user.id) : null), + [session], + ) + + const handleEdit = () => setRowAction({ row, type: "update" }) + const handleDelete = () => setRowAction({ row, type: "delete" }) + const handleAddSubClause = () => setRowAction({ row, type: "addSubClause" }) + const handleDuplicate = () => setRowAction({ row, type: "duplicate" }) + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button aria-label="Open menu" variant="ghost" className="flex size-8 p-0 data-[state=open]:bg-muted"> + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + <DropdownMenuItem onSelect={handleEdit}> + <Edit className="mr-2 h-4 w-4" /> + 협의 확인 및 조항 수정 + </DropdownMenuItem> + {/* <DropdownMenuItem onSelect={handleAddSubClause}> + <Plus className="mr-2 h-4 w-4" /> + 하위 조항 추가 + </DropdownMenuItem> + <DropdownMenuItem onSelect={handleDuplicate}> + <Copy className="mr-2 h-4 w-4" /> + 복제 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={handleDelete} className="text-destructive"> + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> */} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + maxSize: 40, + } + + // 🔹 그룹 헤더 제거: 평탄화된 컬럼 배열 반환 + return [ + selectColumn, + ...clauseInfoColumns, + ...vendorColumns, // 벤더 칼럼 추가 + ...auditColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/gtc-clauses-table-floating-bar.tsx b/lib/basic-contract/gtc-vendor/gtc-clauses-table-floating-bar.tsx new file mode 100644 index 00000000..5b701df6 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/gtc-clauses-table-floating-bar.tsx @@ -0,0 +1,239 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + Edit, + Trash2, + ArrowUpDown, + Download, + Copy, + Wand2, + X +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog" + +interface GtcClausesTableFloatingBarProps { + table: Table<GtcClauseTreeView> +} + +export function GtcClausesTableFloatingBar({ table }: GtcClausesTableFloatingBarProps) { + const selectedRows = table.getSelectedRowModel().rows + const selectedCount = selectedRows.length + + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + + if (selectedCount === 0) return null + + const selectedClauses = selectedRows.map(row => row.original) + + const handleClearSelection = () => { + table.toggleAllRowsSelected(false) + } + + const handleBulkEdit = () => { + console.log("Bulk edit:", selectedClauses) + } + + const handleReorder = () => { + console.log("Reorder:", selectedClauses) + } + + const handleExport = () => { + console.log("Export:", selectedClauses) + } + + const handleDuplicate = () => { + console.log("Duplicate:", selectedClauses) + } + + const handleGenerateVariables = () => { + console.log("Generate variables:", selectedClauses) + } + + const canReorder = selectedClauses.every(clause => + clause.parentId === selectedClauses[0].parentId + ) + + const hasVariablesMissing = selectedClauses.some(clause => + !clause.hasAllVariableNames + ) + + return ( + <div className="fixed bottom-4 left-1/2 z-50 w-fit -translate-x-1/2"> + <div className="w-fit rounded-lg border bg-card p-2 shadow-2xl animate-in fade-in-0 slide-in-from-bottom-2"> + <div className="flex items-center gap-2"> + {/* 선택된 항목 수 */} + <div className="flex items-center gap-2 px-2"> + <span className="text-sm font-medium"> + {selectedCount}개 선택됨 + </span> + </div> + + <Separator orientation="vertical" className="h-6" /> + + {/* 액션 버튼들 */} + <div className="flex items-center gap-1"> + {/* 일괄 수정 */} + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={handleBulkEdit} + > + <Edit className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 조항들을 일괄 수정</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + + {/* 순서 변경 (같은 부모의 조항들만) */} + {canReorder && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={handleReorder} + > + <ArrowUpDown className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 조항들의 순서 변경</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + + {/* 복제 */} + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={handleDuplicate} + > + <Copy className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 조항들을 복제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + + {/* 변수명 생성 (변수가 없는 조항이 있을 때만) */} + {hasVariablesMissing && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={handleGenerateVariables} + > + <Wand2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 조항들의 PDFTron 변수명 생성</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + + {/* 내보내기 */} + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={handleExport} + > + <Download className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 조항들을 Excel로 내보내기</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + + <Separator orientation="vertical" className="h-6" /> + + {/* 삭제 버튼 */} + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={() => setShowDeleteDialog(true)} + className="text-destructive hover:text-destructive" + > + <Trash2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 조항들을 삭제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + + <Separator orientation="vertical" className="h-6" /> + + {/* 선택 해제 버튼 */} + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={handleClearSelection} + > + <X className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택 해제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </div> + + {/* 삭제 다이얼로그 */} + <DeleteGtcClausesDialog + open={showDeleteDialog} + onOpenChange={setShowDeleteDialog} + gtcClauses={selectedClauses} + showTrigger={false} + onSuccess={() => { + table.toggleAllRowsSelected(false) + setShowDeleteDialog(false) + }} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx b/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx new file mode 100644 index 00000000..3a0fbdb6 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx @@ -0,0 +1,350 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + Download, + Upload, + Settings2, + ArrowUpDown, + Edit, + Eye, + FileText, + Wand2 +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { CreateVendorGtcClauseDialog } from "./create-gtc-clause-dialog" +import { PreviewDocumentDialog } from "./preview-document-dialog" +import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog" +import { exportTableToExcel } from "@/lib/export" +import { exportFullDataToExcel, type ExcelColumnDef } from "@/lib/export" +import { getAllGtcClausesForExport, importGtcClausesFromExcel } from "@/lib/gtc-contract/service" +import { ImportExcelDialog } from "./import-excel-dialog" +import { toast } from "@/hooks/use-toast" + +interface GtcClausesTableToolbarActionsProps { + table: Table<GtcClauseTreeView> + documentId: number + document: any + currentUserId?: number // 현재 사용자 ID 추가 +} + +// GTC 조항을 위한 Excel 컬럼 정의 (실용적으로 간소화) +const gtcClauseExcelColumns: ExcelColumnDef[] = [ + { + id: "itemNumber", + header: "채번", + accessor: "itemNumber", + group: "필수 정보" + }, + { + id: "subtitle", + header: "소제목", + accessor: "subtitle", + group: "필수 정보" + }, + { + id: "content", + header: "상세항목", + accessor: "content", + group: "기본 정보" + }, + { + id: "category", + header: "분류", + accessor: "category", + group: "기본 정보" + }, + { + id: "sortOrder", + header: "순서", + accessor: "sortOrder", + group: "순서" + }, + { + id: "parentId", + header: "상위 조항 ID", + accessor: "parentId", + group: "계층 구조" + }, + { + id: "isActive", + header: "활성 상태", + accessor: (row) => row.isActive ? "활성" : "비활성", + group: "상태" + }, + { + id: "editReason", + header: "편집 사유", + accessor: "editReason", + group: "추가 정보" + } +] + +export function GtcClausesTableToolbarActions({ + table, + documentId, + document, + currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) +}: GtcClausesTableToolbarActionsProps) { + const [showCreateDialog, setShowCreateDialog] = React.useState(false) + const [showReorderDialog, setShowReorderDialog] = React.useState(false) + const [showBulkUpdateDialog, setShowBulkUpdateDialog] = React.useState(false) + const [showGenerateVariablesDialog, setShowGenerateVariablesDialog] = React.useState(false) + const [showPreviewDialog, setShowPreviewDialog] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) + + const selectedRows = table.getSelectedRowModel().rows + const selectedCount = selectedRows.length + + // 테이블의 모든 데이터 가져오기 (현재 페이지만) + const allClauses = table.getRowModel().rows.map(row => row.original) + + const transformClausesForPreview = (clauses: GtcClauseTreeView[]) => { + return clauses.map(clause => { + // vendorInfo가 있고 수정된 값이 있는 경우 적용 + if (clause.vendorInfo) { + const { vendorInfo } = clause; + return { + ...clause, + // null이 아닌 수정값이 있으면 해당 값으로 대체 + category: vendorInfo.modifiedCategory ?? clause.category, + content: vendorInfo.modifiedContent ?? clause.content, + itemNumber: vendorInfo.modifiedItemNumber ?? clause.itemNumber, + subtitle: vendorInfo.modifiedSubtitle ?? clause.subtitle, + }; + } + // vendorInfo가 없으면 원본 그대로 반환 + return clause; + }); + }; + + // 컴포넌트 내에서 변환된 데이터 생성 + const previewClauses = React.useMemo( + () => transformClausesForPreview(allClauses), + [allClauses] + ); + + console.log(allClauses, "allClauses") + + // 현재 페이지 데이터만 Excel로 내보내기 + const handleExportCurrentPageToExcel = () => { + exportTableToExcel(table, { + filename: `gtc-clauses-page-${new Date().toISOString().split('T')[0]}`, + excludeColumns: ["select", "actions"], + }) + } + + // 전체 데이터를 Excel로 내보내기 + const handleExportAllToExcel = async () => { + try { + setIsExporting(true) + + // 서버에서 전체 데이터 가져오기 + const allData = await getAllGtcClausesForExport(documentId) + + // 전체 데이터를 Excel로 내보내기 + await exportFullDataToExcel( + allData, + gtcClauseExcelColumns, + { + filename: `gtc-clauses-all-${new Date().toISOString().split('T')[0]}`, + useGroupHeader: true + } + ) + + toast({ + title: "내보내기 완료", + description: `총 ${allData.length}개의 조항이 Excel 파일로 내보내졌습니다.`, + }) + } catch (error) { + console.error("Excel export failed:", error) + toast({ + title: "내보내기 실패", + description: "Excel 파일 내보내기 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsExporting(false) + } + } + + // Excel 데이터 가져오기 처리 + const handleImportExcelData = async (data: Partial<GtcClauseTreeView>[]) => { + try { + const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + + if (result.success) { + toast({ + title: "가져오기 성공", + description: `${result.importedCount}개의 조항이 성공적으로 가져와졌습니다.`, + }) + + // 테이블 새로고침 + handleRefreshTable() + } else { + const errorMessage = result.errors.length > 0 + ? `오류: ${result.errors.slice(0, 3).join(', ')}${result.errors.length > 3 ? '...' : ''}` + : "알 수 없는 오류가 발생했습니다." + + toast({ + title: "가져오기 실패", + description: errorMessage, + variant: "destructive" + }) + + // 오류가 있어도 일부는 성공했을 수 있음 + if (result.importedCount > 0) { + handleRefreshTable() + } + + throw new Error("Import failed with errors") + } + } catch (error) { + console.error("Excel import failed:", error) + throw error // ImportExcelDialog에서 처리하도록 다시 throw + } + } + + const handlePreviewDocument = () => { + setShowPreviewDialog(true) + } + + const handleGenerateDocument = () => { + // 최종 문서 생성 + console.log("Generate final document") + } + + const handleReorderClauses = () => { + setShowReorderDialog(true) + } + + const handleBulkUpdate = () => { + setShowBulkUpdateDialog(true) + } + + const handleGenerateVariables = () => { + setShowGenerateVariablesDialog(true) + } + + const handleRefreshTable = () => { + // 테이블 새로고침 로직 + console.log("Refresh table after creation") + // table.reset() 또는 상위 컴포넌트의 refetch 함수 호출 + } + + return ( + <> + <div className="flex items-center gap-2"> + {/* 조항 추가 버튼 */} + <CreateVendorGtcClauseDialog + documentId={documentId} + document={document} + onSuccess={handleRefreshTable} + /> + + {/* 선택된 항목이 있을 때 표시되는 액션들 */} + {selectedCount > 0 && ( + <> + <DeleteGtcClausesDialog + gtcClauses={allClauses} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + </> + )} + + {/* 관리 도구 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" disabled={isExporting}> + <Settings2 className="mr-2 h-4 w-4" /> + 관리 도구 + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-64"> + <DropdownMenuItem onClick={handleExportCurrentPageToExcel}> + <Download className="mr-2 h-4 w-4" /> + 현재 페이지 Excel로 내보내기 + </DropdownMenuItem> + + <DropdownMenuItem + onClick={handleExportAllToExcel} + disabled={isExporting} + > + <Download className="mr-2 h-4 w-4" /> + {isExporting ? "내보내는 중..." : "전체 데이터 Excel로 내보내기"} + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + {/* <ImportExcelDialog + documentId={documentId} + columns={gtcClauseExcelColumns} + onSuccess={handleRefreshTable} + onImport={handleImportExcelData} + trigger={ + <DropdownMenuItem onSelect={(e) => e.preventDefault()}> + <Upload className="mr-2 h-4 w-4" /> + Excel에서 가져오기 + </DropdownMenuItem> + } + /> */} + + <DropdownMenuSeparator /> + + <DropdownMenuItem onClick={handlePreviewDocument}> + <Eye className="mr-2 h-4 w-4" /> + 문서 미리보기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 조건부로 표시되는 다이얼로그들 */} + {showReorderDialog && ( + <div> + {/* ReorderGtcClausesDialog 컴포넌트가 여기에 올 예정 */} + </div> + )} + + {showBulkUpdateDialog && ( + <div> + {/* BulkUpdateGtcClausesDialog 컴포넌트가 여기에 올 예정 */} + </div> + )} + + {showGenerateVariablesDialog && ( + <div> + {/* GenerateVariableNamesDialog 컴포넌트가 여기에 올 예정 */} + </div> + )} + </div> + + {/* 미리보기 다이얼로그 */} + <PreviewDocumentDialog + open={showPreviewDialog} + onOpenChange={setShowPreviewDialog} + clauses={previewClauses} + document={document} + onExport={() => { + console.log("Export from preview dialog") + }} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/import-excel-dialog.tsx b/lib/basic-contract/gtc-vendor/import-excel-dialog.tsx new file mode 100644 index 00000000..f37566fc --- /dev/null +++ b/lib/basic-contract/gtc-vendor/import-excel-dialog.tsx @@ -0,0 +1,381 @@ +"use client" + +import * as React from "react" +import { Upload, Download, FileText, AlertCircle, CheckCircle2, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" + +import { type ExcelColumnDef } from "@/lib/export" +import { downloadExcelTemplate, parseExcelFile } from "./excel-import" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { toast } from "@/hooks/use-toast" + +interface ImportExcelDialogProps { + documentId: number + columns: ExcelColumnDef[] + onSuccess?: () => void + onImport?: (data: Partial<GtcClauseTreeView>[]) => Promise<void> + trigger?: React.ReactNode +} + +type ImportStep = "upload" | "preview" | "importing" | "complete" + +export function ImportExcelDialog({ + documentId, + columns, + onSuccess, + onImport, + trigger, +}: ImportExcelDialogProps) { + const [open, setOpen] = React.useState(false) + const [step, setStep] = React.useState<ImportStep>("upload") + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + const [parsedData, setParsedData] = React.useState<Partial<GtcClauseTreeView>[]>([]) + const [errors, setErrors] = React.useState<string[]>([]) + const [isProcessing, setIsProcessing] = React.useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 다이얼로그 열기/닫기 시 상태 초기화 + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + // 다이얼로그 닫을 때 상태 초기화 + setStep("upload") + setSelectedFile(null) + setParsedData([]) + setErrors([]) + setIsProcessing(false) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + } + + // 템플릿 다운로드 + const handleDownloadTemplate = async () => { + try { + await downloadExcelTemplate(columns, { + filename: `gtc-clauses-template-${new Date().toISOString().split('T')[0]}`, + includeExampleData: true, + useGroupHeader: true, + }) + + toast({ + title: "템플릿 다운로드 완료", + description: "Excel 템플릿이 다운로드되었습니다. 템플릿에 데이터를 입력한 후 업로드해주세요.", + }) + } catch (error) { + toast({ + title: "템플릿 다운로드 실패", + description: "템플릿 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } + + // 파일 선택 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + if (file.type !== "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" && + file.type !== "application/vnd.ms-excel") { + toast({ + title: "잘못된 파일 형식", + description: "Excel 파일(.xlsx, .xls)만 업로드할 수 있습니다.", + variant: "destructive", + }) + return + } + setSelectedFile(file) + } + } + + // 파일 파싱 + const handleParseFile = async () => { + if (!selectedFile) return + + setIsProcessing(true) + try { + const result = await parseExcelFile<GtcClauseTreeView>( + selectedFile, + columns, + { + hasGroupHeader: true, + sheetName: "GTC조항템플릿", + } + ) + + setParsedData(result.data) + setErrors(result.errors) + + if (result.errors.length > 0) { + toast({ + title: "파싱 완료 (오류 있음)", + description: `${result.data.length}개의 행을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.`, + variant: "destructive", + }) + } else { + toast({ + title: "파싱 완료", + description: `${result.data.length}개의 행이 성공적으로 파싱되었습니다.`, + }) + } + + setStep("preview") + } catch (error) { + toast({ + title: "파싱 실패", + description: "파일 파싱 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsProcessing(false) + } + } + + // 데이터 가져오기 실행 + const handleImportData = async () => { + if (parsedData.length === 0 || !onImport) return + + setStep("importing") + try { + await onImport(parsedData) + setStep("complete") + + toast({ + title: "가져오기 완료", + description: `${parsedData.length}개의 조항이 성공적으로 가져와졌습니다.`, + }) + + // 성공 콜백 호출 후 잠시 후 다이얼로그 닫기 + setTimeout(() => { + onSuccess?.() + setOpen(false) + }, 2000) + } catch (error) { + toast({ + title: "가져오기 실패", + description: "데이터 가져오기 중 오류가 발생했습니다.", + variant: "destructive", + }) + setStep("preview") + } + } + + const renderUploadStep = () => ( + <div className="space-y-6"> + <div className="text-center"> + <FileText className="mx-auto h-12 w-12 text-muted-foreground" /> + <h3 className="mt-4 text-lg font-semibold">Excel 파일로 조항 가져오기</h3> + <p className="mt-2 text-sm text-muted-foreground"> + 먼저 템플릿을 다운로드하여 데이터를 입력한 후 업로드해주세요. + </p> + </div> + + <div className="space-y-4"> + <div> + <Button + onClick={handleDownloadTemplate} + variant="outline" + className="w-full" + > + <Download className="mr-2 h-4 w-4" /> + Excel 템플릿 다운로드 + </Button> + <p className="mt-2 text-xs text-muted-foreground"> + 템플릿에는 입력 가이드와 예시 데이터가 포함되어 있습니다. + </p> + </div> + + <Separator /> + + <div> + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + onChange={handleFileSelect} + className="hidden" + /> + <Button + onClick={() => fileInputRef.current?.click()} + variant={selectedFile ? "secondary" : "outline"} + className="w-full" + > + <Upload className="mr-2 h-4 w-4" /> + {selectedFile ? selectedFile.name : "Excel 파일 선택"} + </Button> + </div> + + {selectedFile && ( + <Button + onClick={handleParseFile} + disabled={isProcessing} + className="w-full" + > + {isProcessing ? "파싱 중..." : "파일 분석하기"} + </Button> + )} + </div> + </div> + ) + + const renderPreviewStep = () => ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">데이터 미리보기</h3> + <div className="flex items-center gap-2"> + <Badge variant="secondary"> + {parsedData.length}개 행 + </Badge> + {errors.length > 0 && ( + <Badge variant="destructive"> + {errors.length}개 오류 + </Badge> + )} + </div> + </div> + + {errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <div className="space-y-1"> + <div className="font-medium">다음 오류들을 확인해주세요:</div> + <ul className="list-disc list-inside space-y-1 text-sm"> + {errors.slice(0, 5).map((error, index) => ( + <li key={index}>{error}</li> + ))} + {errors.length > 5 && ( + <li>... 및 {errors.length - 5}개 추가 오류</li> + )} + </ul> + </div> + </AlertDescription> + </Alert> + )} + + <ScrollArea className="h-[300px] border rounded-md"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-12">#</TableHead> + <TableHead>채번</TableHead> + <TableHead>소제목</TableHead> + <TableHead>상세항목</TableHead> + <TableHead>분류</TableHead> + <TableHead>상태</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {parsedData.map((item, index) => ( + <TableRow key={index}> + <TableCell>{index + 1}</TableCell> + <TableCell className="font-mono"> + {item.itemNumber || "-"} + </TableCell> + <TableCell className="max-w-[200px] truncate"> + {item.subtitle || "-"} + </TableCell> + <TableCell className="max-w-[300px] truncate"> + {item.content || "-"} + </TableCell> + <TableCell>{item.category || "-"}</TableCell> + <TableCell> + <Badge variant={item.isActive ? "default" : "secondary"}> + {item.isActive ? "활성" : "비활성"} + </Badge> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ScrollArea> + + <div className="flex gap-2"> + <Button + variant="outline" + onClick={() => setStep("upload")} + className="flex-1" + > + 다시 선택 + </Button> + <Button + onClick={handleImportData} + disabled={parsedData.length === 0 || errors.length > 0} + className="flex-1" + > + {errors.length > 0 ? "오류 수정 후 가져오기" : `${parsedData.length}개 조항 가져오기`} + </Button> + </div> + </div> + ) + + const renderImportingStep = () => ( + <div className="text-center py-8"> + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> + <h3 className="mt-4 text-lg font-semibold">가져오는 중...</h3> + <p className="mt-2 text-sm text-muted-foreground"> + {parsedData.length}개의 조항을 데이터베이스에 저장하고 있습니다. + </p> + </div> + ) + + const renderCompleteStep = () => ( + <div className="text-center py-8"> + <CheckCircle2 className="mx-auto h-12 w-12 text-green-500" /> + <h3 className="mt-4 text-lg font-semibold">가져오기 완료!</h3> + <p className="mt-2 text-sm text-muted-foreground"> + {parsedData.length}개의 조항이 성공적으로 가져와졌습니다. + </p> + </div> + ) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + {trigger || ( + <Button variant="outline" size="sm"> + <Upload className="mr-2 h-4 w-4" /> + Excel에서 가져오기 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden"> + <DialogHeader> + <DialogTitle>Excel에서 조항 가져오기</DialogTitle> + <DialogDescription> + Excel 파일을 사용하여 여러 조항을 한 번에 가져올 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="mt-4"> + {step === "upload" && renderUploadStep()} + {step === "preview" && renderPreviewStep()} + {step === "importing" && renderImportingStep()} + {step === "complete" && renderCompleteStep()} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/markdown-image-editor.tsx b/lib/basic-contract/gtc-vendor/markdown-image-editor.tsx new file mode 100644 index 00000000..422d8475 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/markdown-image-editor.tsx @@ -0,0 +1,360 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Upload, X, Image as ImageIcon, Eye, EyeOff } from "lucide-react" +import { toast } from "sonner" +import { cn } from "@/lib/utils" + +interface ClauseImage { + id: string + url: string + fileName: string + size: number +} + +interface MarkdownImageEditorProps { + content: string + images: ClauseImage[] + onChange: (content: string, images: ClauseImage[]) => void + placeholder?: string + rows?: number + className?: string +} + +export function MarkdownImageEditor({ + content, + images, + onChange, + placeholder = "텍스트를 입력하고, 이미지를 삽입하려면 '이미지 추가' 버튼을 클릭하세요.", + rows = 6, + className +}: MarkdownImageEditorProps) { + const [imageUploadOpen, setImageUploadOpen] = React.useState(false) + const [showPreview, setShowPreview] = React.useState(false) + const [uploading, setUploading] = React.useState(false) + const textareaRef = React.useRef<HTMLTextAreaElement>(null) + + // 이미지 업로드 핸들러 + const handleImageUpload = async (file: File) => { + if (!file.type.startsWith('image/')) { + toast.error('이미지 파일만 업로드 가능합니다.') + return + } + + if (file.size > 5 * 1024 * 1024) { // 5MB 제한 + toast.error('파일 크기는 5MB 이하여야 합니다.') + return + } + + setUploading(true) + try { + // 실제 구현에서는 서버로 업로드 + const uploadedUrl = await uploadImage(file) + const imageId = `image${Date.now()}` + + // 이미지 배열에 추가 + const newImages = [...images, { + id: imageId, + url: uploadedUrl, + fileName: file.name, + size: file.size, + }] + + // 커서 위치에 이미지 참조 삽입 + const imageRef = `![${imageId}]` + const textarea = textareaRef.current + + if (textarea) { + const start = textarea.selectionStart + const end = textarea.selectionEnd + const newContent = content.substring(0, start) + imageRef + content.substring(end) + + onChange(newContent, newImages) + + // 커서 위치를 이미지 참조 뒤로 이동 + setTimeout(() => { + textarea.focus() + textarea.setSelectionRange(start + imageRef.length, start + imageRef.length) + }, 0) + } else { + // 텍스트 끝에 추가 + const newContent = content + (content ? '\n\n' : '') + imageRef + onChange(newContent, newImages) + } + + toast.success('이미지가 추가되었습니다.') + setImageUploadOpen(false) + } catch (error) { + toast.error('이미지 업로드에 실패했습니다.') + console.error('Image upload error:', error) + } finally { + setUploading(false) + } + } + + // 이미지 제거 + const removeImage = (imageId: string) => { + // 이미지 배열에서 제거 + const newImages = images.filter(img => img.id !== imageId) + + // 텍스트에서 이미지 참조 제거 + const imageRef = `![${imageId}]` + const newContent = content.replace(new RegExp(`\\!\\[${imageId}\\]`, 'g'), '') + + onChange(newContent, newImages) + toast.success('이미지가 제거되었습니다.') + } + + // 커서 위치에 텍스트 삽입 + const insertAtCursor = (text: string) => { + const textarea = textareaRef.current + if (!textarea) return + + const start = textarea.selectionStart + const end = textarea.selectionEnd + const newContent = content.substring(0, start) + text + content.substring(end) + + onChange(newContent, images) + + // 커서 위치 조정 + setTimeout(() => { + textarea.focus() + textarea.setSelectionRange(start + text.length, start + text.length) + }, 0) + } + + return ( + <div className={cn("space-y-3", className)}> + {/* 에디터 툴바 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => setImageUploadOpen(true)} + disabled={uploading} + > + <Upload className="h-4 w-4 mr-1" /> + {uploading ? '업로드 중...' : '이미지 추가'} + </Button> + + <Button + type="button" + variant="outline" + size="sm" + onClick={() => setShowPreview(!showPreview)} + > + {showPreview ? ( + <> + <EyeOff className="h-4 w-4 mr-1" /> + 편집 + </> + ) : ( + <> + <Eye className="h-4 w-4 mr-1" /> + 미리보기 + </> + )} + </Button> + </div> + + {images.length > 0 && ( + <span className="text-xs text-muted-foreground"> + {images.length}개 이미지 첨부됨 + </span> + )} + </div> + + {/* 에디터 영역 */} + {showPreview ? ( + /* 미리보기 모드 */ + <div className="border rounded-lg p-4 bg-muted/10 min-h-[200px]"> + <div className="space-y-3"> + {renderMarkdownPreview(content, images)} + </div> + </div> + ) : ( + /* 편집 모드 */ + <div className="relative"> + <Textarea + ref={textareaRef} + value={content} + onChange={(e) => onChange(e.target.value, images)} + placeholder={placeholder} + rows={rows} + className="font-mono text-sm resize-none" + /> + + {/* 이미지 참조 안내 */} + {content.includes('![image') && ( + <div className="absolute bottom-2 right-2 text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded"> + ![imageXXX] = 이미지 삽입 위치 + </div> + )} + </div> + )} + + {/* 첨부된 이미지 목록 */} + {images.length > 0 && !showPreview && ( + <div className="space-y-2"> + <div className="text-sm font-medium">첨부된 이미지:</div> + <div className="grid grid-cols-2 md:grid-cols-4 gap-2"> + {images.map((img) => ( + <div key={img.id} className="relative group"> + <img + src={img.url} + alt={img.fileName} + className="w-full h-16 object-cover rounded border" + /> + <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded flex items-center justify-center"> + <div className="text-white text-xs text-center"> + <div className="font-medium">![{img.id}]</div> + <div className="truncate max-w-16" title={img.fileName}> + {img.fileName} + </div> + </div> + </div> + <Button + type="button" + variant="destructive" + size="sm" + className="absolute -top-1 -right-1 h-5 w-5 p-0 opacity-0 group-hover:opacity-100" + onClick={() => removeImage(img.id)} + > + <X className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + </div> + )} + + {/* 이미지 업로드 다이얼로그 */} + <Dialog open={imageUploadOpen} onOpenChange={setImageUploadOpen}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>이미지 추가</DialogTitle> + </DialogHeader> + <div className="space-y-4"> + <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6"> + <div className="flex flex-col items-center justify-center text-center"> + <ImageIcon className="h-8 w-8 text-muted-foreground mb-2" /> + <p className="text-sm text-muted-foreground mb-4"> + 이미지를 선택하거나 드래그해서 업로드하세요 + </p> + <input + type="file" + accept="image/*" + onChange={(e) => { + const file = e.target.files?.[0] + if (file) handleImageUpload(file) + }} + className="hidden" + id="image-upload" + disabled={uploading} + /> + <label + htmlFor="image-upload" + className={cn( + "cursor-pointer inline-flex items-center gap-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md", + uploading && "opacity-50 cursor-not-allowed" + )} + > + <Upload className="h-4 w-4" /> + 파일 선택 + </label> + </div> + </div> + <div className="text-xs text-muted-foreground space-y-1"> + <div>• 지원 형식: JPG, PNG, GIF, WebP</div> + <div>• 최대 크기: 5MB</div> + <div>• 현재 커서 위치에 삽입됩니다</div> + </div> + </div> + </DialogContent> + </Dialog> + </div> + ) +} + +// 마크다운 미리보기 렌더링 +function renderMarkdownPreview(content: string, images: ClauseImage[]) { + if (!content.trim()) { + return ( + <p className="text-muted-foreground italic"> + 내용을 입력하세요... + </p> + ) + } + + const parts = content.split(/(\![a-zA-Z0-9_]+\])/) + + return parts.map((part, index) => { + // 이미지 참조인지 확인 + const imageMatch = part.match(/^!\[(.+)\]$/) + + if (imageMatch) { + const imageId = imageMatch[1] + const image = images.find(img => img.id === imageId) + + if (image) { + return ( + <div key={index} className="my-3"> + <img + src={image.url} + alt={image.fileName} + className="max-w-full h-auto rounded border shadow-sm" + style={{ maxHeight: '400px' }} + /> + <p className="text-xs text-muted-foreground mt-1 text-center"> + {image.fileName} ({formatFileSize(image.size)}) + </p> + </div> + ) + } else { + return ( + <div key={index} className="my-2 p-2 bg-yellow-50 border border-yellow-200 rounded"> + <p className="text-xs text-yellow-700"> + ⚠️ 이미지를 찾을 수 없음: {part} + </p> + </div> + ) + } + } else { + // 일반 텍스트 (줄바꿈 처리) + return part.split('\n').map((line, lineIndex) => ( + <p key={`${index}-${lineIndex}`} className="text-sm"> + {line || '\u00A0'} {/* 빈 줄 처리 */} + </p> + )) + } + }) +} + +// 파일 크기 포맷팅 +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes' + + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +// 임시 이미지 업로드 함수 (실제 구현 필요) +async function uploadImage(file: File): Promise<string> { + // TODO: 실제 서버 업로드 로직 구현 + // 현재는 임시로 ObjectURL 반환 + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = (e) => { + resolve(e.target?.result as string) + } + reader.readAsDataURL(file) + }) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx b/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx new file mode 100644 index 00000000..78ddc7f7 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx @@ -0,0 +1,272 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" + +import { + Eye, + Download, + Loader2, + FileText, + RefreshCw, + Settings, + AlertCircle +} from "lucide-react" +import { toast } from "sonner" + +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { ClausePreviewViewer } from "./clause-preview-viewer" + +interface PreviewDocumentDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + clauses: GtcClauseTreeView[] + document: any + onExport?: () => void +} + +export function PreviewDocumentDialog({ + clauses, + document, + onExport, + ...props +}: PreviewDocumentDialogProps) { + const [isGenerating, setIsGenerating] = React.useState(false) + const [documentGenerated, setDocumentGenerated] = React.useState(false) + const [viewerInstance, setViewerInstance] = React.useState<any>(null) + const [hasError, setHasError] = React.useState(false) + + // 조항 통계 계산 + const stats = React.useMemo(() => { + const activeClausesCount = clauses.filter(c => c.isActive !== false).length + const topLevelCount = clauses.filter(c => !c.parentId && c.isActive !== false).length + const hasContentCount = clauses.filter(c => c.content && c.isActive !== false).length + + return { + total: activeClausesCount, + topLevel: topLevelCount, + withContent: hasContentCount, + withoutContent: activeClausesCount - hasContentCount + } + }, [clauses]) + + const handleGeneratePreview = async () => { + setIsGenerating(true) + setHasError(false) + setDocumentGenerated(false) + + try { + // 실제로는 ClausePreviewViewer에서 문서 생성을 처리하므로 + // 여기서는 상태만 관리 + console.log("🚀 문서 미리보기 생성 시작") + + // ClausePreviewViewer가 완전히 로드될 때까지 기다림 + await new Promise(resolve => setTimeout(resolve, 2000)) + + if (!hasError) { + setDocumentGenerated(true) + toast.success("문서 미리보기가 생성되었습니다.") + } + } catch (error) { + console.error("문서 생성 중 오류:", error) + setHasError(true) + toast.error("문서 생성 중 오류가 발생했습니다.") + } finally { + setIsGenerating(false) + } + } + + const handleExportDocument = () => { + if (viewerInstance) { + try { + // PDFTron의 다운로드 기능 실행 + viewerInstance.UI.downloadPdf({ + filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` + }) + toast.success("PDF 다운로드가 시작됩니다.") + } catch (error) { + console.error("다운로드 오류:", error) + toast.error("다운로드 중 오류가 발생했습니다.") + } + } else { + toast.error("뷰어가 준비되지 않았습니다.") + } + } + + const handleRegenerateDocument = () => { + console.log("🔄 문서 재생성 시작") + setDocumentGenerated(false) + setHasError(false) + handleGeneratePreview() + } + + const handleViewerSuccess = React.useCallback(() => { + setDocumentGenerated(true) + setIsGenerating(false) + setHasError(false) + }, []) + + const handleViewerError = React.useCallback(() => { + setHasError(true) + setIsGenerating(false) + setDocumentGenerated(false) + }, []) + + // 다이얼로그가 열릴 때 자동으로 미리보기 생성 + React.useEffect(() => { + if (props.open && !documentGenerated && !isGenerating && !hasError) { + const timer = setTimeout(() => { + handleGeneratePreview() + }, 300) // 다이얼로그 애니메이션 후 시작 + + return () => clearTimeout(timer) + } + }, [props.open, documentGenerated, isGenerating, hasError]) + + // 다이얼로그가 닫힐 때 상태 초기화 + React.useEffect(() => { + if (!props.open) { + setDocumentGenerated(false) + setIsGenerating(false) + setHasError(false) + setViewerInstance(null) + } + }, [props.open]) + + return ( + <Dialog {...props}> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="flex items-center gap-2"> + <Eye className="h-5 w-5" /> + 문서 미리보기 + </DialogTitle> + <DialogDescription> + 현재 조항들을 기반으로 생성된 문서를 미리보기합니다. + </DialogDescription> + </DialogHeader> + + {/* 문서 정보 및 통계 */} + <div className="flex-shrink-0 p-4 bg-muted/30 rounded-lg"> + <div className="flex items-center justify-between mb-3"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4" /> + <span className="font-medium">{document?.title || 'GTC 계약서'}</span> + <Badge variant="outline">{stats.total}개 조항</Badge> + {hasError && ( + <Badge variant="destructive" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + 오류 발생 + </Badge> + )} + </div> + <div className="flex items-center gap-2"> + {documentGenerated && !hasError && ( + <> + <Button + variant="outline" + size="sm" + onClick={handleRegenerateDocument} + disabled={isGenerating} + > + <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> + 재생성 + </Button> + {/* <Button + variant="outline" + size="sm" + onClick={handleExportDocument} + disabled={!viewerInstance} + > + <Download className="mr-2 h-3 w-3" /> + PDF 다운로드 + </Button> */} + </> + )} + {hasError && ( + <Button + variant="default" + size="sm" + onClick={handleRegenerateDocument} + disabled={isGenerating} + > + <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> + 다시 시도 + </Button> + )} + </div> + </div> + + <div className="grid grid-cols-4 gap-4 text-sm"> + <div className="text-center p-2 bg-background rounded"> + <div className="font-medium text-lg">{stats.total}</div> + <div className="text-muted-foreground">총 조항</div> + </div> + <div className="text-center p-2 bg-background rounded"> + <div className="font-medium text-lg">{stats.topLevel}</div> + <div className="text-muted-foreground">최상위 조항</div> + </div> + <div className="text-center p-2 bg-background rounded"> + <div className="font-medium text-lg text-green-600">{stats.withContent}</div> + <div className="text-muted-foreground">내용 있음</div> + </div> + <div className="text-center p-2 bg-background rounded"> + <div className="font-medium text-lg text-amber-600">{stats.withoutContent}</div> + <div className="text-muted-foreground">제목만</div> + </div> + </div> + </div> + + <Separator /> + + {/* PDFTron 뷰어 영역 */} + <div className="flex-1 min-h-0 relative"> + {isGenerating ? ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-background"> + <Loader2 className="h-8 w-8 animate-spin text-primary mb-4" /> + <p className="text-lg font-medium mb-2">문서 생성 중...</p> + <p className="text-sm text-muted-foreground"> + {stats.total}개의 조항을 배치하고 있습니다. + </p> + <p className="text-xs text-gray-400 mt-2"> + 초기화에 시간이 걸릴 수 있습니다... + </p> + </div> + ) : hasError ? ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10"> + <AlertCircle className="h-12 w-12 text-destructive mb-4" /> + <p className="text-lg font-medium mb-2 text-destructive">문서 생성 실패</p> + <p className="text-sm text-muted-foreground mb-4 text-center max-w-md"> + 문서 생성 중 오류가 발생했습니다. 네트워크 연결이나 파일 권한을 확인해주세요. + </p> + <Button onClick={handleRegenerateDocument} disabled={isGenerating}> + <RefreshCw className="mr-2 h-4 w-4" /> + 다시 시도 + </Button> + </div> + ) : documentGenerated ? ( + <ClausePreviewViewer + clauses={clauses} + document={document} + instance={viewerInstance} + setInstance={setViewerInstance} + onSuccess={handleViewerSuccess} + onError={handleViewerError} + /> + ) : ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10"> + <FileText className="h-12 w-12 text-muted-foreground mb-4" /> + <p className="text-lg font-medium mb-2">문서 미리보기 준비 중</p> + <Button onClick={handleGeneratePreview} disabled={isGenerating}> + <Eye className="mr-2 h-4 w-4" /> + 미리보기 생성 + </Button> + </div> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx b/lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx new file mode 100644 index 00000000..7d0180df --- /dev/null +++ b/lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx @@ -0,0 +1,540 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Loader, + ArrowUpDown, + ArrowUp, + ArrowDown, + GripVertical, + RotateCcw, + Info +} from "lucide-react" +import { toast } from "sonner" +import { cn } from "@/lib/utils" + +import { reorderGtcClausesSchema, type ReorderGtcClausesSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { reorderGtcClauses, getGtcClausesTree } from "@/lib/gtc-contract/gtc-clauses/service" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" + +interface ReorderGtcClausesDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + documentId: number + onSuccess?: () => void +} + +interface ClauseWithOrder extends GtcClauseTreeView { + newSortOrder: number + hasChanges: boolean + children?: ClauseWithOrder[] +} + +export function ReorderGtcClausesDialog({ + documentId, + onSuccess, + ...props +}: ReorderGtcClausesDialogProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [clauses, setClauses] = React.useState<ClauseWithOrder[]>([]) + const [originalClauses, setOriginalClauses] = React.useState<ClauseWithOrder[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [draggedItem, setDraggedItem] = React.useState<ClauseWithOrder | null>(null) + const { data: session } = useSession() + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + const form = useForm<ReorderGtcClausesSchema>({ + resolver: zodResolver(reorderGtcClausesSchema), + defaultValues: { + clauses: [], + editReason: "", + }, + }) + + // 조항 데이터 로드 + React.useEffect(() => { + if (props.open && documentId) { + loadClauses() + } + }, [props.open, documentId]) + + const loadClauses = async () => { + setIsLoading(true) + try { + const tree = await getGtcClausesTree(documentId) + const flatClauses = flattenTreeWithOrder(tree) + setClauses(flatClauses) + setOriginalClauses(JSON.parse(JSON.stringify(flatClauses))) // 깊은 복사 + } catch (error) { + console.error("Error loading clauses:", error) + toast.error("조항 목록을 불러오는 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + // 트리를 평면 배열로 변환하면서 순서 정보 추가 + const flattenTreeWithOrder = (tree: any[]): ClauseWithOrder[] => { + const result: ClauseWithOrder[] = [] + + function traverse(nodes: any[], parentId: number | null = null) { + nodes.forEach((node, index) => { + const clauseWithOrder: ClauseWithOrder = { + ...node, + newSortOrder: parseFloat(node.sortOrder), + hasChanges: false, + } + + result.push(clauseWithOrder) + + if (node.children && node.children.length > 0) { + traverse(node.children, node.id) + } + }) + } + + traverse(tree) + return result + } + + // 조항 순서 변경 + const moveClause = (clauseId: number, direction: 'up' | 'down') => { + setClauses(prev => { + const newClauses = [...prev] + const clauseIndex = newClauses.findIndex(c => c.id === clauseId) + + if (clauseIndex === -1) return prev + + const clause = newClauses[clauseIndex] + + // 같은 부모를 가진 형제 조항들 찾기 + const siblings = newClauses.filter(c => c.parentId === clause.parentId) + const siblingIndex = siblings.findIndex(c => c.id === clauseId) + + if (direction === 'up' && siblingIndex > 0) { + // 위로 이동 + const targetSibling = siblings[siblingIndex - 1] + const tempOrder = clause.newSortOrder + clause.newSortOrder = targetSibling.newSortOrder + targetSibling.newSortOrder = tempOrder + + clause.hasChanges = true + targetSibling.hasChanges = true + } else if (direction === 'down' && siblingIndex < siblings.length - 1) { + // 아래로 이동 + const targetSibling = siblings[siblingIndex + 1] + const tempOrder = clause.newSortOrder + clause.newSortOrder = targetSibling.newSortOrder + targetSibling.newSortOrder = tempOrder + + clause.hasChanges = true + targetSibling.hasChanges = true + } + + // sortOrder로 정렬 + return newClauses.sort((a, b) => { + if (a.parentId !== b.parentId) { + // 부모가 다르면 부모 기준으로 정렬 + return (a.parentId || 0) - (b.parentId || 0) + } + return a.newSortOrder - b.newSortOrder + }) + }) + } + + // 변경사항 초기화 + const resetChanges = () => { + setClauses(JSON.parse(JSON.stringify(originalClauses))) + toast.success("변경사항이 초기화되었습니다.") + } + + // 드래그 앤 드롭 핸들러 + const handleDragStart = (e: React.DragEvent, clause: ClauseWithOrder) => { + setDraggedItem(clause) + e.dataTransfer.effectAllowed = 'move' + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + } + + const handleDrop = (e: React.DragEvent, targetClause: ClauseWithOrder) => { + e.preventDefault() + + if (!draggedItem || draggedItem.id === targetClause.id) { + setDraggedItem(null) + return + } + + // 같은 부모를 가진 경우에만 순서 변경 허용 + if (draggedItem.parentId === targetClause.parentId) { + setClauses(prev => { + const newClauses = [...prev] + const draggedIndex = newClauses.findIndex(c => c.id === draggedItem.id) + const targetIndex = newClauses.findIndex(c => c.id === targetClause.id) + + if (draggedIndex !== -1 && targetIndex !== -1) { + const tempOrder = newClauses[draggedIndex].newSortOrder + newClauses[draggedIndex].newSortOrder = newClauses[targetIndex].newSortOrder + newClauses[targetIndex].newSortOrder = tempOrder + + newClauses[draggedIndex].hasChanges = true + newClauses[targetIndex].hasChanges = true + } + + return newClauses.sort((a, b) => { + if (a.parentId !== b.parentId) { + return (a.parentId || 0) - (b.parentId || 0) + } + return a.newSortOrder - b.newSortOrder + }) + }) + } + + setDraggedItem(null) + } + + async function onSubmit(data: ReorderGtcClausesSchema) { + startUpdateTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + // 변경된 조항들만 필터링 + const changedClauses = clauses.filter(c => c.hasChanges).map(c => ({ + id: c.id, + sortOrder: c.newSortOrder, + parentId: c.parentId, + depth: c.depth, + fullPath: c.fullPath, + })) + + if (changedClauses.length === 0) { + toast.error("변경된 조항이 없습니다.") + return + } + + try { + const result = await reorderGtcClauses({ + clauses: changedClauses, + editReason: data.editReason || "조항 순서 변경", + updatedById: currentUserId + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success(`${changedClauses.length}개 조항의 순서가 변경되었습니다.`) + onSuccess?.() + } catch (error) { + toast.error("조항 순서 변경 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setClauses([]) + setOriginalClauses([]) + } + props.onOpenChange?.(nextOpen) + } + + const changedCount = clauses.filter(c => c.hasChanges).length + const groupedClauses = groupClausesByParent(clauses) + + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + <DialogContent className="max-w-4xl h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="flex items-center gap-2"> + <ArrowUpDown className="h-5 w-5" /> + 조항 순서 변경 + </DialogTitle> + <DialogDescription> + 드래그 앤 드롭 또는 화살표 버튼으로 조항의 순서를 변경하세요. 같은 계층 내에서만 순서 변경이 가능합니다. + </DialogDescription> + </DialogHeader> + + {/* 상태 정보 */} + <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg flex-shrink-0"> + <div className="flex items-center gap-4 text-sm"> + <div className="flex items-center gap-2"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span>총 {clauses.length}개 조항</span> + </div> + {changedCount > 0 && ( + <Badge variant="default"> + {changedCount}개 변경됨 + </Badge> + )} + </div> + + <div className="flex gap-2"> + {changedCount > 0 && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={resetChanges} + > + <RotateCcw className="mr-2 h-4 w-4" /> + 초기화 + </Button> + )} + </div> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 조항 목록 */} + <ScrollArea className="flex-1 border rounded-lg"> + {isLoading ? ( + <div className="flex items-center justify-center h-32"> + <Loader className="h-6 w-6 animate-spin" /> + <span className="ml-2">조항을 불러오는 중...</span> + </div> + ) : ( + <div className="p-4 space-y-2"> + {Object.entries(groupedClauses).map(([parentInfo, clauses]) => ( + <ClauseGroup + key={parentInfo} + parentInfo={parentInfo} + clauses={clauses} + onMove={moveClause} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + /> + ))} + </div> + )} + </ScrollArea> + + {/* 편집 사유 */} + <div className="mt-4 flex-shrink-0"> + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="순서 변경 사유를 입력하세요..." + {...field} + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4"> + <Button + type="button" + variant="outline" + onClick={() => props.onOpenChange?.(false)} + disabled={isUpdatePending} + > + Cancel + </Button> + <Button + type="submit" + disabled={isUpdatePending || changedCount === 0} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + <ArrowUpDown className="mr-2 h-4 w-4" /> + Apply Changes ({changedCount}) + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} + +// 조항 그룹 컴포넌트 +interface ClauseGroupProps { + parentInfo: string + clauses: ClauseWithOrder[] + onMove: (clauseId: number, direction: 'up' | 'down') => void + onDragStart: (e: React.DragEvent, clause: ClauseWithOrder) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent, clause: ClauseWithOrder) => void +} + +function ClauseGroup({ + parentInfo, + clauses, + onMove, + onDragStart, + onDragOver, + onDrop +}: ClauseGroupProps) { + const isRootLevel = parentInfo === "root" + + return ( + <div className="space-y-1"> + {!isRootLevel && ( + <div className="text-sm font-medium text-muted-foreground px-2 py-1 bg-muted/30 rounded"> + {parentInfo} + </div> + )} + + {clauses.map((clause, index) => ( + <ClauseItem + key={clause.id} + clause={clause} + index={index} + isFirst={index === 0} + isLast={index === clauses.length - 1} + onMove={onMove} + onDragStart={onDragStart} + onDragOver={onDragOver} + onDrop={onDrop} + /> + ))} + </div> + ) +} + +// 개별 조항 컴포넌트 +interface ClauseItemProps { + clause: ClauseWithOrder + index: number + isFirst: boolean + isLast: boolean + onMove: (clauseId: number, direction: 'up' | 'down') => void + onDragStart: (e: React.DragEvent, clause: ClauseWithOrder) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent, clause: ClauseWithOrder) => void +} + +function ClauseItem({ + clause, + isFirst, + isLast, + onMove, + onDragStart, + onDragOver, + onDrop +}: ClauseItemProps) { + return ( + <div + className={cn( + "flex items-center gap-2 p-3 border rounded-lg bg-background", + clause.hasChanges && "border-blue-300 bg-blue-50", + "hover:bg-muted/50 transition-colors" + )} + draggable + onDragStart={(e) => onDragStart(e, clause)} + onDragOver={onDragOver} + onDrop={(e) => onDrop(e, clause)} + > + {/* 드래그 핸들 */} + <GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" /> + + {/* 조항 정보 */} + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 mb-1"> + <Badge variant="outline" className="text-xs"> + {clause.itemNumber} + </Badge> + <span className="font-medium truncate">{clause.subtitle}</span> + {clause.hasChanges && ( + <Badge variant="default" className="text-xs"> + 변경됨 + </Badge> + )} + </div> + {clause.content && ( + <p className="text-xs text-muted-foreground line-clamp-1"> + {clause.content.substring(0, 100)}... + </p> + )} + </div> + + {/* 순서 변경 버튼 */} + <div className="flex gap-1"> + <Button + type="button" + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + disabled={isFirst} + onClick={() => onMove(clause.id, 'up')} + > + <ArrowUp className="h-4 w-4" /> + </Button> + <Button + type="button" + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + disabled={isLast} + onClick={() => onMove(clause.id, 'down')} + > + <ArrowDown className="h-4 w-4" /> + </Button> + </div> + </div> + ) +} + +// 조항을 부모별로 그룹화 +function groupClausesByParent(clauses: ClauseWithOrder[]): Record<string, ClauseWithOrder[]> { + const groups: Record<string, ClauseWithOrder[]> = {} + + clauses.forEach(clause => { + const parentKey = clause.parentId + ? `${clause.parentItemNumber || 'Unknown'} - ${clause.parentSubtitle || 'Unknown'}` + : "root" + + if (!groups[parentKey]) { + groups[parentKey] = [] + } + groups[parentKey].push(clause) + }) + + // 각 그룹 내에서 sortOrder로 정렬 + Object.keys(groups).forEach(key => { + groups[key].sort((a, b) => a.newSortOrder - b.newSortOrder) + }) + + return groups +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/update-gtc-clause-sheet.tsx b/lib/basic-contract/gtc-vendor/update-gtc-clause-sheet.tsx new file mode 100644 index 00000000..3487ebbf --- /dev/null +++ b/lib/basic-contract/gtc-vendor/update-gtc-clause-sheet.tsx @@ -0,0 +1,522 @@ +// update-vendor-gtc-clause-sheet.tsx +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Info, AlertCircle } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Label } from "@/components/ui/label" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" + +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { updateVendorGtcClauseSchema, type UpdateVendorGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { updateVendorGtcClause } from "@/lib/gtc-contract/gtc-clauses/service" +import { useSession } from "next-auth/react" +import { MarkdownImageEditor } from "./markdown-image-editor" +import { Checkbox } from "@/components/ui/checkbox" + +interface ClauseImage { + id: string + url: string + fileName: string + size: number + savedName?: string + mimeType?: string + width?: number + height?: number + hash?: string +} + +export interface UpdateVendorGtcClauseSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + gtcClause: GtcClauseTreeView | null + vendorInfo?: any // 벤더 조항 정보 + documentId: number + vendorId: number + vendorName?: string +} + +export function UpdateGtcClauseSheet ({ + gtcClause, + vendorInfo, + documentId, + vendorId, + vendorName, + ...props +}: UpdateVendorGtcClauseSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const { data: session } = useSession() + const [images, setImages] = React.useState<ClauseImage[]>([]) + + console.log(vendorInfo,"vendorInfo") + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + const form = useForm<UpdateVendorGtcClauseSchema>({ + resolver: zodResolver(updateVendorGtcClauseSchema), + defaultValues: { + modifiedItemNumber: "", + modifiedCategory: "", + modifiedSubtitle: "", + modifiedContent: "", + isNumberModified: false, + isCategoryModified: false, + isSubtitleModified: false, + isContentModified: false, + reviewStatus: "draft", + negotiationNote: "", + isExcluded: false, + }, + }) + + // 벤더 정보가 있으면 초기값 세팅 + React.useEffect(() => { + if (vendorInfo) { + form.reset({ + modifiedItemNumber: vendorInfo.modifiedItemNumber || "", + modifiedCategory: vendorInfo.modifiedCategory || "", + modifiedSubtitle: vendorInfo.modifiedSubtitle || "", + modifiedContent: vendorInfo.modifiedContent || "", + isNumberModified: vendorInfo.isNumberModified || false, + isCategoryModified: vendorInfo.isCategoryModified || false, + isSubtitleModified: vendorInfo.isSubtitleModified || false, + isContentModified: vendorInfo.isContentModified || false, + reviewStatus: vendorInfo.reviewStatus || "draft", + negotiationNote: vendorInfo.negotiationNote || "", + isExcluded: vendorInfo.isExcluded || false, + }) + setImages((vendorInfo.images as any[]) || []) + } + }, [vendorInfo, form]) + + async function onSubmit(input: UpdateVendorGtcClauseSchema) { + startUpdateTransition(async () => { + if (!gtcClause || !currentUserId) { + toast.error("조항 정보를 찾을 수 없습니다.") + return + } + + try { + const result = await updateVendorGtcClause({ + baseClauseId: gtcClause.id, + documentId, + vendorId, + ...input, + images, + updatedById: currentUserId, + }) + + if (result.error) { + toast.error(result.error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("벤더 협의 내용이 저장되었습니다!") + } catch (error) { + toast.error("벤더 협의 저장 중 오류가 발생했습니다.") + } + }) + } + + const getDepthBadge = (depth: number) => { + const levels = ["1단계", "2단계", "3단계", "4단계", "5단계+"] + return levels[depth] || levels[4] + } + + const handleContentImageChange = (content: string, newImages: ClauseImage[]) => { + form.setValue("modifiedContent", content) + setImages(newImages) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col sm:max-w-2xl h-full"> + <SheetHeader className="text-left flex-shrink-0"> + <SheetTitle>벤더 GTC 조항 협의</SheetTitle> + <SheetDescription> + {vendorName ? `${vendorName}과(와)의 조항 협의 내용을 입력하세요` : '벤더별 조항 수정사항을 입력하세요'} + </SheetDescription> + </SheetHeader> + + <div className="flex-1 overflow-y-auto"> + {/* 기존 조항 정보 (읽기 전용) */} + <div className="space-y-4 p-4 bg-muted/30 rounded-lg mb-4"> + <div className="flex items-center justify-between"> + <h3 className="text-sm font-semibold">표준 조항 내용</h3> + <Badge variant="outline"> + {getDepthBadge(gtcClause?.depth || 0)} + </Badge> + </div> + + <div className="space-y-3"> + <div> + <Label className="text-xs text-muted-foreground">채번</Label> + <div className="text-sm font-mono mt-1">{gtcClause?.itemNumber}</div> + </div> + + {gtcClause?.category && ( + <div> + <Label className="text-xs text-muted-foreground">분류</Label> + <div className="text-sm mt-1">{gtcClause.category}</div> + </div> + )} + + <div> + <Label className="text-xs text-muted-foreground">소제목</Label> + <div className="text-sm font-medium mt-1">{gtcClause?.subtitle}</div> + </div> + + {gtcClause?.content && ( + <div> + <Label className="text-xs text-muted-foreground">상세항목</Label> + <div className="text-sm mt-1 p-2 bg-background rounded border"> + <pre className="whitespace-pre-wrap font-sans">{gtcClause.content}</pre> + </div> + </div> + )} + </div> + </div> + + <Separator className="my-4" /> + + {/* 벤더별 수정 폼 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 p-4"> + <div className="flex items-center gap-2 mb-4"> + <AlertCircle className="h-4 w-4 text-blue-500" /> + <p className="text-sm text-muted-foreground"> + 수정할 항목에 체크하고 내용을 입력하세요. 체크하지 않은 항목은 표준 조항의 내용을 그대로 사용합니다. + </p> + </div> + + {/* 협의 상태 */} + <FormField + control={form.control} + name="reviewStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>협의 상태</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="협의 상태를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="draft">초안</SelectItem> + <SelectItem value="pending">협의 대기</SelectItem> + <SelectItem value="reviewing">협의 중</SelectItem> + <SelectItem value="approved">승인됨</SelectItem> + <SelectItem value="rejected">거부됨</SelectItem> + <SelectItem value="revised">수정됨</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 제외 여부 */} + <FormField + control={form.control} + name="isExcluded" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel> + 이 조항을 벤더 계약에서 제외 + </FormLabel> + <FormDescription> + 체크하면 최종 계약서에서 이 조항이 제외됩니다 + </FormDescription> + </div> + </FormItem> + )} + /> + + {/* 채번 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isNumberModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 채번 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isNumberModified") && ( + <FormField + control={form.control} + name="modifiedItemNumber" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 채번 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 분류 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isCategoryModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 분류 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isCategoryModified") && ( + <FormField + control={form.control} + name="modifiedCategory" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 분류 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 소제목 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isSubtitleModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 소제목 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isSubtitleModified") && ( + <FormField + control={form.control} + name="modifiedSubtitle" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 소제목 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 내용 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isContentModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 상세항목 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isContentModified") && ( + <FormField + control={form.control} + name="modifiedContent" + render={({ field }) => ( + <FormItem> + <FormControl> + <MarkdownImageEditor + content={field.value || ""} + images={images} + onChange={handleContentImageChange} + placeholder="수정할 내용을 입력하세요..." + rows={6} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 협의 이력 표시 섹션 */} +{vendorInfo?.negotiationHistory && vendorInfo.negotiationHistory.length > 0 && ( + <div className="space-y-3 mb-4"> + <h3 className="text-sm font-semibold">협의 이력</h3> + <div className="border rounded-lg p-3 max-h-64 overflow-y-auto space-y-3"> + {vendorInfo.negotiationHistory.map((history, index) => ( + <div key={index} className="border-b pb-3 last:border-b-0 last:pb-0"> + <div className="flex justify-between text-xs text-muted-foreground mb-1"> + <div className="font-medium"> + {history.actorName || "시스템"} + {history.previousStatus && history.newStatus && ( + <span className="ml-2 text-xs"> + ({history.previousStatus} → {history.newStatus}) + </span> + )} + </div> + <div> + {new Date(history.createdAt).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + </div> + </div> + {history.comment && ( + <div className="text-sm p-2 bg-muted/30 rounded"> + {history.comment} + </div> + )} + </div> + ))} + </div> + </div> +)} + + {/* 협의 노트 */} + <FormField + control={form.control} + name="negotiationNote" + render={({ field }) => ( + <FormItem> + <FormLabel>협의 메모</FormLabel> + <FormControl> + <Textarea + placeholder="협의 과정에서의 메모나 특이사항을 기록하세요..." + {...field} + rows={3} + /> + </FormControl> + <FormDescription> + 벤더와의 협의 내용이나 변경 사유를 기록합니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + </div> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0 flex-shrink-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + + <Button + onClick={form.handleSubmit(onSubmit)} + disabled={isUpdatePending} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/gtc-vendor/view-clause-variables-dialog.tsx b/lib/basic-contract/gtc-vendor/view-clause-variables-dialog.tsx new file mode 100644 index 00000000..e500c069 --- /dev/null +++ b/lib/basic-contract/gtc-vendor/view-clause-variables-dialog.tsx @@ -0,0 +1,231 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" + +import { Eye, Copy, Check, Settings2 } from "lucide-react" +import { toast } from "sonner" +import { cn } from "@/lib/utils" + +import { type GtcClauseTreeView } from "@/db/schema/gtc" + +interface ViewClauseVariablesDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + clause: GtcClauseTreeView | null + onEditVariables?: () => void +} + +export function ViewClauseVariablesDialog({ + clause, + onEditVariables, + ...props +}: ViewClauseVariablesDialogProps) { + const [copiedField, setCopiedField] = React.useState<string | null>(null) + + const copyToClipboard = async (text: string, fieldName: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedField(fieldName) + toast.success(`${fieldName} 변수명이 복사되었습니다.`) + + // 2초 후 복사 상태 초기화 + setTimeout(() => { + setCopiedField(null) + }, 2000) + } catch (error) { + toast.error("복사 중 오류가 발생했습니다.") + } + } + + const copyAllVariables = async () => { + if (!clause) return + + const allVariables = [ + clause.autoNumberVariable, + clause.autoSubtitleVariable, + clause.autoContentVariable + ].filter(Boolean).join('\n') + + try { + await navigator.clipboard.writeText(allVariables) + toast.success("모든 변수명이 복사되었습니다.") + } catch (error) { + toast.error("복사 중 오류가 발생했습니다.") + } + } + + if (!clause) { + return null + } + + const variables = [ + { + label: "채번 변수명", + value: clause.autoNumberVariable, + fieldName: "채번", + description: "조항 번호를 표시하는 변수" + }, + { + label: "소제목 변수명", + value: clause.autoSubtitleVariable, + fieldName: "소제목", + description: "조항 제목을 표시하는 변수" + }, + { + label: "상세항목 변수명", + value: clause.autoContentVariable, + fieldName: "상세항목", + description: "조항 내용을 표시하는 변수" + } + ] + + return ( + <Dialog {...props}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Eye className="h-5 w-5" /> + PDFTron 변수명 보기 + </DialogTitle> + <DialogDescription> + 현재 조항에 설정된 PDFTron 변수명을 확인하고 복사할 수 있습니다. + </DialogDescription> + </DialogHeader> + + {/* 조항 정보 */} + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="font-medium mb-2">조항 정보</div> + <div className="space-y-1 text-sm text-muted-foreground"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{clause.itemNumber}</Badge> + <span>{clause.subtitle}</span> + <Badge variant={clause.hasAllVariableNames ? "default" : "destructive"}> + {clause.hasAllVariableNames ? "설정됨" : "미설정"} + </Badge> + </div> + {clause.fullPath && ( + <div>경로: {clause.fullPath}</div> + )} + {clause.category && ( + <div>분류: {clause.category}</div> + )} + </div> + </div> + + {/* 변수명 목록 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-sm font-medium">설정된 변수명</h3> + <Button + variant="outline" + size="sm" + onClick={copyAllVariables} + className="h-8" + > + <Copy className="mr-2 h-3 w-3" /> + 전체 복사 + </Button> + </div> + + <div className="space-y-3"> + {variables.map((variable, index) => ( + <div key={index} className="space-y-2"> + <div className="flex items-center justify-between"> + <div> + <div className="text-sm font-medium">{variable.label}</div> + <div className="text-xs text-muted-foreground">{variable.description}</div> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => copyToClipboard(variable.value, variable.fieldName)} + className="h-8 px-2" + > + {copiedField === variable.fieldName ? ( + <Check className="h-3 w-3 text-green-500" /> + ) : ( + <Copy className="h-3 w-3" /> + )} + </Button> + </div> + <div className="relative"> + <Input + value={variable.value} + readOnly + className="font-mono text-xs bg-muted/30" + /> + </div> + </div> + ))} + </div> + </div> + + {/* PDFTron 템플릿 미리보기 */} + <div className="space-y-2"> + <h3 className="text-sm font-medium">PDFTron 템플릿 미리보기</h3> + <div className="p-3 bg-gray-50 border rounded-lg"> + <div className="space-y-2 text-xs font-mono"> + <div className="text-blue-600"> + {"{{" + clause.autoNumberVariable + "}}"}. {"{{" + clause.autoSubtitleVariable + "}}"} + </div> + <div className="text-gray-600 ml-4"> + {"{{" + clause.autoContentVariable + "}}"} + </div> + </div> + <div className="text-xs text-muted-foreground mt-2"> + 실제 문서에서 위와 같은 형태로 표시됩니다. + </div> + </div> + </div> + + {/* 실제 값 미리보기 */} + <div className="space-y-2"> + <h3 className="text-sm font-medium">실제 값 미리보기</h3> + <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg"> + <div className="space-y-2 text-sm"> + <div className="font-medium text-blue-900"> + {clause.itemNumber}. {clause.subtitle} + </div> + {clause.content && ( + <div className="text-blue-800 ml-4"> + {clause.content.length > 150 + ? `${clause.content.substring(0, 150)}...` + : clause.content + } + </div> + )} + {!clause.content && ( + <div className="text-blue-600 ml-4 italic"> + (그룹핑 조항 - 상세내용 없음) + </div> + )} + </div> + </div> + </div> + + <DialogFooter> + {onEditVariables && ( + <Button + variant="outline" + onClick={() => { + props.onOpenChange?.(false) + onEditVariables() + }} + > + <Settings2 className="mr-2 h-4 w-4" /> + 변수 설정 수정 + </Button> + )} + <Button + onClick={() => props.onOpenChange?.(false)} + > + Close + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 194d27eb..8189381b 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -31,7 +31,8 @@ import { type GtcVendorClause,
type GtcClause,
projects,
- legalWorks
+ legalWorks,
+ BasicContractView, users
} from "@/db/schema";
import path from "path";
@@ -39,6 +40,9 @@ import { GetBasicContractTemplatesSchema,
CreateBasicContractTemplateSchema,
GetBasciContractsSchema,
+ GetBasciContractsVendorSchema,
+ GetBasciContractsByIdSchema,
+ updateStatusSchema,
} from "./validations";
import { readFile } from "fs/promises"
@@ -689,8 +693,8 @@ export async function getBasicContractsByVendorId( input: GetBasciContractsVendorSchema,
vendorId: number
) {
- // return unstable_cache(
- // async () => {
+ return unstable_cache(
+ async () => {
try {
const offset = (input.page - 1) * input.perPage;
@@ -757,13 +761,13 @@ export async function getBasicContractsByVendorId( // 에러 발생 시 디폴트
return { data: [], pageCount: 0 };
}
- // },
- // [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
- // {
- // revalidate: 3600,
- // tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
- // }
- // )();
+ },
+ [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
+ {
+ revalidate: 3600,
+ tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
+ }
+ )();
}
@@ -2115,10 +2119,10 @@ export async function getVendorGtcData(contractId?: number): Promise<GtcVendorDa isSubtitleModified: gtcVendorClauses.isSubtitleModified,
isContentModified: gtcVendorClauses.isContentModified,
})
- .from(gtcClauses)
+ .from(gtcClauses)
.leftJoin(gtcVendorClauses, and(
eq(gtcVendorClauses.baseClauseId, gtcClauses.id),
- vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`, // 벤더 문서가 없으면 조인하지 않음
+ vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`,
eq(gtcVendorClauses.isActive, true)
))
.where(
@@ -2129,23 +2133,57 @@ export async function getVendorGtcData(contractId?: number): Promise<GtcVendorDa )
.orderBy(gtcClauses.sortOrder);
+ let negotiationHistoryMap = new Map();
+
+ if (vendorDocument.id) {
+ const vendorClauseIds = clausesResult
+ .filter(c => c.vendorClauseId)
+ .map(c => c.vendorClauseId);
+
+ if (vendorClauseIds.length > 0) {
+ const histories = await db
+ .select({
+ vendorClauseId: gtcNegotiationHistory.vendorClauseId,
+ action: gtcNegotiationHistory.action,
+ previousStatus: gtcNegotiationHistory.previousStatus,
+ newStatus: gtcNegotiationHistory.newStatus,
+ comment: gtcNegotiationHistory.comment,
+ actorType: gtcNegotiationHistory.actorType,
+ actorId: gtcNegotiationHistory.actorId,
+ actorName: gtcNegotiationHistory.actorName,
+ actorEmail: gtcNegotiationHistory.actorEmail,
+ createdAt: gtcNegotiationHistory.createdAt,
+ changedFields: gtcNegotiationHistory.changedFields,
+ })
+ .from(gtcNegotiationHistory)
+ .leftJoin(users, eq(gtcNegotiationHistory.actorId, users.id))
+ .where(inArray(gtcNegotiationHistory.vendorClauseId, vendorClauseIds))
+ .orderBy(desc(gtcNegotiationHistory.createdAt));
+
+ // 벤더 조항별로 이력 그룹화
+ histories.forEach(history => {
+ if (!negotiationHistoryMap.has(history.vendorClauseId)) {
+ negotiationHistoryMap.set(history.vendorClauseId, []);
+ }
+ negotiationHistoryMap.get(history.vendorClauseId).push(history);
+ });
+ }
+ }
+
+
+
// 6. 데이터 변환 및 추가 정보 계산
- const clauses = clausesResult.map(clause => {
- // 벤더별 수정사항이 있는지 확인
+ const clauses = clausesResult.map(clause => {
const hasVendorData = !!clause.vendorClauseId;
+ const negotiationHistory = hasVendorData ?
+ (negotiationHistoryMap.get(clause.vendorClauseId) || []) : [];
- const hasModifications = hasVendorData && (
- clause.isNumberModified ||
- clause.isCategoryModified ||
- clause.isSubtitleModified ||
- clause.isContentModified
- );
-
- const hasComment = hasVendorData && !!clause.negotiationNote;
+ // 코멘트가 있는 이력들만 필터링
+ const commentHistory = negotiationHistory.filter(h => h.comment);
+ const latestComment = commentHistory[0]?.comment || null;
+ const hasComment = commentHistory.length > 0;
return {
- // 벤더 조항 ID (있는 경우만, 없으면 null)
- // id: clause.vendorClauseId,
id: clause.baseClauseId,
vendorClauseId: clause.vendorClauseId,
vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null,
@@ -2174,15 +2212,16 @@ export async function getVendorGtcData(contractId?: number): Promise<GtcVendorDa baseContent: clause.baseContent,
// 수정 여부
- hasModifications,
+ // hasModifications,
isNumberModified: clause.isNumberModified || false,
isCategoryModified: clause.isCategoryModified || false,
isSubtitleModified: clause.isSubtitleModified || false,
isContentModified: clause.isContentModified || false,
- // 코멘트 관련
- hasComment,
- pendingComment: null, // 클라이언트에서 관리
+ hasComment,
+ latestComment,
+ commentHistory, // 전체 코멘트 이력
+ negotiationHistory, // 전체 협의 이력
};
});
@@ -2218,8 +2257,8 @@ interface VendorDocument { export async function updateVendorClause(
baseClauseId: number,
vendorClauseId: number | null,
- clauseData: ClauseUpdateData,
- vendorDocument?: VendorDocument
+ clauseData: any,
+ vendorDocument: any
): Promise<{ success: boolean; error?: string; vendorClauseId?: number; vendorDocumentId?: number }> {
try {
const session = await getServerSession(authOptions);
@@ -2228,10 +2267,10 @@ export async function updateVendorClause( }
const companyId = session.user.companyId;
- const vendorId = companyId; // companyId를 vendorId로 사용
+ const vendorId = companyId;
const userId = Number(session.user.id);
- // 1. 기본 조항 정보 가져오기 (비교용)
+ // 1. 기본 조항 정보 가져오기
const baseClause = await db.query.gtcClauses.findFirst({
where: eq(gtcClauses.id, baseClauseId),
});
@@ -2240,11 +2279,22 @@ export async function updateVendorClause( return { success: false, error: "기본 조항을 찾을 수 없습니다." };
}
- // 2. 벤더 문서 ID 확보 (없으면 생성)
+ // 2. 이전 코멘트 가져오기 (vendorClauseId가 있는 경우)
+ let previousComment = null;
+ if (vendorClauseId) {
+ const previousData = await db
+ .select({ comment: gtcVendorClauses.negotiationNote })
+ .from(gtcVendorClauses)
+ .where(eq(gtcVendorClauses.id, vendorClauseId))
+ .limit(1);
+
+ previousComment = previousData?.[0]?.comment || null;
+ }
+
+ // 3. 벤더 문서 ID 확보 (없으면 생성)
let finalVendorDocumentId = vendorDocument?.id;
if (!finalVendorDocumentId && vendorDocument) {
- // 벤더 문서 생성
const newVendorDoc = await db.insert(gtcVendorDocuments).values({
vendorId: vendorId,
baseDocumentId: vendorDocument.baseDocumentId,
@@ -2268,7 +2318,7 @@ export async function updateVendorClause( return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." };
}
- // 3. 수정 여부 확인
+ // 4. 수정 여부 확인
const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber;
const isCategoryModified = clauseData.category !== baseClause.category;
const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle;
@@ -2277,7 +2327,7 @@ export async function updateVendorClause( const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified;
const hasComment = !!(clauseData.comment?.trim());
- // 4. 벤더 조항 데이터 준비
+ // 5. 벤더 조항 데이터 준비
const vendorClauseData = {
vendorDocumentId: finalVendorDocumentId,
baseClauseId: baseClauseId,
@@ -2286,22 +2336,19 @@ export async function updateVendorClause( sortOrder: baseClause.sortOrder,
fullPath: baseClause.fullPath,
- // 수정된 값들 (수정되지 않았으면 null로 저장)
modifiedItemNumber: isNumberModified ? clauseData.itemNumber : null,
modifiedCategory: isCategoryModified ? clauseData.category : null,
modifiedSubtitle: isSubtitleModified ? clauseData.subtitle : null,
modifiedContent: isContentModified ? clauseData.content : null,
- // 수정 여부 플래그
isNumberModified,
isCategoryModified,
isSubtitleModified,
isContentModified,
- // 상태 정보
reviewStatus: (hasAnyModifications || hasComment) ? 'reviewing' : 'draft',
negotiationNote: clauseData.comment?.trim() || null,
- editReason: clauseData.comment?.trim() || null, // 수정 이유도 동일하게 저장
+ editReason: clauseData.comment?.trim() || null,
updatedAt: new Date(),
updatedById: userId,
@@ -2309,9 +2356,8 @@ export async function updateVendorClause( let finalVendorClauseId = vendorClauseId;
- // 5. 벤더 조항 생성 또는 업데이트
+ // 6. 벤더 조항 생성 또는 업데이트
if (vendorClauseId) {
- // 기존 벤더 조항 업데이트
await db
.update(gtcVendorClauses)
.set(vendorClauseData)
@@ -2319,7 +2365,6 @@ export async function updateVendorClause( console.log(`벤더 조항 업데이트: ${vendorClauseId}`);
} else {
- // 새 벤더 조항 생성
const newVendorClause = await db.insert(gtcVendorClauses).values({
...vendorClauseData,
createdById: userId,
@@ -2333,21 +2378,24 @@ export async function updateVendorClause( console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`);
}
- // 6. 협의 이력에 기록
- if (hasAnyModifications || hasComment) {
- const historyAction = hasAnyModifications ? 'modified' : 'commented';
- const historyComment = hasAnyModifications
- ? `조항 수정: ${clauseData.comment || '수정 이유 없음'}`
- : clauseData.comment;
-
+ // 7. 협의 이력에 기록 (코멘트가 변경된 경우만)
+ if (clauseData.comment !== previousComment) {
await db.insert(gtcNegotiationHistory).values({
vendorClauseId: finalVendorClauseId,
- action: historyAction,
- comment: historyComment?.trim(),
- actorType: 'vendor',
- actorId: session.user.id,
+ action: previousComment ? "modified" : "commented",
+ comment: clauseData.comment || null,
+ previousStatus: null,
+ newStatus: 'reviewing',
+ actorType: "vendor",
+ actorId: userId,
actorName: session.user.name,
actorEmail: session.user.email,
+ changedFields: {
+ comment: {
+ from: previousComment,
+ to: clauseData.comment || null
+ }
+ }
});
}
@@ -2365,7 +2413,6 @@ export async function updateVendorClause( };
}
}
-
// 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경
export async function updateVendorClauseComment(
clauseId: number,
@@ -2635,6 +2682,140 @@ export async function requestLegalReviewAction( }
}
+export async function resendContractsAction(contractIds: number[]) {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error('인증이 필요합니다.')
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ fileName: basicContract.fileName,
+ deadline: basicContract.deadline,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .where(inArray(basicContract.id, contractIds))
+
+ if (contracts.length === 0) {
+ throw new Error('발송할 계약서를 찾을 수 없습니다.')
+ }
+
+ // 각 계약서에 대해 이메일 발송
+ const emailPromises = contracts.map(async (contract) => {
+ // 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ country: vendors.country,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, contract.vendorId!))
+ .limit(1)
+
+ if (!vendor[0]) {
+ console.error(`벤더를 찾을 수 없습니다: vendorId ${contract.vendorId}`)
+ return null
+ }
+
+ // 벤더 연락처 조회 (Primary 연락처 우선, 없으면 첫 번째 연락처)
+ const contacts = await db
+ .select({
+ contactName: vendorContacts.contactName,
+ contactEmail: vendorContacts.contactEmail,
+ isPrimary: vendorContacts.isPrimary,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendor[0].id))
+ .orderBy(vendorContacts.isPrimary)
+
+ // 이메일 수신자 결정 (Primary 연락처 > 첫 번째 연락처 > 벤더 기본 이메일)
+ const primaryContact = contacts.find(c => c.isPrimary)
+ const recipientEmail = primaryContact?.contactEmail || contacts[0]?.contactEmail || vendor[0].email
+ const recipientName = primaryContact?.contactName || contacts[0]?.contactName || vendor[0].vendorName
+
+ if (!recipientEmail) {
+ console.error(`이메일 주소를 찾을 수 없습니다: vendorId ${vendor[0].id}`)
+ return null
+ }
+
+ // 언어 결정 (한국 = 한글, 그 외 = 영어)
+ const isKorean = vendor[0].country === 'KR'
+ const template = isKorean ? 'contract-reminder-kr' : 'contract-reminder-en'
+ const subject = isKorean
+ ? '[eVCP] 계약서 서명 요청 리마인더'
+ : '[eVCP] Contract Signature Reminder'
+
+ // 마감일 포맷팅
+ const deadlineDate = new Date(contract.deadline)
+ const formattedDeadline = isKorean
+ ? `${deadlineDate.getFullYear()}년 ${deadlineDate.getMonth() + 1}월 ${deadlineDate.getDate()}일`
+ : deadlineDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
+
+ // 남은 일수 계산
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ const deadline = new Date(contract.deadline)
+ deadline.setHours(0, 0, 0, 0)
+ const daysRemaining = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
+
+ // 이메일 발송
+ await sendEmail({
+ from: session.user.email,
+ to: recipientEmail,
+ subject,
+ template,
+ context: {
+ recipientName,
+ vendorName: vendor[0].vendorName,
+ vendorCode: vendor[0].vendorCode,
+ contractFileName: contract.fileName,
+ deadline: formattedDeadline,
+ daysRemaining,
+ senderName: session.user.name || session.user.email,
+ senderEmail: session.user.email,
+ // 계약서 링크 (실제 환경에 맞게 수정 필요)
+ contractLink: `${process.env.NEXT_PUBLIC_APP_URL}/contracts/${contract.id}`,
+ },
+ })
+
+ console.log(`리마인더 이메일 발송 완료: ${recipientEmail} (계약서 ID: ${contract.id})`)
+ return { contractId: contract.id, email: recipientEmail }
+ })
+
+ const results = await Promise.allSettled(emailPromises)
+
+ // 성공/실패 카운트
+ const successful = results.filter(r => r.status === 'fulfilled' && r.value !== null).length
+ const failed = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value === null)).length
+
+ if (failed > 0) {
+ console.warn(`${failed}건의 이메일 발송 실패`)
+ }
+
+ return {
+ success: true,
+ message: `${successful}건의 리마인더 이메일을 발송했습니다.`,
+ successful,
+ failed,
+ }
+
+ } catch (error) {
+ console.error('계약서 재발송 중 오류:', error)
+ throw new Error('계약서 재발송 중 오류가 발생했습니다.')
+ }
+}
+
+
export async function processBuyerSignatureAction(
contractId: number,
signedFileData: ArrayBuffer,
@@ -2962,4 +3143,267 @@ export async function getVendorSignatureFile() { error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다."
}
}
-}
\ No newline at end of file +}
+
+
+
+
+// templateName에서 project code 추출
+function extractProjectCodeFromTemplateName(templateName: string): string | null {
+ if (!templateName.includes('GTC')) return null;
+ if (templateName.toLowerCase().includes('general')) return null;
+
+ // GTC 앞의 문자열을 추출
+ const gtcIndex = templateName.indexOf('GTC');
+ if (gtcIndex > 0) {
+ const beforeGTC = templateName.substring(0, gtcIndex).trim();
+ // 마지막 단어를 project code로 간주
+ const words = beforeGTC.split(/\s+/);
+ return words[words.length - 1];
+ }
+
+ return null;
+}
+
+// 단일 contract에 대한 GTC 정보 확인
+async function checkGTCCommentsForContract(
+ templateName: string,
+ vendorId: number
+): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> {
+ try {
+ const projectCode = extractProjectCodeFromTemplateName(templateName);
+ let gtcDocumentId: number | null = null;
+
+ console.log(projectCode,"projectCode")
+
+ // 1. GTC Document ID 찾기
+ if (projectCode && projectCode.trim() !== '') {
+ // Project GTC인 경우
+ const project = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(eq(projects.code, projectCode.trim()))
+ .limit(1)
+
+ if (project.length > 0) {
+ const projectGtcDoc = await db
+ .select({ id: gtcDocuments.id })
+ .from(gtcDocuments)
+ .where(
+ and(
+ eq(gtcDocuments.projectId, project[0].id),
+ eq(gtcDocuments.isActive, true)
+ )
+ )
+ .orderBy(desc(gtcDocuments.revision))
+ .limit(1)
+
+ if (projectGtcDoc.length > 0) {
+ gtcDocumentId = projectGtcDoc[0].id
+ }
+ }
+ } else {
+ // Standard GTC인 경우 (general 포함하거나 project code가 없는 경우)
+ const standardGtcDoc = await db
+ .select({ id: gtcDocuments.id })
+ .from(gtcDocuments)
+ .where(
+ and(
+ eq(gtcDocuments.type, "standard"),
+ eq(gtcDocuments.isActive, true),
+ isNull(gtcDocuments.projectId)
+ )
+ )
+ .orderBy(desc(gtcDocuments.revision))
+ .limit(1)
+
+ if (standardGtcDoc.length > 0) {
+ gtcDocumentId = standardGtcDoc[0].id
+ }
+ }
+
+ console.log(gtcDocumentId,"gtcDocumentId")
+
+ // GTC Document를 찾지 못한 경우
+ if (!gtcDocumentId) {
+ return { gtcDocumentId: null, hasComments: false };
+ }
+
+ // 2. 코멘트 존재 여부 확인
+ // gtcDocumentId로 해당 벤더의 vendor documents 찾기
+ const vendorDocuments = await db
+ .select({ id: gtcVendorDocuments.id })
+ .from(gtcVendorDocuments)
+ .where(
+ and(
+ eq(gtcVendorDocuments.baseDocumentId, gtcDocumentId),
+ eq(gtcVendorDocuments.vendorId, vendorId),
+ eq(gtcVendorDocuments.isActive, true)
+ )
+ )
+ .limit(1)
+
+ if (vendorDocuments.length === 0) {
+ return { gtcDocumentId, hasComments: false };
+ }
+
+ // vendor document에 연결된 clauses에서 negotiation history 확인
+ const commentsExist = await db
+ .select({ count: gtcNegotiationHistory.id })
+ .from(gtcNegotiationHistory)
+ .innerJoin(
+ gtcVendorClauses,
+ eq(gtcNegotiationHistory.vendorClauseId, gtcVendorClauses.id)
+ )
+ .where(
+ and(
+ eq(gtcVendorClauses.vendorDocumentId, vendorDocuments[0].id),
+ eq(gtcVendorClauses.isActive, true),
+ isNotNull(gtcNegotiationHistory.comment),
+ ne(gtcNegotiationHistory.comment, '')
+ )
+ )
+ .limit(1)
+
+ return {
+ gtcDocumentId,
+ hasComments: commentsExist.length > 0
+ };
+
+ } catch (error) {
+ console.error('Error checking GTC comments for contract:', error);
+ return { gtcDocumentId: null, hasComments: false };
+ }
+}
+
+// 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집
+export async function checkGTCCommentsForContracts(
+ contracts: BasicContractView[]
+): Promise<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>> {
+ const gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> = {};
+
+ // GTC가 포함된 contract만 필터링
+ const gtcContracts = contracts.filter(contract =>
+ contract.templateName?.includes('GTC')
+ );
+
+ if (gtcContracts.length === 0) {
+ return gtcData;
+ }
+
+ // Promise.all을 사용해서 병렬 처리
+ const checkPromises = gtcContracts.map(async (contract) => {
+ try {
+ const result = await checkGTCCommentsForContract(
+ contract.templateName!,
+ contract.vendorId!
+ );
+
+ return {
+ contractId: contract.id,
+ gtcDocumentId: result.gtcDocumentId,
+ hasComments: result.hasComments
+ };
+ } catch (error) {
+ console.error(`Error checking GTC for contract ${contract.id}:`, error);
+ return {
+ contractId: contract.id,
+ gtcDocumentId: null,
+ hasComments: false
+ };
+ }
+ });
+
+ const results = await Promise.all(checkPromises);
+
+ // 결과를 Record 형태로 변환
+ results.forEach(({ contractId, gtcDocumentId, hasComments }) => {
+ gtcData[contractId] = { gtcDocumentId, hasComments };
+ });
+
+ return gtcData;
+}
+
+
+
+export async function updateVendorDocumentStatus(
+ formData: FormData | {
+ status: string;
+ vendorDocumentId: number;
+ documentId: number;
+ vendorId: number;
+ }
+) {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return { success: false, error: "인증되지 않은 사용자입니다." }
+ }
+
+ // 데이터 파싱
+ const rawData = formData instanceof FormData
+ ? {
+ status: formData.get("status") as string,
+ vendorDocumentId: Number(formData.get("vendorDocumentId")),
+ documentId: Number(formData.get("documentId")),
+ vendorId: Number(formData.get("vendorId")),
+ }
+ : formData
+
+ // 유효성 검사
+ const validatedData = updateStatusSchema.safeParse(rawData)
+ if (!validatedData.success) {
+ return { success: false, error: "유효하지 않은 데이터입니다." }
+ }
+
+ const { status, vendorDocumentId, documentId, vendorId } = validatedData.data
+
+ // 완료 상태로 변경 시, 모든 조항이 approved 상태인지 확인
+ if (status === "complete") {
+ // 승인되지 않은 조항 확인
+ const pendingClauses = await db
+ .select({ id: gtcVendorClauses.id })
+ .from(gtcVendorClauses)
+ .where(
+ and(
+ eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId),
+ eq(gtcVendorClauses.isActive, true),
+ not(eq(gtcVendorClauses.reviewStatus, "approved")),
+ not(eq(gtcVendorClauses.isExcluded, true)) // 제외된 조항은 검사에서 제외
+ )
+ )
+ .limit(1)
+
+ if (pendingClauses.length > 0) {
+ return {
+ success: false,
+ error: "모든 조항이 승인되어야 협의 완료 처리가 가능합니다."
+ }
+ }
+ }
+
+ // 업데이트 실행
+ await db
+ .update(gtcVendorDocuments)
+ .set({
+ reviewStatus: status,
+ updatedAt: new Date(),
+ updatedById: Number(session.user.id),
+ // 완료 처리 시 협의 종료일 설정
+ ...(status === "complete" ? {
+ negotiationEndDate: new Date(),
+ approvalDate: new Date()
+ } : {})
+ })
+ .where(eq(gtcVendorDocuments.id, vendorDocumentId))
+
+ // 캐시 무효화
+ // revalidatePath(`/evcp/gtc/${documentId}?vendorId=${vendorId}`)
+
+ return { success: true }
+ } catch (error) {
+ console.error("Error updating vendor document status:", error)
+ return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }
+ }
+}
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx index 3b5cdd21..37ae135c 100644 --- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx +++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx @@ -21,7 +21,7 @@ import { Badge } from "@/components/ui/badge" import { Textarea } from "@/components/ui/textarea" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" -import { prepareFinalApprovalAction, quickFinalApprovalAction, requestLegalReviewAction } from "../service" +import { prepareFinalApprovalAction, quickFinalApprovalAction, requestLegalReviewAction, resendContractsAction } from "../service" import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog" interface BasicContractDetailTableToolbarActionsProps { @@ -222,12 +222,12 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD setFinalApproveDialog(true) } - // 재발송 확인 + // 재요청 확인 const confirmResend = async () => { setLoading(true) try { // TODO: 서버액션 호출 - // await resendContractsAction(resendContracts.map(c => c.id)) + await resendContractsAction(resendContracts.map(c => c.id)) console.log("대량 재발송:", resendContracts) toast.success(`${resendContracts.length}건의 계약서 재발송을 완료했습니다`) @@ -339,7 +339,7 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD </span> </Button> - {/* 재발송 버튼 */} + {/* 재요청 버튼 */} <Button variant="outline" size="sm" @@ -350,7 +350,7 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD > <Mail className="size-4" aria-hidden="true" /> <span className="hidden sm:inline"> - 재발송 {hasSelectedRows ? `(${selectedRows.length})` : ''} + 재요청 {hasSelectedRows ? `(${selectedRows.length})` : ''} </span> </Button> diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx index 80c39d1e..9a140b27 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx @@ -1,4 +1,4 @@ -// basic-contracts-detail-columns.tsx +// simple-basic-contracts-detail-columns.tsx "use client" import * as React from "react" @@ -10,7 +10,16 @@ import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { Button } from "@/components/ui/button" -import { MoreHorizontal, Download, Eye, Mail, FileText, Clock } from "lucide-react" +import { + MoreHorizontal, + Download, + Eye, + Mail, + FileText, + Clock, + MessageCircle, + Loader2 +} from "lucide-react" import { DropdownMenu, DropdownMenuContent, @@ -20,11 +29,18 @@ import { import { BasicContractView } from "@/db/schema" import { downloadFile, quickPreview } from "@/lib/file-download" import { toast } from "sonner" +import { useRouter } from "next/navigation" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>> + gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> + isLoadingGtcData: boolean + router: NextRouter; } +type NextRouter = ReturnType<typeof useRouter>; + + const CONTRACT_STATUS_CONFIG = { PENDING: { label: "발송완료", color: "gray" }, VENDOR_SIGNED: { label: "협력업체 서명완료", color: "blue" }, @@ -35,7 +51,12 @@ const CONTRACT_STATUS_CONFIG = { REJECTED: { label: "거절됨", color: "red" }, } as const -export function getDetailColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] { +export function getDetailColumns({ + setRowAction, + gtcData, + isLoadingGtcData, + router +}: GetColumnsProps): ColumnDef<BasicContractView>[] { const selectColumn: ColumnDef<BasicContractView> = { id: "select", @@ -163,7 +184,7 @@ export function getDetailColumns({ setRowAction }: GetColumnsProps): ColumnDef<B minSize: 120, }, - // 업체명 + // 업체명 (GTC 정보 포함) { accessorKey: "vendorName", header: ({ column }) => ( @@ -171,11 +192,51 @@ export function getDetailColumns({ setRowAction }: GetColumnsProps): ColumnDef<B ), cell: ({ row }) => { const name = row.getValue("vendorName") as string | null + const contract = row.original + const isGTCTemplate = contract.templateName?.includes('GTC') + const contractGtcData = gtcData[contract.id] + + const handleOpenGTC = (e: React.MouseEvent) => { + e.stopPropagation() + if (contractGtcData?.gtcDocumentId) { + const gtcUrl = `/evcp/basic-contract/vendor-gtc/${contractGtcData.gtcDocumentId}?vendorId=${contract.vendorId}&vendorName=${encodeURIComponent(contract.vendorName || '')}&contractId=${contract.id}&templateId=${contract.templateId}` + window.open(gtcUrl, '_blank') + } + } + return ( + <div className="flex items-center gap-2"> <div className="font-medium">{name || "-"}</div> + {isGTCTemplate && ( + <div className="flex items-center gap-1"> + {isLoadingGtcData ? ( + <Loader2 className="h-3 w-3 animate-spin text-gray-400" /> + ) : contractGtcData ? ( + <div className="flex items-center gap-1"> + {contractGtcData.hasComments && ( + <Badge + variant="secondary" + className="text-xs bg-orange-100 text-orange-700 cursor-pointer hover:bg-orange-200" + title={`GTC Document ID: ${contractGtcData.gtcDocumentId} - 클릭하여 협의이력 보기`} + onClick={handleOpenGTC} + > + <MessageCircle className="h-3 w-3 mr-1" /> + 협의이력 + </Badge> + )} + + </div> + ) : ( + <Badge variant="secondary" className="text-xs"> + GTC + </Badge> + )} + </div> + )} + </div> ) }, - minSize: 180, + minSize: 250, }, // 진행상태 @@ -349,7 +410,6 @@ export function getDetailColumns({ setRowAction }: GetColumnsProps): ColumnDef<B minSize: 120, }, - // 서명된 파일 { accessorKey: "signedFileName", diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx index 2698842e..f18359de 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx @@ -9,9 +9,11 @@ import type { DataTableRowAction, } from "@/types/table" import { getDetailColumns } from "./basic-contracts-detail-columns" -import { getBasicContractsByTemplateId } from "@/lib/basic-contract/service" +import { getBasicContractsByTemplateId, checkGTCCommentsForContracts } from "@/lib/basic-contract/service" import { BasicContractView } from "@/db/schema" import { BasicContractDetailTableToolbarActions } from "./basic-contract-detail-table-toolbar-actions" +import { toast } from "sonner" +import { useRouter } from "next/navigation" interface BasicContractsDetailTableProps { templateId: number @@ -25,14 +27,52 @@ interface BasicContractsDetailTableProps { export function BasicContractsDetailTable({ templateId, promises }: BasicContractsDetailTableProps) { const [rowAction, setRowAction] = React.useState<DataTableRowAction<BasicContractView> | null>(null) + + // GTC data 상태 관리 + const [gtcData, setGtcData] = React.useState<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>>({}) + const [isLoadingGtcData, setIsLoadingGtcData] = React.useState(false) const [{ data, pageCount }] = React.use(promises) + const router = useRouter() - console.log(data,"data") + console.log(gtcData, "gtcData") + console.log(data, "data") + + // GTC data 로딩 + React.useEffect(() => { + const loadGtcData = async () => { + if (!data || data.length === 0) return; + + // GTC가 포함된 template이 있는지 확인 + const hasGtcTemplates = data.some(contract => + contract.templateName?.includes('GTC') + ); + + if (!hasGtcTemplates) return; + + setIsLoadingGtcData(true); + try { + const gtcResults = await checkGTCCommentsForContracts(data); + setGtcData(gtcResults); + } catch (error) { + console.error('Error checking GTC data:', error); + toast.error("GTC 데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoadingGtcData(false); + } + }; + + loadGtcData(); + }, [data]); const columns = React.useMemo( - () => getDetailColumns({ setRowAction }), - [setRowAction] + () => getDetailColumns({ + setRowAction, + gtcData, + isLoadingGtcData , + router + }), + [setRowAction, gtcData, isLoadingGtcData, router] ) const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [ @@ -76,15 +116,13 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac }) return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - > - <BasicContractDetailTableToolbarActions table={table} /> - </DataTableAdvancedToolbar> - </DataTable> - </> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <BasicContractDetailTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> ) }
\ No newline at end of file diff --git a/lib/basic-contract/validations.ts b/lib/basic-contract/validations.ts index 53738dfc..d558ed84 100644 --- a/lib/basic-contract/validations.ts +++ b/lib/basic-contract/validations.ts @@ -146,3 +146,13 @@ export const searchParamsCacheByTemplateId = createSearchParamsCache({ export type GetBasciContractsByIdSchema = Awaited<ReturnType<typeof searchParamsCacheByTemplateId.parse>>;
+
+// 상태 업데이트를 위한 스키마
+export const updateStatusSchema = z.object({
+ status: z.enum(["draft", "reviewing", "approved"]),
+ vendorDocumentId: z.number(),
+ documentId: z.number(),
+ vendorId: z.number(),
+})
+
+
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index f70bed94..fa68c9c8 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -51,9 +51,9 @@ interface BasicContractSignDialogProps { onOpenChange?: (open: boolean) => void; } -export function BasicContractSignDialog({ - contracts, - onSuccess, +export function BasicContractSignDialog({ + contracts, + onSuccess, hasSelectedRows = false, mode = 'vendor', onBuyerSignComplete, @@ -68,19 +68,26 @@ export function BasicContractSignDialog({ const [instance, setInstance] = React.useState<null | WebViewerInstance>(null); const [searchTerm, setSearchTerm] = React.useState(""); const [isSubmitting, setIsSubmitting] = React.useState(false); - + // 추가된 state들 const [additionalFiles, setAdditionalFiles] = React.useState<any[]>([]); const [isLoadingAttachments, setIsLoadingAttachments] = React.useState(false); - + // 계약서 상태 관리 const [contractStatuses, setContractStatuses] = React.useState<ContractStatus[]>([]); - + // 서명/설문/GTC 코멘트 완료 상태 관리 const [surveyCompletionStatus, setSurveyCompletionStatus] = React.useState<Record<number, boolean>>({}); const [signatureStatus, setSignatureStatus] = React.useState<Record<number, boolean>>({}); - const [gtcCommentStatus, setGtcCommentStatus] = React.useState<Record<number, { hasComments: boolean; commentCount: number }>>({}); - + const [gtcCommentStatus, setGtcCommentStatus] = React.useState<Record<number, { + hasComments: boolean; + commentCount: number; + reviewStatus?: string; + isComplete?: boolean; + }>>({}); + + console.log(gtcCommentStatus, "gtcCommentStatus") + const router = useRouter() // 실제 사용할 open 상태 (외부 제어가 있으면 외부 상태 사용, 없으면 내부 상태 사용) @@ -91,7 +98,7 @@ export function BasicContractSignDialog({ const isBuyerMode = mode === 'buyer'; const dialogTitle = isBuyerMode ? "구매자 최종승인 서명" : t("basicContracts.dialog.title"); const signButtonText = isBuyerMode ? "최종승인 완료" : "서명 완료 및 저장"; - + // 버튼 비활성화 조건 const isButtonDisabled = !hasSelectedRows || contracts.length === 0; @@ -107,27 +114,31 @@ export function BasicContractSignDialog({ }; // 현재 선택된 계약서의 서명 완료 가능 여부 확인 - const canCompleteCurrentContract = React.useMemo(() => { - if (!selectedContract) return false; - - const contractId = selectedContract.id; - - // 구매자 모드에서는 설문조사나 GTC 체크 불필요 - if (isBuyerMode) { - const signatureCompleted = signatureStatus[contractId] === true; - return signatureCompleted; - } - - // 협력업체 모드의 기존 로직 - const isComplianceTemplate = selectedContract.templateName?.includes('준법'); - const isGTCTemplate = selectedContract.templateName?.includes('GTC'); - - const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; - const gtcCompleted = isGTCTemplate ? (gtcCommentStatus[contractId]?.hasComments !== true) : true; +const canCompleteCurrentContract = React.useMemo(() => { + if (!selectedContract) return false; + + const contractId = selectedContract.id; + + if (isBuyerMode) { const signatureCompleted = signatureStatus[contractId] === true; - - return surveyCompleted && gtcCompleted && signatureCompleted; - }, [selectedContract, surveyCompletionStatus, signatureStatus, gtcCommentStatus, isBuyerMode]); + return signatureCompleted; + } + + const isComplianceTemplate = selectedContract.templateName?.includes('준법'); + const isGTCTemplate = selectedContract.templateName?.includes('GTC'); + + const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; + + // GTC 체크 수정 + const gtcStatus = gtcCommentStatus[contractId]; + const gtcCompleted = isGTCTemplate ? + (!gtcStatus?.hasComments || gtcStatus?.isComplete === true) : true; + + const signatureCompleted = signatureStatus[contractId] === true; + + return surveyCompleted && gtcCompleted && signatureCompleted; +}, [selectedContract, surveyCompletionStatus, signatureStatus, gtcCommentStatus, isBuyerMode]); + // 계약서별 상태 초기화 React.useEffect(() => { @@ -147,7 +158,7 @@ export function BasicContractSignDialog({ const allCompleted = completedCount === totalCount && totalCount > 0; // 현재 선택된 계약서의 상태 - const currentContractStatus = selectedContract + const currentContractStatus = selectedContract ? contractStatuses.find(status => status.id === selectedContract.id) : null; @@ -155,7 +166,7 @@ export function BasicContractSignDialog({ const getNextPendingContract = () => { const pendingStatuses = contractStatuses.filter(status => status.status === 'pending'); if (pendingStatuses.length === 0) return null; - + const nextPendingId = pendingStatuses[0].id; return contracts.find(contract => contract.id === nextPendingId) || null; }; @@ -169,14 +180,14 @@ export function BasicContractSignDialog({ ); if (!confirmClose) return; } - + // 외부 제어가 있으면 외부 콜백 호출, 없으면 내부 상태 업데이트 if (isControlledExternally && externalOnOpenChange) { externalOnOpenChange(isOpen); } else { setInternalOpen(isOpen); } - + if (!isOpen) { // 다이얼로그 닫을 때 상태 초기화 setSelectedContract(null); @@ -226,7 +237,7 @@ export function BasicContractSignDialog({ } } }, [open, contracts, selectedContract, contractStatuses]); - + // 추가 파일 가져오기 useEffect (구매자 모드에서는 스킵) React.useEffect(() => { if (isBuyerMode) { @@ -285,11 +296,17 @@ export function BasicContractSignDialog({ }, []); // GTC 코멘트 상태 변경 콜백 함수 - const handleGtcCommentStatusChange = React.useCallback((contractId: number, hasComments: boolean, commentCount: number) => { - console.log(`📋 GTC 코멘트 상태 변경: 계약서 ${contractId}, 코멘트 ${commentCount}개`); + const handleGtcCommentStatusChange = React.useCallback(( + contractId: number, + hasComments: boolean, + commentCount: number, + reviewStatus?: string, + isComplete?: boolean + ) => { + console.log(`📋 GTC 상태 변경: 계약서 ${contractId}, 코멘트 ${commentCount}개, 상태: ${reviewStatus}, 완료: ${isComplete}`); setGtcCommentStatus(prev => ({ ...prev, - [contractId]: { hasComments, commentCount } + [contractId]: { hasComments, commentCount, reviewStatus, isComplete } })); }, []); @@ -300,10 +317,10 @@ export function BasicContractSignDialog({ // 서명 완료 가능 여부 재확인 if (!canCompleteCurrentContract) { const contractId = selectedContract.id; - + if (isBuyerMode) { const signatureCompleted = signatureStatus[contractId] === true; - + if (!signatureCompleted) { toast.error("계약서에 서명을 먼저 완료해주세요.", { description: "문서의 서명 필드에 서명해주세요.", @@ -316,9 +333,9 @@ export function BasicContractSignDialog({ const isComplianceTemplate = selectedContract.templateName?.includes('준법'); const isGTCTemplate = selectedContract.templateName?.includes('GTC'); const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; - const gtcCompleted = isGTCTemplate ? (gtcCommentStatus[contractId]?.hasComments !== true) : true; + const gtcCompleted = isGTCTemplate ? (gtcCommentStatus[contractId]?.isComplete !== true) : true; const signatureCompleted = signatureStatus[contractId] === true; - + if (!surveyCompleted) { toast.error("준법 설문조사를 먼저 완료해주세요.", { description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.", @@ -326,7 +343,7 @@ export function BasicContractSignDialog({ }); return; } - + if (!gtcCompleted) { toast.error("GTC 조항에 코멘트가 있어 서명할 수 없습니다.", { description: "조항 검토 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.", @@ -334,7 +351,7 @@ export function BasicContractSignDialog({ }); return; } - + if (!signatureCompleted) { toast.error("계약서에 서명을 먼저 완료해주세요.", { description: "문서의 서명 필드에 서명해주세요.", @@ -343,7 +360,7 @@ export function BasicContractSignDialog({ return; } } - + return; } @@ -376,9 +393,9 @@ export function BasicContractSignDialog({ if (result.success) { // 성공시 해당 계약서 상태를 완료로 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id + setContractStatuses(prev => + prev.map(status => + status.id === selectedContract.id ? { ...status, status: 'completed' as const } : status ) @@ -413,9 +430,9 @@ export function BasicContractSignDialog({ router.refresh(); } else { // 실패시 에러 상태 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id + setContractStatuses(prev => + prev.map(status => + status.id === selectedContract.id ? { ...status, status: 'error' as const, errorMessage: result.message } : status ) @@ -432,26 +449,26 @@ export function BasicContractSignDialog({ submitFormData.append('file', new Blob([data], { type: 'application/pdf' })); submitFormData.append('tableRowId', selectedContract.id.toString()); submitFormData.append('templateName', selectedContract.signedFileName || ''); - + // 폼 필드 데이터 추가 if (Object.keys(formData).length > 0) { submitFormData.append('formData', JSON.stringify(formData)); } - + // API 호출 const response = await fetch('/api/upload/signed-contract', { method: 'POST', body: submitFormData, next: { tags: ["basicContractView-vendor"] }, }); - + const result = await response.json(); if (result.result) { // 성공시 해당 계약서 상태를 완료로 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id + setContractStatuses(prev => + prev.map(status => + status.id === selectedContract.id ? { ...status, status: 'completed' as const } : status ) @@ -481,9 +498,9 @@ export function BasicContractSignDialog({ router.refresh(); } else { // 실패시 에러 상태 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id + setContractStatuses(prev => + prev.map(status => + status.id === selectedContract.id ? { ...status, status: 'error' as const, errorMessage: result.error } : status ) @@ -497,11 +514,11 @@ export function BasicContractSignDialog({ } } catch (error) { console.error("서명 완료 중 오류:", error); - + // 에러 상태 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id + setContractStatuses(prev => + prev.map(status => + status.id === selectedContract.id ? { ...status, status: 'error' as const, errorMessage: '서명 처리 중 오류가 발생했습니다' } : status ) @@ -519,10 +536,10 @@ export function BasicContractSignDialog({ if (onSuccess) { onSuccess(); } - const successMessage = isBuyerMode - ? "모든 계약서 최종승인이 완료되었습니다!" + const successMessage = isBuyerMode + ? "모든 계약서 최종승인이 완료되었습니다!" : "모든 계약서 서명이 완료되었습니다!"; - + toast.success(successMessage, { description: "계약서 관리 페이지가 새고침됩니다.", icon: <Trophy className="h-5 w-5 text-yellow-500" /> @@ -540,33 +557,33 @@ export function BasicContractSignDialog({ disabled={isButtonDisabled} className={cn( "gap-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed", - isBuyerMode - ? "hover:bg-green-50 hover:text-green-600 hover:border-green-200" + isBuyerMode + ? "hover:bg-green-50 hover:text-green-600 hover:border-green-200" : "hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200" )} > {isBuyerMode ? ( - <Shield - className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-green-500'}`} - aria-hidden="true" + <Shield + className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-green-500'}`} + aria-hidden="true" /> ) : ( - <Upload - className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-blue-500'}`} - aria-hidden="true" + <Upload + className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-blue-500'}`} + aria-hidden="true" /> )} <span className="hidden sm:inline flex items-center"> - {isBuyerMode ? "구매자 승인" : t("basicContracts.toolbar.sign")} + {isBuyerMode ? "구매자 서명" : t("basicContracts.toolbar.sign")} {contracts.length > 0 && !isButtonDisabled && ( - <Badge - variant="secondary" + <Badge + variant="secondary" className={cn( "ml-2", - isBuyerMode - ? "bg-green-100 text-green-700 hover:bg-green-200" + isBuyerMode + ? "bg-green-100 text-green-700 hover:bg-green-200" : "bg-blue-100 text-blue-700 hover:bg-blue-200" - )}최 + )} > {contracts.length} </Badge> @@ -582,12 +599,12 @@ export function BasicContractSignDialog({ {/* 서명 다이얼로그 */} <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-7xl w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden" style={{width:'95vw', maxWidth:'95vw'}}> + <DialogContent className="max-w-7xl w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden" style={{ width: '95vw', maxWidth: '95vw' }}> {/* 고정 헤더 - 진행 상황 표시 */} <DialogHeader className={cn( "px-6 py-4 border-b flex-shrink-0", - isBuyerMode - ? "bg-gradient-to-r from-green-50 to-emerald-50" + isBuyerMode + ? "bg-gradient-to-r from-green-50 to-emerald-50" : "bg-gradient-to-r from-blue-50 to-purple-50" )}> <DialogTitle className="text-xl font-bold flex items-center justify-between text-gray-800"> @@ -599,12 +616,12 @@ export function BasicContractSignDialog({ )} {dialogTitle} {/* 진행 상황 표시 */} - <Badge - variant="outline" + <Badge + variant="outline" className={cn( "ml-3", - isBuyerMode - ? "bg-green-50 text-green-700 border-green-200" + isBuyerMode + ? "bg-green-50 text-green-700 border-green-200" : "bg-blue-50 text-blue-700 border-blue-200" )} > @@ -615,7 +632,7 @@ export function BasicContractSignDialog({ <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" /> )} </div> - + {allCompleted && ( <Badge variant="default" className="bg-green-100 text-green-700 border-green-200"> <Trophy className="h-4 w-4 mr-1" /> @@ -635,8 +652,8 @@ export function BasicContractSignDialog({ <div className={cn( "h-2 rounded-full transition-all duration-500", - isBuyerMode - ? "bg-gradient-to-r from-green-500 to-emerald-500" + isBuyerMode + ? "bg-gradient-to-r from-green-500 to-emerald-500" : "bg-gradient-to-r from-blue-500 to-green-500" )} style={{ width: `${(completedCount / totalCount) * 100}%` }} @@ -677,14 +694,14 @@ export function BasicContractSignDialog({ const contractStatus = contractStatuses.find(status => status.id === contract.id); const isCompleted = contractStatus?.status === 'completed'; const hasError = contractStatus?.status === 'error'; - + // 계약서별 완료 상태 확인 const isComplianceTemplate = contract.templateName?.includes('준법'); const isGTCTemplate = contract.templateName?.includes('GTC'); const hasSurveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contract.id] === true : true; const hasGtcCompleted = isGTCTemplate ? (gtcCommentStatus[contract.id]?.hasComments !== true) : true; const hasSignatureCompleted = signatureStatus[contract.id] === true; - + return ( <Button key={contract.id} @@ -723,7 +740,7 @@ export function BasicContractSignDialog({ </Badge> )} </span> - + {/* 상태 표시 */} {isCompleted ? ( <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs ml-2 flex-shrink-0"> @@ -741,7 +758,7 @@ export function BasicContractSignDialog({ </Badge> )} </div> - + {/* 완료 상태 표시 (구매자 모드에서는 간소화) */} {!isCompleted && !hasError && !isBuyerMode && ( <div className="flex items-center space-x-2 text-xs"> @@ -763,7 +780,7 @@ export function BasicContractSignDialog({ </span> </div> )} - + {/* 구매자 모드의 간소화된 상태 표시 */} {!isCompleted && !hasError && isBuyerMode && ( <div className="flex items-center space-x-2 text-xs"> @@ -773,7 +790,7 @@ export function BasicContractSignDialog({ </span> </div> )} - + {/* 두 번째 줄: 사용자 + 날짜 */} <div className="flex items-center justify-between text-xs text-gray-500"> <div className="flex items-center min-w-0"> @@ -815,7 +832,7 @@ export function BasicContractSignDialog({ <FileText className="h-4 w-4 mr-2 text-blue-500" /> )} {selectedContract.templateName || t("basicContracts.dialog.document")} - + {/* 현재 계약서 상태 표시 */} {currentContractStatus?.status === 'completed' ? ( <Badge variant="outline" className="ml-2 bg-green-50 text-green-700 border-green-200"> @@ -852,7 +869,7 @@ export function BasicContractSignDialog({ GTC 계약서 </Badge> )} - + {/* 비밀유지 계약서인 경우 추가 파일 수 표시 (구매자 모드가 아닐 때만) */} {!isBuyerMode && selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && ( <Badge variant="outline" className="ml-2 bg-blue-50 text-blue-700 border-blue-200"> @@ -871,7 +888,7 @@ export function BasicContractSignDialog({ </span> </div> </div> - + {/* 뷰어 영역 - 남은 공간 모두 사용 */} <div className="flex-1 min-h-0 overflow-hidden"> <BasicContractSignViewer @@ -884,8 +901,8 @@ export function BasicContractSignDialog({ setInstance={setInstance} onSurveyComplete={() => handleSurveyComplete(selectedContract.id)} onSignatureComplete={() => handleSignatureComplete(selectedContract.id)} - onGtcCommentStatusChange={(hasComments, commentCount) => - handleGtcCommentStatusChange(selectedContract.id, hasComments, commentCount) + onGtcCommentStatusChange={(hasComments, commentCount, reviewStatus, isComplete) => + handleGtcCommentStatusChange(selectedContract.id, hasComments, commentCount, reviewStatus, isComplete) } mode={mode} t={t} @@ -912,12 +929,12 @@ export function BasicContractSignDialog({ <div className="flex flex-col space-y-1"> <p className="text-sm text-gray-600 flex items-center"> <AlertCircle className="h-4 w-4 text-yellow-500 mr-1" /> - {isBuyerMode + {isBuyerMode ? "계약서에 구매자 서명을 완료해주세요." : t("basicContracts.dialog.signWarning") } </p> - + {/* 완료 상태 체크리스트 */} {!isBuyerMode && ( <div className="flex items-center space-x-4 text-xs"> @@ -928,9 +945,9 @@ export function BasicContractSignDialog({ </span> )} {selectedContract.templateName?.includes('GTC') && ( - <span className={`flex items-center ${(gtcCommentStatus[selectedContract.id]?.hasComments !== true) ? 'text-green-600' : 'text-red-600'}`}> - <CheckCircle2 className={`h-3 w-3 mr-1 ${(gtcCommentStatus[selectedContract.id]?.hasComments !== true) ? 'text-green-500' : 'text-red-500'}`} /> - 조항검토 {(gtcCommentStatus[selectedContract.id]?.hasComments !== true) ? '완료' : + <span className={`flex items-center ${(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? 'text-green-600' : 'text-red-600'}`}> + <CheckCircle2 className={`h-3 w-3 mr-1 ${(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? 'text-green-500' : 'text-red-500'}`} /> + 조항검토 {(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? '완료' : `미완료 (코멘트 ${gtcCommentStatus[selectedContract.id]?.commentCount || 0}개)`} </span> )} @@ -962,8 +979,8 @@ export function BasicContractSignDialog({ <Button className={cn( "gap-2 transition-colors", - isBuyerMode - ? "bg-green-600 hover:bg-green-700" + isBuyerMode + ? "bg-green-600 hover:bg-green-700" : "bg-green-600 hover:bg-green-700" )} onClick={completeAllSigns} @@ -992,7 +1009,7 @@ export function BasicContractSignDialog({ <Button className={cn( "gap-2 transition-colors", - canCompleteCurrentContract + canCompleteCurrentContract ? isBuyerMode ? "bg-green-600 hover:bg-green-700" : "bg-blue-600 hover:bg-blue-700" @@ -1043,7 +1060,7 @@ export function BasicContractSignDialog({ </div> <h3 className="text-xl font-medium text-gray-800 mb-2">{t("basicContracts.dialog.selectDocument")}</h3> <p className="text-gray-500 max-w-md"> - {isBuyerMode + {isBuyerMode ? "승인할 계약서를 선택해주세요." : t("basicContracts.dialog.selectDocumentDescription") } diff --git a/lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx b/lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx new file mode 100644 index 00000000..e6e22fda --- /dev/null +++ b/lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx @@ -0,0 +1,145 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { CheckCircle, FileText, Loader, AlertTriangle } from "lucide-react" +import { updateVendorDocumentStatus } from "../service" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" + +interface UpdateVendorDocumentStatusButtonProps { + vendorDocumentId: number + documentId: number + vendorId: number + currentStatus: "draft" | "approved" | "reviewing" // reviewing 상태 추가 +} + +export function UpdateVendorDocumentStatusButton({ + vendorDocumentId, + documentId, + vendorId, + currentStatus +}: UpdateVendorDocumentStatusButtonProps) { + + console.log(currentStatus) + + const [isLoading, setIsLoading] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const router = useRouter() + + // 토글할 새로운 상태 결정 + const newStatus = currentStatus === "approved" ? "draft" : "approved" + + // 상태에 따른 버튼 텍스트 + const buttonText = currentStatus === "approved" + ? "협의 취소" + : "협의 완료 처리" + + // 상태에 따른 다이얼로그 제목 + const dialogTitle = newStatus === "approved" + ? "협의를 완료 처리하시겠습니까?" + : "협의 완료를 취소하시겠습니까?" + + // 상태에 따른 다이얼로그 설명 + const dialogDescription = newStatus === "approved" + ? "협의가 완료 처리되면 협력업체가 계약서에 서명할 수 있게 됩니다. 모든 조항 검토가 완료되었는지 확인해주세요." + : "협의 완료를 취소하면 협력업체가 계약서에 서명할 수 없게 됩니다. 추가 수정이 필요한 경우에만 취소해주세요." + + // 상태에 따른 토스트 메시지 + const successMessage = newStatus === "approved" + ? "협의가 완료 처리되었습니다. 협력업체가 서명할 수 있습니다." + : "협의 완료가 취소되었습니다. 협력업체가 서명할 수 없습니다." + + const handleUpdateStatus = async () => { + try { + setIsLoading(true) + setIsOpen(false) + + // 서버 액션 호출 - 새로운 상태로 업데이트 + const result = await updateVendorDocumentStatus({ + status: newStatus, + vendorDocumentId, + documentId, + vendorId + }) + + if (!result.success) { + throw new Error(result.error || "상태 업데이트 실패") + } + + toast.success(successMessage) + router.refresh() // 페이지 새로고침 + } catch (error) { + toast.error(error instanceof Error ? error.message : "상태 업데이트 중 오류가 발생했습니다") + console.error(error) + } finally { + setIsLoading(false) + } + } + + return ( + <AlertDialog open={isOpen} onOpenChange={setIsOpen}> + <AlertDialogTrigger asChild> + <Button + variant={currentStatus === "approved" ? "secondary" : "outline"} + size="sm" + disabled={isLoading} + className={currentStatus === "approved" ? "bg-green-50 hover:bg-green-100 text-green-700" : ""} + > + {isLoading ? ( + <Loader className="h-4 w-4 mr-2 animate-spin" /> + ) : currentStatus === "approved" ? ( + <FileText className="h-4 w-4 mr-2" /> + ) : ( + <CheckCircle className="h-4 w-4 mr-2" /> + )} + {buttonText} + </Button> + </AlertDialogTrigger> + + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <AlertTriangle className="h-5 w-5 text-amber-500" /> + {dialogTitle} + </AlertDialogTitle> + <AlertDialogDescription className="space-y-2"> + <p>{dialogDescription}</p> + {newStatus === "approved" && ( + <div className="bg-blue-50 border border-blue-200 rounded-md p-3 mt-3"> + <p className="text-sm text-blue-800"> + <strong>확인사항:</strong> + </p> + <ul className="text-sm text-blue-700 mt-1 ml-4 list-disc"> + <li>모든 필수 조항이 검토되었습니까?</li> + <li>협력업체와의 협의 내용이 모두 반영되었습니까?</li> + <li>법무팀 검토가 완료되었습니까?</li> + </ul> + </div> + )} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleUpdateStatus} + className={newStatus === "approved" ? "bg-green-600 hover:bg-green-700" : "bg-amber-600 hover:bg-amber-700"} + > + {newStatus === "approved" ? "완료 처리" : "완료 취소"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/viewer/GtcClausesComponent.tsx b/lib/basic-contract/viewer/GtcClausesComponent.tsx index 8f565971..381e69dc 100644 --- a/lib/basic-contract/viewer/GtcClausesComponent.tsx +++ b/lib/basic-contract/viewer/GtcClausesComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback,useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { ScrollArea } from "@/components/ui/scroll-area"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -25,17 +25,23 @@ import { Minimize2, Maximize2, } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - getVendorGtcData, +import { cn, formatDateTime } from "@/lib/utils"; +import { + getVendorGtcData, updateVendorClause, checkVendorClausesCommentStatus, - type GtcVendorData + type GtcVendorData } from "../service"; +import { useSession } from "next-auth/react" interface GtcClausesComponentProps { contractId?: number; - onCommentStatusChange?: (hasComments: boolean, commentCount: number) => void; + onCommentStatusChange?: ( + hasComments: boolean, + commentCount: number, + reviewStatus?: string, + isComplete?: boolean + ) => void; t?: (key: string) => string; } @@ -52,26 +58,26 @@ type GtcVendorClause = { reviewStatus: string; negotiationNote: string | null; isExcluded: boolean; - + // 실제 표시될 값들 (기본 조항 값) effectiveItemNumber: string; effectiveCategory: string | null; effectiveSubtitle: string; effectiveContent: string | null; - + // 기본 조항 정보 (동일) baseItemNumber: string; baseCategory: string | null; baseSubtitle: string; baseContent: string | null; - + // 수정 여부 (코멘트만 있으면 false) hasModifications: boolean; isNumberModified: boolean; isCategoryModified: boolean; isSubtitleModified: boolean; isContentModified: boolean; - + // 코멘트 관련 hasComment: boolean; pendingComment: string | null; @@ -82,14 +88,24 @@ interface ClauseState extends GtcVendorClause { isEditing?: boolean; tempComment?: string; isSaving?: boolean; - // 고유 식별자를 위한 헬퍼 속성 uniqueId: number; + commentHistory?: CommentHistory[]; // 추가 + showHistory?: boolean; // 이력 표시 여부 } -export function GtcClausesComponent({ - contractId, +interface CommentHistory { + vendorClauseId: number; + comment: string; + actorName?: string; + actorEmail?: string; + createdAt: Date; + action: string; +} + +export function GtcClausesComponent({ + contractId, onCommentStatusChange, - t = (key: string) => key + t = (key: string) => key }: GtcClausesComponentProps) { const [gtcData, setGtcData] = useState<GtcVendorData | null>(null); const [clauses, setClauses] = useState<ClauseState[]>([]); @@ -98,6 +114,7 @@ export function GtcClausesComponent({ const [searchTerm, setSearchTerm] = useState(""); const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set()); const [compactMode, setCompactMode] = useState(true); // 컴팩트 모드 상태 추가 + const { data: session } = useSession(); const onCommentStatusChangeRef = useRef(onCommentStatusChange); onCommentStatusChangeRef.current = onCommentStatusChange; @@ -109,14 +126,14 @@ export function GtcClausesComponent({ setError(null); const data = await getVendorGtcData(contractId); - + if (!data) { setError("GTC 데이터를 찾을 수 없습니다."); return; } setGtcData(data); - + const initialClauses: ClauseState[] = data.clauses.map(clause => ({ ...clause, uniqueId: clause.id, @@ -125,7 +142,7 @@ export function GtcClausesComponent({ tempComment: clause.negotiationNote || "", isSaving: false, })); - + setClauses(initialClauses); } catch (err) { @@ -136,26 +153,33 @@ export function GtcClausesComponent({ } }, [contractId]); - const lastCommentStatusRef = useRef<{ hasComments: boolean; commentCount: number } | null>(null); + const lastCommentStatusRef = useRef<{ hasComments: boolean; commentCount: number , reviewStatus:string} | null>(null); // 코멘트 상태 변경을 별도 useEffect로 처리 useEffect(() => { - if (clauses.length > 0) { + if (clauses.length > 0 && gtcData) { const commentCount = clauses.filter(c => c.hasComment).length; const hasComments = commentCount > 0; + const reviewStatus = gtcData.vendorDocument?.reviewStatus || 'draft'; + + // reviewStatus가 complete이면 코멘트가 있어도 완료된 것으로 처리 + const isComplete = reviewStatus === 'complete' || reviewStatus === 'approved'; + + const currentStatus = { hasComments, commentCount, reviewStatus, isComplete }; - // Only call callback if status actually changed - const currentStatus = { hasComments, commentCount }; if (!lastCommentStatusRef.current || lastCommentStatusRef.current.hasComments !== hasComments || - lastCommentStatusRef.current.commentCount !== commentCount) { + lastCommentStatusRef.current.commentCount !== commentCount || + lastCommentStatusRef.current.reviewStatus !== reviewStatus) { lastCommentStatusRef.current = currentStatus; - onCommentStatusChangeRef.current?.(hasComments, commentCount); + // isComplete 정보도 전달 + onCommentStatusChangeRef.current?.(hasComments, commentCount, reviewStatus, isComplete); } } - }, [clauses]); - + }, [clauses, gtcData]); + + useEffect(() => { loadGtcData(); }, [loadGtcData]); @@ -176,11 +200,11 @@ export function GtcClausesComponent({ // 계층 구조로 조항 그룹화 const groupedClauses = React.useMemo(() => { const grouped: { [key: number]: ClauseState[] } = { 0: [] }; // 최상위는 0 - + filteredClauses.forEach(clause => { // parentId를 baseClauseId와 매핑 (parentId는 실제 baseClauseId를 가리킴) let parentKey = 0; // 기본값은 최상위 - + if (clause.parentId !== null) { // parentId에 해당하는 조항을 찾아서 그 조항의 uniqueId를 사용 const parentClause = filteredClauses.find(c => c.baseClauseId === clause.parentId); @@ -188,7 +212,7 @@ export function GtcClausesComponent({ parentKey = parentClause.uniqueId; } } - + if (!grouped[parentKey]) { grouped[parentKey] = []; } @@ -223,7 +247,7 @@ export function GtcClausesComponent({ return { ...clause, isEditing: !clause.isEditing, - tempComment: clause.negotiationNote || "", + tempComment: "", }; } return clause; @@ -240,17 +264,32 @@ export function GtcClausesComponent({ })); }, []); + // toggleCommentHistory 함수 추가 + const toggleCommentHistory = useCallback((uniqueId: number) => { + setClauses(prev => prev.map(clause => { + if (clause.uniqueId === uniqueId) { + return { ...clause, showHistory: !clause.showHistory }; + } + return clause; + })); + }, []); + // 코멘트 저장 const saveComment = useCallback(async (uniqueId: number) => { const clause = clauses.find(c => c.uniqueId === uniqueId); if (!clause) return; - setClauses(prev => prev.map(c => + // 빈 코멘트 체크 - 신규 입력 시에만 + if (!clause.hasComment && (!clause.tempComment || clause.tempComment.trim() === "")) { + toast.error("코멘트를 입력해주세요."); + return; + } + + setClauses(prev => prev.map(c => c.uniqueId === uniqueId ? { ...c, isSaving: true } : c )); try { - // 기본 조항 정보를 그대로 사용하고 코멘트만 처리 const clauseData = { itemNumber: clause.effectiveItemNumber, category: clause.effectiveCategory, @@ -260,22 +299,38 @@ export function GtcClausesComponent({ }; const result = await updateVendorClause( - clause.id, + clause.id, clause.vendorClauseId, clauseData, gtcData?.vendorDocument ); - + if (result.success) { - const hasComment = !!(clause.tempComment?.trim()); + + if (!session?.user?.id) { + toast.error("로그인이 필요합니다."); + return; + } + + // 새 코멘트를 이력에 추가 + const newHistory = { + vendorClauseId: result.vendorClauseId, + comment: clause.tempComment || "", + actorName: session.user.name ||"현재 사용자", // 실제로는 세션에서 가져와야 함 + createdAt: new Date(), + action: "commented" + }; setClauses(prev => prev.map(c => { if (c.uniqueId === uniqueId) { + const updatedHistory = [newHistory, ...(c.commentHistory || [])]; return { ...c, vendorClauseId: result.vendorClauseId || c.vendorClauseId, negotiationNote: clause.tempComment?.trim() || null, - hasComment, + latestComment: clause.tempComment?.trim() || null, + commentHistory: updatedHistory, + hasComment: true, isEditing: false, isSaving: false, }; @@ -284,22 +339,20 @@ export function GtcClausesComponent({ })); toast.success("코멘트가 저장되었습니다."); - } else { toast.error(result.error || "코멘트 저장에 실패했습니다."); - setClauses(prev => prev.map(c => + setClauses(prev => prev.map(c => c.uniqueId === uniqueId ? { ...c, isSaving: false } : c )); } } catch (error) { console.error('코멘트 저장 실패:', error); toast.error("코멘트 저장 중 오류가 발생했습니다."); - setClauses(prev => prev.map(c => + setClauses(prev => prev.map(c => c.uniqueId === uniqueId ? { ...c, isSaving: false } : c )); } }, [clauses, gtcData]); - // 편집 취소 const cancelEdit = useCallback((uniqueId: number) => { setClauses(prev => prev.map(clause => { @@ -319,7 +372,7 @@ export function GtcClausesComponent({ const isExpanded = expandedItems.has(clause.uniqueId); const children = groupedClauses[clause.uniqueId] || []; const hasChildren = children.length > 0; - + return ( <div key={clause.uniqueId} className={`${depth > 0 ? 'ml-4' : ''}`}> <div className={cn( @@ -401,8 +454,8 @@ export function GtcClausesComponent({ onClick={() => toggleEdit(clause.uniqueId)} className={cn( "h-6 w-6 p-0 transition-colors", - clause.hasComment - ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" + clause.hasComment + ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" : "text-gray-500 hover:text-gray-700 hover:bg-gray-50" )} > @@ -426,7 +479,7 @@ export function GtcClausesComponent({ <span className="text-sm text-gray-700">{clause.effectiveCategory}</span> </div> )} - + {/* 내용 */} {clause.effectiveContent && ( <p className="text-sm text-gray-700 leading-relaxed mb-3 whitespace-pre-wrap"> @@ -455,17 +508,72 @@ export function GtcClausesComponent({ )} {/* 기존 코멘트 표시 */} - {!clause.isEditing && clause.hasComment && clause.negotiationNote && ( + {!clause.isEditing && clause.hasComment && ( <div className="mb-2 p-2.5 bg-amber-50 rounded border border-amber-200"> - <div className="flex items-center text-sm font-medium text-amber-800 mb-2"> - <MessageSquare className="h-4 w-4 mr-2" /> - 협의 코멘트 + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center text-sm font-medium text-amber-800"> + <MessageSquare className="h-4 w-4 mr-2" /> + 협의 코멘트 + {clause.commentHistory && clause.commentHistory.length > 1 && ( + <Badge variant="outline" className="ml-2 text-xs"> + {clause.commentHistory.length}개 이력 + </Badge> + )} + </div> + {clause.commentHistory && clause.commentHistory.length > 1 && ( + <Button + variant="ghost" + size="sm" + onClick={() => toggleCommentHistory(clause.uniqueId)} + className="h-6 px-2 text-xs text-amber-600 hover:text-amber-700" + > + {clause.showHistory ? "이력 숨기기" : "이력 보기"} + </Button> + )} + </div> + + {/* 최신 코멘트 */} + <div className="space-y-2"> + <div className="bg-white p-2 rounded border border-amber-100"> + <p className="text-sm text-amber-700 whitespace-pre-wrap"> + {clause.latestComment || clause.negotiationNote} + </p> + {clause.commentHistory?.[0] && ( + <div className="flex items-center justify-between mt-1 pt-1 border-t border-amber-100"> + <span className="text-xs text-amber-600"> + {clause.commentHistory[0].actorName || "SHI"} + </span> + <span className="text-xs text-amber-500"> + {formatDateTime(clause.commentHistory[0].createdAt, "KR")} + </span> + </div> + )} + </div> + + {/* 이전 코멘트 이력 */} + {clause.showHistory && clause.commentHistory && clause.commentHistory.length > 1 && ( + <div className="space-y-1.5 max-h-60 overflow-y-auto"> + {clause.commentHistory.slice(1).map((history, idx) => ( + <div key={idx} className="bg-white/50 p-2 rounded border border-amber-100/50"> + <p className="text-xs text-amber-600 whitespace-pre-wrap"> + {history.comment} + </p> + <div className="flex items-center justify-between mt-1 pt-1 border-t border-amber-100/50"> + <span className="text-xs text-amber-500"> + {history.actorName || "SHI"} + </span> + <span className="text-xs text-amber-400"> + {formatDateTime(history.createdAt, "KR")} + </span> + </div> + </div> + ))} + </div> + )} </div> - <p className="text-sm text-amber-700 whitespace-pre-wrap"> - {clause.negotiationNote} - </p> </div> )} + </div> )} @@ -484,7 +592,7 @@ export function GtcClausesComponent({ const isExpanded = expandedItems.has(clause.uniqueId); const children = groupedClauses[clause.uniqueId] || []; const hasChildren = children.length > 0; - + return ( <div key={clause.uniqueId} className={`mb-1 ${depth > 0 ? 'ml-4' : ''}`}> <Card className={cn( @@ -564,8 +672,8 @@ export function GtcClausesComponent({ onClick={() => toggleEdit(clause.uniqueId)} className={cn( "h-6 px-2 transition-colors", - clause.hasComment - ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" + clause.hasComment + ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" : "text-gray-500 hover:text-gray-700 hover:bg-gray-50" )} > @@ -722,6 +830,27 @@ export function GtcClausesComponent({ compactMode ? "h-4 w-4" : "h-5 w-5" )} /> {gtcData.vendorDocument.name} + + {/* reviewStatus 배지 추가 */} + {gtcData.vendorDocument.reviewStatus && ( + <Badge + variant="outline" + className={cn( + "ml-2", + gtcData.vendorDocument.reviewStatus === 'complete' || gtcData.vendorDocument.reviewStatus === 'approved' + ? "bg-green-50 text-green-700 border-green-200" + : gtcData.vendorDocument.reviewStatus === 'reviewing' + ? "bg-blue-50 text-blue-700 border-blue-200" + : "bg-gray-50 text-gray-700 border-gray-200" + )} + > + {gtcData.vendorDocument.reviewStatus === 'complete' ? '협의 완료' : + gtcData.vendorDocument.reviewStatus === 'approved' ? '승인됨' : + gtcData.vendorDocument.reviewStatus === 'reviewing' ? '협의 중' : + gtcData.vendorDocument.reviewStatus === 'draft' ? '초안' : + gtcData.vendorDocument.reviewStatus} + </Badge> + )} </h3> {!compactMode && ( <p className="text-sm text-gray-500 mt-0.5"> @@ -747,7 +876,7 @@ export function GtcClausesComponent({ <Minimize2 className="h-3 w-3" /> )} </Button> - + <Badge variant="outline" className={cn( "bg-blue-50 text-blue-700 border-blue-200", compactMode ? "text-xs px-1.5 py-0.5" : "text-xs" @@ -788,8 +917,8 @@ export function GtcClausesComponent({ /> </div> - {/* 안내 메시지 */} - {totalComments > 0 && ( + {/* 안내 메시지 수정 - reviewStatus 체크 */} + {totalComments > 0 && gtcData.vendorDocument.reviewStatus !== 'complete' && gtcData.vendorDocument.reviewStatus !== 'approved' && ( <div className={cn( "bg-amber-50 rounded border border-amber-200", compactMode ? "mt-2 p-2" : "mt-2 p-2" @@ -811,6 +940,25 @@ export function GtcClausesComponent({ )} </div> )} + + {/* 협의 완료 메시지 */} + {totalComments > 0 && (gtcData.vendorDocument.reviewStatus === 'complete' || gtcData.vendorDocument.reviewStatus === 'approved') && ( + <div className={cn( + "bg-green-50 rounded border border-green-200", + compactMode ? "mt-2 p-2" : "mt-2 p-2" + )}> + <div className={cn( + "flex items-center text-green-800", + compactMode ? "text-sm" : "text-sm" + )}> + <CheckCircle2 className={cn( + "mr-2", + compactMode ? "h-4 w-4" : "h-4 w-4" + )} /> + <span className="font-medium">협의가 완료되어 서명 가능합니다.</span> + </div> + </div> + )} </div> {/* 조항 목록 */} @@ -825,7 +973,7 @@ export function GtcClausesComponent({ </div> ) : ( <div className={compactMode ? "space-y-0.5" : "space-y-1"}> - {(groupedClauses[0] || []).map(clause => + {(groupedClauses[0] || []).map(clause => compactMode ? renderCompactClause(clause) : renderNormalClause(clause) )} </div> diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 943878da..e52f0d79 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -44,7 +44,12 @@ interface BasicContractSignViewerProps { setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; onSurveyComplete?: () => void; onSignatureComplete?: () => void; - onGtcCommentStatusChange?: (hasComments: boolean, commentCount: number) => void; + onGtcCommentStatusChange?: ( + hasComments: boolean, + commentCount: number, + reviewStatus?: string, + isComplete?: boolean + ) => void; mode?: 'vendor' | 'buyer'; // 추가된 mode prop t?: (key: string) => string; } @@ -63,58 +68,15 @@ interface SignaturePattern { // 초간단 안전한 서명 필드 감지 클래스 class AutoSignatureFieldDetector { private instance: WebViewerInstance; - private signaturePatterns: SignaturePattern[]; - private mode: 'vendor' | 'buyer'; // mode 추가 + private mode: 'vendor' | 'buyer'; constructor(instance: WebViewerInstance, mode: 'vendor' | 'buyer' = 'vendor') { this.instance = instance; this.mode = mode; - this.signaturePatterns = this.initializePatterns(); - } - - private initializePatterns(): SignaturePattern[] { - return [ - { - regex: /서명\s*[::]\s*[_\-\s]{3,}/gi, - name: "한국어_서명_콜론", - priority: 10, - offsetX: 80, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /서명란\s*[_\-\s]{0,}/gi, - name: "한국어_서명란", - priority: 9, - offsetX: 60, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /signature\s*[::]\s*[_\-\s]{3,}/gi, - name: "영어_signature_콜론", - priority: 8, - offsetX: 120, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /sign\s+here\s*[::]?\s*[_\-\s]{0,}/gi, - name: "영어_sign_here", - priority: 9, - offsetX: 100, - offsetY: -5, - width: 150, - height: 40 - } - ]; } async detectAndCreateSignatureFields(): Promise<string[]> { - console.log(`🔍 안전한 서명 필드 감지 시작... (모드: ${this.mode})`); + console.log(`🔍 텍스트 기반 서명 필드 감지 시작... (모드: ${this.mode})`); try { if (!this.instance?.Core?.documentViewer) { @@ -129,25 +91,122 @@ class AutoSignatureFieldDetector { throw new Error("PDF 문서가 로드되지 않았습니다."); } - console.log("📄 문서 확인 완료, 기본 서명 필드 생성..."); - const defaultField = await this.createSimpleSignatureField(); + // 모드에 따라 검색할 텍스트 결정 + const searchText = this.mode === 'buyer' + ? '삼성중공업_서명란' + : '협력업체_서명란'; + + console.log(`📄 "${searchText}" 텍스트 검색 중...`); - console.log("✅ 서명 필드 생성 완료"); - return [defaultField]; + // 텍스트 검색 및 서명 필드 생성 + const fieldName = await this.createSignatureFieldAtText(searchText); + + if (fieldName) { + console.log(`✅ 텍스트 위치에 서명 필드 생성 완료: ${searchText}`); + return [fieldName]; + } else { + // 텍스트를 찾지 못한 경우 기본 위치에 생성 + console.log(`⚠️ "${searchText}" 텍스트를 찾지 못해 기본 위치에 생성`); + const defaultField = await this.createSimpleSignatureField(); + return [defaultField]; + } } catch (error) { console.error("📛 서명 필드 생성 실패:", error); - let errorMessage = "서명 필드 생성에 실패했습니다."; - if (error instanceof Error) { - if (error.message.includes("인스턴스")) { - errorMessage = "뷰어가 준비되지 않았습니다."; - } else if (error.message.includes("문서")) { - errorMessage = "문서를 불러오는 중입니다."; + // 오류 발생 시 기본 위치에 생성 + try { + const defaultField = await this.createSimpleSignatureField(); + return [defaultField]; + } catch (fallbackError) { + throw new Error("서명 필드 생성에 실패했습니다."); + } + } + } + + private async createSignatureFieldAtText(searchText: string): Promise<string | null> { + const { Core } = this.instance; + const { documentViewer, annotationManager } = Core; + const document = documentViewer.getDocument(); + + if (!document) return null; + + try { + // 모든 페이지에서 텍스트 검색 + const searchMode = Core.Search.Mode.PAGE_STOP | Core.Search.Mode.HIGHLIGHT; + const searchOptions = { + fullSearch: true, + onResult: null, + }; + + // 텍스트 검색 시작 + const textSearchIterator = await document.getTextSearchIterator(); + textSearchIterator.begin(searchText, searchMode); + + let searchResult = await textSearchIterator.next(); + + // 검색 결과가 있는 경우 + if (searchResult && searchResult.resultCode === Core.Search.ResultCode.FOUND) { + const pageNumber = searchResult.pageNum; + const quads = searchResult.quads; + + if (quads && quads.length > 0) { + // 첫 번째 검색 결과의 위치 가져오기 + const quad = quads[0]; + + // 쿼드의 좌표를 기반으로 서명 필드 위치 계산 + const x = Math.min(quad.x1, quad.x2, quad.x3, quad.x4); + const y = Math.min(quad.y1, quad.y2, quad.y3, quad.y4); + const textWidth = Math.abs(quad.x2 - quad.x1); + const textHeight = Math.abs(quad.y3 - quad.y1); + + // 서명 필드 생성 + const fieldName = `signature_at_text_${Date.now()}`; + const flags = new Core.Annotations.WidgetFlags(); + flags.set('Required', true); + + const field = new Core.Annotations.Forms.Field(fieldName, { + type: 'Sig', + flags + }); + + const widget = new Core.Annotations.SignatureWidgetAnnotation(field, { + Width: 150, + Height: 50 + }); + + widget.setPageNumber(pageNumber); + + // 텍스트 바로 아래 또는 오른쪽에 서명 필드 배치 + // 옵션 1: 텍스트 바로 아래 + widget.setX(x); + widget.setY(y + textHeight + 5); // 텍스트 아래 5픽셀 간격 + + // 옵션 2: 텍스트 오른쪽 (필요시 아래 주석 해제) + // widget.setX(x + textWidth + 10); // 텍스트 오른쪽 10픽셀 간격 + // widget.setY(y); + + widget.setWidth(150); + widget.setHeight(50); + + // 필드 매니저에 추가 + const fm = annotationManager.getFieldManager(); + fm.addField(field); + annotationManager.addAnnotation(widget); + annotationManager.drawAnnotationsFromList([widget]); + + console.log(`📌 서명 필드를 페이지 ${pageNumber}의 "${searchText}" 위치에 생성`); + + return fieldName; } } - throw new Error(errorMessage); + console.log(`⚠️ "${searchText}" 텍스트를 찾을 수 없음`); + return null; + + } catch (error) { + console.error(`📛 텍스트 검색 중 오류: ${error}`); + return null; } } @@ -163,11 +222,18 @@ class AutoSignatureFieldDetector { const flags = new Annotations.WidgetFlags(); flags.set('Required', true); - const field = new Core.Annotations.Forms.Field(fieldName, { type: 'Sig', flags }); + const field = new Core.Annotations.Forms.Field(fieldName, { + type: 'Sig', + flags + }); + + const widget = new Annotations.SignatureWidgetAnnotation(field, { + Width: 150, + Height: 50 + }); - const widget = new Annotations.SignatureWidgetAnnotation(field, { Width: 150, Height: 50 }); widget.setPageNumber(page); - + // 구매자 모드일 때는 왼쪽 하단으로 위치 설정 if (this.mode === 'buyer') { widget.setX(w * 0.1); // 왼쪽 (10%) @@ -177,7 +243,7 @@ class AutoSignatureFieldDetector { widget.setX(w * 0.7); // 오른쪽 (70%) widget.setY(h * 0.85); // 하단 (85%) } - + widget.setWidth(150); widget.setHeight(50); @@ -190,6 +256,7 @@ class AutoSignatureFieldDetector { } } + function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendor' | 'buyer' = 'vendor') { const [signatureFields, setSignatureFields] = useState<string[]>([]); const [isProcessing, setIsProcessing] = useState(false); @@ -280,15 +347,23 @@ function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendo } if (fields.length > 0) { + const hasTextBasedField = fields.some(field => field.startsWith('signature_at_text_')); const hasSimpleField = fields.some(field => field.startsWith('simple_signature_')); - if (hasSimpleField) { - const positionMessage = mode === 'buyer' + if (hasTextBasedField) { + const searchText = mode === 'buyer' ? '삼성중공업_서명란' : '협력업체_서명란'; + toast.success(`📝 "${searchText}" 위치에 서명 필드가 생성되었습니다.`, { + description: "해당 텍스트 근처의 파란색 영역에서 서명해주세요.", + icon: <FileSignature className="h-4 w-4 text-blue-500" />, + duration: 5000 + }); + } else if (hasSimpleField) { + const positionMessage = mode === 'buyer' ? "마지막 페이지 왼쪽 하단의 파란색 영역에서 서명해주세요." : "마지막 페이지 하단의 파란색 영역에서 서명해주세요."; - toast.success("📝 서명 필드가 생성되었습니다.", { - description: positionMessage, + toast.info("📝 기본 위치에 서명 필드가 생성되었습니다.", { + description: `검색 텍스트를 찾을 수 없어 ${positionMessage}`, icon: <FileSignature className="h-4 w-4 text-blue-500" />, duration: 5000 }); @@ -508,7 +583,16 @@ export function BasicContractSignViewer({ const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false); const [surveyTemplate, setSurveyTemplate] = useState<SurveyTemplateWithQuestions | null>(null); const [surveyLoading, setSurveyLoading] = useState<boolean>(false); - const [gtcCommentStatus, setGtcCommentStatus] = useState<{ hasComments: boolean; commentCount: number }>({ hasComments: false, commentCount: 0 }); + const [gtcCommentStatus, setGtcCommentStatus] = useState<{ + hasComments: boolean; + commentCount: number; + reviewStatus?: string; + isComplete?: boolean; + }>({ + hasComments: false, + commentCount: 0, + isComplete: false + }); console.log(surveyTemplate, "surveyTemplate") @@ -739,9 +823,12 @@ export function BasicContractSignViewer({ stamp.Width = Width; stamp.Height = Height; - await stamp.setImageData(signatureImage.data.dataUrl); - annot.sign(stamp); - annot.setFieldFlag(WidgetFlags.READ_ONLY, true); + if (signatureImage) { + await stamp.setImageData(signatureImage.data.dataUrl); + annot.sign(stamp); + annot.setFieldFlag(WidgetFlags.READ_ONLY, true); + } + } } }); @@ -994,8 +1081,8 @@ export function BasicContractSignViewer({ return; } - if (isGTCTemplate && gtcCommentStatus.hasComments) { - toast.error("GTC 조항에 코멘트가 있어 서명할 수 없습니다."); + if (isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete) { + toast.error("GTC 조항에 미해결 코멘트가 있어 서명할 수 없습니다."); toast.info("모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요."); setActiveTab('clauses'); return; @@ -1130,9 +1217,9 @@ export function BasicContractSignViewer({ {/* GTC 조항 컴포넌트 */} <GtcClausesComponent contractId={contractId} - onCommentStatusChange={(hasComments, commentCount) => { - setGtcCommentStatus({ hasComments, commentCount }); - onGtcCommentStatusChange?.(hasComments, commentCount); + onCommentStatusChange={(hasComments, commentCount, reviewStatus, isComplete) => { + setGtcCommentStatus({ hasComments, commentCount, reviewStatus, isComplete }); + onGtcCommentStatusChange?.(hasComments, commentCount, reviewStatus, isComplete); }} t={t} /> @@ -1193,9 +1280,14 @@ export function BasicContractSignViewer({ ); } - const handleGtcCommentStatusChange = React.useCallback((hasComments: boolean, commentCount: number) => { - setGtcCommentStatus({ hasComments, commentCount }); - onGtcCommentStatusChange?.(hasComments, commentCount); + const handleGtcCommentStatusChange = React.useCallback(( + hasComments: boolean, + commentCount: number, + reviewStatus?: string, + isComplete?: boolean + ) => { + setGtcCommentStatus({ hasComments, commentCount, reviewStatus, isComplete }); + onGtcCommentStatusChange?.(hasComments, commentCount, reviewStatus, isComplete); }, [onGtcCommentStatusChange]); // 다이얼로그 뷰어 렌더링 @@ -1230,6 +1322,16 @@ export function BasicContractSignViewer({ ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 코멘트가 있어 서명할 수 없습니다. </span> )} + {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete && ( + <span className="block mt-1 text-red-600"> + ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 미해결 코멘트가 있어 서명할 수 없습니다. + </span> + )} + {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && gtcCommentStatus.isComplete && ( + <span className="block mt-1 text-green-600"> + ✅ GTC 조항 협의가 완료되어 서명 가능합니다. + </span> + )} </DialogDescription> </DialogHeader> diff --git a/lib/file-stroage.ts b/lib/file-stroage.ts index 7bc9ef1c..4063aa40 100644 --- a/lib/file-stroage.ts +++ b/lib/file-stroage.ts @@ -28,7 +28,7 @@ const SECURITY_CONFIG = { 'exe', 'bat', 'cmd', 'scr', 'vbs', 'js', 'jar', 'com', 'pif', 'msi', 'reg', 'ps1', 'sh', 'php', 'asp', 'jsp', 'py', 'pl', // XSS 방지를 위한 추가 확장자 - 'html', 'htm', 'xhtml', 'xml', 'xsl', 'xslt' + 'html', 'htm', 'xhtml', 'xml', 'xsl', 'xslt','svg' ]), // 허용된 MIME 타입 @@ -66,9 +66,9 @@ class FileSecurityValidator { return { valid: false, error: `금지된 파일 형식입니다: .${extension}` }; } - if (!SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension)) { - return { valid: false, error: `허용되지 않은 파일 형식입니다: .${extension}` }; - } + // if (!SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension)) { + // return { valid: false, error: `허용되지 않은 파일 형식입니다: .${extension}` }; + // } return { valid: true }; } @@ -135,9 +135,9 @@ class FileSecurityValidator { // 기본 MIME 타입 체크 const baseMimeType = mimeType.split(';')[0].toLowerCase(); - if (!SECURITY_CONFIG.ALLOWED_MIME_TYPES.has(baseMimeType)) { - return { valid: false, error: `허용되지 않은 파일 형식입니다: ${baseMimeType}` }; - } + // if (!SECURITY_CONFIG.ALLOWED_MIME_TYPES.has(baseMimeType)) { + // return { valid: false, error: `허용되지 않은 파일 형식입니다: ${baseMimeType}` }; + // } // 확장자와 MIME 타입 일치성 체크 const extension = path.extname(fileName).toLowerCase().substring(1); diff --git a/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx b/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx index 52faea3c..4cf29362 100644 --- a/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx +++ b/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx @@ -15,23 +15,9 @@ export function GtcClausesPageHeader({ document }: GtcClausesPageHeaderProps) { const router = useRouter() const handleBack = () => { - router.push('/evcp/basic-contract-template/gtc') + router.push('/evcp/gtc') } - const handlePreview = () => { - // PDFTron 미리보기 기능 - console.log("PDF Preview for document:", document.id) - } - - const handleDownload = () => { - // 문서 다운로드 기능 - console.log("Download document:", document.id) - } - - const handleSettings = () => { - // 문서 설정 (템플릿 관리 등) - console.log("Document settings:", document.id) - } return ( <div className="flex items-center justify-between"> diff --git a/lib/gtc-contract/gtc-clauses/service.ts b/lib/gtc-contract/gtc-clauses/service.ts index b6f620bc..2660dcdd 100644 --- a/lib/gtc-contract/gtc-clauses/service.ts +++ b/lib/gtc-contract/gtc-clauses/service.ts @@ -4,9 +4,15 @@ import db from "@/db/db" import { gtcClauses, gtcClausesTreeView, + gtcVendorClausesView, + gtcClausesWithVendorView, type GtcClause, type GtcClauseTreeView, - type NewGtcClause + type NewGtcClause, + gtcNegotiationHistory, + gtcVendorClauses, + gtcVendorDocuments, + GtcVendorClause } from "@/db/schema/gtc" import { users } from "@/db/schema/users" import { and, asc, count, desc, eq, ilike, or, sql, gt, lt, inArray, like } from "drizzle-orm" @@ -19,9 +25,12 @@ import type { ReorderGtcClausesSchema, BulkUpdateGtcClausesSchema, GenerateVariableNamesSchema, + GetGtcVendorClausesSchema, + UpdateVendorGtcClauseSchema, } from "@/lib/gtc-contract/gtc-clauses/validations" import { decryptWithServerAction } from "@/components/drm/drmUtils" import { saveDRMFile } from "@/lib/file-stroage" +import { vendors } from "@/db/schema" interface ClauseImage { id: string @@ -97,12 +106,14 @@ export async function getGtcClauses(input: GetGtcClausesSchema & { documentId: n // 정렬 const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => { - const column = gtcClausesTreeView[item.id as keyof typeof gtcClausesTreeView] - return item.desc ? desc(column) : asc(column) - }) - : [asc(gtcClausesTreeView.sortOrder), asc(gtcClausesTreeView.depth)] + input.sort.length > 0 + ? input.sort.map((item) => { + const column = gtcClausesTreeView[item.id as keyof typeof gtcClausesTreeView] + const result = item.desc ? desc(column) : asc(column) + return result + }) + : [asc(gtcClausesTreeView.itemNumber)] + // 데이터 조회 const { data, total } = await db.transaction(async (tx) => { @@ -442,6 +453,81 @@ export async function createGtcClause( } } +export async function createVendorGtcClause( + input: CreateVendorGtcClauseSchema & { createdById: number } +) { + try { + // Calculate depth if parent is specified + let depth = 0 + + if (input.parentId) { + const parent = await db.query.gtcVendorClauses.findFirst({ + where: eq(gtcVendorClauses.id, input.parentId), + }) + if (parent) { + depth = parent.depth + 1 + } + } + + // Create the new vendor clause + const newVendorClause = { + vendorDocumentId: input.vendorDocumentId, + baseClauseId: input.baseClauseId, + parentId: input.parentId, + + // Modified fields + modifiedItemNumber: input.isNumberModified ? input.modifiedItemNumber : null, + modifiedCategory: input.isCategoryModified ? input.modifiedCategory : null, + modifiedSubtitle: input.isSubtitleModified ? input.modifiedSubtitle : null, + modifiedContent: input.isContentModified ? input.modifiedContent?.trim() : null, + + // Modification flags + isNumberModified: input.isNumberModified, + isCategoryModified: input.isCategoryModified, + isSubtitleModified: input.isSubtitleModified, + isContentModified: input.isContentModified, + + // Additional fields + sortOrder: input.sortOrder.toString(), + depth, + reviewStatus: input.reviewStatus, + negotiationNote: input.negotiationNote || null, + isExcluded: input.isExcluded, + + // Audit fields + createdById: input.createdById, + updatedById: input.createdById, + editReason: input.editReason, + images: input.images || null, + isActive: true, + } + + const [result] = await db.insert(gtcVendorClauses).values(newVendorClause).returning() + + // Create negotiation history entry + if (input.negotiationNote) { + await db.insert(gtcNegotiationHistory).values({ + vendorClauseId: result.id, + action: "created", + comment: input.negotiationNote, + actorName: input.actorName, // You'll need to pass this from the form + actorEmail: input.actorEmail, // You'll need to pass this from the form + previousStatus: null, + newStatus: input.reviewStatus, + createdById: input.createdById, + }) + } + + // Revalidate cache + // await revalidatePath(`/evcp/gtc/${input.documentId}?vendorId=${input.vendorId}`) + + return { data: result, error: null } + } catch (error) { + console.error("Error creating vendor GTC clause:", error) + return { data: null, error: "벤더 조항 생성 중 오류가 발생했습니다." } + } +} + /** * GTC 조항 수정 */ @@ -751,6 +837,15 @@ async function countGtcClauses(tx: any, where: any) { return total } +async function countGtcVendorClauses(tx: any, where: any) { + const [{ count: total }] = await tx + .select({ count: count() }) + .from(gtcClausesWithVendorView) + .where(where) + return total +} + + function buildClausesTree(clauses: GtcClauseTreeView[]): GtcClauseTreeView[] { const clauseMap = new Map<number, GtcClauseTreeView & { children: GtcClauseTreeView[] }>() const rootClauses: (GtcClauseTreeView & { children: GtcClauseTreeView[] })[] = [] @@ -781,6 +876,8 @@ async function revalidateGtcClausesCaches(documentId: number) { const { revalidateTag } = await import("next/cache") revalidateTag(`gtc-clauses-${documentId}`) revalidateTag(`gtc-clauses-tree-${documentId}`) + revalidateTag( "basicContractView-vendor") + } /** @@ -933,4 +1030,316 @@ export async function moveGtcClauseDown(clauseId: number, updatedById: number) { console.error("Error moving clause down:", error) return { error: "조항 이동 중 오류가 발생했습니다." } } +} + + +// 벤더별 조항 정보를 조회하는 함수 추가 +export async function getVendorClausesForDocument({ + documentId, + vendorId, +}: { + documentId: number + vendorId?: number +}) { + try { + // vendorId가 없으면 빈 객체 반환 + if (!vendorId) { + return {} + } + + // 1. 해당 문서와 벤더에 대한 벤더 문서 찾기 + const vendorDocument = await db + .select() + .from(gtcVendorDocuments) + .where( + and( + eq(gtcVendorDocuments.baseDocumentId, documentId), + eq(gtcVendorDocuments.vendorId, vendorId), + eq(gtcVendorDocuments.isActive, true) + ) + ) + .limit(1) + + if (!vendorDocument[0]) { + return {} + } + + // 2. 벤더 조항들 조회 (협의 이력 포함) + const vendorClauses = await db + .select({ + // 벤더 조항 정보 + id: gtcVendorClauses.id, + baseClauseId: gtcVendorClauses.baseClauseId, + vendorDocumentId: gtcVendorClauses.vendorDocumentId, + + // 수정된 내용 + modifiedItemNumber: gtcVendorClauses.modifiedItemNumber, + modifiedCategory: gtcVendorClauses.modifiedCategory, + modifiedSubtitle: gtcVendorClauses.modifiedSubtitle, + modifiedContent: gtcVendorClauses.modifiedContent, + + // 수정 플래그 + isNumberModified: gtcVendorClauses.isNumberModified, + isCategoryModified: gtcVendorClauses.isCategoryModified, + isSubtitleModified: gtcVendorClauses.isSubtitleModified, + isContentModified: gtcVendorClauses.isContentModified, + + // 협의 상태 + reviewStatus: gtcVendorClauses.reviewStatus, + negotiationNote: gtcVendorClauses.negotiationNote, + isExcluded: gtcVendorClauses.isExcluded, + + // 날짜 + createdAt: gtcVendorClauses.createdAt, + updatedAt: gtcVendorClauses.updatedAt, + }) + .from(gtcVendorClauses) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocument[0].id), + eq(gtcVendorClauses.isActive, true) + ) + ) + + // 3. 각 벤더 조항에 대한 협의 이력 조회 + const clauseIds = vendorClauses.map(c => c.id) + const negotiationHistories = clauseIds.length > 0 + ? await db + .select({ + vendorClauseId: gtcNegotiationHistory.vendorClauseId, + action: gtcNegotiationHistory.action, + previousStatus: gtcNegotiationHistory.previousStatus, + newStatus: gtcNegotiationHistory.newStatus, + comment: gtcNegotiationHistory.comment, + actorName: gtcNegotiationHistory.actorName, + actorEmail: gtcNegotiationHistory.actorEmail, + createdAt: gtcNegotiationHistory.createdAt, + }) + .from(gtcNegotiationHistory) + .where(inArray(gtcNegotiationHistory.vendorClauseId, clauseIds)) + .orderBy(desc(gtcNegotiationHistory.createdAt)) + : [] + + // 4. baseClauseId를 키로 하는 맵 생성 + const vendorClauseMap = new Map() + + vendorClauses.forEach(vc => { + // 해당 조항의 협의 이력 필터링 + const history = negotiationHistories + .filter(h => h.vendorClauseId === vc.id) + .map(h => ({ + action: h.action, + comment: h.comment, + actorName: h.actorName, + actorEmail: h.actorEmail, + createdAt: h.createdAt, + previousStatus: h.previousStatus, + newStatus: h.newStatus, + })) + + // 가장 최근 코멘트 찾기 + const latestComment = history.find(h => h.comment)?.comment || null + + vendorClauseMap.set(vc.baseClauseId, { + ...vc, + negotiationHistory: history, + latestComment, + hasModifications: + vc.isNumberModified || + vc.isCategoryModified || + vc.isSubtitleModified || + vc.isContentModified, + }) + }) + + return { + vendorDocument: vendorDocument[0], + vendorClauseMap, + totalModified: vendorClauses.filter(vc => + vc.isNumberModified || + vc.isCategoryModified || + vc.isSubtitleModified || + vc.isContentModified + ).length, + totalExcluded: vendorClauses.filter(vc => vc.isExcluded).length, + } + } catch (error) { + console.error("Failed to fetch vendor clauses:", error) + return {} + } +} + +// service.ts에 추가 +/** + * 벤더별 GTC 조항 수정 + */ +export async function updateVendorGtcClause( + input: UpdateVendorGtcClauseSchema & { + baseClauseId: number + documentId: number + vendorId: number + updatedById: number + images?: any[] + } +) { + try { + // 1. 먼저 벤더 문서 찾기 또는 생성 + let vendorDocument = await db + .select() + .from(gtcVendorDocuments) + .where( + and( + eq(gtcVendorDocuments.baseDocumentId, input.documentId), + eq(gtcVendorDocuments.vendorId, input.vendorId), + eq(gtcVendorDocuments.isActive, true) + ) + ) + .limit(1) + + console.log(vendorDocument,"vendorDocument", input.vendorId, input.documentId) + + // 벤더 문서가 없으면 생성 + if (!vendorDocument[0]) { + const vendor = await db + .select() + .from(vendors) + .where(eq(vendors.id, input.vendorId)) + .limit(1) + + if (!vendor[0]) { + return { data: null, error: "벤더 정보를 찾을 수 없습니다." } + } + + [vendorDocument[0]] = await db + .insert(gtcVendorDocuments) + .values({ + baseDocumentId: input.documentId, + vendorId: input.vendorId, + name: `${vendor[0].vendorName} GTC Agreement`, + description: `GTC negotiation with ${vendor[0].vendorName}`, + version: "1.0", + reviewStatus: "draft", + createdById: input.updatedById, + updatedById: input.updatedById, + }) + .returning() + } + + // 2. 벤더 조항 찾기 또는 생성 + let vendorClause = await db + .select() + .from(gtcVendorClauses) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocument[0].id), + eq(gtcVendorClauses.baseClauseId, input.baseClauseId) + ) + ) + .limit(1) + + const updateData: Partial<GtcVendorClause> = { + updatedById: input.updatedById, + updatedAt: new Date(), + + // 수정 플래그 + isNumberModified: input.isNumberModified, + isCategoryModified: input.isCategoryModified, + isSubtitleModified: input.isSubtitleModified, + isContentModified: input.isContentModified, + + // 협의 정보 + reviewStatus: input.reviewStatus, + negotiationNote: input.negotiationNote, + isExcluded: input.isExcluded, + } + + // 수정된 내용만 업데이트 + if (input.isNumberModified) { + updateData.modifiedItemNumber = input.modifiedItemNumber + } else { + updateData.modifiedItemNumber = null + } + + if (input.isCategoryModified) { + updateData.modifiedCategory = input.modifiedCategory + } else { + updateData.modifiedCategory = null + } + + if (input.isSubtitleModified) { + updateData.modifiedSubtitle = input.modifiedSubtitle + } else { + updateData.modifiedSubtitle = null + } + + if (input.isContentModified) { + updateData.modifiedContent = input.modifiedContent + } else { + updateData.modifiedContent = null + } + + console.log("updateData",updateData) + + let result + if (vendorClause[0]) { + // 업데이트 + [result] = await db + .update(gtcVendorClauses) + .set(updateData) + .where(eq(gtcVendorClauses.id, vendorClause[0].id)) + .returning() + } else { + // 새로 생성 + const baseClause = await db + .select() + .from(gtcClauses) + .where(eq(gtcClauses.id, input.baseClauseId)) + .limit(1) + + if (!baseClause[0]) { + return { data: null, error: "기본 조항을 찾을 수 없습니다." } + } + + [result] = await db + .insert(gtcVendorClauses) + .values({ + vendorDocumentId: vendorDocument[0].id, + baseClauseId: input.baseClauseId, + parentId: baseClause[0].parentId, + sortOrder: baseClause[0].sortOrder, + depth: baseClause[0].depth, + fullPath: baseClause[0].fullPath, + createdById: input.updatedById, + ...updateData, + }) + .returning() + } + + // 3. 협의 이력 추가 + if (input.negotiationNote) { + await db.insert(gtcNegotiationHistory).values({ + vendorClauseId: result.id, + action: vendorClause[0] ? "modified" : "created", + previousStatus: vendorClause[0]?.reviewStatus || null, + newStatus: input.reviewStatus, + comment: input.negotiationNote, + actorType: "internal", + actorId: input.updatedById, + changedFields: { + isNumberModified: input.isNumberModified, + isCategoryModified: input.isCategoryModified, + isSubtitleModified: input.isSubtitleModified, + isContentModified: input.isContentModified, + }, + }) + } + + // 캐시 무효화 + await revalidateGtcClausesCaches(input.documentId) + + return { data: result, error: null } + } catch (error) { + console.error("Error updating vendor GTC clause:", error) + return { data: null, error: "벤더 조항 수정 중 오류가 발생했습니다." } + } }
\ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/clause-table.tsx b/lib/gtc-contract/gtc-clauses/table/clause-table.tsx index 89674db9..32550299 100644 --- a/lib/gtc-contract/gtc-clauses/table/clause-table.tsx +++ b/lib/gtc-contract/gtc-clauses/table/clause-table.tsx @@ -43,8 +43,6 @@ interface GtcClausesTableProps { export function GtcClausesTable({ promises, documentId, document }: GtcClausesTableProps) { const [{ data, pageCount }, users] = React.use(promises) - console.log(data) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<GtcClauseTreeView> | null>(null) diff --git a/lib/gtc-contract/gtc-clauses/validations.ts b/lib/gtc-contract/gtc-clauses/validations.ts index edbcf612..f60255ba 100644 --- a/lib/gtc-contract/gtc-clauses/validations.ts +++ b/lib/gtc-contract/gtc-clauses/validations.ts @@ -1,4 +1,4 @@ -import { type GtcClause } from "@/db/schema/gtc" +import { GtcClauseTreeView, GtcClauseWithVendorView, GtcVendorClauseView, type GtcClause } from "@/db/schema/gtc" import { createSearchParamsCache, parseAsArrayOf, @@ -15,13 +15,24 @@ export const searchParamsCache = createSearchParamsCache({ ), page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(20), - sort: getSortingStateParser<GtcClause>().withDefault([ - { id: "sortOrder", desc: false }, + sort: getSortingStateParser<GtcClauseTreeView>().withDefault([ + { id: "itemNumber", desc: false }, + ]), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +export const searchParamsVendorCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(20), + sort: getSortingStateParser<GtcClauseWithVendorView>().withDefault([ + { id: "effectiveItemNumber", desc: false }, ]), - // 검색 필터들 - category: parseAsString.withDefault(""), - depth: parseAsInteger.withDefault(0), - parentId: parseAsInteger.withDefault(0), // advanced filter filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), @@ -117,8 +128,79 @@ export const generateVariableNamesSchema = z.object({ }) export type GetGtcClausesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type GetGtcVendorClausesSchema = Awaited<ReturnType<typeof searchParamsVendorCache.parse>> export type CreateGtcClauseSchema = z.infer<typeof createGtcClauseSchema> export type UpdateGtcClauseSchema = z.infer<typeof updateGtcClauseSchema> export type ReorderGtcClausesSchema = z.infer<typeof reorderGtcClausesSchema> export type BulkUpdateGtcClausesSchema = z.infer<typeof bulkUpdateGtcClausesSchema> -export type GenerateVariableNamesSchema = z.infer<typeof generateVariableNamesSchema>
\ No newline at end of file +export type GenerateVariableNamesSchema = z.infer<typeof generateVariableNamesSchema> + + +// validations.ts에 추가 +export const updateVendorGtcClauseSchema = z.object({ + modifiedItemNumber: z.string().optional(), + modifiedCategory: z.string().optional(), + modifiedSubtitle: z.string().optional(), + modifiedContent: z.string().optional(), + + isNumberModified: z.boolean().default(false), + isCategoryModified: z.boolean().default(false), + isSubtitleModified: z.boolean().default(false), + isContentModified: z.boolean().default(false), + + reviewStatus: z.enum([ + "draft", + "pending", + "reviewing", + "approved", + "rejected", + "revised" + ]).default("draft"), + + negotiationNote: z.string().optional(), + isExcluded: z.boolean().default(false), +}) + +export type UpdateVendorGtcClauseSchema = z.infer<typeof updateVendorGtcClauseSchema> + +// validations.ts +export const createVendorGtcClauseSchema = z.object({ + vendorDocumentId: z.number({ + required_error: "벤더 문서 ID는 필수입니다.", + }), + baseClauseId: z.number({ + required_error: "기본 조항 ID는 필수입니다.", + }), + documentId: z.number({ + required_error: "문서 ID는 필수입니다.", + }), + parentId: z.number().nullable().optional(), + modifiedItemNumber: z.string().optional().nullable(), + modifiedCategory: z.string().optional().nullable(), + modifiedSubtitle: z.string().optional().nullable(), + modifiedContent: z.string().optional().nullable(), + sortOrder: z.number().default(0), + reviewStatus: z.enum(["draft", "pending", "reviewing", "approved", "rejected", "revised"]).default("draft"), + negotiationNote: z.string().optional().nullable(), + isExcluded: z.boolean().default(false), + isNumberModified: z.boolean().default(false), + isCategoryModified: z.boolean().default(false), + isSubtitleModified: z.boolean().default(false), + isContentModified: z.boolean().default(false), + editReason: z.string().optional().nullable(), + images: z.array( + z.object({ + id: z.string(), + url: z.string(), + fileName: z.string(), + size: z.number(), + savedName: z.string().optional(), + mimeType: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + hash: z.string().optional(), + }) + ).optional().nullable(), +}) + +export type CreateVendorGtcClauseSchema = z.infer<typeof createVendorGtcClauseSchema>
\ No newline at end of file diff --git a/lib/mail/templates/contract-reminder-en.hbs b/lib/mail/templates/contract-reminder-en.hbs new file mode 100644 index 00000000..ffaadb4b --- /dev/null +++ b/lib/mail/templates/contract-reminder-en.hbs @@ -0,0 +1,118 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Contract Signature Reminder</title> +</head> +<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"> + <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;"> + <tr> + <td align="center"> + <table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"> + <!-- Header --> + <tr> + <td style="background-color: #2563eb; padding: 30px; border-radius: 8px 8px 0 0;"> + <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-align: center;"> + SHI Contract Signature Reminder + </h1> + </td> + </tr> + + <!-- Body --> + <tr> + <td style="padding: 40px 30px;"> + <p style="color: #333333; font-size: 16px; line-height: 1.6; margin-bottom: 20px;"> + Dear {{recipientName}}, + </p> + + <p style="color: #333333; font-size: 16px; line-height: 1.6; margin-bottom: 20px;"> + We would like to remind you that the contract sent to <strong>{{vendorName}}</strong> (Vendor Code: {{vendorCode}}) is still pending your signature. + </p> + + <!-- Contract Info Box --> + <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8f9fa; border-radius: 6px; padding: 20px; margin: 20px 0;"> + <tr> + <td> + <h3 style="color: #2563eb; margin: 0 0 15px 0; font-size: 18px;">Contract Details</h3> + <p style="color: #666666; margin: 8px 0; font-size: 14px;"> + <strong>Contract Name:</strong> {{contractFileName}} + </p> + <p style="color: #666666; margin: 8px 0; font-size: 14px;"> + <strong>Deadline:</strong> {{deadline}} + </p> + {{#if daysRemaining}} + <p style="margin: 15px 0 0 0;"> + {{#if (eq daysRemaining 0)}} + <span style="color: #dc2626; font-weight: bold; font-size: 16px;"> + ⚠️ Today is the deadline! + </span> + {{else if (lt daysRemaining 0)}} + <span style="color: #dc2626; font-weight: bold; font-size: 16px;"> + ⚠️ The deadline has passed by {{Math.abs daysRemaining}} day(s)! + </span> + {{else if (lte daysRemaining 3)}} + <span style="color: #f59e0b; font-weight: bold; font-size: 16px;"> + ⏰ {{daysRemaining}} day(s) remaining until deadline + </span> + {{else}} + <span style="color: #059669; font-weight: bold; font-size: 16px;"> + {{daysRemaining}} day(s) remaining until deadline + </span> + {{/if}} + </p> + {{/if}} + </td> + </tr> + </table> + + <p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;"> + Please click the button below to review and sign the contract. + </p> + + <!-- CTA Button --> + <table width="100%" cellpadding="0" cellspacing="0" style="margin: 30px 0;"> + <tr> + <td align="center"> + <a href="{{contractLink}}" style="display: inline-block; padding: 14px 32px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: bold;"> + Review and Sign Contract + </a> + </td> + </tr> + </table> + + <p style="color: #666666; font-size: 14px; line-height: 1.6; margin-top: 30px;"> + If the button doesn't work, please copy and paste the following link into your browser:<br> + <a href="{{contractLink}}" style="color: #2563eb; word-break: break-all;">{{contractLink}}</a> + </p> + + <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;"> + + <p style="color: #666666; font-size: 14px; line-height: 1.6;"> + If you have any questions or concerns, please don't hesitate to contact us. + </p> + + <p style="color: #666666; font-size: 14px; line-height: 1.6; margin-top: 20px;"> + Best regards,<br><br> + {{senderName}}<br> + {{senderEmail}}<br> + SHI Procurement Team + </p> + </td> + </tr> + + <!-- Footer --> + <tr> + <td style="background-color: #f8f9fa; padding: 20px 30px; border-radius: 0 0 8px 8px; text-align: center;"> + <p style="color: #999999; font-size: 12px; margin: 0;"> + This email was automatically sent from the EVCP Contract Management System.<br> + © 2024 EVCP. All rights reserved. + </p> + </td> + </tr> + </table> + </td> + </tr> + </table> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/contract-reminder-kr.hbs b/lib/mail/templates/contract-reminder-kr.hbs new file mode 100644 index 00000000..acd704a9 --- /dev/null +++ b/lib/mail/templates/contract-reminder-kr.hbs @@ -0,0 +1,118 @@ +<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>계약서 서명 요청 리마인더</title> +</head> +<body style="margin: 0; padding: 0; font-family: 'Malgun Gothic', '맑은 고딕', sans-serif; background-color: #f5f5f5;"> + <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;"> + <tr> + <td align="center"> + <table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"> + <!-- 헤더 --> + <tr> + <td style="background-color: #2563eb; padding: 30px; border-radius: 8px 8px 0 0;"> + <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-align: center;"> + 삼성중공업 계약서 서명 요청 리마인더 + </h1> + </td> + </tr> + + <!-- 본문 --> + <tr> + <td style="padding: 40px 30px;"> + <p style="color: #333333; font-size: 16px; line-height: 1.6; margin-bottom: 20px;"> + 안녕하세요, {{recipientName}}님 + </p> + + <p style="color: #333333; font-size: 16px; line-height: 1.6; margin-bottom: 20px;"> + <strong>{{vendorName}}</strong> (업체코드: {{vendorCode}})로 발송된 계약서의 서명이 아직 완료되지 않았습니다. + </p> + + <!-- 계약서 정보 박스 --> + <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8f9fa; border-radius: 6px; padding: 20px; margin: 20px 0;"> + <tr> + <td> + <h3 style="color: #2563eb; margin: 0 0 15px 0; font-size: 18px;">계약서 정보</h3> + <p style="color: #666666; margin: 8px 0; font-size: 14px;"> + <strong>계약서명:</strong> {{contractFileName}} + </p> + <p style="color: #666666; margin: 8px 0; font-size: 14px;"> + <strong>마감일:</strong> {{deadline}} + </p> + {{#if daysRemaining}} + <p style="margin: 15px 0 0 0;"> + {{#if (eq daysRemaining 0)}} + <span style="color: #dc2626; font-weight: bold; font-size: 16px;"> + ⚠️ 오늘이 마감일입니다! + </span> + {{else if (lt daysRemaining 0)}} + <span style="color: #dc2626; font-weight: bold; font-size: 16px;"> + ⚠️ 마감일이 {{Math.abs daysRemaining}}일 지났습니다! + </span> + {{else if (lte daysRemaining 3)}} + <span style="color: #f59e0b; font-weight: bold; font-size: 16px;"> + ⏰ 마감까지 {{daysRemaining}}일 남았습니다 + </span> + {{else}} + <span style="color: #059669; font-weight: bold; font-size: 16px;"> + 마감까지 {{daysRemaining}}일 남았습니다 + </span> + {{/if}} + </p> + {{/if}} + </td> + </tr> + </table> + + <p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;"> + 계약서 검토 및 서명을 위해 아래 버튼을 클릭해 주시기 바랍니다. + </p> + + <!-- CTA 버튼 --> + <table width="100%" cellpadding="0" cellspacing="0" style="margin: 30px 0;"> + <tr> + <td align="center"> + <a href="{{contractLink}}" style="display: inline-block; padding: 14px 32px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: bold;"> + 계약서 확인 및 서명하기 + </a> + </td> + </tr> + </table> + + <p style="color: #666666; font-size: 14px; line-height: 1.6; margin-top: 30px;"> + 버튼이 작동하지 않는 경우, 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요:<br> + <a href="{{contractLink}}" style="color: #2563eb; word-break: break-all;">{{contractLink}}</a> + </p> + + <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;"> + + <p style="color: #666666; font-size: 14px; line-height: 1.6;"> + 궁금하신 사항이 있으시면 언제든지 문의해 주시기 바랍니다. + </p> + + <p style="color: #666666; font-size: 14px; line-height: 1.6; margin-top: 20px;"> + 감사합니다.<br><br> + {{senderName}}<br> + {{senderEmail}}<br> + SHI 구매팀 + </p> + </td> + </tr> + + <!-- 푸터 --> + <tr> + <td style="background-color: #f8f9fa; padding: 20px 30px; border-radius: 0 0 8px 8px; text-align: center;"> + <p style="color: #999999; font-size: 12px; margin: 0;"> + 이 이메일은 EVCP 계약 관리 시스템에서 자동으로 발송되었습니다.<br> + © 2024 EVCP. All rights reserved. + </p> + </td> + </tr> + </table> + </td> + </tr> + </table> +</body> +</html>
\ No newline at end of file |
