diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-19 09:23:47 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-19 09:23:47 +0000 |
| commit | 8077419e40368dc703f94d558fc746b73fbc6702 (patch) | |
| tree | 333bdfb3b0d84336f1bf7d4f0f1bbced6bec2d4c | |
| parent | aa71f75ace013b2fe982e5a104e61440458e0fd2 (diff) | |
(최겸) 구매 PQ 비밀유지계약서 별첨 첨부파일 추가, 정규업체등록관리 개발
12 files changed, 776 insertions, 650 deletions
diff --git a/app/[lng]/partners/(partners)/registration-status/page.tsx b/app/[lng]/partners/(partners)/registration-status/page.tsx deleted file mode 100644 index 21bcea59..00000000 --- a/app/[lng]/partners/(partners)/registration-status/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from "react"
-import { Suspense } from "react"
-
-import { Skeleton } from "@/components/ui/skeleton"
-import { Shell } from "@/components/shell"
-import { VendorRegistrationStatusView } from "@/lib/vendor-registration-status/vendor-registration-status-view"
-
-export default async function VendorRegistrationStatusPage() {
- return (
- <Shell className="gap-4">
- <Suspense
- fallback={
- <div className="space-y-4">
- <Skeleton className="h-10 w-full" />
- <Skeleton className="h-64 w-full" />
- <Skeleton className="h-32 w-full" />
- </div>
- }
- >
- <VendorRegistrationStatusView />
- </Suspense>
- </Shell>
- )
-}
diff --git a/components/additional-info/join-form.tsx b/components/additional-info/join-form.tsx index da2ddac7..d9f5052e 100644 --- a/components/additional-info/join-form.tsx +++ b/components/additional-info/join-form.tsx @@ -36,12 +36,16 @@ import { CommandGroup, CommandItem, } from "@/components/ui/command" -import { Check, ChevronsUpDown, Download, Loader2, Plus, X } from "lucide-react" +import { Check, ChevronsUpDown, Download, Loader2, Plus, X, FileText, Eye, Upload, CheckCircle } from "lucide-react" import { cn } from "@/lib/utils" import { useTranslation } from "@/i18n/client" import { getVendorDetailById, downloadVendorAttachments, updateVendorInfo } from "@/lib/vendors/service" import { updateVendorSchema, updateVendorSchemaWithConditions, type UpdateVendorInfoSchema } from "@/lib/vendors/validations" +import { fetchVendorRegistrationStatus } from "@/lib/vendor-regular-registrations/service" +import { DocumentStatusDialog } from "@/components/vendor-regular-registrations/document-status-dialog" +import { AdditionalInfoDialog } from "@/components/vendor-regular-registrations/additional-info-dialog" +import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig" import { Select, SelectContent, @@ -124,6 +128,17 @@ const cashFlowRatingScaleMap: Record<string, string[]> = { const MAX_FILE_SIZE = 3e9 +// 첨부파일 타입 정의 +const ATTACHMENT_TYPES = [ + { value: "BUSINESS_REGISTRATION", label: "사업자등록증" }, + { value: "CREDIT_REPORT", label: "신용평가보고서" }, + { value: "BANK_ACCOUNT_COPY", label: "통장사본" }, + { value: "ISO_CERTIFICATION", label: "ISO인증서" }, + { value: "GENERAL", label: "일반 문서" }, +] as const + +type AttachmentType = typeof ATTACHMENT_TYPES[number]['value'] + // 파일 타입 정의 interface AttachmentFile { id: number @@ -151,12 +166,25 @@ export function InfoForm() { const [existingFiles, setExistingFiles] = React.useState<AttachmentFile[]>([]) const [existingCreditFiles, setExistingCreditFiles] = React.useState<AttachmentFile[]>([]) const [existingCashFlowFiles, setExistingCashFlowFiles] = React.useState<AttachmentFile[]>([]) + const [existingSignatureFiles, setExistingSignatureFiles] = React.useState<AttachmentFile[]>([]) const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) const [creditRatingFile, setCreditRatingFile] = React.useState<File[]>([]) const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState<File[]>([]) const [isDownloading, setIsDownloading] = React.useState(false); + // 정규등록 관련 상태 + const [registrationData, setRegistrationData] = React.useState<any>(null) + const [documentDialogOpen, setDocumentDialogOpen] = React.useState(false) + const [additionalInfoDialogOpen, setAdditionalInfoDialogOpen] = React.useState(false) + + // 첨부파일 타입 선택 상태 + const [selectedAttachmentType, setSelectedAttachmentType] = React.useState<AttachmentType>("GENERAL") + + // 서명/직인 업로드 관련 상태 + const [signatureFiles, setSignatureFiles] = React.useState<File[]>([]) + const [hasSignature, setHasSignature] = React.useState(false) + // React Hook Form const form = useForm<UpdateVendorInfoSchema>({ resolver: zodResolver(updateVendorSchemaWithConditions), @@ -223,19 +251,34 @@ export function InfoForm() { // 첨부파일 정보 분류 (view에서 이미 파싱된 attachments 배열 사용) if (vendorData.attachments && Array.isArray(vendorData.attachments)) { - const generalFiles = vendorData.attachments.filter( - (file: AttachmentFile) => file.attachmentType === "GENERAL" - ) + // 신용평가 관련 파일들만 따로 분리 const creditFiles = vendorData.attachments.filter( (file: AttachmentFile) => file.attachmentType === "CREDIT_RATING" ) const cashFlowFiles = vendorData.attachments.filter( (file: AttachmentFile) => file.attachmentType === "CASH_FLOW_RATING" ) + + // 서명/직인 파일들 분리 + const signatureFiles = vendorData.attachments.filter( + (file: AttachmentFile) => + file.attachmentType === "SIGNATURE" || + file.attachmentType === "SEAL" + ) + + // 나머지 모든 파일들 (사업자등록증, 신용평가보고서, 통장사본, ISO인증서, 일반문서 등) + const otherFiles = vendorData.attachments.filter( + (file: AttachmentFile) => + file.attachmentType !== "CREDIT_RATING" && + file.attachmentType !== "CASH_FLOW_RATING" && + file.attachmentType !== "SIGNATURE" && + file.attachmentType !== "SEAL" + ) - setExistingFiles(generalFiles) + setExistingFiles(otherFiles) // 모든 기타 파일들을 일반 첨부파일 섹션에 표시 setExistingCreditFiles(creditFiles) setExistingCashFlowFiles(cashFlowFiles) + setExistingSignatureFiles(signatureFiles) // 서명/직인 파일들 } // 폼 기본값 설정 (연락처 포함) @@ -272,6 +315,25 @@ export function InfoForm() { replaceContacts(formattedContacts) } + + // 정규등록 상태 데이터 로드 (없는 경우 에러가 아님) + try { + const registrationResult = await fetchVendorRegistrationStatus(Number(companyId)) + if (registrationResult.success) { + setRegistrationData(registrationResult.data) + } else if (registrationResult.noRegistration) { + // 정규등록 데이터가 없는 경우는 정상적인 상황 (기존 정규업체 등) + console.log("정규등록 데이터 없음 - 기존 정규업체이거나 아직 등록 진행하지 않음") + setRegistrationData(null) + } else { + // 실제 에러인 경우 + console.error("정규등록 상태 조회 오류:", registrationResult.error) + setRegistrationData(null) + } + } catch (error) { + console.error("정규등록 상태 조회 중 예외 발생:", error) + setRegistrationData(null) + } } catch (error) { console.error("Error fetching vendor data:", error) toast({ @@ -465,6 +527,7 @@ export function InfoForm() { setExistingFiles(existingFiles.filter(file => file.id !== fileId)) setExistingCreditFiles(existingCreditFiles.filter(file => file.id !== fileId)) setExistingCashFlowFiles(existingCashFlowFiles.filter(file => file.id !== fileId)) + setExistingSignatureFiles(existingSignatureFiles.filter(file => file.id !== fileId)) toast({ title: "파일 삭제 표시됨", @@ -472,6 +535,82 @@ export function InfoForm() { }) } + + + // 서명/직인 업로드 핸들러들 (한 개만 허용) + const handleSignatureDropAccepted = (acceptedFiles: File[]) => { + // 첫 번째 파일만 사용 (한 개만 허용) + const newFile = acceptedFiles[0] + if (newFile) { + // 기존 서명/직인 파일이 있으면 삭제 목록에 추가 + if (existingSignatureFiles.length > 0) { + const existingFileId = existingSignatureFiles[0].id + setFilesToDelete([...filesToDelete, existingFileId]) + setExistingSignatureFiles([]) // UI에서 제거 + + toast({ + title: "서명/직인 교체", + description: "기존 서명/직인이 새 파일로 교체됩니다.", + }) + } + + setSignatureFiles([newFile]) // 새 파일 설정 + setHasSignature(true) + + if (acceptedFiles.length > 1) { + toast({ + title: "파일 제한", + description: "서명/직인은 한 개만 등록할 수 있습니다. 첫 번째 파일만 선택되었습니다.", + }) + } + } + } + + const handleSignatureDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "파일 오류", + description: `${rej.file.name}: ${rej.errors[0]?.message || "업로드 실패"}`, + }) + }) + } + + const removeSignatureFile = () => { + setSignatureFiles([]) + setHasSignature(false) + } + + // 파일 타입 라벨 가져오기 + const getAttachmentTypeLabel = (type: string) => { + const attachmentType = ATTACHMENT_TYPES.find(t => t.value === type) + return attachmentType?.label || type + } + + const handleAdditionalInfoSave = async () => { + // 데이터 새로고침 + try { + const registrationResult = await fetchVendorRegistrationStatus(Number(companyId)) + if (registrationResult.success) { + setRegistrationData(registrationResult.data) + toast({ + title: "데이터 새로고침", + description: "등록 현황 데이터가 새로고침되었습니다.", + }) + } else if (registrationResult.noRegistration) { + // 정규등록 데이터가 없는 경우는 정상적인 상황 + setRegistrationData(null) + } else { + // 실제 에러인 경우 + console.error("정규등록 상태 새로고침 오류:", registrationResult.error) + setRegistrationData(null) + } + } catch (error) { + console.error("정규등록 상태 새로고침 중 예외 발생:", error) + setRegistrationData(null) + } + } + // Submit async function onSubmit(values: UpdateVendorInfoSchema) { if (!companyId) { @@ -519,8 +658,10 @@ export function InfoForm() { files: mainFiles, creditRatingFiles, cashFlowRatingFiles, + signatureFiles, // 서명/직인 파일들 contacts: values.contacts, filesToDelete, // 삭제할 파일 ID 목록 + selectedAttachmentType, // 선택된 첨부파일 타입 }) if (!result.error) { @@ -606,6 +747,172 @@ export function InfoForm() { <Separator /> + {/* 정규업체 등록 현황 섹션 */} + {registrationData ? ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 정규업체 등록 현황 + </CardTitle> + <CardDescription> + 정규업체 등록을 위한 현황을 확인하고 관리하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 현재 상태 표시 */} + {registrationData.registration && ( + <div className="flex items-center gap-2 p-3 bg-muted/30 rounded-lg"> + <Badge variant="secondary"> + {registrationData.registration.status === 'under_review' && '검토중'} + {registrationData.registration.status === 'approval_ready' && '조건충족'} + {registrationData.registration.status === 'in_review' && '정규등록검토'} + {registrationData.registration.status === 'completed' && '등록완료'} + {registrationData.registration.status === 'pending_approval' && '장기미등록'} + </Badge> + </div> + )} + + {/* 서명/직인 등록 */} + <div className="space-y-2"> + <h4 className="font-medium text-sm">회사 서명/직인 등록</h4> + + {/* 현재 등록된 서명/직인 파일 표시 (한 개만) */} + {(existingSignatureFiles.length > 0 || signatureFiles.length > 0) && ( + <div className="space-y-2"> + <div className="flex items-center gap-2 p-2 border rounded-lg bg-green-50"> + <CheckCircle className="w-4 h-4 text-green-600" /> + <span className="text-sm text-green-800">서명/직인 등록됨</span> + </div> + + {/* 기존 등록된 서명/직인 (첫 번째만 표시) */} + {existingSignatureFiles.length > 0 && signatureFiles.length === 0 && ( + <div className="p-2 border rounded-lg"> + {(() => { + const file = existingSignatureFiles[0]; + const fileInfo = getFileInfo(file.fileName); + return ( + <div className="flex items-center gap-2"> + <FileListIcon /> + <div className="flex-1"> + <div className="text-xs font-medium">{fileInfo.icon} {file.fileName}</div> + <div className="text-xs text-muted-foreground"> + {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + </div> + </div> + <div className="flex items-center space-x-1"> + <FileListAction + onClick={() => handleDownloadFile(file)} + disabled={isDownloading} + > + {isDownloading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />} + </FileListAction> + <FileListAction onClick={() => handleDeleteExistingFile(file.id)}> + <X className="h-3 w-3" /> + </FileListAction> + </div> + </div> + ); + })()} + </div> + )} + + {/* 새로 업로드된 서명/직인 */} + {signatureFiles.length > 0 && ( + <div className="p-2 border rounded-lg bg-blue-50"> + {(() => { + const file = signatureFiles[0]; + return ( + <div className="flex items-center gap-2"> + <FileListIcon /> + <div className="flex-1"> + <div className="text-xs font-medium">{file.name}</div> + <div className="text-xs text-muted-foreground"> + 서명/직인 (새 파일) | {prettyBytes(file.size)} + </div> + </div> + <FileListAction onClick={removeSignatureFile}> + <X className="h-3 w-3" /> + </FileListAction> + </div> + ); + })()} + </div> + )} + </div> + )} + + {/* 서명/직인 업로드 드롭존 */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleSignatureDropAccepted} + onDropRejected={handleSignatureDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center min-h-[50px]"> + <DropzoneInput /> + <div className="flex items-center gap-2"> + <Upload className="w-4 h-4" /> + <div className="text-sm"> + <DropzoneTitle> + {existingSignatureFiles.length > 0 || signatureFiles.length > 0 + ? "서명/직인 교체" + : "서명/직인 업로드" + } + </DropzoneTitle> + <DropzoneDescription> + 한 개 파일만 업로드 가능 {maxSize ? ` | 최대: ${prettyBytes(maxSize)}` : ""} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + </div> + + {/* 액션 버튼들 */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> + <Button + onClick={() => setDocumentDialogOpen(true)} + variant="outline" + size="sm" + > + <Eye className="w-4 h-4 mr-2" /> + 문서 현황 확인 + </Button> + <Button + onClick={() => setAdditionalInfoDialogOpen(true)} + variant={registrationData.additionalInfo ? "outline" : "default"} + size="sm" + > + <FileText className="w-4 h-4 mr-2" /> + {registrationData.additionalInfo ? "추가정보 수정" : "추가정보 등록"} + </Button> + </div> + </CardContent> + </Card> + ) : ( + // 정규업체 등록 데이터가 없는 경우 (기존 정규업체이거나 아직 등록 진행 안함) + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 정규업체 등록 현황 + </CardTitle> + <CardDescription> + 현재 정규업체 등록 진행 상황이 없습니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="text-center py-4 text-muted-foreground"> + <p>이미 정규업체로 등록되어 있거나, 아직 정규업체 등록을 진행하지 않았습니다.</p> + <p className="text-sm mt-1">정규업체 등록이 필요한 경우 담당자에게 문의하세요.</p> + </div> + </CardContent> + </Card> + )} + {/* 첨부파일 요약 카드 - 기존 파일 있는 경우만 표시 */} {(existingFiles.length > 0 || existingCreditFiles.length > 0 || existingCashFlowFiles.length > 0) && ( <Card> @@ -619,8 +926,8 @@ export function InfoForm() { <div className="grid gap-4"> {existingFiles.length > 0 && ( <div> - <h4 className="font-medium mb-2">일반 첨부파일</h4> - <ScrollArea className="h-32"> + <h4 className="font-medium mb-2">첨부파일</h4> + <ScrollArea className="h-48"> <FileList className="gap-2"> {existingFiles.map((file) => { const fileInfo = getFileInfo(file.fileName); @@ -633,7 +940,7 @@ export function InfoForm() { {fileInfo.icon} {file.fileName} </FileListName> <FileListDescription> - {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} </FileListDescription> </FileListInfo> <div className="flex items-center space-x-2"> @@ -659,7 +966,7 @@ export function InfoForm() { {existingCreditFiles.length > 0 && ( <div> <h4 className="font-medium mb-2">신용평가 첨부파일</h4> - <ScrollArea className="h-32"> + <ScrollArea className="h-24"> <FileList className="gap-2"> {existingCreditFiles.map((file) => { const fileInfo = getFileInfo(file.fileName); @@ -672,7 +979,7 @@ export function InfoForm() { {fileInfo.icon} {file.fileName} </FileListName> <FileListDescription> - {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} </FileListDescription> </FileListInfo> <div className="flex items-center space-x-2"> @@ -698,7 +1005,7 @@ export function InfoForm() { {existingCashFlowFiles.length > 0 && ( <div> <h4 className="font-medium mb-2">현금흐름 첨부파일</h4> - <ScrollArea className="h-32"> + <ScrollArea className="h-24"> <FileList className="gap-2"> {existingCashFlowFiles.map((file) => { const fileInfo = getFileInfo(file.fileName); @@ -711,7 +1018,7 @@ export function InfoForm() { {fileInfo.icon} {file.fileName} </FileListName> <FileListDescription> - {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} + {getAttachmentTypeLabel(file.attachmentType)} | {file.fileSize ? formatFileSize(file.fileSize) : '크기 정보 없음'} </FileListDescription> </FileListInfo> <div className="flex items-center space-x-2"> @@ -1383,13 +1690,33 @@ export function InfoForm() { ───────────────────────────────────────── */} <div className="rounded-md border p-4 space-y-4"> <h4 className="text-md font-semibold">기타 첨부파일 추가</h4> + + {/* 첨부파일 타입 선택 */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label className="text-sm font-medium mb-2 block">파일 타입</label> + <Select value={selectedAttachmentType} onValueChange={(value: AttachmentType) => setSelectedAttachmentType(value)}> + <SelectTrigger> + <SelectValue placeholder="파일 타입 선택" /> + </SelectTrigger> + <SelectContent> + {ATTACHMENT_TYPES.map((type) => ( + <SelectItem key={type.value} value={type.value}> + {type.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + <FormField control={form.control} name="attachedFiles" render={() => ( <FormItem> <FormLabel> - 첨부 파일 (추가) + 첨부 파일 (추가) - {getAttachmentTypeLabel(selectedAttachmentType)} </FormLabel> <Dropzone maxSize={MAX_FILE_SIZE} @@ -1427,7 +1754,7 @@ export function InfoForm() { <FileListInfo> <FileListName>{file.name}</FileListName> <FileListDescription> - {prettyBytes(file.size)} + {getAttachmentTypeLabel(selectedAttachmentType)} | {prettyBytes(file.size)} </FileListDescription> </FileListInfo> <FileListAction onClick={() => removeFile(i)}> @@ -1459,6 +1786,61 @@ export function InfoForm() { </div> </form> </Form> + + {/* 정규등록 관련 다이얼로그들 - 정규등록 데이터가 있을 때만 표시 */} + {registrationData && ( + <> + {/* 문서 현황 Dialog */} + <DocumentStatusDialog + open={documentDialogOpen} + onOpenChange={setDocumentDialogOpen} + registration={{ + id: registrationData.registration?.id || 0, + vendorId: registrationData.vendor.id, + companyName: registrationData.vendor.vendorName, + businessNumber: registrationData.vendor.taxId, + representative: registrationData.vendor.representativeName || "", + country: registrationData.vendor.country || "KR", + potentialCode: registrationData.registration?.potentialCode || "", + status: registrationData.registration?.status || "under_review", + majorItems: "[]", + establishmentDate: registrationData.vendor.createdAt || new Date(), + registrationRequestDate: registrationData.registration?.registrationRequestDate, + assignedDepartment: registrationData.registration?.assignedDepartment, + assignedUser: registrationData.registration?.assignedUser, + remarks: registrationData.registration?.remarks, + safetyQualificationContent: registrationData.registration?.safetyQualificationContent || null, + gtcSkipped: registrationData.registration?.gtcSkipped || false, + additionalInfo: registrationData.additionalInfo, + documentSubmissions: registrationData.documentStatus, + contractAgreements: { + cp: "not_submitted", + gtc: "not_submitted", + standardSubcontract: "not_submitted", + safetyHealth: "not_submitted", + ethics: "not_submitted", + domesticCredit: "not_submitted", + }, + basicContracts: registrationData.basicContracts || [], + documentFiles: { + businessRegistration: [], + creditEvaluation: [], + bankCopy: [], + auditResult: [], + }, + } as VendorRegularRegistration} + onRefresh={handleAdditionalInfoSave} + /> + + {/* 추가정보 입력 Dialog */} + <AdditionalInfoDialog + open={additionalInfoDialogOpen} + onOpenChange={setAdditionalInfoDialogOpen} + vendorId={Number(companyId)} + onSave={handleAdditionalInfoSave} + /> + </> + )} </div> </section> </div> diff --git a/components/vendor-regular-registrations/additional-info-dialog.tsx b/components/vendor-regular-registrations/additional-info-dialog.tsx index fbd60515..84475877 100644 --- a/components/vendor-regular-registrations/additional-info-dialog.tsx +++ b/components/vendor-regular-registrations/additional-info-dialog.tsx @@ -16,13 +16,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+
import {
Form,
FormControl,
@@ -54,12 +48,12 @@ const businessContactSchema = z.object({ // 추가정보 스키마
const additionalInfoSchema = z.object({
- businessType: z.string().optional(),
- industryType: z.string().optional(),
- companySize: z.string().optional(),
- revenue: z.string().optional(),
- factoryEstablishedDate: z.string().optional(),
- preferredContractTerms: z.string().optional(),
+ businessType: z.string().min(1, "사업유형은 필수입니다"),
+ industryType: z.string().min(1, "산업유형은 필수입니다"),
+ companySize: z.string().min(1, "기업규모는 필수입니다"),
+ revenue: z.string().min(1, "매출액은 필수입니다"),
+ factoryEstablishedDate: z.string().min(1, "공장설립일은 필수입니다"),
+ preferredContractTerms: z.string().min(1, "선호계약조건은 필수입니다"),
});
// 전체 폼 스키마
@@ -85,28 +79,7 @@ const contactTypes = [ { value: "tax_invoice", label: "세금계산서", required: true },
];
-const businessTypes = [
- { value: "manufacturing", label: "제조업" },
- { value: "trading", label: "무역업" },
- { value: "service", label: "서비스업" },
- { value: "construction", label: "건설업" },
- { value: "other", label: "기타" },
-];
-const industryTypes = [
- { value: "shipbuilding", label: "조선업" },
- { value: "marine", label: "해양플랜트" },
- { value: "energy", label: "에너지" },
- { value: "automotive", label: "자동차" },
- { value: "other", label: "기타" },
-];
-
-const companySizes = [
- { value: "large", label: "대기업" },
- { value: "medium", label: "중견기업" },
- { value: "small", label: "중소기업" },
- { value: "startup", label: "스타트업" },
-];
export function AdditionalInfoDialog({
open,
@@ -169,16 +142,17 @@ export function AdditionalInfoDialog({ };
});
- // 추가정보 데이터 설정
+ // 추가정보 데이터 설정
+ const additionalInfoData = additionalInfo as any;
const additionalData = {
- businessType: additionalInfo?.businessType || "",
- industryType: additionalInfo?.industryType || "",
- companySize: additionalInfo?.companySize || "",
- revenue: additionalInfo?.revenue || "",
- factoryEstablishedDate: additionalInfo?.factoryEstablishedDate
- ? new Date(additionalInfo.factoryEstablishedDate).toISOString().split('T')[0]
+ businessType: additionalInfoData?.businessType || "",
+ industryType: additionalInfoData?.industryType || "",
+ companySize: additionalInfoData?.companySize || "",
+ revenue: additionalInfoData?.revenue || "",
+ factoryEstablishedDate: additionalInfoData?.factoryEstablishedDate
+ ? new Date(additionalInfoData.factoryEstablishedDate).toISOString().split('T')[0]
: "",
- preferredContractTerms: additionalInfo?.preferredContractTerms || "",
+ preferredContractTerms: additionalInfoData?.preferredContractTerms || "",
};
// 폼 데이터 업데이트
@@ -257,7 +231,6 @@ export function AdditionalInfoDialog({ <CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
{contactType.label} 담당자
- <Badge variant="destructive" className="text-xs">필수</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -352,21 +325,10 @@ export function AdditionalInfoDialog({ name="additionalInfo.businessType"
render={({ field }) => (
<FormItem>
- <FormLabel>사업유형</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="사업유형 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {businessTypes.map((type) => (
- <SelectItem key={type.value} value={type.value}>
- {type.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <FormLabel>사업유형 *</FormLabel>
+ <FormControl>
+ <Input placeholder="사업유형 입력" {...field} />
+ </FormControl>
<FormMessage />
</FormItem>
)}
@@ -376,21 +338,10 @@ export function AdditionalInfoDialog({ name="additionalInfo.industryType"
render={({ field }) => (
<FormItem>
- <FormLabel>산업유형</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="산업유형 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {industryTypes.map((type) => (
- <SelectItem key={type.value} value={type.value}>
- {type.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <FormLabel>산업유형 *</FormLabel>
+ <FormControl>
+ <Input placeholder="산업유형 입력" {...field} />
+ </FormControl>
<FormMessage />
</FormItem>
)}
@@ -402,21 +353,10 @@ export function AdditionalInfoDialog({ name="additionalInfo.companySize"
render={({ field }) => (
<FormItem>
- <FormLabel>기업규모</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="기업규모 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {companySizes.map((size) => (
- <SelectItem key={size.value} value={size.value}>
- {size.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <FormLabel>기업규모 *</FormLabel>
+ <FormControl>
+ <Input placeholder="기업규모 입력" {...field} />
+ </FormControl>
<FormMessage />
</FormItem>
)}
@@ -426,7 +366,7 @@ export function AdditionalInfoDialog({ name="additionalInfo.revenue"
render={({ field }) => (
<FormItem>
- <FormLabel>매출액 (억원)</FormLabel>
+ <FormLabel>매출액 (억원) *</FormLabel>
<FormControl>
<Input
placeholder="매출액 입력"
@@ -445,7 +385,7 @@ export function AdditionalInfoDialog({ name="additionalInfo.factoryEstablishedDate"
render={({ field }) => (
<FormItem>
- <FormLabel>공장설립일</FormLabel>
+ <FormLabel>공장설립일 *</FormLabel>
<FormControl>
<Input
placeholder="YYYY-MM-DD"
@@ -463,7 +403,7 @@ export function AdditionalInfoDialog({ name="additionalInfo.preferredContractTerms"
render={({ field }) => (
<FormItem>
- <FormLabel>선호계약조건</FormLabel>
+ <FormLabel>선호계약조건 *</FormLabel>
<FormControl>
<Textarea
placeholder="선호하는 계약조건을 상세히 입력해주세요"
diff --git a/lib/risk-management/table/risks-mail-dialog.tsx b/lib/risk-management/table/risks-mail-dialog.tsx index 8bee1191..02c470ce 100644 --- a/lib/risk-management/table/risks-mail-dialog.tsx +++ b/lib/risk-management/table/risks-mail-dialog.tsx @@ -176,7 +176,7 @@ function RisksMailDialog(props: RisksMailDialogProps) { setManagerList(managerList); } catch (error) { console.error('Error in Loading Risk Event for Managing:', error); - toast.error(error instanceof Error ? error.message : '구매 담당자 목록을 불러오는 데 실패했어요.'); + toast.error(error instanceof Error ? error.message : '구매 담당자 목록을 불러오는 데 실패했습니다.'); } finally { setIsLoadingManagerList(false); } @@ -210,7 +210,7 @@ function RisksMailDialog(props: RisksMailDialogProps) { const file = files[0]; const maxFileSize = 10 * 1024 * 1024 if (file.size > maxFileSize) { - toast.error('파일 크기는 10MB를 초과할 수 없어요.'); + toast.error('파일 크기는 10MB를 초과할 수 없습니다.'); return; } form.setValue('attachment', file); @@ -272,15 +272,15 @@ function RisksMailDialog(props: RisksMailDialogProps) { if (!res.ok) { const errorData = await res.json(); - throw new Error(errorData.message || '리스크 알림 메일 전송에 실패했어요.'); + throw new Error(errorData.message || '리스크 알림 메일 전송에 실패했습니다.'); } - toast.success('리스크 알림 메일이 구매 담당자에게 발송되었어요.'); + toast.success('리스크 알림 메일이 구매 담당자에게 발송되었습니다.'); onSuccess(); } catch (error) { console.error('Error in Saving Risk Event:', error); toast.error( - error instanceof Error ? error.message : '리스크 알림 메일 발송 중 오류가 발생했어요.', + error instanceof Error ? error.message : '리스크 알림 메일 발송 중 오류가 발생했습니다.', ); } }) diff --git a/lib/vendor-registration-status/vendor-registration-status-view.tsx b/lib/vendor-registration-status/vendor-registration-status-view.tsx deleted file mode 100644 index 850dd777..00000000 --- a/lib/vendor-registration-status/vendor-registration-status-view.tsx +++ /dev/null @@ -1,300 +0,0 @@ -"use client"
-
-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 {
- CheckCircle,
- XCircle,
- FileText,
- AlertCircle,
- Eye,
- Upload
-} from "lucide-react"
-import { DocumentStatusDialog } from "@/components/vendor-regular-registrations/document-status-dialog"
-import { AdditionalInfoDialog } from "@/components/vendor-regular-registrations/additional-info-dialog"
-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: {
- label: "실사통과",
- color: "bg-blue-100 text-blue-800",
- description: "품질담당자(QM) 최종 의견에 따라 실사 통과로 결정된 상태"
- },
- cp_submitted: {
- label: "CP등록",
- color: "bg-green-100 text-green-800",
- description: "협력업체에서 실사 통과 후 기본계약문서에 대한 답변 제출/서약 완료한 상태"
- },
- cp_review: {
- label: "CP검토",
- color: "bg-yellow-100 text-yellow-800",
- description: "협력업체에서 제출한 CP/GTC에 대한 법무검토 의뢰한 상태"
- },
- cp_finished: {
- label: "CP완료",
- color: "bg-purple-100 text-purple-800",
- description: "CP 답변에 대한 법무검토 완료되어 정규업체 등록 가능한 상태"
- },
- approval_ready: {
- label: "조건충족",
- color: "bg-emerald-100 text-emerald-800",
- description: "정규업체 등록 문서/자료 접수현황에 누락이 없는 상태"
- },
- in_review: {
- label: "정규등록검토",
- color: "bg-orange-100 text-orange-800",
- description: "구매담당자 요청에 따라 정규업체 등록 관리자가 정규업체 등록 가능여부 검토"
- },
- pending_approval: {
- label: "장기미등록",
- color: "bg-red-100 text-red-800",
- description: "정규업체로 등록 요청되어 3개월 이내 정규업체 등록되지 않은 상태"
- }
-}
-
-// 필수문서 목록
-const requiredDocuments = [
- { key: "businessRegistration", label: "사업자등록증" },
- { key: "creditEvaluation", label: "신용평가서" },
- { key: "bankCopy", label: "통장사본" },
- { key: "cpDocument", label: "CP문서" },
- { key: "gtc", label: "GTC" },
- { key: "standardSubcontract", label: "표준하도급" },
- { key: "safetyHealth", label: "안전보건관리" },
- { key: "ethics", label: "윤리규범준수" },
- { key: "domesticCredit", label: "내국신용장" },
- { key: "safetyQualification", label: "안전적격성평가" },
-]
-
-export function VendorRegistrationStatusView() {
- const [additionalInfoDialogOpen, setAdditionalInfoDialogOpen] = useState(false)
- const [documentDialogOpen, setDocumentDialogOpen] = useState(false)
- const [hasSignature, setHasSignature] = useState(false)
- const [data, setData] = useState<any>(null)
- const [loading, setLoading] = useState(true)
-
- // 세션에서 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)
- if (result.success) {
- setData(result.data)
- } else {
- toast.error(result.error)
- }
- } catch {
- toast.error("데이터 로드 중 오류가 발생했습니다.")
- } finally {
- setLoading(false)
- }
- }
-
- initialLoad()
- }, [vendorId])
-
- 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>
- }
-
- const currentStatusConfig = statusConfig[data.registration?.status as keyof typeof statusConfig] || statusConfig.audit_pass
-
- // 미완성 항목 계산
- const missingDocuments = requiredDocuments.filter(
- doc => !data.documentStatus[doc.key as keyof typeof data.documentStatus]
- )
-
- // Document Status Dialog에 전달할 registration 데이터 구성
- const registrationForDialog: any = {
- id: data.registration?.id || 0,
- vendorId: data.vendor.id,
- 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 문자열
- establishmentDate: data.vendor.createdAt || new Date(),
- registrationRequestDate: data.registration?.registrationRequestDate,
- assignedDepartment: data.registration?.assignedDepartment,
- assignedDepartmentCode: data.registration?.assignedDepartmentCode,
- 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,
- gtc: data.documentStatus.gtc,
- standardSubcontract: data.documentStatus.standardSubcontract,
- safetyHealth: data.documentStatus.safetyHealth,
- ethics: data.documentStatus.ethics,
- domesticCredit: data.documentStatus.domesticCredit,
- },
- createdAt: data.registration?.createdAt || new Date(),
- updatedAt: data.registration?.updatedAt || new Date(),
- }
-
- const handleSignatureUpload = () => {
- // TODO: 서명/직인 업로드 기능 구현
- setHasSignature(true)
- toast.success("서명/직인이 등록되었습니다.")
- }
-
- const handleAdditionalInfoSave = () => {
- // 데이터 새로고침
- loadData()
- }
-
- 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)
- }
- } catch {
- toast.error("데이터 로드 중 오류가 발생했습니다.")
- }
- }
-
- return (
- <div className="space-y-6">
- {/* 헤더 섹션 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <div>
- <h1 className="text-3xl font-bold">정규업체 등록관리 현황</h1>
- <p className="text-muted-foreground">
- {data.registration?.potentialCode || "미등록"} | {data.vendor.companyName}
- </p>
- <p className="text-sm text-muted-foreground mt-1">
- 정규업체 등록 진행현황을 확인하세요.
- </p>
- </div>
- <Badge className={currentStatusConfig.color} variant="secondary">
- {currentStatusConfig.label}
- </Badge>
- </div>
- </div>
-
- {/* 회사 서명/직인 등록 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 회사 서명/직인 등록
- <Badge variant="destructive" className="text-xs">필수</Badge>
- </CardTitle>
- </CardHeader>
- <CardContent>
- {hasSignature ? (
- <div className="flex items-center gap-3 p-4 border rounded-lg bg-green-50">
- <CheckCircle className="w-5 h-5 text-green-600" />
- <span className="text-green-800">서명/직인이 등록되었습니다.</span>
- </div>
- ) : (
- <Button
- onClick={handleSignatureUpload}
- className="w-full h-20 border-2 border-dashed border-muted-foreground/25 bg-muted/25"
- variant="outline"
- >
- <div className="text-center">
- <Upload className="w-6 h-6 mx-auto mb-2" />
- <span>서명/직인 등록하기</span>
- </div>
- </Button>
- )}
- </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>
-
- {/* 상태 설명 */}
- <Card>
- <CardHeader>
- <CardTitle>현재 상태 안내</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="flex items-start gap-3">
- <Badge className={currentStatusConfig.color} variant="secondary">
- {currentStatusConfig.label}
- </Badge>
- <p className="text-sm text-muted-foreground">
- {currentStatusConfig.description}
- </p>
- </div>
- </CardContent>
- </Card>
-
- {/* 문서 현황 Dialog */}
- <DocumentStatusDialog
- open={documentDialogOpen}
- onOpenChange={setDocumentDialogOpen}
- registration={registrationForDialog}
- onRefresh={loadData}
- />
-
- {/* 추가정보 입력 Dialog */}
- <AdditionalInfoDialog
- open={additionalInfoDialogOpen}
- onOpenChange={setAdditionalInfoDialogOpen}
- vendorId={vendorId}
- onSave={handleAdditionalInfoSave}
- />
- </div>
- )
-}
diff --git a/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx b/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx deleted file mode 100644 index a93fbf22..00000000 --- a/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"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 51f4e82b..d64c7b8b 100644 --- a/lib/vendor-regular-registrations/service.ts +++ b/lib/vendor-regular-registrations/service.ts @@ -25,7 +25,46 @@ import { basicContractTemplates
} from "@/db/schema";
import db from "@/db/db";
-import { inArray, eq, desc } from "drizzle-orm";
+import { inArray, eq, desc, and, lt } from "drizzle-orm";
+
+// 3개월 이상 정규등록검토 상태인 등록을 장기미등록으로 변경
+async function updatePendingApprovals() {
+ try {
+ const threeMonthsAgo = new Date();
+ threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
+
+ // 3개월 이상 정규등록검토 상태인 등록들을 조회
+ const outdatedRegistrations = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(
+ and(
+ eq(vendorRegularRegistrations.status, "in_review"),
+ lt(vendorRegularRegistrations.updatedAt, threeMonthsAgo)
+ )
+ );
+
+ // 장기미등록으로 상태 변경
+ if (outdatedRegistrations.length > 0) {
+ await db
+ .update(vendorRegularRegistrations)
+ .set({
+ status: "pending_approval",
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(vendorRegularRegistrations.status, "in_review"),
+ lt(vendorRegularRegistrations.updatedAt, threeMonthsAgo)
+ )
+ );
+
+ console.log(`${outdatedRegistrations.length}개의 등록이 장기미등록으로 변경되었습니다.`);
+ }
+ } catch (error) {
+ console.error("장기미등록 상태 업데이트 오류:", error);
+ }
+}
// 캐싱과 에러 핸들링이 포함된 조회 함수
export async function fetchVendorRegularRegistrations(input?: {
@@ -37,6 +76,9 @@ export async function fetchVendorRegularRegistrations(input?: { return unstable_cache(
async () => {
try {
+ // 장기미등록 상태 업데이트 실행
+ await updatePendingApprovals();
+
const registrations = await getVendorRegularRegistrations();
let filteredData = registrations;
@@ -113,6 +155,7 @@ export async function createVendorRegistration(data: { const registration = await createVendorRegularRegistration({
...data,
+ status: data.status || "under_review", // 기본 상태를 '검토중'으로 설정
majorItems: majorItemsJson,
});
@@ -438,7 +481,7 @@ export async function skipLegalReview(vendorIds: number[], skipReason: string) { // 새로 생성
const newRegistration = await createVendorRegularRegistration({
vendorId: vendorId,
- status: "cp_finished", // CP완료로 변경
+ status: "under_review", // 검토중으로 변경
remarks: `GTC Skip: ${skipReason}`,
});
registrationId = newRegistration.id;
@@ -451,7 +494,6 @@ export async function skipLegalReview(vendorIds: number[], skipReason: string) { : `GTC Skip: ${skipReason}`;
await updateVendorRegularRegistration(registrationId, {
- status: "cp_finished", // CP완료로 변경
gtcSkipped: true, // GTC Skip 여부 설정
remarks: newRemarks,
});
@@ -630,7 +672,7 @@ export async function fetchVendorRegistrationStatus(vendorId: number) { }
}
- // 정규업체 등록 정보
+ // 정규업체 등록 정보 (없을 수도 있음 - 기존 정규업체이거나 아직 등록 진행 안함)
const registration = await db
.select({
id: vendorRegularRegistrations.id,
@@ -653,6 +695,15 @@ export async function fetchVendorRegistrationStatus(vendorId: number) { .where(eq(vendorRegularRegistrations.vendorId, vendorId))
.limit(1)
+ // 정규업체 등록 정보가 없는 경우 (정상적인 상황)
+ if (!registration[0]) {
+ return {
+ success: false,
+ error: "정규업체 등록 진행 정보가 없습니다.", // 에러가 아닌 정보성 메시지
+ noRegistration: true // 등록 정보가 없음을 명시적으로 표시
+ }
+ }
+
// 벤더 첨부파일 조회
const vendorFiles = await db
.select()
@@ -783,7 +834,8 @@ export async function fetchVendorRegistrationStatus(vendorId: number) { missingDocuments,
businessContacts,
missingContactTypes,
- additionalInfo: additionalInfoCompleted, // boolean 값으로 변경
+ additionalInfo: additionalInfo[0] || null, // 실제 추가정보 데이터 반환
+ additionalInfoCompleted, // 완료 여부는 별도 필드로 추가
pqSubmission: pqSubmission[0] || null,
auditPassed: investigationFiles.length > 0,
basicContracts: vendorContracts, // 기본계약 정보 추가
diff --git a/lib/vendor-regular-registrations/major-items-update-sheet.tsx b/lib/vendor-regular-registrations/table/major-items-update-dialog.tsx index ba125bbe..26741a1b 100644 --- a/lib/vendor-regular-registrations/major-items-update-sheet.tsx +++ b/lib/vendor-regular-registrations/table/major-items-update-dialog.tsx @@ -6,18 +6,18 @@ import { toast } from "sonner" import { X, Plus, Search } from "lucide-react" import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" 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" +import { updateMajorItems } from "../service" // PQ 대상 품목 타입 정의 interface PQItem { @@ -25,7 +25,7 @@ interface PQItem { itemName: string } -interface MajorItemsUpdateSheetProps { +interface MajorItemsUpdateDialogProps { open: boolean onOpenChange: (open: boolean) => void registrationId?: number @@ -34,14 +34,14 @@ interface MajorItemsUpdateSheetProps { onSuccess?: () => void } -export function MajorItemsUpdateSheet({ +export function MajorItemsUpdateDialog({ open, onOpenChange, registrationId, vendorName, currentItems, onSuccess, -}: MajorItemsUpdateSheetProps) { +}: MajorItemsUpdateDialogProps) { const [isLoading, setIsLoading] = useState(false) const [selectedItems, setSelectedItems] = useState<PQItem[]>([]) @@ -144,14 +144,14 @@ export function MajorItemsUpdateSheet({ } return ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="w-[400px] sm:w-[540px]"> - <SheetHeader> - <SheetTitle>주요품목 등록</SheetTitle> - <SheetDescription> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="w-[400px] sm:w-[540px] max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>주요품목 등록</DialogTitle> + <DialogDescription> {vendorName && `${vendorName}의 `}주요품목을 등록해주세요. - </SheetDescription> - </SheetHeader> + </DialogDescription> + </DialogHeader> <div className="space-y-6 mt-6"> {/* 선택된 아이템들 표시 */} @@ -239,7 +239,7 @@ export function MajorItemsUpdateSheet({ {isLoading ? "저장 중..." : "저장"} </Button> </div> - </SheetContent> - </Sheet> + </DialogContent> + </Dialog> ) } diff --git a/lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx b/lib/vendor-regular-registrations/table/safety-qualification-update-dialog.tsx index c2aeba70..80084732 100644 --- a/lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx +++ b/lib/vendor-regular-registrations/table/safety-qualification-update-dialog.tsx @@ -7,12 +7,12 @@ import { z } from "zod" import { toast } from "sonner" import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { Form, FormControl, @@ -29,7 +29,7 @@ const formSchema = z.object({ safetyQualificationContent: z.string().min(1, "안전적격성 평가 내용을 입력해주세요."), }) -interface SafetyQualificationUpdateSheetProps { +interface SafetyQualificationUpdateDialogProps { open: boolean onOpenChange: (open: boolean) => void registrationId?: number @@ -38,14 +38,14 @@ interface SafetyQualificationUpdateSheetProps { onSuccess?: () => void } -export function SafetyQualificationUpdateSheet({ +export function SafetyQualificationUpdateDialog({ open, onOpenChange, registrationId, vendorName, currentContent, onSuccess, -}: SafetyQualificationUpdateSheetProps) { +}: SafetyQualificationUpdateDialogProps) { const [isLoading, setIsLoading] = React.useState(false) const form = useForm<z.infer<typeof formSchema>>({ @@ -93,14 +93,14 @@ export function SafetyQualificationUpdateSheet({ } return ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="w-[400px] sm:w-[540px]"> - <SheetHeader> - <SheetTitle>안전적격성 평가 입력</SheetTitle> - <SheetDescription> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="w-[400px] sm:w-[540px] max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>안전적격성 평가 입력</DialogTitle> + <DialogDescription> {vendorName && `${vendorName}의 `}안전적격성 평가 내용을 입력해주세요. - </SheetDescription> - </SheetHeader> + </DialogDescription> + </DialogHeader> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 mt-6"> @@ -137,7 +137,7 @@ export function SafetyQualificationUpdateSheet({ </div> </form> </Form> - </SheetContent> - </Sheet> + </DialogContent> + </Dialog> ) } 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 765b0279..7446716b 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 @@ -13,29 +13,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge 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"
+import { SafetyQualificationUpdateDialog } from "./safety-qualification-update-dialog"
+import { MajorItemsUpdateDialog } from "./major-items-update-dialog"
const statusLabels = {
- audit_pass: "실사통과",
- cp_submitted: "CP등록",
- cp_review: "CP검토",
- cp_finished: "CP완료",
+ under_review: "검토중",
approval_ready: "조건충족",
- registration_requested: "등록요청됨",
in_review: "정규등록검토",
+ completed: "등록완료",
pending_approval: "장기미등록",
}
const statusColors = {
- audit_pass: "bg-blue-100 text-blue-800",
- cp_submitted: "bg-green-100 text-green-800",
- cp_review: "bg-yellow-100 text-yellow-800",
- cp_finished: "bg-purple-100 text-purple-800",
+ under_review: "bg-blue-100 text-blue-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",
+ completed: "bg-green-100 text-green-800",
pending_approval: "bg-red-100 text-red-800",
}
@@ -295,7 +289,7 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] { </DropdownMenuContent>
</DropdownMenu>
- <SafetyQualificationUpdateSheet
+ <SafetyQualificationUpdateDialog
open={safetyQualificationSheetOpen}
onOpenChange={setSafetyQualificationSheetOpen}
registrationId={registration.id}
@@ -306,7 +300,7 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] { window.location.reload()
}}
/>
- <MajorItemsUpdateSheet
+ <MajorItemsUpdateDialog
open={majorItemsSheetOpen}
onOpenChange={setMajorItemsSheetOpen}
registrationId={registration.id}
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 2a927069..9af81021 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -9,6 +9,8 @@ import crypto from 'crypto'; import fs from 'fs/promises'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; +import { saveDRMFile } from "@/lib/file-stroage"; +import { decryptWithServerAction } from "@/components/drm/drmUtils"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; @@ -2002,6 +2004,7 @@ export async function getVendorDetailById(id: number) { if (!vendor) { return null; } + console.log("vendor", vendor.attachments) // JSON 문자열로 반환된 contacts와 attachments를 JavaScript 객체로 파싱 const contacts = typeof vendor.contacts === 'string' @@ -2059,8 +2062,10 @@ export async function updateVendorInfo(params: { files?: File[] creditRatingFiles?: File[] cashFlowRatingFiles?: File[] + signatureFiles?: File[] // 서명/직인 파일들 contacts: ContactInfo[] filesToDelete?: number[] // 삭제할 파일 ID 목록 + selectedAttachmentType?: string // 선택된 첨부파일 타입 }) { try { const { @@ -2068,8 +2073,10 @@ export async function updateVendorInfo(params: { files = [], creditRatingFiles = [], cashFlowRatingFiles = [], + signatureFiles = [], contacts, - filesToDelete = [] + filesToDelete = [], + selectedAttachmentType = "GENERAL" } = params // 세션 및 권한 확인 @@ -2204,9 +2211,9 @@ export async function updateVendorInfo(params: { } // 4. 새 파일 저장 (제공된 storeVendorFiles 함수 활용) - // 4-1. 일반 파일 저장 + // 4-1. 일반 파일 저장 (선택된 타입 사용) if (files.length > 0) { - await storeVendorFiles(tx, vendorData.id, files, "GENERAL"); + await storeVendorFiles(tx, vendorData.id, files, selectedAttachmentType); } // 4-2. 신용평가 파일 저장 @@ -2218,6 +2225,11 @@ export async function updateVendorInfo(params: { if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, vendorData.id, cashFlowRatingFiles, "CASH_FLOW_RATING"); } + + // 4-4. 서명/직인 파일 저장 + if (signatureFiles.length > 0) { + await storeVendorFiles(tx, vendorData.id, signatureFiles, "SIGNATURE"); + } }) // 캐시 무효화 @@ -2989,3 +3001,96 @@ export async function requestBasicContractInfo({ }; } } + +/** + * 비밀유지 계약서 첨부파일 저장 서버 액션 + */ +export async function saveNdaAttachments(input: { + vendorIds: number[]; + files: File[]; + userId: string; +}) { + unstable_noStore(); + + try { + console.log("📎 비밀유지 계약서 첨부파일 저장 시작"); + console.log(`벤더 수: ${input.vendorIds.length}, 파일 수: ${input.files.length}`); + + const results = []; + + for (const vendorId of input.vendorIds) { + for (const file of input.files) { + console.log(`📄 처리 중: 벤더 ID ${vendorId} - ${file.name}`); + + try { + // saveDRMFile을 사용해서 파일 저장 + const saveResult = await saveDRMFile( + file, + decryptWithServerAction, + `vendor-attachments/nda/${vendorId}`, + input.userId + ); + + if (!saveResult.success) { + throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`); + } + + // vendor_attachments 테이블에 파일 정보 저장 + const insertedAttachment = await db.insert(vendorAttachments).values({ + vendorId: vendorId, + fileType: file.type || 'application/octet-stream', + fileName: saveResult.fileName || file.name, + filePath: saveResult.publicPath || '', + attachmentType: 'NDA_ATTACHMENT', + }).returning(); + + results.push({ + vendorId, + fileName: file.name, + attachmentId: insertedAttachment[0]?.id || 0, + success: true + }); + + console.log(`✅ 완료: 벤더 ID ${vendorId} - ${file.name}`); + + } catch (error) { + console.error(`❌ 실패: 벤더 ID ${vendorId} - ${file.name}`, error); + results.push({ + vendorId, + fileName: file.name, + success: false, + error: error instanceof Error ? error.message : '알 수 없는 오류' + }); + } + } + } + + // 성공/실패 카운트 + const successCount = results.filter(r => r.success).length; + const failureCount = results.filter(r => !r.success).length; + + console.log(`📊 처리 결과: 성공 ${successCount}개, 실패 ${failureCount}개`); + + // 캐시 무효화 + revalidateTag("vendor-attachments"); + + return { + success: true, + results, + summary: { + total: results.length, + success: successCount, + failure: failureCount + } + }; + + } catch (error) { + console.error("비밀유지 계약서 첨부파일 저장 중 오류 발생:", error); + return { + success: false, + error: error instanceof Error + ? error.message + : "첨부파일 저장 처리 중 오류가 발생했습니다." + }; + } +} diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx index 5b5f722c..9fd7b1d8 100644 --- a/lib/vendors/table/request-pq-dialog.tsx +++ b/lib/vendors/table/request-pq-dialog.tsx @@ -46,6 +46,7 @@ import { DatePicker } from "@/components/ui/date-picker" import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
import type { BasicContractTemplate } from "@/db/schema"
import { searchItemsForPQ } from "@/lib/items/service"
+import { saveNdaAttachments } from "../service"
// import { PQContractViewer } from "../pq-contract-viewer" // 더 이상 사용하지 않음
interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -54,16 +55,16 @@ interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dia onSuccess?: () => void
}
-const AGREEMENT_LIST = [
- "준법서약",
- "표준하도급계약",
- "안전보건관리계약",
- "윤리규범 준수 서약",
- "동반성장협약",
- "내국신용장 미개설 합의",
- "기술자료 제출 기본 동의",
- "GTC 합의",
-]
+// const AGREEMENT_LIST = [
+// "준법서약",
+// "표준하도급계약",
+// "안전보건관리계약",
+// "윤리규범 준수 서약",
+// "동반성장협약",
+// "내국신용장 미개설 합의",
+// "기술자료 제출 기본 동의",
+// "GTC 합의",
+// ]
// PQ 대상 품목 타입 정의
interface PQItem {
@@ -92,6 +93,10 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
+
+ // 비밀유지 계약서 첨부파일 관련 상태
+ const [ndaAttachments, setNdaAttachments] = React.useState<File[]>([])
+ const [isUploadingNdaFiles, setIsUploadingNdaFiles] = React.useState(false)
// 아이템 검색 필터링
React.useEffect(() => {
@@ -172,6 +177,8 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro setItemSearchQuery("")
setFilteredItems([])
setShowItemDropdown(false)
+ setNdaAttachments([])
+ setIsUploadingNdaFiles(false)
}
}, [props.open])
@@ -197,6 +204,30 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro setPqItems(prev => prev.filter(item => item.itemCode !== itemCode))
}
+ // 비밀유지 계약서 첨부파일 추가 함수
+ const handleAddNdaAttachment = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (files) {
+ const newFiles = Array.from(files)
+ setNdaAttachments(prev => [...prev, ...newFiles])
+ }
+ // input 초기화
+ event.target.value = ''
+ }
+
+ // 비밀유지 계약서 첨부파일 제거 함수
+ const handleRemoveNdaAttachment = (fileIndex: number) => {
+ setNdaAttachments(prev => prev.filter((_, index) => index !== fileIndex))
+ }
+
+ // 비밀유지 계약서가 선택되었는지 확인하는 함수
+ const isNdaTemplateSelected = () => {
+ return basicContractTemplates.some(template =>
+ selectedTemplateIds.includes(template.id) &&
+ template.templateName?.includes("비밀유지")
+ )
+ }
+
const onApprove = () => {
if (!type) return toast.error("PQ 유형을 선택하세요.")
if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.")
@@ -235,6 +266,23 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro console.log("📋 기본계약서 백그라운드 처리 시작", templates.length, "개 템플릿")
await processBasicContractsInBackground(templates, vendors)
}
+
+ // 3단계: 비밀유지 계약서 첨부파일이 있는 경우 저장
+ if (isNdaTemplateSelected() && ndaAttachments.length > 0) {
+ console.log("📎 비밀유지 계약서 첨부파일 처리 시작", ndaAttachments.length, "개 파일")
+
+ const ndaResult = await saveNdaAttachments({
+ vendorIds: vendors.map((v) => v.id),
+ files: ndaAttachments,
+ userId: session.user.id.toString()
+ })
+
+ if (ndaResult.success) {
+ toast.success(`비밀유지 계약서 첨부파일이 모두 저장되었습니다 (${ndaResult.summary?.success}/${ndaResult.summary?.total})`)
+ } else {
+ toast.error(`첨부파일 처리 중 일부 오류가 발생했습니다: ${ndaResult.error}`)
+ }
+ }
// 완료 후 다이얼로그 닫기
props.onOpenChange?.(false)
@@ -262,12 +310,14 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro for (let vendorIndex = 0; vendorIndex < vendors.length; vendorIndex++) {
const vendor = vendors[vendorIndex]
- // 벤더별 템플릿 데이터 생성
+ // 벤더별 템플릿 데이터 생성 (한글 변수명 사용)
const templateData = {
- vendor_name: vendor.vendorName || '협력업체명',
- address: vendor.address || '주소',
+ company_name: vendor.vendorName || '협력업체명',
+ company_address: vendor.address || '주소',
representative_name: vendor.representativeName || '대표자명',
- today_date: new Date().toLocaleDateString('ko-KR'),
+ signature_date: new Date().toLocaleDateString('ko-KR'),
+ tax_id: vendor.taxId || '사업자번호',
+ phone_number: vendor.phone || '전화번호',
}
console.log(`🔄 벤더 ${vendorIndex + 1}/${vendors.length} 템플릿 데이터:`, templateData)
@@ -559,6 +609,76 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro )}
</div>
+ {/* 비밀유지 계약서 첨부파일 */}
+ {isNdaTemplateSelected() && (
+ <div className="space-y-2">
+ <Label>비밀유지 계약서 첨부파일</Label>
+
+ {/* 선택된 파일들 표시 */}
+ {ndaAttachments.length > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일 ({ndaAttachments.length}개)
+ </div>
+ <div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
+ {ndaAttachments.map((file, index) => (
+ <div key={index} className="flex items-center justify-between text-sm bg-muted/50 rounded px-2 py-1">
+ <div className="flex-1 truncate">
+ <span className="font-medium">{file.name}</span>
+ <span className="text-muted-foreground ml-2">
+ ({(file.size / 1024 / 1024).toFixed(2)} MB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={() => handleRemoveNdaAttachment(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 파일 선택 버튼 */}
+ <div className="flex items-center gap-2">
+ <input
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xlsx,.xls,.png,.jpg,.jpeg"
+ onChange={handleAddNdaAttachment}
+ className="hidden"
+ id="nda-file-input"
+ />
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => document.getElementById('nda-file-input')?.click()}
+ disabled={isUploadingNdaFiles}
+ >
+ <Plus className="h-4 w-4" />
+ 파일 추가
+ </Button>
+ {isUploadingNdaFiles && (
+ <div className="text-sm text-muted-foreground">
+ 파일 업로드 중...
+ </div>
+ )}
+ </div>
+
+ <div className="text-xs text-muted-foreground">
+ 비밀유지 계약서와 관련된 첨부파일을 업로드하세요.
+ 각 벤더별로 동일한 파일이 저장됩니다.
+ </div>
+ </div>
+ )}
+
{/* <div className="space-y-2">
<Label>계약 항목 선택</Label>
{AGREEMENT_LIST.map((label) => (
|
