summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/api/revision-upload-ship/route.ts23
-rw-r--r--app/api/revisions/max-serial-no/route.ts71
-rw-r--r--components/ship-vendor-document/edit-revision-dialog.tsx137
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx268
-rw-r--r--components/ship-vendor-document/revision-validation.tsx266
-rw-r--r--components/ship-vendor-document/user-vendor-document-table-container.tsx6
6 files changed, 433 insertions, 338 deletions
diff --git a/app/api/revision-upload-ship/route.ts b/app/api/revision-upload-ship/route.ts
index ccfa2e59..26105efd 100644
--- a/app/api/revision-upload-ship/route.ts
+++ b/app/api/revision-upload-ship/route.ts
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { revalidateTag } from "next/cache"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import db from "@/db/db"
import {
@@ -21,6 +23,15 @@ import {
export async function POST(request: NextRequest) {
try {
+ // 세션 정보 가져오기
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
+ const currentUserId = Number(session.user.id)
+ const currentUserName = session.user.name || session.user.email || "unknown"
+
const formData = await request.formData()
/* ------- 파라미터 파싱 ------- */
@@ -166,8 +177,8 @@ export async function POST(request: NextRequest) {
"UPDATE",
updated,
existingRev,
- undefined,
- uploaderName ?? undefined,
+ currentUserId, // 세션에서 가져온 실제 user ID
+ currentUserName, // 세션에서 가져온 실제 user name
[targetSystem]
)
} else {
@@ -197,8 +208,8 @@ export async function POST(request: NextRequest) {
"CREATE",
newRev,
undefined,
- undefined,
- uploaderName ?? undefined,
+ currentUserId, // 세션에서 가져온 실제 user ID
+ currentUserName, // 세션에서 가져온 실제 user name
[targetSystem]
)
}
@@ -252,8 +263,8 @@ export async function POST(request: NextRequest) {
"CREATE",
att,
undefined,
- undefined,
- uploaderName ?? undefined,
+ currentUserId, // 세션에서 가져온 실제 user ID
+ currentUserName, // 세션에서 가져온 실제 user name
[targetSystem]
)
}
diff --git a/app/api/revisions/max-serial-no/route.ts b/app/api/revisions/max-serial-no/route.ts
index b202956a..c0bfe5c3 100644
--- a/app/api/revisions/max-serial-no/route.ts
+++ b/app/api/revisions/max-serial-no/route.ts
@@ -1,48 +1,81 @@
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'
+import { revisions, issueStages } from '@/db/schema/vendorDocu'
+import { eq, sql } from 'drizzle-orm'
+import { alias } from 'drizzle-orm/pg-core'
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const documentId = searchParams.get('documentId')
-
+
+ debugLog('1. Input documentId:', documentId)
+
if (!documentId) {
+ debugLog('2. documentId is missing, returning 400')
return NextResponse.json(
{ error: 'documentId is required' },
{ status: 400 }
)
}
+ const parsedDocumentId = parseInt(documentId)
+ debugLog('3. Parsed documentId:', parsedDocumentId)
+
// 해당 document의 모든 issueStages와 연결된 revisions에서 최대 serialNo 조회
+ const r = alias(revisions, 'r')
+ const is = alias(issueStages, 'is')
+
+ debugLog('4. Created aliases - r:', r, 'is:', is)
+
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')
+ maxSerial: sql<number>`
+ COALESCE(MAX(CAST(${r.serialNo} AS INTEGER)), 0)
+ `,
+ maxRegisterSerial: sql<number>`
+ COALESCE(MAX(CAST(${r.registerSerialNoMax} AS INTEGER)), 0)
+ `
})
- .from(revisions.as('r'))
+ .from(r)
.innerJoin(
- issueStages.as('is'),
- eq(revisions.issueStageId, issueStages.id)
+ is,
+ eq(r.issueStageId, is.id)
)
- .where(eq(issueStages.documentId, parseInt(documentId)))
+ .where(eq(is.documentId, parsedDocumentId))
+
+ debugLog('5. Query result:', maxSerialResult)
+ debugLog('6. Query result length:', maxSerialResult.length)
+ debugLog('7. First result item:', maxSerialResult[0])
+
+ const maxSerialValue = maxSerialResult[0]?.maxSerial || 0
+ const maxRegisterSerialValue = maxSerialResult[0]?.maxRegisterSerial || 0
+
+ debugLog('8. maxSerial value:', maxSerialValue)
+ debugLog('9. maxRegisterSerial value:', maxRegisterSerialValue)
+
+ const maxSerialNo = Math.max(maxSerialValue, maxRegisterSerialValue)
+
+ debugSuccess('10. Final maxSerialNo:', maxSerialNo)
+ debugSuccess('11. Next serialNo:', maxSerialNo + 1)
- const maxSerialNo = maxSerialResult[0]?.maxSerialNo || 0
-
- return NextResponse.json({
+ return NextResponse.json({
maxSerialNo,
nextSerialNo: maxSerialNo + 1,
- documentId: documentId
+ documentId: documentId,
+ debug: {
+ parsedDocumentId,
+ queryResult: maxSerialResult,
+ maxSerialValue,
+ maxRegisterSerialValue
+ }
})
} catch (error) {
- console.error('Error fetching max serial no:', error)
+ debugError('Error fetching max serial no:', error)
+ debugError('Error stack:', error?.stack)
return NextResponse.json(
- { error: 'Failed to fetch max serial number' },
+ { error: 'Failed to fetch max serial number', details: error?.message },
{ status: 500 }
)
}
diff --git a/components/ship-vendor-document/edit-revision-dialog.tsx b/components/ship-vendor-document/edit-revision-dialog.tsx
index 2b8735e7..9ca4c65d 100644
--- a/components/ship-vendor-document/edit-revision-dialog.tsx
+++ b/components/ship-vendor-document/edit-revision-dialog.tsx
@@ -31,17 +31,23 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
-import {
- Edit,
- FileText,
- Loader2,
+import {
+ Edit,
+ FileText,
+ Loader2,
AlertTriangle,
Trash2,
- CheckCircle,
- Clock
+ CheckCircle
} from "lucide-react"
import { toast } from "sonner"
import { updateRevisionAction, deleteRevisionAction } from "@/lib/vendor-document-list/enhanced-document-service" // ✅ 서버 액션 import
+import {
+ createEditRevisionSchema,
+ getUsageOptions,
+ getUsageTypeOptions,
+ getRevisionGuide,
+ B3RevisionInput
+} from "./revision-validation"
/* -------------------------------------------------------------------------------------------------
* Schema & Types
@@ -84,88 +90,16 @@ interface AttachmentInfo {
updatedAt: Date
}
-// drawingKind에 따른 동적 스키마 생성 (수정용)
-const createEditRevisionSchema = (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"), // ✅ revision 필드 추가
- comment: z.string().optional(),
- }
- // B3인 경우에만 usageType 필드 추가
- if (drawingKind === 'B3') {
- return z.object({
- ...baseSchema,
- usageType: z.string().min(1, "Please select a usage type"),
- })
- } else {
- return z.object({
- ...baseSchema,
- usageType: z.string().optional(),
- })
- }
-}
-// drawingKind에 따른 용도 옵션
-const getUsageOptions = (drawingKind: string) => {
- switch (drawingKind) {
- case 'B3':
- return [
- { value: "Approval", label: "Approval" },
- { value: "Working", label: "Working" },
- { value: "Comments", label: "Comments" },
- ]
- case 'B4':
- return [
- { value: "Pre", label: "Pre" },
- { value: "Working", label: "Working" },
- ]
- case 'B5':
- return [
- { value: "Pre", label: "Pre" },
- { value: "Working", label: "Working" },
- ]
- default:
- return [
- { value: "Pre", label: "Pre" },
- { value: "Working", label: "Working" },
- ]
- }
-}
-// B3 전용 용도 타입 옵션
-const getUsageTypeOptions = (usage: string) => {
- switch (usage) {
- case 'Approval':
- return [
- { value: "Full", label: "Full" },
- { value: "Partial", label: "Partial" },
- ]
- case 'Working':
- return [
- { value: "Full", label: "Full" },
- { value: "Partial", label: "Partial" },
- ]
- case 'Comments':
- return [
- { value: "Comments", label: "Comments" },
- ]
- default:
- return []
- }
-}
-
-// ✅ 리비전 가이드 생성 (NewRevisionDialog와 동일)
-const getRevisionGuide = () => {
- return "Enter in R01, R02, R03... format"
-}
interface EditRevisionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
revision: RevisionInfo | null
drawingKind?: string
- onSuccess: (action: 'update' | 'delete', result?: any) => void
+ onSuccess: (action: 'update' | 'delete', result?: unknown) => void
}
/* -------------------------------------------------------------------------------------------------
@@ -302,8 +236,8 @@ export function EditRevisionDialog({
// ✅ 리비전 가이드 텍스트
const revisionGuide = React.useMemo(() => {
- return getRevisionGuide()
- }, [])
+ return getRevisionGuide(drawingKind)
+ }, [drawingKind])
// revision이 변경될 때 폼 데이터 초기화
React.useEffect(() => {
@@ -324,7 +258,6 @@ export function EditRevisionDialog({
form.setValue("usageType", "Comments")
} else {
// Comments가 아닌 경우, 초기 로드가 아니라면 초기화
- const currentValue = form.getValues("usageType")
if (revision && watchedUsage !== revision.usage) {
form.setValue("usageType", "")
}
@@ -471,15 +404,31 @@ export function EditRevisionDialog({
<FormItem>
<FormLabel className="required">Revision</FormLabel>
<FormControl>
- <Input
- placeholder={revisionGuide}
- disabled={!canEdit}
- {...field}
- />
+ {drawingKind === 'B3' ? (
+ <B3RevisionInput
+ value={field.value}
+ onChange={field.onChange}
+ error={form.formState.errors.revision?.message}
+ />
+ ) : (
+ <>
+ <Input
+ placeholder={revisionGuide.placeholder}
+ disabled={!canEdit}
+ {...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>
)}
@@ -495,7 +444,7 @@ export function EditRevisionDialog({
<Select onValueChange={field.onChange} value={field.value} disabled={!canEdit}>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="Select usage" />
+ <SelectValue placeholder='Select usage' />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -526,7 +475,7 @@ export function EditRevisionDialog({
>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="Select usage type" />
+ <SelectValue placeholder='Select usage type' />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -539,7 +488,7 @@ export function EditRevisionDialog({
</Select>
{watchedUsage === "Comments" && (
<div className="text-xs text-muted-foreground mt-1">
- Automatically set to "Comments" for this usage
+ Automatically set to &quot;Comments&quot; for this usage
</div>
)}
<FormMessage />
@@ -574,7 +523,7 @@ export function EditRevisionDialog({
<div className="space-y-2">
<FormLabel>Attachments ({revision.attachments.length})</FormLabel>
<div className="space-y-2 max-h-32 overflow-y-auto border rounded-lg p-3">
- {revision.attachments.map((file, index) => (
+ {revision.attachments.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-2 bg-muted/50 rounded text-sm"
diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx
index 3ec58d1d..91694827 100644
--- a/components/ship-vendor-document/new-revision-dialog.tsx
+++ b/components/ship-vendor-document/new-revision-dialog.tsx
@@ -31,17 +31,22 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
-import {
- Upload,
- FileText,
- X,
- Loader2,
- CheckCircle,
- Info
+import {
+ Upload,
+ FileText,
+ X,
+ Loader2,
+ CheckCircle
} from "lucide-react"
import { toast } from "sonner"
import { useSession } from "next-auth/react"
-import { Alert, AlertDescription } from "@/components/ui/alert"
+import {
+ createUploadRevisionSchema,
+ getUsageOptions,
+ getUsageTypeOptions,
+ getRevisionGuide,
+ B3RevisionInput
+} from "./revision-validation"
// 기존 메인 컴포넌트에서 추가할 import
// import { NewRevisionDialog } from "./new-revision-dialog"
@@ -50,166 +55,12 @@ import { Alert, AlertDescription } from "@/components/ui/alert"
* Schema & Types
* -----------------------------------------------------------------------------------------------*/
-// 파일 검증 스키마
-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"),
- comment: z.string().optional(),
- attachments: z
- .array(z.instanceof(File))
- .min(1, "Please upload at least 1 file")
- .refine(
- (files) => files.every((file) => file.size <= MAX_FILE_SIZE),
- "File size must be 50MB or less"
- ),
- }
- // 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(),
- })
- }
-}
-// drawingKind에 따른 용도 옵션
-const getUsageOptions = (drawingKind: string) => {
- switch (drawingKind) {
- case 'B3':
- return [
- { value: "Approval", label: "Approval" },
- { value: "Working", label: "Working" },
- { value: "Comments", label: "Comments" },
- ]
- case 'B4':
- return [
- { value: "Pre", label: "Pre" },
- { value: "Working", label: "Working" },
- ]
- case 'B5':
- return [
- { value: "Pre", label: "Pre" },
- { value: "Working", label: "Working" },
- ]
- default:
- return [
- { value: "Pre", label: "Pre" },
- { value: "Working", label: "Working" },
- ]
- }
-}
-// B3 전용 용도 타입 옵션
-const getUsageTypeOptions = (usage: string) => {
- switch (usage) {
- case 'Approval':
- return [
- { value: "Full", label: "Full" },
- { value: "Partial", label: "Partial" },
- ]
- case 'Working':
- return [
- { value: "Full", label: "Full" },
- { value: "Partial", label: "Partial" },
- ]
- case 'Comments':
- return [
- { value: "Comments", label: "Comments" },
- ]
- default:
- return []
- }
-}
-
-// 리비전 형식 가이드 생성
-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 {
open: boolean
@@ -217,7 +68,7 @@ interface NewRevisionDialogProps {
documentId: number
documentTitle?: string
drawingKind: string
- onSuccess?: (result?: any) => void
+ onSuccess?: (result?: unknown) => void
}
/* -------------------------------------------------------------------------------------------------
@@ -329,55 +180,6 @@ 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
@@ -398,15 +200,30 @@ export function NewRevisionDialog({
// Serial No 조회
const fetchNextSerialNo = React.useCallback(async () => {
+ console.log('🔍 fetchNextSerialNo called with documentId:', documentId)
setIsLoadingSerialNo(true)
try {
- const response = await fetch(`/api/revisions/max-serial-no?documentId=${documentId}`)
+ const apiUrl = `/api/revisions/max-serial-no?documentId=${documentId}`
+ console.log('🔍 Calling API:', apiUrl)
+
+ const response = await fetch(apiUrl)
+ console.log('🔍 API Response status:', response.status)
+
if (response.ok) {
const data = await response.json()
- setNextSerialNo(String(data.nextSerialNo))
+ console.log('🔍 API Response data:', data)
+ console.log('🔍 data.nextSerialNo:', data.nextSerialNo)
+
+ const serialNoString = String(data.nextSerialNo)
+ console.log('🔍 Setting nextSerialNo to:', serialNoString)
+
+ setNextSerialNo(serialNoString)
+ console.log('🔍 nextSerialNo state updated')
+ } else {
+ console.error('🔍 API call failed with status:', response.status)
}
} catch (error) {
- console.error('Failed to fetch serial no:', error)
+ console.error('❌ Failed to fetch serial no:', error)
// 에러 시 기본값 1 사용
setNextSerialNo("1")
} finally {
@@ -416,8 +233,12 @@ export function NewRevisionDialog({
// Dialog 열릴 때 Serial No 조회
React.useEffect(() => {
+ console.log('🎯 useEffect triggered - open:', open, 'documentId:', documentId)
if (open && documentId) {
+ console.log('🎯 Calling fetchNextSerialNo')
fetchNextSerialNo()
+ } else {
+ console.log('🎯 Conditions not met for fetchNextSerialNo')
}
}, [open, documentId, fetchNextSerialNo])
@@ -427,7 +248,7 @@ export function NewRevisionDialog({
}, [session]);
// drawingKind에 따른 동적 스키마 및 옵션 생성
- const revisionUploadSchema = React.useMemo(() => createRevisionUploadSchema(drawingKind), [drawingKind])
+ const revisionUploadSchema = React.useMemo(() => createUploadRevisionSchema(drawingKind), [drawingKind])
const usageOptions = React.useMemo(() => getUsageOptions(drawingKind), [drawingKind])
const showUsageType = drawingKind === 'B3'
@@ -480,6 +301,10 @@ export function NewRevisionDialog({
}
const onSubmit = async (data: RevisionUploadSchema) => {
+ console.log('🚀 onSubmit called with data:', data)
+ console.log('🚀 Current nextSerialNo state:', nextSerialNo)
+ console.log('🚀 documentId:', documentId)
+
setIsUploading(true)
setUploadProgress(0)
@@ -487,6 +312,8 @@ export function NewRevisionDialog({
const formData = new FormData()
formData.append("documentId", String(documentId))
formData.append("serialNo", nextSerialNo) // 추가
+ console.log('🚀 Appending serialNo to formData:', nextSerialNo)
+
formData.append("usage", data.usage)
formData.append("revision", data.revision)
formData.append("uploaderName", userName || "evcp")
@@ -603,8 +430,15 @@ export function NewRevisionDialog({
<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" />
+ <>
+ <Loader2 className="inline-block ml-2 h-3 w-3 animate-spin" />
+ <span className="ml-1">Loading...</span>
+ </>
)}
+ {/* 디버그용 임시 표시 */}
+ <div className="mt-1 text-xs text-orange-600">
+ Debug: nextSerialNo={nextSerialNo}, isLoading={isLoadingSerialNo}
+ </div>
</div>
</DialogDescription>
)}
diff --git a/components/ship-vendor-document/revision-validation.tsx b/components/ship-vendor-document/revision-validation.tsx
new file mode 100644
index 00000000..4ff621a0
--- /dev/null
+++ b/components/ship-vendor-document/revision-validation.tsx
@@ -0,0 +1,266 @@
+import React from "react"
+import { z } from "zod"
+import { Input } from "@/components/ui/input"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { Info } from "lucide-react"
+
+// 파일 검증 스키마
+export const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+
+// B3 리비전 검증 함수
+export 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 리비전 검증 함수
+export const validateB4Revision = (value: string) => {
+ // B4 리비전 패턴: R01-R99
+ const numericPattern = /^R(0[1-9]|[1-9][0-9])$/
+ return numericPattern.test(value)
+}
+
+// 리비전 검증 함수 (drawingKind에 따라)
+export const validateRevision = (value: string, drawingKind: string) => {
+ if (drawingKind === 'B3') {
+ return validateB3Revision(value)
+ } else {
+ return validateB4Revision(value)
+ }
+}
+
+// 리비전 수정용 스키마 생성
+export const createEditRevisionSchema = (drawingKind: string) => {
+ const baseSchema = {
+ usage: z.string().min(1, "Please select a usage"),
+ comment: z.string().optional(),
+ }
+
+ // drawingKind에 따른 리비전 검증 추가
+ 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(),
+ })
+ }
+}
+
+// 리비전 업로드용 스키마 생성
+export const createUploadRevisionSchema = (drawingKind: string) => {
+ const baseSchema = {
+ usage: z.string().min(1, "Please select a usage"),
+ comment: z.string().optional(),
+ attachments: z
+ .array(z.instanceof(File))
+ .min(1, "Please upload at least 1 file")
+ .refine(
+ (files) => files.every((file) => file.size <= MAX_FILE_SIZE),
+ "File size must be 50MB or less"
+ ),
+ }
+
+ // drawingKind에 따른 리비전 검증 추가
+ 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(),
+ })
+ }
+}
+
+// drawingKind에 따른 용도 옵션
+export const getUsageOptions = (drawingKind: string) => {
+ switch (drawingKind) {
+ case 'B3':
+ return [
+ { value: "Approval", label: "Approval" },
+ { value: "Working", label: "Working" },
+ { value: "Comments", label: "Comments" },
+ ]
+ case 'B4':
+ return [
+ { value: "Pre", label: "Pre" },
+ { value: "Working", label: "Working" },
+ ]
+ case 'B5':
+ return [
+ { value: "Pre", label: "Pre" },
+ { value: "Working", label: "Working" },
+ ]
+ default:
+ return [
+ { value: "Pre", label: "Pre" },
+ { value: "Working", label: "Working" },
+ ]
+ }
+}
+
+// B3 전용 용도 타입 옵션
+export const getUsageTypeOptions = (usage: string) => {
+ switch (usage) {
+ case 'Approval':
+ return [
+ { value: "Full", label: "Full" },
+ { value: "Partial", label: "Partial" },
+ ]
+ case 'Working':
+ return [
+ { value: "Full", label: "Full" },
+ { value: "Partial", label: "Partial" },
+ ]
+ case 'Comments':
+ return [
+ { value: "Comments", label: "Comments" },
+ ]
+ default:
+ return []
+ }
+}
+
+// 리비전 형식 가이드 생성
+export 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 리비전 자동 포맷팅 함수
+export 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
+}
+
+// B3 리비전 입력 컴포넌트
+export function B3RevisionInput({
+ value,
+ onChange,
+ error
+}: {
+ value: string
+ onChange: (value: string) => void
+ error?: string
+}) {
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const rawValue = e.target.value.toUpperCase()
+
+ // 길이 제한 (알파벳은 1자, R숫자는 3자)
+ if (rawValue.length <= 3) {
+ onChange(rawValue)
+ }
+ }
+
+ const handleBlur = () => {
+ // blur 시점에 포맷팅 적용
+ const formattedValue = formatB3RevisionInput(value)
+ onChange(formattedValue)
+ }
+
+ const revisionGuide = getRevisionGuide('B3')
+
+ return (
+ <div className="space-y-2">
+ <Input
+ value={value}
+ onChange={handleInputChange}
+ onBlur={handleBlur}
+ 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>
+ )
+}
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 775dac47..0c3390d1 100644
--- a/components/ship-vendor-document/user-vendor-document-table-container.tsx
+++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx
@@ -967,6 +967,7 @@ function SubTables() {
reviewerId: null,
reviewerName: null,
reviewComments: null,
+ serialNo: uploadResult.data.serialNo || null, // ✅ serialNo 추가
createdAt: new Date(),
updatedAt: new Date(),
stageName: uploadResult.data.stage,
@@ -997,11 +998,12 @@ function SubTables() {
)
if (targetStage) {
- // 기존 revision과 중복 체크 (같은 revision, usage, usageType)
+ // 기존 revision과 중복 체크 (같은 revision, usage, usageType, serialNo)
const isDuplicate = targetStage.revisions.some(rev =>
rev.revision === newRevision.revision &&
rev.usage === newRevision.usage &&
- rev.usageType === newRevision.usageType
+ rev.usageType === newRevision.usageType &&
+ rev.serialNo === newRevision.serialNo
)
if (!isDuplicate) {