summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-01 09:12:09 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-01 09:12:09 +0000
commit18954df6565108a469fb1608ea3715dd9bb1b02d (patch)
tree2675d254c547861a903a32459d89283a324e0e0d /lib
parentf91cd16a872d9cda04aeb5c4e31538e3e2bd1895 (diff)
(대표님) 구매 기본계약, gtc 개발
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/gtc-vendor/bulk-update-gtc-clauses-dialog.tsx276
-rw-r--r--lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx570
-rw-r--r--lib/basic-contract/gtc-vendor/clause-table.tsx261
-rw-r--r--lib/basic-contract/gtc-vendor/clause-variable-settings-dialog.tsx364
-rw-r--r--lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx625
-rw-r--r--lib/basic-contract/gtc-vendor/delete-gtc-clauses-dialog.tsx175
-rw-r--r--lib/basic-contract/gtc-vendor/duplicate-gtc-clause-dialog.tsx372
-rw-r--r--lib/basic-contract/gtc-vendor/excel-import.tsx340
-rw-r--r--lib/basic-contract/gtc-vendor/generate-variable-names-dialog.tsx348
-rw-r--r--lib/basic-contract/gtc-vendor/gtc-clauses-table-columns.tsx409
-rw-r--r--lib/basic-contract/gtc-vendor/gtc-clauses-table-floating-bar.tsx239
-rw-r--r--lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx350
-rw-r--r--lib/basic-contract/gtc-vendor/import-excel-dialog.tsx381
-rw-r--r--lib/basic-contract/gtc-vendor/markdown-image-editor.tsx360
-rw-r--r--lib/basic-contract/gtc-vendor/preview-document-dialog.tsx272
-rw-r--r--lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx540
-rw-r--r--lib/basic-contract/gtc-vendor/update-gtc-clause-sheet.tsx522
-rw-r--r--lib/basic-contract/gtc-vendor/view-clause-variables-dialog.tsx231
-rw-r--r--lib/basic-contract/service.ts556
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx10
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx72
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx66
-rw-r--r--lib/basic-contract/validations.ts10
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx237
-rw-r--r--lib/basic-contract/vendor-table/update-vendor-document-status-button.tsx145
-rw-r--r--lib/basic-contract/viewer/GtcClausesComponent.tsx262
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx256
-rw-r--r--lib/file-stroage.ts14
-rw-r--r--lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx16
-rw-r--r--lib/gtc-contract/gtc-clauses/service.ts423
-rw-r--r--lib/gtc-contract/gtc-clauses/table/clause-table.tsx2
-rw-r--r--lib/gtc-contract/gtc-clauses/validations.ts98
-rw-r--r--lib/mail/templates/contract-reminder-en.hbs118
-rw-r--r--lib/mail/templates/contract-reminder-kr.hbs118
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