summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/api/attachment-delete/route.ts48
-rw-r--r--app/api/revision-attachment/route.ts15
-rw-r--r--app/api/revision-upload-ship/route.ts4
-rw-r--r--app/api/revision-upload/route.ts25
-rw-r--r--app/api/revisions/max-serial-no/route.ts49
-rw-r--r--app/api/sync/batches/route.ts2
-rw-r--r--app/api/sync/status/route.ts105
-rw-r--r--components/form-data/form-data-table.tsx6
-rw-r--r--components/form-data/spreadJS-dialog.tsx63
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx237
-rw-r--r--components/ship-vendor-document/user-vendor-document-table-container.tsx168
-rw-r--r--db/schema/vendorDocu.ts4
-rw-r--r--hooks/use-sync-status.ts156
-rw-r--r--lib/vendor-document-list/dolce-upload-service.ts49
-rw-r--r--lib/vendor-document-list/import-service.ts8
-rw-r--r--lib/vendor-document-list/plant/document-stages-columns.tsx20
-rw-r--r--lib/vendor-document-list/ship/enhanced-documents-table.tsx184
-rw-r--r--lib/vendor-document-list/ship/send-to-shi-button.tsx108
-rw-r--r--lib/vendor-document-list/sync-service.ts167
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
}
+}
/**
* 최근 동기화 배치 목록 조회