summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/sedp/get-form-tags.ts9
-rw-r--r--lib/tags/table/add-tag-dialog.tsx76
-rw-r--r--lib/vendor-document-list/dolce-upload-service.ts606
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts163
-rw-r--r--lib/vendor-document-list/plant/document-stages-table.tsx39
-rw-r--r--lib/vendor-document-list/ship/send-to-shi-button.tsx18
-rw-r--r--lib/vendor-document-list/sync-service.ts65
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table.tsx90
-rw-r--r--lib/vendor-registration-status/vendor-registration-status-view.tsx260
-rw-r--r--lib/vendor-regular-registrations/major-items-update-sheet.tsx245
-rw-r--r--lib/vendor-regular-registrations/repository.ts157
-rw-r--r--lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx143
-rw-r--r--lib/vendor-regular-registrations/service.ts400
-rw-r--r--lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx143
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx105
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx108
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx34
17 files changed, 1921 insertions, 740 deletions
diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts
index efa4a9c0..34f990f3 100644
--- a/lib/sedp/get-form-tags.ts
+++ b/lib/sedp/get-form-tags.ts
@@ -459,6 +459,8 @@ export async function importTagsFromSEDP(
}
}
+ const packageCode = projectType === "ship" ? tagEntry.ATTRIBUTES.find(v=>v.ATT_ID === "CM3003")?.VALUE :tagEntry.ATTRIBUTES.find(v=>v.ATT_ID === "ME5074")?.VALUE
+
// 기본 태그 데이터 객체 생성 (formEntries용)
const tagObject: any = {
TAG_IDX: tagEntry.TAG_IDX, // SEDP 고유 식별자
@@ -468,7 +470,7 @@ export async function importTagsFromSEDP(
VNDRCD: vendorRecord[0].vendorCode,
VNDRNM_1: vendorRecord[0].vendorName,
status: "From S-EDP", // SEDP에서 가져온 데이터임을 표시
- ...(projectType === "ship" ? { CM3003: tagEntry.CM3003 } : { ME5074: tagEntry.ME5074 })
+ ...(projectType === "ship" ? { CM3003: packageCode } : { ME5074:packageCode })
}
// tags 테이블용 데이터 (UPSERT용)
@@ -542,6 +544,11 @@ export async function importTagsFromSEDP(
hasUpdates = true;
continue;
}
+ if (key === "CLS_ID" && tagObject[key] !== existingTag.data[key]) {
+ updates[key] = tagObject[key];
+ hasUpdates = true;
+ continue;
+ }
const columnInfo = columnsJSON.find(col => col.key === key);
if (columnInfo && columnInfo.shi === true) {
diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx
index e5207cd8..f3eaed3f 100644
--- a/lib/tags/table/add-tag-dialog.tsx
+++ b/lib/tags/table/add-tag-dialog.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { useRouter } from "next/navigation"
+import { useRouter, useParams } from "next/navigation"
import { useForm, useWatch, useFieldArray } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
@@ -51,6 +51,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
+import { useTranslation } from "@/i18n/client"
import type { CreateTagSchema } from "@/lib/tags/validations"
import { createTagSchema } from "@/lib/tags/validations"
@@ -102,6 +103,9 @@ interface AddTagDialogProps {
export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
const router = useRouter()
+ const params = useParams()
+ const lng = (params?.lng as string) || "ko"
+ const { t } = useTranslation(lng, "engineering")
const [open, setOpen] = React.useState(false)
const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([])
@@ -134,7 +138,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
const result = await getClassOptions(selectedPackageId)
setClassOptions(result)
} catch (err) {
- toast.error("클래스 옵션을 불러오는데 실패했습니다.")
+ toast.error(t("toast.classOptionsLoadFailed"))
} finally {
setIsLoadingClasses(false)
}
@@ -198,7 +202,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
form.setValue("rows", updatedRows);
return true
} catch (err) {
- toast.error("서브필드를 불러오는데 실패했습니다.")
+ toast.error(t("toast.subfieldsLoadFailed"))
setSubFields([])
return false
} finally {
@@ -310,7 +314,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
// ---------------
async function onSubmit(data: MultiTagFormValues) {
if (!selectedPackageId) {
- toast.error("No selectedPackageId.");
+ toast.error(t("toast.noSelectedPackageId"));
return;
}
@@ -353,12 +357,12 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
// Show results to the user
if (successfulTags.length > 0) {
- toast.success(`${successfulTags.length}개의 태그가 성공적으로 생성되었습니다!`);
+ toast.success(`${successfulTags.length}${t("toast.tagsCreatedSuccess")}`);
}
if (failedTags.length > 0) {
console.log("Failed tags:", failedTags);
- toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`);
+ toast.error(`${failedTags.length}${t("toast.tagsCreateFailed")}`);
}
// Refresh the page
@@ -370,7 +374,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
setOpen(false);
}
} catch (err) {
- toast.error("태그 생성 처리에 실패했습니다.");
+ toast.error(t("toast.tagProcessingFailed"));
} finally {
setIsSubmitting(false);
}
@@ -435,7 +439,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
return (
<FormItem className="w-1/3">
- <FormLabel>Class</FormLabel>
+ <FormLabel>{t("labels.class")}</FormLabel>
<FormControl>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
@@ -448,13 +452,13 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
>
{isLoadingClasses ? (
<>
- <span>클래스 로딩 중...</span>
+ <span>{t("messages.loadingClasses")}</span>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
</>
) : (
<>
<span className="truncate mr-1 flex-grow text-left">
- {field.value || "클래스 선택..."}
+ {field.value || t("placeholders.selectClass")}
</span>
<ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" />
</>
@@ -465,7 +469,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
<Command key={commandId}>
<CommandInput
key={`${commandId}-input`}
- placeholder="클래스 검색..."
+ placeholder={t("placeholders.searchClass")}
value={classSearchTerm}
onValueChange={setClassSearchTerm}
/>
@@ -475,7 +479,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
const target = e.currentTarget;
target.scrollTop += e.deltaY; // 직접 스크롤 처리
}}>
- <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty>
+ <CommandEmpty key={`${commandId}-empty`}>{t("messages.noSearchResults")}</CommandEmpty>
<CommandGroup key={`${commandId}-group`}>
{classOptions.map((opt, optIndex) => {
if (!classOptionIdsRef.current[opt.code]) {
@@ -543,7 +547,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
disabled={!selectedClassOption}
>
<SelectTrigger className="h-9">
- <SelectValue placeholder="서브클래스 선택..." />
+ <SelectValue placeholder={t("placeholders.selectSubclass")} />
</SelectTrigger>
<SelectContent>
{selectedClassOption?.subclasses.map((subclass) => (
@@ -576,7 +580,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
return (
<FormItem className={width}>
- <FormLabel>Tag Type</FormLabel>
+ <FormLabel>{t("labels.tagType")}</FormLabel>
<FormControl>
{isReadOnly ? (
<div className="relative">
@@ -592,7 +596,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
key={`tag-type-placeholder-${inputId}`}
{...field}
readOnly
- placeholder="클래스 선택시 자동으로 결정됩니다"
+ placeholder={t("placeholders.autoSetByClass")}
className="h-9 bg-muted"
/>
)}
@@ -610,15 +614,15 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
return (
<div className="flex justify-center items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
- <div className="ml-3 text-muted-foreground">필드 로딩 중...</div>
+ <div className="ml-3 text-muted-foreground">{t("messages.loadingFields")}</div>
</div>
)
}
if (subFields.length === 0 && selectedTagTypeCode) {
const message = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0
- ? "서브클래스를 선택해주세요."
- : "이 태그 유형에 대한 필드가 없습니다."
+ ? t("messages.selectSubclassFirst")
+ : t("messages.noFieldsForTagType")
return (
<div className="py-4 text-center text-muted-foreground">
@@ -630,7 +634,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
if (subFields.length === 0) {
return (
<div className="py-4 text-center text-muted-foreground">
- 태그 데이터를 입력하려면 먼저 상단에서 클래스를 선택하세요.
+ {t("messages.selectClassFirst")}
</div>
)
}
@@ -639,10 +643,10 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
<div className="space-y-4">
{/* 헤더 */}
<div className="flex justify-between items-center">
- <h3 className="text-sm font-medium">태그 항목 ({fields.length}개)</h3>
+ <h3 className="text-sm font-medium">{t("sections.tagItems")} ({fields.length}개)</h3>
{!areAllTagNosValid && (
<Badge variant="destructive" className="ml-2">
- 유효하지 않은 태그 존재
+ {t("messages.invalidTagsExist")}
</Badge>
)}
</div>
@@ -655,10 +659,10 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
<TableRow>
<TableHead className="w-10 text-center">#</TableHead>
<TableHead className="w-[120px]">
- <div className="font-medium">Tag No</div>
+ <div className="font-medium">{t("labels.tagNo")}</div>
</TableHead>
<TableHead className="w-[180px]">
- <div className="font-medium">Description</div>
+ <div className="font-medium">{t("labels.description")}</div>
</TableHead>
{/* Subfields */}
@@ -680,7 +684,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
</TableHead>
))}
- <TableHead className="w-[100px] text-center sticky right-0 bg-muted">Actions</TableHead>
+ <TableHead className="w-[100px] text-center sticky right-0 bg-muted">{t("labels.actions")}</TableHead>
</TableRow>
</TableHeader>
@@ -738,7 +742,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
<Input
{...field}
className="h-8 w-full"
- placeholder="항목 이름 입력"
+ placeholder={t("placeholders.enterDescription")}
title={field.value || ""}
/>
</FormControl>
@@ -768,7 +772,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
className="w-full h-8 truncate"
title={field.value || ""}
>
- <SelectValue placeholder={`선택...`} className="truncate" />
+ <SelectValue placeholder={t("placeholders.selectOption")} className="truncate" />
</SelectTrigger>
<SelectContent
align="start"
@@ -792,7 +796,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
<Input
{...field}
className="h-8 w-full"
- placeholder={`입력...`}
+ placeholder={t("placeholders.enterValue")}
title={field.value || ""}
/>
)}
@@ -820,7 +824,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
</Button>
</TooltipTrigger>
<TooltipContent side="left">
- <p>행 복제</p>
+ <p>{t("tooltips.duplicateRow")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -843,7 +847,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
</Button>
</TooltipTrigger>
<TooltipContent side="left">
- <p>행 삭제</p>
+ <p>{t("tooltips.deleteRow")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -864,7 +868,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
disabled={!selectedTagTypeCode || isLoadingSubFields || subFields.length === 0}
>
<Plus className="h-4 w-4 mr-2" />
- 새 행 추가
+ {t("buttons.addRow")}
</Button>
</div>
</div>
@@ -903,15 +907,15 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
>
<DialogTrigger asChild>
<Button variant="default" size="sm">
- 태그 추가
+ {t("buttons.addTags")}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}>
<DialogHeader>
- <DialogTitle>새 태그 추가</DialogTitle>
+ <DialogTitle>{t("dialogs.addFormTag")}</DialogTitle>
<DialogDescription>
- 클래스와 서브클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요.
+ {t("dialogs.selectClassToLoadFields")}
</DialogDescription>
</DialogHeader>
@@ -968,7 +972,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
}}
disabled={isSubmitting}
>
- 취소
+ {t("buttons.cancel")}
</Button>
<Button
type="submit"
@@ -977,10 +981,10 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
- 처리 중...
+ {t("messages.processing")}
</>
) : (
- `${fields.length}개 태그 생성`
+ `${fields.length}${t("buttons.createTags")}`
)}
</Button>
</div>
diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts
index 2d6a83c6..84ae4525 100644
--- a/lib/vendor-document-list/dolce-upload-service.ts
+++ b/lib/vendor-document-list/dolce-upload-service.ts
@@ -5,6 +5,8 @@ import { eq, and, desc, sql, inArray, min } from "drizzle-orm"
import { v4 as uuidv4 } from "uuid"
import path from "path"
import * as crypto from "crypto"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
export interface DOLCEUploadResult {
success: boolean
@@ -87,7 +89,7 @@ interface DOLCEFileMapping {
function getFileReaderConfig(): FileReaderConfig {
const isProduction = process.env.NODE_ENV === "production";
-
+
if (isProduction) {
return {
baseDir: process.env.NAS_PATH || "/evcp_nas", // NAS 기본 경로
@@ -118,13 +120,13 @@ class DOLCEUploadService {
): Promise<DOLCEUploadResult> {
try {
console.log(`Starting DOLCE upload for contract ${projectId}, revisions: ${revisionIds.join(', ')}`)
-
+
// 1. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등)
const contractInfo = await this.getContractInfo(projectId)
if (!contractInfo) {
throw new Error(`Contract info not found for ID: ${projectId}`)
}
-
+
// 2. 업로드할 리비전 정보 조회
const revisionsToUpload = await this.getRevisionsForUpload(revisionIds)
if (revisionsToUpload.length === 0) {
@@ -134,7 +136,7 @@ class DOLCEUploadService {
uploadedFiles: 0
}
}
-
+
let uploadedDocuments = 0
let uploadedFiles = 0
const errors: string[] = []
@@ -143,19 +145,19 @@ class DOLCEUploadService {
fileResults: [],
mappingResults: []
}
-
+
// 3. 각 리비전별로 처리
for (const revision of revisionsToUpload) {
try {
console.log(`Processing revision ${revision.revision} for document ${revision.documentNo}`)
-
+
// 3-1. UploadId 미리 생성 (파일이 있는 경우에만)
let uploadId: string | undefined
if (revision.attachments && revision.attachments.length > 0) {
uploadId = uuidv4() // 문서 업로드 시 사용할 UploadId 미리 생성
console.log(`Generated UploadId for document upload: ${uploadId}`)
}
-
+
// 3-2. 문서 정보 업로드 (UploadId 포함)
const dolceDoc = this.transformToDoLCEDocument(
revision,
@@ -163,43 +165,43 @@ class DOLCEUploadService {
uploadId, // 미리 생성된 UploadId 사용
contractInfo.vendorCode,
)
-
+
const docResult = await this.uploadDocument([dolceDoc], userId)
if (!docResult.success) {
errors.push(`Document upload failed for ${revision.documentNo}: ${docResult.error}`)
continue // 문서 업로드 실패 시 다음 리비전으로 넘어감
}
-
+
uploadedDocuments++
results.documentResults.push(docResult)
console.log(`✅ Document uploaded successfully: ${revision.documentNo}`)
-
+
// 3-3. 파일 업로드 (이미 생성된 UploadId 사용)
if (uploadId && revision.attachments && revision.attachments.length > 0) {
try {
// 파일 업로드 시 이미 생성된 UploadId 사용
const fileUploadResults = await this.uploadFiles(
- revision.attachments,
- userId,
+ revision.attachments,
+ userId,
uploadId // 이미 생성된 UploadId 전달
)
-
+
} catch (fileError) {
errors.push(`File upload failed for ${revision.documentNo}: ${fileError instanceof Error ? fileError.message : 'Unknown error'}`)
console.error(`❌ File upload failed for ${revision.documentNo}:`, fileError)
}
}
-
+
// 3-5. 성공한 리비전의 상태 업데이트
await this.updateRevisionStatus(revision.id, 'SUBMITTED', uploadId)
-
+
} catch (error) {
const errorMessage = `Failed to process revision ${revision.revision}: ${error instanceof Error ? error.message : 'Unknown error'}`
errors.push(errorMessage)
console.error(errorMessage, error)
}
}
-
+
return {
success: errors.length === 0,
uploadedDocuments,
@@ -207,7 +209,7 @@ class DOLCEUploadService {
errors: errors.length > 0 ? errors : undefined,
results
}
-
+
} catch (error) {
console.error('DOLCE upload failed:', error)
throw error
@@ -216,22 +218,34 @@ class DOLCEUploadService {
/**
* 계약 정보 조회
*/
- private async getContractInfo(projectId: number) {
+ private async getContractInfo(projectId: number): Promise<{
+ projectCode: string;
+ vendorCode: string;
+ } | null> {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+
const [result] = await db
.select({
projectCode: projects.code,
- vendorCode: vendors.vendorCode,
- contractNo: contracts.contractNo
+ vendorCode: vendors.vendorCode
})
.from(contracts)
.innerJoin(projects, eq(contracts.projectId, projects.id))
.innerJoin(vendors, eq(contracts.vendorId, vendors.id))
- .where(eq(contracts.projectId, projectId))
+ .where(and(eq(contracts.projectId, projectId), eq(contracts.vendorId, Number(session.user.companyId))))
.limit(1)
- return result
+ return result?.projectCode && result?.vendorCode
+ ? { projectCode: result.projectCode, vendorCode: result.vendorCode }
+ : null
}
+
/**
* 각 issueStageId별로 첫 번째 revision 정보를 조회
*/
@@ -264,7 +278,7 @@ class DOLCEUploadService {
.select({
// revision 테이블 정보
id: revisions.id,
- registerId:revisions.registerId,
+ registerId: revisions.registerId,
revision: revisions.revision, // revisionNo가 아니라 revision
revisionStatus: revisions.revisionStatus,
uploaderId: revisions.uploaderId,
@@ -341,181 +355,181 @@ class DOLCEUploadService {
return revisionsWithAttachments
}
-/**
- * 파일 업로드 (PWPUploadService.ashx) - 수정된 버전
- * @param attachments 업로드할 첨부파일 목록
- * @param userId 사용자 ID
- * @param uploadId 이미 생성된 UploadId (문서 업로드 시 생성됨)
- */
-private async uploadFiles(
- attachments: any[],
- userId: string,
- uploadId: string // 이미 생성된 UploadId를 매개변수로 받음
-): Promise<Array<{ uploadId: string, fileId: string, filePath: string }>> {
- const uploadResults = []
- const resultDataArray: ResultData[] = []
+ /**
+ * 파일 업로드 (PWPUploadService.ashx) - 수정된 버전
+ * @param attachments 업로드할 첨부파일 목록
+ * @param userId 사용자 ID
+ * @param uploadId 이미 생성된 UploadId (문서 업로드 시 생성됨)
+ */
+ private async uploadFiles(
+ attachments: any[],
+ userId: string,
+ uploadId: string // 이미 생성된 UploadId를 매개변수로 받음
+ ): Promise<Array<{ uploadId: string, fileId: string, filePath: string }>> {
+ const uploadResults = []
+ const resultDataArray: ResultData[] = []
- for (let i = 0; i < attachments.length; i++) {
- const attachment = attachments[i]
- try {
- // FileId만 새로 생성 (UploadId는 이미 생성된 것 사용)
- const fileId = uuidv4()
+ for (let i = 0; i < attachments.length; i++) {
+ const attachment = attachments[i]
+ try {
+ // FileId만 새로 생성 (UploadId는 이미 생성된 것 사용)
+ const fileId = uuidv4()
- console.log(`Uploading file with predefined UploadId: ${uploadId}, FileId: ${fileId}`)
+ console.log(`Uploading file with predefined UploadId: ${uploadId}, FileId: ${fileId}`)
- // 파일 데이터 읽기
- const fileBuffer = await this.getFileBuffer(attachment.filePath)
+ // 파일 데이터 읽기
+ const fileBuffer = await this.getFileBuffer(attachment.filePath)
- const uploadUrl = `${this.UPLOAD_SERVICE_URL}?UploadId=${uploadId}&FileId=${fileId}`
+ const uploadUrl = `${this.UPLOAD_SERVICE_URL}?UploadId=${uploadId}&FileId=${fileId}`
- const response = await fetch(uploadUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/octet-stream',
- },
- body: fileBuffer
- })
+ const response = await fetch(uploadUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ },
+ body: fileBuffer
+ })
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`File upload failed: HTTP ${response.status} - ${errorText}`)
- }
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`File upload failed: HTTP ${response.status} - ${errorText}`)
+ }
- const dolceFilePath = await response.text() // DOLCE에서 반환하는 파일 경로
-
- // 업로드 성공 후 documentAttachments 테이블 업데이트
- await db
- .update(documentAttachments)
- .set({
- uploadId: uploadId, // 이미 생성된 UploadId 사용
- fileId: fileId,
- uploadedBy: userId,
- dolceFilePath: dolceFilePath,
- uploadedAt: new Date(),
- updatedAt: new Date()
+ const dolceFilePath = await response.text() // DOLCE에서 반환하는 파일 경로
+
+ // 업로드 성공 후 documentAttachments 테이블 업데이트
+ await db
+ .update(documentAttachments)
+ .set({
+ uploadId: uploadId, // 이미 생성된 UploadId 사용
+ fileId: fileId,
+ uploadedBy: userId,
+ dolceFilePath: dolceFilePath,
+ uploadedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(documentAttachments.id, attachment.id))
+
+ uploadResults.push({
+ uploadId,
+ fileId,
+ filePath: dolceFilePath
})
- .where(eq(documentAttachments.id, attachment.id))
- uploadResults.push({
- uploadId,
- fileId,
- filePath: dolceFilePath
- })
-
- // ResultData 객체 생성 (PWPUploadResultService 호출용)
- const fileStats = await this.getFileStats(attachment.filePath) // 파일 통계 정보 조회
-
- const resultData: ResultData = {
- FileId: fileId,
- UploadId: uploadId,
- FileSeq: i + 1, // 1부터 시작하는 시퀀스
- FileName: attachment.fileName,
- FileRelativePath: dolceFilePath,
- FileSize: fileStats.size,
- FileCreateDT: fileStats.birthtime.toISOString(),
- FileWriteDT: fileStats.mtime.toISOString(),
- OwnerUserId: userId
- }
+ // ResultData 객체 생성 (PWPUploadResultService 호출용)
+ const fileStats = await this.getFileStats(attachment.filePath) // 파일 통계 정보 조회
+
+ const resultData: ResultData = {
+ FileId: fileId,
+ UploadId: uploadId,
+ FileSeq: i + 1, // 1부터 시작하는 시퀀스
+ FileName: attachment.fileName,
+ FileRelativePath: dolceFilePath,
+ FileSize: fileStats.size,
+ FileCreateDT: fileStats.birthtime.toISOString(),
+ FileWriteDT: fileStats.mtime.toISOString(),
+ OwnerUserId: userId
+ }
- resultDataArray.push(resultData)
+ resultDataArray.push(resultData)
- console.log(`✅ File uploaded successfully: ${attachment.fileName} -> ${dolceFilePath}`)
- console.log(`✅ DB updated for attachment ID: ${attachment.id}`)
+ console.log(`✅ File uploaded successfully: ${attachment.fileName} -> ${dolceFilePath}`)
+ console.log(`✅ DB updated for attachment ID: ${attachment.id}`)
- // 🧪 DOLCE 업로드 확인 테스트
- try {
- const testResult = await this.testDOLCEFileDownload(fileId, userId, attachment.fileName)
- if (testResult.success) {
- console.log(`✅ DOLCE 업로드 확인 성공: ${attachment.fileName}`)
- } else {
- console.warn(`⚠️ DOLCE 업로드 확인 실패: ${attachment.fileName} - ${testResult.error}`)
+ // 🧪 DOLCE 업로드 확인 테스트
+ try {
+ const testResult = await this.testDOLCEFileDownload(fileId, userId, attachment.fileName)
+ if (testResult.success) {
+ console.log(`✅ DOLCE 업로드 확인 성공: ${attachment.fileName}`)
+ } else {
+ console.warn(`⚠️ DOLCE 업로드 확인 실패: ${attachment.fileName} - ${testResult.error}`)
+ }
+ } catch (testError) {
+ console.warn(`⚠️ DOLCE 업로드 확인 중 오류: ${attachment.fileName}`, testError)
}
- } catch (testError) {
- console.warn(`⚠️ DOLCE 업로드 확인 중 오류: ${attachment.fileName}`, testError)
- }
- } catch (error) {
- console.error(`❌ File upload failed for ${attachment.fileName}:`, error)
- throw error
+ } catch (error) {
+ console.error(`❌ File upload failed for ${attachment.fileName}:`, error)
+ throw error
+ }
}
- }
- // 모든 파일 업로드가 완료된 후 PWPUploadResultService 호출
- if (resultDataArray.length > 0) {
- try {
- await this.finalizeUploadResult(resultDataArray)
- console.log(`✅ Upload result finalized for UploadId: ${uploadId}`)
- } catch (error) {
- console.error(`❌ Failed to finalize upload result for UploadId: ${uploadId}`, error)
- // 파일 업로드는 성공했지만 결과 저장 실패 - 로그만 남기고 계속 진행
+ // 모든 파일 업로드가 완료된 후 PWPUploadResultService 호출
+ if (resultDataArray.length > 0) {
+ try {
+ await this.finalizeUploadResult(resultDataArray)
+ console.log(`✅ Upload result finalized for UploadId: ${uploadId}`)
+ } catch (error) {
+ console.error(`❌ Failed to finalize upload result for UploadId: ${uploadId}`, error)
+ // 파일 업로드는 성공했지만 결과 저장 실패 - 로그만 남기고 계속 진행
+ }
}
+
+ return uploadResults
}
- return uploadResults
-}
+ private async finalizeUploadResult(resultDataArray: ResultData[]): Promise<void> {
+ const url = `${this.BASE_URL}/PWPUploadResultService.ashx?`
-private async finalizeUploadResult(resultDataArray: ResultData[]): Promise<void> {
- const url = `${this.BASE_URL}/PWPUploadResultService.ashx?`
-
- try {
- const jsonData = JSON.stringify(resultDataArray)
- const dataBuffer = Buffer.from(jsonData, 'utf-8')
+ try {
+ const jsonData = JSON.stringify(resultDataArray)
+ const dataBuffer = Buffer.from(jsonData, 'utf-8')
- console.log(`Calling PWPUploadResultService with ${resultDataArray.length} files`)
- console.log('ResultData:', JSON.stringify(resultDataArray, null, 2))
+ console.log(`Calling PWPUploadResultService with ${resultDataArray.length} files`)
+ console.log('ResultData:', JSON.stringify(resultDataArray, null, 2))
- const response = await fetch(url, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: dataBuffer
- })
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: dataBuffer
+ })
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`PWPUploadResultService failed: HTTP ${response.status} - ${errorText}`)
- }
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`PWPUploadResultService failed: HTTP ${response.status} - ${errorText}`)
+ }
- const result = await response.text()
-
- if (result !== 'Success') {
- console.log(result,"돌체 업로드 실패")
- throw new Error(`PWPUploadResultService returned unexpected result: ${result}`)
- }
+ const result = await response.text()
- console.log('✅ PWPUploadResultService call successful')
+ if (result !== 'Success') {
+ console.log(result, "돌체 업로드 실패")
+ throw new Error(`PWPUploadResultService returned unexpected result: ${result}`)
+ }
- } catch (error) {
- console.error('❌ PWPUploadResultService call failed:', error)
- throw error
- }
-}
+ console.log('✅ PWPUploadResultService call successful')
-// 파일 통계 정보 조회 헬퍼 메서드 (파일시스템에서 파일 정보를 가져옴)
-private async getFileStats(filePath: string): Promise<{ size: number, birthtime: Date, mtime: Date }> {
- try {
- // Node.js 환경이라면 fs.stat 사용
- const fs = require('fs').promises
- const stats = await fs.stat(filePath)
-
- return {
- size: stats.size,
- birthtime: stats.birthtime,
- mtime: stats.mtime
+ } catch (error) {
+ console.error('❌ PWPUploadResultService call failed:', error)
+ throw error
}
- } catch (error) {
- console.warn(`Could not get file stats for ${filePath}, using defaults`)
- // 파일 정보를 가져올 수 없는 경우 기본값 사용
- const now = new Date()
- return {
- size: 0,
- birthtime: now,
- mtime: now
+ }
+
+ // 파일 통계 정보 조회 헬퍼 메서드 (파일시스템에서 파일 정보를 가져옴)
+ private async getFileStats(filePath: string): Promise<{ size: number, birthtime: Date, mtime: Date }> {
+ try {
+ // Node.js 환경이라면 fs.stat 사용
+ const fs = require('fs').promises
+ const stats = await fs.stat(filePath)
+
+ return {
+ size: stats.size,
+ birthtime: stats.birthtime,
+ mtime: stats.mtime
+ }
+ } catch (error) {
+ console.warn(`Could not get file stats for ${filePath}, using defaults`)
+ // 파일 정보를 가져올 수 없는 경우 기본값 사용
+ const now = new Date()
+ return {
+ size: 0,
+ birthtime: now,
+ mtime: now
+ }
}
}
-}
/**
* 문서 정보 업로드 (DetailDwgReceiptMgmtEdit)
@@ -604,125 +618,125 @@ private async getFileStats(filePath: string): Promise<{ size: number, birthtime:
/**
* 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용)
*/
-/**
- * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용)
- */
-private transformToDoLCEDocument(
- revision: any,
- contractInfo: any,
- uploadId?: string,
- vendorCode?: string,
-): DOLCEDocument {
- // Mode 결정: registerId가 있으면 MOD, 없으면 ADD
- let mode: "ADD" | "MOD" = "ADD" // 기본값은 ADD
-
- if (revision.registerId) {
- mode = "MOD"
- } else {
- mode = "ADD"
- }
+ /**
+ * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용)
+ */
+ private transformToDoLCEDocument(
+ revision: any,
+ contractInfo: any,
+ uploadId?: string,
+ vendorCode?: string,
+ ): DOLCEDocument {
+ // Mode 결정: registerId가 있으면 MOD, 없으면 ADD
+ let mode: "ADD" | "MOD" = "ADD" // 기본값은 ADD
+
+ if (revision.registerId) {
+ mode = "MOD"
+ } else {
+ mode = "ADD"
+ }
- // RegisterKind 결정: usage와 usageType에 따라 설정
- let registerKind = "APPR" // 기본값
-
- 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" // 기본값
- }
- break
-
- case "WORKING":
- if (revision.usageType === "Full") {
- registerKind = "WORK"
- } else if (revision.usageType === "Partial") {
- registerKind = "WORK-P"
- } else {
- registerKind = "WORK" // 기본값
- }
- break
+ // RegisterKind 결정: usage와 usageType에 따라 설정
+ let registerKind = "APPR" // 기본값
+
+ 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" // 기본값
+ }
+ break
+
+ case "WORKING":
+ if (revision.usageType === "Full") {
+ registerKind = "WORK"
+ } else if (revision.usageType === "Partial") {
+ registerKind = "WORK-P"
+ } else {
+ registerKind = "WORK" // 기본값
+ }
+ break
- case "The 1st":
- registerKind = "FMEA-1"
- break
+ case "The 1st":
+ registerKind = "FMEA-1"
+ break
- case "The 2nd":
- registerKind = "FMEA-2"
- break
+ case "The 2nd":
+ registerKind = "FMEA-2"
+ break
- case "Pre":
- registerKind = "RECP"
- break
+ case "Pre":
+ registerKind = "RECP"
+ break
- case "Working":
- registerKind = "RECW"
- break
+ case "Working":
+ registerKind = "RECW"
+ break
- case "Mark-Up":
- registerKind = "CMTM"
- break
+ case "Mark-Up":
+ registerKind = "CMTM"
+ break
- default:
- console.warn(`Unknown usage type: ${revision.usage}, using default APPR`)
- registerKind = "APPR" // 기본값
- break
+ default:
+ console.warn(`Unknown usage type: ${revision.usage}, using default APPR`)
+ registerKind = "APPR" // 기본값
+ break
+ }
+ } else {
+ console.warn(`No usage specified for revision ${revision.revision}, using default APPR`)
}
- } else {
- console.warn(`No usage specified for revision ${revision.revision}, using default APPR`)
- }
- // Serial Number 계산 함수
- const getSerialNumber = (revisionValue: string): number => {
- if (!revisionValue) {
- return 1
- }
+ // Serial Number 계산 함수
+ const getSerialNumber = (revisionValue: string): number => {
+ if (!revisionValue) {
+ return 1
+ }
- // 먼저 숫자인지 확인
- const numericValue = parseInt(revisionValue)
- if (!isNaN(numericValue)) {
- return numericValue
- }
+ // 먼저 숫자인지 확인
+ const numericValue = parseInt(revisionValue)
+ if (!isNaN(numericValue)) {
+ return numericValue
+ }
- // 문자인 경우 (a=1, b=2, c=3, ...)
- if (typeof revisionValue === 'string' && revisionValue.length === 1) {
- const charCode = revisionValue.toLowerCase().charCodeAt(0)
- if (charCode >= 97 && charCode <= 122) { // a-z
- return charCode - 96 // a=1, b=2, c=3, ...
+ // 문자인 경우 (a=1, b=2, c=3, ...)
+ if (typeof revisionValue === 'string' && revisionValue.length === 1) {
+ const charCode = revisionValue.toLowerCase().charCodeAt(0)
+ if (charCode >= 97 && charCode <= 122) { // a-z
+ return charCode - 96 // a=1, b=2, c=3, ...
+ }
}
+
+ // 기본값
+ return 1
}
- // 기본값
- return 1
- }
+ console.log(`Transform to DOLCE: Mode=${mode}, RegisterKind=${registerKind}, Usage=${revision.usage}, UsageType=${revision.usageType}`)
- console.log(`Transform to DOLCE: Mode=${mode}, RegisterKind=${registerKind}, Usage=${revision.usage}, UsageType=${revision.usageType}`)
-
- return {
- Mode: mode,
- Status: revision.revisionStatus || "Standby",
- RegisterId: revision.registerId || 0, // registerId가 없으면 0 (ADD 모드)
- ProjectNo: contractInfo.projectCode,
- Discipline: revision.discipline || "DL",
- DrawingKind: revision.drawingKind || "B3",
- DrawingNo: revision.documentNo,
- DrawingName: revision.documentName,
- RegisterGroupId: revision.registerGroupId || 0,
- RegisterSerialNo: getSerialNumber(revision.revision || "1"),
- RegisterKind: registerKind, // usage/usageType에 따라 동적 설정
- DrawingRevNo: revision.revision || "-",
- Category: revision.category || "TS",
- Receiver: null,
- Manager: revision.managerNo || "202206", // 담당자 번호 사용
- RegisterDesc: revision.comment || "System upload",
- UploadId: uploadId,
- RegCompanyCode: vendorCode || "A0005531" // 벤더 코드
+ return {
+ Mode: mode,
+ Status: revision.revisionStatus || "Standby",
+ RegisterId: revision.registerId || 0, // registerId가 없으면 0 (ADD 모드)
+ ProjectNo: contractInfo.projectCode,
+ Discipline: revision.discipline || "DL",
+ DrawingKind: revision.drawingKind || "B3",
+ DrawingNo: revision.documentNo,
+ DrawingName: revision.documentName,
+ RegisterGroupId: revision.registerGroupId || 0,
+ RegisterSerialNo: getSerialNumber(revision.revision || "1"),
+ RegisterKind: registerKind, // usage/usageType에 따라 동적 설정
+ DrawingRevNo: revision.revision || "-",
+ Category: revision.category || "TS",
+ Receiver: null,
+ Manager: revision.managerNo || "202206", // 담당자 번호 사용
+ RegisterDesc: revision.comment || "System upload",
+ UploadId: uploadId,
+ RegCompanyCode: vendorCode || "A0005531" // 벤더 코드
+ }
}
-}
/**
* 파일 매핑 데이터 변환
*/
@@ -769,28 +783,28 @@ private transformToDoLCEDocument(
private async getFileBuffer(filePath: string): Promise<ArrayBuffer> {
try {
console.log(`📂 파일 읽기 요청: ${filePath}`);
-
+
if (filePath.startsWith('http')) {
// ✅ URL인 경우 직접 다운로드 (기존과 동일)
console.log(`🌐 HTTP URL에서 파일 다운로드: ${filePath}`);
-
+
const response = await fetch(filePath);
if (!response.ok) {
throw new Error(`파일 다운로드 실패: ${response.status}`);
}
-
+
const arrayBuffer = await response.arrayBuffer();
console.log(`✅ HTTP 다운로드 완료: ${arrayBuffer.byteLength} bytes`);
-
+
return arrayBuffer;
} else {
// ✅ 로컬/NAS 파일 경로 처리 (환경별 분기)
const fs = await import('fs');
const path = await import('path');
const config = getFileReaderConfig();
-
+
let actualFilePath: string;
-
+
// 경로 형태별 처리
if (filePath.startsWith('/documents/')) {
// ✅ DB에 저장된 경로 형태: "/documents/[uuid].ext"
@@ -798,32 +812,48 @@ private transformToDoLCEDocument(
// 프로덕션: /evcp_nas/documents/[uuid].ext
actualFilePath = path.join(config.baseDir, 'public', filePath.substring(1)); // 앞의 '/' 제거
console.log(`📁 documents 경로 처리: ${filePath} → ${actualFilePath}`);
- }
-
+ }
+ else if (filePath.startsWith('/api/files')) {
+
+ actualFilePath = `${process.env.NEXT_PUBLIC_URL}${filePath}`
+
+
+ const response = await fetch(actualFilePath);
+ if (!response.ok) {
+ throw new Error(`파일 다운로드 실패: ${response.status}`);
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ console.log(`✅ HTTP 다운로드 완료: ${arrayBuffer.byteLength} bytes`);
+
+ return arrayBuffer;
+
+ }
+
else {
// ✅ 상대 경로는 현재 디렉토리 기준
actualFilePath = filePath;
console.log(`📂 상대 경로 사용: ${actualFilePath}`);
}
-
+
console.log(`🔍 실제 파일 경로: ${actualFilePath}`);
console.log(`🏠 환경: ${config.isProduction ? 'PRODUCTION (NAS)' : 'DEVELOPMENT (public)'}`);
-
+
// 파일 존재 여부 확인
if (!fs.existsSync(actualFilePath)) {
console.error(`❌ 파일 없음: ${actualFilePath}`);
throw new Error(`파일을 찾을 수 없습니다: ${actualFilePath}`);
}
-
+
// 파일 읽기
const fileBuffer = fs.readFileSync(actualFilePath);
console.log(`✅ 파일 읽기 성공: ${actualFilePath} (${fileBuffer.length} bytes)`);
-
+
// ✅ Buffer를 ArrayBuffer로 정확히 변환
const arrayBuffer = new ArrayBuffer(fileBuffer.length);
const uint8Array = new Uint8Array(arrayBuffer);
uint8Array.set(fileBuffer);
-
+
return arrayBuffer;
}
} catch (error) {
@@ -881,19 +911,19 @@ private transformToDoLCEDocument(
try {
// DES 암호화 (C# DESCryptoServiceProvider 호환)
const DES_KEY = Buffer.from("4fkkdijg", "ascii")
-
+
// 암호화 문자열 생성: FileId↔UserId↔FileName
const encryptString = `${fileId}↔${userId}↔${fileName}`
-
+
// DES 암호화 (createCipheriv 사용)
const cipher = crypto.createCipheriv('des-ecb', DES_KEY, '')
cipher.setAutoPadding(true)
let encrypted = cipher.update(encryptString, 'utf8', 'base64')
encrypted += cipher.final('base64')
const encryptedKey = encrypted.replace(/\+/g, '|||')
-
+
const downloadUrl = `${process.env.DOLCE_DOWNLOAD_URL}?key=${encryptedKey}` || `http://60.100.99.217:1111/Download.aspx?key=${encryptedKey}`
-
+
console.log(`🧪 DOLCE 파일 다운로드 테스트:`)
console.log(` 파일명: ${fileName}`)
console.log(` FileId: ${fileId}`)
@@ -919,7 +949,7 @@ private transformToDoLCEDocument(
const buffer = Buffer.from(await response.arrayBuffer())
console.log(`✅ DOLCE 파일 다운로드 테스트 성공: ${fileName} (${buffer.length} bytes)`)
-
+
return {
success: true,
downloadUrl
diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts
index 05ace8d5..f2d9c26f 100644
--- a/lib/vendor-document-list/enhanced-document-service.ts
+++ b/lib/vendor-document-list/enhanced-document-service.ts
@@ -2,7 +2,7 @@
"use server"
import { revalidatePath, unstable_cache } from "next/cache"
-import { and, asc, desc, eq, ilike, or, count, avg, inArray, sql } from "drizzle-orm"
+import { and, asc, desc, eq, ilike, or, count, avg, inArray, sql, ne } from "drizzle-orm"
import db from "@/db/db"
import { StageDocumentsView, documentAttachments, documentStagesOnlyView, documents, enhancedDocumentsView, issueStages, revisions, simplifiedDocumentsView, type EnhancedDocumentsView } from "@/db/schema/vendorDocu"
import { filterColumns } from "@/lib/filter-columns"
@@ -1175,3 +1175,164 @@ export async function getDocumentDetails(documentId: number) {
+ export interface UpdateRevisionInput {
+ revisionId: number
+ revision: string // ✅ revision 필드 추가
+ comment?: string | null
+ usage: string
+ usageType?: string | null
+ }
+
+ export interface UpdateRevisionResult {
+ success: boolean
+ message?: string
+ error?: string
+ updatedRevision?: any
+ }
+
+ export async function updateRevisionAction(
+ input: UpdateRevisionInput
+ ): Promise<UpdateRevisionResult> {
+ try {
+ const { revisionId, revision, comment, usage, usageType } = input
+
+ // 1. 리비전 존재 여부 확인
+ const existingRevision = await db
+ .select()
+ .from(revisions)
+ .where(eq(revisions.id, revisionId))
+ .limit(1)
+
+ if (!existingRevision || existingRevision.length === 0) {
+ return {
+ success: false,
+ error: "Revision not found"
+ }
+ }
+
+ // 2. 동일한 revision 번호가 같은 문서에 이미 존재하는지 확인 (자기 자신 제외)
+ const duplicateRevision = await db
+ .select()
+ .from(revisions)
+ .innerJoin(issueStages, eq(revisions.issueStageId, issueStages.id))
+ .where(
+ and(
+ eq(revisions.revision, revision.trim()),
+ eq(issueStages.documentId, existingRevision[0].issueStageId), // 같은 문서 내에서
+ ne(revisions.id, revisionId) // 자기 자신 제외
+ )
+ )
+ .limit(1)
+
+ if (duplicateRevision && duplicateRevision.length > 0) {
+ return {
+ success: false,
+ error: `Revision "${revision.trim()}" already exists in this document`
+ }
+ }
+
+ // 3. 첨부파일이 처리된 상태인지 확인 (수정 가능 여부 체크)
+ const attachments = await db
+ .select()
+ .from(documentAttachments)
+ .where(eq(documentAttachments.revisionId, revisionId))
+
+ const hasProcessedFiles = attachments.some(att =>
+ att.dolceFilePath && att.dolceFilePath.trim() !== ''
+ )
+
+ if (hasProcessedFiles) {
+ return {
+ success: false,
+ error: "Cannot edit revision with processed files"
+ }
+ }
+
+ // 4. 리비전 업데이트
+ const [updatedRevision] = await db
+ .update(revisions)
+ .set({
+ revision: revision.trim(), // ✅ revision 필드 업데이트 추가
+ comment: comment?.trim() || null,
+ usage: usage.trim(),
+ usageType: usageType?.trim() || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(revisions.id, revisionId))
+ .returning()
+
+ revalidatePath("/partners/document-list-ship") // ✅ 경로 오타 수정
+
+ return {
+ success: true,
+ message: `Revision ${revision.trim()} updated successfully`, // ✅ 새 revision 값 사용
+ updatedRevision
+ }
+
+ } catch (error) {
+ console.error("❌ Revision update server action error:", error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Failed to update revision"
+ }
+ }
+ }
+ // 삭제 서버 액션도 함께 만들어드릴게요
+ export interface DeleteRevisionInput {
+ revisionId: number
+ }
+
+ export interface DeleteRevisionResult {
+ success: boolean
+ message?: string
+ error?: string
+ deletedRevisionId?: number
+ deletedAttachmentsCount?: number
+ }
+
+ export async function deleteRevisionAction(
+ input: DeleteRevisionInput
+ ): Promise<DeleteRevisionResult> {
+ try {
+ const { revisionId } = input
+
+ // 1. 리비전과 첨부파일 정보 조회
+ const revision = await db
+ .select()
+ .from(revisions)
+ .where(eq(revisions.id, revisionId))
+ .limit(1)
+
+ if (!revision || revision.length === 0) {
+ return {
+ success: false,
+ error: "Revision not found"
+ }
+ }
+
+
+ // 5. 리비전 삭제
+ await db
+ .delete(revisions)
+ .where(eq(revisions.id, revisionId))
+
+ // 6. 캐시 재검증
+ revalidatePath("/parnters/document-list-ship")
+
+ return {
+ success: true,
+ message: `Revision ${revision[0].revision} deleted successfully`,
+ deletedRevisionId: revisionId,
+ deletedAttachmentsCount: 0 // revisionAttachments.length
+ }
+
+ } catch (error) {
+ console.error("❌ Revision delete server action error:", error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Failed to delete revision"
+ }
+ }
+ } \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx
index f843862d..ccf35f4b 100644
--- a/lib/vendor-document-list/plant/document-stages-table.tsx
+++ b/lib/vendor-document-list/plant/document-stages-table.tsx
@@ -22,6 +22,8 @@ import {
Plus,
FileSpreadsheet
} from "lucide-react"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
import { getDocumentStagesColumns } from "./document-stages-columns"
import { ExpandableDataTable } from "@/components/data-table/expandable-data-table"
import { toast } from "sonner"
@@ -45,6 +47,11 @@ export function DocumentStagesTable({
projectType,
}: DocumentStagesTableProps) {
const [{ data, pageCount, total }] = React.use(promises)
+
+ // URL에서 언어 파라미터 가져오기
+ const params = useParams()
+ const lng = (params?.lng as string) || 'ko'
+ const { t } = useTranslation(lng, 'document')
// 상태 관리
@@ -160,13 +167,13 @@ export function DocumentStagesTable({
.filter(Boolean)
if (stageIds.length > 0) {
- toast.success(`${stageIds.length}개 스테이지가 완료 처리되었습니다.`)
+ toast.success(t('documentList.messages.stageCompletionSuccess', { count: stageIds.length }))
}
} else if (action === 'bulk_assign') {
- toast.info("일괄 담당자 지정 기능은 준비 중입니다.")
+ toast.info(t('documentList.messages.bulkAssignPending'))
}
} catch (error) {
- toast.error("일괄 작업 중 오류가 발생했습니다.")
+ toast.error(t('documentList.messages.bulkActionError'))
}
}
@@ -260,47 +267,47 @@ export function DocumentStagesTable({
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">전체 문서</CardTitle>
+ <CardTitle className="text-sm font-medium">{t('documentList.dashboard.totalDocuments')}</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground">
- 총 {total}개 문서
+ {t('documentList.dashboard.totalDocumentCount', { total })}
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">지연 문서</CardTitle>
+ <CardTitle className="text-sm font-medium">{t('documentList.dashboard.overdueDocuments')}</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{stats.overdue}</div>
- <p className="text-xs text-muted-foreground">즉시 확인 필요</p>
+ <p className="text-xs text-muted-foreground">{t('documentList.dashboard.checkImmediately')}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">마감 임박</CardTitle>
+ <CardTitle className="text-sm font-medium">{t('documentList.dashboard.dueSoonDocuments')}</CardTitle>
<Clock className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{stats.dueSoon}</div>
- <p className="text-xs text-muted-foreground">3일 이내 마감</p>
+ <p className="text-xs text-muted-foreground">{t('documentList.dashboard.dueInDays')}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">평균 진행률</CardTitle>
+ <CardTitle className="text-sm font-medium">{t('documentList.dashboard.averageProgress')}</CardTitle>
<Target className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.avgProgress}%</div>
- <p className="text-xs text-muted-foreground">전체 프로젝트 진행도</p>
+ <p className="text-xs text-muted-foreground">{t('documentList.dashboard.overallProgress')}</p>
</CardContent>
</Card>
</div>
@@ -312,7 +319,7 @@ export function DocumentStagesTable({
className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap"
onClick={() => setQuickFilter('all')}
>
- 전체 ({stats.total})
+ {t('documentList.quickFilters.all')} ({stats.total})
</Badge>
<Badge
variant={quickFilter === 'overdue' ? 'destructive' : 'outline'}
@@ -320,7 +327,7 @@ export function DocumentStagesTable({
onClick={() => setQuickFilter('overdue')}
>
<AlertTriangle className="w-3 h-3 mr-1" />
- 지연 ({stats.overdue})
+ {t('documentList.quickFilters.overdue')} ({stats.overdue})
</Badge>
<Badge
variant={quickFilter === 'due_soon' ? 'default' : 'outline'}
@@ -328,7 +335,7 @@ export function DocumentStagesTable({
onClick={() => setQuickFilter('due_soon')}
>
<Clock className="w-3 h-3 mr-1" />
- 마감임박 ({stats.dueSoon})
+ {t('documentList.quickFilters.dueSoon')} ({stats.dueSoon})
</Badge>
<Badge
variant={quickFilter === 'in_progress' ? 'default' : 'outline'}
@@ -336,7 +343,7 @@ export function DocumentStagesTable({
onClick={() => setQuickFilter('in_progress')}
>
<Users className="w-3 h-3 mr-1" />
- 진행중 ({stats.inProgress})
+ {t('documentList.quickFilters.inProgress')} ({stats.inProgress})
</Badge>
<Badge
variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'}
@@ -344,7 +351,7 @@ export function DocumentStagesTable({
onClick={() => setQuickFilter('high_priority')}
>
<Target className="w-3 h-3 mr-1" />
- 높은우선순위 ({stats.highPriority})
+ {t('documentList.quickFilters.highPriority')} ({stats.highPriority})
</Badge>
</div>
diff --git a/lib/vendor-document-list/ship/send-to-shi-button.tsx b/lib/vendor-document-list/ship/send-to-shi-button.tsx
index 447b461b..87cc6ff5 100644
--- a/lib/vendor-document-list/ship/send-to-shi-button.tsx
+++ b/lib/vendor-document-list/ship/send-to-shi-button.tsx
@@ -28,6 +28,7 @@ import { useClientSyncStatus, useTriggerSync, syncUtils } from "@/hooks/use-sync
import type { EnhancedDocument } from "@/types/enhanced-documents"
import { useParams } from "next/navigation"
import { useTranslation } from "@/i18n/client"
+import { useSession } from "next-auth/react"
interface SendToSHIButtonProps {
documents?: EnhancedDocument[]
@@ -43,6 +44,7 @@ export function SendToSHIButton({
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const [syncProgress, setSyncProgress] = React.useState(0)
const [currentSyncingContract, setCurrentSyncingContract] = React.useState<number | null>(null)
+ const { data: session } = useSession();
const params = useParams()
const lng = (params?.lng as string) || "ko"
@@ -60,6 +62,8 @@ export function SendToSHIButton({
return uniqueIds.sort()
}, [documents])
+ const vendorId = session?.user.companyId
+
// ✅ 클라이언트 전용 Hook 사용 (서버 사이드 렌더링 호환)
const { contractStatuses, totalStats, refetchAll } = useClientSyncStatus(
documentsContractIds,
@@ -68,20 +72,6 @@ export function SendToSHIButton({
const { triggerSync, isLoading: isSyncing, error: syncError } = useTriggerSync()
- // 개발 환경에서 디버깅 정보
- React.useEffect(() => {
- if (process.env.NODE_ENV === 'development') {
- console.log('SendToSHIButton Debug Info:', {
- documentsContractIds,
- totalStats,
- contractStatuses: contractStatuses.map(({ projectId, syncStatus, error }) => ({
- projectId,
- pendingChanges: syncStatus?.pendingChanges,
- hasError: !!error
- }))
- })
- }
- }, [documentsContractIds, totalStats, contractStatuses])
// 동기화 실행 함수
const handleSync = async () => {
diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts
index 0544ce06..cdc22e11 100644
--- a/lib/vendor-document-list/sync-service.ts
+++ b/lib/vendor-document-list/sync-service.ts
@@ -8,6 +8,8 @@ import {
} from "@/db/schema/vendorDocu"
import { documents, revisions, documentAttachments } from "@/db/schema/vendorDocu"
import { eq, and, lt, desc, sql, inArray } from "drizzle-orm"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
export interface SyncableEntity {
entityType: 'document' | 'revision' | 'attachment'
@@ -42,7 +44,7 @@ class SyncService {
* 변경사항을 change_logs에 기록
*/
async logChange(
- projectId: number,
+ vendorId: number,
entityType: 'document' | 'revision' | 'attachment',
entityId: number,
action: 'CREATE' | 'UPDATE' | 'DELETE',
@@ -56,7 +58,7 @@ class SyncService {
const changedFields = this.detectChangedFields(oldValues, newValues)
await db.insert(changeLogs).values({
- projectId,
+ vendorId,
entityType,
entityId,
action,
@@ -99,7 +101,7 @@ class SyncService {
* 동기화할 변경사항 조회 (증분)
*/
async getPendingChanges(
- projectId: number,
+ vendorId: number,
targetSystem: string = 'DOLCE',
limit?: number
): Promise<ChangeLog[]> {
@@ -107,7 +109,7 @@ class SyncService {
.select()
.from(changeLogs)
.where(and(
- eq(changeLogs.projectId, projectId),
+ eq(changeLogs.vendorId, vendorId),
eq(changeLogs.isSynced, false),
lt(changeLogs.syncAttempts, 3),
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
@@ -136,14 +138,14 @@ class SyncService {
* 동기화 배치 생성
*/
async createSyncBatch(
- projectId: number,
+ vendorId: number,
targetSystem: string,
changeLogIds: number[]
): Promise<number> {
const [batch] = await db
.insert(syncBatches)
.values({
- projectId,
+ vendorId,
targetSystem,
batchSize: changeLogIds.length,
changeLogIds,
@@ -168,8 +170,16 @@ class SyncService {
throw new Error(`Sync not enabled for ${targetSystem}`)
}
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const vendorId = Number(session.user.companyId)
+
+
// 2. 대기 중인 변경사항 조회 (전체)
- const pendingChanges = await this.getPendingChanges(projectId, targetSystem)
+ const pendingChanges = await this.getPendingChanges(vendorId, targetSystem)
if (pendingChanges.length === 0) {
return {
@@ -182,7 +192,7 @@ class SyncService {
// 3. 배치 생성
const batchId = await this.createSyncBatch(
- projectId,
+ vendorId,
targetSystem,
pendingChanges.map(c => c.id)
)
@@ -446,11 +456,20 @@ class SyncService {
*/
async getSyncStatus(projectId: number, targetSystem: string = 'DOLCE') {
try {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const vendorId = Number(session.user.companyId)
+
+
// 대기 중인 변경사항 수 조회
const pendingCount = await db.$count(
changeLogs,
and(
- eq(changeLogs.projectId, projectId),
+ eq(changeLogs.vendorId, vendorId),
eq(changeLogs.isSynced, false),
lt(changeLogs.syncAttempts, 3),
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
@@ -461,7 +480,7 @@ class SyncService {
const syncedCount = await db.$count(
changeLogs,
and(
- eq(changeLogs.projectId, projectId),
+ eq(changeLogs.vendorId, vendorId),
eq(changeLogs.isSynced, true),
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
)
@@ -471,7 +490,7 @@ class SyncService {
const failedCount = await db.$count(
changeLogs,
and(
- eq(changeLogs.projectId, projectId),
+ eq(changeLogs.vendorId, vendorId),
eq(changeLogs.isSynced, false),
sql`${changeLogs.syncAttempts} >= 3`,
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
@@ -483,7 +502,7 @@ class SyncService {
.select()
.from(syncBatches)
.where(and(
- eq(syncBatches.projectId, projectId),
+ eq(syncBatches.vendorId, vendorId),
eq(syncBatches.targetSystem, targetSystem),
eq(syncBatches.status, 'SUCCESS')
))
@@ -491,7 +510,7 @@ class SyncService {
.limit(1)
return {
- projectId,
+ vendorId,
targetSystem,
totalChanges: pendingCount + syncedCount + failedCount,
pendingChanges: pendingCount,
@@ -511,11 +530,19 @@ class SyncService {
*/
async getRecentSyncBatches(projectId: number, targetSystem: string = 'DOLCE', limit: number = 10) {
try {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const vendorId = Number(session.user.companyId)
+
const batches = await db
.select()
.from(syncBatches)
.where(and(
- eq(syncBatches.projectId, projectId),
+ eq(syncBatches.vendorId, vendorId),
eq(syncBatches.targetSystem, targetSystem)
))
.orderBy(desc(syncBatches.createdAt))
@@ -524,7 +551,7 @@ class SyncService {
// Date 객체를 문자열로 변환
return batches.map(batch => ({
id: Number(batch.id),
- projectId: batch.projectId,
+ vendorId: batch.vendorId,
targetSystem: batch.targetSystem,
batchSize: batch.batchSize,
status: batch.status,
@@ -561,7 +588,7 @@ export async function logDocumentChange(
}
export async function logRevisionChange(
- projectId: number,
+ vendorId: number,
revisionId: number,
action: 'CREATE' | 'UPDATE' | 'DELETE',
newValues?: any,
@@ -570,11 +597,11 @@ export async function logRevisionChange(
userName?: string,
targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(projectId, 'revision', revisionId, action, newValues, oldValues, userId, userName, targetSystems)
+ return syncService.logChange(vendorId, 'revision', revisionId, action, newValues, oldValues, userId, userName, targetSystems)
}
export async function logAttachmentChange(
- projectId: number,
+ vendorId: number,
attachmentId: number,
action: 'CREATE' | 'UPDATE' | 'DELETE',
newValues?: any,
@@ -583,5 +610,5 @@ export async function logAttachmentChange(
userName?: string,
targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(projectId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName, targetSystems)
+ return syncService.logChange(vendorId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName, targetSystems)
} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx
index cb49f796..7e20892e 100644
--- a/lib/vendor-document-list/table/enhanced-documents-table.tsx
+++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx
@@ -26,6 +26,8 @@ import {
Settings,
Filter
} from "lucide-react"
+import { useTranslation } from "@/i18n/client"
+import { useParams } from "next/navigation"
import { getUpdatedEnhancedColumns } from "./enhanced-doc-table-columns"
import { ExpandableDataTable } from "@/components/data-table/expandable-data-table"
import { toast } from "sonner"
@@ -48,7 +50,7 @@ interface FinalIntegratedDocumentsTableProps {
}
// ✅ Drawing Kind 옵션 정의
-const DRAWING_KIND_OPTIONS = [
+const DRAWING_KIND_KEYS = [
{ value: "all", label: "전체 문서" },
{ value: "B3", label: "B3: Vendor" },
{ value: "B4", label: "B4: GTT" },
@@ -63,6 +65,20 @@ export function EnhancedDocumentsTable({
initialDrawingKind = "all"
}: FinalIntegratedDocumentsTableProps) {
const [{ data, pageCount, total }] = React.use(promises)
+
+ // URL에서 언어 파라미터 가져오기
+ const params = useParams()
+ const lng = (params?.lng as string) || 'ko'
+ const { t } = useTranslation(lng, 'engineering')
+ console.log(t, 't')
+
+ // ✅ Drawing Kind 옵션 동적 생성
+ const DRAWING_KIND_OPTIONS = React.useMemo(() =>
+ DRAWING_KIND_KEYS.map(item => ({
+ value: item.value,
+ label: t(`documentList.drawingKindOptions.${item.value}`)
+ }))
+ , [t])
// ✅ Drawing Kind 필터 상태 추가
const [drawingKindFilter, setDrawingKindFilter] = React.useState<string>(initialDrawingKind)
@@ -267,13 +283,13 @@ export function EnhancedDocumentsTable({
.filter(Boolean)
if (stageIds.length > 0) {
- toast.success(`${stageIds.length}개 항목이 승인되었습니다.`)
+ toast.success(t('documentList.messages.approvalSuccess', { count: stageIds.length }))
}
} else if (action === 'bulk_upload') {
- toast.info("일괄 업로드 기능은 준비 중입니다.")
+ toast.info(t('documentList.messages.bulkUploadPending'))
}
} catch (error) {
- toast.error("일괄 작업 중 오류가 발생했습니다.")
+ toast.error(t('documentList.messages.bulkActionError'))
}
}
@@ -318,17 +334,17 @@ export function EnhancedDocumentsTable({
const advancedFilterFields: DataTableAdvancedFilterField<EnhancedDocument>[] = [
{
id: "docNumber",
- label: "문서번호",
+ label: t('documentList.filters.documentNumber'),
type: "text",
},
{
id: "title",
- label: "문서제목",
+ label: t('documentList.filters.documentTitle'),
type: "text",
},
{
id: "drawingKind",
- label: "문서종류",
+ label: t('documentList.filters.documentType'),
type: "select",
options: [
{ label: "B3", value: "B3" },
@@ -338,43 +354,43 @@ export function EnhancedDocumentsTable({
},
{
id: "currentStageStatus",
- label: "스테이지 상태",
+ label: t('documentList.filters.stageStatus'),
type: "select",
options: [
- { label: "계획됨", value: "PLANNED" },
- { label: "진행중", value: "IN_PROGRESS" },
- { label: "제출됨", value: "SUBMITTED" },
- { label: "승인됨", value: "APPROVED" },
- { label: "완료됨", value: "COMPLETED" },
+ { label: t('documentList.statusOptions.planned'), value: "PLANNED" },
+ { label: t('documentList.statusOptions.inProgress'), value: "IN_PROGRESS" },
+ { label: t('documentList.statusOptions.submitted'), value: "SUBMITTED" },
+ { label: t('documentList.statusOptions.approved'), value: "APPROVED" },
+ { label: t('documentList.statusOptions.completed'), value: "COMPLETED" },
],
},
{
id: "currentStagePriority",
- label: "우선순위",
+ label: t('documentList.filters.priority'),
type: "select",
options: [
- { label: "높음", value: "HIGH" },
- { label: "보통", value: "MEDIUM" },
- { label: "낮음", value: "LOW" },
+ { label: t('documentList.priorityOptions.high'), value: "HIGH" },
+ { label: t('documentList.priorityOptions.medium'), value: "MEDIUM" },
+ { label: t('documentList.priorityOptions.low'), value: "LOW" },
],
},
{
id: "isOverdue",
- label: "지연 여부",
+ label: t('documentList.filters.overdueStatus'),
type: "select",
options: [
- { label: "지연됨", value: "true" },
- { label: "정상", value: "false" },
+ { label: t('documentList.overdueOptions.overdue'), value: "true" },
+ { label: t('documentList.overdueOptions.normal'), value: "false" },
],
},
{
id: "currentStageAssigneeName",
- label: "담당자",
+ label: t('documentList.filters.assignee'),
type: "text",
},
{
id: "createdAt",
- label: "생성일",
+ label: t('documentList.filters.createdDate'),
type: "date",
},
]
@@ -405,48 +421,48 @@ export function EnhancedDocumentsTable({
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
- {drawingKindFilter === "all" ? "전체 문서" : `${DRAWING_KIND_OPTIONS.find(o => o.value === drawingKindFilter)?.label} 문서`}
+ {drawingKindFilter === "all" ? t('documentList.dashboard.totalDocuments') : `${DRAWING_KIND_OPTIONS.find(o => o.value === drawingKindFilter)?.label}`}
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground">
- 총 {total}개 중 {stats.total}개 표시
+ {t('documentList.dashboard.totalCount', { total, shown: stats.total })}
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">지연 문서</CardTitle>
+ <CardTitle className="text-sm font-medium">{t('documentList.dashboard.overdueDocuments')}</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{stats.overdue}</div>
- <p className="text-xs text-muted-foreground">즉시 확인 필요</p>
+ <p className="text-xs text-muted-foreground">{t('documentList.dashboard.checkImmediately')}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">마감 임박</CardTitle>
+ <CardTitle className="text-sm font-medium">{t('documentList.dashboard.dueSoonDocuments')}</CardTitle>
<Clock className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{stats.dueSoon}</div>
- <p className="text-xs text-muted-foreground">3일 이내 마감</p>
+ <p className="text-xs text-muted-foreground">{t('documentList.dashboard.dueInDays')}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">평균 진행률</CardTitle>
+ <CardTitle className="text-sm font-medium">{t('documentList.dashboard.averageProgress')}</CardTitle>
<Target className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.avgProgress}%</div>
- <p className="text-xs text-muted-foreground">전체 프로젝트 진행도</p>
+ <p className="text-xs text-muted-foreground">{t('documentList.dashboard.overallProgress')}</p>
</CardContent>
</Card>
</div>
@@ -460,7 +476,7 @@ export function EnhancedDocumentsTable({
className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap"
onClick={() => setQuickFilter('all')}
>
- 전체 ({stats.total})
+ {t('documentList.quickFilters.all')} ({stats.total})
</Badge>
<Badge
variant={quickFilter === 'overdue' ? 'destructive' : 'outline'}
@@ -468,7 +484,7 @@ export function EnhancedDocumentsTable({
onClick={() => setQuickFilter('overdue')}
>
<AlertTriangle className="w-3 h-3 mr-1" />
- 지연 ({stats.overdue})
+ {t('documentList.quickFilters.overdue')} ({stats.overdue})
</Badge>
<Badge
variant={quickFilter === 'due_soon' ? 'default' : 'outline'}
@@ -476,7 +492,7 @@ export function EnhancedDocumentsTable({
onClick={() => setQuickFilter('due_soon')}
>
<Clock className="w-3 h-3 mr-1" />
- 마감임박 ({stats.dueSoon})
+ {t('documentList.quickFilters.dueSoon')} ({stats.dueSoon})
</Badge>
<Badge
variant={quickFilter === 'in_progress' ? 'default' : 'outline'}
@@ -484,7 +500,7 @@ export function EnhancedDocumentsTable({
onClick={() => setQuickFilter('in_progress')}
>
<Users className="w-3 h-3 mr-1" />
- 진행중 ({stats.inProgress})
+ {t('documentList.quickFilters.inProgress')} ({stats.inProgress})
</Badge>
<Badge
variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'}
@@ -492,7 +508,7 @@ export function EnhancedDocumentsTable({
onClick={() => setQuickFilter('high_priority')}
>
<Target className="w-3 h-3 mr-1" />
- 높은우선순위 ({stats.highPriority})
+ {t('documentList.quickFilters.highPriority')} ({stats.highPriority})
</Badge>
</div>
@@ -500,7 +516,7 @@ export function EnhancedDocumentsTable({
<div className="flex items-center gap-2 flex-shrink-0">
<Select value={drawingKindFilter} onValueChange={setDrawingKindFilter}>
<SelectTrigger className="w-[140px]">
- <SelectValue placeholder="문서 종류" />
+ <SelectValue placeholder={t('documentList.filters.selectDocumentType')} />
</SelectTrigger>
<SelectContent>
{DRAWING_KIND_OPTIONS.map(option => (
@@ -520,7 +536,7 @@ export function EnhancedDocumentsTable({
{drawingKindFilter === "B4" && (
<div className="flex items-center gap-1 text-blue-600 bg-blue-50 px-2 py-1 rounded text-xs">
<Settings className="h-3 w-3" />
- <span className="hidden sm:inline">상세정보 확장가능</span>
+ <span className="hidden sm:inline">{t('documentList.ui.expandedInfoAvailable')}</span>
</div>
)}
</div>
diff --git a/lib/vendor-registration-status/vendor-registration-status-view.tsx b/lib/vendor-registration-status/vendor-registration-status-view.tsx
index b3000f73..850dd777 100644
--- a/lib/vendor-registration-status/vendor-registration-status-view.tsx
+++ b/lib/vendor-registration-status/vendor-registration-status-view.tsx
@@ -4,13 +4,11 @@ import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import { Separator } from "@/components/ui/separator"
+
import {
CheckCircle,
XCircle,
FileText,
- Users,
- Building2,
AlertCircle,
Eye,
Upload
@@ -21,6 +19,9 @@ import { format } from "date-fns"
import { toast } from "sonner"
import { fetchVendorRegistrationStatus } from "@/lib/vendor-regular-registrations/service"
+// 세션에서 벤더아이디 가져오기 위한 훅
+import { useSession } from "next-auth/react"
+
// 상태별 정의
const statusConfig = {
audit_pass: {
@@ -81,11 +82,15 @@ export function VendorRegistrationStatusView() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
- // 임시로 vendorId = 1 사용 (실제로는 세션에서 가져와야 함)
- const vendorId = 1
+ // 세션에서 vendorId 가져오기
+ const { data: session, status: sessionStatus } = useSession()
+ const vendorId = session?.user?.companyId
+ console.log(vendorId)
// 데이터 로드
useEffect(() => {
+ if (!vendorId) return
+
const initialLoad = async () => {
try {
const result = await fetchVendorRegistrationStatus(vendorId)
@@ -104,10 +109,14 @@ export function VendorRegistrationStatusView() {
initialLoad()
}, [vendorId])
- if (loading) {
+ if (sessionStatus === "loading" || loading) {
return <div className="p-8 text-center">로딩 중...</div>
}
+ if (!vendorId) {
+ return <div className="p-8 text-center">벤더 정보가 없습니다. 다시 로그인 해주세요.</div>
+ }
+
if (!data) {
return <div className="p-8 text-center">데이터를 불러올 수 없습니다.</div>
}
@@ -123,9 +132,10 @@ export function VendorRegistrationStatusView() {
const registrationForDialog: any = {
id: data.registration?.id || 0,
vendorId: data.vendor.id,
- companyName: data.vendor.companyName,
- businessNumber: data.vendor.businessNumber,
- representative: data.vendor.representative || "",
+ companyName: data.vendor.vendorName,
+ businessNumber: data.vendor.taxId,
+ representative: data.vendor.representativeName || "",
+ country: data.vendor.country || "KR", // 기본값 KR
potentialCode: data.registration?.potentialCode || "",
status: data.registration?.status || "audit_pass",
majorItems: "[]", // 빈 JSON 문자열
@@ -136,9 +146,12 @@ export function VendorRegistrationStatusView() {
assignedUser: data.registration?.assignedUser,
assignedUserCode: data.registration?.assignedUserCode,
remarks: data.registration?.remarks,
+ safetyQualificationContent: data.registration?.safetyQualificationContent || null,
+ gtcSkipped: data.registration?.gtcSkipped || false,
additionalInfo: data.additionalInfo,
documentSubmissions: data.documentStatus, // documentSubmissions를 documentStatus로 설정
contractAgreements: [],
+ basicContracts: data.basicContracts || [], // 실제 데이터 사용
documentSubmissionsStatus: data.documentStatus,
contractAgreementsStatus: {
cpDocument: data.documentStatus.cpDocument,
@@ -164,10 +177,12 @@ export function VendorRegistrationStatusView() {
}
const loadData = async () => {
+ if (!vendorId) return
try {
const result = await fetchVendorRegistrationStatus(vendorId)
if (result.success) {
setData(result.data)
+ toast.success("데이터가 새로고침되었습니다.")
} else {
toast.error(result.error)
}
@@ -226,214 +241,28 @@ export function VendorRegistrationStatusView() {
</CardContent>
</Card>
- {/* 기본 정보 */}
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Building2 className="w-5 h-5" />
- 업체 정보
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-3">
- <div className="grid grid-cols-2 gap-4">
- <div>
- <span className="text-sm font-medium text-gray-600">업체명:</span>
- <p className="mt-1">{data.vendor.companyName}</p>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">사업자번호:</span>
- <p className="mt-1">{data.vendor.businessNumber}</p>
- </div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div>
- <span className="text-sm font-medium text-gray-600">업체구분:</span>
- <p className="mt-1">{data.registration ? "정규업체" : "잠재업체"}</p>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">eVCP 가입:</span>
- <p className="mt-1">{data.vendor.createdAt ? format(new Date(data.vendor.createdAt), "yyyy.MM.dd") : "-"}</p>
- </div>
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Users className="w-5 h-5" />
- 담당자 정보
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-3">
- <div className="grid grid-cols-2 gap-4">
- <div>
- <span className="text-sm font-medium text-gray-600">SHI 담당자:</span>
- <p className="mt-1">{data.registration?.assignedDepartment || "-"} {data.registration?.assignedUser || "-"}</p>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">진행상태:</span>
- <Badge className={`mt-1 ${currentStatusConfig.color}`} variant="secondary">
- {currentStatusConfig.label}
- </Badge>
- </div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div>
- <span className="text-sm font-medium text-gray-600">상태변경일:</span>
- <p className="mt-1">{data.registration?.updatedAt ? format(new Date(data.registration.updatedAt), "yyyy.MM.dd") : "-"}</p>
- </div>
- </div>
- </CardContent>
- </Card>
+ {/* 간소화된 액션 버튼들 */}
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <Button
+ onClick={() => setDocumentDialogOpen(true)}
+ variant="outline"
+ size="lg"
+ className="h-16 flex flex-col items-center gap-2"
+ >
+ <Eye className="w-6 h-6" />
+ <span>문서 현황 확인</span>
+ </Button>
+ <Button
+ onClick={() => setAdditionalInfoDialogOpen(true)}
+ variant={data.additionalInfo ? "outline" : "default"}
+ size="lg"
+ className="h-16 flex flex-col items-center gap-2"
+ >
+ <FileText className="w-6 h-6" />
+ <span>{data.additionalInfo ? "추가정보 수정" : "추가정보 등록"}</span>
+ </Button>
</div>
- {/* 미완항목 */}
- {missingDocuments.length > 0 && (
- <Card className="border-red-200 bg-red-50">
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-red-800">
- <AlertCircle className="w-5 h-5" />
- 미완항목
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
- {data.incompleteItemsCount.documents > 0 && (
- <div className="flex items-center justify-between p-3 bg-white rounded-lg border">
- <span className="text-sm font-medium">미제출문서</span>
- <Badge variant="destructive">{data.incompleteItemsCount.documents} 건</Badge>
- </div>
- )}
- {!data.documentStatus.auditResult && (
- <div className="flex items-center justify-between p-3 bg-white rounded-lg border">
- <span className="text-sm font-medium">실사결과</span>
- <Badge variant="destructive">미실시</Badge>
- </div>
- )}
- {data.incompleteItemsCount.additionalInfo > 0 && (
- <div className="flex items-center justify-between p-3 bg-white rounded-lg border">
- <span className="text-sm font-medium">추가정보</span>
- <Badge variant="destructive">미입력</Badge>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- )}
-
- {/* 상세 진행현황 */}
- <Card>
- <CardHeader>
- <CardTitle>상세 진행현황</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-6">
- {/* 기본 진행상황 */}
- <div className="grid grid-cols-4 gap-4 text-center">
- <div className="space-y-2">
- <div className="text-sm font-medium text-gray-600">PQ 제출</div>
- <div className="text-lg font-semibold">
- {data.pqSubmission ? (
- <div className="flex items-center justify-center gap-2">
- <CheckCircle className="w-5 h-5 text-green-600" />
- {format(new Date(data.pqSubmission.createdAt), "yyyy.MM.dd")}
- </div>
- ) : (
- <div className="flex items-center justify-center gap-2">
- <XCircle className="w-5 h-5 text-red-500" />
- 미제출
- </div>
- )}
- </div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-gray-600">실사 통과</div>
- <div className="text-lg font-semibold">
- {data.auditPassed ? (
- <div className="flex items-center justify-center gap-2">
- <CheckCircle className="w-5 h-5 text-green-600" />
- 통과
- </div>
- ) : (
- <div className="flex items-center justify-center gap-2">
- <XCircle className="w-5 h-5 text-red-500" />
- 미통과
- </div>
- )}
- </div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-gray-600">문서 현황</div>
- <Button
- onClick={() => setDocumentDialogOpen(true)}
- variant="outline"
- size="sm"
- className="flex items-center gap-2"
- >
- <Eye className="w-4 h-4" />
- 확인하기
- </Button>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-gray-600">추가정보</div>
- <Button
- onClick={() => setAdditionalInfoDialogOpen(true)}
- variant={data.additionalInfo ? "outline" : "default"}
- size="sm"
- className="flex items-center gap-2"
- >
- <FileText className="w-4 h-4" />
- {data.additionalInfo ? "수정하기" : "등록하기"}
- </Button>
- </div>
- </div>
-
- <Separator />
-
- {/* 필수문서 상태 */}
- <div>
- <h4 className="text-sm font-medium text-gray-600 mb-4">필수문서 제출 현황</h4>
- <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
- {requiredDocuments.map((doc) => {
- const isSubmitted = data.documentStatus[doc.key as keyof typeof data.documentStatus]
- return (
- <div
- key={doc.key}
- className={`p-3 rounded-lg border text-center ${
- isSubmitted
- ? 'bg-green-50 border-green-200'
- : 'bg-red-50 border-red-200'
- }`}
- >
- <div className="flex items-center justify-center mb-2">
- {isSubmitted ? (
- <CheckCircle className="w-5 h-5 text-green-600" />
- ) : (
- <XCircle className="w-5 h-5 text-red-500" />
- )}
- </div>
- <div className="text-xs font-medium">{doc.label}</div>
- {isSubmitted && (
- <Button
- variant="ghost"
- size="sm"
- className="mt-2 h-6 text-xs"
- >
- <Eye className="w-3 h-3 mr-1" />
- 보기
- </Button>
- )}
- </div>
- )
- })}
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
-
{/* 상태 설명 */}
<Card>
<CardHeader>
@@ -456,6 +285,7 @@ export function VendorRegistrationStatusView() {
open={documentDialogOpen}
onOpenChange={setDocumentDialogOpen}
registration={registrationForDialog}
+ onRefresh={loadData}
/>
{/* 추가정보 입력 Dialog */}
diff --git a/lib/vendor-regular-registrations/major-items-update-sheet.tsx b/lib/vendor-regular-registrations/major-items-update-sheet.tsx
new file mode 100644
index 00000000..ba125bbe
--- /dev/null
+++ b/lib/vendor-regular-registrations/major-items-update-sheet.tsx
@@ -0,0 +1,245 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { toast } from "sonner"
+import { X, Plus, Search } from "lucide-react"
+
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Label } from "@/components/ui/label"
+import { searchItemsForPQ } from "@/lib/items/service"
+import { updateMajorItems } from "./service"
+
+// PQ 대상 품목 타입 정의
+interface PQItem {
+ itemCode: string
+ itemName: string
+}
+
+interface MajorItemsUpdateSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ registrationId?: number
+ vendorName?: string
+ currentItems?: string | null
+ onSuccess?: () => void
+}
+
+export function MajorItemsUpdateSheet({
+ open,
+ onOpenChange,
+ registrationId,
+ vendorName,
+ currentItems,
+ onSuccess,
+}: MajorItemsUpdateSheetProps) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [selectedItems, setSelectedItems] = useState<PQItem[]>([])
+
+ // 아이템 검색 관련 상태
+ const [itemSearchQuery, setItemSearchQuery] = useState<string>("")
+ const [filteredItems, setFilteredItems] = useState<PQItem[]>([])
+ const [showItemDropdown, setShowItemDropdown] = useState<boolean>(false)
+
+ // 기존 아이템들 파싱 및 초기화
+ React.useEffect(() => {
+ if (open && currentItems) {
+ try {
+ const parsedItems = JSON.parse(currentItems)
+ if (Array.isArray(parsedItems)) {
+ setSelectedItems(parsedItems)
+ }
+ } catch (error) {
+ console.error("기존 주요품목 파싱 오류:", error)
+ setSelectedItems([])
+ }
+ } else if (open) {
+ setSelectedItems([])
+ }
+ }, [open, currentItems])
+
+ // 아이템 검색 필터링
+ React.useEffect(() => {
+ if (itemSearchQuery.trim() === "") {
+ setFilteredItems([])
+ setShowItemDropdown(false)
+ return
+ }
+
+ const searchItems = async () => {
+ try {
+ const results = await searchItemsForPQ(itemSearchQuery)
+ setFilteredItems(results)
+ setShowItemDropdown(true)
+ } catch (error) {
+ console.error("아이템 검색 오류:", error)
+ toast.error("아이템 검색 중 오류가 발생했습니다.")
+ setFilteredItems([])
+ setShowItemDropdown(false)
+ }
+ }
+
+ // 디바운싱: 300ms 후에 검색 실행
+ const timeoutId = setTimeout(searchItems, 300)
+ return () => clearTimeout(timeoutId)
+ }, [itemSearchQuery])
+
+ // 아이템 선택 함수
+ const handleSelectItem = (item: PQItem) => {
+ // 이미 선택된 아이템인지 확인
+ const isAlreadySelected = selectedItems.some(selectedItem =>
+ selectedItem.itemCode === item.itemCode
+ )
+
+ if (!isAlreadySelected) {
+ setSelectedItems(prev => [...prev, item])
+ }
+
+ // 검색 초기화
+ setItemSearchQuery("")
+ setFilteredItems([])
+ setShowItemDropdown(false)
+ }
+
+ // 아이템 제거 함수
+ const handleRemoveItem = (itemCode: string) => {
+ setSelectedItems(prev => prev.filter(item => item.itemCode !== itemCode))
+ }
+
+ const handleSave = async () => {
+ if (!registrationId) {
+ toast.error("등록 ID가 없습니다.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await updateMajorItems(
+ registrationId,
+ JSON.stringify(selectedItems)
+ )
+
+ if (result.success) {
+ toast.success("주요품목이 업데이트되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "주요품목 업데이트에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("주요품목 업데이트 오류:", error)
+ toast.error("주요품목 업데이트 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[400px] sm:w-[540px]">
+ <SheetHeader>
+ <SheetTitle>주요품목 등록</SheetTitle>
+ <SheetDescription>
+ {vendorName && `${vendorName}의 `}주요품목을 등록해주세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="space-y-6 mt-6">
+ {/* 선택된 아이템들 표시 */}
+ {selectedItems.length > 0 && (
+ <div className="space-y-2">
+ <Label>선택된 주요품목 ({selectedItems.length}개)</Label>
+ <div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto border rounded-md p-3">
+ {selectedItems.map((item) => (
+ <Badge key={item.itemCode} variant="secondary" className="flex items-center gap-1">
+ <span className="text-xs">
+ {item.itemCode} - {item.itemName}
+ </span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={() => handleRemoveItem(item.itemCode)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 검색 입력 */}
+ <div className="space-y-2">
+ <Label>품목 검색 및 추가</Label>
+ <div className="relative">
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템 코드 또는 이름으로 검색하세요"
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-9"
+ />
+ </div>
+
+ {/* 검색 결과 드롭다운 */}
+ {showItemDropdown && (
+ <div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-background border rounded-md shadow-lg">
+ {filteredItems.length > 0 ? (
+ filteredItems.map((item) => (
+ <button
+ key={item.itemCode}
+ type="button"
+ className="w-full px-3 py-2 text-left text-sm hover:bg-muted focus:bg-muted focus:outline-none"
+ onClick={() => handleSelectItem(item)}
+ >
+ <div className="font-medium">{item.itemCode}</div>
+ <div className="text-muted-foreground text-xs">{item.itemName}</div>
+ </button>
+ ))
+ ) : (
+ <div className="px-3 py-2 text-sm text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ <div className="text-xs text-muted-foreground">
+ 아이템 코드나 이름을 입력하여 검색하고 선택하세요.
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-end space-x-2 mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSave}
+ disabled={isLoading}
+ >
+ {isLoading ? "저장 중..." : "저장"}
+ </Button>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts
index d4c979a5..38bf4aaf 100644
--- a/lib/vendor-regular-registrations/repository.ts
+++ b/lib/vendor-regular-registrations/repository.ts
@@ -5,8 +5,11 @@ import {
vendorAttachments,
vendorInvestigationAttachments,
basicContract,
+ basicContractTemplates,
vendorPQSubmissions,
vendorInvestigations,
+ vendorBusinessContacts,
+ vendorAdditionalInfo,
} from "@/db/schema";
import { eq, desc, and, sql, inArray } from "drizzle-orm";
import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
@@ -27,11 +30,16 @@ export async function getVendorRegularRegistrations(
assignedDepartment: vendorRegularRegistrations.assignedDepartment,
assignedUser: vendorRegularRegistrations.assignedUser,
remarks: vendorRegularRegistrations.remarks,
+ // 새로 추가된 필드들
+ safetyQualificationContent: vendorRegularRegistrations.safetyQualificationContent,
+ gtcSkipped: vendorRegularRegistrations.gtcSkipped,
// 벤더 기본 정보
businessNumber: vendors.taxId,
companyName: vendors.vendorName,
establishmentDate: vendors.createdAt,
representative: vendors.representativeName,
+ // 국가 정보 추가
+ country: vendors.country,
})
.from(vendorRegularRegistrations)
.innerJoin(vendors, eq(vendorRegularRegistrations.vendorId, vendors.id))
@@ -59,23 +67,81 @@ export async function getVendorRegularRegistrations(
.innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
.where(inArray(vendorInvestigations.vendorId, vendorIds)) : [];
+ // 기본 계약 정보 조회 (템플릿별로 가장 최신 것만)
+ const basicContractsList = vendorIds.length > 0 ? await db
+ .select({
+ vendorId: basicContract.vendorId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ templateName: basicContractTemplates.templateName,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
+ .where(inArray(basicContract.vendorId, vendorIds))
+ .orderBy(desc(basicContract.createdAt)) : [];
+
+ // 추가정보 입력 상태 조회 (업무담당자 정보)
+ const businessContactsList = vendorIds.length > 0 ? await db
+ .select({
+ vendorId: vendorBusinessContacts.vendorId,
+ contactType: vendorBusinessContacts.contactType,
+ })
+ .from(vendorBusinessContacts)
+ .where(inArray(vendorBusinessContacts.vendorId, vendorIds)) : [];
+
+ // 추가정보 테이블 조회
+ const additionalInfoList = vendorIds.length > 0 ? await db
+ .select({
+ vendorId: vendorAdditionalInfo.vendorId,
+ })
+ .from(vendorAdditionalInfo)
+ .where(inArray(vendorAdditionalInfo.vendorId, vendorIds)) : [];
+
// 각 등록 레코드별로 데이터를 매핑하여 결과 반환
return registrations.map((registration) => {
// 벤더별 첨부파일 필터링
const vendorFiles = vendorAttachmentsList.filter(att => att.vendorId === registration.vendorId);
const investigationFiles = investigationAttachmentsList.filter(att => att.vendorId === registration.vendorId);
+ const allVendorContracts = basicContractsList.filter(contract => contract.vendorId === registration.vendorId);
+ const vendorContacts = businessContactsList.filter(contact => contact.vendorId === registration.vendorId);
+ const vendorAdditionalInfoData = additionalInfoList.filter(info => info.vendorId === registration.vendorId);
+
+ // 기술자료 동의서, 비밀유지계약서 제외 필터링
+ const filteredContracts = allVendorContracts.filter(contract => {
+ const templateName = contract.templateName?.toLowerCase() || '';
+ return !templateName.includes('기술자료') && !templateName.includes('비밀유지');
+ });
+
+ // 템플릿명 기준으로 가장 최신 계약만 유지 (중복 제거)
+ const vendorContracts = filteredContracts.reduce((acc, contract) => {
+ const existing = acc.find(c => c.templateName === contract.templateName);
+ if (!existing || (contract.createdAt && existing.createdAt && contract.createdAt > existing.createdAt)) {
+ // 기존에 같은 템플릿명이 없거나, 더 최신인 경우 추가/교체
+ return acc.filter(c => c.templateName !== contract.templateName).concat(contract);
+ }
+ return acc;
+ }, [] as typeof filteredContracts);
// 디버깅을 위한 로그
- console.log(`📋 벤더 ID ${registration.vendorId} (${registration.companyName}) 첨부파일 현황:`, {
+ console.log(`📋 벤더 ID ${registration.vendorId} (${registration.companyName}) 현황:`, {
vendorFiles: vendorFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName })),
- investigationFiles: investigationFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName }))
+ investigationFiles: investigationFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName })),
+ allContracts: allVendorContracts.length,
+ uniqueContracts: vendorContracts.map(c => ({
+ templateName: c.templateName,
+ status: c.status,
+ createdAt: c.createdAt?.toISOString()
+ })),
+ contactTypes: vendorContacts.map(c => c.contactType)
});
- // 문서 제출 현황 - 실제 첨부파일 존재 여부 확인 (DB 타입명과 정확히 매칭)
+ // 문서 제출 현황 - 국가별 요구사항 적용
+ const isForeign = registration.country !== 'KR';
const documentSubmissionsStatus = {
businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"),
- bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
+ bankCopy: isForeign ? vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY") : true, // 내자는 통장사본 불필요
auditResult: investigationFiles.length > 0, // 실사 첨부파일이 하나라도 있으면 true
};
@@ -88,33 +154,88 @@ export async function getVendorRegularRegistrations(
};
// 문서 제출 현황 로그
- console.log(`📊 벤더 ID ${registration.vendorId} 문서 제출 현황:`, documentSubmissionsStatus);
+ console.log(`📊 벤더 ID ${registration.vendorId} 문서 제출 현황:`, {
+ documentSubmissionsStatus,
+ isForeign,
+ vendorFiles: vendorFiles.map(f => ({
+ type: f.attachmentType,
+ fileName: f.fileName
+ })),
+ investigationFilesCount: investigationFiles.length,
+ country: registration.country
+ });
- // 계약 동의 현황 (기본값 - 추후 실제 계약 테이블과 연동)
+ // 계약 동의 현황 - 실제 기본 계약 데이터 기반으로 단순화
const contractAgreementsStatus = {
- cp: "not_submitted",
- gtc: "not_submitted",
- standardSubcontract: "not_submitted",
- safetyHealth: "not_submitted",
- ethics: "not_submitted",
- domesticCredit: "not_submitted",
- safetyQualification: "not_submitted",
+ cp: vendorContracts.some(c => c.status === "COMPLETED") ? "completed" : "not_submitted",
+ gtc: registration.gtcSkipped ? "completed" : (vendorContracts.some(c => c.templateName?.includes("GTC") && c.status === "COMPLETED") ? "completed" : "not_submitted"),
+ standardSubcontract: vendorContracts.some(c => c.templateName?.includes("표준하도급") && c.status === "COMPLETED") ? "completed" : "not_submitted",
+ safetyHealth: vendorContracts.some(c => c.templateName?.includes("안전보건") && c.status === "COMPLETED") ? "completed" : "not_submitted",
+ ethics: vendorContracts.some(c => c.templateName?.includes("윤리") && c.status === "COMPLETED") ? "completed" : "not_submitted",
+ domesticCredit: vendorContracts.some(c => c.templateName?.includes("내국신용장") && c.status === "COMPLETED") ? "completed" : "not_submitted",
};
+ // 추가정보 입력 완료 여부 - 5개 필수 업무담당자 타입 + 추가정보 테이블 모두 입력되었는지 확인
+ const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"];
+ const contactsCompleted = requiredContactTypes.every(type =>
+ vendorContacts.some(contact => contact.contactType === type)
+ );
+ const additionalInfoTableCompleted = vendorAdditionalInfoData.length > 0;
+ const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted;
+
+ // 추가정보 디버깅 로그
+ console.log(`🔍 벤더 ID ${registration.vendorId} 추가정보 상세:`, {
+ requiredContactTypes,
+ vendorContactTypes: vendorContacts.map(c => c.contactType),
+ contactsCompleted,
+ additionalInfoTableCompleted,
+ additionalInfoData: vendorAdditionalInfoData,
+ finalAdditionalInfoCompleted: additionalInfoCompleted
+ });
+
+ // 모든 조건 충족 여부 확인
+ const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true);
+ const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED");
+ const safetyQualificationCompleted = !!registration.safetyQualificationContent;
+
+ // 모든 조건이 충족되면 status를 "approval_ready"(조건충족)로 자동 변경
+ const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted;
+
+ // 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트
+ if (shouldUpdateStatus && registration.status !== "approval_ready") {
+ // 비동기 업데이트 (백그라운드에서 실행)
+ updateVendorRegularRegistration(registration.id, {
+ status: "approval_ready"
+ }).catch(error => {
+ console.error(`상태 자동 업데이트 실패 (벤더 ID: ${registration.vendorId}):`, error);
+ });
+ }
+
return {
id: registration.id,
vendorId: registration.vendorId,
- status: registration.status || "audit_pass",
+ status: shouldUpdateStatus ? "approval_ready" : (registration.status || "audit_pass"),
potentialCode: registration.potentialCode,
businessNumber: registration.businessNumber || "",
companyName: registration.companyName || "",
majorItems: registration.majorItems,
establishmentDate: registration.establishmentDate?.toISOString() || null,
representative: registration.representative,
+ country: registration.country,
documentSubmissions: documentSubmissionsStatus,
documentFiles: documentFiles, // 파일 정보 추가
contractAgreements: contractAgreementsStatus,
- additionalInfo: true, // TODO: 추가정보 로직 구현 필요
+ // 새로 추가된 필드들
+ safetyQualificationContent: registration.safetyQualificationContent,
+ gtcSkipped: registration.gtcSkipped || false,
+ additionalInfo: additionalInfoCompleted,
+ // 기본계약 정보
+ basicContracts: vendorContracts.map(contract => ({
+ templateId: contract.templateId,
+ templateName: contract.templateName,
+ status: contract.status,
+ createdAt: contract.createdAt,
+ })),
registrationRequestDate: registration.registrationRequestDate || null,
assignedDepartment: registration.assignedDepartment,
assignedUser: registration.assignedUser,
@@ -137,6 +258,8 @@ export async function createVendorRegularRegistration(data: {
assignedUser?: string;
assignedUserCode?: string;
remarks?: string;
+ safetyQualificationContent?: string;
+ gtcSkipped?: boolean;
}) {
try {
const [registration] = await db
@@ -151,6 +274,8 @@ export async function createVendorRegularRegistration(data: {
assignedUser: data.assignedUser,
assignedUserCode: data.assignedUserCode,
remarks: data.remarks,
+ safetyQualificationContent: data.safetyQualificationContent,
+ gtcSkipped: data.gtcSkipped || false,
})
.returning();
@@ -173,6 +298,8 @@ export async function updateVendorRegularRegistration(
assignedUser: string;
assignedUserCode: string;
remarks: string;
+ safetyQualificationContent: string;
+ gtcSkipped: boolean;
}>
) {
try {
diff --git a/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx b/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx
new file mode 100644
index 00000000..a93fbf22
--- /dev/null
+++ b/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { updateSafetyQualification } from "./service"
+
+const formSchema = z.object({
+ safetyQualificationContent: z.string().min(1, "안전적격성 평가 내용을 입력해주세요."),
+})
+
+interface SafetyQualificationUpdateSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ registrationId?: number
+ vendorName?: string
+ currentContent?: string | null
+ onSuccess?: () => void
+}
+
+export function SafetyQualificationUpdateSheet({
+ open,
+ onOpenChange,
+ registrationId,
+ vendorName,
+ currentContent,
+ onSuccess,
+}: SafetyQualificationUpdateSheetProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ safetyQualificationContent: currentContent || "",
+ },
+ })
+
+ // 폼 값 초기화
+ React.useEffect(() => {
+ if (open) {
+ form.reset({
+ safetyQualificationContent: currentContent || "",
+ })
+ }
+ }, [open, currentContent, form])
+
+ async function onSubmit(values: z.infer<typeof formSchema>) {
+ if (!registrationId) {
+ toast.error("등록 ID가 없습니다.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await updateSafetyQualification(
+ registrationId,
+ values.safetyQualificationContent
+ )
+
+ if (result.success) {
+ toast.success("안전적격성 평가가 등록되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "안전적격성 평가 등록에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("안전적격성 평가 등록 오류:", error)
+ toast.error("안전적격성 평가 등록 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[400px] sm:w-[540px]">
+ <SheetHeader>
+ <SheetTitle>안전적격성 평가 입력</SheetTitle>
+ <SheetDescription>
+ {vendorName && `${vendorName}의 `}안전적격성 평가 내용을 입력해주세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 mt-6">
+ <FormField
+ control={form.control}
+ name="safetyQualificationContent"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>안전적격성 평가 내용</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="안전적격성 평가 결과 및 내용을 입력해주세요..."
+ className="min-h-[200px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading}>
+ {isLoading ? "저장 중..." : "저장"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts
index b587ec23..51f4e82b 100644
--- a/lib/vendor-regular-registrations/service.ts
+++ b/lib/vendor-regular-registrations/service.ts
@@ -11,6 +11,7 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { headers } from "next/headers";
import { sendEmail } from "@/lib/mail/sendEmail";
+import type { RegistrationRequestData } from "@/components/vendor-regular-registrations/registration-request-dialog";
import {
vendors,
vendorRegularRegistrations,
@@ -20,7 +21,8 @@ import {
basicContract,
vendorPQSubmissions,
vendorBusinessContacts,
- vendorAdditionalInfo
+ vendorAdditionalInfo,
+ basicContractTemplates
} from "@/db/schema";
import db from "@/db/db";
import { inArray, eq, desc } from "drizzle-orm";
@@ -437,7 +439,7 @@ export async function skipLegalReview(vendorIds: number[], skipReason: string) {
const newRegistration = await createVendorRegularRegistration({
vendorId: vendorId,
status: "cp_finished", // CP완료로 변경
- remarks: `법무검토 Skip: ${skipReason}`,
+ remarks: `GTC Skip: ${skipReason}`,
});
registrationId = newRegistration.id;
} else {
@@ -445,11 +447,12 @@ export async function skipLegalReview(vendorIds: number[], skipReason: string) {
registrationId = existingRegistrations[0].id;
const currentRemarks = existingRegistrations[0].remarks || "";
const newRemarks = currentRemarks
- ? `${currentRemarks}\n법무검토 Skip: ${skipReason}`
- : `법무검토 Skip: ${skipReason}`;
+ ? `${currentRemarks}\nGTC Skip: ${skipReason}`
+ : `GTC Skip: ${skipReason}`;
await updateVendorRegularRegistration(registrationId, {
status: "cp_finished", // CP완료로 변경
+ gtcSkipped: true, // GTC Skip 여부 설정
remarks: newRemarks,
});
}
@@ -470,18 +473,19 @@ export async function skipLegalReview(vendorIds: number[], skipReason: string) {
return {
success: true,
- message: `${successCount}개 업체의 법무검토를 Skip 처리했습니다.`,
+ message: `${successCount}개 업체의 GTC를 Skip 처리했습니다.`,
};
} catch (error) {
console.error("Error skipping legal review:", error);
return {
success: false,
- error: error instanceof Error ? error.message : "법무검토 Skip 처리 중 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "GTC Skip 처리 중 오류가 발생했습니다.",
};
}
}
-// 안전적격성평가 Skip 기능
+// 안전적격성평가 Skip 기능 (삭제됨 - 개별 입력으로 대체)
+/*
export async function skipSafetyQualification(vendorIds: number[], skipReason: string) {
try {
const session = await getServerSession(authOptions);
@@ -562,6 +566,42 @@ export async function skipSafetyQualification(vendorIds: number[], skipReason: s
};
}
}
+*/
+
+// 주요품목 업데이트
+export async function updateMajorItems(
+ registrationId: number,
+ majorItems: string
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ const result = await updateVendorRegularRegistration(registrationId, {
+ majorItems: majorItems,
+ });
+
+ if (!result) {
+ return { success: false, error: "등록 정보를 찾을 수 없습니다." };
+ }
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return {
+ success: true,
+ message: "주요품목이 업데이트되었습니다.",
+ };
+ } catch (error) {
+ console.error("Error updating major items:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "주요품목 업데이트 중 오류가 발생했습니다.",
+ };
+ }
+}
// 벤더용 현황 조회 함수들
export async function fetchVendorRegistrationStatus(vendorId: number) {
@@ -570,7 +610,15 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
try {
// 벤더 기본 정보
const vendor = await db
- .select()
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ taxId: vendors.taxId,
+ representativeName: vendors.representativeName,
+ country: vendors.country,
+ createdAt: vendors.createdAt,
+ updatedAt: vendors.updatedAt,
+ })
.from(vendors)
.where(eq(vendors.id, vendorId))
.limit(1)
@@ -584,7 +632,23 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
// 정규업체 등록 정보
const registration = await db
- .select()
+ .select({
+ id: vendorRegularRegistrations.id,
+ vendorId: vendorRegularRegistrations.vendorId,
+ potentialCode: vendorRegularRegistrations.potentialCode,
+ status: vendorRegularRegistrations.status,
+ majorItems: vendorRegularRegistrations.majorItems,
+ registrationRequestDate: vendorRegularRegistrations.registrationRequestDate,
+ assignedDepartment: vendorRegularRegistrations.assignedDepartment,
+ assignedDepartmentCode: vendorRegularRegistrations.assignedDepartmentCode,
+ assignedUser: vendorRegularRegistrations.assignedUser,
+ assignedUserCode: vendorRegularRegistrations.assignedUserCode,
+ remarks: vendorRegularRegistrations.remarks,
+ safetyQualificationContent: vendorRegularRegistrations.safetyQualificationContent,
+ gtcSkipped: vendorRegularRegistrations.gtcSkipped,
+ createdAt: vendorRegularRegistrations.createdAt,
+ updatedAt: vendorRegularRegistrations.updatedAt,
+ })
.from(vendorRegularRegistrations)
.where(eq(vendorRegularRegistrations.vendorId, vendorId))
.limit(1)
@@ -613,12 +677,45 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
.orderBy(desc(vendorPQSubmissions.createdAt))
.limit(1)
- // 기본계약 정보
- const contractInfo = await db
- .select()
+ // 기본계약 정보 - 템플릿 정보와 함께 조회
+ const allVendorContracts = await db
+ .select({
+ templateId: basicContract.templateId,
+ templateName: basicContractTemplates.templateName,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ })
.from(basicContract)
+ .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
.where(eq(basicContract.vendorId, vendorId))
- .limit(1)
+ .orderBy(desc(basicContract.createdAt))
+
+ // 계약 필터링 (기술자료, 비밀유지 제외)
+ const filteredContracts = allVendorContracts.filter(contract =>
+ contract.templateName &&
+ !contract.templateName.includes("기술자료") &&
+ !contract.templateName.includes("비밀유지")
+ )
+
+ // 템플릿 이름별로 가장 최신 계약만 유지
+ const vendorContracts = filteredContracts.reduce((acc: typeof filteredContracts, contract) => {
+ const existing = acc.find((c: typeof contract) => c.templateName === contract.templateName)
+ if (!existing || (contract.createdAt && existing.createdAt && contract.createdAt > existing.createdAt)) {
+ return acc.filter((c: typeof contract) => c.templateName !== contract.templateName).concat(contract)
+ }
+ return acc
+ }, [] as typeof filteredContracts)
+
+ console.log(`🏢 Partners 벤더 ID ${vendorId} 기본계약 정보:`, {
+ allContractsCount: allVendorContracts.length,
+ filteredContractsCount: filteredContracts.length,
+ finalContractsCount: vendorContracts.length,
+ vendorContracts: vendorContracts.map((c: any) => ({
+ templateName: c.templateName,
+ status: c.status,
+ createdAt: c.createdAt
+ }))
+ })
// 업무담당자 정보
const businessContacts = await db
@@ -636,15 +733,15 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
// 문서 제출 현황 계산
const documentStatus = {
businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_EVALUATION"),
- bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_COPY"),
+ creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"), // CREDIT_EVALUATION -> CREDIT_REPORT
+ bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY"), // BANK_COPY -> BANK_ACCOUNT_COPY
auditResult: investigationFiles.length > 0, // DocumentStatusDialog에서 사용하는 키
- cpDocument: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
- gtc: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
- standardSubcontract: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
- safetyHealth: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
- ethics: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
- domesticCredit: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ cpDocument: vendorContracts.some(c => c.status === "COMPLETED"),
+ gtc: vendorContracts.some(c => c.templateName?.includes("GTC") && c.status === "COMPLETED"),
+ standardSubcontract: vendorContracts.some(c => c.templateName?.includes("표준하도급") && c.status === "COMPLETED"),
+ safetyHealth: vendorContracts.some(c => c.templateName?.includes("안전보건") && c.status === "COMPLETED"),
+ ethics: vendorContracts.some(c => c.templateName?.includes("윤리") && c.status === "COMPLETED"),
+ domesticCredit: vendorContracts.some(c => c.templateName?.includes("신용") && c.status === "COMPLETED"),
safetyQualification: investigationFiles.length > 0,
}
@@ -656,6 +753,26 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"]
const existingContactTypes = businessContacts.map(contact => contact.contactType)
const missingContactTypes = requiredContactTypes.filter(type => !existingContactTypes.includes(type))
+
+ // 추가정보 완료 여부 (업무담당자 + 추가정보 테이블 모두 필요)
+ const contactsCompleted = missingContactTypes.length === 0
+ const additionalInfoTableCompleted = !!additionalInfo[0]
+ const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted
+
+ console.log(`🔍 Partners 벤더 ID ${vendorId} 전체 데이터:`, {
+ vendor: vendor[0],
+ registration: registration[0],
+ safetyQualificationContent: registration[0]?.safetyQualificationContent,
+ gtcSkipped: registration[0]?.gtcSkipped,
+ requiredContactTypes,
+ existingContactTypes,
+ missingContactTypes,
+ contactsCompleted,
+ additionalInfoTableCompleted,
+ additionalInfoData: additionalInfo[0],
+ finalAdditionalInfoCompleted: additionalInfoCompleted,
+ basicContractsCount: vendorContracts.length
+ })
return {
success: true,
@@ -666,10 +783,10 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
missingDocuments,
businessContacts,
missingContactTypes,
- additionalInfo: additionalInfo[0] || null,
+ additionalInfo: additionalInfoCompleted, // boolean 값으로 변경
pqSubmission: pqSubmission[0] || null,
auditPassed: investigationFiles.length > 0,
- contractInfo: contractInfo[0] || null,
+ basicContracts: vendorContracts, // 기본계약 정보 추가
incompleteItemsCount: {
documents: missingDocuments.length,
contacts: missingContactTypes.length,
@@ -823,3 +940,240 @@ export async function saveVendorAdditionalInfo(
}
}
}
+
+// 안전적격성 평가 업데이트
+export async function updateSafetyQualification(
+ registrationId: number,
+ safetyQualificationContent: string
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ const result = await updateVendorRegularRegistration(registrationId, {
+ safetyQualificationContent: safetyQualificationContent.trim(),
+ });
+
+ if (!result) {
+ return { success: false, error: "등록 정보를 찾을 수 없습니다." };
+ }
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return {
+ success: true,
+ message: "안전적격성 평가가 등록되었습니다.",
+ };
+ } catch (error) {
+ console.error("Error updating safety qualification:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "안전적격성 평가 등록 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// GTC Skip 처리
+export async function updateGtcSkip(
+ registrationId: number,
+ skipReason: string
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ // 현재 비고 가져오기
+ const existingRegistration = await getVendorRegularRegistrationById(registrationId);
+ if (!existingRegistration) {
+ return { success: false, error: "등록 정보를 찾을 수 없습니다." };
+ }
+
+ const currentRemarks = existingRegistration.remarks || "";
+ const newRemarks = currentRemarks
+ ? `${currentRemarks}\nGTC Skip: ${skipReason}`
+ : `GTC Skip: ${skipReason}`;
+
+ const result = await updateVendorRegularRegistration(registrationId, {
+ gtcSkipped: true,
+ remarks: newRemarks,
+ });
+
+ if (!result) {
+ return { success: false, error: "등록 정보를 찾을 수 없습니다." };
+ }
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return {
+ success: true,
+ message: "GTC Skip이 처리되었습니다.",
+ };
+ } catch (error) {
+ console.error("Error updating GTC skip:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "GTC Skip 처리 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 정규업체 등록 요청을 위한 상세 데이터 조회
+export async function fetchRegistrationRequestData(registrationId: number) {
+ try {
+ // 등록 정보 조회
+ const registration = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.id, registrationId))
+ .limit(1);
+
+ if (!registration[0]) {
+ return { success: false, error: "등록 정보를 찾을 수 없습니다." };
+ }
+
+ // 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ taxId: vendors.taxId,
+ representativeName: vendors.representativeName,
+ representativeBirth: vendors.representativeBirth,
+ representativeEmail: vendors.representativeEmail,
+ representativePhone: vendors.representativePhone,
+ representativeWorkExpirence: vendors.representativeWorkExpirence,
+ country: vendors.country,
+ corporateRegistrationNumber: vendors.corporateRegistrationNumber,
+ address: vendors.address,
+ phone: vendors.phone,
+ email: vendors.email,
+ createdAt: vendors.createdAt,
+ updatedAt: vendors.updatedAt,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, registration[0].vendorId))
+ .limit(1);
+
+ if (!vendor[0]) {
+ return { success: false, error: "벤더 정보를 찾을 수 없습니다." };
+ }
+
+ // 업무담당자 정보 조회
+ const businessContacts = await db
+ .select()
+ .from(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendor[0].id));
+
+ // 추가정보 조회
+ const additionalInfo = await db
+ .select()
+ .from(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendor[0].id))
+ .limit(1);
+
+ return {
+ success: true,
+ data: {
+ registration: registration[0],
+ vendor: vendor[0],
+ businessContacts,
+ additionalInfo: additionalInfo[0] || null,
+ }
+ };
+
+ } catch (error) {
+ console.error("정규업체 등록 요청 데이터 조회 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "데이터 조회 중 오류가 발생했습니다."
+ };
+ }
+}
+
+// 정규업체 등록 요청 서버 액션
+export async function submitRegistrationRequest(
+ registrationId: number,
+ requestData: RegistrationRequestData
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "인증이 필요합니다." };
+ }
+
+ // 현재 등록 정보 조회
+ const registration = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.id, registrationId))
+ .limit(1);
+
+ if (!registration[0]) {
+ return { success: false, error: "등록 정보를 찾을 수 없습니다." };
+ }
+
+ // 조건충족 상태인지 확인
+ if (registration[0].status !== "approval_ready") {
+ return { success: false, error: "조건충족 상태가 아닙니다." };
+ }
+
+ // 정규업체 등록 요청 데이터를 JSON으로 저장
+ const registrationRequestData = {
+ requestDate: new Date(),
+ requestedBy: session.user.id,
+ requestedByName: session.user.name,
+ requestData: requestData,
+ status: "requested" // 요청됨
+ };
+
+ // 상태를 '등록요청됨'으로 변경하고 요청 데이터 저장
+ await db
+ .update(vendorRegularRegistrations)
+ .set({
+ status: "registration_requested",
+ remarks: `정규업체 등록 요청됨 - ${new Date().toISOString()}\n요청자: ${session.user.name}`,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorRegularRegistrations.id, registrationId));
+
+ // TODO: MDG 인터페이스 연동
+ // await sendToMDG(registrationRequestData);
+
+ // TODO: Knox 결재 연동
+ // - 사업자등록증, 신용평가보고서, 개인정보동의서, 통장사본
+ // - 실사결과 보고서
+ // - CP문서, GTC문서, 비밀유지계약서
+ // await initiateKnoxApproval(registrationRequestData);
+
+ console.log("✅ 정규업체 등록 요청 데이터:", {
+ registrationId,
+ companyName: requestData.companyNameKor,
+ businessNumber: requestData.businessNumber,
+ representative: requestData.representativeNameKor,
+ requestedBy: session.user.name,
+ requestDate: new Date().toISOString()
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+ revalidateTag(`vendor-regular-registration-${registrationId}`);
+
+ return {
+ success: true,
+ message: "정규업체 등록 요청이 성공적으로 제출되었습니다.\nKnox 결재 시스템과 MDG 인터페이스 연동은 추후 구현 예정입니다."
+ };
+
+ } catch (error) {
+ console.error("정규업체 등록 요청 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 요청 중 오류가 발생했습니다."
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx b/lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx
new file mode 100644
index 00000000..c2aeba70
--- /dev/null
+++ b/lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { updateSafetyQualification } from "../service"
+
+const formSchema = z.object({
+ safetyQualificationContent: z.string().min(1, "안전적격성 평가 내용을 입력해주세요."),
+})
+
+interface SafetyQualificationUpdateSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ registrationId?: number
+ vendorName?: string
+ currentContent?: string | null
+ onSuccess?: () => void
+}
+
+export function SafetyQualificationUpdateSheet({
+ open,
+ onOpenChange,
+ registrationId,
+ vendorName,
+ currentContent,
+ onSuccess,
+}: SafetyQualificationUpdateSheetProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ safetyQualificationContent: currentContent || "",
+ },
+ })
+
+ // 폼 값 초기화
+ React.useEffect(() => {
+ if (open) {
+ form.reset({
+ safetyQualificationContent: currentContent || "",
+ })
+ }
+ }, [open, currentContent, form])
+
+ async function onSubmit(values: z.infer<typeof formSchema>) {
+ if (!registrationId) {
+ toast.error("등록 ID가 없습니다.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await updateSafetyQualification(
+ registrationId,
+ values.safetyQualificationContent
+ )
+
+ if (result.success) {
+ toast.success("안전적격성 평가가 등록되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "안전적격성 평가 등록에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("안전적격성 평가 등록 오류:", error)
+ toast.error("안전적격성 평가 등록 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[400px] sm:w-[540px]">
+ <SheetHeader>
+ <SheetTitle>안전적격성 평가 입력</SheetTitle>
+ <SheetDescription>
+ {vendorName && `${vendorName}의 `}안전적격성 평가 내용을 입력해주세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 mt-6">
+ <FormField
+ control={form.control}
+ name="safetyQualificationContent"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>안전적격성 평가 내용</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="안전적격성 평가 결과 및 내용을 입력해주세요..."
+ className="min-h-[200px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading}>
+ {isLoading ? "저장 중..." : "저장"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
index 023bcfba..765b0279 100644
--- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
@@ -10,9 +10,12 @@ import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrati
import { DocumentStatusDialog } from "@/components/vendor-regular-registrations/document-status-dialog"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
-import { Eye, FileText, Ellipsis } from "lucide-react"
+import { Eye, FileText, Ellipsis, Shield, Package } from "lucide-react"
import { toast } from "sonner"
import { useState } from "react"
+import { SafetyQualificationUpdateSheet } from "./safety-qualification-update-sheet"
+import { MajorItemsUpdateSheet } from "../major-items-update-sheet"
+
const statusLabels = {
audit_pass: "실사통과",
@@ -20,6 +23,7 @@ const statusLabels = {
cp_review: "CP검토",
cp_finished: "CP완료",
approval_ready: "조건충족",
+ registration_requested: "등록요청됨",
in_review: "정규등록검토",
pending_approval: "장기미등록",
}
@@ -30,6 +34,7 @@ const statusColors = {
cp_review: "bg-yellow-100 text-yellow-800",
cp_finished: "bg-purple-100 text-purple-800",
approval_ready: "bg-emerald-100 text-emerald-800",
+ registration_requested: "bg-indigo-100 text-indigo-800",
in_review: "bg-orange-100 text-orange-800",
pending_approval: "bg-red-100 text-red-800",
}
@@ -159,22 +164,58 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
},
{
id: "documentStatus",
- header: "문서/자료 접수 현황",
+ header: "진행현황",
cell: ({ row }) => {
const DocumentStatusCell = () => {
const [documentDialogOpen, setDocumentDialogOpen] = useState(false)
const registration = row.original
+
+ // 문서 현황 계산 (국가별 요구사항 적용)
+ const isForeign = registration.country !== 'KR'
+ const requiredDocs = isForeign ? 4 : 3 // 외자: 4개(통장사본 포함), 내자: 3개(통장사본 제외)
+ const submittedDocs = Object.values(registration.documentSubmissions).filter(Boolean).length
+ const incompleteDocs = requiredDocs - submittedDocs
+
+ // 기본계약 현황 계산
+ const totalContracts = registration.basicContracts?.length || 0
+ const completedContracts = registration.basicContracts?.filter(c => c.status === "COMPLETED").length || 0
+ const incompleteContracts = totalContracts - completedContracts
+
+ // 안전적격성 평가 현황
+ const safetyCompleted = !!registration.safetyQualificationContent
+
+ // 추가정보 현황
+ const additionalInfoCompleted = registration.additionalInfo
+
+ // 전체 미완료 항목 계산
+ const totalIncomplete =
+ (incompleteDocs > 0 ? 1 : 0) +
+ (incompleteContracts > 0 ? 1 : 0) +
+ (!safetyCompleted ? 1 : 0) +
+ (!additionalInfoCompleted ? 1 : 0)
+
+ const isAllComplete = totalIncomplete === 0
return (
<>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => setDocumentDialogOpen(true)}
- >
- <Eye className="w-4 h-4" />
- 현황보기
- </Button>
+ <div className="space-y-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setDocumentDialogOpen(true)}
+ className="h-auto p-1 text-left justify-start"
+ >
+ <div className="space-y-0.5">
+ {isAllComplete ? (
+ <div className="text-xs text-green-600 font-medium">모든 항목 완료</div>
+ ) : (
+ <div className="text-xs text-orange-600 font-medium">
+ 총 {totalIncomplete}건 미완료
+ </div>
+ )}
+ </div>
+ </Button>
+ </div>
<DocumentStatusDialog
open={documentDialogOpen}
onOpenChange={setDocumentDialogOpen}
@@ -222,7 +263,8 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
id: "actions",
cell: ({ row }) => {
const ActionsDropdownCell = () => {
- const [documentDialogOpen, setDocumentDialogOpen] = useState(false)
+ const [safetyQualificationSheetOpen, setSafetyQualificationSheetOpen] = useState(false)
+ const [majorItemsSheetOpen, setMajorItemsSheetOpen] = useState(false)
const registration = row.original
return (
@@ -239,26 +281,43 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem
- onClick={() => setDocumentDialogOpen(true)}
+ onClick={() => setSafetyQualificationSheetOpen(true)}
>
- <Eye className="mr-2 h-4 w-4" />
- 현황보기
+ <Shield className="mr-2 h-4 w-4" />
+ 안전적격성 평가
</DropdownMenuItem>
<DropdownMenuItem
- onClick={() => {
- toast.info("정규업체 등록 요청 기능은 준비 중입니다.")
- }}
+ onClick={() => setMajorItemsSheetOpen(true)}
>
- <FileText className="mr-2 h-4 w-4" />
- 등록요청
+ <Package className="mr-2 h-4 w-4" />
+ 주요품목 등록
</DropdownMenuItem>
+
</DropdownMenuContent>
</DropdownMenu>
- <DocumentStatusDialog
- open={documentDialogOpen}
- onOpenChange={setDocumentDialogOpen}
- registration={registration}
+ <SafetyQualificationUpdateSheet
+ open={safetyQualificationSheetOpen}
+ onOpenChange={setSafetyQualificationSheetOpen}
+ registrationId={registration.id}
+ vendorName={registration.companyName}
+ currentContent={registration.safetyQualificationContent}
+ onSuccess={() => {
+ // 페이지 새로고침 또는 데이터 리페치
+ window.location.reload()
+ }}
+ />
+ <MajorItemsUpdateSheet
+ open={majorItemsSheetOpen}
+ onOpenChange={setMajorItemsSheetOpen}
+ registrationId={registration.id}
+ vendorName={registration.companyName}
+ currentItems={registration.majorItems}
+ onSuccess={() => {
+ // 페이지 새로고침 또는 데이터 리페치
+ window.location.reload()
+ }}
/>
+
</>
)
}
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
index c3b4739a..3a1216f2 100644
--- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
@@ -2,18 +2,20 @@
import { type Table } from "@tanstack/react-table"
import { toast } from "sonner"
+import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
-import { FileText, RefreshCw, Download, Mail, FileWarning, Scale, Shield } from "lucide-react"
+import { Mail, FileWarning, Scale, FileText } from "lucide-react"
import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
import {
sendMissingContractRequestEmails,
sendAdditionalInfoRequestEmails,
skipLegalReview,
- skipSafetyQualification
+ submitRegistrationRequest
} from "../service"
import { useState } from "react"
import { SkipReasonDialog } from "@/components/vendor-regular-registrations/skip-reason-dialog"
+import { RegistrationRequestDialog } from "@/components/vendor-regular-registrations/registration-request-dialog"
interface VendorRegularRegistrationsTableToolbarActionsProps {
table: Table<VendorRegularRegistration>
@@ -22,24 +24,31 @@ interface VendorRegularRegistrationsTableToolbarActionsProps {
export function VendorRegularRegistrationsTableToolbarActions({
table,
}: VendorRegularRegistrationsTableToolbarActionsProps) {
+ const router = useRouter()
const [syncLoading, setSyncLoading] = useState<{
missingContract: boolean;
additionalInfo: boolean;
legalSkip: boolean;
- safetySkip: boolean;
+ registrationRequest: boolean;
}>({
missingContract: false,
additionalInfo: false,
legalSkip: false,
- safetySkip: false,
+ registrationRequest: false,
})
const [skipDialogs, setSkipDialogs] = useState<{
legalReview: boolean;
- safetyQualification: boolean;
}>({
legalReview: false,
- safetyQualification: false,
+ })
+
+ const [registrationRequestDialog, setRegistrationRequestDialog] = useState<{
+ open: boolean;
+ registration: VendorRegularRegistration | null;
+ }>({
+ open: false,
+ registration: null,
})
const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
@@ -108,7 +117,7 @@ export function VendorRegularRegistrationsTableToolbarActions({
if (result.success) {
toast.success(result.message);
- window.location.reload();
+ router.refresh();
} else {
toast.error(result.error);
}
@@ -120,33 +129,52 @@ export function VendorRegularRegistrationsTableToolbarActions({
}
};
- const handleSafetyQualificationSkip = async (reason: string) => {
- if (selectedRows.length === 0) {
- toast.error("업체를 선택해주세요.");
+ // 등록요청 핸들러
+ const handleRegistrationRequest = () => {
+ const approvalReadyRows = selectedRows.filter(row => row.status === "approval_ready");
+
+ if (approvalReadyRows.length === 0) {
+ toast.error("조건충족 상태의 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (approvalReadyRows.length > 1) {
+ toast.error("정규업체 등록 요청은 한 번에 하나씩만 가능합니다.");
return;
}
- setSyncLoading(prev => ({ ...prev, safetySkip: true }));
+ setRegistrationRequestDialog({
+ open: true,
+ registration: approvalReadyRows[0],
+ });
+ };
+
+ const handleRegistrationRequestSubmit = async (requestData: any) => {
+ if (!registrationRequestDialog.registration) return;
+
+ setSyncLoading(prev => ({ ...prev, registrationRequest: true }));
try {
- const vendorIds = selectedRows.map(row => row.vendorId);
- const result = await skipSafetyQualification(vendorIds, reason);
-
+ const result = await submitRegistrationRequest(registrationRequestDialog.registration.id, requestData);
if (result.success) {
toast.success(result.message);
- window.location.reload();
+ setRegistrationRequestDialog({ open: false, registration: null });
+ window.location.reload(); // 데이터 새로고침
} else {
toast.error(result.error);
}
} catch (error) {
- console.error("Error skipping safety qualification:", error);
- toast.error("안전적격성평가 Skip 처리 중 오류가 발생했습니다.");
+ console.error("등록요청 오류:", error);
+ toast.error("등록요청 중 오류가 발생했습니다.");
} finally {
- setSyncLoading(prev => ({ ...prev, safetySkip: false }));
+ setSyncLoading(prev => ({ ...prev, registrationRequest: false }));
}
};
// CP검토 상태인 선택된 행들 개수
const cpReviewCount = selectedRows.filter(row => row.status === "cp_review").length;
+
+ // 조건충족 상태인 선택된 행들 개수
+ const approvalReadyCount = selectedRows.filter(row => row.status === "approval_ready").length;
return (
<div className="flex items-center gap-2">
@@ -193,55 +221,37 @@ export function VendorRegularRegistrationsTableToolbarActions({
<Button
variant="outline"
size="sm"
- onClick={() => {
- if (selectedRows.length === 0) {
- toast.error("내보낼 항목을 선택해주세요.")
- return
- }
- toast.info("엑셀 내보내기 기능은 준비 중입니다.")
- }}
- disabled={selectedRows.length === 0}
- >
- <Download className="mr-2 h-4 w-4" />
- 엑셀 내보내기
- </Button>
-
- <Button
- variant="outline"
- size="sm"
onClick={() => setSkipDialogs(prev => ({ ...prev, legalReview: true }))}
disabled={syncLoading.legalSkip || cpReviewCount === 0}
>
<Scale className="mr-2 h-4 w-4" />
- {syncLoading.legalSkip ? "처리 중..." : "법무검토 Skip"}
+ {syncLoading.legalSkip ? "처리 중..." : "GTC Skip"}
</Button>
<Button
- variant="outline"
+ variant="default"
size="sm"
- onClick={() => setSkipDialogs(prev => ({ ...prev, safetyQualification: true }))}
- disabled={syncLoading.safetySkip || selectedRows.length === 0}
+ onClick={handleRegistrationRequest}
+ disabled={syncLoading.registrationRequest || approvalReadyCount === 0}
>
- <Shield className="mr-2 h-4 w-4" />
- {syncLoading.safetySkip ? "처리 중..." : "안전 Skip"}
+ <FileText className="mr-2 h-4 w-4" />
+ {syncLoading.registrationRequest ? "처리 중..." : "등록요청"}
</Button>
<SkipReasonDialog
open={skipDialogs.legalReview}
onOpenChange={(open) => setSkipDialogs(prev => ({ ...prev, legalReview: open }))}
- title="법무검토 Skip"
- description={`선택된 ${cpReviewCount}개 업체의 법무검토를 Skip하고 CP완료 상태로 변경합니다. Skip 사유를 입력해주세요.`}
+ title="GTC Skip"
+ description={`선택된 ${cpReviewCount}개 업체의 GTC를 Skip하고 CP완료 상태로 변경합니다. Skip 사유를 입력해주세요.`}
onConfirm={handleLegalReviewSkip}
loading={syncLoading.legalSkip}
/>
- <SkipReasonDialog
- open={skipDialogs.safetyQualification}
- onOpenChange={(open) => setSkipDialogs(prev => ({ ...prev, safetyQualification: open }))}
- title="안전적격성평가 Skip"
- description={`선택된 ${selectedRows.length}개 업체의 안전적격성평가를 Skip하고 완료 상태로 변경합니다. Skip 사유를 입력해주세요.`}
- onConfirm={handleSafetyQualificationSkip}
- loading={syncLoading.safetySkip}
+ <RegistrationRequestDialog
+ open={registrationRequestDialog.open}
+ onOpenChange={(open) => setRegistrationRequestDialog(prev => ({ ...prev, open }))}
+ registration={registrationRequestDialog.registration}
+ onSubmit={handleRegistrationRequestSubmit}
/>
</div>
)
diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx
index a0c24dc6..5b5f722c 100644
--- a/lib/vendors/table/request-pq-dialog.tsx
+++ b/lib/vendors/table/request-pq-dialog.tsx
@@ -127,14 +127,38 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
}
}, [type])
- // 기본계약서 템플릿 로딩
+ // 기본계약서 템플릿 로딩 및 자동 선택
React.useEffect(() => {
setIsLoadingTemplates(true)
getALLBasicContractTemplates()
- .then(setBasicContractTemplates)
+ .then((templates) => {
+ setBasicContractTemplates(templates)
+
+ // 벤더 국가별 자동 선택 로직
+ if (vendors.length > 0) {
+ const isAllForeign = vendors.every(vendor => vendor.country !== 'KR')
+ const isAllDomestic = vendors.every(vendor => vendor.country === 'KR')
+
+ if (isAllForeign) {
+ // 외자: 준법서약 (영문), GTC만 선택
+ const foreignTemplates = templates.filter(template =>
+ template.templateName?.includes('준법서약') && template.templateName?.includes('영문') ||
+ template.templateName?.includes('GTC')
+ )
+ setSelectedTemplateIds(foreignTemplates.map(t => t.id))
+ } else if (isAllDomestic) {
+ // 내자: 준법서약 (영문), GTC 제외한 모든 템플릿 선택
+ const domesticTemplates = templates.filter(template => {
+ const name = template.templateName?.toLowerCase() || ''
+ return !(name.includes('준법서약') && name.includes('영문')) && !name.includes('gtc')
+ })
+ setSelectedTemplateIds(domesticTemplates.map(t => t.id))
+ }
+ }
+ })
.catch(() => toast.error("기본계약서 템플릿 로딩 실패"))
.finally(() => setIsLoadingTemplates(false))
- }, [])
+ }, [vendors])
React.useEffect(() => {
if (!props.open) {
@@ -527,6 +551,10 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
{selectedTemplateIds.length > 0 && (
<div className="text-xs text-muted-foreground">
{selectedTemplateIds.length}개 템플릿이 선택되었습니다.
+ {vendors.length > 0 && vendors.every(v => v.country !== 'KR') &&
+ " (외자 벤더 - 자동 선택됨)"}
+ {vendors.length > 0 && vendors.every(v => v.country === 'KR') &&
+ " (내자 벤더 - 자동 선택됨)"}
</div>
)}
</div>