summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/tags/service.ts2
-rw-r--r--lib/vendor-document-list/sync-service.ts847
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx1
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table.tsx2
-rw-r--r--lib/vendor-document-list/table/revision-upload-dialog.tsx75
-rw-r--r--lib/vendor-document-list/table/send-to-shi-button.tsx16
-rw-r--r--lib/vendor-document-list/table/stage-revision-expanded-content.tsx12
-rw-r--r--lib/vendor-document/service.ts87
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx1
-rw-r--r--lib/vendor-investigation/table/update-investigation-sheet.tsx656
-rw-r--r--lib/vendor-investigation/validations.ts28
11 files changed, 1195 insertions, 532 deletions
diff --git a/lib/tags/service.ts b/lib/tags/service.ts
index 307a4774..6a34d208 100644
--- a/lib/tags/service.ts
+++ b/lib/tags/service.ts
@@ -244,7 +244,7 @@ export async function createTag(
// 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시)
revalidateTag(`tags-${selectedPackageId}`)
- revalidateTag(`forms-${selectedPackageId}`)
+ revalidateTag(`forms-${selectedPackageId}-ENG`)
revalidateTag("tags")
// 7) 성공 시 반환
diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts
index 6978c1cc..1f2872c4 100644
--- a/lib/vendor-document-list/sync-service.ts
+++ b/lib/vendor-document-list/sync-service.ts
@@ -1,17 +1,13 @@
-// lib/sync-service.ts
+// lib/sync-service.ts (시스템별 분리 버전)
import db from "@/db/db"
-import {
- syncConfigs,
- changeLogs,
- syncBatches,
- syncStatusView,
- type SyncConfig,
+import {
+ changeLogs,
+ syncBatches,
type ChangeLog,
- type SyncBatch
+ type SyncBatch
} from "@/db/schema/vendorDocu"
import { documents, revisions, documentAttachments } from "@/db/schema/vendorDocu"
import { eq, and, lt, desc, sql, inArray } from "drizzle-orm"
-import { toast } from "sonner"
export interface SyncableEntity {
entityType: 'document' | 'revision' | 'attachment'
@@ -27,10 +23,23 @@ export interface SyncResult {
successCount: number
failureCount: number
errors?: string[]
+ endpointResults?: Record<string, any>
}
class SyncService {
-
+ private readonly CHUNK_SIZE = 50
+
+
+
+ /**
+ * 동기화 활성화 여부 확인
+ */
+ private isSyncEnabled(targetSystem: string): boolean {
+ const upperSystem = targetSystem.toUpperCase()
+ const enabled = process.env[`SYNC_${upperSystem}_ENABLED`]
+ return enabled === 'true' || enabled === '1'
+ }
+
/**
* 변경사항을 change_logs에 기록
*/
@@ -42,11 +51,12 @@ class SyncService {
newValues?: any,
oldValues?: any,
userId?: number,
- userName?: string
+ userName?: string,
+ targetSystems: string[] = ["DOLCE", "SWP"]
) {
try {
const changedFields = this.detectChangedFields(oldValues, newValues)
-
+
await db.insert(changeLogs).values({
contractId,
entityType,
@@ -57,9 +67,9 @@ class SyncService {
newValues,
userId,
userName,
- targetSystems: ['SHI'], // 기본적으로 SHI로 동기화
+ targetSystems,
})
-
+
console.log(`Change logged: ${entityType}/${entityId} - ${action}`)
} catch (error) {
console.error('Failed to log change:', error)
@@ -72,9 +82,9 @@ class SyncService {
*/
private detectChangedFields(oldValues: any, newValues: any): Record<string, any> | null {
if (!oldValues || !newValues) return null
-
+
const changes: Record<string, any> = {}
-
+
for (const [key, newValue] of Object.entries(newValues)) {
if (JSON.stringify(oldValues[key]) !== JSON.stringify(newValue)) {
changes[key] = {
@@ -83,65 +93,45 @@ class SyncService {
}
}
}
-
- return Object.keys(changes).length > 0 ? changes : null
- }
- /**
- * 계약별 동기화 설정 조회
- */
- async getSyncConfig(contractId: number, targetSystem: string = 'SHI'): Promise<SyncConfig | null> {
- const [config] = await db
- .select()
- .from(syncConfigs)
- .where(and(
- eq(syncConfigs.contractId, contractId),
- eq(syncConfigs.targetSystem, targetSystem)
- ))
- .limit(1)
-
- return config || null
- }
-
- /**
- * 동기화 설정 생성/업데이트
- */
- async upsertSyncConfig(config: Partial<SyncConfig> & {
- contractId: number
- targetSystem: string
- endpointUrl: string
- }) {
- const existing = await this.getSyncConfig(config.contractId, config.targetSystem)
-
- if (existing) {
- await db
- .update(syncConfigs)
- .set({ ...config, updatedAt: new Date() })
- .where(eq(syncConfigs.id, existing.id))
- } else {
- await db.insert(syncConfigs).values(config)
- }
+ return Object.keys(changes).length > 0 ? changes : null
}
/**
* 동기화할 변경사항 조회 (증분)
*/
async getPendingChanges(
- contractId: number,
- targetSystem: string = 'SHI',
- limit: number = 100
+ contractId: number,
+ targetSystem: string = 'DOLCE',
+ limit?: number
): Promise<ChangeLog[]> {
- return await db
+ const query = db
.select()
.from(changeLogs)
.where(and(
eq(changeLogs.contractId, contractId),
eq(changeLogs.isSynced, false),
- lt(changeLogs.syncAttempts, 3), // 최대 3회 재시도
+ lt(changeLogs.syncAttempts, 3),
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
))
.orderBy(changeLogs.createdAt)
- .limit(limit)
+
+ if (limit) {
+ query.limit(limit)
+ }
+
+ return await query
+ }
+
+ /**
+ * 배열을 청크 단위로 분할
+ */
+ private chunkArray<T>(array: T[], chunkSize: number): T[][] {
+ const chunks: T[][] = []
+ for (let i = 0; i < array.length; i += chunkSize) {
+ chunks.push(array.slice(i, i + chunkSize))
+ }
+ return chunks
}
/**
@@ -162,31 +152,26 @@ class SyncService {
status: 'PENDING'
})
.returning({ id: syncBatches.id })
-
+
return batch.id
}
/**
- * 메인 동기화 실행 함수
+ * 메인 동기화 실행 함수 (청크 처리 포함)
*/
async syncToExternalSystem(
contractId: number,
- targetSystem: string = 'SHI',
+ targetSystem: string = 'DOLCE',
manualTrigger: boolean = false
): Promise<SyncResult> {
try {
- // 1. 동기화 설정 확인
- const config = await this.getSyncConfig(contractId, targetSystem)
- if (!config || !config.syncEnabled) {
- throw new Error(`Sync not enabled for contract ${contractId} to ${targetSystem}`)
+ // 1. 동기화 활성화 확인
+ if (!this.isSyncEnabled(targetSystem)) {
+ throw new Error(`Sync not enabled for ${targetSystem}`)
}
- // 2. 대기 중인 변경사항 조회
- const pendingChanges = await this.getPendingChanges(
- contractId,
- targetSystem,
- config.maxBatchSize || 100
- )
+ // 2. 대기 중인 변경사항 조회 (전체)
+ const pendingChanges = await this.getPendingChanges(contractId, targetSystem)
if (pendingChanges.length === 0) {
return {
@@ -207,75 +192,99 @@ class SyncService {
// 4. 배치 상태를 PROCESSING으로 업데이트
await db
.update(syncBatches)
- .set({
- status: 'PROCESSING',
+ .set({
+ status: 'PROCESSING',
startedAt: new Date(),
updatedAt: new Date()
})
.where(eq(syncBatches.id, batchId))
- // 5. 실제 데이터 동기화 수행
- const syncResult = await this.performSync(config, pendingChanges)
-
- // 6. 배치 상태 업데이트
- await db
- .update(syncBatches)
- .set({
- status: syncResult.success ? 'SUCCESS' : (syncResult.successCount > 0 ? 'PARTIAL' : 'FAILED'),
- completedAt: new Date(),
- successCount: syncResult.successCount,
- failureCount: syncResult.failureCount,
- errorMessage: syncResult.errors?.join('; '),
- updatedAt: new Date()
- })
- .where(eq(syncBatches.id, batchId))
+ // 5. 청크 단위로 동기화 수행 - 시스템별 분기
+ const chunks = this.chunkArray(pendingChanges, this.CHUNK_SIZE)
+ let totalSuccessCount = 0
+ let totalFailureCount = 0
+ const allErrors: string[] = []
+ const endpointResults: Record<string, any> = {}
+
+ for (let i = 0; i < chunks.length; i++) {
+ const chunk = chunks[i]
+ console.log(`Processing chunk ${i + 1}/${chunks.length} (${chunk.length} items) for ${targetSystem}`)
+
+ try {
+ let chunkResult;
+
+ // 시스템별로 다른 동기화 메서드 호출
+ switch (targetSystem.toUpperCase()) {
+ case 'DOLCE':
+ chunkResult = await this.performSyncDOLCE(chunk, contractId)
+ break
+ case 'SWP':
+ chunkResult = await this.performSyncSWP(chunk, contractId)
+ break
+ default:
+ throw new Error(`Unsupported target system: ${targetSystem}`)
+ }
+
+ totalSuccessCount += chunkResult.successCount
+ totalFailureCount += chunkResult.failureCount
+
+ if (chunkResult.errors) {
+ allErrors.push(...chunkResult.errors)
+ }
- // 7. 성공한 변경사항들을 동기화 완료로 표시
- if (syncResult.successCount > 0) {
- const successfulChangeIds = pendingChanges
- .slice(0, syncResult.successCount)
- .map(c => c.id)
-
- await db
- .update(changeLogs)
- .set({
- isSynced: true,
- syncedAt: new Date()
- })
- .where(inArray(changeLogs.id, successfulChangeIds))
- }
+ // 엔드포인트별 결과 병합
+ Object.assign(endpointResults, chunkResult.endpointResults || {})
- // 8. 실패한 변경사항들의 재시도 횟수 증가
- if (syncResult.failureCount > 0) {
- const failedChangeIds = pendingChanges
- .slice(syncResult.successCount)
- .map(c => c.id)
-
- await db
- .update(changeLogs)
- .set({
- syncAttempts: sql`${changeLogs.syncAttempts} + 1`,
- lastSyncError: syncResult.errors?.[0] || 'Unknown error'
- })
- .where(inArray(changeLogs.id, failedChangeIds))
+ // 성공한 변경사항들을 동기화 완료로 표시
+ if (chunkResult.successCount > 0) {
+ const successfulChangeIds = chunk
+ .slice(0, chunkResult.successCount)
+ .map(c => c.id)
+
+ await this.markChangesAsSynced(successfulChangeIds)
+ }
+
+ // 실패한 변경사항들의 재시도 횟수 증가
+ if (chunkResult.failureCount > 0) {
+ const failedChangeIds = chunk
+ .slice(chunkResult.successCount)
+ .map(c => c.id)
+
+ await this.incrementSyncAttempts(failedChangeIds, chunkResult.errors?.[0])
+ }
+
+ } catch (error) {
+ console.error(`Chunk ${i + 1} failed for ${targetSystem}:`, error)
+ totalFailureCount += chunk.length
+ allErrors.push(`Chunk ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`)
+
+ // 전체 청크 실패 시 재시도 횟수 증가
+ await this.incrementSyncAttempts(chunk.map(c => c.id), error instanceof Error ? error.message : 'Unknown error')
+ }
}
- // 9. 동기화 설정의 마지막 동기화 시간 업데이트
+ const overallSuccess = totalFailureCount === 0
+
+ // 6. 배치 상태 업데이트
await db
- .update(syncConfigs)
+ .update(syncBatches)
.set({
- lastSyncAttempt: new Date(),
- ...(syncResult.success && { lastSuccessfulSync: new Date() }),
+ status: overallSuccess ? 'SUCCESS' : (totalSuccessCount > 0 ? 'PARTIAL' : 'FAILED'),
+ completedAt: new Date(),
+ successCount: totalSuccessCount,
+ failureCount: totalFailureCount,
+ errorMessage: allErrors.length > 0 ? allErrors.join('; ') : null,
updatedAt: new Date()
})
- .where(eq(syncConfigs.id, config.id))
+ .where(eq(syncBatches.id, batchId))
return {
batchId,
- success: syncResult.success,
- successCount: syncResult.successCount,
- failureCount: syncResult.failureCount,
- errors: syncResult.errors
+ success: overallSuccess,
+ successCount: totalSuccessCount,
+ failureCount: totalFailureCount,
+ errors: allErrors.length > 0 ? allErrors : undefined,
+ endpointResults
}
} catch (error) {
@@ -285,77 +294,275 @@ class SyncService {
}
/**
- * 실제 외부 시스템으로 데이터 전송
+ * DOLCE 시스템 전용 동기화 수행
*/
- private async performSync(
- config: SyncConfig,
- changes: ChangeLog[]
- ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[] }> {
+ private async performSyncDOLCE(
+ changes: ChangeLog[],
+ contractId: number
+ ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[]; endpointResults?: Record<string, any> }> {
const errors: string[] = []
- let successCount = 0
- let failureCount = 0
+ const endpointResults: Record<string, any> = {}
+ let overallSuccess = true
+
+ // 변경사항을 DOLCE 시스템 형태로 변환
+ const syncData = await this.transformChangesForDOLCE(changes)
+
+ // DOLCE 엔드포인트 호출들을 직접 정의
+ const endpointPromises = []
+
+ // 1. DOLCE 메인 엔드포인트
+ const mainUrl = process.env.SYNC_DOLCE_URL
+ if (mainUrl) {
+ endpointPromises.push(
+ (async () => {
+ try {
+ console.log(`Sending to DOLCE main: ${mainUrl}`)
+
+ const transformedData = {
+ contractId,
+ systemType: 'DOLCE',
+ changes: syncData,
+ batchSize: changes.length,
+ timestamp: new Date().toISOString(),
+ source: 'EVCP',
+ version: '1.0'
+ }
- try {
- // 변경사항을 외부 시스템 형태로 변환
- const syncData = await this.transformChangesForExternalSystem(changes)
-
- // 외부 API 호출
- const response = await fetch(config.endpointUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${config.authToken}`,
- 'X-API-Version': config.apiVersion || 'v1'
- },
- body: JSON.stringify({
- contractId: changes[0]?.contractId,
+ // 헤더 구성 (토큰이 있을 때만 Authorization 포함)
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json',
+ 'X-API-Version': process.env.SYNC_DOLCE_VERSION || 'v1',
+ 'X-System': 'DOLCE'
+ }
+
+ if (process.env.SYNC_DOLCE_TOKEN) {
+ headers['Authorization'] = `Bearer ${process.env.SYNC_DOLCE_TOKEN}`
+ }
+
+ const response = await fetch(mainUrl, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(transformedData)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`DOLCE main: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const result = await response.json()
+ endpointResults['dolce_main'] = result
+
+ console.log(`✅ DOLCE main sync successful`)
+ return { success: true, endpoint: 'dolce_main', result }
+
+ } catch (error) {
+ const errorMessage = `DOLCE main: ${error instanceof Error ? error.message : 'Unknown error'}`
+ errors.push(errorMessage)
+ overallSuccess = false
+
+ console.error(`❌ DOLCE main sync failed:`, error)
+ return { success: false, endpoint: 'dolce_main', error: errorMessage }
+ }
+ })()
+ )
+ }
+
+ // 2. DOLCE 문서 전용 엔드포인트 (선택사항)
+ const docUrl = process.env.SYNC_DOLCE_DOCUMENT_URL
+ if (docUrl) {
+ endpointPromises.push(
+ (async () => {
+ try {
+ console.log(`Sending to DOLCE documents: ${docUrl}`)
+
+ const documentData = {
+ documents: syncData.filter(item => item.entityType === 'document'),
+ source: 'EVCP_DOLCE',
+ timestamp: new Date().toISOString()
+ }
+
+ // 헤더 구성 (토큰이 있을 때만 Authorization 포함)
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json'
+ }
+
+ if (process.env.SYNC_DOLCE_TOKEN) {
+ headers['Authorization'] = `Bearer ${process.env.SYNC_DOLCE_TOKEN}`
+ }
+
+ const response = await fetch(docUrl, {
+ method: 'PUT',
+ headers,
+ body: JSON.stringify(documentData)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`DOLCE documents: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const result = await response.json()
+ endpointResults['dolce_documents'] = result
+
+ console.log(`✅ DOLCE documents sync successful`)
+ return { success: true, endpoint: 'dolce_documents', result }
+
+ } catch (error) {
+ const errorMessage = `DOLCE documents: ${error instanceof Error ? error.message : 'Unknown error'}`
+ errors.push(errorMessage)
+ overallSuccess = false
+
+ console.error(`❌ DOLCE documents sync failed:`, error)
+ return { success: false, endpoint: 'dolce_documents', error: errorMessage }
+ }
+ })()
+ )
+ }
+
+ if (endpointPromises.length === 0) {
+ throw new Error('No DOLCE sync endpoints configured')
+ }
+
+ // 모든 엔드포인트 요청 완료 대기
+ const results = await Promise.allSettled(endpointPromises)
+
+ // 결과 집계
+ const successfulEndpoints = results.filter(r => r.status === 'fulfilled' && r.value.success).length
+ const totalEndpoints = endpointPromises.length
+
+ console.log(`DOLCE endpoint results: ${successfulEndpoints}/${totalEndpoints} successful`)
+
+ 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 performSyncSWP(
+ changes: ChangeLog[],
+ contractId: 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()
+ timestamp: new Date().toISOString(),
+ source: 'EVCP'
})
- })
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`HTTP ${response.status}: ${errorText}`)
+ 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)
}
+ }
- const result = await response.json()
-
- // 응답에 따라 성공/실패 카운트 처리
- if (result.success) {
- successCount = changes.length
- } else if (result.partialSuccess) {
- successCount = result.successCount || 0
- failureCount = changes.length - successCount
- if (result.errors) {
- errors.push(...result.errors)
+ // 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'
}
- } else {
- failureCount = changes.length
- if (result.error) {
- errors.push(result.error)
+
+ 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)
}
+ }
- } catch (error) {
- console.error('External sync failed:', error)
- failureCount = changes.length
- errors.push(error instanceof Error ? error.message : 'Unknown error')
+ if (!mainUrl) {
+ throw new Error('No SWP main endpoint configured')
}
+ console.log(`SWP sync completed with ${errors.length} errors`)
+
return {
- success: failureCount === 0,
- successCount,
- failureCount,
- errors: errors.length > 0 ? errors : undefined
+ success: overallSuccess && errors.length === 0,
+ successCount: overallSuccess ? changes.length : 0,
+ failureCount: overallSuccess ? 0 : changes.length,
+ errors: errors.length > 0 ? errors : undefined,
+ endpointResults
}
}
/**
- * 변경사항을 외부 시스템 형태로 변환
+ * DOLCE 시스템용 데이터 변환
*/
- private async transformChangesForExternalSystem(changes: ChangeLog[]): Promise<SyncableEntity[]> {
+ private async transformChangesForDOLCE(changes: ChangeLog[]): Promise<SyncableEntity[]> {
const syncData: SyncableEntity[] = []
for (const change of changes) {
@@ -374,7 +581,7 @@ class SyncService {
entityData = document
}
break
-
+
case 'revision':
if (change.action !== 'DELETE') {
const [revision] = await db
@@ -385,7 +592,7 @@ class SyncService {
entityData = revision
}
break
-
+
case 'attachment':
if (change.action !== 'DELETE') {
const [attachment] = await db
@@ -398,21 +605,26 @@ class SyncService {
break
}
+ // DOLCE 특화 데이터 구조
syncData.push({
entityType: change.entityType as any,
entityId: change.entityId,
action: change.action as any,
- data: entityData || change.oldValues, // DELETE의 경우 oldValues 사용
+ data: entityData || change.oldValues,
metadata: {
changeId: change.id,
changedAt: change.createdAt,
changedBy: change.userName,
- changedFields: change.changedFields
+ changedFields: change.changedFields,
+ // DOLCE 전용 메타데이터
+ dolceVersion: '2.0',
+ processingPriority: change.entityType === 'revision' ? 'HIGH' : 'NORMAL',
+ requiresApproval: change.action === 'DELETE'
}
})
} catch (error) {
- console.error(`Failed to transform change ${change.id}:`, error)
+ console.error(`Failed to transform change ${change.id} for DOLCE:`, error)
}
}
@@ -420,40 +632,260 @@ class SyncService {
}
/**
- * 동기화 상태 조회
+ * SWP 시스템용 데이터 변환
*/
- async getSyncStatus(contractId: number, targetSystem: string = 'SHI') {
- const [status] = await db
- .select()
- .from(syncStatusView)
+ 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)
+ }
+ }
+
+ 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
+ }
+
+ /**
+ * 성공한 변경사항들을 동기화 완료로 표시
+ */
+ private async markChangesAsSynced(changeIds: number[]) {
+ if (changeIds.length === 0) return
+
+ await db
+ .update(changeLogs)
+ .set({
+ isSynced: true,
+ syncedAt: new Date()
+ })
+ .where(inArray(changeLogs.id, changeIds))
+
+ // 리비전 상태 업데이트
+ const revisionChanges = await db
+ .select({ entityId: changeLogs.entityId })
+ .from(changeLogs)
.where(and(
- eq(syncStatusView.contractId, contractId),
- eq(syncStatusView.targetSystem, targetSystem)
+ inArray(changeLogs.id, changeIds),
+ eq(changeLogs.entityType, 'revision')
))
- .limit(1)
- return status
+ if (revisionChanges.length > 0) {
+ const revisionIds = revisionChanges.map(c => c.entityId)
+ await db.update(revisions)
+ .set({
+ revisionStatus: "SUBMITTED",
+ externalSentAt: new Date().toISOString().slice(0, 10)
+ })
+ .where(inArray(revisions.id, revisionIds))
+ }
+ }
+
+ /**
+ * 실패한 변경사항들의 재시도 횟수 증가
+ */
+ private async incrementSyncAttempts(changeIds: number[], errorMessage?: string) {
+ if (changeIds.length === 0) return
+
+ await db
+ .update(changeLogs)
+ .set({
+ syncAttempts: sql`${changeLogs.syncAttempts} + 1`,
+ lastSyncError: errorMessage || 'Unknown error'
+ })
+ .where(inArray(changeLogs.id, changeIds))
+ }
+
+ /**
+ * 동기화 상태 조회
+ */
+ async getSyncStatus(contractId: number, targetSystem: string = 'DOLCE') {
+ try {
+ // 대기 중인 변경사항 수 조회
+ const pendingCount = await db.$count(
+ changeLogs,
+ and(
+ eq(changeLogs.contractId, contractId),
+ eq(changeLogs.isSynced, false),
+ lt(changeLogs.syncAttempts, 3),
+ sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
+ )
+ )
+
+ // 동기화된 변경사항 수 조회
+ const syncedCount = await db.$count(
+ changeLogs,
+ and(
+ eq(changeLogs.contractId, contractId),
+ eq(changeLogs.isSynced, true),
+ sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
+ )
+ )
+
+ // 실패한 변경사항 수 조회
+ const failedCount = await db.$count(
+ changeLogs,
+ and(
+ eq(changeLogs.contractId, contractId),
+ eq(changeLogs.isSynced, false),
+ sql`${changeLogs.syncAttempts} >= 3`,
+ sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
+ )
+ )
+
+ // 마지막 성공한 배치 조회
+ const [lastSuccessfulBatch] = await db
+ .select()
+ .from(syncBatches)
+ .where(and(
+ eq(syncBatches.contractId, contractId),
+ eq(syncBatches.targetSystem, targetSystem),
+ eq(syncBatches.status, 'SUCCESS')
+ ))
+ .orderBy(desc(syncBatches.completedAt))
+ .limit(1)
+
+ return {
+ contractId,
+ targetSystem,
+ totalChanges: pendingCount + syncedCount + failedCount,
+ pendingChanges: pendingCount,
+ syncedChanges: syncedCount,
+ failedChanges: failedCount,
+ lastSyncAt: lastSuccessfulBatch?.completedAt?.toISOString() || null,
+ syncEnabled: this.isSyncEnabled(targetSystem)
+ }
+ } catch (error) {
+ console.error('Failed to get sync status:', error)
+ throw error
+ }
}
/**
* 최근 동기화 배치 목록 조회
*/
- async getRecentSyncBatches(contractId: number, targetSystem: string = 'SHI', limit: number = 10) {
- return await db
- .select()
- .from(syncBatches)
- .where(and(
- eq(syncBatches.contractId, contractId),
- eq(syncBatches.targetSystem, targetSystem)
- ))
- .orderBy(desc(syncBatches.createdAt))
- .limit(limit)
+ async getRecentSyncBatches(contractId: number, targetSystem: string = 'DOLCE', limit: number = 10) {
+ try {
+ const batches = await db
+ .select()
+ .from(syncBatches)
+ .where(and(
+ eq(syncBatches.contractId, contractId),
+ eq(syncBatches.targetSystem, targetSystem)
+ ))
+ .orderBy(desc(syncBatches.createdAt))
+ .limit(limit)
+
+ // Date 객체를 문자열로 변환
+ return batches.map(batch => ({
+ id: Number(batch.id),
+ contractId: batch.contractId,
+ targetSystem: batch.targetSystem,
+ batchSize: batch.batchSize,
+ status: batch.status,
+ startedAt: batch.startedAt?.toISOString() || null,
+ completedAt: batch.completedAt?.toISOString() || null,
+ errorMessage: batch.errorMessage,
+ retryCount: batch.retryCount,
+ successCount: batch.successCount,
+ failureCount: batch.failureCount,
+ createdAt: batch.createdAt.toISOString(),
+ updatedAt: batch.updatedAt.toISOString()
+ }))
+ } catch (error) {
+ console.error('Failed to get sync batches:', error)
+ throw error
+ }
}
}
export const syncService = new SyncService()
-// 편의 함수들
+// 편의 함수들 (기본 타겟 시스템을 DOLCE로 변경)
export async function logDocumentChange(
contractId: number,
documentId: number,
@@ -461,9 +893,10 @@ export async function logDocumentChange(
newValues?: any,
oldValues?: any,
userId?: number,
- userName?: string
+ userName?: string,
+ targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'document', documentId, action, newValues, oldValues, userId, userName)
+ return syncService.logChange(contractId, 'document', documentId, action, newValues, oldValues, userId, userName, targetSystems)
}
export async function logRevisionChange(
@@ -473,9 +906,10 @@ export async function logRevisionChange(
newValues?: any,
oldValues?: any,
userId?: number,
- userName?: string
+ userName?: string,
+ targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'revision', revisionId, action, newValues, oldValues, userId, userName)
+ return syncService.logChange(contractId, 'revision', revisionId, action, newValues, oldValues, userId, userName, targetSystems)
}
export async function logAttachmentChange(
@@ -485,7 +919,8 @@ export async function logAttachmentChange(
newValues?: any,
oldValues?: any,
userId?: number,
- userName?: string
+ userName?: string,
+ targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName)
+ return syncService.logChange(contractId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName, targetSystems)
} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
index f9d4d695..368b1e1c 100644
--- a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
+++ b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
@@ -91,6 +91,7 @@ export function EnhancedDocTableToolbarActions({
contractId={selectedPackageId}
documents={allDocuments}
onSyncComplete={handleSyncComplete}
+ projectType={projectType}
/>
{/* 일괄 업로드 다이얼로그 */}
diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx
index 3b623193..d0f2991a 100644
--- a/lib/vendor-document-list/table/enhanced-documents-table.tsx
+++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx
@@ -42,7 +42,7 @@ export function EnhancedDocumentsTable({
}: FinalIntegratedDocumentsTableProps) {
// 데이터 로딩
const [{ data, pageCount, total }] = React.use(promises)
-
+
// 상태 관리
const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null)
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set())
diff --git a/lib/vendor-document-list/table/revision-upload-dialog.tsx b/lib/vendor-document-list/table/revision-upload-dialog.tsx
index ac58b974..546fa7a3 100644
--- a/lib/vendor-document-list/table/revision-upload-dialog.tsx
+++ b/lib/vendor-document-list/table/revision-upload-dialog.tsx
@@ -7,6 +7,7 @@ import { z } from "zod"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
+import { mutate } from "swr" // ✅ SWR mutate import 추가
import {
Dialog,
@@ -77,6 +78,11 @@ interface RevisionUploadDialogProps {
presetStage?: string
presetRevision?: string
mode?: 'new' | 'append'
+ onUploadComplete?: () => void // ✅ 업로드 완료 콜백 추가
+}
+
+function getTargetSystem(projectType: "ship" | "plant") {
+ return projectType === "ship" ? "DOLCE" : "SWP"
}
export function RevisionUploadDialog({
@@ -87,12 +93,19 @@ export function RevisionUploadDialog({
presetStage,
presetRevision,
mode = 'new',
+ onUploadComplete, // ✅ 추가된 prop
}: RevisionUploadDialogProps) {
+
+ const targetSystem = React.useMemo(
+ () => getTargetSystem(projectType),
+ [projectType]
+ )
+
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
const [isUploading, setIsUploading] = React.useState(false)
const [uploadProgress, setUploadProgress] = React.useState(0)
const router = useRouter()
-
+
// ✅ next-auth session 가져오기
const { data: session } = useSession()
@@ -109,7 +122,7 @@ export function RevisionUploadDialog({
defaultValues: {
stage: presetStage || document?.currentStageName || "",
revision: presetRevision || "",
- uploaderName: session?.user?.name || "", // ✅ session.user.name 사용
+ uploaderName: session?.user?.name || "",
comment: "",
attachments: [],
},
@@ -146,6 +159,34 @@ export function RevisionUploadDialog({
form.setValue('attachments', updatedFiles, { shouldValidate: true })
}
+ // ✅ 캐시 갱신 함수
+ const refreshCaches = async () => {
+ try {
+ // 1. 서버 컴포넌트 캐시 갱신 (Enhanced Documents 등)
+ router.refresh()
+
+ // 2. SWR 캐시 갱신 (Sync Status)
+ if (document?.contractId) {
+ await mutate(`/api/sync/status/${document.contractId}/${targetSystem}`)
+ console.log('✅ Sync status cache refreshed')
+ }
+
+ // 3. 다른 관련 SWR 캐시들도 갱신 (필요시)
+ await mutate(key =>
+ typeof key === 'string' &&
+ key.includes('sync') &&
+ key.includes(String(document?.contractId))
+ )
+
+ // 4. 상위 컴포넌트 콜백 호출
+ onUploadComplete?.()
+
+ console.log('✅ All caches refreshed after upload')
+ } catch (error) {
+ console.error('❌ Cache refresh failed:', error)
+ }
+ }
+
// 업로드 처리
async function onSubmit(data: RevisionUploadSchema) {
if (!document) return
@@ -158,8 +199,9 @@ export function RevisionUploadDialog({
formData.append("documentId", String(document.documentId))
formData.append("stage", data.stage)
formData.append("revision", data.revision)
- formData.append("mode", mode) // 'new' 또는 'append'
-
+ formData.append("mode", mode)
+ formData.append("targetSystem", targetSystem)
+
if (data.uploaderName) {
formData.append("uploaderName", data.uploaderName)
}
@@ -175,7 +217,7 @@ export function RevisionUploadDialog({
// 진행률 업데이트 시뮬레이션
const updateProgress = (progress: number) => {
- setUploadProgress(Math.min(progress, 95)) // 95%까지만 진행률 표시
+ setUploadProgress(Math.min(progress, 95))
}
// 파일 크기에 따른 진행률 시뮬레이션
@@ -183,7 +225,7 @@ export function RevisionUploadDialog({
let uploadedSize = 0
const progressInterval = setInterval(() => {
- uploadedSize += totalSize * 0.1 // 10%씩 증가 시뮬레이션
+ uploadedSize += totalSize * 0.1
const progress = Math.min((uploadedSize / totalSize) * 100, 90)
updateProgress(progress)
}, 300)
@@ -211,10 +253,10 @@ export function RevisionUploadDialog({
console.log('✅ 업로드 성공:', result)
- // 잠시 대기 후 다이얼로그 닫기
- setTimeout(() => {
+ // ✅ 캐시 갱신 및 다이얼로그 닫기
+ setTimeout(async () => {
+ await refreshCaches()
handleDialogClose()
- router.refresh()
}, 1000)
} catch (error) {
@@ -222,7 +264,7 @@ export function RevisionUploadDialog({
toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다")
} finally {
setIsUploading(false)
- setTimeout(() => setUploadProgress(0), 2000) // 2초 후 진행률 리셋
+ setTimeout(() => setUploadProgress(0), 2000)
}
}
@@ -230,7 +272,7 @@ export function RevisionUploadDialog({
form.reset({
stage: presetStage || document?.currentStageName || "",
revision: presetRevision || "",
- uploaderName: session?.user?.name || "", // ✅ 다이얼로그 닫을 때도 session 값으로 리셋
+ uploaderName: session?.user?.name || "",
comment: "",
attachments: [],
})
@@ -257,13 +299,15 @@ export function RevisionUploadDialog({
<Badge variant={projectType === "ship" ? "default" : "secondary"}>
{projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"}
</Badge>
- {/* ✅ 현재 사용자 정보 표시 */}
+ {/* ✅ 타겟 시스템 표시 추가 */}
+ <Badge variant="outline" className="text-xs">
+ → {targetSystem}
+ </Badge>
{session?.user?.name && (
<Badge variant="outline" className="text-xs">
업로더: {session.user.name}
</Badge>
)}
- {/* ✅ 모드에 따른 정보 표시 */}
{mode === 'append' && presetRevision && (
<Badge variant="outline" className="text-xs">
리비전 {presetRevision}에 파일 추가
@@ -320,7 +364,6 @@ export function RevisionUploadDialog({
/>
</FormControl>
<FormMessage />
- {/* ✅ 모드에 따른 도움말 표시 */}
{mode === 'new' && presetRevision && (
<p className="text-xs text-gray-500">
자동으로 계산된 다음 리비전입니다.
@@ -346,7 +389,7 @@ export function RevisionUploadDialog({
<Input
{...field}
placeholder="업로더 이름을 입력하세요"
- className="bg-gray-50" // ✅ session 값이므로 읽기 전용 느낌으로 스타일링
+ className="bg-gray-50"
/>
</FormControl>
<FormMessage />
@@ -420,7 +463,7 @@ export function RevisionUploadDialog({
<FileListIcon />
<FileListInfo>
<FileListName>{file.name}</FileListName>
- <FileListSize>{prettyBytes(file.size)}</FileListSize>
+ <FileListSize>{file.size}</FileListSize>
</FileListInfo>
<FileListAction
onClick={() => removeFile(index)}
diff --git a/lib/vendor-document-list/table/send-to-shi-button.tsx b/lib/vendor-document-list/table/send-to-shi-button.tsx
index e0360144..1a27a794 100644
--- a/lib/vendor-document-list/table/send-to-shi-button.tsx
+++ b/lib/vendor-document-list/table/send-to-shi-button.tsx
@@ -28,22 +28,26 @@ interface SendToSHIButtonProps {
contractId: number
documents?: EnhancedDocument[]
onSyncComplete?: () => void
+ projectType: "ship" | "plant"
}
export function SendToSHIButton({
contractId,
documents = [],
- onSyncComplete
+ onSyncComplete,
+ projectType
}: SendToSHIButtonProps) {
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const [syncProgress, setSyncProgress] = React.useState(0)
+
+ const targetSystem = projectType === 'ship'?"DOLCE":"SWP"
const {
syncStatus,
isLoading: statusLoading,
error: statusError,
refetch: refetchStatus
- } = useSyncStatus(contractId, 'SHI')
+ } = useSyncStatus(contractId, targetSystem)
const {
triggerSync,
@@ -71,7 +75,7 @@ export function SendToSHIButton({
const result = await triggerSync({
contractId,
- targetSystem: 'SHI'
+ targetSystem
})
clearInterval(progressInterval)
@@ -152,10 +156,11 @@ export function SendToSHIButton({
<>
<Popover>
<PopoverTrigger asChild>
+ <div className="flex items-center gap-3">
<Button
variant="default"
size="sm"
- className="gap-2 relative bg-blue-600 hover:bg-blue-700"
+ className="flex items-center bg-blue-600 hover:bg-blue-700"
disabled={isSyncing || statusLoading}
>
{isSyncing ? (
@@ -167,12 +172,13 @@ export function SendToSHIButton({
{syncStatus?.pendingChanges > 0 && (
<Badge
variant="destructive"
- className="absolute -top-2 -right-2 h-5 w-5 p-0 text-xs flex items-center justify-center"
+ className="h-5 w-5 p-0 text-xs flex items-center justify-center"
>
{syncStatus.pendingChanges}
</Badge>
)}
</Button>
+ </div>
</PopoverTrigger>
<PopoverContent className="w-80">
diff --git a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx
index c2395aa8..d9d53cc9 100644
--- a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx
+++ b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx
@@ -66,6 +66,7 @@ const getStatusText = (status: string) => {
case 'PLANNED': return '계획됨'
case 'IN_PROGRESS': return '진행중'
case 'SUBMITTED': return '제출됨'
+ case 'UPLOADED': return '등록됨'
case 'UNDER_REVIEW': return '검토중'
case 'APPROVED': return '승인됨'
case 'REJECTED': return '반려됨'
@@ -330,6 +331,8 @@ export const StageRevisionExpandedContent = ({
// 뷰에서 가져온 allStages 데이터를 바로 사용
const stagesWithRevisions = documentData.allStages || []
+ console.log(stagesWithRevisions)
+
if (stagesWithRevisions.length === 0) {
return (
<div className="p-6 text-center text-gray-500">
@@ -485,6 +488,7 @@ export const StageRevisionExpandedContent = ({
<TableHead className="w-16 py-1 px-2 text-xs">리비전</TableHead>
<TableHead className="w-20 py-1 px-2 text-xs">상태</TableHead>
<TableHead className="w-24 py-1 px-2 text-xs">업로더</TableHead>
+ <TableHead className="w-32 py-1 px-2 text-xs">등록일</TableHead>
<TableHead className="w-32 py-1 px-2 text-xs">제출일</TableHead>
<TableHead className="w-32 py-1 px-2 text-xs">승인/반려일</TableHead>
<TableHead className="min-w-[120px] py-1 px-2 text-xs">첨부파일</TableHead>
@@ -525,11 +529,17 @@ export const StageRevisionExpandedContent = ({
<span className="text-xs truncate max-w-[60px]">{revision.uploaderName || '-'}</span>
</div>
</TableCell>
+ {/* 제출일 */}
+ <TableCell className="py-1 px-2">
+ <span className="text-xs text-gray-600">
+ {revision.uploadedAt ? formatDate(revision.uploadedAt) : '-'}
+ </span>
+ </TableCell>
{/* 제출일 */}
<TableCell className="py-1 px-2">
<span className="text-xs text-gray-600">
- {revision.submittedDate ? formatDate(revision.submittedDate) : '-'}
+ {revision.externalSentDate ? formatDate(revision.externalSentDate) : '-'}
</span>
</TableCell>
diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts
index d81e703c..a0ae6f76 100644
--- a/lib/vendor-document/service.ts
+++ b/lib/vendor-document/service.ts
@@ -198,6 +198,12 @@ export async function createRevisionAction(formData: FormData) {
const uploaderName = formData.get("uploaderName") as string | null
const comment = formData.get("comment") as string | null
+ // 추가 필드들 (옵션)
+ const uploaderId = formData.get("uploaderId") as string | null
+ const reviewerId = formData.get("reviewerId") as string | null
+ const reviewerName = formData.get("reviewerName") as string | null
+ const reviewComments = formData.get("reviewComments") as string | null
+
if (!docId || Number.isNaN(docId)) {
throw new Error("Invalid or missing documentId")
}
@@ -244,10 +250,14 @@ export async function createRevisionAction(formData: FormData) {
.where(and(eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision)))
.limit(1)
- // 기본 상태값 설정
- let status = 'submitted';
- if (uploaderType === 'client') status = 'reviewed';
- if (uploaderType === 'shi') status = 'official';
+ // 기본 상태값 설정 (새로운 상태값 사용)
+ let revisionStatus = 'SUBMITTED';
+ if (uploaderType === 'client') revisionStatus = 'UNDER_REVIEW';
+ if (uploaderType === 'shi') revisionStatus = 'APPROVED';
+
+ // 현재 날짜
+ const now = new Date();
+ const today = now.toISOString().split('T')[0]; // YYYY-MM-DD 형식
if (!revisionRecord.length) {
// Revision이 없으면 새로 생성
@@ -257,29 +267,72 @@ export async function createRevisionAction(formData: FormData) {
issueStageId,
revision,
uploaderType,
+ uploaderId: uploaderId ? parseInt(uploaderId, 10) : undefined,
uploaderName: uploaderName || undefined,
+ revisionStatus,
+ uploadedAt: today,
+ // 상태에 따른 날짜 설정
+ reviewStartDate: revisionStatus === 'UNDER_REVIEW' ? today : undefined,
+ approvedDate: revisionStatus === 'APPROVED' ? today : undefined,
+ // 검토자 정보
+ reviewerId: reviewerId ? parseInt(reviewerId, 10) : undefined,
+ reviewerName: reviewerName || undefined,
+ reviewComments: reviewComments || undefined,
comment: comment || undefined,
- status,
- updatedAt: new Date(),
+ updatedAt: now,
})
.returning()
revisionId = newRevision.id
} else {
- // 이미 존재하는 경우, 업로더 타입이 다르면 업데이트
- if (revisionRecord[0].uploaderType !== uploaderType) {
+ // 이미 존재하는 경우, 업로더 타입이 다르거나 다른 정보가 변경되면 업데이트
+ const existingRevision = revisionRecord[0];
+ const needsUpdate =
+ existingRevision.uploaderType !== uploaderType ||
+ existingRevision.uploaderName !== uploaderName ||
+ existingRevision.comment !== comment;
+
+ if (needsUpdate) {
+ // 상태 변경에 따른 날짜 업데이트 로직
+ const updateValues: any = {
+ uploaderType,
+ uploaderId: uploaderId ? parseInt(uploaderId, 10) : undefined,
+ uploaderName: uploaderName || undefined,
+ revisionStatus,
+ reviewerId: reviewerId ? parseInt(reviewerId, 10) : undefined,
+ reviewerName: reviewerName || undefined,
+ reviewComments: reviewComments || undefined,
+ comment: comment || undefined,
+ updatedAt: now,
+ };
+
+ // 상태가 변경된 경우 해당 날짜 필드 업데이트
+ if (existingRevision.revisionStatus !== revisionStatus) {
+ switch (revisionStatus) {
+ case 'UNDER_REVIEW':
+ if (!existingRevision.reviewStartDate) {
+ updateValues.reviewStartDate = today;
+ }
+ break;
+ case 'APPROVED':
+ if (!existingRevision.approvedDate) {
+ updateValues.approvedDate = today;
+ }
+ break;
+ case 'REJECTED':
+ if (!existingRevision.rejectedDate) {
+ updateValues.rejectedDate = today;
+ }
+ break;
+ }
+ }
+
await tx
.update(revisions)
- .set({
- uploaderType,
- uploaderName: uploaderName || undefined,
- comment: comment || undefined,
- status,
- updatedAt: new Date(),
- })
- .where(eq(revisions.id, revisionRecord[0].id))
+ .set(updateValues)
+ .where(eq(revisions.id, existingRevision.id))
}
- revisionId = revisionRecord[0].id
+ revisionId = existingRevision.id
}
// (3) 파일 처리
diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx
index 40b849fc..d5dc05ac 100644
--- a/lib/vendor-investigation/table/investigation-table.tsx
+++ b/lib/vendor-investigation/table/investigation-table.tsx
@@ -44,7 +44,6 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
})
}, [rawResponse.data])
- console.log(transformedData)
const pageCount = rawResponse.pageCount
diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx
index 69f0d9ae..29f0fa92 100644
--- a/lib/vendor-investigation/table/update-investigation-sheet.tsx
+++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx
@@ -3,7 +3,7 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
-import { CalendarIcon, Loader } from "lucide-react"
+import { CalendarIcon, Loader, X } from "lucide-react"
import { format } from "date-fns"
import { toast } from "sonner"
@@ -49,6 +49,16 @@ import {
DropzoneDescription,
DropzoneInput
} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list"
import {
updateVendorInvestigationSchema,
@@ -56,6 +66,7 @@ import {
} from "../validations"
import { updateVendorInvestigationAction, getInvestigationAttachments, deleteInvestigationAttachment } from "../service"
import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
+import prettyBytes from "pretty-bytes"
interface UpdateVendorInvestigationSheetProps
extends React.ComponentPropsWithoutRef<typeof Sheet> {
@@ -70,7 +81,7 @@ const getFileUploadConfig = (status: string) => {
enabled: false,
label: "",
description: "",
- accept: undefined, // undefined로 변경
+ accept: undefined,
maxSize: 0,
maxSizeText: ""
}
@@ -122,7 +133,7 @@ export function UpdateVendorInvestigationSheet({
evaluationScore: investigation?.evaluationScore ?? undefined,
evaluationResult: investigation?.evaluationResult ?? undefined,
investigationNotes: investigation?.investigationNotes ?? "",
- attachments: undefined, // 파일은 매번 새로 업로드
+ attachments: undefined,
},
})
@@ -142,9 +153,9 @@ export function UpdateVendorInvestigationSheet({
evaluationScore: investigation.evaluationScore ?? undefined,
evaluationResult: investigation.evaluationResult ?? undefined,
investigationNotes: investigation.investigationNotes ?? "",
- attachments: undefined, // 파일은 매번 새로 업로드
+ attachments: undefined,
})
-
+
// 기존 첨부파일 로드
loadExistingAttachments(investigation.investigationId)
}
@@ -171,32 +182,46 @@ export function UpdateVendorInvestigationSheet({
// 첨부파일 삭제 함수
const handleDeleteAttachment = async (attachmentId: number) => {
if (!investigation) return
-
+
try {
const response = await fetch(`/api/vendor-investigations/${investigation.investigationId}/attachments?attachmentId=${attachmentId}`, {
method: "DELETE",
})
-
+
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "첨부파일 삭제 실패")
}
-
+
toast.success("첨부파일이 삭제되었습니다.")
// 목록 새로고침
loadExistingAttachments(investigation.investigationId)
-
+
} catch (error) {
console.error("첨부파일 삭제 오류:", error)
toast.error(error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.")
}
}
+ // 선택된 파일에서 특정 파일 제거
+ const handleRemoveSelectedFile = (indexToRemove: number) => {
+ const currentFiles = form.getValues("attachments") || []
+ const updatedFiles = currentFiles.filter((_, index) => index !== indexToRemove)
+ form.setValue("attachments", updatedFiles.length > 0 ? updatedFiles : undefined)
+
+ if (updatedFiles.length === 0) {
+ toast.success("모든 선택된 파일이 제거되었습니다.")
+ } else {
+ toast.success("파일이 제거되었습니다.")
+ }
+ }
+
// 파일 업로드 섹션 렌더링
const renderFileUploadSection = () => {
const currentStatus = form.watch("investigationStatus")
+ const selectedFiles = form.watch("attachments") as File[] | undefined
const config = getFileUploadConfig(currentStatus)
-
+
if (!config.enabled) return null
return (
@@ -269,10 +294,13 @@ export function UpdateVendorInvestigationSheet({
}
})
}
-
+
if (acceptedFiles.length > 0) {
- onChange(acceptedFiles)
- toast.success(`${acceptedFiles.length}개 파일이 선택되었습니다.`)
+ // 기존 파일들과 새로 선택된 파일들을 합치기
+ const currentFiles = form.getValues("attachments") || []
+ const newFiles = [...currentFiles, ...acceptedFiles]
+ onChange(newFiles)
+ toast.success(`${acceptedFiles.length}개 파일이 추가되었습니다.`)
}
}}
accept={config.accept}
@@ -283,8 +311,8 @@ export function UpdateVendorInvestigationSheet({
<DropzoneZone>
<DropzoneUploadIcon />
<DropzoneTitle>
- {isPending || uploadingFiles
- ? "파일 업로드 중..."
+ {isPending || uploadingFiles
+ ? "파일 업로드 중..."
: "파일을 드래그하거나 클릭하여 업로드"
}
</DropzoneTitle>
@@ -299,6 +327,50 @@ export function UpdateVendorInvestigationSheet({
</FormItem>
)}
/>
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles && selectedFiles.length > 0 && (
+ <div className="space-y-2">
+ {/* <FormLabel>선택된 파일 ({selectedFiles.length}개)</FormLabel> */}
+ <FileList>
+ <FileListHeader>
+ <span className="text-sm font-medium">업로드 예정 파일 ({selectedFiles.length}개)</span>
+ </FileListHeader>
+ {selectedFiles.map((file, index) => (
+ <FileListItem
+ key={`${file.name}-${index}`}
+ className="flex items-center justify-between gap-2 px-2 py-2"
+ >
+ {/* 왼쪽 아이콘 */}
+ <FileListIcon className="shrink-0 h-4 w-4 text-muted-foreground" />
+
+ {/* 가운데 이름 + 사이즈 */}
+ <FileListInfo className="flex-1 min-w-0">
+ <FileListName className="truncate">{file.name}</FileListName>
+ <FileListSize className="text-xs text-muted-foreground shrink-0">
+ {file.size}
+ </FileListSize>
+ </FileListInfo>
+
+ {/* 오른쪽 삭제 버튼 */}
+ <FileListAction className="shrink-0">
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => handleRemoveSelectedFile(index)}
+ disabled={isPending || uploadingFiles}
+ className="h-5 w-5 text-destructive hover:text-destructive"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </FileListAction>
+ </FileListItem>
+
+ ))}
+ </FileList>
+ </div>
+ )}
</>
)
}
@@ -308,80 +380,87 @@ export function UpdateVendorInvestigationSheet({
const uploadPromises = files.map(async (file) => {
const formData = new FormData()
formData.append("file", file)
-
+
const response = await fetch(`/api/vendor-investigations/${investigationId}/attachments`, {
method: "POST",
body: formData,
})
-
+
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "파일 업로드 실패")
}
-
+
return await response.json()
})
-
+
return await Promise.all(uploadPromises)
}
// Submit handler
async function onSubmit(values: UpdateVendorInvestigationSchema) {
- if (!values.investigationId) return
+ console.log("onSubmit 호출됨:", values)
+
+ if (!values.investigationId) {
+ console.log("investigationId가 없음:", values.investigationId)
+ return
+ }
startTransition(async () => {
try {
+ console.log("startTransition 시작")
+
// 1) 먼저 텍스트 데이터 업데이트
const formData = new FormData()
// 필수 필드
formData.append("investigationId", String(values.investigationId))
formData.append("investigationStatus", values.investigationStatus)
-
+
// 선택적 필드들
if (values.evaluationType) {
formData.append("evaluationType", values.evaluationType)
}
-
+
if (values.investigationAddress) {
formData.append("investigationAddress", values.investigationAddress)
}
-
+
if (values.investigationMethod) {
formData.append("investigationMethod", values.investigationMethod)
}
-
+
if (values.forecastedAt) {
formData.append("forecastedAt", values.forecastedAt.toISOString())
}
-
+
if (values.requestedAt) {
formData.append("requestedAt", values.requestedAt.toISOString())
}
-
+
if (values.confirmedAt) {
formData.append("confirmedAt", values.confirmedAt.toISOString())
}
-
+
if (values.completedAt) {
formData.append("completedAt", values.completedAt.toISOString())
}
-
+
if (values.evaluationScore !== undefined) {
formData.append("evaluationScore", String(values.evaluationScore))
}
-
+
if (values.evaluationResult) {
formData.append("evaluationResult", values.evaluationResult)
}
-
+
if (values.investigationNotes) {
formData.append("investigationNotes", values.investigationNotes)
}
// 텍스트 데이터 업데이트
const { error } = await updateVendorInvestigationAction(formData)
-
+
if (error) {
toast.error(error)
return
@@ -390,14 +469,15 @@ export function UpdateVendorInvestigationSheet({
// 2) 파일이 있으면 업로드
if (values.attachments && values.attachments.length > 0) {
setUploadingFiles(true)
-
+
try {
await uploadFiles(values.attachments, values.investigationId)
toast.success(`실사 정보와 ${values.attachments.length}개 파일이 업데이트되었습니다!`)
-
+
// 첨부파일 목록 새로고침
loadExistingAttachments(values.investigationId)
} catch (fileError) {
+ console.error("파일 업로드 에러:", fileError)
toast.error(`데이터는 저장되었지만 파일 업로드 중 오류가 발생했습니다: ${fileError}`)
} finally {
setUploadingFiles(false)
@@ -408,7 +488,7 @@ export function UpdateVendorInvestigationSheet({
form.reset()
props.onOpenChange?.(false)
-
+
} catch (error) {
console.error("실사 정보 업데이트 오류:", error)
toast.error("실사 정보 업데이트 중 오류가 발생했습니다.")
@@ -416,9 +496,26 @@ export function UpdateVendorInvestigationSheet({
})
}
+ // 디버깅을 위한 버튼 클릭 핸들러
+ const handleSaveClick = async () => {
+ console.log("저장 버튼 클릭됨")
+ console.log("현재 폼 값:", form.getValues())
+ console.log("폼 에러:", form.formState.errors)
+
+ // 폼 검증 실행
+ const isValid = await form.trigger()
+ console.log("폼 검증 결과:", isValid)
+
+ if (isValid) {
+ form.handleSubmit(onSubmit)()
+ } else {
+ console.log("폼 검증 실패, 에러:", form.formState.errors)
+ }
+ }
+
return (
<Sheet {...props}>
- <SheetContent className="flex flex-col h-full sm:max-w-md">
+ <SheetContent className="flex flex-col h-full sm:max-w-xl" >
<SheetHeader className="text-left flex-shrink-0">
<SheetTitle>실사 업데이트</SheetTitle>
<SheetDescription>
@@ -433,250 +530,51 @@ export function UpdateVendorInvestigationSheet({
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
+ id="update-investigation-form"
>
- {/* 실사 상태 */}
- <FormField
- control={form.control}
- name="investigationStatus"
- render={({ field }) => (
- <FormItem>
- <FormLabel>실사 상태</FormLabel>
- <FormControl>
- <Select value={field.value} onValueChange={field.onChange}>
- <SelectTrigger>
- <SelectValue placeholder="상태를 선택하세요" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="PLANNED">계획됨</SelectItem>
- <SelectItem value="IN_PROGRESS">진행 중</SelectItem>
- <SelectItem value="COMPLETED">완료됨</SelectItem>
- <SelectItem value="CANCELED">취소됨</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 평가 유형 */}
- <FormField
- control={form.control}
- name="evaluationType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>평가 유형</FormLabel>
- <FormControl>
- <Select value={field.value || ""} onValueChange={field.onChange}>
- <SelectTrigger>
- <SelectValue placeholder="평가 유형을 선택하세요" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- <SelectItem value="SITE_AUDIT">실사의뢰평가</SelectItem>
- <SelectItem value="QM_SELF_AUDIT">QM자체평가</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 실사 주소 */}
- <FormField
- control={form.control}
- name="investigationAddress"
- render={({ field }) => (
- <FormItem>
- <FormLabel>실사 주소</FormLabel>
- <FormControl>
- <Textarea
- placeholder="실사가 진행될 주소를 입력하세요..."
- {...field}
- className="min-h-[60px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 실사 방법 */}
- <FormField
- control={form.control}
- name="investigationMethod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>실사 방법</FormLabel>
- <FormControl>
- <Input placeholder="실사 방법을 입력하세요..." {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 실사 예정일 */}
- <FormField
- control={form.control}
- name="forecastedAt"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>실사 예정일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일")
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 실사 확정일 */}
- <FormField
- control={form.control}
- name="confirmedAt"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>실사 확정일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일")
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 실제 실사일 */}
- <FormField
- control={form.control}
- name="completedAt"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>실제 실사일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일")
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 평가 점수 - 완료된 상태일 때만 표시 */}
- {form.watch("investigationStatus") === "COMPLETED" && (
+ {/* 실사 상태 */}
<FormField
control={form.control}
- name="evaluationScore"
+ name="investigationStatus"
render={({ field }) => (
<FormItem>
- <FormLabel>평가 점수</FormLabel>
+ <FormLabel>실사 상태</FormLabel>
<FormControl>
- <Input
- type="number"
- min={0}
- max={100}
- placeholder="0-100점"
- {...field}
- value={field.value || ""}
- onChange={(e) => {
- const value = e.target.value === "" ? undefined : parseInt(e.target.value, 10)
- field.onChange(value)
- }}
- />
+ <Select value={field.value} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="상태를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="PLANNED">계획됨</SelectItem>
+ <SelectItem value="IN_PROGRESS">진행 중</SelectItem>
+ <SelectItem value="COMPLETED">완료됨</SelectItem>
+ <SelectItem value="CANCELED">취소됨</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
- )}
- {/* 평가 결과 - 완료된 상태일 때만 표시 */}
- {form.watch("investigationStatus") === "COMPLETED" && (
+ {/* 평가 유형 */}
<FormField
control={form.control}
- name="evaluationResult"
+ name="evaluationType"
render={({ field }) => (
<FormItem>
- <FormLabel>평가 결과</FormLabel>
+ <FormLabel>평가 유형</FormLabel>
<FormControl>
<Select value={field.value || ""} onValueChange={field.onChange}>
<SelectTrigger>
- <SelectValue placeholder="평가 결과를 선택하세요" />
+ <SelectValue placeholder="평가 유형을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
- <SelectItem value="APPROVED">승인</SelectItem>
- <SelectItem value="SUPPLEMENT">보완</SelectItem>
- <SelectItem value="REJECTED">불가</SelectItem>
+ <SelectItem value="SITE_AUDIT">실사의뢰평가</SelectItem>
+ <SelectItem value="QM_SELF_AUDIT">QM자체평가</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
@@ -685,29 +583,229 @@ export function UpdateVendorInvestigationSheet({
</FormItem>
)}
/>
- )}
- {/* QM 의견 */}
- <FormField
- control={form.control}
- name="investigationNotes"
- render={({ field }) => (
- <FormItem>
- <FormLabel>QM 의견</FormLabel>
- <FormControl>
- <Textarea
- placeholder="실사에 대한 QM 의견을 입력하세요..."
- {...field}
- className="min-h-[80px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
+ {/* 실사 주소 */}
+ <FormField
+ control={form.control}
+ name="investigationAddress"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 주소</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사가 진행될 주소를 입력하세요..."
+ {...field}
+ className="min-h-[60px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사 방법 */}
+ <FormField
+ control={form.control}
+ name="investigationMethod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사 방법</FormLabel>
+ <FormControl>
+ <Input placeholder="실사 방법을 입력하세요..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사 예정일 */}
+ <FormField
+ control={form.control}
+ name="forecastedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 예정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사 확정일 */}
+ <FormField
+ control={form.control}
+ name="confirmedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 확정일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실제 실사일 */}
+ <FormField
+ control={form.control}
+ name="completedAt"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>실제 실사일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가 점수 - 완료된 상태일 때만 표시 */}
+ {form.watch("investigationStatus") === "COMPLETED" && (
+ <FormField
+ control={form.control}
+ name="evaluationScore"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 점수</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min={0}
+ max={100}
+ placeholder="0-100점"
+ {...field}
+ value={field.value || ""}
+ onChange={(e) => {
+ const value = e.target.value === "" ? undefined : parseInt(e.target.value, 10)
+ field.onChange(value)
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 평가 결과 - 완료된 상태일 때만 표시 */}
+ {form.watch("investigationStatus") === "COMPLETED" && (
+ <FormField
+ control={form.control}
+ name="evaluationResult"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가 결과</FormLabel>
+ <FormControl>
+ <Select value={field.value || ""} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="평가 결과를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="APPROVED">승인</SelectItem>
+ <SelectItem value="SUPPLEMENT">보완</SelectItem>
+ <SelectItem value="REJECTED">불가</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
)}
- />
- {/* 파일 첨부 섹션 */}
- {renderFileUploadSection()}
+ {/* QM 의견 */}
+ <FormField
+ control={form.control}
+ name="investigationNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>QM 의견</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="실사에 대한 QM 의견을 입력하세요..."
+ {...field}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 첨부 섹션 */}
+ {renderFileUploadSection()}
</form>
</Form>
</div>
@@ -719,9 +817,9 @@ export function UpdateVendorInvestigationSheet({
취소
</Button>
</SheetClose>
- <Button
+ <Button
disabled={isPending || uploadingFiles}
- onClick={form.handleSubmit(onSubmit)}
+ onClick={handleSaveClick}
>
{(isPending || uploadingFiles) && (
<Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts
index bfe2e988..d04f100f 100644
--- a/lib/vendor-investigation/validations.ts
+++ b/lib/vendor-investigation/validations.ts
@@ -70,10 +70,28 @@ export const updateVendorInvestigationSchema = z.object({
evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"]).optional(),
investigationAddress: z.string().optional(),
investigationMethod: z.string().max(100, "실사 방법은 100자 이내로 입력해주세요.").optional(),
- forecastedAt: z.date().optional(),
- requestedAt: z.date().optional(),
- confirmedAt: z.date().optional(),
- completedAt: z.date().optional(),
+
+ // 날짜 필드들을 string에서 Date로 변환하도록 수정
+ forecastedAt: z.union([
+ z.date(),
+ z.string().transform((str) => str ? new Date(str) : undefined)
+ ]).optional(),
+
+ requestedAt: z.union([
+ z.date(),
+ z.string().transform((str) => str ? new Date(str) : undefined)
+ ]).optional(),
+
+ confirmedAt: z.union([
+ z.date(),
+ z.string().transform((str) => str ? new Date(str) : undefined)
+ ]).optional(),
+
+ completedAt: z.union([
+ z.date(),
+ z.string().transform((str) => str ? new Date(str) : undefined)
+ ]).optional(),
+
evaluationScore: z.number()
.int("평가 점수는 정수여야 합니다.")
.min(0, "평가 점수는 0점 이상이어야 합니다.")
@@ -84,4 +102,4 @@ export const updateVendorInvestigationSchema = z.object({
attachments: z.any().optional(), // File 업로드를 위한 필드
})
-export type UpdateVendorInvestigationSchema = z.infer<typeof updateVendorInvestigationSchema>
+export type UpdateVendorInvestigationSchema = z.infer<typeof updateVendorInvestigationSchema> \ No newline at end of file