summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list')
-rw-r--r--lib/vendor-document-list/dolce-upload-service.ts17
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts8
-rw-r--r--lib/vendor-document-list/import-service.ts56
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx446
-rw-r--r--lib/vendor-document-list/plant/document-stage-toolbar.tsx103
-rw-r--r--lib/vendor-document-list/plant/document-stages-columns.tsx10
-rw-r--r--lib/vendor-document-list/plant/document-stages-expanded-content.tsx4
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts400
-rw-r--r--lib/vendor-document-list/plant/document-stages-table.tsx107
-rw-r--r--lib/vendor-document-list/service.ts15
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx80
-rw-r--r--lib/vendor-document-list/ship/enhanced-documents-table.tsx111
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx363
-rw-r--r--lib/vendor-document-list/ship/send-to-shi-button.tsx32
-rw-r--r--lib/vendor-document-list/sync-service.ts270
15 files changed, 1179 insertions, 843 deletions
diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts
index 032b028c..d0db9f2f 100644
--- a/lib/vendor-document-list/dolce-upload-service.ts
+++ b/lib/vendor-document-list/dolce-upload-service.ts
@@ -110,18 +110,18 @@ class DOLCEUploadService {
* 메인 업로드 함수: 변경된 문서와 파일을 DOLCE로 업로드
*/
async uploadToDoLCE(
- contractId: number,
+ projectId: number,
revisionIds: number[],
userId: string,
userName?: string
): Promise<DOLCEUploadResult> {
try {
- console.log(`Starting DOLCE upload for contract ${contractId}, revisions: ${revisionIds.join(', ')}`)
+ console.log(`Starting DOLCE upload for contract ${projectId}, revisions: ${revisionIds.join(', ')}`)
// 1. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등)
- const contractInfo = await this.getContractInfo(contractId)
+ const contractInfo = await this.getContractInfo(projectId)
if (!contractInfo) {
- throw new Error(`Contract info not found for ID: ${contractId}`)
+ throw new Error(`Contract info not found for ID: ${projectId}`)
}
// 2. 업로드할 리비전 정보 조회
@@ -215,7 +215,7 @@ class DOLCEUploadService {
/**
* 계약 정보 조회
*/
- private async getContractInfo(contractId: number) {
+ private async getContractInfo(revisionIds: number) {
const [result] = await db
.select({
projectCode: projects.code,
@@ -225,7 +225,7 @@ class DOLCEUploadService {
.from(contracts)
.innerJoin(projects, eq(contracts.projectId, projects.id))
.innerJoin(vendors, eq(contracts.vendorId, vendors.id))
- .where(eq(contracts.id, contractId))
+ .where(eq(contracts.projectId, revisionIds))
.limit(1)
return result
@@ -468,6 +468,7 @@ private async finalizeUploadResult(resultDataArray: ResultData[]): Promise<void>
const result = await response.text()
if (result !== 'Success') {
+ console.log(result,"돌체 업로드 실패")
throw new Error(`PWPUploadResultService returned unexpected result: ${result}`)
}
@@ -861,10 +862,10 @@ export const dolceUploadService = new DOLCEUploadService()
// 편의 함수
export async function uploadRevisionsToDOLCE(
- contractId: number,
+ projectId: number,
revisionIds: number[],
userId: string,
userName?: string
): Promise<DOLCEUploadResult> {
- return dolceUploadService.uploadToDoLCE(contractId, revisionIds, userId, userName)
+ return dolceUploadService.uploadToDoLCE(projectId, revisionIds, userId, userName)
} \ No newline at end of file
diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts
index 9eaa2a40..28fad74b 100644
--- a/lib/vendor-document-list/enhanced-document-service.ts
+++ b/lib/vendor-document-list/enhanced-document-service.ts
@@ -1027,11 +1027,11 @@ export async function getDocumentDetails(documentId: number) {
// 2. 해당 벤더의 모든 계약 ID들 조회
const vendorContracts = await db
- .select({ id: contracts.id })
+ .select({ projectId: contracts.projectId })
.from(contracts)
.where(eq(contracts.vendorId, companyId))
- const contractIds = vendorContracts.map(c => c.id)
+ const contractIds = vendorContracts.map(c => c.projectId)
if (contractIds.length === 0) {
return { data: [], pageCount: 0, total: 0, drawingKind: null, vendorInfo: null }
@@ -1057,7 +1057,7 @@ export async function getDocumentDetails(documentId: number) {
// 5. 최종 WHERE 조건 (계약 ID들로 필터링)
const finalWhere = and(
- inArray(simplifiedDocumentsView.contractId, contractIds),
+ inArray(simplifiedDocumentsView.projectId, contractIds),
advancedWhere,
globalWhere,
)
@@ -1101,7 +1101,7 @@ export async function getDocumentDetails(documentId: number) {
})
.from(contracts)
.leftJoin(vendors, eq(contracts.vendorId, vendors.id))
- .where(eq(contracts.id, contractIds[0]))
+ .where(eq(contracts.projectId, contractIds[0]))
.limit(1)
return { data, total, drawingKind, vendorInfo }
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts
index c7ba041a..9e1016ea 100644
--- a/lib/vendor-document-list/import-service.ts
+++ b/lib/vendor-document-list/import-service.ts
@@ -141,16 +141,16 @@ class ImportService {
* DOLCE 시스템에서 문서 목록 가져오기
*/
async importFromExternalSystem(
- contractId: number,
+ projectId: number,
sourceSystem: string = 'DOLCE'
): Promise<ImportResult> {
try {
- console.log(`Starting import from ${sourceSystem} for contract ${contractId}`)
+ console.log(`Starting import from ${sourceSystem} for contract ${projectId}`)
// 1. 계약 정보를 통해 프로젝트 코드와 벤더 코드 조회
- const contractInfo = await this.getContractInfoById(contractId)
+ const contractInfo = await this.getContractInfoById(projectId)
if (!contractInfo?.projectCode || !contractInfo?.vendorCode) {
- throw new Error(`Project code or vendor code not found for contract ${contractId}`)
+ throw new Error(`Project code or vendor code not found for contract ${projectId}`)
}
// 2. 각 drawingKind별로 데이터 조회
@@ -200,19 +200,19 @@ class ImportService {
// 3. 각 문서 동기화 처리
for (const dolceDoc of allDocuments) {
try {
- const result = await this.syncSingleDocument(contractId, dolceDoc, sourceSystem)
+ const result = await this.syncSingleDocument(projectId, dolceDoc, sourceSystem)
if (result === 'NEW') {
newCount++
// B4 문서의 경우 이슈 스테이지 자동 생성
if (dolceDoc.DrawingKind === 'B4') {
- await this.createIssueStagesForB4Document(dolceDoc.DrawingNo, contractId, dolceDoc)
+ await this.createIssueStagesForB4Document(dolceDoc.DrawingNo, projectId, dolceDoc)
}
if (dolceDoc.DrawingKind === 'B3') {
- await this.createIssueStagesForB3Document(dolceDoc.DrawingNo, contractId, dolceDoc)
+ await this.createIssueStagesForB3Document(dolceDoc.DrawingNo, projectId, dolceDoc)
}
if (dolceDoc.DrawingKind === 'B5') {
- await this.createIssueStagesForB5Document(dolceDoc.DrawingNo, contractId, dolceDoc)
+ await this.createIssueStagesForB5Document(dolceDoc.DrawingNo, projectId, dolceDoc)
}
} else if (result === 'UPDATED') {
updatedCount++
@@ -223,7 +223,7 @@ class ImportService {
// 4. revisions 동기화 처리
try {
const revisionResult = await this.syncDocumentRevisions(
- contractId,
+ projectId,
dolceDoc,
sourceSystem
)
@@ -277,7 +277,7 @@ class ImportService {
/**
* 계약 ID로 프로젝트 코드와 벤더 코드 조회
*/
- private async getContractInfoById(contractId: number): Promise<{
+ private async getContractInfoById(projectId: number): Promise<{
projectCode: string;
vendorCode: string;
} | null> {
@@ -289,7 +289,7 @@ class ImportService {
.from(contracts)
.innerJoin(projects, eq(contracts.projectId, projects.id))
.innerJoin(vendors, eq(contracts.vendorId, vendors.id))
- .where(eq(contracts.id, contractId))
+ .where(eq(contracts.projectId, projectId))
.limit(1)
return result?.projectCode && result?.vendorCode
@@ -560,7 +560,7 @@ class ImportService {
* 단일 문서 동기화
*/
private async syncSingleDocument(
- contractId: number,
+ projectId: number,
dolceDoc: DOLCEDocument,
sourceSystem: string
): Promise<'NEW' | 'UPDATED' | 'SKIPPED'> {
@@ -569,14 +569,14 @@ class ImportService {
.select()
.from(documents)
.where(and(
- eq(documents.contractId, contractId),
+ eq(documents.projectId, projectId),
eq(documents.docNumber, dolceDoc.DrawingNo)
))
.limit(1)
// DOLCE 문서를 DB 스키마에 맞게 변환
const documentData = {
- contractId,
+ projectId,
docNumber: dolceDoc.DrawingNo,
title: dolceDoc.DrawingName,
status: 'ACTIVE',
@@ -653,7 +653,7 @@ class ImportService {
* 문서의 revisions 동기화
*/
private async syncDocumentRevisions(
- contractId: number,
+ projectId: number,
dolceDoc: DOLCEDocument,
sourceSystem: string
): Promise<{ newCount: number; updatedCount: number }> {
@@ -676,7 +676,7 @@ class ImportService {
.select({ id: documents.id })
.from(documents)
.where(and(
- eq(documents.contractId, contractId),
+ eq(documents.projectId, projectId),
eq(documents.docNumber, dolceDoc.DrawingNo)
))
.limit(1)
@@ -1140,7 +1140,7 @@ class ImportService {
*/
private async createIssueStagesForB4Document(
drawingNo: string,
- contractId: number,
+ projectId: number,
dolceDoc: DOLCEDocument
): Promise<void> {
try {
@@ -1149,7 +1149,7 @@ class ImportService {
.select({ id: documents.id })
.from(documents)
.where(and(
- eq(documents.contractId, contractId),
+ eq(documents.projectId, projectId),
eq(documents.docNumber, drawingNo)
))
.limit(1)
@@ -1205,7 +1205,7 @@ class ImportService {
private async createIssueStagesForB3Document(
drawingNo: string,
- contractId: number,
+ projectId: number,
dolceDoc: DOLCEDocument
): Promise<void> {
try {
@@ -1214,7 +1214,7 @@ class ImportService {
.select({ id: documents.id })
.from(documents)
.where(and(
- eq(documents.contractId, contractId),
+ eq(documents.projectId, projectId),
eq(documents.docNumber, drawingNo)
))
.limit(1)
@@ -1268,7 +1268,7 @@ class ImportService {
private async createIssueStagesForB5Document(
drawingNo: string,
- contractId: number,
+ projectId: number,
dolceDoc: DOLCEDocument
): Promise<void> {
try {
@@ -1277,7 +1277,7 @@ class ImportService {
.select({ id: documents.id })
.from(documents)
.where(and(
- eq(documents.contractId, contractId),
+ eq(documents.projectId, projectId),
eq(documents.docNumber, drawingNo)
))
.limit(1)
@@ -1336,7 +1336,7 @@ class ImportService {
* 가져오기 상태 조회 - 에러 시 안전한 기본값 반환
*/
async getImportStatus(
- contractId: number,
+ projectId: number,
sourceSystem: string = 'DOLCE'
): Promise<ImportStatus> {
try {
@@ -1347,16 +1347,16 @@ async getImportStatus(
})
.from(documents)
.where(and(
- eq(documents.contractId, contractId),
+ eq(documents.projectId, projectId),
eq(documents.externalSystemType, sourceSystem)
))
// 프로젝트 코드와 벤더 코드 조회
- const contractInfo = await this.getContractInfoById(contractId)
+ const contractInfo = await this.getContractInfoById(projectId)
// 🔥 계약 정보가 없으면 기본 상태 반환 (에러 throw 하지 않음)
if (!contractInfo?.projectCode || !contractInfo?.vendorCode) {
- console.warn(`Project code or vendor code not found for contract ${contractId}`)
+ console.warn(`Project code or vendor code not found for contract ${projectId}`)
return {
lastImportAt: lastImport?.lastSynced ? new Date(lastImport.lastSynced).toISOString() : undefined,
availableDocuments: 0,
@@ -1369,7 +1369,7 @@ async getImportStatus(
newAttachments: 0,
updatedAttachments: 0,
importEnabled: false, // 🔥 계약 정보가 없으면 import 비활성화
- error: `Contract ${contractId}에 대한 프로젝트 코드 또는 벤더 코드를 찾을 수 없습니다.` // 🔥 에러 메시지 추가
+ error: `Contract ${projectId}에 대한 프로젝트 코드 또는 벤더 코드를 찾을 수 없습니다.` // 🔥 에러 메시지 추가
}
}
@@ -1402,7 +1402,7 @@ async getImportStatus(
.select({ id: documents.id, updatedAt: documents.updatedAt })
.from(documents)
.where(and(
- eq(documents.contractId, contractId),
+ eq(documents.projectId, projectId),
eq(documents.docNumber, externalDoc.DrawingNo)
))
.limit(1)
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index 732a4bed..726ea101 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -3,11 +3,13 @@
import React from "react"
import {
Dialog,
+ DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
+ DialogTrigger,
} from "@/components/ui/dialog"
import {
Sheet,
@@ -30,19 +32,36 @@ import {
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { DocumentStagesOnlyView } from "@/db/schema"
-import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2 } from "lucide-react"
+import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash} from "lucide-react"
import { toast } from "sonner"
import {
getDocumentNumberTypes,
getDocumentNumberTypeConfigs,
getComboBoxOptions,
getDocumentClasses,
+ getDocumentClassOptions,
createDocument,
+ updateDocument,
+ deleteDocuments,
updateStage
} from "./document-stages-service"
+import { type Row } from "@tanstack/react-table"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { useRouter } from "next/navigation"
// =============================================================================
-// 1. Add Document Dialog (Updated with fixed header/footer and English text)
+// 1. Add Document Dialog
// =============================================================================
interface AddDocumentDialogProps {
open: boolean
@@ -62,14 +81,15 @@ export function AddDocumentDialog({
const [documentClasses, setDocumentClasses] = React.useState<any[]>([])
const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([])
const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({})
+ const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([])
const [formData, setFormData] = React.useState({
documentNumberTypeId: "",
documentClassId: "",
title: "",
- pic: "",
vendorDocNumber: "",
- fieldValues: {} as Record<string, string>
+ fieldValues: {} as Record<string, string>,
+ planDates: {} as Record<number, string> // optionId -> planDate
})
// Load initial data
@@ -150,6 +170,35 @@ export function AddDocumentDialog({
})
}
+ // Handle document class change
+ const handleDocumentClassChange = async (documentClassId: string) => {
+ setFormData({
+ ...formData,
+ documentClassId,
+ planDates: {}
+ })
+
+ if (documentClassId) {
+ const optionsResult = await getDocumentClassOptions(Number(documentClassId))
+ if (optionsResult.success) {
+ setDocumentClassOptions(optionsResult.data)
+ }
+ } else {
+ setDocumentClassOptions([])
+ }
+ }
+
+ // Handle plan date change
+ const handlePlanDateChange = (optionId: number, date: string) => {
+ setFormData({
+ ...formData,
+ planDates: {
+ ...formData.planDates,
+ [optionId]: date
+ }
+ })
+ }
+
// Generate document number preview
const generatePreviewDocNumber = () => {
if (selectedTypeConfigs.length === 0) return ""
@@ -166,12 +215,44 @@ export function AddDocumentDialog({
return preview
}
+ // Check if form is valid for submission
+ const isFormValid = () => {
+ // Check basic required fields
+ if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title.trim()) {
+ return false
+ }
+
+ // Check if all required document number components are filled
+ const requiredConfigs = selectedTypeConfigs.filter(config => config.required)
+ for (const config of requiredConfigs) {
+ const fieldKey = `field_${config.sdq}`
+ const value = formData.fieldValues[fieldKey]
+ if (!value || !value.trim()) {
+ return false
+ }
+ }
+
+ // Check if document number can be generated
+ const docNumber = generatePreviewDocNumber()
+ if (!docNumber || docNumber === "" || docNumber.includes("[value]")) {
+ return false
+ }
+
+ return true
+ }
+
const handleSubmit = async () => {
- if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title) {
+ if (!isFormValid()) {
toast.error("Please fill in all required fields.")
return
}
+ const docNumber = generatePreviewDocNumber()
+ if (!docNumber) {
+ toast.error("Cannot generate document number.")
+ return
+ }
+
setIsLoading(true)
try {
const result = await createDocument({
@@ -179,8 +260,9 @@ export function AddDocumentDialog({
documentNumberTypeId: Number(formData.documentNumberTypeId),
documentClassId: Number(formData.documentClassId),
title: formData.title,
+ docNumber: docNumber, // 미리 생성된 문서번호 전송
fieldValues: formData.fieldValues,
- pic: formData.pic,
+ planDates: formData.planDates,
vendorDocNumber: formData.vendorDocNumber,
})
@@ -203,12 +285,13 @@ export function AddDocumentDialog({
documentNumberTypeId: "",
documentClassId: "",
title: "",
- pic: "",
vendorDocNumber: "",
- fieldValues: {}
+ fieldValues: {},
+ planDates: {}
})
setSelectedTypeConfigs([])
setComboBoxOptions({})
+ setDocumentClassOptions([])
}
const isPlantProject = projectType === "plant"
@@ -317,7 +400,7 @@ export function AddDocumentDialog({
</Label>
<Select
value={formData.documentClassId}
- onValueChange={(value) => setFormData({ ...formData, documentClassId: value })}
+ onValueChange={handleDocumentClassChange}
>
<SelectTrigger>
<SelectValue placeholder="Select document class" />
@@ -337,6 +420,38 @@ export function AddDocumentDialog({
)}
</div>
+ {/* Document Class Options with Plan Dates */}
+ {documentClassOptions.length > 0 && (
+ <div className="border rounded-lg p-4 bg-green-50/30">
+ <Label className="text-sm font-medium text-green-800 mb-3 block">
+ Document Class Stages with Plan Dates
+ </Label>
+ <div className="grid gap-3">
+ {documentClassOptions.map((option) => (
+ <div key={option.id} className="grid grid-cols-2 gap-3 items-center">
+ <div>
+ <Label className="text-sm font-medium">
+ {option.optionValue}
+ </Label>
+ {option.optionCode && (
+ <p className="text-xs text-gray-500">Code: {option.optionCode}</p>
+ )}
+ </div>
+ <div className="grid gap-1">
+ <Label className="text-xs text-gray-600">Plan Date</Label>
+ <Input
+ type="date"
+ value={formData.planDates[option.id] || ""}
+ onChange={(e) => handlePlanDateChange(option.id, e.target.value)}
+ className="text-sm"
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
{/* Document Title */}
<div className="grid gap-2">
<Label htmlFor="title">
@@ -351,17 +466,17 @@ export function AddDocumentDialog({
</div>
{/* Additional Information */}
- {isPlantProject && (
- <div className="grid gap-2">
- <Label htmlFor="vendorDocNumber">Vendor Document Number</Label>
- <Input
- id="vendorDocNumber"
- value={formData.vendorDocNumber}
- onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })}
- placeholder="Vendor provided document number"
- />
- </div>
- )}
+ {isPlantProject && (
+ <div className="grid gap-2">
+ <Label htmlFor="vendorDocNumber">Vendor Document Number</Label>
+ <Input
+ id="vendorDocNumber"
+ value={formData.vendorDocNumber}
+ onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })}
+ placeholder="Vendor provided document number"
+ />
+ </div>
+ )}
</div>
</div>
)}
@@ -370,7 +485,7 @@ export function AddDocumentDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
- <Button onClick={handleSubmit} disabled={isLoading}>
+ <Button onClick={handleSubmit} disabled={isLoading || !isFormValid()}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Add Document
</Button>
@@ -381,7 +496,7 @@ export function AddDocumentDialog({
}
// =============================================================================
-// 2. Edit Document Dialog (Updated with English text)
+// 2. Edit Document Dialog
// =============================================================================
interface EditDocumentDialogProps {
open: boolean
@@ -398,29 +513,77 @@ export function EditDocumentDialog({
contractId,
projectType
}: EditDocumentDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
const [formData, setFormData] = React.useState({
title: "",
- pic: "",
vendorDocNumber: "",
+ stagePlanDates: {} as Record<number, string> // stageId -> planDate
})
React.useEffect(() => {
if (document) {
+ // 기본 문서 정보 설정
setFormData({
title: document.title || "",
- pic: document.pic || "",
vendorDocNumber: document.vendorDocNumber || "",
+ stagePlanDates: {}
})
+
+ // 현재 스테이지들의 plan date 설정
+ if (document.allStages) {
+ const planDates: Record<number, string> = {}
+ document.allStages.forEach(stage => {
+ if (stage.planDate) {
+ planDates[stage.id] = stage.planDate
+ }
+ })
+ setFormData(prev => ({
+ ...prev,
+ stagePlanDates: planDates
+ }))
+ }
}
}, [document])
+ const handleStagePlanDateChange = (stageId: number, date: string) => {
+ setFormData({
+ ...formData,
+ stagePlanDates: {
+ ...formData.stagePlanDates,
+ [stageId]: date
+ }
+ })
+ }
+
+ const isFormValid = () => {
+ return formData.title.trim() !== ""
+ }
+
const handleSubmit = async () => {
+ if (!isFormValid() || !document) {
+ toast.error("Please fill in all required fields.")
+ return
+ }
+
+ setIsLoading(true)
try {
- // TODO: API call to update document
- toast.success("Document updated successfully.")
- onOpenChange(false)
+ const result = await updateDocument({
+ documentId: document.id,
+ title: formData.title,
+ vendorDocNumber: formData.vendorDocNumber,
+ stagePlanDates: formData.stagePlanDates,
+ })
+
+ if (result.success) {
+ toast.success("Document updated successfully.")
+ onOpenChange(false)
+ } else {
+ toast.error(result.error || "Error updating document.")
+ }
} catch (error) {
toast.error("Error updating document.")
+ } finally {
+ setIsLoading(false)
}
}
@@ -428,35 +591,38 @@ export function EditDocumentDialog({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="sm:max-w-[500px]">
- <SheetHeader>
+ <SheetContent className="sm:max-w-[600px] h-full flex flex-col">
+ <SheetHeader className="flex-shrink-0">
<SheetTitle>Edit Document</SheetTitle>
<SheetDescription>
- You can modify the basic information of the document.
+ You can modify the document information and stage plan dates.
</SheetDescription>
</SheetHeader>
- <div className="grid gap-4 py-4">
- <div className="grid gap-2">
- <Label>Document Number</Label>
- <div className="p-2 bg-gray-100 rounded text-sm font-mono">
- {document?.docNumber}
+ <div className="flex-1 overflow-y-auto pr-2">
+ <div className="grid gap-4 py-4">
+ {/* Document Number (Read-only) */}
+ <div className="grid gap-2">
+ <Label>Document Number</Label>
+ <div className="p-2 bg-gray-100 rounded text-sm font-mono">
+ {document?.docNumber}
+ </div>
</div>
- </div>
- <div className="grid gap-2">
- <Label htmlFor="edit-title">
- Document Title <span className="text-red-500">*</span>
- </Label>
- <Input
- id="edit-title"
- value={formData.title}
- onChange={(e) => setFormData({ ...formData, title: e.target.value })}
- placeholder="Enter document title"
- />
- </div>
+ {/* Document Title */}
+ <div className="grid gap-2">
+ <Label htmlFor="edit-title">
+ Document Title <span className="text-red-500">*</span>
+ </Label>
+ <Input
+ id="edit-title"
+ value={formData.title}
+ onChange={(e) => setFormData({ ...formData, title: e.target.value })}
+ placeholder="Enter document title"
+ />
+ </div>
- <div className="grid grid-cols-2 gap-4">
+ {/* Vendor Document Number (Plant project only) */}
{isPlantProject && (
<div className="grid gap-2">
<Label htmlFor="edit-vendorDocNumber">Vendor Document Number</Label>
@@ -468,23 +634,50 @@ export function EditDocumentDialog({
/>
</div>
)}
- <div className="grid gap-2">
- <Label htmlFor="edit-pic">PIC</Label>
- <Input
- id="edit-pic"
- value={formData.pic}
- onChange={(e) => setFormData({ ...formData, pic: e.target.value })}
- placeholder="Person in charge"
- />
- </div>
+
+ {/* Current Document Stages with Plan Dates */}
+ {document?.allStages && document.allStages.length > 0 && (
+ <div className="border rounded-lg p-4 bg-green-50/30">
+ <Label className="text-sm font-medium text-green-800 mb-3 block">
+ Document Stages - Plan Dates
+ </Label>
+ <div className="grid gap-3">
+ {document.allStages
+ .sort((a, b) => (a.stageOrder || 0) - (b.stageOrder || 0))
+ .map((stage) => (
+ <div key={stage.id} className="grid grid-cols-2 gap-3 items-center">
+ <div>
+ <Label className="text-sm font-medium">
+ {stage.stageName}
+ </Label>
+ <p className="text-xs text-gray-500">
+ Status: {stage.stageStatus}
+ {stage.actualDate && ` | Completed: ${stage.actualDate}`}
+ </p>
+ </div>
+ <div className="grid gap-1">
+ <Label className="text-xs text-gray-600">Plan Date</Label>
+ <Input
+ type="date"
+ value={formData.stagePlanDates[stage.id] || ""}
+ onChange={(e) => handleStagePlanDateChange(stage.id, e.target.value)}
+ className="text-sm"
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
</div>
</div>
- <SheetFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
+ <SheetFooter className="flex-shrink-0">
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
- <Button onClick={handleSubmit}>
+ <Button onClick={handleSubmit} disabled={isLoading || !isFormValid()}>
+ {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Save Changes
</Button>
</SheetFooter>
@@ -494,7 +687,7 @@ export function EditDocumentDialog({
}
// =============================================================================
-// 3. Edit Stage Dialog (Updated with English text)
+// 3. Edit Stage Dialog
// =============================================================================
interface EditStageDialogProps {
open: boolean
@@ -687,7 +880,7 @@ export function EditStageDialog({
}
// =============================================================================
-// 4. Excel Import Dialog (Updated with English text)
+// 4. Excel Import Dialog
// =============================================================================
interface ExcelImportDialogProps {
open: boolean
@@ -767,7 +960,7 @@ export function ExcelImportDialog({
<p>• First row must be header row</p>
<p>• Required columns: Document Number, Document Title, Document Class</p>
{projectType === "plant" && (
- <p>• Optional columns: Vendor Document Number, PIC</p>
+ <p>• Optional columns: Vendor Document Number</p>
)}
<p>• Supported formats: .xlsx, .xls</p>
</div>
@@ -786,4 +979,129 @@ export function ExcelImportDialog({
</DialogContent>
</Dialog>
)
-} \ No newline at end of file
+}
+
+// =============================================================================
+// 5. Delete Documents Confirmation Dialog
+// =============================================================================
+
+interface DeleteDocumentsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ documents: Row<DocumentStagesOnlyView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteDocumentsDialog({
+ documents,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteDocumentsDialogProps) {
+
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const router = useRouter()
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await deleteDocuments({
+ ids: documents.map((document) => document.documentId),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Documents deleted")
+
+ router.refresh()
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({documents.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{documents.length}</span>
+ {documents.length === 1 ? " document" : " documents"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({documents.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{documents.length}</span>
+ {documents.length === 1 ? " document" : " documents"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx
new file mode 100644
index 00000000..87b221b7
--- /dev/null
+++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx
@@ -0,0 +1,103 @@
+"use client"
+
+import * as React from "react"
+import { type DocumentStagesOnlyView } from "@/db/schema"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload, Plus, FileSpreadsheet } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+// 다이얼로그 컴포넌트들 import
+import {
+ DeleteDocumentsDialog,
+ AddDocumentDialog,
+ ExcelImportDialog
+} from "./document-stage-dialogs"
+
+// 서버 액션 import (필요한 경우)
+// import { importDocumentsExcel } from "./document-stages-service"
+
+interface DocumentsTableToolbarActionsProps {
+ table: Table<DocumentStagesOnlyView>
+ contractId: number
+ projectType: "ship" | "plant"
+}
+
+export function DocumentsTableToolbarActions({
+ table,
+ contractId,
+ projectType
+}: DocumentsTableToolbarActionsProps) {
+ // 다이얼로그 상태 관리
+ const [showAddDialog, setShowAddDialog] = React.useState(false)
+ const [showExcelImportDialog, setShowExcelImportDialog] = React.useState(false)
+
+ const handleExcelImport = () => {
+ setShowExcelImportDialog(true)
+ }
+ function handleDeleteSuccess() {
+ // 삭제 성공 후 모든 선택 해제
+ table.toggleAllRowsSelected(false)
+ toast.success("Selected documents deleted successfully")
+ }
+
+ function handleExport() {
+ exportTableToExcel(table, {
+ filename: `documents_contract_${contractId}`,
+ excludeColumns: ["select", "actions"], // 체크박스와 액션 컬럼 제외
+ })
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 1) 선택된 문서가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteDocumentsDialog
+ documents={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+
+ {/* 2) 새 문서 추가 다이얼로그 */}
+ <Button onClick={() => setShowAddDialog(true)} size="sm" className="gap-2">
+ <Plus className="h-4 w-4" />
+ <span className="hidden sm:inline">Add Document</span>
+ </Button>
+
+ <AddDocumentDialog
+ open={showAddDialog}
+ onOpenChange={setShowAddDialog}
+ contractId={contractId}
+ projectType={projectType}
+ />
+
+ <Button onClick={handleExcelImport} variant="outline" size="sm">
+ <FileSpreadsheet className="mr-2 h-4 w-4" />
+ Excel Import
+ </Button>
+
+ <ExcelImportDialog
+ open={showExcelImportDialog}
+ onOpenChange={setShowExcelImportDialog}
+ contractId={contractId}
+ projectType={projectType}
+ />
+
+ {/* 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ className="gap-2"
+ >
+ <Download className="h-4 w-4" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+}
diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx
index d39af4e8..7456c2aa 100644
--- a/lib/vendor-document-list/plant/document-stages-columns.tsx
+++ b/lib/vendor-document-list/plant/document-stages-columns.tsx
@@ -328,7 +328,7 @@ export function getDocumentStagesColumns({
return (
<div className="flex items-center gap-2">
- <span className="text-sm">{formatDate(doc.currentStagePlanDate, 'MM/dd')}</span>
+ <span className="text-sm">{formatDate(doc.currentStagePlanDate)}</span>
<DueDateInfo
daysUntilDue={doc.daysUntilDue}
isOverdue={doc.isOverdue || false}
@@ -336,7 +336,7 @@ export function getDocumentStagesColumns({
</div>
)
},
- size: 120,
+ size: 150,
enableResizing: true,
meta: {
excelHeader: "Plan Date"
@@ -425,7 +425,7 @@ export function getDocumentStagesColumns({
key: "edit_document",
label: "Edit Document",
icon: Edit,
- action: () => setRowAction({ row, type: "edit_document" }),
+ action: () => setRowAction({ row, type: "update" }),
show: true
}
]
@@ -502,9 +502,9 @@ export function getDocumentStagesColumns({
>
<action.icon className="mr-2 h-3 w-3" />
<span className="text-xs">{action.label}</span>
- {action.shortcut && (
+ {/* {action.shortcut && (
<DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut>
- )}
+ )} */}
</DropdownMenuItem>
))}
</>
diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx
index 2f6b637c..070d6904 100644
--- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx
+++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx
@@ -106,13 +106,13 @@ export function DocumentStagesExpandedContent({
{planDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
- <span>계획: {formatDate(planDate.toISOString(), 'MM/dd')}</span>
+ <span>계획: {formatDate(planDate.toISOString())}</span>
</div>
)}
{actualDate && (
<div className="flex items-center gap-1">
<CheckCircle className="h-3 w-3 text-green-500" />
- <span>실적: {formatDate(actualDate.toISOString(), 'MM/dd')}</span>
+ <span>실적: {formatDate(actualDate.toISOString())}</span>
</div>
)}
</div>
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index 2fd20fa4..108b5869 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -4,8 +4,8 @@
import { revalidatePath, revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import db from "@/db/db"
-import { codeGroups, comboBoxSettings, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages } from "@/db/schema"
-import { and, eq, asc, desc, sql, inArray, max } from "drizzle-orm"
+import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages } from "@/db/schema"
+import { and, eq, asc, desc, sql, inArray, max, ne ,or, ilike} from "drizzle-orm"
import {
createDocumentSchema,
updateDocumentSchema,
@@ -31,163 +31,57 @@ import { filterColumns } from "@/lib/filter-columns"
import { GetEnhancedDocumentsSchema } from "../enhanced-document-service"
import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository"
-// =============================================================================
-// 1. 문서 관련 액션들
-// =============================================================================
+interface UpdateDocumentData {
+ documentId: number
+ title: string
+ vendorDocNumber?: string
+ stagePlanDates: Record<number, string> // stageId -> planDate
+}
-// 문서 생성
-// export async function createDocument(input: CreateDocumentInput) {
-// noStore()
-
-// try {
-// // 입력값 검증
-// const validatedData = createDocumentSchema.parse(input)
-
-// // 프로젝트 타입 확인 (계약 정보에서 가져와야 함)
-// const contract = await db.query.contracts.findFirst({
-// where: eq(documents.contractId, validatedData.contractId),
-// with: { project: true }
-// })
-
-// if (!contract) {
-// throw new Error("계약 정보를 찾을 수 없습니다")
-// }
-
-// const projectType = contract.project?.type === "plant" ? "plant" : "ship"
-
-// // 문서번호 유효성 검사
-// if (!validateDocNumber(validatedData.docNumber, projectType)) {
-// throw new Error(`${projectType === "ship" ? "선박" : "플랜트"} 프로젝트의 문서번호 형식에 맞지 않습니다`)
-// }
-
-// // B4 필드 유효성 검사
-// validateB4Fields(validatedData)
-
-// // 문서번호 중복 검사
-// const existingDoc = await db.query.documents.findFirst({
-// where: and(
-// eq(documents.contractId, validatedData.contractId),
-// eq(documents.docNumber, validatedData.docNumber),
-// eq(documents.status, "ACTIVE")
-// )
-// })
-
-// if (existingDoc) {
-// throw new Error("이미 존재하는 문서번호입니다")
-// }
-
-// // 문서 생성
-// const [newDocument] = await db.insert(documents).values({
-// contractId: validatedData.contractId,
-// docNumber: validatedData.docNumber,
-// title: validatedData.title,
-// drawingKind: validatedData.drawingKind,
-// vendorDocNumber: validatedData.vendorDocNumber || null,
-// pic: validatedData.pic || null,
-// issuedDate: validatedData.issuedDate || null,
-// drawingMoveGbn: validatedData.drawingMoveGbn || null,
-// discipline: validatedData.discipline || null,
-// externalDocumentId: validatedData.externalDocumentId || null,
-// externalSystemType: validatedData.externalSystemType || null,
-// cGbn: validatedData.cGbn || null,
-// dGbn: validatedData.dGbn || null,
-// degreeGbn: validatedData.degreeGbn || null,
-// deptGbn: validatedData.deptGbn || null,
-// jGbn: validatedData.jGbn || null,
-// sGbn: validatedData.sGbn || null,
-// shiDrawingNo: validatedData.shiDrawingNo || null,
-// manager: validatedData.manager || null,
-// managerENM: validatedData.managerENM || null,
-// managerNo: validatedData.managerNo || null,
-// status: "ACTIVE",
-// createdAt: new Date(),
-// updatedAt: new Date(),
-// }).returning()
-
-// // 캐시 무효화
-// revalidateTag(`documents-${validatedData.contractId}`)
-// revalidatePath(`/contracts/${validatedData.contractId}/documents`)
-
-// return {
-// success: true,
-// data: newDocument,
-// message: "문서가 성공적으로 생성되었습니다"
-// }
-
-// } catch (error) {
-// console.error("Error creating document:", error)
-// return {
-// success: false,
-// error: error instanceof Error ? error.message : "문서 생성 중 오류가 발생했습니다"
-// }
-// }
-// }
// 문서 수정
-export async function updateDocument(input: UpdateDocumentInput) {
- noStore()
-
+export async function updateDocument(data: UpdateDocumentData) {
try {
- const validatedData = updateDocumentSchema.parse(input)
-
- // 문서 존재 확인
- const existingDoc = await db.query.documents.findFirst({
- where: eq(documents.id, validatedData.id)
- })
-
- if (!existingDoc) {
- throw new Error("문서를 찾을 수 없습니다")
- }
-
- // B4 필드 유효성 검사 (drawingKind 변경 시)
- if (validatedData.drawingKind) {
- validateB4Fields(validatedData)
- }
-
- // 문서번호 중복 검사 (문서번호 변경 시)
- if (validatedData.docNumber && validatedData.docNumber !== existingDoc.docNumber) {
- const duplicateDoc = await db.query.documents.findFirst({
- where: and(
- eq(documents.contractId, existingDoc.contractId),
- eq(documents.docNumber, validatedData.docNumber),
- eq(documents.status, "ACTIVE")
- )
- })
-
- if (duplicateDoc) {
- throw new Error("이미 존재하는 문서번호입니다")
- }
- }
-
- // 문서 업데이트
+ // 1. 문서 기본 정보 업데이트
const [updatedDocument] = await db
.update(documents)
.set({
- ...validatedData,
+ title: data.title,
+ vendorDocNumber: data.vendorDocNumber || null,
updatedAt: new Date(),
})
- .where(eq(documents.id, validatedData.id))
+ .where(eq(documents.id, data.documentId))
.returning()
-
- // 캐시 무효화
- revalidateTag(`documents-${existingDoc.contractId}`)
- revalidatePath(`/contracts/${existingDoc.contractId}/documents`)
+
+ if (!updatedDocument) {
+ return { success: false, error: "문서를 찾을 수 없습니다." }
+ }
+
+ // 2. 스테이지들의 plan date 업데이트
+ const stageUpdatePromises = Object.entries(data.stagePlanDates).map(([stageId, planDate]) => {
+ return db
+ .update(issueStages)
+ .set({
+ planDate: planDate || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(issueStages.id, Number(stageId)))
+ })
+
+ await Promise.all(stageUpdatePromises)
+
+ // 3. 캐시 무효화
+ revalidatePath(`/contracts/${updatedDocument.contractId}/documents`)
return {
success: true,
- data: updatedDocument,
- message: "문서가 성공적으로 수정되었습니다"
+ data: updatedDocument
}
-
} catch (error) {
- console.error("Error updating document:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "문서 수정 중 오류가 발생했습니다"
- }
+ console.error("문서 업데이트 실패:", error)
+ return { success: false, error: "문서 업데이트 중 오류가 발생했습니다." }
}
}
-
// 문서 삭제 (소프트 삭제)
export async function deleteDocument(input: { id: number }) {
noStore()
@@ -240,6 +134,82 @@ export async function deleteDocument(input: { id: number }) {
}
}
+interface DeleteDocumentsData {
+ ids: number[]
+}
+
+export async function deleteDocuments(data: DeleteDocumentsData) {
+ try {
+ if (data.ids.length === 0) {
+ return { success: false, error: "삭제할 문서가 선택되지 않았습니다." }
+ }
+
+ /* 1. 요청한 문서가 존재하는지 확인 ------------------------------------ */
+ const existingDocs = await db
+ .select({ id: documents.id, docNumber: documents.docNumber })
+ .from(documents)
+ .where(and(
+ inArray(documents.id, data.ids),
+ ))
+
+ if (existingDocs.length === 0) {
+ return { success: false, error: "삭제할 문서를 찾을 수 없습니다." }
+ }
+
+ if (existingDocs.length !== data.ids.length) {
+ return {
+ success: false,
+ error: "일부 문서를 찾을 수 없거나 이미 삭제되었습니다."
+ }
+ }
+
+ /* 2. 연관 스테이지 건수 파악(로그·메시지용) --------------------------- */
+ const relatedStages = await db
+ .select({ documentId: issueStages.documentId })
+ .from(issueStages)
+ .where(inArray(issueStages.documentId, data.ids))
+
+ const stagesToDelete = relatedStages.length
+
+ /* 3. 연관 스테이지 삭제 --------------------------------------------- */
+ // ─> FK에 ON DELETE CASCADE 가 있다면 생략 가능.
+ if (stagesToDelete > 0) {
+ await db
+ .delete(issueStages)
+ .where(inArray(issueStages.documentId, data.ids))
+ }
+
+ /* 4. 문서 하드 삭제 --------------------------------------------------- */
+ const deletedDocs = await db
+ .delete(documents)
+ .where(and(
+ inArray(documents.id, data.ids),
+ ))
+ .returning({ id: documents.id, docNumber: documents.docNumber })
+
+ /* 5. 캐시 무효화 ------------------------------------------------------ */
+
+ return {
+ success: true,
+ message: `${deletedDocs.length}개의 문서가 완전히 삭제되었습니다.`,
+ data: {
+ deletedCount: deletedDocs.length,
+ deletedDocuments: deletedDocs,
+ stagesDeletedCount: stagesToDelete
+ }
+ }
+ } catch (error) {
+ console.error("문서 삭제 실패:", error)
+ return {
+ success: false,
+ error: error instanceof Error
+ ? error.message
+ : "문서 삭제 중 오류가 발생했습니다."
+ }
+ }
+}
+
+
// =============================================================================
// 2. 스테이지 관련 액션들
// =============================================================================
@@ -840,64 +810,105 @@ export async function generateDocumentNumber(configs: any[], values: Record<stri
return docNumber.replace(/-$/, "") // 마지막 하이픈 제거
}
-// 문서 생성
-export async function createDocument(data: {
+interface CreateDocumentData {
contractId: number
documentNumberTypeId: number
documentClassId: number
title: string
+ docNumber: string
fieldValues: Record<string, string>
+ planDates: Record<number, string> // optionId -> planDate
pic?: string
vendorDocNumber?: string
-}) {
+}
+
+// 문서 생성
+export async function createDocument(data: CreateDocumentData) {
try {
- // 1. 문서번호 타입 설정 조회
- const configsResult = await getDocumentNumberTypeConfigs(data.documentNumberTypeId)
+ /* ──────────────────────────────── 0. 계약 확인 & projectId 가져오기 ─────────────────────────────── */
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, data.contractId),
+ columns: {
+ id: true,
+ projectId: true,
+ },
+ })
+
+ if (!contract) {
+ return { success: false, error: "유효하지 않은 계약(ID)입니다." }
+ }
+ const { projectId } = contract
+
+ /* ──────────────────────────────── 1. 문서번호 타입 설정 조회 ─────────────────────────────── */
+ const configsResult = await getDocumentNumberTypeConfigs(
+ data.documentNumberTypeId
+ )
if (!configsResult.success) {
return { success: false, error: configsResult.error }
}
- // 2. 문서번호 생성
- const documentNumber = generateDocumentNumber(configsResult.data, data.fieldValues)
- // 3. 문서 생성 (실제 documents 테이블에 INSERT)
- // TODO: 실제 documents 테이블 스키마에 맞게 수정 필요
- /*
- const [document] = await db.insert(documents).values({
+ /* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */
+ const insertData = {
+ // 필수
+ projectId, // ★ 새로 추가
contractId: data.contractId,
- docNumber: documentNumber,
+ docNumber: data.docNumber,
title: data.title,
- documentClassId: data.documentClassId,
- pic: data.pic,
- vendorDocNumber: data.vendorDocNumber,
- }).returning()
- */
+ status: "ACTIVE" as const,
+ createdAt: new Date(),
+ updatedAt: new Date(),
- // 4. 문서 클래스의 옵션들을 스테이지로 자동 생성
- const stageOptionsResult = await getDocumentClassOptions(data.documentClassId)
+ // 선택
+ pic: data.pic ?? null,
+ vendorDocNumber: data.vendorDocNumber ?? null,
+
+ }
+
+ const [document] = await db
+ .insert(documents)
+ .values(insertData)
+ .onConflictDoNothing({
+ // ★ 유니크 키가 projectId 기반이라면 target 도 같이 변경
+ target: [
+ documents.projectId,
+ documents.docNumber,
+ documents.status,
+ ],
+ })
+ .returning()
+
+ if (!document) {
+ return {
+ success: false,
+ error: "같은 프로젝트·문서번호·상태의 문서가 이미 존재합니다.",
+ }
+ }
+
+ /* ──────────────────────────────── 4. 스테이지 자동 생성 ─────────────────────────────── */
+ const stageOptionsResult = await getDocumentClassOptions(
+ data.documentClassId
+ )
if (stageOptionsResult.success && stageOptionsResult.data.length > 0) {
- // TODO: 실제 stages 테이블에 스테이지들 생성
- /*
- const stageInserts = stageOptionsResult.data.map((option, index) => ({
- documentId: document.id,
- stageName: option.optionValue,
- stageOrder: option.sortOrder || index + 1,
- stageStatus: 'PLANNED',
- // 기본값들...
+ const now = new Date()
+ const stageInserts = stageOptionsResult.data.map((opt, idx) => ({
+ documentId: document.id,
+ stageName: opt.optionValue,
+ stageOrder: opt.sortOrder ?? idx + 1,
+ stageStatus: "PLANNED" as const,
+ planDate: data.planDates[opt.id] ?? null,
+ createdAt: now,
+ updatedAt: now,
}))
-
- await db.insert(documentStages).values(stageInserts)
- */
+ await db.insert(issueStages).values(stageInserts)
}
+ /* ──────────────────────────────── 5. 캐시 무효화 및 응답 ─────────────────────────────── */
revalidatePath(`/contracts/${data.contractId}/documents`)
-
- return {
- success: true,
- data: {
- documentNumber,
- // document
- }
+
+ return {
+ success: true,
+ data: { document },
}
} catch (error) {
console.error("문서 생성 실패:", error)
@@ -905,37 +916,6 @@ export async function createDocument(data: {
}
}
-// 스테이지 업데이트
-// export async function updateStage(data: {
-// stageId: number
-// stageName?: string
-// planDate?: string
-// actualDate?: string
-// stageStatus?: string
-// assigneeName?: string
-// priority?: string
-// notes?: string
-// }) {
-// try {
-// // TODO: 실제 stages 테이블 업데이트
-// /*
-// await db
-// .update(documentStages)
-// .set({
-// ...data,
-// updatedAt: new Date(),
-// })
-// .where(eq(documentStages.id, data.stageId))
-// */
-
-// revalidatePath("/contracts/[contractId]/documents", "page")
-
-// return { success: true }
-// } catch (error) {
-// console.error("스테이지 업데이트 실패:", error)
-// return { success: false, error: "스테이지 업데이트 중 오류가 발생했습니다." }
-// }
-// }
export async function getDocumentStagesOnly(
input: GetEnhancedDocumentsSchema,
diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx
index 736a7467..7d41277e 100644
--- a/lib/vendor-document-list/plant/document-stages-table.tsx
+++ b/lib/vendor-document-list/plant/document-stages-table.tsx
@@ -13,11 +13,11 @@ import type { DocumentStagesOnlyView } from "@/db/schema"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import {
- AlertTriangle,
- Clock,
- TrendingUp,
- Target,
+import {
+ AlertTriangle,
+ Clock,
+ TrendingUp,
+ Target,
Users,
Plus,
FileSpreadsheet
@@ -27,10 +27,11 @@ import { ExpandableDataTable } from "@/components/data-table/expandable-data-tab
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { DocumentStagesExpandedContent } from "./document-stages-expanded-content"
-import { AddDocumentDialog } from "./document-stage-dialogs"
+import { AddDocumentDialog, DeleteDocumentsDialog } from "./document-stage-dialogs"
import { EditDocumentDialog } from "./document-stage-dialogs"
import { EditStageDialog } from "./document-stage-dialogs"
import { ExcelImportDialog } from "./document-stage-dialogs"
+import { DocumentsTableToolbarActions } from "./document-stage-toolbar"
interface DocumentStagesTableProps {
promises: Promise<[Awaited<ReturnType<typeof getDocumentStagesOnly>>]>
@@ -45,33 +46,32 @@ export function DocumentStagesTable({
}: DocumentStagesTableProps) {
const [{ data, pageCount, total }] = React.use(promises)
- console.log(data)
// 상태 관리
const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesOnlyView> | null>(null)
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set())
const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all')
-
+
// 다이얼로그 상태들
const [addDocumentOpen, setAddDocumentOpen] = React.useState(false)
const [editDocumentOpen, setEditDocumentOpen] = React.useState(false)
const [editStageOpen, setEditStageOpen] = React.useState(false)
const [excelImportOpen, setExcelImportOpen] = React.useState(false)
-
+
// 선택된 항목들
const [selectedDocument, setSelectedDocument] = React.useState<DocumentStagesOnlyView | null>(null)
const [selectedStageId, setSelectedStageId] = React.useState<number | null>(null)
// 컬럼 정의
const columns = React.useMemo(
- () => getDocumentStagesColumns({
+ () => getDocumentStagesColumns({
setRowAction: (action) => {
setRowAction(action)
if (action) {
setSelectedDocument(action.row.original)
-
+
switch (action.type) {
- case "edit_document":
+ case "update":
setEditDocumentOpen(true)
break
case "edit_stage":
@@ -102,24 +102,24 @@ export function DocumentStagesTable({
const stats = React.useMemo(() => {
const totalDocs = data.length
const overdue = data.filter(doc => doc.isOverdue).length
- const dueSoon = data.filter(doc =>
- doc.daysUntilDue !== null &&
- doc.daysUntilDue >= 0 &&
+ const dueSoon = data.filter(doc =>
+ doc.daysUntilDue !== null &&
+ doc.daysUntilDue >= 0 &&
doc.daysUntilDue <= 3
).length
const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length
const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length
- const avgProgress = totalDocs > 0
+ const avgProgress = totalDocs > 0
? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs)
: 0
-
- return {
- total: totalDocs,
- overdue,
- dueSoon,
- inProgress,
- highPriority,
- avgProgress
+
+ return {
+ total: totalDocs,
+ overdue,
+ dueSoon,
+ inProgress,
+ highPriority,
+ avgProgress
}
}, [data])
@@ -129,9 +129,9 @@ export function DocumentStagesTable({
case 'overdue':
return data.filter(doc => doc.isOverdue)
case 'due_soon':
- return data.filter(doc =>
- doc.daysUntilDue !== null &&
- doc.daysUntilDue >= 0 &&
+ return data.filter(doc =>
+ doc.daysUntilDue !== null &&
+ doc.daysUntilDue >= 0 &&
doc.daysUntilDue <= 3
)
case 'in_progress':
@@ -158,7 +158,7 @@ export function DocumentStagesTable({
const stageIds = selectedRows
.map(row => row.original.currentStageId)
.filter(Boolean)
-
+
if (stageIds.length > 0) {
toast.success(`${stageIds.length}개 스테이지가 완료 처리되었습니다.`)
}
@@ -189,7 +189,7 @@ export function DocumentStagesTable({
},
{
label: "제목",
- value: "title",
+ value: "title",
placeholder: "제목으로 검색...",
},
]
@@ -280,7 +280,7 @@ export function DocumentStagesTable({
</p>
</CardContent>
</Card>
-
+
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">지연 문서</CardTitle>
@@ -291,7 +291,7 @@ export function DocumentStagesTable({
<p className="text-xs text-muted-foreground">즉시 확인 필요</p>
</CardContent>
</Card>
-
+
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">마감 임박</CardTitle>
@@ -302,7 +302,7 @@ export function DocumentStagesTable({
<p className="text-xs text-muted-foreground">3일 이내 마감</p>
</CardContent>
</Card>
-
+
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">평균 진행률</CardTitle>
@@ -317,14 +317,14 @@ export function DocumentStagesTable({
{/* 빠른 필터 */}
<div className="flex gap-2 overflow-x-auto pb-2">
- <Badge
+ <Badge
variant={quickFilter === 'all' ? 'default' : 'outline'}
className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap"
onClick={() => setQuickFilter('all')}
>
전체 ({stats.total})
</Badge>
- <Badge
+ <Badge
variant={quickFilter === 'overdue' ? 'destructive' : 'outline'}
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap"
onClick={() => setQuickFilter('overdue')}
@@ -332,7 +332,7 @@ export function DocumentStagesTable({
<AlertTriangle className="w-3 h-3 mr-1" />
지연 ({stats.overdue})
</Badge>
- <Badge
+ <Badge
variant={quickFilter === 'due_soon' ? 'default' : 'outline'}
className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap"
onClick={() => setQuickFilter('due_soon')}
@@ -340,7 +340,7 @@ export function DocumentStagesTable({
<Clock className="w-3 h-3 mr-1" />
마감임박 ({stats.dueSoon})
</Badge>
- <Badge
+ <Badge
variant={quickFilter === 'in_progress' ? 'default' : 'outline'}
className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap"
onClick={() => setQuickFilter('in_progress')}
@@ -348,7 +348,7 @@ export function DocumentStagesTable({
<Users className="w-3 h-3 mr-1" />
진행중 ({stats.inProgress})
</Badge>
- <Badge
+ <Badge
variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'}
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap"
onClick={() => setQuickFilter('high_priority')}
@@ -361,13 +361,13 @@ export function DocumentStagesTable({
{/* 메인 테이블 */}
<div className="space-y-4">
<div className="rounded-md border bg-white overflow-hidden">
- <ExpandableDataTable
+ <ExpandableDataTable
table={table}
expandable={true}
expandedRows={expandedRows}
setExpandedRows={setExpandedRows}
renderExpandedContent={(document) => (
- <DocumentStagesExpandedContent
+ <DocumentStagesExpandedContent
document={document}
onEditStage={(stageId) => {
setSelectedDocument(document)
@@ -379,8 +379,8 @@ export function DocumentStagesTable({
)}
expandedRowClassName="!p-0"
excludeFromClick={[
- 'actions',
- 'select'
+ 'actions',
+ 'select'
]}
>
<DataTableAdvancedToolbar
@@ -388,16 +388,11 @@ export function DocumentStagesTable({
filterFields={advancedFilterFields}
shallow={false}
>
- <div className="flex items-center gap-2">
- <Button onClick={handleNewDocument} size="sm">
- <Plus className="mr-2 h-4 w-4" />
- 문서 추가
- </Button>
- <Button onClick={handleExcelImport} variant="outline" size="sm">
- <FileSpreadsheet className="mr-2 h-4 w-4" />
- 엑셀 가져오기
- </Button>
- </div>
+ <DocumentsTableToolbarActions
+ table={table}
+ contractId={contractId}
+ projectType={projectType}
+ />
</DataTableAdvancedToolbar>
</ExpandableDataTable>
</div>
@@ -444,6 +439,16 @@ export function DocumentStagesTable({
contractId={contractId}
projectType={projectType}
/>
+
+ <DeleteDocumentsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ showTrigger={false}
+ documents={rowAction?.row.original ? [rowAction?.row.original] : []} // 전체 문서 배열
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+
</div>
)
} \ No newline at end of file
diff --git a/lib/vendor-document-list/service.ts b/lib/vendor-document-list/service.ts
index de6f0488..76bdac49 100644
--- a/lib/vendor-document-list/service.ts
+++ b/lib/vendor-document-list/service.ts
@@ -308,4 +308,19 @@ export async function getContractIdsByVendor(vendorId: number): Promise<number[]
console.error('Error fetching contract IDs by vendor:', error)
return []
}
+}
+
+export async function getProjectIdsByVendor(vendorId: number): Promise<number[]> {
+ try {
+ const contractsData = await db
+ .selectDistinct({ projectId: contracts.projectId })
+ .from(contracts)
+ .where(eq(contracts.vendorId, vendorId))
+ .orderBy(contracts.projectId) // projectId로 정렬하는 것이 더 의미있을 수 있음
+
+ return contractsData.map(contract => contract.projectId)
+ } catch (error) {
+ console.error('Error fetching contract IDs by vendor:', error)
+ return []
+ }
} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
index 255b1f9d..4ec57369 100644
--- a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
+++ b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
@@ -1,4 +1,6 @@
+// enhanced-doc-table-toolbar-actions.tsx - 최적화된 버전
"use client"
+
import * as React from "react"
import { type Table } from "@tanstack/react-table"
import { Download, Upload, Plus, Files, RefreshCw } from "lucide-react"
@@ -6,14 +8,13 @@ import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
-import { SimplifiedDocumentsView } from "@/db/schema/vendorDocu"
+import { SimplifiedDocumentsView } from "@/db/schema/vendorDocu"
import { SendToSHIButton } from "./send-to-shi-button"
import { ImportFromDOLCEButton } from "./import-from-dolce-button"
interface EnhancedDocTableToolbarActionsProps {
table: Table<SimplifiedDocumentsView>
projectType: "ship" | "plant"
- contractId?: number
}
export function EnhancedDocTableToolbarActions({
@@ -21,70 +22,77 @@ export function EnhancedDocTableToolbarActions({
projectType,
}: EnhancedDocTableToolbarActionsProps) {
const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false)
-
- // 현재 테이블의 모든 데이터 (필터링된 상태)
- const allDocuments = table.getFilteredRowModel().rows.map(row => row.original)
- const handleSyncComplete = () => {
- // 동기화 완료 후 테이블 새로고침
+ // 🔥 메모이제이션으로 불필요한 재계산 방지
+ const allDocuments = React.useMemo(() => {
+ return table.getFilteredRowModel().rows.map(row => row.original)
+ }, [
+ table.getFilteredRowModel().rows.length, // 행 개수가 변경될 때만 재계산
+ table.getState().columnFilters, // 필터가 변경될 때만 재계산
+ table.getState().globalFilter, // 전역 필터가 변경될 때만 재계산
+ ])
+
+ // 🔥 projectIds 메모이제이션 (ImportFromDOLCEButton에서 중복 계산 방지)
+ const projectIds = React.useMemo(() => {
+ const uniqueIds = [...new Set(allDocuments.map(doc => doc.projectId).filter(Boolean))]
+ return uniqueIds.sort()
+ }, [allDocuments])
+
+ // 🔥 핸들러들을 useCallback으로 메모이제이션
+ const handleSyncComplete = React.useCallback(() => {
table.resetRowSelection()
- // 필요시 추가 액션 수행
- }
+ }, [table])
- const handleDocumentAdded = () => {
- // 테이블 새로고침
+ const handleDocumentAdded = React.useCallback(() => {
table.resetRowSelection()
-
- // 추가적인 새로고침 시도
+ // 🔥 강제 새로고침 대신 더 효율적인 방법 사용
setTimeout(() => {
- window.location.reload() // 강제 새로고침
+ // 상태 업데이트만으로 충분한 경우가 많음
+ window.location.reload()
}, 500)
- }
+ }, [table])
- const handleImportComplete = () => {
- // 가져오기 완료 후 테이블 새로고침
+ const handleImportComplete = React.useCallback(() => {
table.resetRowSelection()
setTimeout(() => {
window.location.reload()
}, 500)
- }
+ }, [table])
+
+ // 🔥 Export 핸들러 메모이제이션
+ const handleExport = React.useCallback(() => {
+ exportTableToExcel(table, {
+ filename: "Document-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }, [table])
return (
<div className="flex items-center gap-2">
-
- <>
- {/* SHIP: DOLCE에서 목록 가져오기 */}
- <ImportFromDOLCEButton
- allDocuments={allDocuments}
- onImportComplete={handleImportComplete}
- />
- </>
-
+ {/* SHIP: DOLCE에서 목록 가져오기 */}
+ <ImportFromDOLCEButton
+ allDocuments={allDocuments}
+ projectIds={projectIds} // 🔥 미리 계산된 projectIds 전달
+ onImportComplete={handleImportComplete}
+ />
{/* Export 버튼 (공통) */}
<Button
variant="outline"
size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "Document-list",
- excludeColumns: ["select", "actions"],
- })
- }
+ onClick={handleExport}
className="gap-2"
>
<Download className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">Export</span>
</Button>
- {/* Send to SHI 버튼 (공통) - 내부 → 외부로 보내기 */}
+ {/* Send to SHI 버튼 (공통) */}
<SendToSHIButton
documents={allDocuments}
onSyncComplete={handleSyncComplete}
projectType={projectType}
/>
-
-
</div>
)
} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/enhanced-documents-table.tsx b/lib/vendor-document-list/ship/enhanced-documents-table.tsx
index 9885c027..8051da7e 100644
--- a/lib/vendor-document-list/ship/enhanced-documents-table.tsx
+++ b/lib/vendor-document-list/ship/enhanced-documents-table.tsx
@@ -1,4 +1,4 @@
-// simplified-documents-table.tsx
+// simplified-documents-table.tsx - 최적화된 버전
"use client"
import React from "react"
@@ -52,41 +52,45 @@ export function SimplifiedDocumentsTable({
allPromises,
onDataLoaded,
}: SimplifiedDocumentsTableProps) {
- // React.use()로 Promise 결과를 받고, 그 다음에 destructuring
- const [documentResult, statsResult] = React.use(allPromises)
- const { data, pageCount, total, drawingKind, vendorInfo } = documentResult
- const { stats, totalDocuments, primaryDrawingKind } = statsResult
+ // 🔥 React.use() 결과를 안전하게 처리
+ const promiseResults = React.use(allPromises)
+ const [documentResult, statsResult] = promiseResults
+
+ // 🔥 데이터 구조분해를 메모이제이션
+ const { data, pageCount, total, drawingKind, vendorInfo } = React.useMemo(() => documentResult, [documentResult])
+ const { stats, totalDocuments, primaryDrawingKind } = React.useMemo(() => statsResult, [statsResult])
- // 데이터가 로드되면 콜백 호출
+ // 🔥 데이터 로드 콜백을 useCallback으로 최적화
+ const handleDataLoaded = React.useCallback((loadedData: SimplifiedDocumentsView[]) => {
+ onDataLoaded?.(loadedData)
+ }, [onDataLoaded])
+
+ // 🔥 데이터가 로드되면 콜백 호출 (의존성 최적화)
React.useEffect(() => {
- if (onDataLoaded && data) {
- onDataLoaded(data)
+ if (data && handleDataLoaded) {
+ handleDataLoaded(data)
}
- }, [data, onDataLoaded])
+ }, [data, handleDataLoaded])
- // 기존 상태들
+ // 🔥 상태들을 안정적으로 관리
const [rowAction, setRowAction] = React.useState<DataTableRowAction<SimplifiedDocumentsView> | null>(null)
- const [expandedRows,] = React.useState<Set<string>>(new Set())
+ const [expandedRows] = React.useState<Set<string>>(() => new Set())
+ // 🔥 컬럼 메모이제이션 최적화
const columns = React.useMemo(
() => getSimplifiedDocumentColumns({
setRowAction,
}),
- [setRowAction]
+ [] // setRowAction은 항상 동일한 함수이므로 의존성에서 제외
)
- // ✅ SimplifiedDocumentsView에 맞게 필터 필드 업데이트
- const advancedFilterFields: DataTableAdvancedFilterField<SimplifiedDocumentsView>[] = [
+ // 🔥 필터 필드들을 메모이제이션
+ const advancedFilterFields: DataTableAdvancedFilterField<SimplifiedDocumentsView>[] = React.useMemo(() => [
{
id: "docNumber",
label: "Document No",
type: "text",
},
- // {
- // id: "vendorDocNumber",
- // label: "Vendor Document No",
- // type: "text",
- // },
{
id: "title",
label: "Document Title",
@@ -178,10 +182,10 @@ export function SimplifiedDocumentsTable({
label: "Updated Date",
type: "date",
},
- ]
+ ], [])
- // ✅ B4 전용 필드들 (조건부로 추가)
- const b4FilterFields: DataTableAdvancedFilterField<SimplifiedDocumentsView>[] = [
+ // 🔥 B4 전용 필드들 메모이제이션
+ const b4FilterFields: DataTableAdvancedFilterField<SimplifiedDocumentsView>[] = React.useMemo(() => [
{
id: "cGbn",
label: "C Category",
@@ -212,33 +216,49 @@ export function SimplifiedDocumentsTable({
label: "S Category",
type: "text",
},
- ]
+ ], [])
+
+ // 🔥 B4 문서 존재 여부 체크 메모이제이션
+ const hasB4Documents = React.useMemo(() => {
+ return data.some(doc => doc.drawingKind === 'B4')
+ }, [data])
+
+ // 🔥 최종 필터 필드 메모이제이션
+ const finalFilterFields = React.useMemo(() => {
+ return hasB4Documents ? [...advancedFilterFields, ...b4FilterFields] : advancedFilterFields
+ }, [hasB4Documents, advancedFilterFields, b4FilterFields])
+
+ // 🔥 테이블 초기 상태 메모이제이션
+ const tableInitialState = React.useMemo(() => ({
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ }), [])
- // B4 문서가 있는지 확인하여 B4 전용 필드 추가
- const hasB4Documents = data.some(doc => doc.drawingKind === 'B4')
- const finalFilterFields = hasB4Documents
- ? [...advancedFilterFields, ...b4FilterFields]
- : advancedFilterFields
+ // 🔥 getRowId 함수 메모이제이션
+ const getRowId = React.useCallback((originalRow: SimplifiedDocumentsView) => String(originalRow.documentId), [])
const { table } = useDataTable({
- data: data,
+ data,
columns,
pageCount,
enablePinning: true,
enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.documentId),
+ initialState: tableInitialState,
+ getRowId,
shallow: false,
clearOnDefault: true,
columnResizeMode: "onEnd",
})
- // 실제 데이터의 drawingKind 또는 주요 drawingKind 사용
- const activeDrawingKind = drawingKind || primaryDrawingKind
- const kindInfo = activeDrawingKind ? DRAWING_KIND_INFO[activeDrawingKind] : null
+ // 🔥 활성 drawingKind 메모이제이션
+ const activeDrawingKind = React.useMemo(() => {
+ return drawingKind || primaryDrawingKind
+ }, [drawingKind, primaryDrawingKind])
+
+ // 🔥 kindInfo 메모이제이션
+ const kindInfo = React.useMemo(() => {
+ return activeDrawingKind ? DRAWING_KIND_INFO[activeDrawingKind] : null
+ }, [activeDrawingKind])
return (
<div className="w-full space-y-4">
@@ -246,13 +266,7 @@ export function SimplifiedDocumentsTable({
{kindInfo && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
- {/* <Badge variant="default" className="flex items-center gap-1 text-sm">
- <FileText className="w-4 h-4" />
- {kindInfo.title}
- </Badge>
- <span className="text-sm text-muted-foreground">
- {kindInfo.description}
- </span> */}
+ {/* 주석 처리된 부분은 그대로 유지 */}
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
@@ -270,11 +284,10 @@ export function SimplifiedDocumentsTable({
filterFields={finalFilterFields}
shallow={false}
>
- <EnhancedDocTableToolbarActions
- table={table}
- projectType="ship"
- />
-
+ <EnhancedDocTableToolbarActions
+ table={table}
+ projectType="ship"
+ />
</DataTableAdvancedToolbar>
</DataTable>
</div>
diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
index de9e63bc..90796d8e 100644
--- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx
+++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
@@ -1,3 +1,4 @@
+// import-from-dolce-button.tsx - 최적화된 버전
"use client"
import * as React from "react"
@@ -23,15 +24,38 @@ import { Separator } from "@/components/ui/separator"
import { SimplifiedDocumentsView } from "@/db/schema"
import { ImportStatus } from "../import-service"
import { useSession } from "next-auth/react"
-import { getContractIdsByVendor } from "../service" // 서버 액션 import
+import { getProjectIdsByVendor } from "../service"
+
+// 🔥 API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유)
+const statusCache = new Map<string, { data: ImportStatus; timestamp: number }>()
+const CACHE_TTL = 2 * 60 * 1000 // 2분 캐시
interface ImportFromDOLCEButtonProps {
- allDocuments: SimplifiedDocumentsView[] // contractId 대신 문서 배열
+ allDocuments: SimplifiedDocumentsView[]
+ projectIds?: number[] // 🔥 미리 계산된 projectIds를 props로 받음
onImportComplete?: () => void
}
+// 🔥 디바운스 훅
+function useDebounce<T>(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = React.useState(value)
+
+ React.useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value)
+ }, delay)
+
+ return () => {
+ clearTimeout(handler)
+ }
+ }, [value, delay])
+
+ return debouncedValue
+}
+
export function ImportFromDOLCEButton({
allDocuments,
+ projectIds: propProjectIds,
onImportComplete
}: ImportFromDOLCEButtonProps) {
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
@@ -39,104 +63,120 @@ export function ImportFromDOLCEButton({
const [isImporting, setIsImporting] = React.useState(false)
const [importStatusMap, setImportStatusMap] = React.useState<Map<number, ImportStatus>>(new Map())
const [statusLoading, setStatusLoading] = React.useState(false)
- const [vendorContractIds, setVendorContractIds] = React.useState<number[]>([]) // 서버에서 가져온 contractIds
- const [loadingVendorContracts, setLoadingVendorContracts] = React.useState(false)
+ const [vendorProjectIds, setVendorProjectIds] = React.useState<number[]>([])
+ const [loadingVendorProjects, setLoadingVendorProjects] = React.useState(false)
+
const { data: session } = useSession()
+ const vendorId = session?.user.companyId
- const vendorId = session?.user.companyId;
-
- // allDocuments에서 추출한 contractIds
- const documentsContractIds = React.useMemo(() => {
- const uniqueIds = [...new Set(allDocuments.map(doc => doc.contractId).filter(Boolean))]
+ // 🔥 allDocuments에서 projectIds 추출 (props로 전달받은 경우 사용)
+ const documentsProjectIds = React.useMemo(() => {
+ if (propProjectIds) return propProjectIds // props로 받은 경우 그대로 사용
+
+ const uniqueIds = [...new Set(allDocuments.map(doc => doc.projectId).filter(Boolean))]
return uniqueIds.sort()
- }, [allDocuments])
+ }, [allDocuments, propProjectIds])
- // 최종 사용할 contractIds (allDocuments가 있으면 문서에서, 없으면 vendor의 모든 contracts)
- const contractIds = React.useMemo(() => {
- if (documentsContractIds.length > 0) {
- return documentsContractIds
+ // 🔥 최종 projectIds (변경 빈도 최소화)
+ const projectIds = React.useMemo(() => {
+ if (documentsProjectIds.length > 0) {
+ return documentsProjectIds
}
- return vendorContractIds
- }, [documentsContractIds, vendorContractIds])
+ return vendorProjectIds
+ }, [documentsProjectIds, vendorProjectIds])
- console.log(contractIds, "contractIds")
+ // 🔥 projectIds 디바운싱 (API 호출 과다 방지)
+ const debouncedProjectIds = useDebounce(projectIds, 300)
- // vendorId로 contracts 가져오기
- React.useEffect(() => {
- const fetchVendorContracts = async () => {
- // allDocuments가 비어있고 vendorId가 있을 때만 실행
- if (allDocuments.length === 0 && vendorId) {
- setLoadingVendorContracts(true)
- try {
- const contractIds = await getContractIdsByVendor(vendorId)
- setVendorContractIds(contractIds)
- } catch (error) {
- console.error('Failed to fetch vendor contracts:', error)
- toast.error('Failed to fetch contract information.')
- } finally {
- setLoadingVendorContracts(false)
- }
- }
- }
-
- fetchVendorContracts()
- }, [allDocuments.length, vendorId])
-
- // 주요 contractId (가장 많이 나타나는 것)
- const primaryContractId = React.useMemo(() => {
- if (contractIds.length === 1) return contractIds[0]
+ // 🔥 주요 projectId 메모이제이션
+ const primaryProjectId = React.useMemo(() => {
+ if (projectIds.length === 1) return projectIds[0]
if (allDocuments.length > 0) {
const counts = allDocuments.reduce((acc, doc) => {
- const id = doc.contractId || 0
+ const id = doc.projectId || 0
acc[id] = (acc[id] || 0) + 1
return acc
}, {} as Record<number, number>)
return Number(Object.entries(counts)
- .sort(([,a], [,b]) => b - a)[0]?.[0] || contractIds[0] || 0)
+ .sort(([,a], [,b]) => b - a)[0]?.[0] || projectIds[0] || 0)
}
- return contractIds[0] || 0
- }, [contractIds, allDocuments])
+ return projectIds[0] || 0
+ }, [projectIds, allDocuments])
+
+ // 🔥 캐시된 API 호출 함수
+ const fetchImportStatusCached = React.useCallback(async (projectId: number): Promise<ImportStatus | null> => {
+ const cacheKey = `import-status-${projectId}`
+ const cached = statusCache.get(cacheKey)
+
+ // 캐시된 데이터가 있고 유효하면 사용
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
+ return cached.data
+ }
+
+ try {
+ const response = await fetch(`/api/sync/import/status?projectId=${projectId}&sourceSystem=DOLCE`)
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.message || 'Failed to fetch import status')
+ }
+
+ const status = await response.json()
+ if (status.error) {
+ console.warn(`Status error for project ${projectId}:`, status.error)
+ return null
+ }
+
+ // 캐시에 저장
+ statusCache.set(cacheKey, {
+ data: status,
+ timestamp: Date.now()
+ })
+
+ return status
+ } catch (error) {
+ console.error(`Failed to fetch status for project ${projectId}:`, error)
+ return null
+ }
+ }, [])
- // 모든 contractId에 대한 상태 조회
- const fetchAllImportStatus = async () => {
- if (contractIds.length === 0) return
+ // 🔥 모든 projectId에 대한 상태 조회 (최적화된 버전)
+ const fetchAllImportStatus = React.useCallback(async () => {
+ if (debouncedProjectIds.length === 0) return
setStatusLoading(true)
const statusMap = new Map<number, ImportStatus>()
try {
- // 각 contractId별로 상태 조회
- const statusPromises = contractIds.map(async (contractId) => {
- try {
- const response = await fetch(`/api/sync/import/status?contractId=${contractId}&sourceSystem=DOLCE`)
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}))
- throw new Error(errorData.message || 'Failed to fetch import status')
- }
-
- const status = await response.json()
- if (status.error) {
- console.warn(`Status error for contract ${contractId}:`, status.error)
- return { contractId, status: null }
+ // 🔥 병렬 처리하되 동시 연결 수 제한 (3개씩)
+ const batchSize = 3
+ const batches = []
+
+ for (let i = 0; i < debouncedProjectIds.length; i += batchSize) {
+ batches.push(debouncedProjectIds.slice(i, i + batchSize))
+ }
+
+ for (const batch of batches) {
+ const batchPromises = batch.map(async (projectId) => {
+ const status = await fetchImportStatusCached(projectId)
+ return { projectId, status }
+ })
+
+ const batchResults = await Promise.all(batchPromises)
+
+ batchResults.forEach(({ projectId, status }) => {
+ if (status) {
+ statusMap.set(projectId, status)
}
-
- return { contractId, status }
- } catch (error) {
- console.error(`Failed to fetch status for contract ${contractId}:`, error)
- return { contractId, status: null }
- }
- })
+ })
- const results = await Promise.all(statusPromises)
-
- results.forEach(({ contractId, status }) => {
- if (status) {
- statusMap.set(contractId, status)
+ // 배치 간 짧은 지연
+ if (batches.length > 1) {
+ await new Promise(resolve => setTimeout(resolve, 100))
}
- })
+ }
setImportStatusMap(statusMap)
@@ -146,19 +186,48 @@ export function ImportFromDOLCEButton({
} finally {
setStatusLoading(false)
}
- }
+ }, [debouncedProjectIds, fetchImportStatusCached])
- // 컴포넌트 마운트 시 상태 조회
+ // 🔥 vendorId로 projects 가져오기 (최적화)
React.useEffect(() => {
- if (contractIds.length > 0) {
- fetchAllImportStatus()
+ let isCancelled = false
+
+ const fetchVendorProjects = async () => {
+ if (allDocuments.length === 0 && vendorId && !loadingVendorProjects) {
+ setLoadingVendorProjects(true)
+ try {
+ const projectIds = await getProjectIdsByVendor(vendorId)
+ if (!isCancelled) {
+ setVendorProjectIds(projectIds)
+ }
+ } catch (error) {
+ console.error('Failed to fetch vendor projects:', error)
+ if (!isCancelled) {
+ toast.error('Failed to fetch project information.')
+ }
+ } finally {
+ if (!isCancelled) {
+ setLoadingVendorProjects(false)
+ }
+ }
+ }
}
- }, [contractIds])
- // 주요 contractId의 상태
- const primaryImportStatus = importStatusMap.get(primaryContractId)
+ fetchVendorProjects()
+
+ return () => {
+ isCancelled = true
+ }
+ }, [allDocuments.length, vendorId, loadingVendorProjects])
- // 전체 통계 계산
+ // 🔥 컴포넌트 마운트 시 상태 조회 (디바운싱 적용)
+ React.useEffect(() => {
+ if (debouncedProjectIds.length > 0) {
+ fetchAllImportStatus()
+ }
+ }, [debouncedProjectIds, fetchAllImportStatus])
+
+ // 🔥 전체 통계 메모이제이션
const totalStats = React.useMemo(() => {
const statuses = Array.from(importStatusMap.values())
return statuses.reduce((acc, status) => ({
@@ -174,38 +243,53 @@ export function ImportFromDOLCEButton({
})
}, [importStatusMap])
- const handleImport = async () => {
- if (contractIds.length === 0) return
+ // 🔥 주요 상태 메모이제이션
+ const primaryImportStatus = React.useMemo(() => {
+ return importStatusMap.get(primaryProjectId)
+ }, [importStatusMap, primaryProjectId])
+
+ // 🔥 가져오기 실행 함수 최적화
+ const handleImport = React.useCallback(async () => {
+ if (projectIds.length === 0) return
setImportProgress(0)
setIsImporting(true)
try {
- // 진행률 시뮬레이션
const progressInterval = setInterval(() => {
setImportProgress(prev => Math.min(prev + 10, 85))
}, 500)
- // 여러 contractId에 대해 순차적으로 가져오기 실행
- const importPromises = contractIds.map(async (contractId) => {
- const response = await fetch('/api/sync/import', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contractId,
- sourceSystem: 'DOLCE'
+ // 🔥 순차 처리로 서버 부하 방지
+ const results = []
+ for (const projectId of projectIds) {
+ try {
+ const response = await fetch('/api/sync/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ projectId,
+ sourceSystem: 'DOLCE'
+ })
})
- })
- if (!response.ok) {
- const errorData = await response.json()
- throw new Error(`Contract ${contractId}: ${errorData.message || 'Import failed'}`)
- }
-
- return response.json()
- })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(`Project ${projectId}: ${errorData.message || 'Import failed'}`)
+ }
- const results = await Promise.all(importPromises)
+ const result = await response.json()
+ results.push(result)
+
+ // 프로젝트 간 짧은 지연
+ if (projectIds.length > 1) {
+ await new Promise(resolve => setTimeout(resolve, 200))
+ }
+ } catch (error) {
+ console.error(`Import failed for project ${projectId}:`, error)
+ results.push({ success: false, error: error instanceof Error ? error.message : 'Unknown error' })
+ }
+ }
clearInterval(progressInterval)
setImportProgress(100)
@@ -232,19 +316,21 @@ export function ImportFromDOLCEButton({
toast.success(
`DOLCE import completed`,
{
- description: `New ${totalResult.newCount}, Updated ${totalResult.updatedCount}, Skipped ${totalResult.skippedCount} (${contractIds.length} contracts)`
+ description: `New ${totalResult.newCount}, Updated ${totalResult.updatedCount}, Skipped ${totalResult.skippedCount} (${projectIds.length} projects)`
}
)
} else {
toast.error(
`DOLCE import partially failed`,
{
- description: 'Some contracts failed to import.'
+ description: 'Some projects failed to import.'
}
)
}
- fetchAllImportStatus() // 상태 갱신
+ // 🔥 캐시 무효화
+ statusCache.clear()
+ fetchAllImportStatus()
onImportComplete?.()
}, 500)
@@ -256,11 +342,12 @@ export function ImportFromDOLCEButton({
description: error instanceof Error ? error.message : 'An unknown error occurred.'
})
}
- }
+ }, [projectIds, fetchAllImportStatus, onImportComplete])
- const getStatusBadge = () => {
- if (loadingVendorContracts) {
- return <Badge variant="secondary">Loading contract information...</Badge>
+ // 🔥 상태 뱃지 메모이제이션
+ const statusBadge = React.useMemo(() => {
+ if (loadingVendorProjects) {
+ return <Badge variant="secondary">Loading project information...</Badge>
}
if (statusLoading) {
@@ -279,7 +366,7 @@ export function ImportFromDOLCEButton({
return (
<Badge variant="samsung" className="gap-1">
<AlertTriangle className="w-3 h-3" />
- Updates Available ({contractIds.length} contracts)
+ Updates Available ({projectIds.length} projects)
</Badge>
)
}
@@ -290,13 +377,19 @@ export function ImportFromDOLCEButton({
Synchronized with DOLCE
</Badge>
)
- }
+ }, [loadingVendorProjects, statusLoading, importStatusMap.size, totalStats, projectIds.length])
const canImport = totalStats.importEnabled &&
(totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0)
- // 로딩 중이거나 contractIds가 없으면 버튼을 표시하지 않음
- if (loadingVendorContracts || contractIds.length === 0) {
+ // 🔥 새로고침 핸들러 최적화
+ const handleRefresh = React.useCallback(() => {
+ statusCache.clear() // 캐시 무효화
+ fetchAllImportStatus()
+ }, [fetchAllImportStatus])
+
+ // 로딩 중이거나 projectIds가 없으면 버튼을 표시하지 않음
+ if (loadingVendorProjects || projectIds.length === 0) {
return null
}
@@ -316,7 +409,7 @@ export function ImportFromDOLCEButton({
) : (
<Download className="w-4 h-4" />
)}
- <span className="hidden sm:inline">Import from DOLCE</span>
+ <span className="hidden sm:inline">Get List</span>
{totalStats.newDocuments + totalStats.updatedDocuments > 0 && (
<Badge
variant="samsung"
@@ -335,24 +428,24 @@ export function ImportFromDOLCEButton({
<h4 className="font-medium">DOLCE Import Status</h4>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Current Status</span>
- {getStatusBadge()}
+ {statusBadge}
</div>
</div>
- {/* 계약 소스 표시 */}
- {allDocuments.length === 0 && vendorContractIds.length > 0 && (
+ {/* 프로젝트 소스 표시 */}
+ {allDocuments.length === 0 && vendorProjectIds.length > 0 && (
<div className="text-xs text-blue-600 bg-blue-50 p-2 rounded">
- No documents found, importing from all contracts.
+ No documents found, importing from all projects.
</div>
)}
- {/* 다중 계약 정보 표시 */}
- {contractIds.length > 1 && (
+ {/* 다중 프로젝트 정보 표시 */}
+ {projectIds.length > 1 && (
<div className="text-sm">
- <div className="text-muted-foreground">Target Contracts</div>
- <div className="font-medium">{contractIds.length} contracts</div>
+ <div className="text-muted-foreground">Target Projects</div>
+ <div className="font-medium">{projectIds.length} projects</div>
<div className="text-xs text-muted-foreground">
- Contract IDs: {contractIds.join(', ')}
+ Project IDs: {projectIds.join(', ')}
</div>
</div>
)}
@@ -373,22 +466,22 @@ export function ImportFromDOLCEButton({
</div>
<div className="text-sm">
- <div className="text-muted-foreground">Total DOLCE Documents (B3/B4/B5)</div>
+ <div className="text-muted-foreground">Total Documents (B3/B4/B5)</div>
<div className="font-medium">{totalStats.availableDocuments || 0}</div>
</div>
- {/* 각 계약별 세부 정보 (펼치기/접기 가능) */}
- {contractIds.length > 1 && (
+ {/* 각 프로젝트별 세부 정보 */}
+ {projectIds.length > 1 && (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
- Details by Contract
+ Details by Project
</summary>
<div className="mt-2 space-y-2 pl-2 border-l-2 border-muted">
- {contractIds.map(contractId => {
- const status = importStatusMap.get(contractId)
+ {projectIds.map(projectId => {
+ const status = importStatusMap.get(projectId)
return (
- <div key={contractId} className="text-xs">
- <div className="font-medium">Contract {contractId}</div>
+ <div key={projectId} className="text-xs">
+ <div className="font-medium">Project {projectId}</div>
{status ? (
<div className="text-muted-foreground">
New {status.newDocuments}, Updates {status.updatedDocuments}
@@ -430,7 +523,7 @@ export function ImportFromDOLCEButton({
<Button
variant="outline"
size="sm"
- onClick={fetchAllImportStatus}
+ onClick={handleRefresh}
disabled={statusLoading}
>
{statusLoading ? (
@@ -451,7 +544,7 @@ export function ImportFromDOLCEButton({
<DialogTitle>Import Document List from DOLCE</DialogTitle>
<DialogDescription>
Import the latest document list from Samsung Heavy Industries DOLCE system.
- {contractIds.length > 1 && ` (${contractIds.length} contracts targeted)`}
+ {projectIds.length > 1 && ` (${projectIds.length} projects targeted)`}
</DialogDescription>
</DialogHeader>
@@ -469,10 +562,10 @@ export function ImportFromDOLCEButton({
Includes new and updated documents (B3, B4, B5).
<br />
For B4 documents, GTTPreDwg and GTTWorkingDwg issue stages will be auto-generated.
- {contractIds.length > 1 && (
+ {projectIds.length > 1 && (
<>
<br />
- Will import sequentially from {contractIds.length} contracts.
+ Will import sequentially from {projectIds.length} projects.
</>
)}
</div>
diff --git a/lib/vendor-document-list/ship/send-to-shi-button.tsx b/lib/vendor-document-list/ship/send-to-shi-button.tsx
index 4607c994..c67c7b2c 100644
--- a/lib/vendor-document-list/ship/send-to-shi-button.tsx
+++ b/lib/vendor-document-list/ship/send-to-shi-button.tsx
@@ -47,7 +47,7 @@ export function SendToSHIButton({
// 문서에서 유효한 계약 ID 목록 추출
const documentsContractIds = React.useMemo(() => {
const validIds = documents
- .map(doc => doc.contractId)
+ .map(doc => doc.projectId)
.filter((id): id is number => typeof id === 'number' && id > 0)
const uniqueIds = [...new Set(validIds)]
@@ -68,8 +68,8 @@ export function SendToSHIButton({
console.log('SendToSHIButton Debug Info:', {
documentsContractIds,
totalStats,
- contractStatuses: contractStatuses.map(({ contractId, syncStatus, error }) => ({
- contractId,
+ contractStatuses: contractStatuses.map(({ projectId, syncStatus, error }) => ({
+ projectId,
pendingChanges: syncStatus?.pendingChanges,
hasError: !!error
}))
@@ -95,7 +95,7 @@ export function SendToSHIButton({
// 동기화 가능한 계약들만 필터링
const contractsToSync = contractStatuses.filter(({ syncStatus, error }) => {
if (error) {
- console.warn(`Contract ${contractStatuses.find(c => c.error === error)?.contractId} has error:`, error)
+ console.warn(`Contract ${contractStatuses.find(c => c.error === error)?.projectId} has error:`, error)
return false
}
if (!syncStatus) return false
@@ -114,32 +114,32 @@ export function SendToSHIButton({
// 각 contract별로 순차 동기화
for (let i = 0; i < contractsToSync.length; i++) {
- const { contractId } = contractsToSync[i]
- setCurrentSyncingContract(contractId)
+ const { projectId } = contractsToSync[i]
+ setCurrentSyncingContract(projectId)
try {
- console.log(`Syncing contract ${contractId}...`)
+ console.log(`Syncing contract ${projectId}...`)
const result = await triggerSync({
- contractId,
+ projectId,
targetSystem
})
if (result?.success) {
successfulSyncs++
totalSuccessCount += result.successCount || 0
- console.log(`Contract ${contractId} sync successful:`, result)
+ console.log(`Contract ${projectId} sync successful:`, result)
} else {
failedSyncs++
totalFailureCount += result?.failureCount || 0
const errorMsg = result?.errors?.[0] || result?.message || 'Unknown sync error'
- errors.push(`Contract ${contractId}: ${errorMsg}`)
- console.error(`Contract ${contractId} sync failed:`, result)
+ errors.push(`Contract ${projectId}: ${errorMsg}`)
+ console.error(`Contract ${projectId} sync failed:`, result)
}
} catch (error) {
failedSyncs++
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'
- errors.push(`Contract ${contractId}: ${errorMessage}`)
- console.error(`Contract ${contractId} sync exception:`, error)
+ errors.push(`Contract ${projectId}: ${errorMessage}`)
+ console.error(`Contract ${projectId} sync exception:`, error)
}
// 진행률 업데이트
@@ -338,9 +338,9 @@ export function SendToSHIButton({
<div className="text-sm font-medium">계약별 상태</div>
<ScrollArea className="h-32">
<div className="space-y-2">
- {contractStatuses.map(({ contractId, syncStatus, isLoading, error }) => (
- <div key={contractId} className="flex items-center justify-between text-xs p-2 rounded border">
- <span className="font-medium">Contract {contractId}</span>
+ {contractStatuses.map(({ projectId, syncStatus, isLoading, error }) => (
+ <div key={projectId} className="flex items-center justify-between text-xs p-2 rounded border">
+ <span className="font-medium">Contract {projectId}</span>
{isLoading ? (
<Badge variant="secondary" className="text-xs">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts
index 4c1f5786..e058803b 100644
--- a/lib/vendor-document-list/sync-service.ts
+++ b/lib/vendor-document-list/sync-service.ts
@@ -42,7 +42,7 @@ class SyncService {
* 변경사항을 change_logs에 기록
*/
async logChange(
- contractId: number,
+ projectId: number,
entityType: 'document' | 'revision' | 'attachment',
entityId: number,
action: 'CREATE' | 'UPDATE' | 'DELETE',
@@ -56,7 +56,7 @@ class SyncService {
const changedFields = this.detectChangedFields(oldValues, newValues)
await db.insert(changeLogs).values({
- contractId,
+ projectId,
entityType,
entityId,
action,
@@ -99,7 +99,7 @@ class SyncService {
* 동기화할 변경사항 조회 (증분)
*/
async getPendingChanges(
- contractId: number,
+ projectId: number,
targetSystem: string = 'DOLCE',
limit?: number
): Promise<ChangeLog[]> {
@@ -107,7 +107,7 @@ class SyncService {
.select()
.from(changeLogs)
.where(and(
- eq(changeLogs.contractId, contractId),
+ eq(changeLogs.projectId, projectId),
eq(changeLogs.isSynced, false),
lt(changeLogs.syncAttempts, 3),
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
@@ -136,14 +136,14 @@ class SyncService {
* 동기화 배치 생성
*/
async createSyncBatch(
- contractId: number,
+ projectId: number,
targetSystem: string,
changeLogIds: number[]
): Promise<number> {
const [batch] = await db
.insert(syncBatches)
.values({
- contractId,
+ projectId,
targetSystem,
batchSize: changeLogIds.length,
changeLogIds,
@@ -158,7 +158,7 @@ class SyncService {
* 메인 동기화 실행 함수 (청크 처리 포함)
*/
async syncToExternalSystem(
- contractId: number,
+ projectId: number,
targetSystem: string = 'DOLCE',
manualTrigger: boolean = false
): Promise<SyncResult> {
@@ -169,7 +169,7 @@ class SyncService {
}
// 2. 대기 중인 변경사항 조회 (전체)
- const pendingChanges = await this.getPendingChanges(contractId, targetSystem)
+ const pendingChanges = await this.getPendingChanges(projectId, targetSystem)
if (pendingChanges.length === 0) {
return {
@@ -182,7 +182,7 @@ class SyncService {
// 3. 배치 생성
const batchId = await this.createSyncBatch(
- contractId,
+ projectId,
targetSystem,
pendingChanges.map(c => c.id)
)
@@ -214,10 +214,10 @@ class SyncService {
// 시스템별로 다른 동기화 메서드 호출
switch (targetSystem.toUpperCase()) {
case 'DOLCE':
- chunkResult = await this.performSyncDOLCE(chunk, contractId)
+ chunkResult = await this.performSyncDOLCE(chunk, projectId)
break
case 'SWP':
- chunkResult = await this.performSyncSWP(chunk, contractId)
+ chunkResult = await this.performSyncSWP(chunk, projectId)
break
default:
throw new Error(`Unsupported target system: ${targetSystem}`)
@@ -296,7 +296,7 @@ class SyncService {
*/
private async performSyncDOLCE(
changes: ChangeLog[],
- contractId: number
+ projectId: number
): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[]; endpointResults?: Record<string, any> }> {
const errors: string[] = []
const endpointResults: Record<string, any> = {}
@@ -325,7 +325,7 @@ class SyncService {
// DOLCE 업로드 실행
const uploadResult = await dolceUploadService.uploadToDoLCE(
- contractId,
+ projectId,
revisionIds,
'system_user', // 시스템 사용자 ID
'System Upload'
@@ -374,216 +374,16 @@ class SyncService {
*/
private async performSyncSWP(
changes: ChangeLog[],
- contractId: number
+ projectId: number
): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[]; endpointResults?: Record<string, any> }> {
- const errors: string[] = []
- const endpointResults: Record<string, any> = {}
- let overallSuccess = true
-
- // 변경사항을 SWP 시스템 형태로 변환
- const syncData = await this.transformChangesForSWP(changes)
-
- // 1. SWP 메인 엔드포인트 (XML 전송)
- const mainUrl = process.env.SYNC_SWP_URL
- if (mainUrl) {
- try {
- console.log(`Sending to SWP main: ${mainUrl}`)
-
- const transformedData = this.convertToXML({
- contractId,
- systemType: 'SWP',
- changes: syncData,
- batchSize: changes.length,
- timestamp: new Date().toISOString(),
- source: 'EVCP'
- })
-
- const response = await fetch(mainUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/xml',
- 'Authorization': `Basic ${Buffer.from(`${process.env.SYNC_SWP_USER}:${process.env.SYNC_SWP_PASSWORD}`).toString('base64')}`,
- 'X-System': 'SWP'
- },
- body: transformedData
- })
-
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`SWP main: HTTP ${response.status} - ${errorText}`)
- }
-
- let result
- const contentType = response.headers.get('content-type')
- if (contentType?.includes('application/json')) {
- result = await response.json()
- } else {
- result = await response.text()
- }
-
- endpointResults['swp_main'] = result
- console.log(`✅ SWP main sync successful`)
-
- } catch (error) {
- const errorMessage = `SWP main: ${error instanceof Error ? error.message : 'Unknown error'}`
- errors.push(errorMessage)
- overallSuccess = false
-
- console.error(`❌ SWP main sync failed:`, error)
- }
- }
-
- // 2. SWP 알림 엔드포인트 (선택사항)
- const notificationUrl = process.env.SYNC_SWP_NOTIFICATION_URL
- if (notificationUrl) {
- try {
- console.log(`Sending to SWP notification: ${notificationUrl}`)
-
- const notificationData = {
- event: 'swp_sync_notification',
- itemCount: syncData.length,
- syncTime: new Date().toISOString(),
- system: 'SWP'
- }
-
- const response = await fetch(notificationUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(notificationData)
- })
-
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`SWP notification: HTTP ${response.status} - ${errorText}`)
- }
-
- const result = await response.json()
- endpointResults['swp_notification'] = result
- console.log(`✅ SWP notification sync successful`)
-
- } catch (error) {
- const errorMessage = `SWP notification: ${error instanceof Error ? error.message : 'Unknown error'}`
- errors.push(errorMessage)
- // 알림은 실패해도 전체 동기화는 성공으로 처리
- console.error(`❌ SWP notification sync failed:`, error)
- }
- }
-
- if (!mainUrl) {
- throw new Error('No SWP main endpoint configured')
- }
-
- console.log(`SWP sync completed with ${errors.length} errors`)
-
+ // SWP 동기화 로직 구현
+ // 현재는 플레이스홀더
return {
- success: overallSuccess && errors.length === 0,
- successCount: overallSuccess ? changes.length : 0,
- failureCount: overallSuccess ? 0 : changes.length,
- errors: errors.length > 0 ? errors : undefined,
- endpointResults
- }
- }
-
- /**
- * SWP 시스템용 데이터 변환
- */
- private async transformChangesForSWP(changes: ChangeLog[]): Promise<SyncableEntity[]> {
- const syncData: SyncableEntity[] = []
-
- for (const change of changes) {
- try {
- let entityData = null
-
- // 엔티티 타입별로 현재 데이터 조회
- switch (change.entityType) {
- case 'document':
- if (change.action !== 'DELETE') {
- const [document] = await db
- .select()
- .from(documents)
- .where(eq(documents.id, change.entityId))
- .limit(1)
- entityData = document
- }
- break
-
- case 'revision':
- if (change.action !== 'DELETE') {
- const [revision] = await db
- .select()
- .from(revisions)
- .where(eq(revisions.id, change.entityId))
- .limit(1)
- entityData = revision
- }
- break
-
- case 'attachment':
- if (change.action !== 'DELETE') {
- const [attachment] = await db
- .select()
- .from(documentAttachments)
- .where(eq(documentAttachments.id, change.entityId))
- .limit(1)
- entityData = attachment
- }
- break
- }
-
- // SWP 특화 데이터 구조
- syncData.push({
- entityType: change.entityType as any,
- entityId: change.entityId,
- action: change.action as any,
- data: entityData || change.oldValues,
- metadata: {
- changeId: change.id,
- changedAt: change.createdAt,
- changedBy: change.userName,
- changedFields: change.changedFields,
- // SWP 전용 메타데이터
- swpFormat: 'legacy',
- batchSequence: syncData.length + 1,
- needsValidation: change.entityType === 'document',
- legacyId: `SWP_${change.entityId}_${Date.now()}`
- }
- })
-
- } catch (error) {
- console.error(`Failed to transform change ${change.id} for SWP:`, error)
- }
+ success: true,
+ successCount: changes.length,
+ failureCount: 0,
+ endpointResults: { message: 'SWP sync placeholder' }
}
-
- return syncData
- }
-
- /**
- * 간단한 XML 변환 헬퍼 (SWP용)
- */
- private convertToXML(data: any): string {
- const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>'
- const xmlBody = `
- <SyncRequest>
- <ContractId>${data.contractId}</ContractId>
- <SystemType>${data.systemType}</SystemType>
- <BatchSize>${data.batchSize}</BatchSize>
- <Timestamp>${data.timestamp}</Timestamp>
- <Source>${data.source}</Source>
- <Changes>
- ${data.changes.map((change: SyncableEntity) => `
- <Change>
- <EntityType>${change.entityType}</EntityType>
- <EntityId>${change.entityId}</EntityId>
- <Action>${change.action}</Action>
- <Data>${JSON.stringify(change.data)}</Data>
- </Change>
- `).join('')}
- </Changes>
- </SyncRequest>`
-
- return xmlHeader + xmlBody
}
/**
@@ -638,13 +438,13 @@ class SyncService {
/**
* 동기화 상태 조회
*/
- async getSyncStatus(contractId: number, targetSystem: string = 'DOLCE') {
+ async getSyncStatus(projectId: number, targetSystem: string = 'DOLCE') {
try {
// 대기 중인 변경사항 수 조회
const pendingCount = await db.$count(
changeLogs,
and(
- eq(changeLogs.contractId, contractId),
+ eq(changeLogs.projectId, projectId),
eq(changeLogs.isSynced, false),
lt(changeLogs.syncAttempts, 3),
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
@@ -655,7 +455,7 @@ class SyncService {
const syncedCount = await db.$count(
changeLogs,
and(
- eq(changeLogs.contractId, contractId),
+ eq(changeLogs.projectId, projectId),
eq(changeLogs.isSynced, true),
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
)
@@ -665,7 +465,7 @@ class SyncService {
const failedCount = await db.$count(
changeLogs,
and(
- eq(changeLogs.contractId, contractId),
+ eq(changeLogs.projectId, projectId),
eq(changeLogs.isSynced, false),
sql`${changeLogs.syncAttempts} >= 3`,
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
@@ -677,7 +477,7 @@ class SyncService {
.select()
.from(syncBatches)
.where(and(
- eq(syncBatches.contractId, contractId),
+ eq(syncBatches.projectId, projectId),
eq(syncBatches.targetSystem, targetSystem),
eq(syncBatches.status, 'SUCCESS')
))
@@ -685,7 +485,7 @@ class SyncService {
.limit(1)
return {
- contractId,
+ projectId,
targetSystem,
totalChanges: pendingCount + syncedCount + failedCount,
pendingChanges: pendingCount,
@@ -703,13 +503,13 @@ class SyncService {
/**
* 최근 동기화 배치 목록 조회
*/
- async getRecentSyncBatches(contractId: number, targetSystem: string = 'DOLCE', limit: number = 10) {
+ async getRecentSyncBatches(projectId: number, targetSystem: string = 'DOLCE', limit: number = 10) {
try {
const batches = await db
.select()
.from(syncBatches)
.where(and(
- eq(syncBatches.contractId, contractId),
+ eq(syncBatches.projectId, projectId),
eq(syncBatches.targetSystem, targetSystem)
))
.orderBy(desc(syncBatches.createdAt))
@@ -718,7 +518,7 @@ class SyncService {
// Date 객체를 문자열로 변환
return batches.map(batch => ({
id: Number(batch.id),
- contractId: batch.contractId,
+ projectId: batch.projectId,
targetSystem: batch.targetSystem,
batchSize: batch.batchSize,
status: batch.status,
@@ -742,7 +542,7 @@ export const syncService = new SyncService()
// 편의 함수들 (기본 타겟 시스템을 DOLCE로 변경)
export async function logDocumentChange(
- contractId: number,
+ projectId: number,
documentId: number,
action: 'CREATE' | 'UPDATE' | 'DELETE',
newValues?: any,
@@ -751,11 +551,11 @@ export async function logDocumentChange(
userName?: string,
targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'document', documentId, action, newValues, oldValues, userId, userName, targetSystems)
+ return syncService.logChange(projectId, 'document', documentId, action, newValues, oldValues, userId, userName, targetSystems)
}
export async function logRevisionChange(
- contractId: number,
+ projectId: number,
revisionId: number,
action: 'CREATE' | 'UPDATE' | 'DELETE',
newValues?: any,
@@ -764,11 +564,11 @@ export async function logRevisionChange(
userName?: string,
targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'revision', revisionId, action, newValues, oldValues, userId, userName, targetSystems)
+ return syncService.logChange(projectId, 'revision', revisionId, action, newValues, oldValues, userId, userName, targetSystems)
}
export async function logAttachmentChange(
- contractId: number,
+ projectId: number,
attachmentId: number,
action: 'CREATE' | 'UPDATE' | 'DELETE',
newValues?: any,
@@ -777,5 +577,5 @@ export async function logAttachmentChange(
userName?: string,
targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName, targetSystems)
+ return syncService.logChange(projectId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName, targetSystems)
} \ No newline at end of file