diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-18 10:33:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-18 10:33:20 +0000 |
| commit | 41ad2455ac47d8e2da331d7240ded1354df9a784 (patch) | |
| tree | 7980d9a6e260f774dd3ff97c541399a32b9c9b4f | |
| parent | 1e6d30c9f649dcaa0c1d24561af35d7a77fd51b2 (diff) | |
(최겸) 구매 피드백 반영
| -rw-r--r-- | components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx | 6 | ||||
| -rw-r--r-- | components/login/login-form.tsx | 19 | ||||
| -rw-r--r-- | components/login/partner-auth-form.tsx | 5 | ||||
| -rw-r--r-- | components/pq-input/pq-input-tabs.tsx | 136 | ||||
| -rw-r--r-- | components/vendor-regular-registrations/document-status-dialog.tsx | 852 | ||||
| -rw-r--r-- | components/vendor-regular-registrations/registration-request-dialog.tsx | 17 | ||||
| -rw-r--r-- | db/schema/bidding.ts | 1 | ||||
| -rw-r--r-- | lib/rfq-last/shared/rfq-items-dialog.tsx | 4 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx | 2 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 10 | ||||
| -rw-r--r-- | lib/vendor-regular-registrations/repository.ts | 614 |
11 files changed, 880 insertions, 786 deletions
diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx index dab65780..90d4975b 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -70,13 +70,13 @@ export interface ProcurementItemSelectorDialogSingleProps { * ``` */ export function ProcurementItemSelectorDialogSingle({ - triggerLabel = "품목 선택", + triggerLabel = "1회성 품목 선택", triggerVariant = "outline", triggerSize = "default", selectedProcurementItem = null, onProcurementItemSelect, - title = "품목 선택", - description = "품목을 검색하고 선택해주세요.", + title = "1회성 품목 선택", + description = "1회성 품목을 검색하고 선택해주세요.", showConfirmButtons = false, }: ProcurementItemSelectorDialogSingleProps) { const [open, setOpen] = useState(false); diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index 5fe6ab51..ee84add2 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -950,15 +950,16 @@ export function LoginForm() { {/* Terms - MFA 화면에서는 숨김 */} {!showMfaForm && ( - <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> - {t("agreement")}{" "} - <Link - href={`/${lng}/privacy`} - className="underline underline-offset-4 hover:text-primary" - > - {t("privacyPolicy")} - </Link> - </div> + // 1118 구매 파워유저 요구사항에 따라 삭제 + // <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> + // {t("agreement")}{" "} + // <Link + // href={`/${lng}/privacy`} + // className="underline underline-offset-4 hover:text-primary" + // > + // {t("privacyPolicy")} + // </Link> + // </div> )} </div> </div> diff --git a/components/login/partner-auth-form.tsx b/components/login/partner-auth-form.tsx index 22917997..ebd2219c 100644 --- a/components/login/partner-auth-form.tsx +++ b/components/login/partner-auth-form.tsx @@ -300,13 +300,14 @@ export function CompanyAuthForm({ className, ...props }: React.HTMLAttributes<HT </form> </div> <p className="px-8 text-center text-sm text-muted-foreground"> - {t("agreement")}{" "} + {/* 1118 구매 파워유저 요구사항에 따라 삭제 */} + {/* {t("agreement")}{" "} <Link href={`/${lng}/privacy`} // 개인정보처리방침만 남김 className="underline underline-offset-4 hover:text-primary" > {t("privacyPolicy")} - </Link> + </Link> */} {/* {t("privacyAgreement")}. */} </p> </div> diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx index f0d44d04..df911d5e 100644 --- a/components/pq-input/pq-input-tabs.tsx +++ b/components/pq-input/pq-input-tabs.tsx @@ -276,6 +276,55 @@ export function PQInputTabs({ setAllSaved(allItemsSaved) }, [form.watch()]) + // ---------------------------------------------------------------------- + // C-1) Calculate item counts for display + // ---------------------------------------------------------------------- + + // ---------------------------------------------------------------------- + // C-2) Tab color mapping for better visual distinction + // ---------------------------------------------------------------------- + const getTabColorClasses = (groupName: string) => { + switch (groupName.toLowerCase()) { + case 'general': + return { + tab: 'data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 data-[state=active]:border-blue-200', + badge: 'bg-blue-100 text-blue-800 border-blue-200' + } + case 'hsg': + return { + tab: 'data-[state=active]:bg-green-50 data-[state=active]:text-green-700 data-[state=active]:border-green-200', + badge: 'bg-green-100 text-green-800 border-green-200' + } + case 'qms': + return { + tab: 'data-[state=active]:bg-orange-50 data-[state=active]:text-orange-700 data-[state=active]:border-orange-200', + badge: 'bg-orange-100 text-orange-800 border-orange-200' + } + case 'warranty': + return { + tab: 'data-[state=active]:bg-red-50 data-[state=active]:text-red-700 data-[state=active]:border-red-200', + badge: 'bg-red-100 text-red-800 border-red-200' + } + default: + return { + tab: 'data-[state=active]:bg-gray-50 data-[state=active]:text-gray-700 data-[state=active]:border-gray-200', + badge: 'bg-gray-100 text-gray-800 border-gray-200' + } + } + } + const getItemCounts = () => { + const values = form.getValues() + const totalItems = values.answers.length + const savedItems = values.answers.filter( + (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0) + ).length + const notSavedItems = totalItems - savedItems + + return { totalItems, savedItems, notSavedItems } + } + + const { totalItems, savedItems, notSavedItems } = getItemCounts() + // Helper to find the array index by criteriaId const getAnswerIndex = (criteriaId: number): number => { return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId) @@ -677,6 +726,30 @@ export function PQInputTabs({ <Tabs defaultValue={data[0]?.groupName || ""} className="w-full"> {/* Top Controls - Sticky Header */} <div className="sticky top-0 z-10 bg-background border-b border-border mb-4 pb-4"> + {/* Item Count Display */} + <div className="mb-3 flex items-center gap-6 text-sm"> + <div className="flex items-center gap-4"> + <span className="font-medium">총 항목:</span> + <Badge variant="outline" className="text-xs"> + {totalItems} + </Badge> + </div> + <div className="flex items-center gap-2"> + <CheckCircle2 className="h-4 w-4 text-green-600" /> + <span className="text-green-600 font-medium">Saved:</span> + <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs"> + {savedItems} + </Badge> + </div> + <div className="flex items-center gap-2"> + <AlertTriangle className="h-4 w-4 text-amber-600" /> + <span className="text-amber-600 font-medium">Not Saved:</span> + <Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 text-xs"> + {notSavedItems} + </Badge> + </div> + </div> + {/* Filter Controls */} <div className="mb-3 flex items-center gap-4"> <span className="text-sm font-medium">필터:</span> @@ -702,8 +775,11 @@ export function PQInputTabs({ checked={filterOptions.showSaved} onCheckedChange={(checked) => { const newOptions = { ...filterOptions, showSaved: !!checked }; - if (!checked && !filterOptions.showAll && !filterOptions.showNotSaved) { - // 최소 하나는 체크되어 있어야 함 + // Save 항목이나 Not Save 항목을 선택하면 전체 항목 자동 해제 + if (checked) { + newOptions.showAll = false; + } else if (!filterOptions.showNotSaved && !filterOptions.showAll) { + // 최소 하나는 체크되어 있어야 함 - 모두 해제되면 전체 항목 체크 newOptions.showAll = true; } setFilterOptions(newOptions); @@ -717,8 +793,11 @@ export function PQInputTabs({ checked={filterOptions.showNotSaved} onCheckedChange={(checked) => { const newOptions = { ...filterOptions, showNotSaved: !!checked }; - if (!checked && !filterOptions.showAll && !filterOptions.showSaved) { - // 최소 하나는 체크되어 있어야 함 + // Save 항목이나 Not Save 항목을 선택하면 전체 항목 자동 해제 + if (checked) { + newOptions.showAll = false; + } else if (!filterOptions.showSaved && !filterOptions.showAll) { + // 최소 하나는 체크되어 있어야 함 - 모두 해제되면 전체 항목 체크 newOptions.showAll = true; } setFilterOptions(newOptions); @@ -731,27 +810,30 @@ export function PQInputTabs({ <div className="flex justify-between items-center"> <TabsList className="grid grid-cols-4"> - {data.map((group) => ( - <TabsTrigger - key={group.groupName} - value={group.groupName} - className="truncate" - > - <div className="flex items-center gap-2"> - {/* Mobile: truncated version */} - <span className="block sm:hidden"> - {group.groupName.length > 5 - ? group.groupName.slice(0, 5) + "..." - : group.groupName} - </span> - {/* Desktop: full text */} - <span className="hidden sm:block">{group.groupName}</span> - <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium"> - {group.items.length} - </span> - </div> - </TabsTrigger> - ))} + {data.map((group) => { + const colorClasses = getTabColorClasses(group.groupName) + return ( + <TabsTrigger + key={group.groupName} + value={group.groupName} + className={`truncate ${colorClasses.tab}`} + > + <div className="flex items-center gap-2"> + {/* Mobile: truncated version */} + <span className="block sm:hidden"> + {group.groupName.length > 5 + ? group.groupName.slice(0, 5) + "..." + : group.groupName} + </span> + {/* Desktop: full text */} + <span className="hidden sm:block">{group.groupName}</span> + <span className={`inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full text-xs font-medium ${colorClasses.badge}`}> + {group.items.length} + </span> + </div> + </TabsTrigger> + ) + })} </TabsList> <div className="flex gap-2"> @@ -849,13 +931,13 @@ export function PQInputTabs({ {/* Save Status & Button */} <div className="flex items-center gap-2"> {!isSaved && canSave && ( - <span className="text-amber-600 text-xs flex items-center"> + <span className="text-amber-600 text-sm font-medium flex items-center"> <AlertTriangle className="h-4 w-4 mr-1" /> Not Saved </span> )} {isSaved && ( - <span className="text-green-600 text-xs flex items-center"> + <span className="text-green-600 text-sm font-medium flex items-center"> <CheckCircle2 className="h-4 w-4 mr-1" /> Saved </span> diff --git a/components/vendor-regular-registrations/document-status-dialog.tsx b/components/vendor-regular-registrations/document-status-dialog.tsx index 5efee64e..02da19bf 100644 --- a/components/vendor-regular-registrations/document-status-dialog.tsx +++ b/components/vendor-regular-registrations/document-status-dialog.tsx @@ -1,426 +1,426 @@ -"use client";
-
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { FileText, Download, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
-import { toast } from "sonner";
-
-import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
-import {
- documentStatusColumns,
-} from "@/config/vendorRegularRegistrationsColumnsConfig";
-
-interface DocumentStatusDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- registration: VendorRegularRegistration | null;
- onRefresh?: () => void;
- isVendorUser?: boolean;
-}
-
-const StatusIcon = ({ status }: { status: string | boolean }) => {
- if (typeof status === "boolean") {
- return status ? (
- <CheckCircle className="w-4 h-4 text-green-600" />
- ) : (
- <XCircle className="w-4 h-4 text-red-500" />
- );
- }
-
- switch (status) {
- case "completed":
- return <CheckCircle className="w-4 h-4 text-green-600" />;
- case "reviewing":
- return <Clock className="w-4 h-4 text-yellow-600" />;
- case "not_submitted":
- default:
- return <XCircle className="w-4 h-4 text-red-500" />;
- }
-};
-
-const StatusBadge = ({ status }: { status: string | boolean }) => {
- if (typeof status === "boolean") {
- return (
- <Badge variant={status ? "default" : "destructive"}>
- {status ? "제출완료" : "미제출"}
- </Badge>
- );
- }
-
- const statusConfig = {
- completed: { label: "완료", variant: "default" as const },
- reviewing: { label: "검토중", variant: "secondary" as const },
- not_submitted: { label: "미제출", variant: "destructive" as const },
- };
-
- const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.not_submitted;
-
- return <Badge variant={config.variant}>{config.label}</Badge>;
-};
-
-export function DocumentStatusDialog({
- open,
- onOpenChange,
- registration,
- onRefresh,
- isVendorUser = false,
-}: DocumentStatusDialogProps) {
- if (!registration) return null;
-
- // 파일 다운로드 핸들러
- const handleFileDownload = async (docKey: string, fileIndex: number = 0) => {
- try {
- console.log(`🔍 파일 다운로드 시도:`, {
- docKey,
- fileIndex,
- allDocumentFiles: registration.documentFiles,
- registrationId: registration.id,
- registrationKeys: Object.keys(registration),
- fullRegistration: registration
- });
- //isvendoruser인 경우는 실사 결과 파일 다운로드 불가능
- if (isVendorUser && docKey === "auditResult") {
- toast.error("실사 결과 파일은 다운로드할 수 없습니다.");
- return;
- }
-
- // documentFiles가 없는 경우 처리
- if (!registration.documentFiles) {
- console.error(`❌ documentFiles가 없음:`, {
- registration,
- hasDocumentFiles: !!registration.documentFiles,
- registrationKeys: Object.keys(registration)
- });
- toast.error("문서 파일 정보를 찾을 수 없습니다. 페이지를 새로고침 해주세요.");
- return;
- }
-
- const files = registration.documentFiles[docKey as keyof typeof registration.documentFiles];
- console.log(`📂 ${docKey} 파일 목록:`, files);
-
- if (!files || files.length === 0) {
- console.warn(`❌ ${docKey}에 파일이 없음:`, { files, length: files?.length });
- toast.error("다운로드할 파일이 없습니다.");
- return;
- }
-
- const file = files[fileIndex];
- console.log(`📄 선택된 파일 (index ${fileIndex}):`, file);
-
- if (!file) {
- console.warn(`❌ 파일 인덱스 ${fileIndex}에 파일이 없음`);
- toast.error("파일을 찾을 수 없습니다.");
- return;
- }
-
- // 파일 객체의 모든 속성 확인
- console.log(`🔍 파일 객체 전체 속성:`, Object.keys(file));
- console.log(`🔍 파일 상세 정보:`, {
- filePath: file.filePath,
- path: file.path,
- originalFileName: file.originalFileName,
- fileName: file.fileName,
- name: file.name,
- fullObject: file
- });
-
- // filePath와 fileName 추출
- const filePath = file.filePath || file.path;
- const fileName = file.originalFileName || file.fileName || file.name;
-
- console.log(`📝 추출된 파일 정보:`, { filePath, fileName });
-
- if (!filePath || !fileName) {
- console.error(`❌ 파일 정보 누락:`, {
- filePath,
- fileName,
- fileObject: file,
- availableKeys: Object.keys(file)
- });
- toast.error("파일 정보가 올바르지 않습니다.");
- return;
- }
-
- console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey });
-
- // downloadFile 함수를 동적으로 import하여 파일 다운로드
- const { downloadFile } = await import('@/lib/file-download');
- const result = await downloadFile(filePath, fileName, {
- showToast: true,
- onError: (error: any) => {
- console.error("파일 다운로드 오류:", error);
- toast.error(`파일 다운로드 실패: ${error}`);
- },
- onSuccess: (fileName: string, fileSize?: number) => {
- console.log(`✅ 파일 다운로드 성공:`, { fileName, fileSize });
- }
- });
-
- if (!result.success) {
- console.error("파일 다운로드 실패:", result.error);
- }
- } catch (error) {
- console.error("파일 다운로드 중 오류 발생:", error);
- toast.error("파일 다운로드 중 오류가 발생했습니다.");
- }
- };
-
- // 기본계약 파일 다운로드 핸들러
- const handleContractDownload = async (contractIndex: number) => {
- try {
- if (!registration.basicContracts || registration.basicContracts.length === 0) {
- toast.error("다운로드할 계약이 없습니다.");
- return;
- }
-
- const contract = registration.basicContracts[contractIndex];
- if (!contract) {
- toast.error("계약을 찾을 수 없습니다.");
- return;
- }
-
- if (contract.status !== "VENDOR_SIGNED" && contract.status !== "COMPLETED") {
- toast.error("완료된 계약서만 다운로드할 수 있습니다.");
- return;
- }
-
- // 서명된 계약서 파일 정보 확인
- const filePath = contract.filePath;
- const fileName = contract.fileName || `${contract.templateName || '기본계약'}_${registration.companyName}.docx`;
-
- if (!filePath) {
- toast.error("계약서 파일을 찾을 수 없습니다.");
- return;
- }
-
- console.log(`📥 기본계약 다운로드 시작:`, {
- filePath,
- fileName,
- templateName: contract.templateName
- });
-
- // downloadFile 함수를 사용하여 서명된 계약서 다운로드
- const { downloadFile } = await import('@/lib/file-download');
- const result = await downloadFile(filePath, fileName, {
- showToast: true,
- onError: (error: any) => {
- console.error("기본계약 다운로드 오류:", error);
- toast.error(`기본계약 다운로드 실패: ${error}`);
- },
- onSuccess: (fileName: string, fileSize?: number) => {
- console.log(`✅ 기본계약 다운로드 성공:`, { fileName, fileSize });
- }
- });
-
- if (!result.success) {
- console.error("기본계약 다운로드 실패:", result.error);
- }
- } catch (error) {
- console.error("기본계약 다운로드 중 오류 발생:", error);
- toast.error("기본계약 다운로드 중 오류가 발생했습니다.");
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[80vh]">
- <DialogHeader className="sticky top-0 z-10 border-b pr-4 pb-4 mb-4">
- <DialogTitle className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 문서/자료 접수 현황 - {registration.companyName}
- </div>
- {onRefresh && (
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="flex items-center gap-2"
- >
- <RefreshCw className="w-4 h-4" />
- 새로고침
- </Button>
- )}
- </DialogTitle>
- </DialogHeader>
-
- <div className="overflow-y-auto max-h-[calc(80vh-120px)] space-y-6">
- {/* 기본 정보 */}
- <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
- <div>
- <span className="text-sm font-medium text-gray-600">업체명:</span>
- <span className="ml-2">{registration.companyName}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">사업자번호:</span>
- <span className="ml-2">{registration.businessNumber}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">대표자:</span>
- <span className="ml-2">{registration.representative || "-"}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">현재상태:</span>
- <Badge className="ml-2">{registration.status}</Badge>
- </div>
- </div>
-
- {/* 문서 제출 현황 */}
- <div>
- <div className="flex items-center justify-between mb-4">
- <h3 className="text-lg font-semibold">문서 제출 현황</h3>
- </div>
- <div className="border rounded-lg">
- <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
- <div>문서유형</div>
- <div>상태</div>
- <div>제출일자</div>
- <div>액션</div>
- </div>
- {documentStatusColumns.map((doc) => {
- const isSubmitted = registration.documentSubmissions?.[
- doc.key as keyof typeof registration.documentSubmissions
- ] as boolean || false;
-
- // 내자인 경우 통장사본은 표시하지 않음
- const isForeign = registration.country !== 'KR';
- if (doc.key === 'bankCopy' && !isForeign) {
- return null;
- }
-
- return (
- <div
- key={doc.key}
- className="grid grid-cols-4 gap-4 p-4 border-t items-center"
- >
- <div className="flex items-center gap-2">
- <StatusIcon status={isSubmitted} />
- {doc.label}
- {doc.key === 'bankCopy' && isForeign && (
- <span className="text-xs text-blue-600">(외자 필수)</span>
- )}
- </div>
- <div>
- <StatusBadge status={isSubmitted} />
- </div>
- <div className="text-sm text-gray-600">
- {isSubmitted ? "2024.01.01" : "-"}
- </div>
- <div>
- {isSubmitted && (
- <Button
- size="sm"
- variant="outline"
- onClick={() => handleFileDownload(doc.key)}
- >
- <Download className="w-4 h-4 mr-1" />
- 다운로드
- </Button>
- )}
- </div>
- </div>
- );
- })}
- </div>
- </div>
-
- {/* 계약 동의 현황 */}
- <div>
- <div className="flex items-center justify-between mb-4">
- <h3 className="text-lg font-semibold">계약 동의 현황</h3>
- </div>
- <div className="border rounded-lg">
- <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
- <div>계약유형</div>
- <div>상태</div>
- <div>서약일자</div>
- <div>액션</div>
- </div>
- {!registration.basicContracts || registration.basicContracts.length === 0 ? (
- <div className="p-4 border-t text-center text-gray-500">
- 요청된 기본계약이 없습니다.
- </div>
- ) : (
- registration.basicContracts.map((contract, index) => {
- const isCompleted = contract.status === "VENDOR_SIGNED" || contract.status === "COMPLETED";
-
- return (
- <div
- key={`${contract.templateId}-${index}`}
- className="grid grid-cols-4 gap-4 p-4 border-t items-center"
- >
- <div className="flex items-center gap-2">
- <StatusIcon status={isCompleted} />
- {contract.templateName || "템플릿명 없음"}
- </div>
- <div>
- <StatusBadge status={isCompleted} />
- </div>
- <div className="text-sm text-gray-600">
- {isCompleted && contract.createdAt
- ? new Intl.DateTimeFormat('ko-KR').format(new Date(contract.createdAt))
- : "-"
- }
- </div>
- <div>
- {isCompleted && (
- <Button
- size="sm"
- variant="outline"
- onClick={() => handleContractDownload(index)}
- >
- <Download className="w-4 h-4 mr-1" />
- 다운로드
- </Button>
- )}
- </div>
- </div>
- );
- })
- )}
- </div>
- </div>
-
- {/* 안전적격성 평가 */}
- <div>
- <h3 className="text-lg font-semibold mb-4">안전적격성 평가</h3>
- <div className="p-4 border rounded-lg">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <StatusIcon status={!!registration.safetyQualificationContent} />
- <span>안전적격성 평가</span>
- </div>
- <StatusBadge status={!!registration.safetyQualificationContent} />
- </div>
- {registration.safetyQualificationContent && (
- <div className="mt-3 p-3 bg-gray-50 rounded">
- <p className="text-sm">{registration.safetyQualificationContent}</p>
- </div>
- )}
- </div>
- </div>
-
- {/* 추가 정보 */}
- <div>
- <h3 className="text-lg font-semibold mb-4">추가 정보</h3>
- <div className="p-4 border rounded-lg">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <StatusIcon status={registration.additionalInfo} />
- <span>추가 정보 등록</span>
- </div>
- <StatusBadge status={registration.additionalInfo} />
- </div>
- </div>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- );
-}
+"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FileText, Download, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; + +import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"; +import { + documentStatusColumns, +} from "@/config/vendorRegularRegistrationsColumnsConfig"; + +interface DocumentStatusDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + registration: VendorRegularRegistration | null; + onRefresh?: () => void; + isVendorUser?: boolean; +} + +const StatusIcon = ({ status }: { status: string | boolean }) => { + if (typeof status === "boolean") { + return status ? ( + <CheckCircle className="w-4 h-4 text-green-600" /> + ) : ( + <XCircle className="w-4 h-4 text-red-500" /> + ); + } + + switch (status) { + case "completed": + return <CheckCircle className="w-4 h-4 text-green-600" />; + case "reviewing": + return <Clock className="w-4 h-4 text-yellow-600" />; + case "not_submitted": + default: + return <XCircle className="w-4 h-4 text-red-500" />; + } +}; + +const StatusBadge = ({ status }: { status: string | boolean }) => { + if (typeof status === "boolean") { + return ( + <Badge variant={status ? "default" : "destructive"}> + {status ? "제출완료" : "미제출"} + </Badge> + ); + } + + const statusConfig = { + completed: { label: "완료", variant: "default" as const }, + reviewing: { label: "검토중", variant: "secondary" as const }, + not_submitted: { label: "미제출", variant: "destructive" as const }, + }; + + const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.not_submitted; + + return <Badge variant={config.variant}>{config.label}</Badge>; +}; + +export function DocumentStatusDialog({ + open, + onOpenChange, + registration, + onRefresh, + isVendorUser = false, +}: DocumentStatusDialogProps) { + if (!registration) return null; + + // 파일 다운로드 핸들러 + const handleFileDownload = async (docKey: string, fileIndex: number = 0) => { + try { + console.log(`🔍 파일 다운로드 시도:`, { + docKey, + fileIndex, + allDocumentFiles: registration.documentFiles, + registrationId: registration.id, + registrationKeys: Object.keys(registration), + fullRegistration: registration + }); + //isvendoruser인 경우는 실사 결과 파일 다운로드 불가능 + if (isVendorUser && docKey === "auditResult") { + toast.error("실사 결과 파일은 다운로드할 수 없습니다."); + return; + } + + // documentFiles가 없는 경우 처리 + if (!registration.documentFiles) { + console.error(`❌ documentFiles가 없음:`, { + registration, + hasDocumentFiles: !!registration.documentFiles, + registrationKeys: Object.keys(registration) + }); + toast.error("문서 파일 정보를 찾을 수 없습니다. 페이지를 새로고침 해주세요."); + return; + } + + const files = registration.documentFiles[docKey as keyof typeof registration.documentFiles]; + console.log(`📂 ${docKey} 파일 목록:`, files); + + if (!files || files.length === 0) { + console.warn(`❌ ${docKey}에 파일이 없음:`, { files, length: files?.length }); + toast.error("다운로드할 파일이 없습니다."); + return; + } + + const file = files[fileIndex]; + console.log(`📄 선택된 파일 (index ${fileIndex}):`, file); + + if (!file) { + console.warn(`❌ 파일 인덱스 ${fileIndex}에 파일이 없음`); + toast.error("파일을 찾을 수 없습니다."); + return; + } + + // 파일 객체의 모든 속성 확인 + console.log(`🔍 파일 객체 전체 속성:`, Object.keys(file)); + console.log(`🔍 파일 상세 정보:`, { + filePath: file.filePath, + path: file.path, + originalFileName: file.originalFileName, + fileName: file.fileName, + name: file.name, + fullObject: file + }); + + // filePath와 fileName 추출 + const filePath = file.filePath || file.path; + const fileName = file.originalFileName || file.fileName || file.name; + + console.log(`📝 추출된 파일 정보:`, { filePath, fileName }); + + if (!filePath || !fileName) { + console.error(`❌ 파일 정보 누락:`, { + filePath, + fileName, + fileObject: file, + availableKeys: Object.keys(file) + }); + toast.error("파일 정보가 올바르지 않습니다."); + return; + } + + console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey }); + + // downloadFile 함수를 동적으로 import하여 파일 다운로드 + const { downloadFile } = await import('@/lib/file-download'); + const result = await downloadFile(filePath, fileName, { + showToast: true, + onError: (error: any) => { + console.error("파일 다운로드 오류:", error); + toast.error(`파일 다운로드 실패: ${error}`); + }, + onSuccess: (fileName: string, fileSize?: number) => { + console.log(`✅ 파일 다운로드 성공:`, { fileName, fileSize }); + } + }); + + if (!result.success) { + console.error("파일 다운로드 실패:", result.error); + } + } catch (error) { + console.error("파일 다운로드 중 오류 발생:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + // 기본계약 파일 다운로드 핸들러 + const handleContractDownload = async (contractIndex: number) => { + try { + if (!registration.basicContracts || registration.basicContracts.length === 0) { + toast.error("다운로드할 계약이 없습니다."); + return; + } + + const contract = registration.basicContracts[contractIndex]; + if (!contract) { + toast.error("계약을 찾을 수 없습니다."); + return; + } + + if (contract.status !== "VENDOR_SIGNED" && contract.status !== "COMPLETED") { + toast.error("완료된 계약서만 다운로드할 수 있습니다."); + return; + } + + // 서명된 계약서 파일 정보 확인 + const filePath = contract.filePath; + const fileName = contract.fileName || `${contract.templateName || '기본계약'}_${registration.companyName}.docx`; + + if (!filePath) { + toast.error("계약서 파일을 찾을 수 없습니다."); + return; + } + + console.log(`📥 기본계약 다운로드 시작:`, { + filePath, + fileName, + templateName: contract.templateName + }); + + // downloadFile 함수를 사용하여 서명된 계약서 다운로드 + const { downloadFile } = await import('@/lib/file-download'); + const result = await downloadFile(filePath, fileName, { + showToast: true, + onError: (error: any) => { + console.error("기본계약 다운로드 오류:", error); + toast.error(`기본계약 다운로드 실패: ${error}`); + }, + onSuccess: (fileName: string, fileSize?: number) => { + console.log(`✅ 기본계약 다운로드 성공:`, { fileName, fileSize }); + } + }); + + if (!result.success) { + console.error("기본계약 다운로드 실패:", result.error); + } + } catch (error) { + console.error("기본계약 다운로드 중 오류 발생:", error); + toast.error("기본계약 다운로드 중 오류가 발생했습니다."); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader className="sticky top-0 z-10 border-b pr-4 pb-4 mb-4"> + <DialogTitle className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 문서/자료 접수 현황 - {registration.companyName} + </div> + {onRefresh && ( + <Button + variant="outline" + size="sm" + onClick={onRefresh} + className="flex items-center gap-2" + > + <RefreshCw className="w-4 h-4" /> + 새로고침 + </Button> + )} + </DialogTitle> + </DialogHeader> + + <div className="overflow-y-auto max-h-[calc(80vh-120px)] space-y-6"> + {/* 기본 정보 */} + <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg"> + <div> + <span className="text-sm font-medium text-gray-600">업체명:</span> + <span className="ml-2">{registration.companyName}</span> + </div> + <div> + <span className="text-sm font-medium text-gray-600">사업자번호:</span> + <span className="ml-2">{registration.businessNumber}</span> + </div> + <div> + <span className="text-sm font-medium text-gray-600">대표자:</span> + <span className="ml-2">{registration.representative || "-"}</span> + </div> + <div> + <span className="text-sm font-medium text-gray-600">현재상태:</span> + <Badge className="ml-2">{registration.status}</Badge> + </div> + </div> + + {/* 문서 제출 현황 */} + <div> + <div className="flex items-center justify-between mb-4"> + <h3 className="text-lg font-semibold">문서 제출 현황</h3> + </div> + <div className="border rounded-lg"> + <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm"> + <div>문서유형</div> + <div>상태</div> + <div>제출일자</div> + <div>액션</div> + </div> + {documentStatusColumns.map((doc) => { + const isSubmitted = registration.documentSubmissions?.[ + doc.key as keyof typeof registration.documentSubmissions + ] as boolean || false; + + // 내자인 경우 통장사본은 표시하지 않음 + const isForeign = registration.country !== 'KR'; + if (doc.key === 'bankCopy' && !isForeign) { + return null; + } + + return ( + <div + key={doc.key} + className="grid grid-cols-4 gap-4 p-4 border-t items-center" + > + <div className="flex items-center gap-2"> + <StatusIcon status={isSubmitted} /> + {doc.label} + {doc.key === 'bankCopy' && isForeign && ( + <span className="text-xs text-blue-600">(외자 필수)</span> + )} + </div> + <div> + <StatusBadge status={isSubmitted} /> + </div> + <div className="text-sm text-gray-600"> + {isSubmitted ? "2024.01.01" : "-"} + </div> + <div> + {isSubmitted && ( + <Button + size="sm" + variant="outline" + onClick={() => handleFileDownload(doc.key)} + > + <Download className="w-4 h-4 mr-1" /> + 다운로드 + </Button> + )} + </div> + </div> + ); + })} + </div> + </div> + + {/* 계약 동의 현황 */} + <div> + <div className="flex items-center justify-between mb-4"> + <h3 className="text-lg font-semibold">계약 동의 현황</h3> + </div> + <div className="border rounded-lg"> + <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm"> + <div>계약유형</div> + <div>상태</div> + <div>서약일자</div> + <div>액션</div> + </div> + {!registration.basicContracts || registration.basicContracts.length === 0 ? ( + <div className="p-4 border-t text-center text-gray-500"> + 요청된 기본계약이 없습니다. + </div> + ) : ( + registration.basicContracts.map((contract, index) => { + const isCompleted = contract.status === "VENDOR_SIGNED" || contract.status === "COMPLETED"; + + return ( + <div + key={`${contract.templateId}-${index}`} + className="grid grid-cols-4 gap-4 p-4 border-t items-center" + > + <div className="flex items-center gap-2"> + <StatusIcon status={isCompleted} /> + {contract.templateName || "템플릿명 없음"} + </div> + <div> + <StatusBadge status={isCompleted} /> + </div> + <div className="text-sm text-gray-600"> + {isCompleted && contract.createdAt + ? new Intl.DateTimeFormat('ko-KR').format(new Date(contract.createdAt)) + : "-" + } + </div> + <div> + {isCompleted && ( + <Button + size="sm" + variant="outline" + onClick={() => handleContractDownload(index)} + > + <Download className="w-4 h-4 mr-1" /> + 다운로드 + </Button> + )} + </div> + </div> + ); + }) + )} + </div> + </div> + + {/* 안전적격성 평가 */} + <div> + <h3 className="text-lg font-semibold mb-4">안전적격성 평가</h3> + <div className="p-4 border rounded-lg"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <StatusIcon status={!!registration.safetyQualificationContent} /> + <span>안전적격성 평가</span> + </div> + <StatusBadge status={!!registration.safetyQualificationContent} /> + </div> + {registration.safetyQualificationContent && ( + <div className="mt-3 p-3 bg-gray-50 rounded"> + <p className="text-sm">{registration.safetyQualificationContent}</p> + </div> + )} + </div> + </div> + + {/* 추가 정보 */} + <div> + <h3 className="text-lg font-semibold mb-4">추가 정보</h3> + <div className="p-4 border rounded-lg"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <StatusIcon status={registration.additionalInfo} /> + <span>추가 정보 등록</span> + </div> + <StatusBadge status={registration.additionalInfo} /> + </div> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/components/vendor-regular-registrations/registration-request-dialog.tsx b/components/vendor-regular-registrations/registration-request-dialog.tsx index 99599ce5..d3aeb812 100644 --- a/components/vendor-regular-registrations/registration-request-dialog.tsx +++ b/components/vendor-regular-registrations/registration-request-dialog.tsx @@ -313,6 +313,16 @@ export function RegistrationRequestDialog({ return; } + // 업무담당자 검증 (최소 하나의 담당자라도 이름과 이메일이 있어야 함) + const hasValidBusinessContact = Object.values(formData.businessContacts).some(contact => + contact.name?.trim() && contact.email?.trim() + ); + + if (!hasValidBusinessContact) { + toast.error("업무담당자 정보를 최소 하나 이상 입력해주세요. (담당자명과 이메일 필수)"); + return; + } + if (onSubmit) { await onSubmit(formData); } @@ -599,7 +609,8 @@ export function RegistrationRequestDialog({ {/* 업무담당자 */} <div> - <h4 className="font-semibold mb-3">업무담당자</h4> + <h4 className="font-semibold mb-3">업무담당자 <span className="text-red-500">*</span></h4> + <p className="text-sm text-muted-foreground mb-4">최소 하나의 업무담당자 정보를 입력해주세요.</p> <div className="space-y-4"> {Object.entries(formData.businessContacts).map(([type, contact]) => { const labels = { @@ -615,7 +626,7 @@ export function RegistrationRequestDialog({ <h5 className="font-medium text-sm">{labels[type as keyof typeof labels]} 담당자</h5> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div> - <Label>담당자명</Label> + <Label>담당자명 <span className="text-red-500">*</span></Label> <Input value={contact.name} onChange={(e) => handleBusinessContactChange(type as keyof typeof formData.businessContacts, 'name', e.target.value)} @@ -646,7 +657,7 @@ export function RegistrationRequestDialog({ /> </div> <div> - <Label>이메일</Label> + <Label>이메일 <span className="text-red-500">*</span></Label> <Input type="email" value={contact.email} diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index 2f0dd07f..1d1fe50a 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -42,6 +42,7 @@ export const biddingStatusEnum = pgEnum('bidding_status', [ 'bidding_opened', // 입찰공고 'bidding_closed', // 입찰마감 'evaluation_of_bidding', // 입찰평가중 + 'approval_pending', // 결재 진행중 'bidding_disposal', // 유찰 'vendor_selected', // 업체선정 'bid_opening', // 개찰 diff --git a/lib/rfq-last/shared/rfq-items-dialog.tsx b/lib/rfq-last/shared/rfq-items-dialog.tsx index f3095c98..e4f71e79 100644 --- a/lib/rfq-last/shared/rfq-items-dialog.tsx +++ b/lib/rfq-last/shared/rfq-items-dialog.tsx @@ -328,11 +328,11 @@ export function RfqItemsDialog({ <TableCell> <div className="flex flex-col items-center gap-1"> <span className="text-xs font-mono">#{index + 1}</span> - {item.majorYn && ( + {/* {item.majorYn && ( <Badge variant="default" className="text-xs px-1 py-0"> 주요 </Badge> - )} + )} */} </div> </TableCell> <TableCell> diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx index abd2b516..8c70b8dd 100644 --- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -322,7 +322,7 @@ export default function VendorResponseEditor({ if (errors.quotationItems) { toast.error("견적 품목 정보를 확인해주세요. 모든 품목의 단가와 총액을 입력해야 합니다.") } else { - toast.error("입력 정보를 확인해주세요.") + toast.error("기본계약 또는 상업조건 정보를 확인해주세요.") } } } diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 2ee2cb73..577ae492 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -1539,10 +1539,10 @@ export function RfqVendorTable({ )} {/* 기본계약 수정 메뉴 추가 */} - <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> + {/* <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> <FileText className="mr-2 h-4 w-4" /> 기본계약 수정 - </DropdownMenuItem> + </DropdownMenuItem> */} {emailSentAt && ( <> @@ -1824,9 +1824,6 @@ export function RfqVendorTable({ <Plus className="h-4 w-4 mr-2" /> 벤더 추가 </Button> - - {selectedRows.length > 0 && ( - <> {/* 정보 일괄 입력 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" @@ -1837,7 +1834,8 @@ export function RfqVendorTable({ <Settings2 className="h-4 w-4 mr-2" /> 협력업체 조건 설정 ({nonCancelledRows.length}) </Button> - + {selectedRows.length > 0 && ( + <> {/* RFQ 발송 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts index 314afb6c..3713f628 100644 --- a/lib/vendor-regular-registrations/repository.ts +++ b/lib/vendor-regular-registrations/repository.ts @@ -1,307 +1,307 @@ -import db from "@/db/db";
-import {
- vendorRegularRegistrations,
- vendors,
- 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";
-
-export async function getVendorRegularRegistrations(
-): Promise<VendorRegularRegistration[]> {
- try {
- // DB 레코드 기준으로 정규업체등록 데이터를 가져옴
- const registrations = await db
- .select({
- // 정규업체등록 정보
- id: vendorRegularRegistrations.id,
- vendorId: vendorRegularRegistrations.vendorId,
- status: vendorRegularRegistrations.status,
- potentialCode: vendorRegularRegistrations.potentialCode,
- majorItems: vendorRegularRegistrations.majorItems,
- registrationRequestDate: vendorRegularRegistrations.registrationRequestDate,
- 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))
- .orderBy(desc(vendorRegularRegistrations.createdAt));
-
- // 벤더 ID 배열 생성
- const vendorIds = registrations.map(r => r.vendorId);
-
- // 벤더 첨부파일 정보 조회 - 벤더별로 그룹화
- const vendorAttachmentsList = vendorIds.length > 0 ? await db
- .select()
- .from(vendorAttachments)
- .where(inArray(vendorAttachments.vendorId, vendorIds)) : [];
-
- // 실사 첨부파일 정보 조회 - 실사 ID를 통해 벤더 ID 매핑
- const investigationAttachmentsList = vendorIds.length > 0 ? await db
- .select({
- vendorId: vendorInvestigations.vendorId,
- attachmentId: vendorInvestigationAttachments.id,
- fileName: vendorInvestigationAttachments.fileName,
- attachmentType: vendorInvestigationAttachments.attachmentType,
- createdAt: vendorInvestigationAttachments.createdAt,
- })
- .from(vendorInvestigationAttachments)
- .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,
- filePath: basicContract.filePath,
- fileName: basicContract.fileName,
- })
- .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);
-
- // 문서 제출 현황 - 국가별 요구사항 적용
- const isForeign = registration.country !== 'KR';
- const documentSubmissionsStatus = {
- businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"),
- bankCopy: isForeign ? vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY") : true, // 내자는 통장사본 불필요
- auditResult: investigationFiles.length > 0, // 실사 첨부파일이 하나라도 있으면 true
- };
-
- // 문서별 파일 정보 (다운로드용)
- const documentFiles = {
- businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"),
- bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
- auditResult: investigationFiles,
- };
-
- // 계약 동의 현황 - 실제 기본 계약 데이터 기반으로 단순화
- const contractAgreementsStatus = {
- 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;
-
- // 모든 조건 충족 여부 확인
- const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true);
- // 진행현황과 dialog에서 VENDOR_SIGNED도 완료로 간주하므로, 조건충족 체크도 동일하게 처리
- const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED" || c.status === "VENDOR_SIGNED");
- const safetyQualificationCompleted = !!registration.safetyQualificationContent;
-
- // 모든 조건이 충족되면 status를 "approval_ready"(조건충족)로 자동 변경
- const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted;
-
- // 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트
- // 단, 결재진행중(pending_approval) 또는 등록요청완료(registration_completed), 등록실패(registration_failed) 상태인 경우 무시
- if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "pending_approval" && registration.status !== "registration_completed" && registration.status !== "registration_failed") {
- // 비동기 업데이트 (백그라운드에서 실행)
- 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",
- 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,
- // 새로 추가된 필드들
- safetyQualificationContent: registration.safetyQualificationContent,
- gtcSkipped: registration.gtcSkipped || false,
- additionalInfo: additionalInfoCompleted,
- // 기본계약 정보
- basicContracts: vendorContracts.map((contract: any) => ({
- templateId: contract.templateId,
- templateName: contract.templateName,
- status: contract.status,
- createdAt: contract.createdAt,
- filePath: contract.filePath,
- fileName: contract.fileName,
- })),
- registrationRequestDate: registration.registrationRequestDate || null,
- assignedDepartment: registration.assignedDepartment,
- assignedUser: registration.assignedUser,
- remarks: registration.remarks,
- };
- });
- } catch (error) {
- console.error("Error fetching vendor regular registrations:", error);
- throw new Error("정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.");
- }
-}
-
-export async function createVendorRegularRegistration(data: {
- vendorId: number;
- status?: string;
- potentialCode?: string;
- majorItems?: string;
- assignedDepartment?: string;
- assignedDepartmentCode?: string;
- assignedUser?: string;
- assignedUserCode?: string;
- remarks?: string;
- safetyQualificationContent?: string;
- gtcSkipped?: boolean;
-}) {
- try {
- const [registration] = await db
- .insert(vendorRegularRegistrations)
- .values({
- vendorId: data.vendorId,
- status: data.status || "under_review",
- potentialCode: data.potentialCode,
- majorItems: data.majorItems,
- assignedDepartment: data.assignedDepartment,
- assignedDepartmentCode: data.assignedDepartmentCode,
- assignedUser: data.assignedUser,
- assignedUserCode: data.assignedUserCode,
- remarks: data.remarks,
- safetyQualificationContent: data.safetyQualificationContent,
- gtcSkipped: data.gtcSkipped || false,
- })
- .returning();
-
- return registration;
- } catch (error) {
- console.error("Error creating vendor regular registration:", error);
- throw new Error("정규업체 등록을 생성하는 중 오류가 발생했습니다.");
- }
-}
-
-export async function updateVendorRegularRegistration(
- id: number,
- data: Partial<{
- status: string;
- potentialCode: string;
- majorItems: string;
- registrationRequestDate: string;
- assignedDepartment: string;
- assignedDepartmentCode: string;
- assignedUser: string;
- assignedUserCode: string;
- remarks: string;
- safetyQualificationContent: string;
- gtcSkipped: boolean;
- }>
-) {
- try {
- const [registration] = await db
- .update(vendorRegularRegistrations)
- .set({
- ...data,
- updatedAt: new Date(),
- })
- .where(eq(vendorRegularRegistrations.id, id))
- .returning();
-
- return registration;
- } catch (error) {
- console.error("Error updating vendor regular registration:", error);
- throw new Error("정규업체 등록을 업데이트하는 중 오류가 발생했습니다.");
- }
-}
-
-export async function getVendorRegularRegistrationById(id: number) {
- try {
- const [registration] = await db
- .select()
- .from(vendorRegularRegistrations)
- .where(eq(vendorRegularRegistrations.id, id));
-
- return registration;
- } catch (error) {
- console.error("Error fetching vendor regular registration by id:", error);
- throw new Error("정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.");
- }
-}
-
-
+import db from "@/db/db"; +import { + vendorRegularRegistrations, + vendors, + 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"; + +export async function getVendorRegularRegistrations( +): Promise<VendorRegularRegistration[]> { + try { + // DB 레코드 기준으로 정규업체등록 데이터를 가져옴 + const registrations = await db + .select({ + // 정규업체등록 정보 + id: vendorRegularRegistrations.id, + vendorId: vendorRegularRegistrations.vendorId, + status: vendorRegularRegistrations.status, + potentialCode: vendorRegularRegistrations.potentialCode, + majorItems: vendorRegularRegistrations.majorItems, + registrationRequestDate: vendorRegularRegistrations.registrationRequestDate, + 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)) + .orderBy(desc(vendorRegularRegistrations.createdAt)); + + // 벤더 ID 배열 생성 + const vendorIds = registrations.map(r => r.vendorId); + + // 벤더 첨부파일 정보 조회 - 벤더별로 그룹화 + const vendorAttachmentsList = vendorIds.length > 0 ? await db + .select() + .from(vendorAttachments) + .where(inArray(vendorAttachments.vendorId, vendorIds)) : []; + + // 실사 첨부파일 정보 조회 - 실사 ID를 통해 벤더 ID 매핑 + const investigationAttachmentsList = vendorIds.length > 0 ? await db + .select({ + vendorId: vendorInvestigations.vendorId, + attachmentId: vendorInvestigationAttachments.id, + fileName: vendorInvestigationAttachments.fileName, + attachmentType: vendorInvestigationAttachments.attachmentType, + createdAt: vendorInvestigationAttachments.createdAt, + }) + .from(vendorInvestigationAttachments) + .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, + filePath: basicContract.filePath, + fileName: basicContract.fileName, + }) + .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); + + // 문서 제출 현황 - 국가별 요구사항 적용 + const isForeign = registration.country !== 'KR'; + const documentSubmissionsStatus = { + businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"), + creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"), + bankCopy: isForeign ? vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY") : true, // 내자는 통장사본 불필요 + auditResult: investigationFiles.length > 0, // 실사 첨부파일이 하나라도 있으면 true + }; + + // 문서별 파일 정보 (다운로드용) + const documentFiles = { + businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"), + creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"), + bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"), + auditResult: investigationFiles, + }; + + // 계약 동의 현황 - 실제 기본 계약 데이터 기반으로 단순화 + const contractAgreementsStatus = { + 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; + + // 모든 조건 충족 여부 확인 + const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true); + // 진행현황과 dialog에서 VENDOR_SIGNED도 완료로 간주하므로, 조건충족 체크도 동일하게 처리 + const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED" || c.status === "VENDOR_SIGNED"); + const safetyQualificationCompleted = !!registration.safetyQualificationContent; + + // 모든 조건이 충족되면 status를 "approval_ready"(조건충족)로 자동 변경 + const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted; + + // 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트 + // 단, 결재진행중(pending_approval) 또는 등록요청완료(registration_completed), 등록실패(registration_failed) 상태인 경우 무시 + if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "pending_approval" && registration.status !== "registration_completed" && registration.status !== "registration_failed") { + // 비동기 업데이트 (백그라운드에서 실행) + 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", + 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, + // 새로 추가된 필드들 + safetyQualificationContent: registration.safetyQualificationContent, + gtcSkipped: registration.gtcSkipped || false, + additionalInfo: additionalInfoCompleted, + // 기본계약 정보 + basicContracts: vendorContracts.map((contract: any) => ({ + templateId: contract.templateId, + templateName: contract.templateName, + status: contract.status, + createdAt: contract.createdAt, + filePath: contract.filePath, + fileName: contract.fileName, + })), + registrationRequestDate: registration.registrationRequestDate || null, + assignedDepartment: registration.assignedDepartment, + assignedUser: registration.assignedUser, + remarks: registration.remarks, + }; + }); + } catch (error) { + console.error("Error fetching vendor regular registrations:", error); + throw new Error("정규업체 등록 목록을 가져오는 중 오류가 발생했습니다."); + } +} + +export async function createVendorRegularRegistration(data: { + vendorId: number; + status?: string; + potentialCode?: string; + majorItems?: string; + assignedDepartment?: string; + assignedDepartmentCode?: string; + assignedUser?: string; + assignedUserCode?: string; + remarks?: string; + safetyQualificationContent?: string; + gtcSkipped?: boolean; +}) { + try { + const [registration] = await db + .insert(vendorRegularRegistrations) + .values({ + vendorId: data.vendorId, + status: data.status || "under_review", + potentialCode: data.potentialCode, + majorItems: data.majorItems, + assignedDepartment: data.assignedDepartment, + assignedDepartmentCode: data.assignedDepartmentCode, + assignedUser: data.assignedUser, + assignedUserCode: data.assignedUserCode, + remarks: data.remarks, + safetyQualificationContent: data.safetyQualificationContent, + gtcSkipped: data.gtcSkipped || false, + }) + .returning(); + + return registration; + } catch (error) { + console.error("Error creating vendor regular registration:", error); + throw new Error("정규업체 등록을 생성하는 중 오류가 발생했습니다."); + } +} + +export async function updateVendorRegularRegistration( + id: number, + data: Partial<{ + status: string; + potentialCode: string; + majorItems: string; + registrationRequestDate: string; + assignedDepartment: string; + assignedDepartmentCode: string; + assignedUser: string; + assignedUserCode: string; + remarks: string; + safetyQualificationContent: string; + gtcSkipped: boolean; + }> +) { + try { + const [registration] = await db + .update(vendorRegularRegistrations) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(vendorRegularRegistrations.id, id)) + .returning(); + + return registration; + } catch (error) { + console.error("Error updating vendor regular registration:", error); + throw new Error("정규업체 등록을 업데이트하는 중 오류가 발생했습니다."); + } +} + +export async function getVendorRegularRegistrationById(id: number) { + try { + const [registration] = await db + .select() + .from(vendorRegularRegistrations) + .where(eq(vendorRegularRegistrations.id, id)); + + return registration; + } catch (error) { + console.error("Error fetching vendor regular registration by id:", error); + throw new Error("정규업체 등록 정보를 가져오는 중 오류가 발생했습니다."); + } +} + + |
