diff options
19 files changed, 1155 insertions, 263 deletions
diff --git a/app/api/attachment-delete/route.ts b/app/api/attachment-delete/route.ts index 254c579f..cfaba61c 100644 --- a/app/api/attachment-delete/route.ts +++ b/app/api/attachment-delete/route.ts @@ -1,14 +1,23 @@ // /api/attachment-delete/route.ts import { NextRequest, NextResponse } from 'next/server' -import db from '@/db/db' -import { documentAttachments } from '@/db/schema' // 실제 스키마에 맞게 수정 +import db from '@/db/db' +import { documentAttachments, changeLogs } from '@/db/schema' import { eq, and } from 'drizzle-orm' -import fs from 'fs/promises' -import path from 'path' +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; export async function DELETE(request: NextRequest) { try { + + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { error: '인증이 필요합니다' }, + { status: 401 } + ); + } + const { attachmentId, revisionId } = await request.json() if (!attachmentId || !revisionId) { @@ -47,19 +56,32 @@ export async function DELETE(request: NextRequest) { ) } - // 4. 데이터베이스에서 첨부파일 레코드 삭제 - await db - .delete(documentAttachments) - .where( - and( - eq(documentAttachments.id, attachmentId), - eq(documentAttachments.revisionId, revisionId) + // 3. 트랜잭션으로 첨부파일과 changeLogs 함께 삭제 + await db.transaction(async (tx) => { + // 3-1. changeLogs에서 해당 attachment 관련 로그 삭제 + await tx + .delete(changeLogs) + .where( + and( + eq(changeLogs.entityType, 'attachment'), + eq(changeLogs.entityId, attachmentId) + ) ) - ) + + // 3-2. 첨부파일 레코드 삭제 + await tx + .delete(documentAttachments) + .where( + and( + eq(documentAttachments.id, attachmentId), + eq(documentAttachments.revisionId, revisionId) + ) + ) + }) return NextResponse.json({ success: true, - message: 'Attachment deleted successfully', + message: 'Attachment and related logs deleted successfully', deletedAttachmentId: attachmentId }) diff --git a/app/api/revision-attachment/route.ts b/app/api/revision-attachment/route.ts index 46c2e9c9..3e72fec5 100644 --- a/app/api/revision-attachment/route.ts +++ b/app/api/revision-attachment/route.ts @@ -17,9 +17,20 @@ import { saveFile, SaveFileResult, saveFileStream } from "@/lib/file-stroage" import { logAttachmentChange, } from "@/lib/vendor-document-list/sync-service" +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; + export async function POST(request: NextRequest) { try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { error: '인증이 필요합니다' }, + { status: 401 } + ); + } + const formData = await request.formData() /* ------- 파라미터 파싱 ------- */ @@ -124,8 +135,8 @@ export async function POST(request: NextRequest) { "CREATE", att, undefined, - undefined, - uploaderName ?? undefined, + Number(session.user.id), + session.user.name, [targetSystem] ) } diff --git a/app/api/revision-upload-ship/route.ts b/app/api/revision-upload-ship/route.ts index b07a3d9c..ccfa2e59 100644 --- a/app/api/revision-upload-ship/route.ts +++ b/app/api/revision-upload-ship/route.ts @@ -32,6 +32,8 @@ export async function POST(request: NextRequest) { const comment = formData.get("comment") as string | null const targetSystem = "DOLCE" const attachmentFiles = formData.getAll("attachments") as File[] + // const issueStageId = formData.get("issueStageId") as string + const serialNo = formData.get("serialNo") as string /* ------- 검증 ------- */ if (!docId || Number.isNaN(docId)) @@ -173,6 +175,7 @@ export async function POST(request: NextRequest) { const [newRev] = await tx.insert(revisions) .values({ issueStageId, + serialNo: serialNo, revision, usage, usageType, @@ -303,6 +306,7 @@ export async function POST(request: NextRequest) { data: { revisionId: result.revisionId, issueStageId: result.issueStageId, + serialNo: serialNo, stage: result.stage, revision: result.revision, usage: result.usage, diff --git a/app/api/revision-upload/route.ts b/app/api/revision-upload/route.ts index 0f67def6..6517cd08 100644 --- a/app/api/revision-upload/route.ts +++ b/app/api/revision-upload/route.ts @@ -18,9 +18,22 @@ import { logRevisionChange, logAttachmentChange, } from "@/lib/vendor-document-list/sync-service" +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; + export async function POST(request: NextRequest) { try { + + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { error: '인증이 필요합니다' }, + { status: 401 } + ); + } + + const formData = await request.formData() /* ------- 파라미터 파싱 ------- */ @@ -136,8 +149,8 @@ export async function POST(request: NextRequest) { "CREATE", newRev, undefined, - undefined, - uploaderName ?? undefined, + Number(session.user.id), + session.user.name, [targetSystem] ) } else { @@ -169,8 +182,8 @@ export async function POST(request: NextRequest) { "UPDATE", updated, revRow, - undefined, - uploaderName ?? undefined, + Number(session.user.id), + session.user.name, [targetSystem] ) } @@ -227,8 +240,8 @@ export async function POST(request: NextRequest) { "CREATE", att, undefined, - undefined, - uploaderName ?? undefined, + Number(session.user.id), + session.user.name, [targetSystem] ) } diff --git a/app/api/revisions/max-serial-no/route.ts b/app/api/revisions/max-serial-no/route.ts new file mode 100644 index 00000000..b202956a --- /dev/null +++ b/app/api/revisions/max-serial-no/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server' +import db from '@/db/db' +import { revisions, issueStages } from '@/db/schema' +import { eq, and, sql, desc } from 'drizzle-orm' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const documentId = searchParams.get('documentId') + + if (!documentId) { + return NextResponse.json( + { error: 'documentId is required' }, + { status: 400 } + ) + } + + // 해당 document의 모든 issueStages와 연결된 revisions에서 최대 serialNo 조회 + const maxSerialResult = await db + .select({ + maxSerialNo: sql<number>` + GREATEST( + COALESCE(MAX(CAST(r.serial_no AS INTEGER)), 0), + COALESCE(MAX(CAST(r.register_serial_no_max AS INTEGER)), 0) + ) + `.as('max_serial_no') + }) + .from(revisions.as('r')) + .innerJoin( + issueStages.as('is'), + eq(revisions.issueStageId, issueStages.id) + ) + .where(eq(issueStages.documentId, parseInt(documentId))) + + const maxSerialNo = maxSerialResult[0]?.maxSerialNo || 0 + + return NextResponse.json({ + maxSerialNo, + nextSerialNo: maxSerialNo + 1, + documentId: documentId + }) + } catch (error) { + console.error('Error fetching max serial no:', error) + return NextResponse.json( + { error: 'Failed to fetch max serial number' }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/sync/batches/route.ts b/app/api/sync/batches/route.ts index 1f37d6e6..66a0ab90 100644 --- a/app/api/sync/batches/route.ts +++ b/app/api/sync/batches/route.ts @@ -1,3 +1,5 @@ +//api/sync/batches/route.ts + import { syncService } from "@/lib/vendor-document-list/sync-service" import { NextRequest, NextResponse } from "next/server" diff --git a/app/api/sync/status/route.ts b/app/api/sync/status/route.ts index 05101d2b..71a077ac 100644 --- a/app/api/sync/status/route.ts +++ b/app/api/sync/status/route.ts @@ -1,5 +1,9 @@ +// app/api/sync/status/route.ts + import { syncService } from "@/lib/vendor-document-list/sync-service" import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" // JSON 직렬화 가능한 형태로 변환하는 헬퍼 함수 function serializeForJSON(obj: any): any { @@ -32,13 +36,20 @@ function serializeForJSON(obj: any): any { export async function GET(request: NextRequest) { try { + + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { searchParams } = new URL(request.url) const projectId = searchParams.get('projectId') - const targetSystem = searchParams.get('targetSystem') || 'SHI' + const targetSystem = searchParams.get('targetSystem') || 'DOLCE' // 기본값 DOLCE로 변경 + const realtime = searchParams.get('realtime') === 'true' if (!projectId) { return NextResponse.json( - { error: 'project ID is required' }, + { error: 'Project ID is required' }, { status: 400 } ) } @@ -46,36 +57,96 @@ export async function GET(request: NextRequest) { let status try { - // 실제 데이터베이스에서 조회 시도 + // 실제 데이터베이스에서 조회 status = await syncService.getSyncStatus( parseInt(projectId), targetSystem ) + } catch (error) { - console.log('Database query failed, using mock data:', error) + console.error('Database query failed:', error) - // ✅ 데이터베이스 조회 실패시 임시 목업 데이터 반환 - status = { - projectId: parseInt(projectId), - targetSystem, - totalChanges: 15, - pendingChanges: 3, // 3건 대기 중 (빨간 뱃지 표시용) - syncedChanges: 12, - failedChanges: 0, - lastSyncAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), // 30분 전 - nextSyncAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10분 후 - syncEnabled: true + // 개발 환경에서만 목업 데이터 반환 + if (process.env.NODE_ENV === 'development') { + console.log('Using mock data for development') + + status = { + projectId: parseInt(projectId), + vendorId: 1, // 임시 vendorId + targetSystem, + totalChanges: 15, + pendingChanges: 3, + syncedChanges: 12, + failedChanges: 0, + // entityType별 상세 통계 추가 + entityTypeDetails: { + document: { + pending: 1, + synced: 4, + failed: 0, + total: 5 + }, + revision: { + pending: 2, + synced: 6, + failed: 0, + total: 8 + }, + attachment: { + pending: 0, + synced: 2, + failed: 0, + total: 2 + } + }, + lastSyncAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + nextSyncAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + syncEnabled: true, + hasPendingChanges: true, + hasFailedChanges: false, + syncHealthy: true, + requiresSync:false + } + } else { + // 프로덕션에서는 에러 반환 + return NextResponse.json( + { + error: 'Failed to get sync status', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) } } // JSON 직렬화 가능한 형태로 변환 const serializedStatus = serializeForJSON(status) - return NextResponse.json(serializedStatus) + // 실시간 모드일 경우 캐시 비활성화 + if (realtime) { + return NextResponse.json(serializedStatus, { + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + }) + } + + // 일반 모드: 캐시 헤더 설정 (30초) + return NextResponse.json(serializedStatus, { + headers: { + 'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=60' + } + }) + } catch (error) { console.error('Failed to get sync status:', error) return NextResponse.json( - { error: 'Failed to get sync status' }, + { + error: 'Failed to get sync status', + message: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 } ) } diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 591ba66a..3d8b1438 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, usePathname } from "next/navigation"; import { useTranslation } from "@/i18n/client"; import { ClientDataTable } from "../client-data-table/data-table"; @@ -99,6 +99,7 @@ export default function DynamicTable({ const router = useRouter(); const lng = (params?.lng as string) || "ko"; const { t } = useTranslation(lng, "engineering"); + const pathname = usePathname(); const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null); @@ -114,6 +115,7 @@ export default function DynamicTable({ const [formStats, setFormStats] = React.useState<FormStatusByVendor | null>(null); const [isLoadingStats, setIsLoadingStats] = React.useState(true); + const isEVCPPath = pathname.includes('evcp'); React.useEffect(() => { const fetchFormStats = async () => { @@ -672,6 +674,7 @@ export default function DynamicTable({ return ( <> + {!isEVCPPath && ( <div className="mb-6"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6"> {/* Tag Count */} @@ -807,6 +810,7 @@ export default function DynamicTable({ </Card> </div> </div> + )} <ClientDataTable data={tableData} diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index 91d5672c..af1a3dca 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -197,7 +197,38 @@ export function TemplateViewDialog({ }, []); React.useEffect(() => { - if (!templateData) return; + // 템플릿 데이터가 없거나 빈 배열인 경우 기본 GRD_LIST 템플릿 생성 + if (!templateData || (Array.isArray(templateData) && templateData.length === 0)) { + // columnsJSON이 있으면 기본 GRD_LIST 템플릿 생성 + if (columnsJSON && columnsJSON.length > 0) { + const defaultGrdTemplate: TemplateItem = { + TMPL_ID: 'DEFAULT_GRD_LIST', + NAME: 'Default Grid View', + TMPL_TYPE: 'GRD_LIST', + SPR_LST_SETUP: { + ACT_SHEET: '', + HIDN_SHEETS: [], + DATA_SHEETS: [] + }, + GRD_LST_SETUP: { + REG_TYPE_ID: 'DEFAULT', + SPR_ITM_IDS: [], + ATTS: [] + }, + SPR_ITM_LST_SETUP: { + ACT_SHEET: '', + HIDN_SHEETS: [], + DATA_SHEETS: [] + } + }; + + setAvailableTemplates([defaultGrdTemplate]); + setSelectedTemplateId('DEFAULT_GRD_LIST'); + setTemplateType('GRD_LIST'); + console.log('📋 Created default GRD_LIST template'); + } + return; + } let templates: TemplateItem[]; if (Array.isArray(templateData)) { @@ -207,6 +238,34 @@ export function TemplateViewDialog({ } const validTemplates = templates.filter(isValidTemplate); + + // 유효한 템플릿이 없지만 columnsJSON이 있으면 기본 GRD_LIST 추가 + if (validTemplates.length === 0 && columnsJSON && columnsJSON.length > 0) { + const defaultGrdTemplate: TemplateItem = { + TMPL_ID: 'DEFAULT_GRD_LIST', + NAME: 'Default Grid View', + TMPL_TYPE: 'GRD_LIST', + SPR_LST_SETUP: { + ACT_SHEET: '', + HIDN_SHEETS: [], + DATA_SHEETS: [] + }, + GRD_LST_SETUP: { + REG_TYPE_ID: 'DEFAULT', + SPR_ITM_IDS: [], + ATTS: [] + }, + SPR_ITM_LST_SETUP: { + ACT_SHEET: '', + HIDN_SHEETS: [], + DATA_SHEETS: [] + } + }; + + validTemplates.push(defaultGrdTemplate); + console.log('📋 Added default GRD_LIST template to empty template list'); + } + setAvailableTemplates(validTemplates); if (validTemplates.length > 0 && !selectedTemplateId) { @@ -215,7 +274,7 @@ export function TemplateViewDialog({ setSelectedTemplateId(firstTemplate.TMPL_ID); setTemplateType(templateTypeToSet); } - }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType, columnsJSON]); const handleTemplateChange = (templateId: string) => { const template = availableTemplates.find(t => t.TMPL_ID === templateId); diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx index 1ffcf630..3ec58d1d 100644 --- a/components/ship-vendor-document/new-revision-dialog.tsx +++ b/components/ship-vendor-document/new-revision-dialog.tsx @@ -9,7 +9,8 @@ import { DialogContent, DialogDescription, DialogHeader, - DialogTitle,DialogFooter + DialogTitle, + DialogFooter } from "@/components/ui/dialog" import { Form, @@ -35,10 +36,12 @@ import { FileText, X, Loader2, - CheckCircle + CheckCircle, + Info } from "lucide-react" import { toast } from "sonner" import { useSession } from "next-auth/react" +import { Alert, AlertDescription } from "@/components/ui/alert" // 기존 메인 컴포넌트에서 추가할 import // import { NewRevisionDialog } from "./new-revision-dialog" @@ -50,12 +53,26 @@ import { useSession } from "next-auth/react" // 파일 검증 스키마 const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB +// B3 리비전 검증 함수 +const validateB3Revision = (value: string) => { + // B3 리비전 패턴: 단일 알파벳(A-Z) 또는 R01-R99 + const alphabetPattern = /^[A-Z]$/ + const numericPattern = /^R(0[1-9]|[1-9][0-9])$/ + + return alphabetPattern.test(value) || numericPattern.test(value) +} + +// B4 리비전 검증 함수 +const validateB4Revision = (value: string) => { + // B4 리비전 패턴: R01-R99 + const numericPattern = /^R(0[1-9]|[1-9][0-9])$/ + return numericPattern.test(value) +} // drawingKind에 따른 동적 스키마 생성 const createRevisionUploadSchema = (drawingKind: string) => { const baseSchema = { usage: z.string().min(1, "Please select a usage"), - revision: z.string().min(1, "Please enter a revision").max(50, "Revision must be 50 characters or less"), comment: z.string().optional(), attachments: z .array(z.instanceof(File)) @@ -63,22 +80,37 @@ const createRevisionUploadSchema = (drawingKind: string) => { .refine( (files) => files.every((file) => file.size <= MAX_FILE_SIZE), "File size must be 50MB or less" - ) - // .refine( - // (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), - // "Unsupported file format" - // ), + ), } + // B3와 B4에 따른 리비전 검증 추가 + const revisionField = drawingKind === 'B3' + ? z.string() + .min(1, "Please enter a revision") + .max(3, "Revision must be 3 characters or less") + .refine( + validateB3Revision, + "Invalid format. Use A-Z or R01-R99" + ) + : z.string() + .min(1, "Please enter a revision") + .max(3, "Revision must be 3 characters or less") + .refine( + validateB4Revision, + "Invalid format. Use R01-R99" + ) + // B3인 경우에만 usageType 필드 추가 if (drawingKind === 'B3') { return z.object({ ...baseSchema, + revision: revisionField, usageType: z.string().min(1, "Please select a usage type"), }) } else { return z.object({ ...baseSchema, + revision: revisionField, usageType: z.string().optional(), }) } @@ -118,7 +150,6 @@ const getUsageTypeOptions = (usage: string) => { return [ { value: "Full", label: "Full" }, { value: "Partial", label: "Partial" }, - ] case 'Working': return [ @@ -128,7 +159,6 @@ const getUsageTypeOptions = (usage: string) => { case 'Comments': return [ { value: "Comments", label: "Comments" }, - ] default: return [] @@ -136,8 +166,49 @@ const getUsageTypeOptions = (usage: string) => { } // 리비전 형식 가이드 생성 -const getRevisionGuide = () => { - return "Enter in R01, R02, R03... format" +const getRevisionGuide = (drawingKind: string) => { + if (drawingKind === 'B3') { + return { + placeholder: "e.g., A, B, C or R01, R02", + helpText: "Use single letter (A-Z) or R01-R99 format", + examples: [ + "A, B, C, ... Z (alphabetic revisions)", + "R01, R02, ... R99 (numeric revisions)" + ] + } + } + return { + placeholder: "e.g., R01, R02, R03", + helpText: "Enter in R01, R02, R03... format", + examples: ["R01, R02, R03, ... R99"] + } +} + +// B3 리비전 자동 포맷팅 함수 +const formatB3RevisionInput = (value: string): string => { + // 입력값을 대문자로 변환 + const upperValue = value.toUpperCase() + + // 단일 알파벳인 경우 + if (/^[A-Z]$/.test(upperValue)) { + return upperValue + } + + // R로 시작하는 경우 + if (upperValue.startsWith('R')) { + // R 뒤의 숫자 추출 + const numPart = upperValue.slice(1).replace(/\D/g, '') + if (numPart) { + const num = parseInt(numPart, 10) + // 1-99 범위 체크 + if (num >= 1 && num <= 99) { + // 01-09는 0을 붙이고, 10-99는 그대로 + return `R${num.toString().padStart(2, '0')}` + } + } + } + + return upperValue } interface NewRevisionDialogProps { @@ -146,7 +217,7 @@ interface NewRevisionDialogProps { documentId: number documentTitle?: string drawingKind: string - onSuccess?: (result?: any) => void // ✅ result 파라미터 추가 + onSuccess?: (result?: any) => void } /* ------------------------------------------------------------------------------------------------- @@ -221,7 +292,7 @@ function FileUploadArea({ </div> {files.length > 0 && ( - <div className="space-y-2 max-h-40 overflow-y-auto overscroll-contain pr-2"> + <div className="space-y-2 max-h-40 overflow-y-auto overscroll-contain pr-2"> <p className="text-sm font-medium">Selected Files ({files.length})</p> <div className="max-h-40 overflow-y-auto space-y-2"> {files.map((file, index) => ( @@ -259,6 +330,56 @@ function FileUploadArea({ } /* ------------------------------------------------------------------------------------------------- + * Revision Input Component for B3 + * -----------------------------------------------------------------------------------------------*/ +function B3RevisionInput({ + value, + onChange, + error +}: { + value: string + onChange: (value: string) => void + error?: string +}) { + const [inputValue, setInputValue] = React.useState(value) + + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const rawValue = e.target.value + const formattedValue = formatB3RevisionInput(rawValue) + + // 길이 제한 (알파벳은 1자, R숫자는 3자) + if (rawValue.length <= 3) { + setInputValue(formattedValue) + onChange(formattedValue) + } + } + + const revisionGuide = getRevisionGuide('B3') + + return ( + <div className="space-y-2"> + <Input + value={inputValue} + onChange={handleInputChange} + placeholder={revisionGuide.placeholder} + className={error ? "border-red-500" : ""} + /> + <Alert className="bg-blue-50 border-blue-200"> + <Info className="h-4 w-4 text-blue-600" /> + <AlertDescription className="text-xs space-y-1"> + <div className="font-medium text-blue-900">{revisionGuide.helpText}</div> + <div className="text-blue-700"> + {revisionGuide.examples.map((example, idx) => ( + <div key={idx}>• {example}</div> + ))} + </div> + </AlertDescription> + </Alert> + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- * Main Dialog Component * -----------------------------------------------------------------------------------------------*/ export function NewRevisionDialog({ @@ -272,6 +393,34 @@ export function NewRevisionDialog({ const [isUploading, setIsUploading] = React.useState(false) const [uploadProgress, setUploadProgress] = React.useState(0) const { data: session } = useSession() + const [nextSerialNo, setNextSerialNo] = React.useState<string>("1") + const [isLoadingSerialNo, setIsLoadingSerialNo] = React.useState(false) + + // Serial No 조회 + const fetchNextSerialNo = React.useCallback(async () => { + setIsLoadingSerialNo(true) + try { + const response = await fetch(`/api/revisions/max-serial-no?documentId=${documentId}`) + if (response.ok) { + const data = await response.json() + setNextSerialNo(String(data.nextSerialNo)) + } + } catch (error) { + console.error('Failed to fetch serial no:', error) + // 에러 시 기본값 1 사용 + setNextSerialNo("1") + } finally { + setIsLoadingSerialNo(false) + } + }, [documentId]) + + // Dialog 열릴 때 Serial No 조회 + React.useEffect(() => { + if (open && documentId) { + fetchNextSerialNo() + } + }, [open, documentId, fetchNextSerialNo]) + const userName = React.useMemo(() => { return session?.user?.name ? session.user.name : null; @@ -319,8 +468,8 @@ export function NewRevisionDialog({ // 리비전 가이드 텍스트 const revisionGuide = React.useMemo(() => { - return getRevisionGuide() - }, []) + return getRevisionGuide(drawingKind) + }, [drawingKind]) const handleDialogClose = () => { if (!isUploading) { @@ -337,6 +486,7 @@ export function NewRevisionDialog({ try { const formData = new FormData() formData.append("documentId", String(documentId)) + formData.append("serialNo", nextSerialNo) // 추가 formData.append("usage", data.usage) formData.append("revision", data.revision) formData.append("uploaderName", userName || "evcp") @@ -365,7 +515,7 @@ export function NewRevisionDialog({ setUploadProgress(progress) }, 300) - const response = await fetch('/api/revision-upload-ship', { // ✅ 올바른 API 엔드포인트 사용 + const response = await fetch('/api/revision-upload-ship', { method: 'POST', body: formData, }) @@ -389,7 +539,7 @@ export function NewRevisionDialog({ setTimeout(() => { handleDialogClose() - onSuccess?.(result) // ✅ API 응답 결과를 콜백에 전달 + onSuccess?.(result) }, 1000) } catch (error) { @@ -400,22 +550,22 @@ export function NewRevisionDialog({ if (error instanceof Error) { const message = error.message.toLowerCase() - // 파일명 관련 에러 (보안상 허용) + // 파일명 관련 에러 if (message.includes("안전하지 않은 파일명") || message.includes("unsafe filename") || message.includes("filename") && message.includes("invalid")) { userMessage = "File name contains invalid characters. Please avoid using < > : \" ' | ? * in file names. filename can't start with '..'." } - // 파일명 길이 에러 (보안상 허용) + // 파일명 길이 에러 else if (message.includes("파일명이 너무 깁니다") || message.includes("filename too long") || message.includes("파일명") && message.includes("길이")) { userMessage = "File name is too long. Please use a shorter name (max 255 characters)." } - // 파일 크기 에러 (보안상 허용) + // 파일 크기 에러 else if (message.includes("파일 크기가 너무 큽니다") || message.includes("file size") || message.includes("1gb limit") || message.includes("exceeds") && message.includes("limit")) { userMessage = "File size is too large. Please use files smaller than 1GB." } - // 클라이언트측 네트워크 에러 (기존과 같이 처리) + // 클라이언트측 네트워크 에러 else if (message.includes("network") || message.includes("fetch") || message.includes("connection") || message.includes("timeout")) { userMessage = "Network error occurred. Please check your connection and try again." @@ -426,7 +576,6 @@ export function NewRevisionDialog({ message.includes("security") || message.includes("validation")) { userMessage = "Please try again later. If the problem persists, please contact the administrator." } - // 그 외는 일반적인 메시지 else { userMessage = "Please try again later. If the problem persists, please contact the administrator." } @@ -441,7 +590,7 @@ export function NewRevisionDialog({ return ( <Dialog open={open} onOpenChange={handleDialogClose}> - <DialogContent className="max-w-2xl h-[90vh] flex flex-col overflow-hidden" style={{maxHeight:'90vh'}}> + <DialogContent className="max-w-2xl h-[90vh] flex flex-col overflow-hidden" style={{maxHeight:'90vh'}}> {/* 고정 헤더 */} <DialogHeader className="flex-shrink-0 pb-4 border-b"> <DialogTitle className="flex items-center gap-2"> @@ -451,6 +600,12 @@ export function NewRevisionDialog({ {documentTitle && ( <DialogDescription className="text-sm space-y-1"> <div>Document: {documentTitle}</div> + <div className="text-xs text-muted-foreground"> + Drawing Type: {drawingKind} | Serial No: {nextSerialNo} + {isLoadingSerialNo && ( + <Loader2 className="inline-block ml-2 h-3 w-3 animate-spin" /> + )} + </div> </DialogDescription> )} </DialogHeader> @@ -513,7 +668,7 @@ export function NewRevisionDialog({ /> )} - {/* 리비전 */} + {/* 리비전 입력 */} <FormField control={form.control} name="revision" @@ -521,14 +676,30 @@ export function NewRevisionDialog({ <FormItem> <FormLabel className="required">Revision</FormLabel> <FormControl> - <Input - placeholder={revisionGuide} - {...field} - /> + {drawingKind === 'B3' ? ( + <B3RevisionInput + value={field.value} + onChange={field.onChange} + error={form.formState.errors.revision?.message} + /> + ) : ( + <> + <Input + placeholder={revisionGuide.placeholder} + {...field} + onChange={(e) => { + const upperValue = e.target.value.toUpperCase() + if (upperValue.length <= 3) { + field.onChange(upperValue) + } + }} + /> + <div className="text-xs text-muted-foreground mt-1"> + {revisionGuide.helpText} + </div> + </> + )} </FormControl> - <div className="text-xs text-muted-foreground mt-1"> - {revisionGuide} - </div> <FormMessage /> </FormItem> )} @@ -617,7 +788,7 @@ export function NewRevisionDialog({ </> )} </Button> - </DialogFooter> + </DialogFooter> </form> </Form> </DialogContent> diff --git a/components/ship-vendor-document/user-vendor-document-table-container.tsx b/components/ship-vendor-document/user-vendor-document-table-container.tsx index 7fac34a9..775dac47 100644 --- a/components/ship-vendor-document/user-vendor-document-table-container.tsx +++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx @@ -40,6 +40,16 @@ import { useRouter } from 'next/navigation' import { AddAttachmentDialog } from "./add-attachment-dialog" // ✅ import 추가 import { EditRevisionDialog } from "./edit-revision-dialog" // ✅ 추가 import { downloadFile } from "@/lib/file-download" // ✅ 공용 다운로드 함수 import +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" /* ------------------------------------------------------------------------------------------------- * Types & Constants @@ -172,12 +182,12 @@ function RevisionTable({ // ✅ 리비전 수정 가능 여부 확인 함수 const canEditRevision = React.useCallback((revision: RevisionInfo) => { // 첨부파일이 없으면 수정 가능 - if ((!revision.attachments || revision.attachments.length === 0)&&revision.uploaderType ==="vendor") { + if ((!revision.attachments || revision.attachments.length === 0) && revision.uploaderType === "vendor") { return true } // 모든 첨부파일의 dolceFilePath가 null이거나 빈값이어야 수정 가능 - return revision.attachments.every(attachment => + return revision.attachments.every(attachment => !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' ) }, []) @@ -188,7 +198,7 @@ function RevisionTable({ return 'no-files' } - const processedCount = revision.attachments.filter(attachment => + const processedCount = revision.attachments.filter(attachment => attachment.dolceFilePath && attachment.dolceFilePath.trim() !== '' ).length @@ -241,7 +251,7 @@ function RevisionTable({ {revisions.map((revision) => { const canEdit = canEditRevision(revision) const processStatus = getRevisionProcessStatus(revision) - + return ( <TableRow key={revision.id} @@ -264,14 +274,14 @@ function RevisionTable({ {revision.revision} {/* ✅ 처리 상태 인디케이터 */} {processStatus === 'fully-processed' && ( - <div - className="w-2 h-2 bg-blue-500 rounded-full" + <div + className="w-2 h-2 bg-blue-500 rounded-full" title="All files processed" /> )} {processStatus === 'partially-processed' && ( - <div - className="w-2 h-2 bg-yellow-500 rounded-full" + <div + className="w-2 h-2 bg-yellow-500 rounded-full" title="Some files processed" /> )} @@ -333,7 +343,7 @@ function RevisionTable({ {/* ✅ 처리된 파일 수 표시 */} {processStatus === 'partially-processed' && ( <span className="text-xs text-muted-foreground"> - ({revision.attachments.filter(att => + ({revision.attachments.filter(att => att.dolceFilePath && att.dolceFilePath.trim() !== '' ).length} processed) </span> @@ -354,21 +364,20 @@ function RevisionTable({ <Eye className="h-4 w-4" /> </Button> )} - + {/* ✅ 수정 버튼 */} <Button variant="ghost" size="sm" onClick={() => onEditRevision(revision)} - className={`h-8 px-2 ${ - canEdit - ? 'text-blue-600 hover:text-blue-700 hover:bg-blue-50' + className={`h-8 px-2 ${canEdit + ? 'text-blue-600 hover:text-blue-700 hover:bg-blue-50' : 'text-muted-foreground cursor-not-allowed' - }`} + }`} disabled={!canEdit} title={ - canEdit - ? 'Edit revision' + canEdit + ? 'Edit revision' : 'Cannot edit - some files have been processed' } > @@ -390,17 +399,23 @@ function RevisionTable({ function AttachmentTable({ attachments, onDownloadFile, - onDeleteFile, // ✅ 삭제 함수 prop 추가 + onDeleteFile, }: { attachments: AttachmentInfo[] onDownloadFile: (attachment: AttachmentInfo) => void - onDeleteFile: (attachment: AttachmentInfo) => Promise<void> // ✅ 삭제 함수 추가 + onDeleteFile: (attachment: AttachmentInfo) => Promise<void> }) { const { selectedRevisionId, allData, setAllData } = React.useContext(DocumentSelectionContext) const [addAttachmentDialogOpen, setAddAttachmentDialogOpen] = React.useState(false) - const [deletingFileId, setDeletingFileId] = React.useState<number | null>(null) // ✅ 삭제 중인 파일 ID + const [deletingFileId, setDeletingFileId] = React.useState<number | null>(null) const router = useRouter() + // ✅ AlertDialog 상태 추가 + const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false) + const [fileToDelete, setFileToDelete] = React.useState<AttachmentInfo | null>(null) + const [errorAlertOpen, setErrorAlertOpen] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState('') + // 선택된 리비전 정보 가져오기 const selectedRevisionInfo = React.useMemo(() => { if (!selectedRevisionId || !allData) return null @@ -425,34 +440,48 @@ function AttachmentTable({ // ✅ 삭제 가능 여부 확인 함수 const canDeleteFile = React.useCallback((attachment: AttachmentInfo) => { + // rejected 상태의 리비전에 속한 첨부파일은 무조건 삭제 가능 + if (selectedRevisionInfo && + selectedRevisionInfo.revisionStatus && + selectedRevisionInfo.revisionStatus.toLowerCase() === 'rejected') { + return true + } + + // 그 외의 경우는 기존 로직대로: dolceFilePath가 없거나 빈값인 경우만 삭제 가능 return !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' - }, []) + }, [selectedRevisionInfo]) - // ✅ 파일 삭제 핸들러 - const handleDeleteFile = React.useCallback(async (attachment: AttachmentInfo) => { + // ✅ 삭제 요청 핸들러 (확인 다이얼로그 표시) + const handleDeleteRequest = React.useCallback((attachment: AttachmentInfo) => { if (!canDeleteFile(attachment)) { - alert('This file cannot be deleted because it has been processed by the system.') + setErrorMessage('This file cannot be deleted because it has been processed by the system.') + setErrorAlertOpen(true) return } - const confirmDelete = window.confirm( - `Are you sure you want to delete "${attachment.fileName}"?\nThis action cannot be undone.` - ) - - if (!confirmDelete) return + setFileToDelete(attachment) + setDeleteConfirmOpen(true) + }, [canDeleteFile]) + + // ✅ 실제 삭제 수행 핸들러 + const handleConfirmDelete = React.useCallback(async () => { + if (!fileToDelete) return try { - setDeletingFileId(attachment.id) - await onDeleteFile(attachment) + setDeletingFileId(fileToDelete.id) + setDeleteConfirmOpen(false) + await onDeleteFile(fileToDelete) } catch (error) { console.error('Delete file error:', error) - alert(`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`) + setErrorMessage(`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`) + setErrorAlertOpen(true) } finally { setDeletingFileId(null) + setFileToDelete(null) } - }, [canDeleteFile, onDeleteFile]) + }, [fileToDelete, onDeleteFile]) - // 첨부파일 업로드 성공 핸들러 + // 첨부파일 업로드 성공 핸들러 (기존 코드 유지) const handleAttachmentUploadSuccess = React.useCallback((uploadResult?: any) => { if (!selectedRevisionId || !allData || !uploadResult?.data) { console.log('🔄 Full refresh') @@ -467,7 +496,7 @@ function AttachmentTable({ revisionId: selectedRevisionId, fileName: file.fileName, filePath: file.filePath, - dolceFilePath: null, // ✅ 새 파일은 dolceFilePath가 없음 + dolceFilePath: null, fileSize: file.fileSize, fileType: file.fileType || null, createdAt: new Date(), @@ -484,7 +513,6 @@ function AttachmentTable({ for (const stage of stages) { const revisionIndex = stage.revisions.findIndex(r => r.id === selectedRevisionId) if (revisionIndex !== -1) { - // 해당 리비전의 첨부파일 배열에 새 파일들 추가 stage.revisions[revisionIndex] = { ...stage.revisions[revisionIndex], attachments: [...stage.revisions[revisionIndex].attachments, ...newAttachments] @@ -501,7 +529,6 @@ function AttachmentTable({ setAllData(updatedData) console.log('✅ AttachmentTable update complete') - // 메인 테이블도 업데이트 (약간의 지연 후) setTimeout(() => { router.refresh() }, 1500) @@ -518,7 +545,6 @@ function AttachmentTable({ <CardHeader> <div className="flex items-center justify-between"> <CardTitle className="text-lg">Attachments</CardTitle> - {/* + 버튼 */} {selectedRevisionId && selectedRevisionInfo && ( <Button onClick={handleAddAttachment} @@ -551,7 +577,6 @@ function AttachmentTable({ ? 'Please select a revision' : 'No attached files'} </span> - {/* 리비전이 선택된 경우 추가 버튼 표시 */} {selectedRevisionId && selectedRevisionInfo && ( <Button onClick={handleAddAttachment} @@ -581,7 +606,6 @@ function AttachmentTable({ : `${(file.fileSize / 1024).toFixed(1)}KB` : '-'} </div> - {/* ✅ dolceFilePath 상태 표시 */} {file.dolceFilePath && file.dolceFilePath.trim() !== '' && ( <div className="text-xs text-blue-600 font-medium"> Processed @@ -591,7 +615,6 @@ function AttachmentTable({ </TableCell> <TableCell> <div className="flex items-center gap-1"> - {/* 다운로드 버튼 */} <Button variant="ghost" size="sm" @@ -601,21 +624,21 @@ function AttachmentTable({ > <Download className="h-4 w-4" /> </Button> - - {/* ✅ 삭제 버튼 */} + <Button variant="ghost" size="sm" - onClick={() => handleDeleteFile(file)} - className={`h-8 px-2 ${ - canDeleteFile(file) - ? 'text-red-600 hover:text-red-700 hover:bg-red-50' + onClick={() => handleDeleteRequest(file)} + className={`h-8 px-2 ${canDeleteFile(file) + ? 'text-red-600 hover:text-red-700 hover:bg-red-50' : 'text-muted-foreground cursor-not-allowed' - }`} + }`} disabled={!canDeleteFile(file) || deletingFileId === file.id} title={ - canDeleteFile(file) - ? 'Delete file' + canDeleteFile(file) + ? selectedRevisionInfo?.revisionStatus?.toLowerCase() === 'rejected' + ? 'Delete file (rejected revision)' + : 'Delete file' : 'Cannot delete processed file' } > @@ -635,6 +658,47 @@ function AttachmentTable({ </CardContent> </Card> + {/* ✅ 삭제 확인 다이얼로그 */} + <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete File</AlertDialogTitle> + <AlertDialogDescription> + Are you sure you want to delete "{fileToDelete?.fileName}"? + This action cannot be undone. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => setFileToDelete(null)}> + Cancel + </AlertDialogCancel> + <AlertDialogAction + onClick={handleConfirmDelete} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + {/* ✅ 에러 메시지 다이얼로그 */} + <AlertDialog open={errorAlertOpen} onOpenChange={setErrorAlertOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Error</AlertDialogTitle> + <AlertDialogDescription> + {errorMessage} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogAction onClick={() => setErrorMessage('')}> + OK + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + {/* AddAttachmentDialog */} {selectedRevisionInfo && ( <AddAttachmentDialog @@ -666,7 +730,7 @@ function SubTables() { const isCancelled = React.useRef(false) const [newRevisionDialogOpen, setNewRevisionDialogOpen] = React.useState(false) - + // ✅ 리비전 수정 다이얼로그 상태 const [editRevisionDialogOpen, setEditRevisionDialogOpen] = React.useState(false) const [editingRevision, setEditingRevision] = React.useState<RevisionInfo | null>(null) @@ -770,7 +834,7 @@ function SubTables() { try { // 파일 경로 처리 let downloadPath = attachment.filePath - + // 공용 다운로드 함수 사용 (보안 검증, 파일 체크 모두 포함) const result = await downloadFile(downloadPath, attachment.fileName, { action: 'download', @@ -784,7 +848,7 @@ function SubTables() { } catch (error) { console.error('File download error:', error) - + // fallback: API 엔드포인트를 통한 다운로드 시도 try { const queryParam = attachment.id diff --git a/db/schema/vendorDocu.ts b/db/schema/vendorDocu.ts index 1c634f64..aa6eb946 100644 --- a/db/schema/vendorDocu.ts +++ b/db/schema/vendorDocu.ts @@ -201,7 +201,8 @@ export const revisions = pgTable( registerId: varchar("register_id", { length: 50 }), - serialNo: varchar("serial_no", { length: 50 }), // 상대 시스템에서 생성한 ID + serialNo: varchar("serial_no", { length: 50 }), + registerSerialNoMax: varchar("register_serial_no_max", { length: 50 }), }, (table) => { @@ -210,6 +211,7 @@ export const revisions = pgTable( table.issueStageId, table.revision, table.usage, + table.serialNo, sql`COALESCE(${table.usageType}, '')` ), diff --git a/hooks/use-sync-status.ts b/hooks/use-sync-status.ts index 52a67343..10ead6a6 100644 --- a/hooks/use-sync-status.ts +++ b/hooks/use-sync-status.ts @@ -1,19 +1,38 @@ -// hooks/use-sync-status.ts (완전히 개선된 버전) +// hooks/use-sync-status.ts (업데이트된 버전) import useSWR, { mutate as globalMutate } from 'swr' import useSWRMutation from 'swr/mutation' import * as React from 'react' +import { fabClasses } from '@mui/material' + +// 🔧 타입 정의 강화 - entityTypeDetails 추가 +interface EntityTypeDetail { + pending: number + synced: number + failed: number + total: number +} -// 🔧 타입 정의 강화 interface SyncStatus { syncEnabled: boolean pendingChanges: number syncedChanges: number failedChanges: number - lastSyncAt?: string | null + totalChanges: number error?: string | null projectId?: number + vendorId?: number targetSystem?: string lastUpdated?: string + requiresSync: boolean + // 새로 추가된 필드들 + entityTypeDetails?: { + document: EntityTypeDetail + revision: EntityTypeDetail + attachment: EntityTypeDetail + } + hasPendingChanges?: boolean + hasFailedChanges?: boolean + syncHealthy?: boolean } interface ApiError extends Error { @@ -76,20 +95,29 @@ const fetcher = async (url: string): Promise<any> => { } } -// 🔧 안전한 기본값 생성 +// 🔧 안전한 기본값 생성 - entityTypeDetails 추가 const createDefaultSyncStatus = (error?: string, projectId?: number): SyncStatus => ({ syncEnabled: false, + requiresSync:false, pendingChanges: 0, syncedChanges: 0, failedChanges: 0, - lastSyncAt: null, + totalChanges: 0, error, projectId, - lastUpdated: new Date().toISOString() + lastUpdated: new Date().toISOString(), + entityTypeDetails: { + document: { pending: 0, synced: 0, failed: 0, total: 0 }, + revision: { pending: 0, synced: 0, failed: 0, total: 0 }, + attachment: { pending: 0, synced: 0, failed: 0, total: 0 } + }, + hasPendingChanges: false, + hasFailedChanges: false, + syncHealthy: true }) -// ✅ 단일 계약 동기화 상태 조회 -export function useSyncStatus(projectId: number | null, targetSystem: string = 'SHI') { +// ✅ 단일 계약 동기화 상태 조회 - DOLCE를 기본값으로 변경 +export function useSyncStatus(projectId: number | null, targetSystem: string = 'DOLCE') { const key = projectId ? `/api/sync/status?projectId=${projectId}&targetSystem=${targetSystem}` : null @@ -120,7 +148,7 @@ export function useSyncStatus(projectId: number | null, targetSystem: string = ' } }, [key, localMutate]) - // 항상 안전한 데이터 반환 + // 항상 안전한 데이터 반환 - 새 필드들 포함 const safeData: SyncStatus = React.useMemo(() => { if (data && typeof data === 'object') { return { @@ -128,11 +156,20 @@ export function useSyncStatus(projectId: number | null, targetSystem: string = ' pendingChanges: Number(data.pendingChanges) || 0, syncedChanges: Number(data.syncedChanges) || 0, failedChanges: Number(data.failedChanges) || 0, - lastSyncAt: data.lastSyncAt || null, + totalChanges: Number(data.totalChanges) || 0, error: data.error || (error ? (error as ApiError).message : null), projectId: projectId || data.projectId, + vendorId: data.vendorId, targetSystem: targetSystem, - lastUpdated: new Date().toISOString() + lastUpdated: new Date().toISOString(), + entityTypeDetails: data.entityTypeDetails || { + document: { pending: 0, synced: 0, failed: 0, total: 0 }, + revision: { pending: 0, synced: 0, failed: 0, total: 0 }, + attachment: { pending: 0, synced: 0, failed: 0, total: 0 } + }, + hasPendingChanges: Boolean(data.hasPendingChanges), + hasFailedChanges: Boolean(data.hasFailedChanges), + syncHealthy: Boolean(data.syncHealthy) } } @@ -150,11 +187,8 @@ export function useSyncStatus(projectId: number | null, targetSystem: string = ' } } -// ❌ useMultipleSyncStatus 제거 (Hook 규칙 위반 때문에) -// 대신 useDynamicSyncStatus 사용 권장 - -// ✅ 다중 계약 동기화 상태 조회 (Hook 규칙 준수) -export function useDynamicSyncStatus(projectIds: number[], targetSystem: string = 'SHI') { +// ✅ 다중 계약 동기화 상태 조회 (Hook 규칙 준수) - DOLCE를 기본값으로 +export function useDynamicSyncStatus(projectIds: number[], targetSystem: string = 'DOLCE') { // Hook 규칙 준수: 고정된 수의 Hook 호출 const paddedContractIds = React.useMemo(() => { // 입력 검증 및 경고 @@ -172,6 +206,7 @@ export function useDynamicSyncStatus(projectIds: number[], targetSystem: string return padded }, [projectIds]) + // 각 contractId에 대해 고정된 수의 Hook 호출 const allResults = paddedContractIds.map((projectId) => { const result = useSyncStatus(projectId, targetSystem) @@ -189,13 +224,21 @@ export function useDynamicSyncStatus(projectIds: number[], targetSystem: string } => result !== null) }, [allResults]) - // 전체 통계 계산 + // 전체 통계 계산 - entityTypeDetails 포함 const totalStats = React.useMemo(() => { let totalPending = 0 let totalSynced = 0 let totalFailed = 0 + let totalChanges = 0 let hasError = false let isLoading = false + + // entityType별 합계 초기화 + const entityTypeDetailsTotals = { + document: { pending: 0, synced: 0, failed: 0, total: 0 }, + revision: { pending: 0, synced: 0, failed: 0, total: 0 }, + attachment: { pending: 0, synced: 0, failed: 0, total: 0 } + } validResults.forEach(({ syncStatus, error, isLoading: loading }) => { if (error) hasError = true @@ -205,6 +248,20 @@ export function useDynamicSyncStatus(projectIds: number[], targetSystem: string totalPending += Number(syncStatus.pendingChanges) || 0 totalSynced += Number(syncStatus.syncedChanges) || 0 totalFailed += Number(syncStatus.failedChanges) || 0 + totalChanges += Number(syncStatus.totalChanges) || 0 + + // entityTypeDetails 합계 계산 + if (syncStatus.entityTypeDetails) { + Object.keys(entityTypeDetailsTotals).forEach((entityType) => { + const key = entityType as keyof typeof entityTypeDetailsTotals + if (syncStatus.entityTypeDetails?.[key]) { + entityTypeDetailsTotals[key].pending += syncStatus.entityTypeDetails[key].pending || 0 + entityTypeDetailsTotals[key].synced += syncStatus.entityTypeDetails[key].synced || 0 + entityTypeDetailsTotals[key].failed += syncStatus.entityTypeDetails[key].failed || 0 + entityTypeDetailsTotals[key].total += syncStatus.entityTypeDetails[key].total || 0 + } + }) + } } }) @@ -212,9 +269,14 @@ export function useDynamicSyncStatus(projectIds: number[], targetSystem: string totalPending, totalSynced, totalFailed, + totalChanges, hasError, isLoading, - canSync: totalPending > 0 && !hasError && projectIds.length > 0 + canSync: totalPending > 0 && !hasError && projectIds.length > 0, + entityTypeDetailsTotals, + hasPendingChanges: totalPending > 0, + hasFailedChanges: totalFailed > 0, + syncHealthy: totalFailed === 0 && totalPending < 100 } }, [validResults, projectIds.length]) @@ -235,8 +297,8 @@ export function useDynamicSyncStatus(projectIds: number[], targetSystem: string } } -// ✅ 클라이언트 전용 동기화 상태 조회 (서버 사이드 렌더링 호환) -export function useClientSyncStatus(projectIds: number[], targetSystem: string = 'SHI',) { +// ✅ 클라이언트 전용 동기화 상태 조회 (서버 사이드 렌더링 호환) - DOLCE 기본값 +export function useClientSyncStatus(projectIds: number[], targetSystem: string = 'DOLCE') { const [isClient, setIsClient] = React.useState(false) React.useEffect(() => { @@ -256,9 +318,18 @@ export function useClientSyncStatus(projectIds: number[], targetSystem: string = totalPending: 0, totalSynced: 0, totalFailed: 0, + totalChanges: 0, hasError: false, isLoading: true, - canSync: false + canSync: false, + entityTypeDetailsTotals: { + document: { pending: 0, synced: 0, failed: 0, total: 0 }, + revision: { pending: 0, synced: 0, failed: 0, total: 0 }, + attachment: { pending: 0, synced: 0, failed: 0, total: 0 } + }, + hasPendingChanges: false, + hasFailedChanges: false, + syncHealthy: true }, refetchAll: () => {} } @@ -267,8 +338,8 @@ export function useClientSyncStatus(projectIds: number[], targetSystem: string = return syncResult } -// ✅ 동기화 배치 목록 조회 -export function useSyncBatches(projectId: number | null, targetSystem: string = 'SHI') { +// ✅ 동기화 배치 목록 조회 - DOLCE 기본값 +export function useSyncBatches(projectId: number | null, targetSystem: string = 'DOLCE') { const key = projectId ? `/api/sync/batches?projectId=${projectId}&targetSystem=${targetSystem}` : null @@ -290,8 +361,8 @@ export function useSyncBatches(projectId: number | null, targetSystem: string = } } -// ✅ 동기화 설정 조회 -export function useSyncConfig(projectId: number | null, targetSystem: string = 'SHI') { +// ✅ 동기화 설정 조회 - DOLCE 기본값 +export function useSyncConfig(projectId: number | null, targetSystem: string = 'DOLCE') { const key = projectId ? `/api/sync/config?projectId=${projectId}&targetSystem=${targetSystem}` : null @@ -319,7 +390,7 @@ export function useSyncConfig(projectId: number | null, targetSystem: string = ' } } -// ✅ 동기화 트리거 (뮤테이션) +// ✅ 동기화 트리거 (뮤테이션) - DOLCE 기본값 export function useTriggerSync() { const { trigger, isMutating, error } = useSWRMutation( '/api/sync/trigger', @@ -327,7 +398,7 @@ export function useTriggerSync() { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(arg) + body: JSON.stringify({ ...arg, targetSystem: arg.targetSystem || 'DOLCE' }) }) if (!response.ok) { @@ -347,7 +418,7 @@ export function useTriggerSync() { const result = await trigger(arg) // 성공 후 관련 캐시 무효화 - const targetSystem = arg.targetSystem || 'SHI' + const targetSystem = arg.targetSystem || 'DOLCE' const statusKey = `/api/sync/status?projectId=${arg.projectId}&targetSystem=${targetSystem}` const batchesKey = `/api/sync/batches?projectId=${arg.projectId}&targetSystem=${targetSystem}` @@ -389,7 +460,6 @@ export function useUpdateSyncConfig() { return response.json() } - // ✅ onSuccess 콜백 제거 ) // ✅ 수동 캐시 무효화를 포함한 래핑 함수 @@ -415,8 +485,9 @@ export function useUpdateSyncConfig() { error: error as ApiError | null } } -// ✅ 실시간 동기화 상태 훅 (높은 갱신 빈도) -export function useRealtimeSyncStatus(projectId: number | null, targetSystem: string = 'SHI') { + +// ✅ 실시간 동기화 상태 훅 (높은 갱신 빈도) - DOLCE 기본값 +export function useRealtimeSyncStatus(projectId: number | null, targetSystem: string = 'DOLCE') { const key = projectId ? `/api/sync/status?projectId=${projectId}&targetSystem=${targetSystem}&realtime=true` : null @@ -449,8 +520,8 @@ export function useRealtimeSyncStatus(projectId: number | null, targetSystem: st // 🔧 유틸리티 함수들 export const syncUtils = { - // 캐시 수동 무효화 - invalidateCache: (projectId: number, targetSystem: string = 'SHI') => { + // 캐시 수동 무효화 - DOLCE 기본값 + invalidateCache: (projectId: number, targetSystem: string = 'DOLCE') => { const statusKey = `/api/sync/status?projectId=${projectId}&targetSystem=${targetSystem}` const batchesKey = `/api/sync/batches?projectId=${projectId}&targetSystem=${targetSystem}` const configKey = `/api/sync/config?projectId=${projectId}&targetSystem=${targetSystem}` @@ -476,5 +547,24 @@ export const syncUtils = { if (error.status >= 500) return '서버 오류가 발생했습니다' return error.message || '알 수 없는 오류가 발생했습니다' + }, + + // entityType별 통계 포맷터 + formatEntityTypeStats: (details?: SyncStatus['entityTypeDetails']): string => { + if (!details) return '통계 없음' + + const parts: string[] = [] + + if (details.document.total > 0) { + parts.push(`document: ${details.document.pending}/${details.document.total}`) + } + if (details.revision.total > 0) { + parts.push(`revision: ${details.revision.pending}/${details.revision.total}`) + } + if (details.attachment.total > 0) { + parts.push(`attachment: ${details.attachment.pending}/${details.attachment.total}`) + } + + return parts.length > 0 ? parts.join(', ') : '변경사항 없음' } }
\ No newline at end of file diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts index 85085d80..7717877b 100644 --- a/lib/vendor-document-list/dolce-upload-service.ts +++ b/lib/vendor-document-list/dolce-upload-service.ts @@ -293,6 +293,8 @@ class DOLCEUploadService { externalRegisterId: revisions.id, externalSentAt: revisions.submittedDate, + serialNo: revisions.serialNo, + // issueStages 테이블 정보 issueStageId: issueStages.id, stageName: issueStages.stageName, @@ -644,40 +646,53 @@ class DOLCEUploadService { if (revision.usage && revision.usage !== 'DEFAULT') { switch (revision.usage) { + case "APPROVAL": - if (revision.usageType === "Full") { - registerKind = "APPR" - } else if (revision.usageType === "Partial") { - registerKind = "APPR-P" - } else { - registerKind = "APPR" // 기본값 + if (revision.drawingKind === "B3") { + if (revision.usageType === "Full") { + registerKind = "APPR" + } else if (revision.usageType === "Partial") { + registerKind = "APPR-P" + } else { + registerKind = "APPR" // 기본값 + } } break case "WORKING": - if (revision.usageType === "Full") { - registerKind = "WORK" - } else if (revision.usageType === "Partial") { - registerKind = "WORK-P" - } else { - registerKind = "WORK" // 기본값 + if (revision.drawingKind === "B3") { + if (revision.usageType === "Full") { + registerKind = "WORK" + } else if (revision.usageType === "Partial") { + registerKind = "WORK-P" + } else { + registerKind = "WORK" // 기본값 + } } break case "The 1st": - registerKind = "FMEA-1" + if (revision.drawingKind === "B5") { + registerKind = "FMEA-1" + } break case "The 2nd": - registerKind = "FMEA-2" + if (revision.drawingKind === "B5") { + registerKind = "FMEA-2" + } break case "Pre": - registerKind = "RECP" + if (revision.drawingKind === "B3") { + registerKind = "RECP" + } break case "Working": - registerKind = "RECW" + if (revision.drawingKind === "B3") { + registerKind = "RECW" + } break case "Mark-Up": @@ -742,7 +757,7 @@ class DOLCEUploadService { DrawingNo: revision.documentNo, DrawingName: revision.documentName, RegisterGroupId: revision.registerGroupId || 0, - RegisterSerialNo: getSerialNumber(revision.revision || "1"), + RegisterSerialNo: revision.serialNo || getSerialNumber(revision.revision || "1"), RegisterKind: registerKind, // usage/usageType에 따라 동적 설정 DrawingRevNo: revision.revision || "-", Category: revision.category || "TS", diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts index 13c51824..fb4db85e 100644 --- a/lib/vendor-document-list/import-service.ts +++ b/lib/vendor-document-list/import-service.ts @@ -1082,13 +1082,14 @@ class ImportService { issueStageId, revision: detailDoc.DrawingRevNo, uploaderType, - uploaderName: detailDoc.CreateUserNM, + registerSerialNoMax:detailDoc.RegisterSerialNoMax, + // uploaderName: detailDoc.CreateUserNM, usage, usageType, revisionStatus: detailDoc.Status, externalUploadId: detailDoc.UploadId, registerId: detailDoc.RegisterId, // 🆕 항상 최신 registerId로 업데이트 - comment: detailDoc.RegisterDesc, + comment: detailDoc.SHINote, submittedDate: this.convertDolceDateToDate(detailDoc.CreateDt), updatedAt: new Date() } @@ -1098,7 +1099,8 @@ class ImportService { const hasChanges = existingRevision.revision !== revisionData.revision || existingRevision.revisionStatus !== revisionData.revisionStatus || - existingRevision.uploaderName !== revisionData.uploaderName || + existingRevision.registerSerialNoMax !== revisionData.registerSerialNoMax || + // existingRevision.uploaderName !== revisionData.uploaderName || existingRevision.serialNo !== revisionData.serialNo || existingRevision.registerId !== revisionData.registerId // 🆕 registerId 변경 확인 diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index d71ecc0f..9a53b55b 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -347,6 +347,26 @@ export function getDocumentStagesColumns({ }, }, + { + accessorKey: "buyerSystemComment", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Comment" /> + ), + cell: ({ row }) => { + const doc = row.original + + return ( + <div className="flex items-center gap-2"> + {doc.buyerSystemComment} + </div> + ) + }, + size: 180, + enableResizing: true, + meta: { + excelHeader: "Document Comment" + }, + }, // { // accessorKey: "buyerSystemStatus", // header: ({ column }) => ( diff --git a/lib/vendor-document-list/ship/enhanced-documents-table.tsx b/lib/vendor-document-list/ship/enhanced-documents-table.tsx index 24ab42fb..cae0fe06 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 - Project Code 필터 기능 추가 "use client" import React from "react" @@ -13,7 +13,8 @@ import { getUserVendorDocuments, getUserVendorDocumentStats } from "@/lib/vendor import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" -import { FileText } from "lucide-react" +import { Button } from "@/components/ui/button" +import { FileText, FileInput, FileOutput, FolderOpen, Building2 } from "lucide-react" import { Label } from "@/components/ui/label" import { DataTable } from "@/components/data-table/data-table" @@ -21,6 +22,15 @@ import { SimplifiedDocumentsView } from "@/db/schema" import { getSimplifiedDocumentColumns } from "./enhanced-doc-table-columns" import { EnhancedDocTableToolbarActions } from "./enhanced-doc-table-toolbar-actions" +// 🔥 Project Code 필터를 위한 Select 컴포넌트 import 추가 +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + // DrawingKind별 설명 매핑 const DRAWING_KIND_INFO = { B3: { @@ -60,17 +70,61 @@ export function SimplifiedDocumentsTable({ const { data, pageCount, total, drawingKind, vendorInfo } = React.useMemo(() => documentResult, [documentResult]) const { stats, totalDocuments, primaryDrawingKind } = React.useMemo(() => statsResult, [statsResult]) + // 🔥 B4 필터 상태 추가 + const [b4FilterType, setB4FilterType] = React.useState<'all' | 'gtt_deliverable' | 'shi_input'>('all') + + // 🔥 Project Code 필터 상태 추가 + const [selectedProjectCode, setSelectedProjectCode] = React.useState<string>('all') + + // 🔥 고유한 Project Code 목록 추출 및 카운트 메모이제이션 + const projectCodeStats = React.useMemo(() => { + const projectMap = new Map<string, number>() + + data.forEach(doc => { + const projectCode = doc.projectCode || 'Unknown' + projectMap.set(projectCode, (projectMap.get(projectCode) || 0) + 1) + }) + + // 정렬된 배열로 변환 (프로젝트 코드 알파벳순) + return Array.from(projectMap.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([code, count]) => ({ code, count })) + }, [data]) + // 🔥 데이터 로드 콜백을 useCallback으로 최적화 const handleDataLoaded = React.useCallback((loadedData: SimplifiedDocumentsView[]) => { onDataLoaded?.(loadedData) }, [onDataLoaded]) - // 🔥 데이터가 로드되면 콜백 호출 (의존성 최적화) + // 🔥 B4 및 Project Code 필터링된 데이터 메모이제이션 + const filteredData = React.useMemo(() => { + let result = data + + // B4 필터 적용 + if (b4FilterType !== 'all') { + if (b4FilterType === 'gtt_deliverable') { + result = result.filter(doc => doc.drawingMoveGbn === '도면입수') + } else if (b4FilterType === 'shi_input') { + result = result.filter(doc => doc.drawingMoveGbn === '도면제출') + } + } + + // Project Code 필터 적용 + if (selectedProjectCode !== 'all') { + result = result.filter(doc => + (doc.projectCode || 'Unknown') === selectedProjectCode + ) + } + + return result + }, [data, b4FilterType, selectedProjectCode]) + + // 🔥 데이터가 로드되면 콜백 호출 (필터링된 데이터 사용) React.useEffect(() => { - if (data && handleDataLoaded) { - handleDataLoaded(data) + if (filteredData && handleDataLoaded) { + handleDataLoaded(filteredData) } - }, [data, handleDataLoaded]) + }, [filteredData, handleDataLoaded]) // 🔥 상태들을 안정적으로 관리 const [rowAction, setRowAction] = React.useState<DataTableRowAction<SimplifiedDocumentsView> | null>(null) @@ -81,7 +135,7 @@ export function SimplifiedDocumentsTable({ () => getSimplifiedDocumentColumns({ setRowAction, }), - [] // setRowAction은 항상 동일한 함수이므로 의존성에서 제외 + [] ) // 🔥 필터 필드들을 메모이제이션 @@ -238,7 +292,7 @@ export function SimplifiedDocumentsTable({ const getRowId = React.useCallback((originalRow: SimplifiedDocumentsView) => String(originalRow.documentId), []) const { table } = useDataTable({ - data, + data: filteredData, columns, pageCount, enablePinning: true, @@ -260,6 +314,21 @@ export function SimplifiedDocumentsTable({ return activeDrawingKind ? DRAWING_KIND_INFO[activeDrawingKind] : null }, [activeDrawingKind]) + // 🔥 B4 문서 통계 계산 + const b4Stats = React.useMemo(() => { + if (!hasB4Documents) return null + + const gttDeliverableCount = data.filter(doc => + doc.drawingKind === 'B4' && doc.drawingMoveGbn === '도면입수' + ).length + + const shiInputCount = data.filter(doc => + doc.drawingKind === 'B4' && doc.drawingMoveGbn === '도면제출' + ).length + + return { gttDeliverableCount, shiInputCount } + }, [data, hasB4Documents]) + return ( <div className="w-full space-y-4"> {/* DrawingKind 정보 간단 표시 */} @@ -270,12 +339,107 @@ export function SimplifiedDocumentsTable({ </div> <div className="flex items-center gap-2"> <Badge variant="outline"> - {total} documents + {filteredData.length} documents </Badge> </div> </div> )} + {/* 🔥 필터 섹션 - Project Code 필터와 B4 필터를 함께 배치 */} + <div className="space-y-3"> + {/* Project Code 필터 드롭다운 */} + <div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg"> + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <Label className="text-sm font-medium">Project:</Label> + </div> + <Select + value={selectedProjectCode} + onValueChange={setSelectedProjectCode} + > + <SelectTrigger className="w-[200px]"> + <SelectValue placeholder="Select a project" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all"> + <div className="flex items-center justify-between w-full"> + <span>All Projects</span> + <Badge variant="secondary" className="ml-2"> + {data.length} + </Badge> + </div> + </SelectItem> + {projectCodeStats.map(({ code, count }) => ( + <SelectItem key={code} value={code}> + <div className="flex items-center justify-between w-full"> + <span className="font-mono">{code}</span> + <Badge variant="secondary" className="ml-2"> + {count} + </Badge> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + + {selectedProjectCode !== 'all' && ( + <Button + variant="ghost" + size="sm" + onClick={() => setSelectedProjectCode('all')} + className="h-8" + > + Clear filter + </Button> + )} + </div> + + {/* B4 필터 버튼 - 기존 코드 유지 */} + {hasB4Documents && b4Stats && ( + <div className="flex items-center gap-2 p-4 bg-muted/50 rounded-lg"> + <Label className="text-sm font-medium">Document Type:</Label> + <div className="flex gap-2"> + <Button + variant={b4FilterType === 'all' ? 'default' : 'outline'} + size="sm" + onClick={() => setB4FilterType('all')} + className="gap-2" + > + <FileText className="h-4 w-4" /> + All + <Badge variant="secondary" className="ml-1"> + {b4Stats.gttDeliverableCount + b4Stats.shiInputCount} + </Badge> + </Button> + <Button + variant={b4FilterType === 'gtt_deliverable' ? 'default' : 'outline'} + size="sm" + onClick={() => setB4FilterType('gtt_deliverable')} + className="gap-2" + > + <FileInput className="h-4 w-4" /> + GTT Deliverable + <Badge variant="secondary" className="ml-1"> + {b4Stats.gttDeliverableCount} + </Badge> + </Button> + <Button + variant={b4FilterType === 'shi_input' ? 'default' : 'outline'} + size="sm" + onClick={() => setB4FilterType('shi_input')} + className="gap-2" + > + <FileOutput className="h-4 w-4" /> + SHI Input Document + <Badge variant="secondary" className="ml-1"> + {b4Stats.shiInputCount} + </Badge> + </Button> + </div> + </div> + )} + </div> + {/* 테이블 */} <div className="overflow-x-auto"> <DataTable table={table} compact> @@ -287,7 +451,7 @@ export function SimplifiedDocumentsTable({ <EnhancedDocTableToolbarActions table={table} projectType="ship" - b4={hasB4Documents} + b4={hasB4Documents && b4FilterType === 'gtt_deliverable'} /> </DataTableAdvancedToolbar> </DataTable> 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 52874702..7bb85710 100644 --- a/lib/vendor-document-list/ship/send-to-shi-button.tsx +++ b/lib/vendor-document-list/ship/send-to-shi-button.tsx @@ -325,21 +325,123 @@ export function SendToSHIButton({ <div className="space-y-3"> <Separator /> + {/* 전체 통계 */} <div className="grid grid-cols-3 gap-4 text-sm"> <div className="text-center"> <div className="text-muted-foreground">{t('shiSync.labels.pending')}</div> - <div className="font-medium text-orange-600">{t('shiSync.labels.itemCount', { count: totalStats.totalPending })}</div> + <div className="font-medium text-orange-600"> + {t('shiSync.labels.itemCount', { count: totalStats.totalPending })} + </div> </div> <div className="text-center"> <div className="text-muted-foreground">{t('shiSync.labels.synced')}</div> - <div className="font-medium text-emerald-600 dark:text-emerald-400">{t('shiSync.labels.itemCount', { count: totalStats.totalSynced })}</div> + <div className="font-medium text-emerald-600 dark:text-emerald-400"> + {t('shiSync.labels.itemCount', { count: totalStats.totalSynced })} + </div> </div> <div className="text-center"> <div className="text-muted-foreground">{t('shiSync.labels.failed')}</div> - <div className="font-medium text-destructive">{t('shiSync.labels.itemCount', { count: totalStats.totalFailed })}</div> + <div className="font-medium text-destructive"> + {t('shiSync.labels.itemCount', { count: totalStats.totalFailed })} + </div> </div> </div> + {/* EntityType별 상세 통계 추가 */} + {totalStats.entityTypeDetailsTotals && ( + <> + <Separator className="my-2" /> + <div className="space-y-2"> + <div className="text-sm font-medium flex items-center gap-2"> + {t('shiSync.labels.detailsByType')} + <Badge variant="outline" className="text-xs"> + {t('shiSync.labels.experimental')} + </Badge> + </div> + + <div className="space-y-1 text-xs"> + {/* Document 통계 */} + {totalStats.entityTypeDetailsTotals.document && ( + <div className="flex items-center justify-between p-2 rounded bg-muted/50"> + <span className="font-medium"> + {t('shiSync.labels.documents')} + </span> + <div className="flex gap-3 text-xs"> + {totalStats.entityTypeDetailsTotals.document.pending > 0 && ( + <span className="text-orange-600"> + {totalStats.entityTypeDetailsTotals.document.pending} {t('shiSync.labels.pendingShort')} + </span> + )} + {totalStats.entityTypeDetailsTotals.document.synced > 0 && ( + <span className="text-emerald-600"> + {totalStats.entityTypeDetailsTotals.document.synced} {t('shiSync.labels.syncedShort')} + </span> + )} + {totalStats.entityTypeDetailsTotals.document.failed > 0 && ( + <span className="text-destructive"> + {totalStats.entityTypeDetailsTotals.document.failed} {t('shiSync.labels.failedShort')} + </span> + )} + </div> + </div> + )} + + {/* Revision 통계 */} + {totalStats.entityTypeDetailsTotals.revision && ( + <div className="flex items-center justify-between p-2 rounded bg-muted/50"> + <span className="font-medium"> + {t('shiSync.labels.revisions')} + </span> + <div className="flex gap-3 text-xs"> + {totalStats.entityTypeDetailsTotals.revision.pending > 0 && ( + <span className="text-orange-600"> + {totalStats.entityTypeDetailsTotals.revision.pending} {t('shiSync.labels.pendingShort')} + </span> + )} + {totalStats.entityTypeDetailsTotals.revision.synced > 0 && ( + <span className="text-emerald-600"> + {totalStats.entityTypeDetailsTotals.revision.synced} {t('shiSync.labels.syncedShort')} + </span> + )} + {totalStats.entityTypeDetailsTotals.revision.failed > 0 && ( + <span className="text-destructive"> + {totalStats.entityTypeDetailsTotals.revision.failed} {t('shiSync.labels.failedShort')} + </span> + )} + </div> + </div> + )} + + {/* Attachment 통계 */} + {totalStats.entityTypeDetailsTotals.attachment && ( + <div className="flex items-center justify-between p-2 rounded bg-muted/50"> + <span className="font-medium"> + {t('shiSync.labels.attachments')} + </span> + <div className="flex gap-3 text-xs"> + {totalStats.entityTypeDetailsTotals.attachment.pending > 0 && ( + <span className="text-orange-600"> + {totalStats.entityTypeDetailsTotals.attachment.pending} {t('shiSync.labels.pendingShort')} + </span> + )} + {totalStats.entityTypeDetailsTotals.attachment.synced > 0 && ( + <span className="text-emerald-600"> + {totalStats.entityTypeDetailsTotals.attachment.synced} {t('shiSync.labels.syncedShort')} + </span> + )} + {totalStats.entityTypeDetailsTotals.attachment.failed > 0 && ( + <span className="text-destructive"> + {totalStats.entityTypeDetailsTotals.attachment.failed} {t('shiSync.labels.failedShort')} + </span> + )} + </div> + </div> + )} + </div> + </div> + </> + )} + {/* 계약별 상세 상태 */} {contractStatuses.length > 1 && ( <div className="space-y-2"> diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts index cdbf489f..c3ddfcca 100644 --- a/lib/vendor-document-list/sync-service.ts +++ b/lib/vendor-document-list/sync-service.ts @@ -101,7 +101,7 @@ class SyncService { * 동기화할 변경사항 조회 (증분) */ async getPendingChanges( - vendorId: number, + userId: number, targetSystem: string = 'DOLCE', limit?: number ): Promise<ChangeLog[]> { @@ -109,7 +109,7 @@ class SyncService { .select() .from(changeLogs) .where(and( - eq(changeLogs.vendorId, vendorId), + eq(changeLogs.userId, userId), eq(changeLogs.isSynced, false), lt(changeLogs.syncAttempts, 3), sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})` @@ -176,10 +176,11 @@ class SyncService { } const vendorId = Number(session.user.companyId) + const userId = Number(session.user.id) // 2. 대기 중인 변경사항 조회 (전체) - const pendingChanges = await this.getPendingChanges(vendorId, targetSystem) + const pendingChanges = await this.getPendingChanges(userId, targetSystem) if (pendingChanges.length === 0) { return { @@ -457,79 +458,105 @@ class SyncService { .where(inArray(changeLogs.id, changeIds)) } - /** - * 동기화 상태 조회 - */ - async getSyncStatus(projectId: number, targetSystem: string = 'DOLCE') { - try { +/** + * 동기화 상태 조회 - entityType별 상세 통계 포함 + */ +async getSyncStatus(projectId: number, targetSystem: string = 'DOLCE') { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + throw new Error("인증이 필요합니다.") + } - const session = await getServerSession(authOptions) - if (!session?.user?.companyId) { - throw new Error("인증이 필요합니다.") + const vendorId = Number(session.user.companyId) + const userId = Number(session.user.id) + + // 기본 조건 + const baseConditions = and( + eq(changeLogs.userId, userId), + sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})` + ) + + // entityType별 통계를 위한 쿼리 + const entityStats = await db + .select({ + entityType: changeLogs.entityType, + pendingCount: sql<number>`COUNT(*) FILTER (WHERE ${changeLogs.isSynced} = false AND ${changeLogs.syncAttempts} < 3)`, + syncedCount: sql<number>`COUNT(*) FILTER (WHERE ${changeLogs.isSynced} = true)`, + failedCount: sql<number>`COUNT(*) FILTER (WHERE ${changeLogs.isSynced} = false AND ${changeLogs.syncAttempts} >= 3)`, + totalCount: sql<number>`COUNT(*)` + }) + .from(changeLogs) + .where(baseConditions) + .groupBy(changeLogs.entityType) + + // 전체 통계 계산 + const totals = entityStats.reduce((acc, stat) => ({ + pendingChanges: acc.pendingChanges + Number(stat.pendingCount), + syncedChanges: acc.syncedChanges + Number(stat.syncedCount), + failedChanges: acc.failedChanges + Number(stat.failedCount), + totalChanges: acc.totalChanges + Number(stat.totalCount) + }), { + pendingChanges: 0, + syncedChanges: 0, + failedChanges: 0, + totalChanges: 0 + }) + + // entityType별 상세 정보 구성 + const entityTypeDetails = { + document: { + pending: 0, + synced: 0, + failed: 0, + total: 0 + }, + revision: { + pending: 0, + synced: 0, + failed: 0, + total: 0 + }, + attachment: { + pending: 0, + synced: 0, + failed: 0, + total: 0 } - - const vendorId = Number(session.user.companyId) - - - // 대기 중인 변경사항 수 조회 - const pendingCount = await db.$count( - changeLogs, - and( - eq(changeLogs.vendorId, vendorId), - 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.vendorId, vendorId), - eq(changeLogs.isSynced, true), - sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})` - ) - ) - - // 실패한 변경사항 수 조회 - const failedCount = await db.$count( - changeLogs, - and( - eq(changeLogs.vendorId, vendorId), - 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.vendorId, vendorId), - eq(syncBatches.targetSystem, targetSystem), - eq(syncBatches.status, 'SUCCESS') - )) - .orderBy(desc(syncBatches.completedAt)) - .limit(1) + } - return { - vendorId, - targetSystem, - totalChanges: pendingCount + syncedCount + failedCount, - pendingChanges: pendingCount, - syncedChanges: syncedCount, - failedChanges: failedCount, - lastSyncAt: lastSuccessfulBatch?.completedAt?.toISOString() || null, - syncEnabled: this.isSyncEnabled(targetSystem) + // 통계 데이터를 entityTypeDetails에 매핑 + entityStats.forEach(stat => { + const entityType = stat.entityType as 'document' | 'revision' | 'attachment' + if (entityTypeDetails[entityType]) { + entityTypeDetails[entityType] = { + pending: Number(stat.pendingCount), + synced: Number(stat.syncedCount), + failed: Number(stat.failedCount), + total: Number(stat.totalCount) + } } - } catch (error) { - console.error('Failed to get sync status:', error) - throw error + }) + + + return { + projectId, + vendorId, + targetSystem, + ...totals, + entityTypeDetails, // entityType별 상세 통계 + syncEnabled: this.isSyncEnabled(targetSystem), + // 추가 메타데이터 + hasPendingChanges: totals.pendingChanges > 0, + hasFailedChanges: totals.failedChanges > 0, + syncHealthy: totals.failedChanges === 0 && totals.pendingChanges < 100, + requiresSync: totals.pendingChanges > 0 } + } catch (error) { + console.error('Failed to get sync status:', error) + throw error } +} /** * 최근 동기화 배치 목록 조회 |
