summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx754
-rw-r--r--lib/bidding/list/biddings-page-header.tsx2
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx8
-rw-r--r--lib/bidding/list/biddings-table.tsx54
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx2096
-rw-r--r--lib/bidding/service.ts424
-rw-r--r--lib/bidding/validation.ts4
-rw-r--r--lib/file-download.ts2
-rw-r--r--lib/forms/services.ts62
-rw-r--r--lib/items/service.ts42
-rw-r--r--lib/mail/templates/vendor-missing-contract-request.hbs155
-rw-r--r--lib/mail/templates/vendor-regular-registration-request.hbs120
-rw-r--r--lib/pq/pq-review-table-new/edit-investigation-dialog.tsx123
-rw-r--r--lib/pq/pq-review-table-new/request-investigation-dialog.tsx4
-rw-r--r--lib/pq/pq-review-table-new/site-visit-dialog.tsx179
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx20
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx55
-rw-r--r--lib/pq/pq-review-table/feature-flags-provider.tsx108
-rw-r--r--lib/pq/pq-review-table/vendors-table-columns.tsx212
-rw-r--r--lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx41
-rw-r--r--lib/pq/pq-review-table/vendors-table.tsx97
-rw-r--r--lib/pq/service.ts182
-rw-r--r--lib/sedp/get-form-tags.ts282
-rw-r--r--lib/sedp/sync-form.ts4
-rw-r--r--lib/site-visit/service.ts42
-rw-r--r--lib/site-visit/site-visit-detail-dialog.tsx2
-rw-r--r--lib/site-visit/vendor-info-sheet.tsx143
-rw-r--r--lib/site-visit/vendor-info-view-dialog.tsx2
-rw-r--r--lib/tags/service.ts45
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts52
-rw-r--r--lib/vendor-document-list/import-service.ts31
-rw-r--r--lib/vendor-document-list/plant/document-stage-validations.ts13
-rw-r--r--lib/vendor-document-list/plant/document-stages-columns.tsx32
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts81
-rw-r--r--lib/vendor-document-list/repository.ts6
-rw-r--r--lib/vendor-investigation/service.ts9
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx17
-rw-r--r--lib/vendor-registration-status/repository.ts165
-rw-r--r--lib/vendor-registration-status/service.ts260
-rw-r--r--lib/vendor-registration-status/vendor-registration-status-view.tsx470
-rw-r--r--lib/vendor-regular-registrations/repository.ts209
-rw-r--r--lib/vendor-regular-registrations/service.ts825
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx270
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx248
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx104
-rw-r--r--lib/vendors/service.ts29
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx143
47 files changed, 6246 insertions, 1982 deletions
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx
new file mode 100644
index 00000000..2e58d676
--- /dev/null
+++ b/lib/bidding/list/bidding-detail-dialogs.tsx
@@ -0,0 +1,754 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ CalendarIcon,
+ ClockIcon,
+ MapPinIcon,
+ FileTextIcon,
+ DownloadIcon,
+ EyeIcon,
+ PackageIcon,
+ HashIcon,
+ DollarSignIcon,
+ WeightIcon,
+ ExternalLinkIcon
+} from "lucide-react"
+import { toast } from "sonner"
+import { BiddingListItem } from "@/db/schema"
+import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download"
+import { getPRDetailsAction, getSpecificationMeetingDetailsAction } from "../service"
+
+// 타입 정의
+interface SpecificationMeetingDetails {
+ id: number;
+ biddingId: number;
+ meetingDate: string;
+ meetingTime: string | null;
+ location: string | null;
+ address: string | null;
+ contactPerson: string | null;
+ contactPhone: string | null;
+ contactEmail: string | null;
+ agenda: string | null;
+ materials: string | null;
+ notes: string | null;
+ isRequired: boolean;
+ createdAt: string;
+ updatedAt: string;
+ documents: Array<{
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ filePath: string;
+ title: string | null;
+ uploadedAt: string;
+ uploadedBy: string | null;
+ }>;
+}
+
+interface PRDetails {
+ documents: Array<{
+ id: number;
+ documentName: string;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ filePath: string;
+ registeredAt: string;
+ registeredBy: string | null;
+ version: string | null;
+ description: string | null;
+ createdAt: string;
+ updatedAt: string;
+ }>;
+ items: Array<{
+ id: number;
+ itemNumber: string;
+ itemInfo: string | null;
+ quantity: number | null;
+ quantityUnit: string | null;
+ requestedDeliveryDate: string | null;
+ prNumber: string | null;
+ annualUnitPrice: number | null;
+ currency: string | null;
+ totalWeight: number | null;
+ weightUnit: string | null;
+ materialDescription: string | null;
+ hasSpecDocument: boolean;
+ createdAt: string;
+ updatedAt: string;
+ specDocuments: Array<{
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ filePath: string;
+ uploadedAt: string;
+ title: string | null;
+ }>;
+ }>;
+}
+
+interface ActionResult<T> {
+ success: boolean;
+ data?: T;
+ error?: string;
+}
+
+// 파일 다운로드 훅
+const useFileDownload = () => {
+ const [downloadingFiles, setDownloadingFiles] = useState<Set<string>>(new Set());
+
+ const handleDownload = async (filePath: string, fileName: string, options?: {
+ action?: 'download' | 'preview'
+ }) => {
+ const fileKey = `${filePath}_${fileName}`;
+ if (downloadingFiles.has(fileKey)) return;
+
+ setDownloadingFiles(prev => new Set(prev).add(fileKey));
+
+ try {
+ await downloadFile(filePath, fileName, {
+ action: options?.action || 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 실패:", error);
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '');
+ }
+ });
+ } catch (error) {
+ console.error("다운로드 처리 중 오류:", error);
+ } finally {
+ setDownloadingFiles(prev => {
+ const newSet = new Set(prev);
+ newSet.delete(fileKey);
+ return newSet;
+ });
+ }
+ };
+
+ return { handleDownload, downloadingFiles };
+};
+
+// 파일 링크 컴포넌트
+interface FileDownloadLinkProps {
+ filePath: string;
+ fileName: string;
+ fileSize?: number;
+ title?: string | null;
+ className?: string;
+}
+
+const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({
+ filePath,
+ fileName,
+ fileSize,
+ title,
+ className = ""
+}) => {
+ const { handleDownload, downloadingFiles } = useFileDownload();
+ const fileInfo = getFileInfo(fileName);
+ const fileKey = `${filePath}_${fileName}`;
+ const isDownloading = downloadingFiles.has(fileKey);
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <button
+ onClick={() => handleDownload(filePath, fileName)}
+ disabled={isDownloading}
+ className={`inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 hover:underline disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
+ >
+ <span className="text-xs">{fileInfo.icon}</span>
+ <span className="truncate max-w-[150px]">
+ {isDownloading ? "다운로드 중..." : (title || fileName)}
+ </span>
+ <ExternalLinkIcon className="h-3 w-3 opacity-60" />
+ </button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="text-xs">
+ <div className="font-medium">{fileName}</div>
+ {fileSize && <div className="text-muted-foreground">{formatFileSize(fileSize)}</div>}
+ <div className="text-muted-foreground">클릭하여 다운로드</div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+};
+
+// 파일 다운로드 버튼 컴포넌트 (간소화된 버전)
+interface FileDownloadButtonProps {
+ filePath: string;
+ fileName: string;
+ fileSize?: number;
+ title?: string | null;
+ variant?: "download" | "preview";
+ size?: "sm" | "default" | "lg";
+}
+
+const FileDownloadButton: React.FC<FileDownloadButtonProps> = ({
+ filePath,
+ fileName,
+ fileSize,
+ title,
+ variant = "download",
+ size = "sm"
+}) => {
+ const { handleDownload, downloadingFiles } = useFileDownload();
+ const fileInfo = getFileInfo(fileName);
+ const fileKey = `${filePath}_${fileName}`;
+ const isDownloading = downloadingFiles.has(fileKey);
+
+ const Icon = variant === "preview" && fileInfo.canPreview ? EyeIcon : DownloadIcon;
+
+ return (
+ <Button
+ onClick={() => handleDownload(filePath, fileName, { action: variant })}
+ disabled={isDownloading}
+ size={size}
+ variant="outline"
+ className="gap-2"
+ >
+ <Icon className="h-4 w-4" />
+ {isDownloading ? "처리중..." : (
+ variant === "preview" && fileInfo.canPreview ? "미리보기" : "다운로드"
+ )}
+ {fileSize && size !== "sm" && (
+ <span className="text-xs text-muted-foreground">
+ ({formatFileSize(fileSize)})
+ </span>
+ )}
+ </Button>
+ );
+};
+
+// 사양설명회 다이얼로그
+interface SpecificationMeetingDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: BiddingListItem | null;
+}
+
+export function SpecificationMeetingDialog({
+ open,
+ onOpenChange,
+ bidding
+}: SpecificationMeetingDialogProps) {
+ const [data, setData] = useState<SpecificationMeetingDetails | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (open && bidding) {
+ fetchSpecificationMeetingData();
+ }
+ }, [open, bidding]);
+
+ const fetchSpecificationMeetingData = async () => {
+ if (!bidding) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const result = await getSpecificationMeetingDetailsAction(bidding.id);
+
+ if (result.success && result.data) {
+ setData(result.data);
+ } else {
+ setError(result.error || "사양설명회 정보를 불러올 수 없습니다.");
+ }
+ } catch (err) {
+ setError("데이터 로딩 중 오류가 발생했습니다.");
+ console.error("Failed to fetch specification meeting data:", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ try {
+ return new Date(dateString).toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ weekday: 'long'
+ });
+ } catch {
+ return dateString;
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <CalendarIcon className="h-5 w-5" />
+ 사양설명회 정보
+ </DialogTitle>
+ <DialogDescription>
+ {bidding?.title}의 사양설명회 상세 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-[75vh]">
+ {loading ? (
+ <div className="flex items-center justify-center py-6">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-2"></div>
+ <p className="text-sm text-muted-foreground">로딩 중...</p>
+ </div>
+ </div>
+ ) : error ? (
+ <div className="flex items-center justify-center py-6">
+ <div className="text-center">
+ <p className="text-sm text-destructive mb-2">{error}</p>
+ <Button onClick={fetchSpecificationMeetingData} size="sm">
+ 다시 시도
+ </Button>
+ </div>
+ </div>
+ ) : data ? (
+ <div className="space-y-4">
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0">
+ <div className="text-sm space-y-1">
+ <div>
+ <CalendarIcon className="inline h-3 w-3 text-muted-foreground mr-2" />
+ <span className="font-medium">날짜:</span> {formatDate(data.meetingDate)}
+ {data.meetingTime && <span className="ml-4"><ClockIcon className="inline h-3 w-3 text-muted-foreground mr-1" />{data.meetingTime}</span>}
+ </div>
+
+ {data.location && (
+ <div>
+ <MapPinIcon className="inline h-3 w-3 text-muted-foreground mr-2" />
+ <span className="font-medium">장소:</span> {data.location}
+ {data.address && <span className="text-muted-foreground ml-2">({data.address})</span>}
+ </div>
+ )}
+
+ <div>
+ <span className="font-medium">참석 필수:</span>
+ <Badge variant={data.isRequired ? "destructive" : "secondary"} className="text-xs ml-2">
+ {data.isRequired ? "필수" : "선택"}
+ </Badge>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 연락처 정보 */}
+ {(data.contactPerson || data.contactPhone || data.contactEmail) && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">연락처 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0">
+ <div className="text-sm">
+ {[
+ data.contactPerson && `담당자: ${data.contactPerson}`,
+ data.contactPhone && `전화: ${data.contactPhone}`,
+ data.contactEmail && `이메일: ${data.contactEmail}`
+ ].filter(Boolean).join(' • ')}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 안건 및 준비물 */}
+ {(data.agenda || data.materials) && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">안건 및 준비물</CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0 space-y-3">
+ {data.agenda && (
+ <div>
+ <span className="font-medium text-sm">안건:</span>
+ <div className="mt-1 p-2 bg-muted rounded text-sm whitespace-pre-wrap">
+ {data.agenda}
+ </div>
+ </div>
+ )}
+
+ {data.materials && (
+ <div>
+ <span className="font-medium text-sm">준비물:</span>
+ <div className="mt-1 p-2 bg-muted rounded text-sm whitespace-pre-wrap">
+ {data.materials}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 비고 */}
+ {data.notes && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">비고</CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0">
+ <div className="p-2 bg-muted rounded text-sm whitespace-pre-wrap">
+ {data.notes}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 관련 문서 */}
+ {data.documents.length > 0 && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base flex items-center gap-2">
+ <FileTextIcon className="h-4 w-4" />
+ 관련 문서 ({data.documents.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0">
+ <div className="space-y-2">
+ {data.documents.map((doc) => (
+ <div key={doc.id} className="flex items-center gap-2">
+ <FileDownloadLink
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ title={doc.title}
+ />
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ ) : null}
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+// PR 문서 다이얼로그
+interface PrDocumentsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: BiddingListItem | null;
+}
+
+export function PrDocumentsDialog({
+ open,
+ onOpenChange,
+ bidding
+}: PrDocumentsDialogProps) {
+ const [data, setData] = useState<PRDetails | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (open && bidding) {
+ fetchPRData();
+ }
+ }, [open, bidding]);
+
+ const fetchPRData = async () => {
+ if (!bidding) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const result = await getPRDetailsAction(bidding.id);
+
+ if (result.success && result.data) {
+ setData(result.data);
+ } else {
+ setError(result.error || "PR 문서 정보를 불러올 수 없습니다.");
+ }
+ } catch (err) {
+ setError("데이터 로딩 중 오류가 발생했습니다.");
+ console.error("Failed to fetch PR data:", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatCurrency = (amount: number | null, currency: string | null) => {
+ if (amount === null) return "-";
+ return `${amount.toLocaleString()} ${currency || ""}`;
+ };
+
+ const formatWeight = (weight: number | null, unit: string | null) => {
+ if (weight === null) return "-";
+ return `${weight.toLocaleString()} ${unit || ""}`;
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl max-h-[90vh]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <PackageIcon className="h-5 w-5" />
+ PR 문서
+ </DialogTitle>
+ <DialogDescription>
+ {bidding?.title}의 PR 문서 및 아이템 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-[75vh]">
+ {loading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
+ <p className="text-sm text-muted-foreground">로딩 중...</p>
+ </div>
+ </div>
+ ) : error ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <p className="text-sm text-destructive mb-2">{error}</p>
+ <Button onClick={fetchPRData} size="sm">
+ 다시 시도
+ </Button>
+ </div>
+ </div>
+ ) : data ? (
+ <div className="space-y-6">
+ {/* PR 문서 목록 */}
+ {data.documents.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <FileTextIcon className="h-5 w-5" />
+ PR 문서 ({data.documents.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>문서명</TableHead>
+ <TableHead>파일명</TableHead>
+ <TableHead>버전</TableHead>
+ <TableHead>크기</TableHead>
+ <TableHead>등록일</TableHead>
+ <TableHead>등록자</TableHead>
+ <TableHead className="text-right">다운로드</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.documents.map((doc) => (
+ <TableRow key={doc.id}>
+ <TableCell className="font-medium">
+ {doc.documentName}
+ {doc.description && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {doc.description}
+ </div>
+ )}
+ </TableCell>
+ <TableCell>
+ <FileDownloadLink
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ />
+ </TableCell>
+ <TableCell>
+ {doc.version ? (
+ <Badge variant="outline">{doc.version}</Badge>
+ ) : "-"}
+ </TableCell>
+ <TableCell>{formatFileSize(doc.fileSize)}</TableCell>
+ <TableCell>
+ {new Date(doc.registeredAt).toLocaleDateString('ko-KR')}
+ </TableCell>
+ <TableCell>{doc.registeredBy || "-"}</TableCell>
+ <TableCell className="text-right">
+ <FileDownloadButton
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ variant="download"
+ size="sm"
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* PR 아이템 테이블 */}
+ {data.items.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <HashIcon className="h-5 w-5" />
+ PR 아이템 ({data.items.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[100px]">아이템 번호</TableHead>
+ <TableHead className="w-[150px]">PR 번호</TableHead>
+ <TableHead>아이템 정보</TableHead>
+ <TableHead className="w-[120px]">수량</TableHead>
+ <TableHead className="w-[120px]">단가</TableHead>
+ <TableHead className="w-[120px]">중량</TableHead>
+ <TableHead className="w-[120px]">요청 납기</TableHead>
+ <TableHead className="w-[200px]">스펙 문서</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.items.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="font-medium">
+ {item.itemNumber}
+ </TableCell>
+ <TableCell>
+ {item.prNumber || "-"}
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.itemInfo && (
+ <div className="font-medium text-sm mb-1">{item.itemInfo}</div>
+ )}
+ {item.materialDescription && (
+ <div className="text-xs text-muted-foreground">
+ {item.materialDescription}
+ </div>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <PackageIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <DollarSignIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {formatCurrency(item.annualUnitPrice, item.currency)}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <WeightIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {formatWeight(item.totalWeight, item.weightUnit)}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ {item.requestedDeliveryDate ? (
+ <div className="flex items-center gap-1">
+ <CalendarIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')}
+ </span>
+ </div>
+ ) : "-"}
+ </TableCell>
+ <TableCell>
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <Badge variant={item.hasSpecDocument ? "default" : "secondary"} className="text-xs">
+ {item.hasSpecDocument ? "있음" : "없음"}
+ </Badge>
+ {item.specDocuments.length > 0 && (
+ <span className="text-xs text-muted-foreground">
+ ({item.specDocuments.length}개)
+ </span>
+ )}
+ </div>
+ {item.specDocuments.length > 0 && (
+ <div className="space-y-1">
+ {item.specDocuments.map((doc, index) => (
+ <div key={doc.id} className="text-xs">
+ <FileDownloadLink
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ title={doc.title}
+ className="text-xs"
+ />
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 데이터가 없는 경우 */}
+ {data.documents.length === 0 && data.items.length === 0 && (
+ <div className="text-center py-8">
+ <FileTextIcon className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
+ <p className="text-muted-foreground">PR 문서가 없습니다.</p>
+ </div>
+ )}
+ </div>
+ ) : null}
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/bidding/list/biddings-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx
index ece29e07..7fa9a39c 100644
--- a/lib/bidding/list/biddings-page-header.tsx
+++ b/lib/bidding/list/biddings-page-header.tsx
@@ -11,7 +11,7 @@ export function BiddingsPageHeader() {
<div className="flex items-center justify-between">
{/* 좌측: 제목과 설명 */}
<div className="space-y-1">
- <h1 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h1>
+ <h2 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h2>
<p className="text-muted-foreground">
입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다.
</p>
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 34fc574e..fde77bfb 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -270,7 +270,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
accessorKey: "contractPeriod",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
cell: ({ row }) => (
- <span className="text-sm">{row.original.contractPeriod || '-'}</span>
+ <span className="truncate max-w-[100px]">{row.original.contractPeriod || '-'}</span>
),
size: 100,
meta: { excelHeader: "계약기간" },
@@ -401,7 +401,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정" />,
cell: ({ row }) => (
<Badge variant="outline" className="font-mono">
- {row.original.participantStats.expected}
+ {row.original.participantExpected}
</Badge>
),
size: 80,
@@ -413,7 +413,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여" />,
cell: ({ row }) => (
<Badge variant="default" className="font-mono">
- {row.original.participantStats.participated}
+ {row.original.participantParticipated}
</Badge>
),
size: 60,
@@ -425,7 +425,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기" />,
cell: ({ row }) => (
<Badge variant="destructive" className="font-mono">
- {row.original.participantStats.declined}
+ {row.original.participantDeclined}
</Badge>
),
size: 60,
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index ce4aade9..672b756b 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -21,6 +21,8 @@ import {
biddingTypeLabels
} from "@/db/schema"
import { EditBiddingSheet } from "./edit-bidding-sheet"
+import { SpecificationMeetingDialog, PrDocumentsDialog } from "./bidding-detail-dialogs"
+
interface BiddingsTableProps {
promises: Promise<
@@ -34,6 +36,11 @@ interface BiddingsTableProps {
export function BiddingsTable({ promises }: BiddingsTableProps) {
const [{ data, pageCount }, statusCounts] = React.use(promises)
const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItem | null>(null)
+
+ console.log(data,"data")
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingListItem> | null>(null)
@@ -44,6 +51,25 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
[setRowAction]
)
+ // rowAction 변경 감지하여 해당 다이얼로그 열기
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedBidding(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "specification_meeting":
+ setSpecMeetingDialogOpen(true)
+ break
+ case "pr_documents":
+ setPrDocumentsDialogOpen(true)
+ break
+ // 기존 다른 액션들은 그대로 유지
+ default:
+ break
+ }
+ }
+ }, [rowAction])
+
const filterFields: DataTableFilterField<BiddingListItem>[] = []
const advancedFilterFields: DataTableAdvancedFilterField<BiddingListItem>[] = [
@@ -104,6 +130,18 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
}, [])
+ const handleSpecMeetingDialogClose = React.useCallback(() => {
+ setSpecMeetingDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
+ const handlePrDocumentsDialogClose = React.useCallback(() => {
+ setPrDocumentsDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
return (
<>
@@ -129,7 +167,21 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
bidding={rowAction?.row.original}
onSuccess={() => router.refresh()}
/>
+
+ {/* 사양설명회 다이얼로그 */}
+ <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ />
+
+ {/* PR 문서 다이얼로그 */}
+ <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ />
</>
)
-} \ No newline at end of file
+} \ No newline at end of file
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 683f6aff..90204dc9 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -79,6 +79,16 @@ import {
awardCountLabels
} from "@/db/schema"
import { ProjectSelector } from "@/components/ProjectSelector"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
// 사양설명회 정보 타입
interface SpecificationMeetingInfo {
@@ -119,6 +129,8 @@ export function CreateBiddingDialog() {
const { data: session } = useSession()
const [open, setOpen] = React.useState(false)
const [activeTab, setActiveTab] = React.useState<TabType>("basic")
+ const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가
+ const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null) // 추가
// 사양설명회 정보 상태
const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({
@@ -372,13 +384,12 @@ export function CreateBiddingDialog() {
const handleProjectSelect = React.useCallback((project: { id: number; code: string; name: string } | null) => {
if (project) {
form.setValue("projectId", project.id)
- form.setValue("projectName", `${project.code} (${project.name})`)
} else {
form.setValue("projectId", 0)
- form.setValue("projectName", "")
}
}, [form])
+
// 다음 버튼 클릭 핸들러
const handleNextClick = () => {
// 현재 탭 validation 체크
@@ -444,11 +455,8 @@ export function CreateBiddingDialog() {
// 생성된 입찰 상세페이지로 이동할지 묻기
if (result.data?.id) {
- setTimeout(() => {
- if (confirm("생성된 입찰의 상세페이지로 이동하시겠습니까?")) {
- router.push(`/admin/biddings/${result.data.id}`)
- }
- }, 500)
+ setCreatedBiddingId(result.data.id)
+ setShowSuccessDialog(true)
}
} else {
toast.error(result.error || "입찰 생성에 실패했습니다.")
@@ -510,6 +518,8 @@ export function CreateBiddingDialog() {
setPrItems([])
setSelectedItemForFile(null)
setActiveTab("basic")
+ setShowSuccessDialog(false) // 추가
+ setCreatedBiddingId(null) // 추가
}, [form])
// 다이얼로그 핸들러
@@ -520,101 +530,109 @@ export function CreateBiddingDialog() {
setOpen(nextOpen)
}
- return (
- <Dialog open={open} onOpenChange={handleDialogOpenChange}>
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- 신규 입찰
- </Button>
- </DialogTrigger>
- <DialogContent className="max-w-6xl h-[90vh] p-0 flex flex-col">
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle>신규 입찰 생성</DialogTitle>
- <DialogDescription>
- 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
- </DialogDescription>
- </DialogHeader>
- </div>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- id="create-bidding-form"
- >
- {/* 탭 영역 */}
- <div className="flex-1 overflow-hidden">
- <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
- <div className="px-6 pt-4">
- <TabsList className="grid w-full grid-cols-5">
- <TabsTrigger value="basic" className="relative">
- 기본 정보
- {!tabValidation.basic.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </TabsTrigger>
- <TabsTrigger value="contract" className="relative">
- 계약 정보
- {!tabValidation.contract.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </TabsTrigger>
- <TabsTrigger value="schedule" className="relative">
- 일정 & 회의
- {!tabValidation.schedule.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </TabsTrigger>
- <TabsTrigger value="details">세부내역</TabsTrigger>
- <TabsTrigger value="manager">담당자 & 기타</TabsTrigger>
- </TabsList>
- </div>
+ // 입찰 생성 버튼 클릭 핸들러 추가
+ const handleCreateBidding = () => {
+ // 마지막 탭 validation 체크
+ if (!isCurrentTabValid()) {
+ toast.error("필수 정보를 모두 입력해주세요.")
+ return
+ }
- <div className="flex-1 overflow-y-auto p-6">
- {/* 기본 정보 탭 */}
- <TabsContent value="basic" className="mt-0 space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>기본 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 프로젝트 선택 */}
- <FormField
- control={form.control}
- name="projectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 프로젝트 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <ProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트 선택..."
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ // 수동으로 폼 제출
+ form.handleSubmit(onSubmit)()
+ }
- <div className="grid grid-cols-2 gap-6">
- {/* 품목명 */}
+ // 성공 다이얼로그 핸들러들
+ const handleNavigateToDetail = () => {
+ if (createdBiddingId) {
+ router.push(`/evcp/biddings/${createdBiddingId}`)
+ }
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }
+
+ const handleStayOnPage = () => {
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }
+
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 신규 입찰
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-6xl h-[90vh] p-0 flex flex-col">
+ {/* 고정 헤더 */}
+ <div className="flex-shrink-0 p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>신규 입찰 생성</DialogTitle>
+ <DialogDescription>
+ 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col flex-1 min-h-0"
+ id="create-bidding-form"
+ >
+ {/* 탭 영역 */}
+ <div className="flex-1 overflow-hidden">
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
+ <div className="px-6 pt-4">
+ <TabsList className="grid w-full grid-cols-5">
+ <TabsTrigger value="basic" className="relative">
+ 기본 정보
+ {!tabValidation.basic.isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="contract" className="relative">
+ 계약 정보
+ {!tabValidation.contract.isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="schedule" className="relative">
+ 일정 & 회의
+ {!tabValidation.schedule.isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="details">세부내역</TabsTrigger>
+ <TabsTrigger value="manager">담당자 & 기타</TabsTrigger>
+ </TabsList>
+ </div>
+
+ <div className="flex-1 overflow-y-auto p-6">
+ {/* 기본 정보 탭 */}
+ <TabsContent value="basic" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 프로젝트 선택 */}
<FormField
control={form.control}
- name="itemName"
+ name="projectId"
render={({ field }) => (
<FormItem>
<FormLabel>
- 품목명 <span className="text-red-500">*</span>
+ 프로젝트 <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
- <Input
- placeholder="품목명"
- {...field}
+ <ProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트 선택..."
/>
</FormControl>
<FormMessage />
@@ -622,156 +640,232 @@ export function CreateBiddingDialog() {
)}
/>
- {/* 리비전 */}
+ <div className="grid grid-cols-2 gap-6">
+ {/* 품목명 */}
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 품목명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="품목명"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 리비전 */}
+ <FormField
+ control={form.control}
+ name="revision"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>리비전</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 입찰명 */}
<FormField
control={form.control}
- name="revision"
+ name="title"
render={({ field }) => (
<FormItem>
- <FormLabel>리비전</FormLabel>
+ <FormLabel>
+ 입찰명 <span className="text-red-500">*</span>
+ </FormLabel>
<FormControl>
<Input
- type="number"
- min="0"
+ placeholder="입찰명을 입력하세요"
{...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
- </div>
-
- {/* 입찰명 */}
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 입찰명 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="입찰명을 입력하세요"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설명</FormLabel>
- <FormControl>
- <Textarea
- placeholder="입찰에 대한 설명을 입력하세요"
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
- </TabsContent>
-
- {/* 계약 정보 탭 */}
- <TabsContent value="contract" className="mt-0 space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>계약 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- {/* 계약구분 */}
+
+ {/* 설명 */}
<FormField
control={form.control}
- name="contractType"
+ name="description"
render={({ field }) => (
<FormItem>
- <FormLabel>
- 계약구분 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="계약구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {Object.entries(contractTypeLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="입찰에 대한 설명을 입력하세요"
+ rows={4}
+ {...field}
+ />
+ </FormControl>
<FormMessage />
</FormItem>
)}
/>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 계약 정보 탭 */}
+ <TabsContent value="contract" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>계약 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ {/* 계약구분 */}
+ <FormField
+ control={form.control}
+ name="contractType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 계약구분 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(contractTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 입찰유형 */}
+ <FormField
+ control={form.control}
+ name="biddingType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 입찰유형 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입찰유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
- {/* 입찰유형 */}
- <FormField
- control={form.control}
- name="biddingType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 입찰유형 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <div className="grid grid-cols-2 gap-6">
+ {/* 낙찰수 */}
+ <FormField
+ control={form.control}
+ name="awardCount"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 낙찰수 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="낙찰수 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(awardCountLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약기간 */}
+ <FormField
+ control={form.control}
+ name="contractPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 계약기간 <span className="text-red-500">*</span>
+ </FormLabel>
<FormControl>
- <SelectTrigger>
- <SelectValue placeholder="입찰유형 선택" />
- </SelectTrigger>
+ <Input
+ placeholder="예: 계약일로부터 60일"
+ {...field}
+ />
</FormControl>
- <SelectContent>
- {Object.entries(biddingTypeLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <div className="grid grid-cols-2 gap-6">
- {/* 낙찰수 */}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader>
+ <CardTitle>가격 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 통화 */}
<FormField
control={form.control}
- name="awardCount"
+ name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>
- 낙찰수 <span className="text-red-500">*</span>
+ 통화 <span className="text-red-500">*</span>
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="낙찰수 선택" />
+ <SelectValue placeholder="통화 선택" />
</SelectTrigger>
</FormControl>
<SelectContent>
- {Object.entries(awardCountLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
+ <SelectItem value="KRW">KRW (원)</SelectItem>
+ <SelectItem value="USD">USD (달러)</SelectItem>
+ <SelectItem value="EUR">EUR (유로)</SelectItem>
+ <SelectItem value="JPY">JPY (엔)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -779,684 +873,688 @@ export function CreateBiddingDialog() {
)}
/>
- {/* 계약기간 */}
- <FormField
- control={form.control}
- name="contractPeriod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 계약기간 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="예: 계약일로부터 60일"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader>
- <CardTitle>가격 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 통화 */}
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 통화 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="KRW">KRW (원)</SelectItem>
- <SelectItem value="USD">USD (달러)</SelectItem>
- <SelectItem value="EUR">EUR (유로)</SelectItem>
- <SelectItem value="JPY">JPY (엔)</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-3 gap-6">
- {/* 예산 */}
- <FormField
- control={form.control}
- name="budget"
- render={({ field }) => (
- <FormItem>
- <FormLabel>예산</FormLabel>
- <FormControl>
- <Input
- type="number"
- step="0.01"
- placeholder="0"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 내정가 */}
+ <div className="grid grid-cols-3 gap-6">
+ {/* 예산 */}
+ <FormField
+ control={form.control}
+ name="budget"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>예산</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.01"
+ placeholder="0"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 내정가 */}
+ <FormField
+ control={form.control}
+ name="targetPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>내정가</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.01"
+ placeholder="0"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 최종입찰가 */}
+ <FormField
+ control={form.control}
+ name="finalBidPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>최종입찰가</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.01"
+ placeholder="0"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 일정 & 회의 탭 */}
+ <TabsContent value="schedule" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>일정 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ {/* 제출시작일시 */}
+ <FormField
+ control={form.control}
+ name="submissionStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 제출시작일시 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="datetime-local"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 제출마감일시 */}
+ <FormField
+ control={form.control}
+ name="submissionEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 제출마감일시 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="datetime-local"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 사양설명회 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>사양설명회</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
<FormField
control={form.control}
- name="targetPrice"
+ name="hasSpecificationMeeting"
render={({ field }) => (
- <FormItem>
- <FormLabel>내정가</FormLabel>
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">
+ 사양설명회 실시
+ </FormLabel>
+ <FormDescription>
+ 사양설명회를 실시할 경우 상세 정보를 입력하세요
+ </FormDescription>
+ </div>
<FormControl>
- <Input
- type="number"
- step="0.01"
- placeholder="0"
- {...field}
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
/>
</FormControl>
- <FormMessage />
</FormItem>
)}
/>
- {/* 최종입찰가 */}
- <FormField
- control={form.control}
- name="finalBidPrice"
- render={({ field }) => (
- <FormItem>
- <FormLabel>최종입찰가</FormLabel>
- <FormControl>
- <Input
- type="number"
- step="0.01"
- placeholder="0"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </CardContent>
- </Card>
- </TabsContent>
-
- {/* 일정 & 회의 탭 */}
- <TabsContent value="schedule" className="mt-0 space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>일정 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- {/* 제출시작일시 */}
- <FormField
- control={form.control}
- name="submissionStartDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 제출시작일시 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
+ {/* 사양설명회 정보 (조건부 표시) */}
+ {form.watch("hasSpecificationMeeting") && (
+ <div className="space-y-6 p-4 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-sm font-medium">
+ 회의일시 <span className="text-red-500">*</span>
+ </label>
<Input
type="datetime-local"
- {...field}
+ value={specMeetingInfo.meetingDate}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingDate: e.target.value }))}
+ className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
/>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 제출마감일시 */}
- <FormField
- control={form.control}
- name="submissionEndDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 제출마감일시 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
+ {!specMeetingInfo.meetingDate && (
+ <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <label className="text-sm font-medium">회의시간</label>
<Input
- type="datetime-local"
- {...field}
+ placeholder="예: 14:00 ~ 16:00"
+ value={specMeetingInfo.meetingTime}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingTime: e.target.value }))}
/>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </CardContent>
- </Card>
-
- {/* 사양설명회 */}
- <Card>
- <CardHeader>
- <CardTitle>사양설명회</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="hasSpecificationMeeting"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">
- 사양설명회 실시
- </FormLabel>
- <FormDescription>
- 사양설명회를 실시할 경우 상세 정보를 입력하세요
- </FormDescription>
+ </div>
</div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- {/* 사양설명회 정보 (조건부 표시) */}
- {form.watch("hasSpecificationMeeting") && (
- <div className="space-y-6 p-4 border rounded-lg bg-muted/50">
- <div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">
- 회의일시 <span className="text-red-500">*</span>
+ 장소 <span className="text-red-500">*</span>
</label>
<Input
- type="datetime-local"
- value={specMeetingInfo.meetingDate}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingDate: e.target.value }))}
- className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
+ placeholder="회의 장소"
+ value={specMeetingInfo.location}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, location: e.target.value }))}
+ className={!specMeetingInfo.location ? 'border-red-200' : ''}
/>
- {!specMeetingInfo.meetingDate && (
- <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
+ {!specMeetingInfo.location && (
+ <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p>
)}
</div>
- <div>
- <label className="text-sm font-medium">회의시간</label>
- <Input
- placeholder="예: 14:00 ~ 16:00"
- value={specMeetingInfo.meetingTime}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingTime: e.target.value }))}
- />
- </div>
- </div>
-
- <div>
- <label className="text-sm font-medium">
- 장소 <span className="text-red-500">*</span>
- </label>
- <Input
- placeholder="회의 장소"
- value={specMeetingInfo.location}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, location: e.target.value }))}
- className={!specMeetingInfo.location ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.location && (
- <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p>
- )}
- </div>
- <div>
- <label className="text-sm font-medium">주소</label>
- <Textarea
- placeholder="상세 주소"
- value={specMeetingInfo.address}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, address: e.target.value }))}
- />
- </div>
-
- <div className="grid grid-cols-3 gap-4">
<div>
- <label className="text-sm font-medium">
- 담당자 <span className="text-red-500">*</span>
- </label>
- <Input
- placeholder="담당자명"
- value={specMeetingInfo.contactPerson}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPerson: e.target.value }))}
- className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.contactPerson && (
- <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p>
- )}
- </div>
- <div>
- <label className="text-sm font-medium">연락처</label>
- <Input
- placeholder="전화번호"
- value={specMeetingInfo.contactPhone}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPhone: e.target.value }))}
+ <label className="text-sm font-medium">주소</label>
+ <Textarea
+ placeholder="상세 주소"
+ value={specMeetingInfo.address}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, address: e.target.value }))}
/>
</div>
- <div>
- <label className="text-sm font-medium">이메일</label>
- <Input
- type="email"
- placeholder="이메일"
- value={specMeetingInfo.contactEmail}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactEmail: e.target.value }))}
- />
+
+ <div className="grid grid-cols-3 gap-4">
+ <div>
+ <label className="text-sm font-medium">
+ 담당자 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ placeholder="담당자명"
+ value={specMeetingInfo.contactPerson}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPerson: e.target.value }))}
+ className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.contactPerson && (
+ <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <label className="text-sm font-medium">연락처</label>
+ <Input
+ placeholder="전화번호"
+ value={specMeetingInfo.contactPhone}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPhone: e.target.value }))}
+ />
+ </div>
+ <div>
+ <label className="text-sm font-medium">이메일</label>
+ <Input
+ type="email"
+ placeholder="이메일"
+ value={specMeetingInfo.contactEmail}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactEmail: e.target.value }))}
+ />
+ </div>
</div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div>
- <label className="text-sm font-medium">회의 안건</label>
- <Textarea
- placeholder="회의 안건"
- value={specMeetingInfo.agenda}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, agenda: e.target.value }))}
- />
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-sm font-medium">회의 안건</label>
+ <Textarea
+ placeholder="회의 안건"
+ value={specMeetingInfo.agenda}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, agenda: e.target.value }))}
+ />
+ </div>
+ <div>
+ <label className="text-sm font-medium">준비물 & 특이사항</label>
+ <Textarea
+ placeholder="준비물 및 특이사항"
+ value={specMeetingInfo.materials}
+ onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, materials: e.target.value }))}
+ />
+ </div>
</div>
- <div>
- <label className="text-sm font-medium">준비물 & 특이사항</label>
- <Textarea
- placeholder="준비물 및 특이사항"
- value={specMeetingInfo.materials}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, materials: e.target.value }))}
+
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="required-meeting"
+ checked={specMeetingInfo.isRequired}
+ onCheckedChange={(checked) => setSpecMeetingInfo(prev => ({ ...prev, isRequired: checked }))}
/>
+ <label htmlFor="required-meeting" className="text-sm font-medium">
+ 필수 참석
+ </label>
</div>
- </div>
- <div className="flex items-center space-x-2">
- <Switch
- id="required-meeting"
- checked={specMeetingInfo.isRequired}
- onCheckedChange={(checked) => setSpecMeetingInfo(prev => ({ ...prev, isRequired: checked }))}
- />
- <label htmlFor="required-meeting" className="text-sm font-medium">
- 필수 참석
- </label>
- </div>
-
- {/* 사양설명회 첨부 파일 */}
- <div className="space-y-4">
- <label className="text-sm font-medium">사양설명회 관련 첨부 파일</label>
- <Dropzone
- onDrop={addMeetingFiles}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- 'image/*': ['.png', '.jpg', '.jpeg'],
- }}
- multiple
- className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors"
- >
- <DropzoneZone>
- <DropzoneUploadIcon />
- <DropzoneTitle>사양설명회 관련 문서 업로드</DropzoneTitle>
- <DropzoneDescription>
- 안내문, 도면, 자료 등을 업로드하세요 (PDF, Word, Excel, 이미지 파일 지원)
- </DropzoneDescription>
- </DropzoneZone>
- <DropzoneInput />
- </Dropzone>
-
- {specMeetingInfo.meetingFiles.length > 0 && (
- <FileList className="mt-4">
- <FileListHeader>
- <span>업로드된 파일 ({specMeetingInfo.meetingFiles.length})</span>
- </FileListHeader>
- {specMeetingInfo.meetingFiles.map((file, fileIndex) => (
- <FileListItem key={fileIndex}>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{file.name}</FileListName>
- <FileListSize>{file.size}</FileListSize>
- </FileListInfo>
- <FileListAction>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => removeMeetingFile(fileIndex)}
- >
- 삭제
- </Button>
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- )}
+ {/* 사양설명회 첨부 파일 */}
+ <div className="space-y-4">
+ <label className="text-sm font-medium">사양설명회 관련 첨부 파일</label>
+ <Dropzone
+ onDrop={addMeetingFiles}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'image/*': ['.png', '.jpg', '.jpeg'],
+ }}
+ multiple
+ className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors"
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon />
+ <DropzoneTitle>사양설명회 관련 문서 업로드</DropzoneTitle>
+ <DropzoneDescription>
+ 안내문, 도면, 자료 등을 업로드하세요 (PDF, Word, Excel, 이미지 파일 지원)
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
+ </Dropzone>
+
+ {specMeetingInfo.meetingFiles.length > 0 && (
+ <FileList className="mt-4">
+ <FileListHeader>
+ <span>업로드된 파일 ({specMeetingInfo.meetingFiles.length})</span>
+ </FileListHeader>
+ {specMeetingInfo.meetingFiles.map((file, fileIndex) => (
+ <FileListItem key={fileIndex}>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListSize>{file.size}</FileListSize>
+ </FileListInfo>
+ <FileListAction>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => removeMeetingFile(fileIndex)}
+ >
+ 삭제
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ )}
+ </div>
</div>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 세부내역 탭 */}
+ <TabsContent value="details" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle>세부내역 관리</CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ PR 아이템 또는 수기 아이템을 추가하여 입찰 세부내역을 관리하세요
+ </p>
</div>
- )}
- </CardContent>
- </Card>
- </TabsContent>
-
- {/* 세부내역 탭 */}
- <TabsContent value="details" className="mt-0 space-y-6">
- <Card>
- <CardHeader className="flex flex-row items-center justify-between">
- <div>
- <CardTitle>세부내역 관리</CardTitle>
- <p className="text-sm text-muted-foreground mt-1">
- PR 아이템 또는 수기 아이템을 추가하여 입찰 세부내역을 관리하세요
- </p>
- </div>
- <Button
- type="button"
- variant="outline"
- onClick={addPRItem}
- className="flex items-center gap-2"
- >
- <Plus className="h-4 w-4" />
- 아이템 추가
- </Button>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 아이템 테이블 */}
- {prItems.length > 0 ? (
- <div className="space-y-4">
- <div className="border rounded-lg">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[60px]">대표</TableHead>
- <TableHead className="w-[120px]">PR 번호</TableHead>
- <TableHead className="w-[120px]">품목코드</TableHead>
- <TableHead>품목정보</TableHead>
- <TableHead className="w-[80px]">수량</TableHead>
- <TableHead className="w-[80px]">단위</TableHead>
- <TableHead className="w-[140px]">납품요청일</TableHead>
- <TableHead className="w-[80px]">스펙파일</TableHead>
- <TableHead className="w-[80px]">액션</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {prItems.map((item, index) => (
- <TableRow key={item.id}>
- <TableCell>
- <div className="flex justify-center">
- <Checkbox
- checked={item.isRepresentative}
- onCheckedChange={() => setRepresentativeItem(item.id)}
+ <Button
+ type="button"
+ variant="outline"
+ onClick={addPRItem}
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 아이템 추가
+ </Button>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 아이템 테이블 */}
+ {prItems.length > 0 ? (
+ <div className="space-y-4">
+ <div className="border rounded-lg">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[60px]">대표</TableHead>
+ <TableHead className="w-[120px]">PR 번호</TableHead>
+ <TableHead className="w-[120px]">품목코드</TableHead>
+ <TableHead>품목정보</TableHead>
+ <TableHead className="w-[80px]">수량</TableHead>
+ <TableHead className="w-[80px]">단위</TableHead>
+ <TableHead className="w-[140px]">납품요청일</TableHead>
+ <TableHead className="w-[80px]">스펙파일</TableHead>
+ <TableHead className="w-[80px]">액션</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {prItems.map((item, index) => (
+ <TableRow key={item.id}>
+ <TableCell>
+ <div className="flex justify-center">
+ <Checkbox
+ checked={item.isRepresentative}
+ onCheckedChange={() => setRepresentativeItem(item.id)}
+ />
+ </div>
+ </TableCell>
+ <TableCell>
+ <Input
+ placeholder="PR 번호"
+ value={item.prNumber}
+ onChange={(e) => updatePRItem(item.id, { prNumber: e.target.value })}
+ className="h-8"
/>
- </div>
- </TableCell>
- <TableCell>
- <Input
- placeholder="PR 번호"
- value={item.prNumber}
- onChange={(e) => updatePRItem(item.id, { prNumber: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Input
- placeholder={`ITEM-${index + 1}`}
- value={item.itemCode}
- onChange={(e) => updatePRItem(item.id, { itemCode: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Input
- placeholder="품목정보"
- value={item.itemInfo}
- onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Input
- type="number"
- placeholder="수량"
- value={item.quantity}
- onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Select
- value={item.quantityUnit}
- onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
- >
- <SelectTrigger className="h-8">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="EA">EA</SelectItem>
- <SelectItem value="SET">SET</SelectItem>
- <SelectItem value="LOT">LOT</SelectItem>
- <SelectItem value="M">M</SelectItem>
- <SelectItem value="M2">M²</SelectItem>
- <SelectItem value="M3">M³</SelectItem>
- </SelectContent>
- </Select>
- </TableCell>
- <TableCell>
- <Input
- type="date"
- value={item.requestedDeliveryDate}
- onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-2">
+ </TableCell>
+ <TableCell>
+ <Input
+ placeholder={`ITEM-${index + 1}`}
+ value={item.itemCode}
+ onChange={(e) => updatePRItem(item.id, { itemCode: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ placeholder="품목정보"
+ value={item.itemInfo}
+ onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ type="number"
+ placeholder="수량"
+ value={item.quantity}
+ onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Select
+ value={item.quantityUnit}
+ onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
+ >
+ <SelectTrigger className="h-8">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EA">EA</SelectItem>
+ <SelectItem value="SET">SET</SelectItem>
+ <SelectItem value="LOT">LOT</SelectItem>
+ <SelectItem value="M">M</SelectItem>
+ <SelectItem value="M2">M²</SelectItem>
+ <SelectItem value="M3">M³</SelectItem>
+ </SelectContent>
+ </Select>
+ </TableCell>
+ <TableCell>
+ <Input
+ type="date"
+ value={item.requestedDeliveryDate}
+ onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant={selectedItemForFile === item.id ? "default" : "outline"}
+ size="sm"
+ onClick={() => setSelectedItemForFile(selectedItemForFile === item.id ? null : item.id)}
+ className="h-8 w-8 p-0"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <span className="text-sm">{item.specFiles.length}</span>
+ </div>
+ </TableCell>
+ <TableCell>
<Button
type="button"
- variant={selectedItemForFile === item.id ? "default" : "outline"}
+ variant="outline"
size="sm"
- onClick={() => setSelectedItemForFile(selectedItemForFile === item.id ? null : item.id)}
+ onClick={() => removePRItem(item.id)}
className="h-8 w-8 p-0"
>
- <Paperclip className="h-4 w-4" />
+ <Trash2 className="h-4 w-4" />
</Button>
- <span className="text-sm">{item.specFiles.length}</span>
- </div>
- </TableCell>
- <TableCell>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => removePRItem(item.id)}
- className="h-8 w-8 p-0"
- >
- <Trash2 className="h-4 w-4" />
- </Button>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
-
- {/* 대표 아이템 정보 표시 */}
- {representativePrNumber && (
- <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
- <CheckCircle2 className="h-4 w-4 text-blue-600" />
- <span className="text-sm text-blue-800">
- 대표 PR 번호: <strong>{representativePrNumber}</strong>
- </span>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
</div>
- )}
- {/* 선택된 아이템의 파일 업로드 */}
- {selectedItemForFile && (
- <div className="space-y-4 p-4 border rounded-lg bg-muted/50">
- {(() => {
- const selectedItem = prItems.find(item => item.id === selectedItemForFile)
- return (
- <>
- <div className="flex items-center justify-between">
- <h6 className="font-medium text-sm">
- {selectedItem?.itemInfo || selectedItem?.itemCode || "선택된 아이템"}의 스펙 파일
- </h6>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => setSelectedItemForFile(null)}
+ {/* 대표 아이템 정보 표시 */}
+ {representativePrNumber && (
+ <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
+ <CheckCircle2 className="h-4 w-4 text-blue-600" />
+ <span className="text-sm text-blue-800">
+ 대표 PR 번호: <strong>{representativePrNumber}</strong>
+ </span>
+ </div>
+ )}
+
+ {/* 선택된 아이템의 파일 업로드 */}
+ {selectedItemForFile && (
+ <div className="space-y-4 p-4 border rounded-lg bg-muted/50">
+ {(() => {
+ const selectedItem = prItems.find(item => item.id === selectedItemForFile)
+ return (
+ <>
+ <div className="flex items-center justify-between">
+ <h6 className="font-medium text-sm">
+ {selectedItem?.itemInfo || selectedItem?.itemCode || "선택된 아이템"}의 스펙 파일
+ </h6>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => setSelectedItemForFile(null)}
+ >
+ 닫기
+ </Button>
+ </div>
+
+ <Dropzone
+ onDrop={(files) => addSpecFiles(selectedItemForFile, files)}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ }}
+ multiple
+ className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors"
>
- 닫기
- </Button>
- </div>
-
- <Dropzone
- onDrop={(files) => addSpecFiles(selectedItemForFile, files)}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- }}
- multiple
- className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors"
- >
- <DropzoneZone>
- <DropzoneUploadIcon />
- <DropzoneTitle>스펙 문서 업로드</DropzoneTitle>
- <DropzoneDescription>
- PDF, Word, Excel 파일을 드래그하거나 클릭하여 선택
- </DropzoneDescription>
- </DropzoneZone>
- <DropzoneInput />
- </Dropzone>
-
- {selectedItem && selectedItem.specFiles.length > 0 && (
- <FileList className="mt-4">
- <FileListHeader>
- <span>업로드된 파일 ({selectedItem.specFiles.length})</span>
- </FileListHeader>
- {selectedItem.specFiles.map((file, fileIndex) => (
- <FileListItem key={fileIndex}>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{file.name}</FileListName>
- <FileListSize>{file.size}</FileListSize>
- </FileListInfo>
- <FileListAction>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => removeSpecFile(selectedItemForFile, fileIndex)}
- >
- 삭제
- </Button>
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- )}
- </>
- )
- })()}
- </div>
- )}
- </div>
- ) : (
- <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
- <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p>
- <p className="text-sm text-gray-400 mb-4">
- PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요
- </p>
- <Button
- type="button"
- variant="outline"
- onClick={addPRItem}
- className="flex items-center gap-2"
- >
- <Plus className="h-4 w-4" />
- 첫 번째 아이템 추가
- </Button>
- </div>
- )}
- </CardContent>
- </Card>
- </TabsContent>
-
- {/* 담당자 & 기타 탭 */}
- <TabsContent value="manager" className="mt-0 space-y-6">
- {/* 담당자 정보 */}
- <Card>
- <CardHeader>
- <CardTitle>담당자 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="managerName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자명</FormLabel>
- <FormControl>
- <Input
- placeholder="담당자명"
- {...field}
- />
- </FormControl>
- <FormDescription>
- 현재 로그인한 사용자 정보로 자동 설정됩니다.
- </FormDescription>
- <FormMessage />
- </FormItem>
+ <DropzoneZone>
+ <DropzoneUploadIcon />
+ <DropzoneTitle>스펙 문서 업로드</DropzoneTitle>
+ <DropzoneDescription>
+ PDF, Word, Excel 파일을 드래그하거나 클릭하여 선택
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
+ </Dropzone>
+
+ {selectedItem && selectedItem.specFiles.length > 0 && (
+ <FileList className="mt-4">
+ <FileListHeader>
+ <span>업로드된 파일 ({selectedItem.specFiles.length})</span>
+ </FileListHeader>
+ {selectedItem.specFiles.map((file, fileIndex) => (
+ <FileListItem
+ key={fileIndex}
+ className="flex items-center justify-between p-3 border rounded-lg mb-2"
+ >
+ <div className="flex items-center gap-3 flex-1">
+ <FileListIcon className="flex-shrink-0" />
+ <FileListInfo className="flex items-center gap-3 flex-1">
+ <FileListName className="font-medium text-gray-700">
+ {file.name}
+ </FileListName>
+ <FileListSize className="text-sm text-gray-500">
+ {file.size}
+ </FileListSize>
+ </FileListInfo>
+ </div>
+ <FileListAction className="flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => removeSpecFile(selectedItemForFile, fileIndex)}
+ >
+ 삭제
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ )}
+ </>
+ )
+ })()}
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
+ <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p>
+ <p className="text-sm text-gray-400 mb-4">
+ PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={addPRItem}
+ className="flex items-center gap-2 mx-auto"
+ >
+ <Plus className="h-4 w-4" />
+ 첫 번째 아이템 추가
+ </Button>
+ </div>
)}
- />
-
- <div className="grid grid-cols-2 gap-6">
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 담당자 & 기타 탭 */}
+ <TabsContent value="manager" className="mt-0 space-y-6">
+ {/* 담당자 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>담당자 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
<FormField
control={form.control}
- name="managerEmail"
+ name="managerName"
render={({ field }) => (
<FormItem>
- <FormLabel>담당자 이메일</FormLabel>
+ <FormLabel>담당자명</FormLabel>
<FormControl>
<Input
- type="email"
- placeholder="email@example.com"
+ placeholder="담당자명"
{...field}
/>
</FormControl>
+ <FormDescription>
+ 현재 로그인한 사용자 정보로 자동 설정됩니다.
+ </FormDescription>
<FormMessage />
</FormItem>
)}
/>
+ <div className="grid grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="managerEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자 이메일</FormLabel>
+ <FormControl>
+ <Input
+ type="email"
+ placeholder="email@example.com"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="managerPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자 전화번호</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="010-1234-5678"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 기타 설정 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>기타 설정</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <FormField
+ control={form.control}
+ name="isPublic"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">
+ 공개 입찰
+ </FormLabel>
+ <FormDescription>
+ 공개 입찰 여부를 설정합니다
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
<FormField
control={form.control}
- name="managerPhone"
+ name="remarks"
render={({ field }) => (
<FormItem>
- <FormLabel>담당자 전화번호</FormLabel>
+ <FormLabel>비고</FormLabel>
<FormControl>
- <Input
- placeholder="010-1234-5678"
+ <Textarea
+ placeholder="추가 메모나 특이사항을 입력하세요"
+ rows={4}
{...field}
/>
</FormControl>
@@ -1464,211 +1562,183 @@ export function CreateBiddingDialog() {
</FormItem>
)}
/>
- </div>
- </CardContent>
- </Card>
-
- {/* 기타 설정 */}
- <Card>
- <CardHeader>
- <CardTitle>기타 설정</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="isPublic"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">
- 공개 입찰
- </FormLabel>
- <FormDescription>
- 공개 입찰 여부를 설정합니다
- </FormDescription>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="remarks"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea
- placeholder="추가 메모나 특이사항을 입력하세요"
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
-
- {/* 입찰 생성 요약 */}
- <Card>
- <CardHeader>
- <CardTitle>입찰 생성 요약</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">프로젝트:</span>
- <p className="text-muted-foreground">
- {form.watch("projectName") || "선택되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">입찰명:</span>
- <p className="text-muted-foreground">
- {form.watch("title") || "입력되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">계약구분:</span>
- <p className="text-muted-foreground">
- {contractTypeLabels[form.watch("contractType") as keyof typeof contractTypeLabels] || "선택되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">입찰유형:</span>
- <p className="text-muted-foreground">
- {biddingTypeLabels[form.watch("biddingType") as keyof typeof biddingTypeLabels] || "선택되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">사양설명회:</span>
- <p className="text-muted-foreground">
- {form.watch("hasSpecificationMeeting") ? "실시함" : "실시하지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">대표 PR 번호:</span>
- <p className="text-muted-foreground">
- {representativePrNumber || "설정되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">세부 아이템:</span>
- <p className="text-muted-foreground">
- {prItems.length}개 아이템
- </p>
- </div>
- <div>
- <span className="font-medium">사양설명회 파일:</span>
- <p className="text-muted-foreground">
- {specMeetingInfo.meetingFiles.length}개 파일
- </p>
+ </CardContent>
+ </Card>
+
+ {/* 입찰 생성 요약 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰 생성 요약</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">프로젝트:</span>
+ <p className="text-muted-foreground">
+ {form.watch("projectName") || "선택되지 않음"}
+ </p>
+ </div>
+ <div>
+ <span className="font-medium">입찰명:</span>
+ <p className="text-muted-foreground">
+ {form.watch("title") || "입력되지 않음"}
+ </p>
+ </div>
+ <div>
+ <span className="font-medium">계약구분:</span>
+ <p className="text-muted-foreground">
+ {contractTypeLabels[form.watch("contractType") as keyof typeof contractTypeLabels] || "선택되지 않음"}
+ </p>
+ </div>
+ <div>
+ <span className="font-medium">입찰유형:</span>
+ <p className="text-muted-foreground">
+ {biddingTypeLabels[form.watch("biddingType") as keyof typeof biddingTypeLabels] || "선택되지 않음"}
+ </p>
+ </div>
+ <div>
+ <span className="font-medium">사양설명회:</span>
+ <p className="text-muted-foreground">
+ {form.watch("hasSpecificationMeeting") ? "실시함" : "실시하지 않음"}
+ </p>
+ </div>
+ <div>
+ <span className="font-medium">대표 PR 번호:</span>
+ <p className="text-muted-foreground">
+ {representativePrNumber || "설정되지 않음"}
+ </p>
+ </div>
+ <div>
+ <span className="font-medium">세부 아이템:</span>
+ <p className="text-muted-foreground">
+ {prItems.length}개 아이템
+ </p>
+ </div>
+ <div>
+ <span className="font-medium">사양설명회 파일:</span>
+ <p className="text-muted-foreground">
+ {specMeetingInfo.meetingFiles.length}개 파일
+ </p>
+ </div>
</div>
- </div>
- </CardContent>
- </Card>
- </TabsContent>
+ </CardContent>
+ </Card>
+ </TabsContent>
- </div>
- </Tabs>
- </div>
-
- {/* 고정 버튼 영역 */}
- <div className="flex-shrink-0 border-t bg-background p-6">
- <div className="flex justify-between items-center">
- <div className="text-sm text-muted-foreground">
- {activeTab === "basic" && (
- <span>
- 기본 정보를 입력하세요
- {!tabValidation.basic.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "contract" && (
- <span>
- 계약 및 가격 정보를 입력하세요
- {!tabValidation.contract.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "schedule" && (
- <span>
- 일정 및 사양설명회 정보를 입력하세요
- {!tabValidation.schedule.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"}
- {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"}
- </div>
+ </div>
+ </Tabs>
+ </div>
+
+ {/* 고정 버튼 영역 */}
+ <div className="flex-shrink-0 border-t bg-background p-6">
+ <div className="flex justify-between items-center">
+ <div className="text-sm text-muted-foreground">
+ {activeTab === "basic" && (
+ <span>
+ 기본 정보를 입력하세요
+ {!tabValidation.basic.isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </span>
+ )}
+ {activeTab === "contract" && (
+ <span>
+ 계약 및 가격 정보를 입력하세요
+ {!tabValidation.contract.isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </span>
+ )}
+ {activeTab === "schedule" && (
+ <span>
+ 일정 및 사양설명회 정보를 입력하세요
+ {!tabValidation.schedule.isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </span>
+ )}
+ {activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"}
+ {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"}
+ </div>
- <div className="flex gap-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => {
- resetAllStates()
- setOpen(false)
- }}
- disabled={isSubmitting}
- >
- 취소
- </Button>
-
- {/* 이전 버튼 (첫 번째 탭이 아닐 때) */}
- {!isFirstTab && (
+ <div className="flex gap-3">
<Button
type="button"
variant="outline"
- onClick={goToPreviousTab}
+ onClick={() => {
+ resetAllStates()
+ setOpen(false)
+ }}
disabled={isSubmitting}
- className="flex items-center gap-2"
>
- <ChevronLeft className="h-4 w-4" />
- 이전
+ 취소
</Button>
- )}
- {/* 다음/생성 버튼 */}
- {isLastTab ? (
- // 마지막 탭: 입찰 생성 버튼 (submit)
- <Button
- type="submit"
- disabled={isSubmitting}
- className="flex items-center gap-2"
- >
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 입찰 생성
- </Button>
- ) : (
- // 이전 탭들: 다음 버튼 (일반 버튼)
- <Button
- type="button"
- onClick={handleNextClick}
- disabled={isSubmitting}
- className="flex items-center gap-2"
- >
- 다음
- <ChevronRight className="h-4 w-4" />
- </Button>
- )}
+ {/* 이전 버튼 (첫 번째 탭이 아닐 때) */}
+ {!isFirstTab && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={goToPreviousTab}
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ <ChevronLeft className="h-4 w-4" />
+ 이전
+ </Button>
+ )}
+
+ {/* 다음/생성 버튼 */}
+ {isLastTab ? (
+ // 마지막 탭: 입찰 생성 버튼 (type="button"으로 변경)
+ <Button
+ type="button"
+ onClick={handleCreateBidding}
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 입찰 생성
+ </Button>
+ ) : (
+ // 이전 탭들: 다음 버튼
+ <Button
+ type="button"
+ onClick={handleNextClick}
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ 다음
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
</div>
</div>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+
+ <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle>
+ <AlertDialogDescription>
+ 생성된 입찰의 상세페이지로 이동하시겠습니까?
+ 아니면 현재 페이지에 남아있으시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={handleStayOnPage}>
+ 현재 페이지에 남기
+ </AlertDialogCancel>
+ <AlertDialogAction onClick={handleNavigateToDetail}>
+ 상세페이지로 이동
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
)
} \ No newline at end of file
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 91fea75e..5d384476 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -8,7 +8,8 @@ import {
projects,
biddingDocuments,
prItemsForBidding,
- specificationMeetings
+ specificationMeetings,
+ prDocuments
} from '@/db/schema'
import {
eq,
@@ -21,7 +22,7 @@ import {
ilike,
gte,
lte,
- SQL
+ SQL, like
} from 'drizzle-orm'
import { revalidatePath } from 'next/cache'
import { BiddingListItem } from '@/db/schema'
@@ -91,6 +92,9 @@ export async function getBiddings(input: GetBiddingsSchema) {
try {
const offset = (input.page - 1) * input.perPage
+ console.log(input.filters)
+ console.log(input.sort)
+
// ✅ 1) 고급 필터 조건
let advancedWhere: SQL<unknown> | undefined = undefined
if (input.filters && input.filters.length > 0) {
@@ -378,7 +382,7 @@ export interface UpdateBiddingInput extends UpdateBiddingSchema {
}
// 자동 입찰번호 생성
-async function generateBiddingNumber(biddingType: string): Promise<string> {
+async function generateBiddingNumber(biddingType: string, tx?: any, maxRetries: number = 5): Promise<string> {
const year = new Date().getFullYear()
const typePrefix = {
'equipment': 'EQ',
@@ -392,22 +396,44 @@ async function generateBiddingNumber(biddingType: string): Promise<string> {
'sale': 'SL'
}[biddingType] || 'GN'
- // 해당 연도의 마지막 번호 조회
- const lastBidding = await db
- .select({ biddingNumber: biddings.biddingNumber })
- .from(biddings)
- .where(eq(biddings.biddingNumber, `${year}${typePrefix}%`))
- .orderBy(biddings.biddingNumber)
- .limit(1)
-
- let sequence = 1
- if (lastBidding.length > 0) {
- const lastNumber = lastBidding[0].biddingNumber
- const lastSequence = parseInt(lastNumber.slice(-4))
- sequence = lastSequence + 1
+ const dbInstance = tx || db
+ const prefix = `${year}${typePrefix}`
+
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ // 현재 최대 시퀀스 번호 조회
+ const result = await dbInstance
+ .select({
+ maxNumber: sql<string>`MAX(${biddings.biddingNumber})`
+ })
+ .from(biddings)
+ .where(like(biddings.biddingNumber, `${prefix}%`))
+
+ let sequence = 1
+ if (result[0]?.maxNumber) {
+ const lastSequence = parseInt(result[0].maxNumber.slice(-4))
+ if (!isNaN(lastSequence)) {
+ sequence = lastSequence + 1
+ }
+ }
+
+ const biddingNumber = `${prefix}${sequence.toString().padStart(4, '0')}`
+
+ // 중복 확인
+ const existing = await dbInstance
+ .select({ id: biddings.id })
+ .from(biddings)
+ .where(eq(biddings.biddingNumber, biddingNumber))
+ .limit(1)
+
+ if (existing.length === 0) {
+ return biddingNumber
+ }
+
+ // 중복이 발견되면 잠시 대기 후 재시도
+ await new Promise(resolve => setTimeout(resolve, 10 + Math.random() * 20))
}
- return `${year}${typePrefix}${sequence.toString().padStart(4, '0')}`
+ throw new Error(`Failed to generate unique bidding number after ${maxRetries} attempts`)
}
// 입찰 생성
@@ -419,7 +445,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
// 프로젝트 정보 조회
let projectName = input.projectName
- if (input.projectId && !projectName) {
+ if (input.projectId) {
const project = await tx
.select({ code: projects.code, name: projects.name })
.from(projects)
@@ -549,8 +575,8 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
originalFileName: saveResult.originalName!,
fileSize: saveResult.fileSize!,
mimeType: file.type,
- filePath: saveResult.filePath!,
- publicPath: saveResult.publicPath,
+ filePath: saveResult.publicPath!,
+ // publicPath: saveResult.publicPath,
title: `사양설명회 - ${file.name}`,
isPublic: false,
isRequired: false,
@@ -606,13 +632,13 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
await tx.insert(biddingDocuments).values({
biddingId,
prItemId: newPrItem.id,
- documentType: 'spec',
+ documentType: 'spec_document',
fileName: saveResult.fileName!,
originalFileName: saveResult.originalName!,
fileSize: saveResult.fileSize!,
mimeType: file.type,
- filePath: saveResult.filePath!,
- publicPath: saveResult.publicPath,
+ filePath: saveResult.publicPath!,
+ // publicPath: saveResult.publicPath,
title: `${prItem.itemInfo || prItem.itemCode} 스펙 - ${file.name}`,
description: `PR ${prItem.prNumber}의 스펙 문서`,
isPublic: false,
@@ -813,3 +839,355 @@ export async function getBiddingById(id: number) {
return null
}
}
+
+// 공통 결과 타입
+interface ActionResult<T> {
+ success: boolean
+ data?: T
+ error?: string
+}
+
+// 사양설명회 상세 정보 타입
+export interface SpecificationMeetingDetails {
+ id: number
+ biddingId: number
+ meetingDate: string
+ meetingTime?: string | null
+ location: string
+ address?: string | null
+ contactPerson: string
+ contactPhone?: string | null
+ contactEmail?: string | null
+ agenda?: string | null
+ materials?: string | null
+ notes?: string | null
+ isRequired: boolean
+ createdAt: string
+ updatedAt: string
+ documents: Array<{
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ filePath: string
+ title?: string | null
+ uploadedAt: string
+ uploadedBy?: string | null
+ }>
+}
+
+// PR 상세 정보 타입
+export interface PRDetails {
+ documents: Array<{
+ id: number
+ documentName: string
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ filePath: string
+ registeredAt: string
+ registeredBy: string
+ version?: string | null
+ description?: string | null
+ createdAt: string
+ updatedAt: string
+ }>
+ items: Array<{
+ id: number
+ itemNumber?: string | null
+ itemInfo: string
+ quantity?: number | null
+ quantityUnit?: string | null
+ requestedDeliveryDate?: string | null
+ prNumber?: string | null
+ annualUnitPrice?: number | null
+ currency: string
+ totalWeight?: number | null
+ weightUnit?: string | null
+ materialDescription?: string | null
+ hasSpecDocument: boolean
+ createdAt: string
+ updatedAt: string
+ specDocuments: Array<{
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ filePath: string
+ uploadedAt: string
+ title?: string | null
+ }>
+ }>
+}
+
+/**
+ * 사양설명회 상세 정보 조회 서버 액션
+ */
+export async function getSpecificationMeetingDetailsAction(
+ biddingId: number
+): Promise<ActionResult<SpecificationMeetingDetails>> {
+ try {
+ // 1. 입력 검증
+ if (!biddingId || isNaN(biddingId) || biddingId <= 0) {
+ return {
+ success: false,
+ error: "유효하지 않은 입찰 ID입니다"
+ }
+ }
+
+ // 2. 사양설명회 기본 정보 조회
+ const meeting = await db
+ .select()
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ .limit(1)
+
+ if (meeting.length === 0) {
+ return {
+ success: false,
+ error: "사양설명회 정보를 찾을 수 없습니다"
+ }
+ }
+
+ const meetingData = meeting[0]
+
+ // 3. 관련 문서들 조회
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ uploadedAt: biddingDocuments.uploadedAt,
+ uploadedBy: biddingDocuments.uploadedBy,
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.documentType, 'specification_meeting'),
+ eq(biddingDocuments.specificationMeetingId, meetingData.id)
+ )
+ )
+
+ // 4. 데이터 직렬화 (Date 객체를 문자열로 변환)
+ const result: SpecificationMeetingDetails = {
+ id: meetingData.id,
+ biddingId: meetingData.biddingId,
+ meetingDate: meetingData.meetingDate?.toISOString() || '',
+ meetingTime: meetingData.meetingTime,
+ location: meetingData.location,
+ address: meetingData.address,
+ contactPerson: meetingData.contactPerson,
+ contactPhone: meetingData.contactPhone,
+ contactEmail: meetingData.contactEmail,
+ agenda: meetingData.agenda,
+ materials: meetingData.materials,
+ notes: meetingData.notes,
+ isRequired: meetingData.isRequired,
+ createdAt: meetingData.createdAt?.toISOString() || '',
+ updatedAt: meetingData.updatedAt?.toISOString() || '',
+ documents: documents.map(doc => ({
+ id: doc.id,
+ fileName: doc.fileName,
+ originalFileName: doc.originalFileName,
+ fileSize: doc.fileSize || 0,
+ filePath: doc.filePath,
+ title: doc.title,
+ uploadedAt: doc.uploadedAt?.toISOString() || '',
+ uploadedBy: doc.uploadedBy,
+ }))
+ }
+
+ return {
+ success: true,
+ data: result
+ }
+
+ } catch (error) {
+ console.error("사양설명회 상세 정보 조회 실패:", error)
+ return {
+ success: false,
+ error: "사양설명회 정보 조회 중 오류가 발생했습니다"
+ }
+ }
+}
+
+/**
+ * PR 상세 정보 조회 서버 액션
+ */
+export async function getPRDetailsAction(
+ biddingId: number
+): Promise<ActionResult<PRDetails>> {
+ try {
+ // 1. 입력 검증
+ if (!biddingId || isNaN(biddingId) || biddingId <= 0) {
+ return {
+ success: false,
+ error: "유효하지 않은 입찰 ID입니다"
+ }
+ }
+
+ // 2. PR 문서들 조회
+ const documents = await db
+ .select({
+ id: prDocuments.id,
+ documentName: prDocuments.documentName,
+ fileName: prDocuments.fileName,
+ originalFileName: prDocuments.originalFileName,
+ fileSize: prDocuments.fileSize,
+ filePath: prDocuments.filePath,
+ registeredAt: prDocuments.registeredAt,
+ registeredBy: prDocuments.registeredBy,
+ version: prDocuments.version,
+ description: prDocuments.description,
+ createdAt: prDocuments.createdAt,
+ updatedAt: prDocuments.updatedAt,
+ })
+ .from(prDocuments)
+ .where(eq(prDocuments.biddingId, biddingId))
+
+ // 3. PR 아이템들 조회
+ const items = await db
+ .select()
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ // 4. 각 아이템별 스펙 문서들 조회
+ const itemsWithDocs = await Promise.all(
+ items.map(async (item) => {
+ const specDocuments = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ uploadedAt: biddingDocuments.uploadedAt,
+ title: biddingDocuments.title,
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.documentType, 'spec_document'),
+ eq(biddingDocuments.prItemId, item.id)
+ )
+ )
+
+ // 5. 데이터 직렬화
+ return {
+ id: item.id,
+ itemNumber: item.itemNumber,
+ itemInfo: item.itemInfo,
+ quantity: item.quantity ? Number(item.quantity) : null,
+ quantityUnit: item.quantityUnit,
+ requestedDeliveryDate: item.requestedDeliveryDate?.toISOString().split('T')[0] || null,
+ prNumber: item.prNumber,
+ annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null,
+ currency: item.currency,
+ totalWeight: item.totalWeight ? Number(item.totalWeight) : null,
+ weightUnit: item.weightUnit,
+ materialDescription: item.materialDescription,
+ hasSpecDocument: item.hasSpecDocument,
+ createdAt: item.createdAt?.toISOString() || '',
+ updatedAt: item.updatedAt?.toISOString() || '',
+ specDocuments: specDocuments.map(doc => ({
+ id: doc.id,
+ fileName: doc.fileName,
+ originalFileName: doc.originalFileName,
+ fileSize: doc.fileSize || 0,
+ filePath: doc.filePath,
+ uploadedAt: doc.uploadedAt?.toISOString() || '',
+ title: doc.title,
+ }))
+ }
+ })
+ )
+
+ const result: PRDetails = {
+ documents: documents.map(doc => ({
+ id: doc.id,
+ documentName: doc.documentName,
+ fileName: doc.fileName,
+ originalFileName: doc.originalFileName,
+ fileSize: doc.fileSize || 0,
+ filePath: doc.filePath,
+ registeredAt: doc.registeredAt?.toISOString() || '',
+ registeredBy: doc.registeredBy,
+ version: doc.version,
+ description: doc.description,
+ createdAt: doc.createdAt?.toISOString() || '',
+ updatedAt: doc.updatedAt?.toISOString() || '',
+ })),
+ items: itemsWithDocs
+ }
+
+ return {
+ success: true,
+ data: result
+ }
+
+ } catch (error) {
+ console.error("PR 상세 정보 조회 실패:", error)
+ return {
+ success: false,
+ error: "PR 정보 조회 중 오류가 발생했습니다"
+ }
+ }
+}
+
+
+
+/**
+ * 입찰 기본 정보 조회 서버 액션 (선택사항)
+ */
+export async function getBiddingBasicInfoAction(
+ biddingId: number
+): Promise<ActionResult<{
+ id: number
+ title: string
+ hasSpecificationMeeting: boolean
+ hasPrDocument: boolean
+}>> {
+ try {
+ if (!biddingId || isNaN(biddingId) || biddingId <= 0) {
+ return {
+ success: false,
+ error: "유효하지 않은 입찰 ID입니다"
+ }
+ }
+
+ // 간단한 입찰 정보만 조회 (성능 최적화)
+ const bidding = await db.query.biddings.findFirst({
+ where: (biddings, { eq }) => eq(biddings.id, biddingId),
+ columns: {
+ id: true,
+ title: true,
+ hasSpecificationMeeting: true,
+ hasPrDocument: true,
+ }
+ })
+
+ if (!bidding) {
+ return {
+ success: false,
+ error: "입찰 정보를 찾을 수 없습니다"
+ }
+ }
+
+ return {
+ success: true,
+ data: bidding
+ }
+
+ } catch (error) {
+ console.error("입찰 기본 정보 조회 실패:", error)
+ return {
+ success: false,
+ error: "입찰 기본 정보 조회 중 오류가 발생했습니다"
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 3d47aefe..556395b5 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -1,4 +1,4 @@
-import { biddings, type Bidding } from "@/db/schema"
+import { BiddingListView, biddings, type Bidding } from "@/db/schema"
import {
createSearchParamsCache,
parseAsArrayOf,
@@ -14,7 +14,7 @@ export const searchParamsCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<Bidding>().withDefault([
+ sort: getSortingStateParser<BiddingListView>().withDefault([
{ id: "createdAt", desc: true },
]),
diff --git a/lib/file-download.ts b/lib/file-download.ts
index 68c4677a..f78ba8f9 100644
--- a/lib/file-download.ts
+++ b/lib/file-download.ts
@@ -466,6 +466,8 @@ export const downloadFile = async (
return { success: false, error };
}
+ console.log(fullUrl,"fullUrl")
+
// 파일 정보 확인
const metadata = await checkFileMetadata(fullUrl);
if (!metadata.exists) {
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index e2aa27ec..34bad300 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -474,6 +474,48 @@ export async function findContractItemId(contractId: number, formCode: string):
}
}
+export async function getPackageCodeById(contractItemId: number): Promise<string | null> {
+ try {
+
+ // 1. forms 테이블에서 formCode에 해당하는 모든 레코드 조회
+ const contractItemsResult = await db
+ .select({
+ itemId: contractItems.itemId
+ })
+ .from(contractItems)
+ .where(eq(contractItems.id, contractItemId))
+ .limit(1)
+ ;
+
+ if (contractItemsResult.length === 0) {
+ console.warn(`[contractItemId]에 해당하는 item을 찾을 수 없습니다.`);
+ return null;
+ }
+
+ const itemId = contractItemsResult[0].itemId
+
+ const packageCodeResult = await db
+ .select({
+ packageCode: items.packageCode
+ })
+ .from(items)
+ .where(eq(items.id, itemId))
+ .limit(1);
+
+ if (packageCodeResult.length === 0) {
+ console.warn(`${itemId}와 일치하는 패키지 코드를 찾을 수 없습니다.`);
+ return null;
+ }
+
+ const packageCode = packageCodeResult[0].packageCode;
+
+ return packageCode;
+ } catch (error) {
+ console.error(`패키지 코드 조회 중 오류 발생:`, error);
+ return null;
+ }
+}
+
export async function syncMissingTags(
contractItemId: number,
@@ -1044,6 +1086,7 @@ interface SEDPAttribute {
VALUE: any;
UOM: string;
UOM_ID?: string;
+ CLS_ID?:string;
}
interface SEDPDataItem {
@@ -1219,7 +1262,7 @@ async function transformDataToSEDPFormat(
TAG_DESC: row.TAG_DESC || "",
ATTRIBUTES: [],
// SCOPE: objectCode,
- SCOPE: formCode,
+ SCOPE: packageCode,
TOOLID: "eVCP", // Changed from VDCS
ITM_NO: row.TAG_NO || "",
OP_DELETE: false,
@@ -1351,6 +1394,21 @@ export async function getProjectCodeById(projectId: number): Promise<string> {
return projectRecord[0].code;
}
+export async function getProjectById(projectId: number): Promise<{ code: string; type: string; }> {
+ const projectRecord = await db
+ .select({ code: projects.code , type:projects.type})
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1);
+
+ if (!projectRecord || projectRecord.length === 0) {
+ throw new Error(`Project not found with ID: ${projectId}`);
+ }
+
+ return projectRecord[0];
+}
+
+
/**
* Send data to SEDP
*/
@@ -1543,7 +1601,7 @@ export async function deleteFormDataByTags({
}> {
try {
// 입력 검증
- if (!formCode || !contractItemId || !Array.isArray(tagIdxs) || TAG_IDX.length === 0) {
+ if (!formCode || !contractItemId || !Array.isArray(tagIdxs) || tagIdxs.length === 0) {
return {
error: "Missing required parameters: formCode, contractItemId, tagIdxs",
}
diff --git a/lib/items/service.ts b/lib/items/service.ts
index 1b6d7e09..1eab3e25 100644
--- a/lib/items/service.ts
+++ b/lib/items/service.ts
@@ -9,7 +9,7 @@ import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
import { getErrorMessage } from "@/lib/handle-error";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or ,eq} from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, isNull, ne, gt } from "drizzle-orm";
import { CreateItemSchema, GetItemsSchema, UpdateItemSchema } from "./validations";
import { Item, items } from "@/db/schema/items";
import { countItems, deleteItemById, deleteItemsByIds, findAllItems, insertItem, selectItems, updateItem } from "./repository";
@@ -417,3 +417,43 @@ export async function getAllItems(): Promise<Item[]> {
throw new Error("Failed to get items");
}
}
+
+// PQ용 아이템 검색 함수
+export async function searchItemsForPQ(query: string): Promise<{ itemCode: string; itemName: string }[]> {
+ unstable_noStore();
+
+ try {
+ if (!query || query.trim().length < 1) {
+ return [];
+ }
+
+ const searchQuery = `%${query.trim()}%`;
+
+ const results = await db
+ .select({
+ itemCode: items.itemCode,
+ itemName: items.itemName,
+ })
+ .from(items)
+ .where(
+ and(
+ or(
+ ilike(items.itemCode, searchQuery),
+ ilike(items.itemName, searchQuery)
+ ),
+ // 삭제되지 않은 아이템만
+ or(
+ isNull(items.deleteFlag),
+ ne(items.deleteFlag, 'Y')
+ )
+ )
+ )
+ .limit(20) // 최대 20개 결과만 반환
+ .orderBy(asc(items.itemCode));
+
+ return results;
+ } catch (err) {
+ console.error("PQ 아이템 검색 오류:", err);
+ return [];
+ }
+}
diff --git a/lib/mail/templates/vendor-missing-contract-request.hbs b/lib/mail/templates/vendor-missing-contract-request.hbs
new file mode 100644
index 00000000..1bbe0a99
--- /dev/null
+++ b/lib/mail/templates/vendor-missing-contract-request.hbs
@@ -0,0 +1,155 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>[SHI] 정규업체 등록을 위한 기본계약/서약 진행 요청</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ }
+ .email-container {
+ max-width: 700px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 24px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ color: #111827;
+ }
+ .section-title {
+ font-weight: bold;
+ margin-top: 24px;
+ }
+ .contract-type {
+ font-weight: bold;
+ margin-top: 16px;
+ margin-bottom: 8px;
+ color: #374151;
+ }
+ .contract-list {
+ margin-left: 1.5em;
+ margin-bottom: 16px;
+ }
+ .button-container {
+ text-align: center;
+ margin: 32px 0;
+ }
+ .access-button {
+ display: inline-block;
+ background-color: #163CC4;
+ color: #ffffff !important;
+ padding: 12px 24px;
+ text-decoration: none;
+ border-radius: 6px;
+ font-weight: bold;
+ font-size: 16px;
+ }
+ </style>
+</head>
+<body>
+ <div class="email-container">
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+ </table>
+
+ <h1 style="font-size:28px; margin-bottom:16px;">
+ [SHI] 정규업체 등록을 위한 기본계약/서약 진행 요청
+ </h1>
+
+ <p style="font-size:16px; line-height:32px;">
+ 안녕하세요,<br>
+ 귀사 일익 번창하심을 기원합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 당사에선 귀사와의 정기적 거래를 위하여 기본계약 및 서약을 요청드렸으며,<br>
+ 아직까지 기본계약/서약이 완료되지 않아 당사와 정기적 거래 가능한 정규업체 등록이 어려운 상황입니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 아래의 해당 링크를 통해 당사 eVCP시스템에 접속하시어 기본계약/서약을 완료해 주시기 바랍니다.
+ </p>
+
+ <p class="section-title">- 아 래 -</p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:16px;">
+ 1) eVCP 시스템 접속 링크
+ </p>
+
+ <div class="button-container">
+ <a href="{{contractManagementUrl}}" class="access-button" target="_blank">
+ eVCP 시스템 접속하기
+ </a>
+ </div>
+
+ <p style="font-size:16px; line-height:32px;">
+ 2) 기본계약/서약 종류 안내
+ </p>
+
+ {{#if isDomesticVendor}}
+ <div class="contract-type">[국내업체]</div>
+ <div class="contract-list">
+ <div style="font-size:16px; line-height:32px;">(1) 준법서약 및 준법설문</div>
+ <div style="font-size:16px; line-height:32px;">(2) 표준하도급기본계약서</div>
+ <div style="font-size:16px; line-height:32px;">(3) 안전보건관리 약정서</div>
+ <div style="font-size:16px; line-height:32px;">(4) 내국신용장 미개설 합의서</div>
+ <div style="font-size:16px; line-height:32px;">(5) 윤리규범준수서약서</div>
+ </div>
+ {{/if}}
+
+ {{#if isOverseasVendor}}
+ <div class="contract-type">[해외업체]</div>
+ <div class="contract-list">
+ <div style="font-size:16px; line-height:32px;">(1) 준법서약 및 준법설문</div>
+ <div style="font-size:16px; line-height:32px;">(2) GTC 합의서</div>
+ </div>
+ {{/if}}
+
+ {{#unless isDomesticVendor}}
+ {{#unless isOverseasVendor}}
+ <div class="contract-type">[국내업체]</div>
+ <div class="contract-list">
+ <div style="font-size:16px; line-height:32px;">(1) 준법서약 및 준법설문</div>
+ <div style="font-size:16px; line-height:32px;">(2) 표준하도급기본계약서</div>
+ <div style="font-size:16px; line-height:32px;">(3) 안전보건관리 약정서</div>
+ <div style="font-size:16px; line-height:32px;">(4) 내국신용장 미개설 합의서</div>
+ <div style="font-size:16px; line-height:32px;">(5) 윤리규범준수서약서</div>
+ </div>
+
+ <div class="contract-type">[해외업체]</div>
+ <div class="contract-list">
+ <div style="font-size:16px; line-height:32px;">(1) 준법서약 및 준법설문</div>
+ <div style="font-size:16px; line-height:32px;">(2) GTC 합의서</div>
+ </div>
+ {{/unless}}
+ {{/unless}}
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ 이번 기회를 통하여 귀사와의 협업으로 다가올 미래 조선/해양산업 시장에서 함께 성장해 나갈 수 있기를 기대합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ {{senderName}} / Procurement Manager / {{senderEmail}}<br>
+ SAMSUNG HEAVY INDUSTRIES CO., LTD.<br>
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </p>
+
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
+ <tr>
+ <td align="center">
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. All rights reserved.</p>
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">본 메일은 발신전용입니다. 회신하지 마십시오.</p>
+ </td>
+ </tr>
+ </table>
+ </div>
+</body>
+</html>
diff --git a/lib/mail/templates/vendor-regular-registration-request.hbs b/lib/mail/templates/vendor-regular-registration-request.hbs
new file mode 100644
index 00000000..611e9d8a
--- /dev/null
+++ b/lib/mail/templates/vendor-regular-registration-request.hbs
@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>[SHI] 정규업체 등록을 위한 추가정보 입력 요청</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ }
+ .email-container {
+ max-width: 700px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 24px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ color: #111827;
+ }
+ .section-title {
+ font-weight: bold;
+ margin-top: 24px;
+ }
+ .info-list {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ padding-left: 1.5em;
+ }
+ .button-container {
+ text-align: center;
+ margin: 32px 0;
+ }
+ .access-button {
+ display: inline-block;
+ background-color: #163CC4;
+ color: #ffffff !important;
+ padding: 12px 24px;
+ text-decoration: none;
+ border-radius: 6px;
+ font-weight: bold;
+ font-size: 16px;
+ }
+ </style>
+</head>
+<body>
+ <div class="email-container">
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+ </table>
+
+ <h1 style="font-size:28px; margin-bottom:16px;">
+ [SHI] 정규업체 등록을 위한 추가정보 입력 요청
+ </h1>
+
+ <p style="font-size:16px; line-height:32px;">
+ 안녕하세요,<br>
+ 귀사 일익 번창하심을 기원합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 당사에선 귀사와의 정기적 거래를 위하여 정규업체 등록을 진행하고 있으며,<br>
+ 아래의 추가 정보가 있어야 최종 정규업체 등록이 완료됩니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 아래의 해당 링크를 통해 당사 eVCP시스템에 접속하시어 요청드린 추가정보를 입력해 주시기 바랍니다.
+ </p>
+
+ <p class="section-title">- 아 래 -</p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:16px;">
+ 1) eVCP 시스템 접속 링크
+ </p>
+
+ <div class="button-container">
+ <a href="{{vendorInfoUrl}}" class="access-button" target="_blank">
+ eVCP 시스템 접속하기
+ </a>
+ </div>
+
+ <p style="font-size:16px; line-height:32px;">
+ 2) 요청 추가정보
+ </p>
+ <div class="info-list">
+ <div style="font-size:16px; line-height:32px;">(1) 영문 업체명</div>
+ <div style="font-size:16px; line-height:32px;">(2) 영업소 기본정보 (주소, 전화번호 등)</div>
+ <div style="font-size:16px; line-height:32px;">(3) 대표자 이력내용 (직장이력/경력 등)</div>
+ <div style="font-size:16px; line-height:32px;">(4) 각 업무 담당자 (영업/설계/납기/품질/세금계산서 등)</div>
+ <div style="font-size:16px; line-height:32px;">(5) 사업유형 및 산업유형</div>
+ <div style="font-size:16px; line-height:32px;">(6) 기업규모</div>
+ </div>
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ 이번 기회를 통하여 귀사와의 협업으로 다가올 미래 조선/해양산업 시장에서 함께 성장해 나갈 수 있기를 기대합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ {{senderName}} / Procurement Manager / {{senderEmail}}<br>
+ SAMSUNG HEAVY INDUSTRIES CO., LTD.<br>
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </p>
+
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
+ <tr>
+ <td align="center">
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. All rights reserved.</p>
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">본 메일은 발신전용입니다. 회신하지 마십시오.</p>
+ </td>
+ </tr>
+ </table>
+ </div>
+</body>
+</html>
diff --git a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
index 4df7a7ec..7fd1c3f8 100644
--- a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
@@ -3,7 +3,7 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
-import { CalendarIcon, Loader } from "lucide-react"
+import { CalendarIcon, Loader, Upload, X, FileText } from "lucide-react"
import { format } from "date-fns"
import { toast } from "sonner"
@@ -49,6 +49,7 @@ const editInvestigationSchema = z.object({
]).optional(),
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
+ attachments: z.array(z.instanceof(File)).optional(),
})
type EditInvestigationSchema = z.infer<typeof editInvestigationSchema>
@@ -72,6 +73,8 @@ export function EditInvestigationDialog({
onSubmit,
}: EditInvestigationDialogProps) {
const [isPending, startTransition] = React.useTransition()
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
const form = useForm<EditInvestigationSchema>({
resolver: zodResolver(editInvestigationSchema),
@@ -79,6 +82,7 @@ export function EditInvestigationDialog({
confirmedAt: investigation?.confirmedAt || undefined,
evaluationResult: investigation?.evaluationResult as "APPROVED" | "SUPPLEMENT" | "REJECTED" | undefined,
investigationNotes: investigation?.investigationNotes || "",
+ attachments: [],
},
})
@@ -89,14 +93,47 @@ export function EditInvestigationDialog({
confirmedAt: investigation.confirmedAt || undefined,
evaluationResult: investigation.evaluationResult as "APPROVED" | "SUPPLEMENT" | "REJECTED" | undefined,
investigationNotes: investigation.investigationNotes || "",
+ attachments: [],
})
+ setSelectedFiles([])
}
}, [investigation, form])
+ // 파일 선택 핸들러
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(event.target.files || [])
+ if (files.length > 0) {
+ const newFiles = [...selectedFiles, ...files]
+ setSelectedFiles(newFiles)
+ form.setValue('attachments', newFiles, { shouldValidate: true })
+ }
+ }
+
+ // 파일 제거 핸들러
+ const removeFile = (index: number) => {
+ const updatedFiles = selectedFiles.filter((_, i) => i !== index)
+ setSelectedFiles(updatedFiles)
+ form.setValue('attachments', updatedFiles, { shouldValidate: true })
+ }
+
+ // 파일 크기 포맷팅
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
const handleSubmit = async (values: EditInvestigationSchema) => {
startTransition(async () => {
try {
- await onSubmit(values)
+ // 선택된 파일들을 values에 포함
+ const submitData = {
+ ...values,
+ attachments: selectedFiles,
+ }
+ await onSubmit(submitData)
toast.success("실사 정보가 업데이트되었습니다!")
onClose()
} catch (error) {
@@ -181,16 +218,16 @@ export function EditInvestigationDialog({
)}
/>
- {/* QM 의견 */}
+ {/* 구매 의견 */}
<FormField
control={form.control}
name="investigationNotes"
render={({ field }) => (
<FormItem>
- <FormLabel>QM 의견</FormLabel>
+ <FormLabel>구매 의견</FormLabel>
<FormControl>
<Textarea
- placeholder="실사에 대한 QM 의견을 입력하세요..."
+ placeholder="실사에 대한 구매 의견을 입력하세요..."
{...field}
className="min-h-[80px]"
/>
@@ -200,6 +237,82 @@ export function EditInvestigationDialog({
)}
/>
+ {/* 첨부파일 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={() => (
+ <FormItem>
+ <FormLabel>첨부파일</FormLabel>
+ <FormControl>
+ <div className="space-y-4">
+ {/* 파일 선택 영역 */}
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
+ <input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif"
+ onChange={handleFileSelect}
+ className="hidden"
+ />
+ <Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
+ <div className="text-sm text-gray-600 mb-2">
+ 파일을 드래그하거나 클릭하여 선택하세요
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isPending}
+ >
+ 파일 선택
+ </Button>
+ <div className="text-xs text-gray-500 mt-2">
+ 지원 형식: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG, GIF (최대 10MB)
+ </div>
+ </div>
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles.length > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">선택된 파일:</div>
+ {selectedFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-2 bg-gray-50 rounded border"
+ >
+ <div className="flex items-center space-x-2">
+ <FileText className="h-4 w-4 text-gray-500" />
+ <div className="flex-1 min-w-0">
+ <div className="text-sm font-medium truncate">
+ {file.name}
+ </div>
+ <div className="text-xs text-gray-500">
+ {formatFileSize(file.size)}
+ </div>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFile(index)}
+ disabled={isPending}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isPending}>
취소
diff --git a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
index b9648e74..6941adbb 100644
--- a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
@@ -241,7 +241,7 @@ export function RequestInvestigationDialog({
)}
/>
- <FormField
+ {/* <FormField
control={form.control}
name="investigationMethod"
render={({ field }) => (
@@ -257,7 +257,7 @@ export function RequestInvestigationDialog({
<FormMessage />
</FormItem>
)}
- />
+ /> */}
<FormField
control={form.control}
diff --git a/lib/pq/pq-review-table-new/site-visit-dialog.tsx b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
index b6bd3624..172aed98 100644
--- a/lib/pq/pq-review-table-new/site-visit-dialog.tsx
+++ b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
@@ -36,6 +36,7 @@ import {
} from "@/components/ui/popover"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "sonner"
import { getSiteVisitRequestAction } from "@/lib/site-visit/service"
import {
@@ -444,82 +445,96 @@ export function SiteVisitDialog({
삼성중공업에 어떤 부문의 담당자가 몇 명 실사 참석 예정인지에 대한 정보를 입력하세요.
</div>
- <div className="space-y-4">
- {[
- { key: "technicalSales", label: "기술영업" },
- { key: "design", label: "설계" },
- { key: "procurement", label: "구매" },
- { key: "quality", label: "품질" },
- { key: "production", label: "생산" },
- { key: "commissioning", label: "시운전" },
- { key: "other", label: "기타" },
- ].map((item) => (
- <div key={item.key} className="border rounded-lg p-4 space-y-3">
- <div className="flex items-center space-x-3">
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.checked` as `shiAttendees.${typeof item.key}.checked`}
- render={({ field }) => (
- <FormItem className="flex flex-row items-center space-x-2 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- disabled={isPending}
- />
- </FormControl>
- <FormLabel className="text-sm font-medium">{item.label}</FormLabel>
- </FormItem>
- )}
- />
- </div>
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.count` as `shiAttendees.${typeof item.key}.count`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-sm">참석 인원</FormLabel>
- <div className="flex items-center space-x-2">
- <FormControl>
- <Input
- type="number"
- min="0"
- placeholder="0"
- {...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
- disabled={isPending}
- className="w-20"
- />
- </FormControl>
- <span className="text-sm text-muted-foreground">명</span>
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.details` as `shiAttendees.${typeof item.key}.details`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-sm">참석자 정보</FormLabel>
- <FormControl>
- <Input
- placeholder="부서 및 이름 등"
- {...field}
- disabled={isPending}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
- ))}
+ <div className="border rounded-lg overflow-hidden">
+ <Table>
+ <TableHeader>
+ <TableRow className="bg-muted/50">
+ <TableHead className="w-[100px]">참석여부</TableHead>
+ <TableHead className="w-[120px]">부문</TableHead>
+ <TableHead className="w-[100px]">참석인원</TableHead>
+ <TableHead>참석자 정보</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {[
+ { key: "technicalSales", label: "기술영업" },
+ { key: "design", label: "설계" },
+ { key: "procurement", label: "구매" },
+ { key: "quality", label: "품질" },
+ { key: "production", label: "생산" },
+ { key: "commissioning", label: "시운전" },
+ { key: "other", label: "기타" },
+ ].map((item) => (
+ <TableRow key={item.key}>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.checked` as any}
+ render={({ field }) => (
+ <FormItem className="flex items-center space-x-2 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value as boolean}
+ onCheckedChange={field.onChange}
+ disabled={isPending}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <span className="font-medium">{item.label}</span>
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.count` as any}
+ render={({ field }) => (
+ <FormItem className="space-y-0">
+ <div className="flex items-center space-x-2">
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ placeholder="0"
+ value={field.value as number}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+ disabled={isPending}
+ className="w-16 h-8"
+ />
+ </FormControl>
+ <span className="text-xs text-muted-foreground">명</span>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.details` as any}
+ render={({ field }) => (
+ <FormItem className="space-y-0">
+ <FormControl>
+ <Input
+ placeholder="부서 및 이름 등"
+ value={field.value as string}
+ onChange={field.onChange}
+ disabled={isPending}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
</div>
{/* 전체 참석자 상세정보 */}
@@ -544,10 +559,10 @@ export function SiteVisitDialog({
</div>
{/* 협력업체 요청정보 및 자료 */}
- <div>
+ {/* <div>
<FormLabel className="text-sm font-medium">협력업체 요청정보 및 자료</FormLabel>
<div className="text-sm text-muted-foreground mb-2">
- 협력업체에게 요청할 정보를 선택하세요. 선택된 항목들은 협력업체 정보 입력 폼에 포함됩니다.
+ 협력업체에게 요청할 정보를 선택하세요.
</div>
<div className="mt-2 space-y-2">
{[
@@ -564,7 +579,7 @@ export function SiteVisitDialog({
<FormField
key={item.key}
control={form.control}
- name={`vendorRequests.${item.key}` as `vendorRequests.${typeof item.key}`}
+ name={`vendorRequests.${item.key}` as any}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
@@ -580,7 +595,7 @@ export function SiteVisitDialog({
/>
))}
</div>
- <FormField
+ {/* <FormField
control={form.control}
name="otherVendorRequests"
render={({ field }) => (
@@ -597,8 +612,8 @@ export function SiteVisitDialog({
<FormMessage />
</FormItem>
)}
- />
- </div>
+ />
+ </div> */}
{/* 추가 요청사항 */}
<FormField
diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
index d3fada0d..449b69be 100644
--- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
@@ -55,7 +55,7 @@ export interface PQSubmission {
pqTypeLabel: string
// PQ 대상품목
- pqItems: string | null
+ pqItems: string | null | Array<{itemCode: string, itemName: string}>
// 방문실사 요청 정보
siteVisitRequestId: number | null // 방문실사 요청 ID
@@ -457,11 +457,19 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
return <span className="text-muted-foreground">-</span>;
}
- return (
- <div className="flex items-center gap-2">
- <span className="text-sm">{pqItems}</span>
- </div>
- )
+ // JSON 파싱하여 첫 번째 아이템 표시
+ const items = typeof pqItems === 'string' ? JSON.parse(pqItems) : pqItems;
+ if (Array.isArray(items) && items.length > 0) {
+ const firstItem = items[0];
+ return (
+ <div className="flex items-center gap-2">
+ <span className="text-sm">{firstItem.itemCode} - {firstItem.itemName}</span>
+ {items.length > 1 && (
+ <span className="text-xs text-muted-foreground">외 {items.length - 1}건</span>
+ )}
+ </div>
+ );
+ }
},
}
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
index f731a922..8398c2e7 100644
--- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -309,20 +309,69 @@ const handleOpenRequestDialog = async () => {
const investigation = row.original.investigation!
const pqSubmission = row.original
+ // pqItems를 상세하게 포맷팅 (itemCode-itemName 형태로 모든 항목 표시)
+ const formatAuditItem = (pqItems: any): string => {
+ if (!pqItems) return pqSubmission.projectName || "N/A";
+
+ try {
+ // 이미 파싱된 객체 배열인 경우
+ if (Array.isArray(pqItems)) {
+ return pqItems.map(item => {
+ if (typeof item === 'string') return item;
+ if (typeof item === 'object') {
+ const code = item.itemCode || item.code || "";
+ const name = item.itemName || item.name || "";
+ if (code && name) return `${code}-${name}`;
+ return name || code || String(item);
+ }
+ return String(item);
+ }).join(', ');
+ }
+
+ // JSON 문자열인 경우
+ if (typeof pqItems === 'string') {
+ try {
+ const parsed = JSON.parse(pqItems);
+ if (Array.isArray(parsed)) {
+ return parsed.map(item => {
+ if (typeof item === 'string') return item;
+ if (typeof item === 'object') {
+ const code = item.itemCode || item.code || "";
+ const name = item.itemName || item.name || "";
+ if (code && name) return `${code}-${name}`;
+ return name || code || String(item);
+ }
+ return String(item);
+ }).join(', ');
+ }
+ return String(parsed);
+ } catch {
+ return String(pqItems);
+ }
+ }
+
+ // 기타 경우
+ return String(pqItems);
+ } catch {
+ return pqSubmission.projectName || "N/A";
+ }
+ };
+
return {
id: investigation.id,
vendorCode: row.original.vendorCode || "N/A",
vendorName: row.original.vendorName || "N/A",
vendorEmail: row.original.email || "N/A",
+ vendorContactPerson: (row.original as any).representativeName || row.original.vendorName || "N/A",
pqNumber: pqSubmission.pqNumber || "N/A",
- auditItem: pqSubmission.pqItems || pqSubmission.projectName || "N/A",
+ auditItem: formatAuditItem(pqSubmission.pqItems),
auditFactoryAddress: investigation.investigationAddress || "N/A",
auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""),
auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" :
investigation.evaluationResult === "SUPPLEMENT" ? "Pass(조건부승인)" :
investigation.evaluationResult === "REJECTED" ? "Fail(미승인)" : "N/A",
- additionalNotes: investigation.investigationNotes,
- investigationNotes: investigation.investigationNotes,
+ additionalNotes: investigation.investigationNotes || undefined,
+ investigationNotes: investigation.investigationNotes || undefined,
}
})
diff --git a/lib/pq/pq-review-table/feature-flags-provider.tsx b/lib/pq/pq-review-table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/pq/pq-review-table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/pq/pq-review-table/vendors-table-columns.tsx b/lib/pq/pq-review-table/vendors-table-columns.tsx
deleted file mode 100644
index 8673443f..00000000
--- a/lib/pq/pq-review-table/vendors-table-columns.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, PaperclipIcon } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
-import { useRouter } from "next/navigation"
-
-import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
-import { Separator } from "@/components/ui/separator"
-
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>;
- router: NextRouter;
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<Vendor> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼 (Dropdown 메뉴)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<Vendor> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
-
- <DropdownMenuItem
- onSelect={() => {
- // 1) 만약 rowAction을 열고 싶다면
- // setRowAction({ row, type: "update" })
-
- // 2) 자세히 보기 페이지로 클라이언트 라우팅
- router.push(`/evcp/pq/${row.original.id}`);
- }}
- >
- Details
- </DropdownMenuItem>
-
-
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
-
- // ----------------------------------------------------------------
- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
- // ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] }
- const groupMap: Record<string, ColumnDef<Vendor>[]> = {}
-
- vendorColumnsConfig.forEach((cfg) => {
- // 만약 group가 없으면 "_noGroup" 처리
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // child column 정의
- const childCol: ColumnDef<Vendor> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- cell: ({ row, cell }) => {
-
-
- if (cfg.id === "status") {
- const statusVal = row.original.status
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- return (
- <div className="flex w-[6.25rem] items-center">
- {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */}
- <span className="capitalize">{statusVal}</span>
- </div>
- )
- }
-
-
- if (cfg.id === "createdAt") {
- const dateVal = cell.getValue() as Date
- return formatDate(dateVal)
- }
-
- if (cfg.id === "updatedAt") {
- const dateVal = cell.getValue() as Date
- return formatDate(dateVal)
- }
-
-
- // code etc...
- return row.getValue(cfg.id) ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // ----------------------------------------------------------------
- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
- // ----------------------------------------------------------------
- const nestedColumns: ColumnDef<Vendor>[] = []
-
- // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
- // 여기서는 그냥 Object.entries 순서
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- // 그룹 없음 → 그냥 최상위 레벨 컬럼
- nestedColumns.push(...colDefs)
- } else {
- // 상위 컬럼
- nestedColumns.push({
- id: groupName,
- header: groupName, // "Basic Info", "Metadata" 등
- columns: colDefs,
- })
- }
- })
-
-
-
-
- // ----------------------------------------------------------------
- // 4) 최종 컬럼 배열: select, nestedColumns, actions
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- actionsColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx
deleted file mode 100644
index 98fef170..00000000
--- a/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Check } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { Vendor } from "@/db/schema/vendors"
-
-interface VendorsTableToolbarActionsProps {
- table: Table<Vendor>
-}
-
-export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
-
-
- return (
- <div className="flex items-center gap-2">
-
-
- {/** 4) Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "vendors",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/pq/pq-review-table/vendors-table.tsx b/lib/pq/pq-review-table/vendors-table.tsx
deleted file mode 100644
index 7eb8f7de..00000000
--- a/lib/pq/pq-review-table/vendors-table.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./vendors-table-columns"
-import { Vendor, vendors } from "@/db/schema/vendors"
-import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
-import { getVendorsInPQ } from "../service"
-
-
-interface VendorsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getVendorsInPQ>>,
- ]
- >
-}
-
-export function VendorsPQReviewTable({ promises }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
-
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null)
-
- // **router** 획득
- const router = useRouter()
-
- // getColumns() 호출 시, router를 주입
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router }),
- [setRowAction, router]
- )
-
- const filterFields: DataTableFilterField<Vendor>[] = [
-
-
- { id: "vendorCode", label: "Vendor Code" },
-
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
-
- { id: "createdAt", label: "Created at", type: "date" },
- { id: "updatedAt", label: "Updated at", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable
- table={table}
- // floatingBar={<VendorsTableFloatingBar table={table} />}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <VendorsTableToolbarActions table={table} />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- </>
- )
-} \ No newline at end of file
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index ba0ce3c5..f15790eb 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -9,11 +9,12 @@ import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count,isNull,SQL
import { z } from "zod"
import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache";
import { format } from "date-fns"
-import { pqCriterias, vendorCriteriaAttachments, vendorInvestigations, vendorPQSubmissions, vendorPqCriteriaAnswers, vendorPqReviewLogs, siteVisitRequests, vendorSiteVisitInfo, siteVisitRequestAttachments } from "@/db/schema/pq"
+import { pqCriterias, vendorCriteriaAttachments, vendorInvestigations, vendorInvestigationAttachments, vendorPQSubmissions, vendorPqCriteriaAnswers, vendorPqReviewLogs, siteVisitRequests, vendorSiteVisitInfo, siteVisitRequestAttachments } from "@/db/schema/pq"
import { sendEmail } from "../mail/sendEmail";
import { decryptWithServerAction } from '@/components/drm/drmUtils'
import { vendorAttachments, vendors } from "@/db/schema/vendors";
+import { vendorRegularRegistrations } from "@/db/schema/vendorRegistrations";
import { saveFile, saveDRMFile } from "@/lib/file-stroage";
import { GetVendorsSchema } from "../vendors/validations";
import { selectVendors } from "../vendors/repository";
@@ -2462,6 +2463,8 @@ export async function requestInvestigationAction(
// 캐시 무효화
revalidateTag("vendor-investigations");
revalidateTag("pq-submissions");
+ revalidateTag("vendor-pq-submissions");
+ revalidatePath("/evcp/pq_new");
return {
success: true,
@@ -2589,8 +2592,32 @@ export async function sendInvestigationResultsAction(input: {
vendorCode: investigation.vendorCode || "N/A",
vendorName: investigation.vendorName || "N/A",
- // 실사 정보
- auditItem: investigation.pqItems || investigation.projectName || "N/A",
+ // 실사 정보 - pqItems를 itemCode-itemName 형태로 모든 항목 표시
+ auditItem: (() => {
+ if (investigation.pqItems) {
+ try {
+ const parsed = typeof investigation.pqItems === 'string'
+ ? JSON.parse(investigation.pqItems)
+ : investigation.pqItems;
+ if (Array.isArray(parsed)) {
+ return parsed.map(item => {
+ if (typeof item === 'string') return item;
+ if (typeof item === 'object') {
+ const code = item.itemCode || item.code || "";
+ const name = item.itemName || item.name || "";
+ if (code && name) return `${code}-${name}`;
+ return name || code || String(item);
+ }
+ return String(item);
+ }).join(', ');
+ }
+ return String(parsed);
+ } catch {
+ return String(investigation.pqItems);
+ }
+ }
+ return investigation.projectName || "N/A";
+ })(),
auditFactoryAddress: investigation.investigationAddress || "N/A",
auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""),
auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" :
@@ -2607,12 +2634,16 @@ export async function sendInvestigationResultsAction(input: {
}
// 이메일 발송
- await sendEmail({
- to: investigation.vendorEmail,
- subject: emailContext.subject,
- template: "audit-result-notice",
- context: emailContext,
- })
+ if (investigation.vendorEmail) {
+ await sendEmail({
+ to: investigation.vendorEmail,
+ subject: emailContext.subject,
+ template: "audit-result-notice",
+ context: emailContext,
+ })
+ } else {
+ throw new Error("벤더 이메일 주소가 없습니다.")
+ }
return { success: true, investigationId: investigation.id }
} catch (error) {
@@ -2636,6 +2667,65 @@ export async function sendInvestigationResultsAction(input: {
updatedAt: new Date(),
})
.where(inArray(vendorInvestigations.id, successfulInvestigationIds))
+
+ // 정규업체등록관리에 레코드 생성 로직
+ const successfulInvestigations = investigations.filter(inv =>
+ successfulInvestigationIds.includes(inv.id)
+ );
+
+ for (const investigation of successfulInvestigations) {
+ // 1. 미실사 PQ는 제외 (이미 COMPLETED 상태인 것만 처리하므로 실사된 것들)
+ // 2. 승인된 실사만 정규업체등록 대상
+ if (investigation.evaluationResult === "APPROVED") {
+ try {
+ // 기존 정규업체등록 레코드 확인
+ const existingRegistration = await tx
+ .select({ id: vendorRegularRegistrations.id })
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, investigation.vendorId))
+ .limit(1);
+
+ // 프로젝트 PQ의 경우 기존 레코드가 있으면 skip, 없으면 생성
+ // 일반 PQ의 경우 무조건 생성 (이미 체크는 위에서 함)
+ if (existingRegistration.length === 0) {
+ // pqItems를 majorItems로 변환 - JSON 통째로 넘겨줌
+ let majorItemsJson = null;
+ if (investigation.pqItems) {
+ try {
+ // 이미 파싱된 객체거나 JSON 문자열인 경우 모두 처리
+ const parsed = typeof investigation.pqItems === 'string'
+ ? JSON.parse(investigation.pqItems)
+ : investigation.pqItems;
+
+ // 원본 구조를 최대한 보존하면서 JSON으로 저장
+ majorItemsJson = JSON.stringify(parsed);
+ } catch {
+ // 파싱 실패 시 문자열로 저장
+ majorItemsJson = JSON.stringify([{
+ itemCode: "UNKNOWN",
+ itemName: String(investigation.pqItems)
+ }]);
+ }
+ }
+
+ await tx.insert(vendorRegularRegistrations).values({
+ vendorId: investigation.vendorId,
+ status: "audit_pass", // 실사 통과 상태로 시작
+ majorItems: majorItemsJson,
+ registrationRequestDate: new Date().toISOString().split('T')[0], // date 타입으로 변환
+ remarks: `PQ 실사 통과로 자동 생성 (PQ번호: ${investigation.pqNumber || 'N/A'})`,
+ });
+
+ console.log(`✅ 정규업체등록 레코드 생성: 벤더 ID ${investigation.vendorId}`);
+ } else {
+ console.log(`⏭️ 정규업체등록 레코드 이미 존재: 벤더 ID ${investigation.vendorId} (Skip)`);
+ }
+ } catch (error) {
+ console.error(`❌ 정규업체등록 레코드 생성 실패 (벤더 ID: ${investigation.vendorId}):`, error);
+ // 정규업체등록 생성 실패는 전체 프로세스를 중단하지 않음
+ }
+ }
+ }
}
return {
@@ -2649,6 +2739,7 @@ export async function sendInvestigationResultsAction(input: {
// 캐시 무효화
revalidateTag("vendor-investigations")
revalidateTag("pq-submissions")
+ revalidateTag("vendor-regular-registrations")
return {
success: true,
@@ -3578,6 +3669,7 @@ export async function updateInvestigationDetailsAction(input: {
confirmedAt?: Date;
evaluationResult?: "APPROVED" | "SUPPLEMENT" | "REJECTED";
investigationNotes?: string;
+ attachments?: File[];
}) {
try {
const updateData: any = {
@@ -3595,11 +3687,72 @@ export async function updateInvestigationDetailsAction(input: {
if (input.investigationNotes !== undefined) {
updateData.investigationNotes = input.investigationNotes;
}
+ // evaluationResult가 APPROVED라면 investigationStatus를 "COMPLETED"(완료됨)로 변경
+ if (input.evaluationResult === "APPROVED") {
+ updateData.investigationStatus = "COMPLETED";
+ }
- await db
- .update(vendorInvestigations)
- .set(updateData)
- .where(eq(vendorInvestigations.id, input.investigationId));
+ // 트랜잭션으로 실사 정보 업데이트와 첨부파일 저장을 함께 처리
+ await db.transaction(async (tx) => {
+ // 1. 실사 정보 업데이트
+ await tx
+ .update(vendorInvestigations)
+ .set(updateData)
+ .where(eq(vendorInvestigations.id, input.investigationId));
+
+ // 2. 첨부파일 처리
+ if (input.attachments && input.attachments.length > 0) {
+ for (const file of input.attachments) {
+ try {
+ console.log(`📁 실사 첨부파일 처리 중: ${file.name} (${file.size} bytes)`);
+
+ // saveFile을 사용하여 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `vendor-investigation/${input.investigationId}`,
+ originalName: file.name,
+ userId: "investigation-update"
+ });
+
+ if (!saveResult.success) {
+ console.error(`❌ 파일 저장 실패: ${file.name}`, saveResult.error);
+ throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`);
+ }
+
+ console.log(`✅ 파일 저장 완료: ${file.name} -> ${saveResult.fileName}`);
+
+ // 파일 타입 결정
+ let attachmentType = "OTHER";
+ if (file.type.includes("pdf")) {
+ attachmentType = "REPORT";
+ } else if (file.type.includes("image")) {
+ attachmentType = "PHOTO";
+ } else if (
+ file.type.includes("word") ||
+ file.type.includes("document") ||
+ file.name.toLowerCase().includes("report")
+ ) {
+ attachmentType = "DOCUMENT";
+ }
+
+ // DB에 첨부파일 레코드 생성
+ await tx.insert(vendorInvestigationAttachments).values({
+ investigationId: input.investigationId,
+ fileName: saveResult.originalName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ mimeType: file.type || 'application/octet-stream',
+ attachmentType: attachmentType as "REPORT" | "PHOTO" | "DOCUMENT" | "CERTIFICATE" | "OTHER",
+ });
+
+ } catch (error) {
+ console.error(`❌ 첨부파일 처리 오류: ${file.name}`, error);
+ throw new Error(`첨부파일 처리 중 오류가 발생했습니다: ${file.name}`);
+ }
+ }
+ }
+ });
revalidateTag("pq-submissions");
revalidatePath("/evcp/pq_new");
@@ -3611,9 +3764,10 @@ export async function updateInvestigationDetailsAction(input: {
} catch (error) {
console.error("실사 정보 업데이트 오류:", error);
+ const errorMessage = error instanceof Error ? error.message : "실사 정보 업데이트 중 오류가 발생했습니다.";
return {
success: false,
- error: "실사 정보 업데이트 중 오류가 발생했습니다."
+ error: errorMessage
};
}
}
diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts
index 821fa372..efa4a9c0 100644
--- a/lib/sedp/get-form-tags.ts
+++ b/lib/sedp/get-form-tags.ts
@@ -1,5 +1,5 @@
import db from "@/db/db";
-import {
+import {
contractItems,
tags,
forms,
@@ -64,13 +64,13 @@ export async function importTagsFromSEDP(
try {
// 진행 상황 보고
if (progressCallback) progressCallback(5);
-
+
// 에러 수집 배열
const errors: string[] = [];
-
+
// SEDP API에서 태그 데이터 가져오기
const tagData = await fetchTagDataFromSEDP(projectCode, formCode);
-
+
// 트랜잭션으로 모든 DB 작업 처리
return await db.transaction(async (tx) => {
// 프로젝트 정보 가져오기 (type 포함)
@@ -78,57 +78,57 @@ export async function importTagsFromSEDP(
.from(projects)
.where(eq(projects.code, projectCode))
.limit(1);
-
+
if (!projectRecord || projectRecord.length === 0) {
throw new Error(`Project not found for code: ${projectCode}`);
}
-
+
const projectId = projectRecord[0].id;
const projectType = projectRecord[0].type;
-
+
// 프로젝트 타입에 따라 packageCode를 찾을 ATT_ID 결정
const packageCodeAttId = projectType === "ship" ? "CM3003" : "ME5074";
-
+
// packageId로 contractItem과 item 정보 가져오기
- const contractItemRecord = await tx.select({ itemId: contractItems.itemId, contractId:contractItems.contractId })
+ const contractItemRecord = await tx.select({ itemId: contractItems.itemId, contractId: contractItems.contractId })
.from(contractItems)
.where(eq(contractItems.id, packageId))
.limit(1);
-
+
if (!contractItemRecord || contractItemRecord.length === 0) {
throw new Error(`Contract item not found for packageId: ${packageId}`);
}
const contractRecord = await tx.select({ vendorId: contracts.vendorId })
- .from(contracts)
- .where(eq(contracts.id, contractItemRecord[0].contractId))
- .limit(1);
+ .from(contracts)
+ .where(eq(contracts.id, contractItemRecord[0].contractId))
+ .limit(1);
const vendorRecord = await tx.select({ vendorCode: vendors.vendorCode, vendorName: vendors.vendorName })
- .from(vendors)
- .where(eq(vendors.id, contractRecord[0].vendorId))
- .limit(1);
-
+ .from(vendors)
+ .where(eq(vendors.id, contractRecord[0].vendorId))
+ .limit(1);
+
const itemRecord = await tx.select({ packageCode: items.packageCode })
.from(items)
.where(eq(items.id, contractItemRecord[0].itemId))
.limit(1);
-
+
if (!itemRecord || itemRecord.length === 0) {
throw new Error(`Item not found for itemId: ${contractItemRecord[0].itemId}`);
}
-
+
const targetPackageCode = itemRecord[0].packageCode;
-
+
// 데이터 형식 처리 - tagData의 첫 번째 키 사용
const tableName = Object.keys(tagData)[0];
-
+
if (!tableName || !tagData[tableName]) {
throw new Error("Invalid tag data format from SEDP API");
}
-
+
const allTagEntries: TagEntry[] = tagData[tableName];
-
+
if (!Array.isArray(allTagEntries) || allTagEntries.length === 0) {
return {
processedCount: 0,
@@ -137,7 +137,7 @@ export async function importTagsFromSEDP(
errors: ["No tag entries found in API response"]
};
}
-
+
// packageCode로 필터링 - ATTRIBUTES에서 지정된 ATT_ID의 VALUE와 packageCode 비교
const tagEntries = allTagEntries.filter(entry => {
if (Array.isArray(entry.ATTRIBUTES)) {
@@ -157,10 +157,10 @@ export async function importTagsFromSEDP(
errors: [`No tag entries found with ${packageCodeAttId} attribute value matching packageCode: ${targetPackageCode}`]
};
}
-
+
// 진행 상황 보고
if (progressCallback) progressCallback(20);
-
+
// 나머지 코드는 기존과 동일...
// form ID 가져오기 - 없으면 생성
let formRecord = await tx.select({ id: forms.id })
@@ -170,18 +170,18 @@ export async function importTagsFromSEDP(
eq(forms.contractItemId, packageId)
))
.limit(1);
-
+
let formCreated = false;
-
+
// form이 없으면 생성
if (!formRecord || formRecord.length === 0) {
console.log(`[IMPORT TAGS] Form ${formCode} not found, attempting to create...`);
-
+
// 첫 번째 태그의 정보를 사용해서 form mapping을 찾습니다
// 모든 태그가 같은 formCode를 사용한다고 가정
if (tagEntries.length > 0) {
const firstTag = tagEntries[0];
-
+
// tagType 조회 (TAG_TYPE_ID -> description)
let tagTypeDescription = firstTag.TAG_TYPE_ID; // 기본값
if (firstTag.TAG_TYPE_ID) {
@@ -192,16 +192,16 @@ export async function importTagsFromSEDP(
eq(tagTypes.projectId, projectId)
))
.limit(1);
-
+
if (tagTypeRecord && tagTypeRecord.length > 0) {
tagTypeDescription = tagTypeRecord[0].description;
}
}
-
+
// tagClass 조회 (CLS_ID -> label)
let tagClassLabel = firstTag.CLS_ID; // 기본값
if (firstTag.CLS_ID) {
- const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label })
+ const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label })
.from(tagClasses)
.where(and(
eq(tagClasses.code, firstTag.CLS_ID),
@@ -213,18 +213,18 @@ export async function importTagsFromSEDP(
tagClassLabel = tagClassRecord[0].label;
}
}
-
+
// 태그 타입에 따른 폼 정보 가져오기
const allFormMappings = await getFormMappingsByTagTypebyProeject(
projectId,
);
-
+
// 현재 formCode와 일치하는 매핑 찾기
const targetFormMapping = allFormMappings.find(mapping => mapping.formCode === formCode);
-
+
if (targetFormMapping) {
console.log(`[IMPORT TAGS] Found form mapping for ${formCode}, creating form...`);
-
+
// form 생성
const insertResult = await tx
.insert(forms)
@@ -236,10 +236,10 @@ export async function importTagsFromSEDP(
im: targetFormMapping.ep === "IMEP" ? true : false
})
.returning({ id: forms.id });
-
+
formRecord = insertResult;
formCreated = true;
-
+
console.log(`[IMPORT TAGS] Successfully created form:`, insertResult[0]);
} else {
console.log(`[IMPORT TAGS] No form mapping found for formCode: ${formCode}`);
@@ -251,25 +251,25 @@ export async function importTagsFromSEDP(
}
} else {
console.log(`[IMPORT TAGS] Found existing form:`, formRecord[0].id);
-
+
// 기존 form이 있는 경우 eng와 im 필드를 체크하고 업데이트
- const existingForm = await tx.select({
+ const existingForm = await tx.select({
eng: forms.eng,
- im: forms.im
+ im: forms.im
})
.from(forms)
.where(eq(forms.id, formRecord[0].id))
.limit(1);
-
+
if (existingForm.length > 0) {
// form mapping 정보 가져오기 (im 필드 업데이트를 위해)
let shouldUpdateIm = false;
let targetImValue = false;
-
+
// 첫 번째 태그의 정보를 사용해서 form mapping을 확인
if (tagEntries.length > 0) {
const firstTag = tagEntries[0];
-
+
// tagType 조회
let tagTypeDescription = firstTag.TAG_TYPE_ID;
if (firstTag.TAG_TYPE_ID) {
@@ -280,12 +280,12 @@ export async function importTagsFromSEDP(
eq(tagTypes.projectId, projectId)
))
.limit(1);
-
+
if (tagTypeRecord && tagTypeRecord.length > 0) {
tagTypeDescription = tagTypeRecord[0].description;
}
}
-
+
// tagClass 조회
let tagClassLabel = firstTag.CLS_ID;
if (firstTag.CLS_ID) {
@@ -296,59 +296,59 @@ export async function importTagsFromSEDP(
eq(tagClasses.projectId, projectId)
))
.limit(1);
-
+
if (tagClassRecord && tagClassRecord.length > 0) {
tagClassLabel = tagClassRecord[0].label;
}
}
-
+
// form mapping 정보 가져오기
const allFormMappings = await getFormMappingsByTagTypebyProeject(
projectId,
);
-
+
// 현재 formCode와 일치하는 매핑 찾기
const targetFormMapping = allFormMappings.find(mapping => mapping.formCode === formCode);
-
+
if (targetFormMapping) {
targetImValue = targetFormMapping.ep === "IMEP";
shouldUpdateIm = existingForm[0].im !== targetImValue;
}
}
-
+
// 업데이트할 필드들 준비
const updates: any = {};
let hasUpdates = false;
-
+
// eng 필드 체크
if (existingForm[0].eng !== true) {
updates.eng = true;
hasUpdates = true;
}
-
+
// im 필드 체크
if (shouldUpdateIm) {
updates.im = targetImValue;
hasUpdates = true;
}
-
+
// 업데이트 실행
if (hasUpdates) {
await tx
.update(forms)
.set(updates)
.where(eq(forms.id, formRecord[0].id));
-
+
console.log(`[IMPORT TAGS] Form ${formRecord[0].id} updated with:`, updates);
}
}
}
-
+
const formId = formRecord[0].id;
-
+
// 나머지 처리 로직은 기존과 동일...
// (양식 메타데이터 가져오기, 태그 처리 등)
-
+
// 양식 메타데이터 가져오기
const formMetaRecord = await tx.select({ columns: formMetas.columns })
.from(formMetas)
@@ -357,17 +357,17 @@ export async function importTagsFromSEDP(
eq(formMetas.formCode, formCode)
))
.limit(1);
-
+
if (!formMetaRecord || formMetaRecord.length === 0) {
throw new Error(`Form metadata not found for formCode: ${formCode} and projectId: ${projectId}`);
}
-
+
// 진행 상황 보고
if (progressCallback) progressCallback(30);
-
+
// 컬럼 정보 파싱
const columnsJSON: Column[] = (formMetaRecord[0].columns);
-
+
// 현재 formEntries 데이터 가져오기
const existingEntries = await tx.select({ id: formEntries.id, data: formEntries.data })
.from(formEntries)
@@ -375,19 +375,19 @@ export async function importTagsFromSEDP(
eq(formEntries.formCode, formCode),
eq(formEntries.contractItemId, packageId)
));
-
+
// 기존 tags 데이터 가져오기
const existingTags = await tx.select()
.from(tags)
.where(eq(tags.contractItemId, packageId));
-
+
// 진행 상황 보고
if (progressCallback) progressCallback(50);
-
+
// 기존 데이터를 맵으로 변환
const existingTagMap = new Map();
const existingTagsMap = new Map();
-
+
existingEntries.forEach(entry => {
const data = entry.data as any[];
data.forEach(item => {
@@ -399,23 +399,23 @@ export async function importTagsFromSEDP(
}
});
});
-
+
existingTags.forEach(tag => {
existingTagsMap.set(tag.tagIdx, tag);
});
-
+
// 진행 상황 보고
if (progressCallback) progressCallback(60);
-
+
// 처리 결과 카운터
let processedCount = 0;
let excludedCount = 0;
-
+
// 새로운 태그 데이터와 업데이트할 데이터 준비
const newTagData: any[] = [];
const upsertTagRecords: any[] = []; // 새로 추가되거나 업데이트될 태그들
- const updateData: {entryId: number, tagNo: string, updates: any}[] = [];
-
+ const updateData: { entryId: number, tagNo: string, updates: any }[] = [];
+
// SEDP 태그 데이터 처리
for (const tagEntry of tagEntries) {
try {
@@ -424,7 +424,7 @@ export async function importTagsFromSEDP(
errors.push(`Missing TAG_NO in tag entry`);
continue;
}
-
+
// tagType 조회 (TAG_TYPE_ID -> description)
let tagTypeDescription = tagEntry.TAG_TYPE_ID; // 기본값
if (tagEntry.TAG_TYPE_ID) {
@@ -435,40 +435,42 @@ export async function importTagsFromSEDP(
eq(tagTypes.projectId, projectId)
))
.limit(1);
-
+
if (tagTypeRecord && tagTypeRecord.length > 0) {
tagTypeDescription = tagTypeRecord[0].description;
}
}
-
+
// tagClass 조회 (CLS_ID -> label)
let tagClassLabel = tagEntry.CLS_ID; // 기본값
let tagClassId = null; // 기본값
if (tagEntry.CLS_ID) {
- const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label })
+ const tagClassRecord = await tx.select({ id: tagClasses.id, label: tagClasses.label })
.from(tagClasses)
.where(and(
eq(tagClasses.code, tagEntry.CLS_ID),
eq(tagClasses.projectId, projectId)
))
.limit(1);
-
+
if (tagClassRecord && tagClassRecord.length > 0) {
tagClassLabel = tagClassRecord[0].label;
tagClassId = tagClassRecord[0].id;
}
}
-
+
// 기본 태그 데이터 객체 생성 (formEntries용)
const tagObject: any = {
TAG_IDX: tagEntry.TAG_IDX, // SEDP 고유 식별자
TAG_NO: tagEntry.TAG_NO,
TAG_DESC: tagEntry.TAG_DESC || "",
- VNDRCD:vendorRecord[0].vendorCode,
- VNDRNM_1:vendorRecord[0].vendorName,
- status: "From S-EDP" // SEDP에서 가져온 데이터임을 표시
- };
-
+ CLS_ID: tagEntry.CLS_ID || "",
+ VNDRCD: vendorRecord[0].vendorCode,
+ VNDRNM_1: vendorRecord[0].vendorName,
+ status: "From S-EDP", // SEDP에서 가져온 데이터임을 표시
+ ...(projectType === "ship" ? { CM3003: tagEntry.CM3003 } : { ME5074: tagEntry.ME5074 })
+ }
+
// tags 테이블용 데이터 (UPSERT용)
const tagRecord = {
contractItemId: packageId,
@@ -482,7 +484,7 @@ export async function importTagsFromSEDP(
createdAt: new Date(),
updatedAt: new Date()
};
-
+
// ATTRIBUTES 필드에서 shi=true인 컬럼의 값 추출
if (Array.isArray(tagEntry.ATTRIBUTES)) {
for (const attr of tagEntry.ATTRIBUTES) {
@@ -510,15 +512,15 @@ export async function importTagsFromSEDP(
}
}
}
-
+
// 기존 태그가 있는지 확인하고 처리
const existingTag = existingTagMap.get(tagEntry.TAG_IDX);
-
+
if (existingTag) {
// 기존 태그가 있으면 formEntries 업데이트 데이터 준비
const updates: any = {};
let hasUpdates = false;
-
+
for (const key of Object.keys(tagObject)) {
if (key === "TAG_IDX") continue;
@@ -527,20 +529,20 @@ export async function importTagsFromSEDP(
hasUpdates = true;
continue;
}
-
-
+
+
if (key === "TAG_DESC" && tagObject[key] !== existingTag.data[key]) {
updates[key] = tagObject[key];
hasUpdates = true;
continue;
}
-
+
if (key === "status" && tagObject[key] !== existingTag.data[key]) {
updates[key] = tagObject[key];
hasUpdates = true;
continue;
}
-
+
const columnInfo = columnsJSON.find(col => col.key === key);
if (columnInfo && columnInfo.shi === true) {
if (existingTag.data[key] !== tagObject[key]) {
@@ -549,7 +551,7 @@ export async function importTagsFromSEDP(
}
}
}
-
+
if (hasUpdates) {
updateData.push({
entryId: existingTag.entryId,
@@ -561,54 +563,76 @@ export async function importTagsFromSEDP(
// 기존 태그가 없으면 새로 추가
newTagData.push(tagObject);
}
-
+
// tags 테이블에는 항상 upsert (새로 추가되거나 업데이트)
upsertTagRecords.push(tagRecord);
-
+
processedCount++;
} catch (error) {
excludedCount++;
errors.push(`Error processing tag ${tagEntry.TAG_IDX || 'unknown'}: ${error}`);
}
}
-
+
// 진행 상황 보고
if (progressCallback) progressCallback(80);
-
+
// formEntries 업데이트 실행
+ // entryId별로 업데이트를 그룹화
+ const updatesByEntryId = new Map();
+
for (const update of updateData) {
+ if (!updatesByEntryId.has(update.entryId)) {
+ updatesByEntryId.set(update.entryId, []);
+ }
+ updatesByEntryId.get(update.entryId).push(update);
+ }
+
+ // 그룹화된 업데이트를 처리
+ for (const [entryId, updates] of updatesByEntryId) {
try {
- const entry = existingEntries.find(e => e.id === update.entryId);
+ const entry = existingEntries.find(e => e.id === entryId);
if (!entry) continue;
-
+
const data = entry.data as any[];
+
+ // 해당 entryId의 모든 업데이트를 한 번에 적용
const updatedData = data.map(item => {
- if (item.TAG_IDX === update.tagIdx) {
- return { ...item, ...update.updates };
+ let updatedItem = { ...item };
+
+ // 현재 item에 적용할 모든 업데이트를 찾아서 적용
+ for (const update of updates) {
+ if (item.TAG_IDX === update.tagIdx) {
+ updatedItem = { ...updatedItem, ...update.updates };
+ }
}
- return item;
+
+ return updatedItem;
});
-
+
+ // entryId별로 한 번만 DB 업데이트
await tx.update(formEntries)
- .set({
+ .set({
data: updatedData,
updatedAt: new Date()
})
- .where(eq(formEntries.id, update.entryId));
+ .where(eq(formEntries.id, entryId));
+
} catch (error) {
- errors.push(`Error updating formEntry for tag ${update.tagNo}: ${error}`);
+ const tagNos = updates.map(u => u.tagNo || u.tagIdx).join(', ');
+ errors.push(`Error updating formEntry ${entryId} for tags ${tagNos}: ${error}`);
}
}
-
+
// 새 태그 추가 (formEntries)
if (newTagData.length > 0) {
if (existingEntries.length > 0) {
const firstEntry = existingEntries[0];
const existingData = firstEntry.data as any[];
const updatedData = [...existingData, ...newTagData];
-
+
await tx.update(formEntries)
- .set({
+ .set({
data: updatedData,
updatedAt: new Date()
})
@@ -624,27 +648,27 @@ export async function importTagsFromSEDP(
});
}
}
-
+
// tags 테이블 처리 (INSERT + UPDATE 분리)
if (upsertTagRecords.length > 0) {
const newTagRecords: any[] = [];
- const updateTagRecords: {tagId: number, updates: any}[] = [];
-
+ const updateTagRecords: { tagId: number, updates: any }[] = [];
+
// 각 태그를 확인하여 신규/업데이트 분류
for (const tagRecord of upsertTagRecords) {
const existingTagRecord = existingTagsMap.get(tagRecord.tagNo);
-
+
if (existingTagRecord) {
// 기존 태그가 있으면 업데이트 준비
const tagUpdates: any = {};
let hasTagUpdates = false;
- // tagNo도 업데이트 가능 (편집된 경우)
- if (existingTagRecord.tagNo !== tagRecord.tagNo) {
- tagUpdates.tagNo = tagRecord.tagNo;
- hasTagUpdates = true;
- }
-
+ // tagNo도 업데이트 가능 (편집된 경우)
+ if (existingTagRecord.tagNo !== tagRecord.tagNo) {
+ tagUpdates.tagNo = tagRecord.tagNo;
+ hasTagUpdates = true;
+ }
+
if (existingTagRecord.tagType !== tagRecord.tagType) {
tagUpdates.tagType = tagRecord.tagType;
hasTagUpdates = true;
@@ -661,7 +685,7 @@ export async function importTagsFromSEDP(
tagUpdates.formId = tagRecord.formId;
hasTagUpdates = true;
}
-
+
if (hasTagUpdates) {
updateTagRecords.push({
tagId: existingTagRecord.id,
@@ -673,7 +697,7 @@ export async function importTagsFromSEDP(
newTagRecords.push(tagRecord);
}
}
-
+
// 새 태그 삽입
if (newTagRecords.length > 0) {
try {
@@ -697,7 +721,7 @@ export async function importTagsFromSEDP(
}
}
}
-
+
// 기존 태그 업데이트
for (const update of updateTagRecords) {
try {
@@ -709,10 +733,10 @@ export async function importTagsFromSEDP(
}
}
}
-
+
// 진행 상황 보고
if (progressCallback) progressCallback(100);
-
+
// 최종 결과 반환
return {
processedCount,
@@ -722,7 +746,7 @@ export async function importTagsFromSEDP(
errors: errors.length > 0 ? errors : undefined
};
});
-
+
} catch (error: any) {
console.error("Tag import error:", error);
throw error;
@@ -739,10 +763,10 @@ async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Prom
try {
// Get the token
const apiKey = await getSEDPToken();
-
+
// Define the API base URL
const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
-
+
// Make the API call
const response = await fetch(
`${SEDP_API_BASE_URL}/Data/GetPubData`,
@@ -761,12 +785,12 @@ async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Prom
})
}
);
-
+
if (!response.ok) {
const errorText = await response.text();
throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`);
}
-
+
const data = await response.json();
return data;
} catch (error: any) {
diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts
index 6ae2e675..1f903c78 100644
--- a/lib/sedp/sync-form.ts
+++ b/lib/sedp/sync-form.ts
@@ -983,7 +983,7 @@ export async function saveFormMappingsAndMetas(
if (!attribute) continue;
const tmplMeta = templateAttrMap.get(attId);
- const isShi = mapAtt.INOUT === "IN";
+ const isShi = mapAtt.INOUT === null || mapAtt.INOUT === "OUT";
let uomSymbol: string | undefined; let uomId: string | undefined;
if (legacy?.LNK_ATT) {
@@ -995,7 +995,7 @@ export async function saveFormMappingsAndMetas(
key: attId,
label: attribute.DESC as string,
type: (attribute.VAL_TYPE === "LIST" || attribute.VAL_TYPE === "DYNAMICLIST") ? "LIST" : (attribute.VAL_TYPE || "STRING"),
- shi: !isShi,
+ shi: isShi,
hidden: tmplMeta?.hidden ?? false,
seq: tmplMeta?.seq ?? 0,
head: tmplMeta?.head ?? "",
diff --git a/lib/site-visit/service.ts b/lib/site-visit/service.ts
index b525fabe..8db05ce4 100644
--- a/lib/site-visit/service.ts
+++ b/lib/site-visit/service.ts
@@ -148,7 +148,7 @@ export async function createSiteVisitRequestAction(input: {
const senderResult = await db
.select()
.from(users)
- .where(eq(users.id, siteVisitRequest.requesterId))
+ .where(eq(users.id, siteVisitRequest.requesterId!))
.limit(1);
const sender = senderResult[0];
@@ -160,7 +160,7 @@ export async function createSiteVisitRequestAction(input: {
const deadlineDate = format(new Date(), 'yyyy.MM.dd');
// SHI 참석자 정보 파싱 (새로운 구조에 맞게)
- const shiAttendees = input.shiAttendees as Record<string, { checked: boolean; count: number; details?: string }>;
+ const shiAttendees = input.shiAttendees as any;
// 메일 제목
const subject = `[SHI Audit] 방문실사 시행 안내 및 실사 관련 추가정보 요청 _ ${vendor.vendorName} (${vendor.vendorCode}, 사업자번호: ${vendor.taxId})`;
@@ -176,7 +176,7 @@ export async function createSiteVisitRequestAction(input: {
// 실사 정보
investigationMethod: investigation.investigationMethod,
- investigationMethodDescription: investigation.investigationMethodDescription,
+ // investigationMethodDescription: investigation.investigationMethodDescription,
requestedStartDate: format(siteVisitRequest.requestedStartDate!, 'yyyy.MM.dd'),
requestedEndDate: format(siteVisitRequest.requestedEndDate!, 'yyyy.MM.dd'),
inspectionDuration: siteVisitRequest.inspectionDuration,
@@ -203,22 +203,17 @@ export async function createSiteVisitRequestAction(input: {
}),
shiAttendeeDetails: input.shiAttendeeDetails || null,
- // 협력업체 요청 정보
- vendorRequests: Object.keys(siteVisitRequest.vendorRequests as Record<string, boolean>)
- .filter(key => (siteVisitRequest.vendorRequests as Record<string, boolean>)[key])
- .map(key => {
- const requestLabels = {
- 'factoryName': '○ 실사공장명',
- 'factoryLocation': '○ 실사공장 주소',
- 'factoryDirections': '○ 실사공장 가는 방법',
- 'factoryPicName': '○ 실사공장 Contact Point',
- 'factoryPicPhone': '○ 실사공장 연락처',
- 'factoryPicEmail': '○ 실사공장 이메일',
- 'attendees': '○ 실사 참석 예정인력',
- 'accessProcedure': '○ 공장 출입절차 및 준비물'
- };
- return requestLabels[key as keyof typeof requestLabels] || key;
- }),
+ // 협력업체 요청 정보 (default 값으로 고정)
+ vendorRequests: [
+ ' 실사공장명',
+ ' 실사공장 주소',
+ ' 실사공장 가는 방법',
+ ' 실사공장 Contact Point',
+ ' 실사공장 연락처',
+ ' 실사공장 이메일',
+ ' 실사 참석 예정인력',
+ ' 공장 출입절차 및 준비물'
+ ],
otherVendorRequests: input.otherVendorRequests,
// 추가 요청사항
@@ -233,11 +228,12 @@ export async function createSiteVisitRequestAction(input: {
// 메일 발송 (벤더 이메일로 직접 발송)
await sendEmail({
- to: vendor.email,
+ to: vendor.email || '',
+ cc: sender.email,
subject,
template: 'site-visit-request' as string,
context,
- cc: vendor.email !== sender.email ? sender.email : undefined
+ // cc: vendor.email !== sender.email ? sender.email : undefined
});
console.log('방문실사 요청 메일 발송 완료:', {
@@ -260,6 +256,7 @@ export async function createSiteVisitRequestAction(input: {
}
revalidatePath("/evcp/pq_new");
+ revalidatePath("/partners/site-visit");
return {
success: true,
@@ -513,7 +510,7 @@ export async function getSiteVisitRequestAction(investigationId: number) {
hasAttachments: input.hasAttachments,
otherInfo: input.otherInfo,
- submittedBy: session.user.id,
+ submittedBy: Number(session.user.id),
});
}
@@ -585,6 +582,7 @@ export async function getSiteVisitRequestAction(investigationId: number) {
})
.where(eq(siteVisitRequests.id, input.siteVisitRequestId));
+ revalidatePath("/evcp/pq_new");
revalidatePath("/partners/site-visit");
return {
diff --git a/lib/site-visit/site-visit-detail-dialog.tsx b/lib/site-visit/site-visit-detail-dialog.tsx
index 3043f358..18ab6bb5 100644
--- a/lib/site-visit/site-visit-detail-dialog.tsx
+++ b/lib/site-visit/site-visit-detail-dialog.tsx
@@ -139,7 +139,7 @@ export function SiteVisitDetailDialog({
</div>
<div>
- <h4 className="font-semibold mb-2">공장 PIC 정보</h4>
+ <h4 className="font-semibold mb-2">공장 담당자 정보</h4>
<div className="space-y-2 text-sm">
<div><span className="font-medium">이름:</span> {selectedRequest.vendorInfo.factoryPicName}</div>
<div><span className="font-medium">전화번호:</span> {selectedRequest.vendorInfo.factoryPicPhone}</div>
diff --git a/lib/site-visit/vendor-info-sheet.tsx b/lib/site-visit/vendor-info-sheet.tsx
index c0b1ab7e..f72766fe 100644
--- a/lib/site-visit/vendor-info-sheet.tsx
+++ b/lib/site-visit/vendor-info-sheet.tsx
@@ -36,8 +36,8 @@ const vendorInfoSchema = z.object({
factoryAddress: z.string().min(1, "공장주소를 입력해주세요."),
// 공장 PIC 정보
- factoryPicName: z.string().min(1, "공장 PIC 이름을 입력해주세요."),
- factoryPicPhone: z.string().min(1, "공장 PIC 전화번호를 입력해주세요."),
+ factoryPicName: z.string().min(1, "공장 담당자 이름을 입력해주세요."),
+ factoryPicPhone: z.string().min(1, "공장 담당자 전화번호를 입력해주세요."),
factoryPicEmail: z.string().email("올바른 이메일 주소를 입력해주세요."),
// 공장 가는 법
@@ -166,7 +166,7 @@ export function VendorInfoSheet({
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
- <SheetContent className="w-[600px] sm:w-[700px] overflow-y-auto">
+ <SheetContent className="w-[800px] sm:max-w-xl max-w-[95vw] overflow-y-auto space-y-2">
<SheetHeader>
<SheetTitle>협력업체 정보 입력</SheetTitle>
<SheetDescription>
@@ -178,9 +178,9 @@ export function VendorInfoSheet({
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
{/* 공장 정보 */}
<div className="space-y-4">
- <h3 className="text-lg font-semibold">공장 정보</h3>
+ <h3 className="text-lg font-semibold border-b pb-2">공장 정보</h3>
- <div className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="factoryName"
@@ -209,32 +209,34 @@ export function VendorInfoSheet({
)}
/>
- <FormField
- control={form.control}
- name="factoryAddress"
- render={({ field }) => (
- <FormItem>
- <FormLabel>공장주소 *</FormLabel>
- <FormControl>
- <Textarea
- placeholder="상세 주소를 입력하세요"
- {...field}
- disabled={isPending}
- className="min-h-[80px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ <div className="md:col-span-2">
+ <FormField
+ control={form.control}
+ name="factoryAddress"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장주소 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 주소를 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
</div>
</div>
{/* 공장 PIC 정보 */}
<div className="space-y-4">
- <h3 className="text-lg font-semibold">공장 PIC 정보</h3>
+ <h3 className="text-lg font-semibold border-b pb-2">공장 담당자 정보</h3>
- <div className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="factoryPicName"
@@ -279,59 +281,56 @@ export function VendorInfoSheet({
</div>
</div>
- {/* 공장 가는 법 */}
+ {/* 공장 정보 상세 */}
<div className="space-y-4">
- <h3 className="text-lg font-semibold">공장 가는 법</h3>
+ <h3 className="text-lg font-semibold border-b pb-2">공장 정보 상세</h3>
- <FormField
- control={form.control}
- name="factoryDirections"
- render={({ field }) => (
- <FormItem>
- <FormLabel>공장 가는 법 *</FormLabel>
- <FormControl>
- <Textarea
- placeholder="공항에서 공장까지 가는 방법, 대중교통 정보 등을 상세히 입력하세요"
- {...field}
- disabled={isPending}
- className="min-h-[100px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* 공장 출입절차 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">공장 출입절차</h3>
-
- <FormField
- control={form.control}
- name="accessProcedure"
- render={({ field }) => (
- <FormItem>
- <FormLabel>공장 출입절차 *</FormLabel>
- <FormControl>
- <Textarea
- placeholder="신분증 제출, 출입증 교환, 준비물 등 출입 절차를 상세히 입력하세요"
- {...field}
- disabled={isPending}
- className="min-h-[100px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="factoryDirections"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장 가는 법 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="공항에서 공장까지 가는 방법, 대중교통 정보 등을 상세히 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[120px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="accessProcedure"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장 출입절차 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="신분증 제출, 출입증 교환, 준비물 등 출입 절차를 상세히 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[120px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
</div>
{/* 첨부파일 */}
<div className="space-y-4">
- <h3 className="text-lg font-semibold">첨부파일</h3>
+ <h3 className="text-lg font-semibold border-b pb-2">첨부파일</h3>
{/* 파일 업로드 */}
<div className="space-y-2">
@@ -399,7 +398,7 @@ export function VendorInfoSheet({
{/* 기타 정보 */}
<div className="space-y-4">
- <h3 className="text-lg font-semibold">기타 정보</h3>
+ <h3 className="text-lg font-semibold border-b pb-2">기타 정보</h3>
<FormField
control={form.control}
@@ -412,7 +411,7 @@ export function VendorInfoSheet({
placeholder="추가로 전달하고 싶은 정보가 있다면 입력하세요"
{...field}
disabled={isPending}
- className="min-h-[80px]"
+ className="min-h-[100px]"
/>
</FormControl>
<FormMessage />
diff --git a/lib/site-visit/vendor-info-view-dialog.tsx b/lib/site-visit/vendor-info-view-dialog.tsx
index b9daf83e..b6e8111d 100644
--- a/lib/site-visit/vendor-info-view-dialog.tsx
+++ b/lib/site-visit/vendor-info-view-dialog.tsx
@@ -137,7 +137,7 @@ export function VendorInfoViewDialog({
</div>
<div>
- <h4 className="font-semibold mb-2">공장 PIC 정보</h4>
+ <h4 className="font-semibold mb-2">공장 담당자 정보</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<User className="h-4 w-4" />
diff --git a/lib/tags/service.ts b/lib/tags/service.ts
index bec342e1..bb59287e 100644
--- a/lib/tags/service.ts
+++ b/lib/tags/service.ts
@@ -13,7 +13,7 @@ import { getErrorMessage } from "../handle-error";
import { getFormMappingsByTagType } from './form-mapping-service';
import { contractItems, contracts } from "@/db/schema/contract";
import { getCodeListsByID } from "../sedp/sync-object-class";
-import { projects } from "@/db/schema";
+import { projects, vendors } from "@/db/schema";
import { randomBytes } from 'crypto';
// 폼 결과를 위한 인터페이스 정의
@@ -390,7 +390,8 @@ export async function createTag(
export async function createTagInForm(
formData: CreateTagSchema,
selectedPackageId: number | null,
- formCode: string
+ formCode: string,
+ packageCode: string
) {
if (!selectedPackageId) {
return { error: "No selectedPackageId provided" }
@@ -412,7 +413,8 @@ export async function createTagInForm(
const contractItemResult = await tx
.select({
contractId: contractItems.contractId,
- projectId: contracts.projectId // projectId 추가
+ projectId: contracts.projectId, // projectId 추가
+ vendorId: contracts.vendorId // projectId 추가
})
.from(contractItems)
.innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
@@ -425,6 +427,15 @@ export async function createTagInForm(
const contractId = contractItemResult[0].contractId
const projectId = contractItemResult[0].projectId
+ const vendorId = contractItemResult[0].vendorId
+
+ const vendor = await db.query.vendors.findFirst({
+ where: eq(vendors.id, vendorId)
+ });
+
+ if (!vendor) {
+ return { error: "선택한 벤더를 찾을 수 없습니다." };
+ }
// 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인
const duplicateCheck = await tx
@@ -560,6 +571,12 @@ export async function createTagInForm(
TAG_IDX: generatedTagIdx, // 🆕 같은 16진수 24자리 값 사용
TAG_NO: validated.data.tagNo,
TAG_DESC: validated.data.description ?? null,
+ CLS_ID: validated.data.class,
+ VNDRCD: vendor.vendorCode,
+ VNDRNM_1: vendor.vendorName,
+ CM3003: packageCode,
+ ME5074: packageCode,
+
status: "New" // 수동으로 생성된 태그임을 표시
};
@@ -596,6 +613,17 @@ export async function createTagInForm(
});
}
+ // 12) 성공 시 반환
+ return {
+ success: true,
+ data: {
+ formId: form.id,
+ tagNo: validated.data.tagNo,
+ tagIdx: generatedTagIdx, // 🆕 생성된 tagIdx도 반환
+ formCreated: !form // form이 새로 생성되었는지 여부
+ }
+ }
+
console.log(`[CREATE TAG IN FORM] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`)
} else {
return { error: "Failed to create or find form" };
@@ -607,16 +635,7 @@ export async function createTagInForm(
revalidateTag(`form-data-${formCode}-${selectedPackageId}`) // 폼 데이터 캐시도 무효화
revalidateTag("tags")
- // 12) 성공 시 반환
- return {
- success: true,
- data: {
- formId: form.id,
- tagNo: validated.data.tagNo,
- tagIdx: generatedTagIdx, // 🆕 생성된 tagIdx도 반환
- formCreated: !form // form이 새로 생성되었는지 여부
- }
- }
+
})
} catch (err: any) {
console.log("createTag in Form error:", err)
diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts
index d2cec15d..05ace8d5 100644
--- a/lib/vendor-document-list/enhanced-document-service.ts
+++ b/lib/vendor-document-list/enhanced-document-service.ts
@@ -4,7 +4,7 @@
import { revalidatePath, unstable_cache } from "next/cache"
import { and, asc, desc, eq, ilike, or, count, avg, inArray, sql } from "drizzle-orm"
import db from "@/db/db"
-import { documentAttachments, documentStagesOnlyView, documents, enhancedDocumentsView, issueStages, revisions, simplifiedDocumentsView, type EnhancedDocumentsView } from "@/db/schema/vendorDocu"
+import { StageDocumentsView, documentAttachments, documentStagesOnlyView, documents, enhancedDocumentsView, issueStages, revisions, simplifiedDocumentsView, type EnhancedDocumentsView } from "@/db/schema/vendorDocu"
import { filterColumns } from "@/lib/filter-columns"
import type {
CreateDocumentInput,
@@ -42,6 +42,22 @@ export interface GetEnhancedDocumentsSchema {
}>
}
+export interface GetDocumentsSchema {
+ page: number
+ perPage: number
+ search?: string
+ filters?: Array<{
+ id: string
+ value: string | string[]
+ operator?: "eq" | "ne" | "like" | "ilike" | "in" | "notin" | "lt" | "lte" | "gt" | "gte"
+ }>
+ joinOperator?: "and" | "or"
+ sort?: Array<{
+ id: keyof StageDocumentsView
+ desc: boolean
+ }>
+}
+
// Repository 함수들
export async function selectEnhancedDocuments(
tx: any,
@@ -1024,19 +1040,7 @@ export async function getDocumentDetails(documentId: number) {
if (!companyId) {
return { data: [], pageCount: 0, total: 0, drawingKind: null, vendorInfo: null }
}
-
- // 2. 해당 벤더의 모든 계약 ID들 조회
- const vendorContracts = await db
- .select({ projectId: contracts.projectId, contractId:contracts.id })
- .from(contracts)
- .where(eq(contracts.vendorId, companyId))
-
- const contractIds = vendorContracts.map(c => c.contractId)
-
- if (contractIds.length === 0) {
- return { data: [], pageCount: 0, total: 0, drawingKind: null, vendorInfo: null }
- }
-
+
// 3. 고급 필터 처리
const advancedWhere = filterColumns({
table: simplifiedDocumentsView,
@@ -1057,7 +1061,7 @@ export async function getDocumentDetails(documentId: number) {
// 5. 최종 WHERE 조건 (계약 ID들로 필터링)
const finalWhere = and(
- inArray(simplifiedDocumentsView.contractId, contractIds),
+ eq(simplifiedDocumentsView.vendorId, Number(companyId)),
advancedWhere,
globalWhere,
)
@@ -1133,32 +1137,18 @@ export async function getDocumentDetails(documentId: number) {
}
const companyId = session?.user?.companyId;
-
if (!companyId) {
return { stats: {}, totalDocuments: 0, primaryDrawingKind: null }
}
-
- // 해당 벤더의 계약 ID들 조회
- const vendorContracts = await db
- .select({ id: contracts.id })
- .from(contracts)
- .where(eq(contracts.vendorId, companyId))
-
- const contractIds = vendorContracts.map(c => c.id)
-
- if (contractIds.length === 0) {
- return { stats: {}, totalDocuments: 0, primaryDrawingKind: null }
- }
-
+
// DrawingKind별 통계 조회
const documents = await db
.select({
drawingKind: simplifiedDocumentsView.drawingKind,
- contractId: simplifiedDocumentsView.contractId,
})
.from(simplifiedDocumentsView)
- .where(inArray(simplifiedDocumentsView.contractId, contractIds))
+ .where(eq(simplifiedDocumentsView.vendorId, Number(companyId)))
// 통계 계산
const stats = documents.reduce((acc, doc) => {
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts
index 84e4263c..216e373e 100644
--- a/lib/vendor-document-list/import-service.ts
+++ b/lib/vendor-document-list/import-service.ts
@@ -9,6 +9,8 @@ import { v4 as uuidv4 } from "uuid"
import { extname } from "path"
import * as crypto from "crypto"
import { debugError, debugWarn, debugSuccess, debugProcess } from "@/lib/debug-utils"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
export interface ImportResult {
success: boolean
@@ -155,11 +157,11 @@ class ImportService {
throw new Error(`Project code or vendor code not found for contract ${projectId}`)
}
- debugLog(`계약 정보 조회 완료`, {
- projectId,
- projectCode: contractInfo.projectCode,
- vendorCode: contractInfo.vendorCode
- })
+ // debugLog(`계약 정보 조회 완료`, {
+ // projectId,
+ // projectCode: contractInfo.projectCode,
+ // vendorCode: contractInfo.vendorCode
+ // })
// 2. 각 drawingKind별로 데이터 조회
const allDocuments: DOLCEDocument[] = []
@@ -324,6 +326,13 @@ class ImportService {
projectCode: string;
vendorCode: string;
} | null> {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+
const [result] = await db
.select({
projectCode: projects.code,
@@ -332,7 +341,7 @@ class ImportService {
.from(contracts)
.innerJoin(projects, eq(contracts.projectId, projects.id))
.innerJoin(vendors, eq(contracts.vendorId, vendors.id))
- .where(eq(contracts.projectId, projectId))
+ .where(and(eq(contracts.projectId, projectId),eq(contracts.vendorId, Number(session.user.companyId))))
.limit(1)
return result?.projectCode && result?.vendorCode
@@ -633,6 +642,15 @@ class ImportService {
dolceDoc: DOLCEDocument,
sourceSystem: string
): Promise<'NEW' | 'UPDATED' | 'SKIPPED'> {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const vendorId = Number(session.user.companyId)
+
+
// 기존 문서 조회 (문서 번호로)
const existingDoc = await db
.select()
@@ -646,6 +664,7 @@ class ImportService {
// DOLCE 문서를 DB 스키마에 맞게 변환
const documentData = {
projectId,
+ vendorId,
docNumber: dolceDoc.DrawingNo,
title: dolceDoc.DrawingName,
status: 'ACTIVE',
diff --git a/lib/vendor-document-list/plant/document-stage-validations.ts b/lib/vendor-document-list/plant/document-stage-validations.ts
index 037293e3..434459a7 100644
--- a/lib/vendor-document-list/plant/document-stage-validations.ts
+++ b/lib/vendor-document-list/plant/document-stage-validations.ts
@@ -8,7 +8,7 @@ import {
parseAsStringEnum,
} from "nuqs/server"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { DocumentStagesOnlyView } from "@/db/schema"
+import { DocumentStagesOnlyView, StageDocumentsView } from "@/db/schema"
// =============================================================================
// 1. 문서 관련 스키마들
@@ -153,7 +153,7 @@ export const searchParamsSchema = z.object({
export const documentStageSearchParamsCache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<DocumentStagesOnlyView>().withDefault([
+ sort: getSortingStateParser<StageDocumentsView>().withDefault([
{ id: "createdAt", desc: true },
]),
@@ -161,15 +161,6 @@ export const documentStageSearchParamsCache = createSearchParamsCache({
filters: getFiltersStateParser().withDefault([]),
joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
search: parseAsString.withDefault(""),
-
- // 문서 스테이지 전용 필터들
- drawingKind: parseAsStringEnum(["all", "B3", "B4", "B5"]).withDefault("all"),
- stageStatus: parseAsStringEnum(["all", "PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"]).withDefault("all"),
- priority: parseAsStringEnum(["all", "HIGH", "MEDIUM", "LOW"]).withDefault("all"),
- isOverdue: parseAsStringEnum(["all", "true", "false"]).withDefault("all"),
- assignee: parseAsString.withDefault(""),
- dateFrom: parseAsString.withDefault(""),
- dateTo: parseAsString.withDefault(""),
})
// =============================================================================
diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx
index 7456c2aa..742b8a8a 100644
--- a/lib/vendor-document-list/plant/document-stages-columns.tsx
+++ b/lib/vendor-document-list/plant/document-stages-columns.tsx
@@ -272,22 +272,22 @@ export function getDocumentStagesColumns({
),
cell: ({ row }) => {
const doc = row.original
- if (!doc.currentStageName) {
- return (
- <Button
- size="sm"
- variant="outline"
- onClick={(e) => {
- e.stopPropagation()
- setRowAction({ row, type: "add_stage" })
- }}
- className="h-6 text-xs"
- >
- <Plus className="w-3 h-3 mr-1" />
- Add stage
- </Button>
- )
- }
+ // if (!doc.currentStageName) {
+ // return (
+ // <Button
+ // size="sm"
+ // variant="outline"
+ // onClick={(e) => {
+ // e.stopPropagation()
+ // setRowAction({ row, type: "add_stage" })
+ // }}
+ // className="h-6 text-xs"
+ // >
+ // <Plus className="w-3 h-3 mr-1" />
+ // Add stage
+ // </Button>
+ // )
+ // }
return (
<div className="flex items-center gap-2">
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index 1e60a062..c6a891c8 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -4,7 +4,7 @@
import { revalidatePath, revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import db from "@/db/db"
-import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages } from "@/db/schema"
+import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema"
import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm"
import {
createDocumentSchema,
@@ -28,7 +28,7 @@ import {
} from "./document-stage-validations"
import { unstable_noStore as noStore } from "next/cache"
import { filterColumns } from "@/lib/filter-columns"
-import { GetEnhancedDocumentsSchema } from "../enhanced-document-service"
+import { GetEnhancedDocumentsSchema, GetDocumentsSchema } from "../enhanced-document-service"
import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository"
interface UpdateDocumentData {
@@ -914,21 +914,20 @@ export async function createDocument(data: CreateDocumentData) {
columns: {
id: true,
projectId: true,
+ vendorId: true,
},
})
if (!contract) {
return { success: false, error: "유효하지 않은 계약(ID)입니다." }
}
- const { projectId } = contract
+ const { projectId, vendorId } = contract
/* ──────────────────────────────── 1. 문서번호 타입 설정 조회 ─────────────────────────────── */
const configsResult = await getDocumentNumberTypeConfigs(
data.documentNumberTypeId
)
- console.log(configsResult, "configsResult")
-
if (!configsResult.success) {
return { success: false, error: configsResult.error }
}
@@ -937,7 +936,8 @@ export async function createDocument(data: CreateDocumentData) {
/* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */
const insertData = {
// 필수
- projectId, // ★ 새로 추가
+ projectId,
+ vendorId, // ★ 새로 추가
contractId: data.contractId,
docNumber: data.docNumber,
title: data.title,
@@ -946,20 +946,19 @@ export async function createDocument(data: CreateDocumentData) {
updatedAt: new Date(),
// 선택
- pic: data.pic ?? null,
- vendorDocNumber: data.vendorDocNumber ?? null,
+ vendorDocNumber: data.vendorDocNumber === null || data.vendorDocNumber ==='' ? null: data.vendorDocNumber ,
}
const [document] = await db
- .insert(documents)
+ .insert(stageDocuments)
.values(insertData)
.onConflictDoNothing({
// ★ 유니크 키가 projectId 기반이라면 target 도 같이 변경
target: [
- documents.projectId,
- documents.docNumber,
- documents.status,
+ stageDocuments.projectId,
+ stageDocuments.docNumber,
+ stageDocuments.status,
],
})
.returning()
@@ -975,22 +974,27 @@ export async function createDocument(data: CreateDocumentData) {
const stageOptionsResult = await getDocumentClassOptions(
data.documentClassId
)
+
+
+ console.log(data.documentClassId,"documentClassId")
+ console.log(stageOptionsResult.data)
+
if (stageOptionsResult.success && stageOptionsResult.data.length > 0) {
const now = new Date()
const stageInserts = stageOptionsResult.data.map((opt, idx) => ({
documentId: document.id,
- stageName: opt.optionValue,
+ stageName: opt.optionCode,
stageOrder: opt.sortOrder ?? idx + 1,
stageStatus: "PLANNED" as const,
planDate: data.planDates[opt.id] ?? null,
createdAt: now,
updatedAt: now,
}))
- await db.insert(issueStages).values(stageInserts)
+ await db.insert(stageIssueStages).values(stageInserts)
}
/* ──────────────────────────────── 5. 캐시 무효화 및 응답 ─────────────────────────────── */
- revalidatePath(`/contracts/${data.contractId}/documents`)
+ revalidatePath(`/partners/${data.contractId}/document-list-only`)
return {
success: true,
@@ -1004,7 +1008,7 @@ export async function createDocument(data: CreateDocumentData) {
export async function getDocumentStagesOnly(
- input: GetEnhancedDocumentsSchema,
+ input: GetDocumentsSchema,
contractId: number
) {
try {
@@ -1012,7 +1016,7 @@ export async function getDocumentStagesOnly(
// 고급 필터 처리
const advancedWhere = filterColumns({
- table: documentStagesOnlyView,
+ table: stageDocumentsView,
filters: input.filters || [],
joinOperator: input.joinOperator || "and",
})
@@ -1022,12 +1026,11 @@ export async function getDocumentStagesOnly(
if (input.search) {
const searchTerm = `%${input.search}%`
globalWhere = or(
- ilike(documentStagesOnlyView.title, searchTerm),
- ilike(documentStagesOnlyView.docNumber, searchTerm),
- ilike(documentStagesOnlyView.currentStageName, searchTerm),
- ilike(documentStagesOnlyView.currentStageAssigneeName, searchTerm),
- ilike(documentStagesOnlyView.vendorDocNumber, searchTerm),
- ilike(documentStagesOnlyView.pic, searchTerm)
+ ilike(stageDocumentsView.title, searchTerm),
+ ilike(stageDocumentsView.docNumber, searchTerm),
+ ilike(stageDocumentsView.currentStageName, searchTerm),
+ ilike(stageDocumentsView.currentStageAssigneeName, searchTerm),
+ ilike(stageDocumentsView.vendorDocNumber, searchTerm),
)
}
@@ -1035,17 +1038,17 @@ export async function getDocumentStagesOnly(
const finalWhere = and(
advancedWhere,
globalWhere,
- eq(documentStagesOnlyView.contractId, contractId)
+ eq(stageDocumentsView.contractId, contractId)
)
// 정렬 처리
const orderBy = input.sort && input.sort.length > 0
? input.sort.map((item) =>
item.desc
- ? desc(documentStagesOnlyView[item.id])
- : asc(documentStagesOnlyView[item.id])
+ ? desc(stageDocumentsView[item.id])
+ : asc(stageDocumentsView[item.id])
)
- : [desc(documentStagesOnlyView.createdAt)]
+ : [desc(stageDocumentsView.createdAt)]
// 트랜잭션 실행
const { data, total } = await db.transaction(async (tx) => {
@@ -1075,8 +1078,8 @@ export async function getDocumentStagesOnlyById(documentId: number) {
try {
const result = await db
.select()
- .from(documentStagesOnlyView)
- .where(eq(documentStagesOnlyView.documentId, documentId))
+ .from(stageDocumentsView)
+ .where(eq(stageDocumentsView.documentId, documentId))
.limit(1)
return result[0] || null
@@ -1091,8 +1094,8 @@ export async function getDocumentStagesOnlyCount(contractId: number) {
try {
const result = await db
.select({ count: sql<number>`count(*)` })
- .from(documentStagesOnlyView)
- .where(eq(documentStagesOnlyView.contractId, contractId))
+ .from(stageDocumentsView)
+ .where(eq(stageDocumentsView.contractId, contractId))
return result[0]?.count ?? 0
} catch (err) {
@@ -1113,8 +1116,8 @@ export async function getDocumentProgressStats(contractId: number) {
overdueDocuments: sql<number>`count(case when is_overdue = true then 1 end)`,
avgProgress: sql<number>`round(avg(progress_percentage), 2)`,
})
- .from(documentStagesOnlyView)
- .where(eq(documentStagesOnlyView.contractId, contractId))
+ .from(stageDocumentsView)
+ .where(eq(stageDocumentsView.contractId, contractId))
return result[0] || {
totalDocuments: 0,
@@ -1142,16 +1145,16 @@ export async function getDocumentsByStageStats(contractId: number) {
try {
const result = await db
.select({
- stageName: documentStagesOnlyView.currentStageName,
- stageStatus: documentStagesOnlyView.currentStageStatus,
+ stageName: stageDocumentsView.currentStageName,
+ stageStatus: stageDocumentsView.currentStageStatus,
documentCount: sql<number>`count(*)`,
overdueCount: sql<number>`count(case when is_overdue = true then 1 end)`,
})
- .from(documentStagesOnlyView)
- .where(eq(documentStagesOnlyView.contractId, contractId))
+ .from(stageDocumentsView)
+ .where(eq(stageDocumentsView.contractId, contractId))
.groupBy(
- documentStagesOnlyView.currentStageName,
- documentStagesOnlyView.currentStageStatus
+ stageDocumentsView.currentStageName,
+ stageDocumentsView.currentStageStatus
)
.orderBy(sql`count(*) desc`)
diff --git a/lib/vendor-document-list/repository.ts b/lib/vendor-document-list/repository.ts
index 4eab3853..ab47f013 100644
--- a/lib/vendor-document-list/repository.ts
+++ b/lib/vendor-document-list/repository.ts
@@ -1,5 +1,5 @@
import db from "@/db/db";
-import { documentStagesOnlyView, documentStagesView } from "@/db/schema/vendorDocu";
+import { stageDocumentsView, documentStagesView } from "@/db/schema/vendorDocu";
import {
eq,
inArray,
@@ -58,7 +58,7 @@ export async function selectDocumentStagesOnly(
return tx
.select()
- .from(documentStagesOnlyView)
+ .from(stageDocumentsView)
.where(where)
.orderBy(...(orderBy ?? []))
.offset(offset)
@@ -70,7 +70,7 @@ export async function countDocumentStagesOnly(
tx: PgTransaction<any, any, any>,
where?: any
) {
- const res = await tx.select({ count: count() }).from(documentStagesOnlyView).where(where);
+ const res = await tx.select({ count: count() }).from(stageDocumentsView).where(where);
return res[0]?.count ?? 0;
}
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts
index 81eabc37..f0eb411e 100644
--- a/lib/vendor-investigation/service.ts
+++ b/lib/vendor-investigation/service.ts
@@ -3,7 +3,7 @@
import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors } from "@/db/schema/"
import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema } from "./validations"
import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
-import { revalidateTag, unstable_noStore } from "next/cache";
+import { revalidateTag, unstable_noStore, revalidatePath } from "next/cache";
import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
import { getErrorMessage } from "@/lib/handle-error";
@@ -293,6 +293,10 @@ export async function updateVendorInvestigationAction(formData: FormData) {
if (parsed.investigationNotes !== undefined) {
updateData.investigationNotes = parsed.investigationNotes
}
+ // evaluationType이 null이 아니고, status가 계획중(PLANNED) 이라면, 진행중(IN_PROGRESS)으로 바꿔주는 로직 추가
+ if (parsed.evaluationResult !== null && parsed.investigationStatus === "PLANNED") {
+ updateData.investigationStatus = "IN_PROGRESS";
+ }
// 5) vendor_investigations 테이블 업데이트
await db
@@ -302,6 +306,9 @@ export async function updateVendorInvestigationAction(formData: FormData) {
// 6) 캐시 무효화
revalidateTag("vendors-in-investigation")
+ revalidateTag("pq-submissions")
+ revalidateTag("vendor-pq-submissions")
+ revalidatePath("/evcp/pq_new")
return { data: "OK", error: null }
} catch (err: unknown) {
diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx
index 3d765179..521befa9 100644
--- a/lib/vendor-investigation/table/investigation-table-columns.tsx
+++ b/lib/vendor-investigation/table/investigation-table-columns.tsx
@@ -179,6 +179,23 @@ export function getColumns({
)
}
+ // Handle pqItems
+ if (column.id === "pqItems") {
+ if (!value) return <span className="text-muted-foreground">-</span>
+ const items = typeof value === 'string' ? JSON.parse(value as string) : value;
+ if (Array.isArray(items) && items.length > 0) {
+ const firstItem = items[0];
+ return (
+ <div className="flex items-center gap-2">
+ <span className="text-sm">{firstItem.itemCode} - {firstItem.itemName}</span>
+ {items.length > 1 && (
+ <span className="text-xs text-muted-foreground">외 {items.length - 1}건</span>
+ )}
+ </div>
+ );
+ }
+ }
+
// Handle IDs for pqSubmissionId (keeping for reference)
if (column.id === "pqSubmissionId") {
return value ? `#${value}` : ""
diff --git a/lib/vendor-registration-status/repository.ts b/lib/vendor-registration-status/repository.ts
new file mode 100644
index 00000000..f9c3d63f
--- /dev/null
+++ b/lib/vendor-registration-status/repository.ts
@@ -0,0 +1,165 @@
+import { db } from "@/db"
+import {
+ vendorBusinessContacts,
+ vendorAdditionalInfo,
+ vendors
+} from "@/db/schema"
+import { eq, and, inArray } from "drizzle-orm"
+
+// 업무담당자 정보 타입
+export interface VendorBusinessContact {
+ id: number
+ vendorId: number
+ contactType: "sales" | "design" | "delivery" | "quality" | "tax_invoice"
+ contactName: string
+ position: string
+ department: string
+ responsibility: string
+ email: string
+ createdAt: Date
+ updatedAt: Date
+}
+
+// 추가정보 타입
+export interface VendorAdditionalInfo {
+ id: number
+ vendorId: number
+ businessType?: string
+ industryType?: string
+ companySize?: string
+ revenue?: string
+ factoryEstablishedDate?: Date
+ preferredContractTerms?: string
+ createdAt: Date
+ updatedAt: Date
+}
+
+// 업무담당자 정보 조회
+export async function getBusinessContactsByVendorId(vendorId: number): Promise<VendorBusinessContact[]> {
+ try {
+ return await db
+ .select()
+ .from(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendorId))
+ .orderBy(vendorBusinessContacts.contactType)
+ } catch (error) {
+ console.error("Error fetching business contacts:", error)
+ throw new Error("업무담당자 정보를 가져오는 중 오류가 발생했습니다.")
+ }
+}
+
+// 업무담당자 정보 저장/업데이트
+export async function upsertBusinessContacts(
+ vendorId: number,
+ contacts: Omit<VendorBusinessContact, "id" | "vendorId" | "createdAt" | "updatedAt">[]
+): Promise<void> {
+ try {
+ // 기존 데이터 삭제
+ await db
+ .delete(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendorId))
+
+ // 새 데이터 삽입
+ if (contacts.length > 0) {
+ await db
+ .insert(vendorBusinessContacts)
+ .values(contacts.map(contact => ({
+ ...contact,
+ vendorId,
+ })))
+ }
+ } catch (error) {
+ console.error("Error upserting business contacts:", error)
+ throw new Error("업무담당자 정보 저장 중 오류가 발생했습니다.")
+ }
+}
+
+// 추가정보 조회
+export async function getAdditionalInfoByVendorId(vendorId: number): Promise<VendorAdditionalInfo | null> {
+ try {
+ const result = await db
+ .select()
+ .from(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ .limit(1)
+
+ return result[0] || null
+ } catch (error) {
+ console.error("Error fetching additional info:", error)
+ throw new Error("추가정보를 가져오는 중 오류가 발생했습니다.")
+ }
+}
+
+// 추가정보 저장/업데이트
+export async function upsertAdditionalInfo(
+ vendorId: number,
+ info: Omit<VendorAdditionalInfo, "id" | "vendorId" | "createdAt" | "updatedAt">
+): Promise<void> {
+ try {
+ const existing = await getAdditionalInfoByVendorId(vendorId)
+
+ if (existing) {
+ // 업데이트
+ await db
+ .update(vendorAdditionalInfo)
+ .set({
+ ...info,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ } else {
+ // 신규 삽입
+ await db
+ .insert(vendorAdditionalInfo)
+ .values({
+ ...info,
+ vendorId,
+ })
+ }
+ } catch (error) {
+ console.error("Error upserting additional info:", error)
+ throw new Error("추가정보 저장 중 오류가 발생했습니다.")
+ }
+}
+
+// 특정 벤더의 모든 추가정보 조회 (업무담당자 + 추가정보)
+export async function getVendorAllAdditionalData(vendorId: number) {
+ try {
+ const [businessContacts, additionalInfo] = await Promise.all([
+ getBusinessContactsByVendorId(vendorId),
+ getAdditionalInfoByVendorId(vendorId)
+ ])
+
+ return {
+ businessContacts,
+ additionalInfo
+ }
+ } catch (error) {
+ console.error("Error fetching vendor additional data:", error)
+ throw new Error("벤더 추가정보를 가져오는 중 오류가 발생했습니다.")
+ }
+}
+
+// 업무담당자 정보 삭제
+export async function deleteBusinessContactsByVendorId(vendorId: number): Promise<void> {
+ try {
+ await db
+ .delete(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendorId))
+ } catch (error) {
+ console.error("Error deleting business contacts:", error)
+ throw new Error("업무담당자 정보 삭제 중 오류가 발생했습니다.")
+ }
+}
+
+// 추가정보 삭제
+export async function deleteAdditionalInfoByVendorId(vendorId: number): Promise<void> {
+ try {
+ await db
+ .delete(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ } catch (error) {
+ console.error("Error deleting additional info:", error)
+ throw new Error("추가정보 삭제 중 오류가 발생했습니다.")
+ }
+}
diff --git a/lib/vendor-registration-status/service.ts b/lib/vendor-registration-status/service.ts
new file mode 100644
index 00000000..97503a13
--- /dev/null
+++ b/lib/vendor-registration-status/service.ts
@@ -0,0 +1,260 @@
+import { revalidateTag, unstable_cache } from "next/cache"
+import {
+ getBusinessContactsByVendorId,
+ upsertBusinessContacts,
+ getAdditionalInfoByVendorId,
+ upsertAdditionalInfo,
+ getVendorAllAdditionalData,
+ deleteBusinessContactsByVendorId,
+ deleteAdditionalInfoByVendorId,
+ type VendorBusinessContact,
+ type VendorAdditionalInfo
+} from "./repository"
+
+// 업무담당자 정보 조회
+export async function fetchBusinessContacts(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const contacts = await getBusinessContactsByVendorId(vendorId)
+ return {
+ success: true,
+ data: contacts,
+ }
+ } catch (error) {
+ console.error("Error in fetchBusinessContacts:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "업무담당자 정보를 가져오는 중 오류가 발생했습니다.",
+ }
+ }
+ },
+ [`business-contacts-${vendorId}`],
+ {
+ revalidate: 300, // 5분 캐시
+ tags: ["business-contacts", `vendor-${vendorId}`],
+ }
+ )()
+}
+
+// 업무담당자 정보 저장
+export async function saveBusinessContacts(
+ vendorId: number,
+ contacts: Omit<VendorBusinessContact, "id" | "vendorId" | "createdAt" | "updatedAt">[]
+) {
+ try {
+ await upsertBusinessContacts(vendorId, contacts)
+
+ // 캐시 무효화
+ revalidateTag("business-contacts")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "업무담당자 정보가 저장되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error in saveBusinessContacts:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "업무담당자 정보 저장 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 추가정보 조회
+export async function fetchAdditionalInfo(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const additionalInfo = await getAdditionalInfoByVendorId(vendorId)
+ return {
+ success: true,
+ data: additionalInfo,
+ }
+ } catch (error) {
+ console.error("Error in fetchAdditionalInfo:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "추가정보를 가져오는 중 오류가 발생했습니다.",
+ }
+ }
+ },
+ [`additional-info-${vendorId}`],
+ {
+ revalidate: 300, // 5분 캐시
+ tags: ["additional-info", `vendor-${vendorId}`],
+ }
+ )()
+}
+
+// 추가정보 저장
+export async function saveAdditionalInfo(
+ vendorId: number,
+ info: Omit<VendorAdditionalInfo, "id" | "vendorId" | "createdAt" | "updatedAt">
+) {
+ try {
+ await upsertAdditionalInfo(vendorId, info)
+
+ // 캐시 무효화
+ revalidateTag("additional-info")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "추가정보가 저장되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error in saveAdditionalInfo:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "추가정보 저장 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 모든 추가정보 조회 (업무담당자 + 추가정보)
+export async function fetchAllAdditionalData(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const data = await getVendorAllAdditionalData(vendorId)
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error("Error in fetchAllAdditionalData:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "벤더 추가정보를 가져오는 중 오류가 발생했습니다.",
+ }
+ }
+ },
+ [`all-additional-data-${vendorId}`],
+ {
+ revalidate: 300, // 5분 캐시
+ tags: ["business-contacts", "additional-info", `vendor-${vendorId}`],
+ }
+ )()
+}
+
+// 업무담당자 + 추가정보 한 번에 저장
+export async function saveAllAdditionalData(
+ vendorId: number,
+ data: {
+ businessContacts: Omit<VendorBusinessContact, "id" | "vendorId" | "createdAt" | "updatedAt">[]
+ additionalInfo: Omit<VendorAdditionalInfo, "id" | "vendorId" | "createdAt" | "updatedAt">
+ }
+) {
+ try {
+ // 두 작업을 순차적으로 실행
+ await Promise.all([
+ upsertBusinessContacts(vendorId, data.businessContacts),
+ upsertAdditionalInfo(vendorId, data.additionalInfo)
+ ])
+
+ // 캐시 무효화
+ revalidateTag("business-contacts")
+ revalidateTag("additional-info")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "모든 추가정보가 저장되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error in saveAllAdditionalData:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "추가정보 저장 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 업무담당자 정보 삭제
+export async function removeBusinessContacts(vendorId: number) {
+ try {
+ await deleteBusinessContactsByVendorId(vendorId)
+
+ // 캐시 무효화
+ revalidateTag("business-contacts")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "업무담당자 정보가 삭제되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error in removeBusinessContacts:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "업무담당자 정보 삭제 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 추가정보 삭제
+export async function removeAdditionalInfo(vendorId: number) {
+ try {
+ await deleteAdditionalInfoByVendorId(vendorId)
+
+ // 캐시 무효화
+ revalidateTag("additional-info")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "추가정보가 삭제되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error in removeAdditionalInfo:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "추가정보 삭제 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 입력 완성도 체크
+export async function checkAdditionalDataCompletion(vendorId: number) {
+ try {
+ const result = await fetchAllAdditionalData(vendorId)
+
+ if (!result.success || !result.data) {
+ return {
+ success: false,
+ error: "추가정보를 확인할 수 없습니다.",
+ }
+ }
+
+ const { businessContacts, additionalInfo } = result.data
+
+ // 필수 업무담당자 5개 타입이 모두 입력되었는지 체크
+ const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"]
+ const existingContactTypes = businessContacts.map(contact => contact.contactType)
+ const missingContactTypes = requiredContactTypes.filter(type => !existingContactTypes.includes(type))
+
+ // 업무담당자 완성도
+ const businessContactsComplete = missingContactTypes.length === 0
+
+ // 추가정보 완성도 (선택사항이므로 존재 여부만 체크)
+ const additionalInfoExists = !!additionalInfo
+
+ return {
+ success: true,
+ data: {
+ businessContactsComplete,
+ missingContactTypes,
+ additionalInfoExists,
+ totalCompletion: businessContactsComplete && additionalInfoExists
+ }
+ }
+ } catch (error) {
+ console.error("Error in checkAdditionalDataCompletion:", error)
+ return {
+ success: false,
+ error: 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
new file mode 100644
index 00000000..b3000f73
--- /dev/null
+++ b/lib/vendor-registration-status/vendor-registration-status-view.tsx
@@ -0,0 +1,470 @@
+"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 { Separator } from "@/components/ui/separator"
+import {
+ CheckCircle,
+ XCircle,
+ FileText,
+ Users,
+ Building2,
+ 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"
+
+// 상태별 정의
+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 = 1 사용 (실제로는 세션에서 가져와야 함)
+ const vendorId = 1
+
+ // 데이터 로드
+ useEffect(() => {
+ 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 (loading) {
+ 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.companyName,
+ businessNumber: data.vendor.businessNumber,
+ representative: data.vendor.representative || "",
+ 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,
+ additionalInfo: data.additionalInfo,
+ documentSubmissions: data.documentStatus, // documentSubmissions를 documentStatus로 설정
+ contractAgreements: [],
+ 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 () => {
+ try {
+ const result = await fetchVendorRegistrationStatus(vendorId)
+ if (result.success) {
+ setData(result.data)
+ } 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-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Building2 className="w-5 h-5" />
+ 업체 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <span className="text-sm font-medium text-gray-600">업체명:</span>
+ <p className="mt-1">{data.vendor.companyName}</p>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">사업자번호:</span>
+ <p className="mt-1">{data.vendor.businessNumber}</p>
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <span className="text-sm font-medium text-gray-600">업체구분:</span>
+ <p className="mt-1">{data.registration ? "정규업체" : "잠재업체"}</p>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">eVCP 가입:</span>
+ <p className="mt-1">{data.vendor.createdAt ? format(new Date(data.vendor.createdAt), "yyyy.MM.dd") : "-"}</p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Users className="w-5 h-5" />
+ 담당자 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <span className="text-sm font-medium text-gray-600">SHI 담당자:</span>
+ <p className="mt-1">{data.registration?.assignedDepartment || "-"} {data.registration?.assignedUser || "-"}</p>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">진행상태:</span>
+ <Badge className={`mt-1 ${currentStatusConfig.color}`} variant="secondary">
+ {currentStatusConfig.label}
+ </Badge>
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <span className="text-sm font-medium text-gray-600">상태변경일:</span>
+ <p className="mt-1">{data.registration?.updatedAt ? format(new Date(data.registration.updatedAt), "yyyy.MM.dd") : "-"}</p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 미완항목 */}
+ {missingDocuments.length > 0 && (
+ <Card className="border-red-200 bg-red-50">
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-red-800">
+ <AlertCircle className="w-5 h-5" />
+ 미완항목
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ {data.incompleteItemsCount.documents > 0 && (
+ <div className="flex items-center justify-between p-3 bg-white rounded-lg border">
+ <span className="text-sm font-medium">미제출문서</span>
+ <Badge variant="destructive">{data.incompleteItemsCount.documents} 건</Badge>
+ </div>
+ )}
+ {!data.documentStatus.auditResult && (
+ <div className="flex items-center justify-between p-3 bg-white rounded-lg border">
+ <span className="text-sm font-medium">실사결과</span>
+ <Badge variant="destructive">미실시</Badge>
+ </div>
+ )}
+ {data.incompleteItemsCount.additionalInfo > 0 && (
+ <div className="flex items-center justify-between p-3 bg-white rounded-lg border">
+ <span className="text-sm font-medium">추가정보</span>
+ <Badge variant="destructive">미입력</Badge>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 상세 진행현황 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>상세 진행현황</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-6">
+ {/* 기본 진행상황 */}
+ <div className="grid grid-cols-4 gap-4 text-center">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-gray-600">PQ 제출</div>
+ <div className="text-lg font-semibold">
+ {data.pqSubmission ? (
+ <div className="flex items-center justify-center gap-2">
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ {format(new Date(data.pqSubmission.createdAt), "yyyy.MM.dd")}
+ </div>
+ ) : (
+ <div className="flex items-center justify-center gap-2">
+ <XCircle className="w-5 h-5 text-red-500" />
+ 미제출
+ </div>
+ )}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-gray-600">실사 통과</div>
+ <div className="text-lg font-semibold">
+ {data.auditPassed ? (
+ <div className="flex items-center justify-center gap-2">
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ 통과
+ </div>
+ ) : (
+ <div className="flex items-center justify-center gap-2">
+ <XCircle className="w-5 h-5 text-red-500" />
+ 미통과
+ </div>
+ )}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-gray-600">문서 현황</div>
+ <Button
+ onClick={() => setDocumentDialogOpen(true)}
+ variant="outline"
+ size="sm"
+ className="flex items-center gap-2"
+ >
+ <Eye className="w-4 h-4" />
+ 확인하기
+ </Button>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-gray-600">추가정보</div>
+ <Button
+ onClick={() => setAdditionalInfoDialogOpen(true)}
+ variant={data.additionalInfo ? "outline" : "default"}
+ size="sm"
+ className="flex items-center gap-2"
+ >
+ <FileText className="w-4 h-4" />
+ {data.additionalInfo ? "수정하기" : "등록하기"}
+ </Button>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 필수문서 상태 */}
+ <div>
+ <h4 className="text-sm font-medium text-gray-600 mb-4">필수문서 제출 현황</h4>
+ <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
+ {requiredDocuments.map((doc) => {
+ const isSubmitted = data.documentStatus[doc.key as keyof typeof data.documentStatus]
+ return (
+ <div
+ key={doc.key}
+ className={`p-3 rounded-lg border text-center ${
+ isSubmitted
+ ? 'bg-green-50 border-green-200'
+ : 'bg-red-50 border-red-200'
+ }`}
+ >
+ <div className="flex items-center justify-center mb-2">
+ {isSubmitted ? (
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ ) : (
+ <XCircle className="w-5 h-5 text-red-500" />
+ )}
+ </div>
+ <div className="text-xs font-medium">{doc.label}</div>
+ {isSubmitted && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="mt-2 h-6 text-xs"
+ >
+ <Eye className="w-3 h-3 mr-1" />
+ 보기
+ </Button>
+ )}
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 상태 설명 */}
+ <Card>
+ <CardHeader>
+ <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}
+ />
+
+ {/* 추가정보 입력 Dialog */}
+ <AdditionalInfoDialog
+ open={additionalInfoDialogOpen}
+ onOpenChange={setAdditionalInfoDialogOpen}
+ vendorId={vendorId}
+ onSave={handleAdditionalInfoSave}
+ />
+ </div>
+ )
+}
diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts
new file mode 100644
index 00000000..d4c979a5
--- /dev/null
+++ b/lib/vendor-regular-registrations/repository.ts
@@ -0,0 +1,209 @@
+import db from "@/db/db";
+import {
+ vendorRegularRegistrations,
+ vendors,
+ vendorAttachments,
+ vendorInvestigationAttachments,
+ basicContract,
+ vendorPQSubmissions,
+ vendorInvestigations,
+} 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,
+ // 벤더 기본 정보
+ businessNumber: vendors.taxId,
+ companyName: vendors.vendorName,
+ establishmentDate: vendors.createdAt,
+ representative: vendors.representativeName,
+ })
+ .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)) : [];
+
+ // 각 등록 레코드별로 데이터를 매핑하여 결과 반환
+ return registrations.map((registration) => {
+ // 벤더별 첨부파일 필터링
+ const vendorFiles = vendorAttachmentsList.filter(att => att.vendorId === registration.vendorId);
+ const investigationFiles = investigationAttachmentsList.filter(att => att.vendorId === registration.vendorId);
+
+ // 디버깅을 위한 로그
+ console.log(`📋 벤더 ID ${registration.vendorId} (${registration.companyName}) 첨부파일 현황:`, {
+ vendorFiles: vendorFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName })),
+ investigationFiles: investigationFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName }))
+ });
+
+ // 문서 제출 현황 - 실제 첨부파일 존재 여부 확인 (DB 타입명과 정확히 매칭)
+ const documentSubmissionsStatus = {
+ businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"),
+ bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
+ 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,
+ };
+
+ // 문서 제출 현황 로그
+ console.log(`📊 벤더 ID ${registration.vendorId} 문서 제출 현황:`, documentSubmissionsStatus);
+
+ // 계약 동의 현황 (기본값 - 추후 실제 계약 테이블과 연동)
+ const contractAgreementsStatus = {
+ cp: "not_submitted",
+ gtc: "not_submitted",
+ standardSubcontract: "not_submitted",
+ safetyHealth: "not_submitted",
+ ethics: "not_submitted",
+ domesticCredit: "not_submitted",
+ safetyQualification: "not_submitted",
+ };
+
+ 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,
+ documentSubmissions: documentSubmissionsStatus,
+ documentFiles: documentFiles, // 파일 정보 추가
+ contractAgreements: contractAgreementsStatus,
+ additionalInfo: true, // TODO: 추가정보 로직 구현 필요
+ 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;
+}) {
+ try {
+ const [registration] = await db
+ .insert(vendorRegularRegistrations)
+ .values({
+ vendorId: data.vendorId,
+ status: data.status || "audit_pass",
+ potentialCode: data.potentialCode,
+ majorItems: data.majorItems,
+ assignedDepartment: data.assignedDepartment,
+ assignedDepartmentCode: data.assignedDepartmentCode,
+ assignedUser: data.assignedUser,
+ assignedUserCode: data.assignedUserCode,
+ remarks: data.remarks,
+ })
+ .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;
+ }>
+) {
+ 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("정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.");
+ }
+}
+
+
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts
new file mode 100644
index 00000000..b587ec23
--- /dev/null
+++ b/lib/vendor-regular-registrations/service.ts
@@ -0,0 +1,825 @@
+"use server"
+import { revalidateTag, unstable_cache } from "next/cache";
+import {
+ getVendorRegularRegistrations,
+ createVendorRegularRegistration,
+ updateVendorRegularRegistration,
+ getVendorRegularRegistrationById,
+} from "./repository";
+
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { headers } from "next/headers";
+import { sendEmail } from "@/lib/mail/sendEmail";
+import {
+ vendors,
+ vendorRegularRegistrations,
+ vendorAttachments,
+ vendorInvestigations,
+ vendorInvestigationAttachments,
+ basicContract,
+ vendorPQSubmissions,
+ vendorBusinessContacts,
+ vendorAdditionalInfo
+} from "@/db/schema";
+import db from "@/db/db";
+import { inArray, eq, desc } from "drizzle-orm";
+
+// 캐싱과 에러 핸들링이 포함된 조회 함수
+export async function fetchVendorRegularRegistrations(input?: {
+ search?: string;
+ status?: string[];
+ page?: number;
+ perPage?: number;
+}) {
+ return unstable_cache(
+ async () => {
+ try {
+ const registrations = await getVendorRegularRegistrations();
+
+ let filteredData = registrations;
+
+ // 검색 필터링
+ if (input?.search) {
+ const searchLower = input.search.toLowerCase();
+ filteredData = filteredData.filter(
+ (reg) =>
+ reg.companyName.toLowerCase().includes(searchLower) ||
+ reg.businessNumber.toLowerCase().includes(searchLower) ||
+ reg.potentialCode?.toLowerCase().includes(searchLower) ||
+ reg.representative?.toLowerCase().includes(searchLower)
+ );
+ }
+
+ // 상태 필터링
+ if (input?.status && input.status.length > 0) {
+ filteredData = filteredData.filter((reg) =>
+ input.status!.includes(reg.status)
+ );
+ }
+
+ // 페이지네이션
+ const page = input?.page || 1;
+ const perPage = input?.perPage || 50;
+ const offset = (page - 1) * perPage;
+ const paginatedData = filteredData.slice(offset, offset + perPage);
+ const pageCount = Math.ceil(filteredData.length / perPage);
+
+ return {
+ success: true,
+ data: paginatedData,
+ pageCount,
+ total: filteredData.length,
+ };
+ } catch (error) {
+ console.error("Error in fetchVendorRegularRegistrations:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.",
+ };
+ }
+ },
+ [JSON.stringify(input || {})],
+ {
+ revalidate: 300, // 5분 캐시
+ tags: ["vendor-regular-registrations"],
+ }
+ )();
+}
+
+export async function getCurrentUserInfo() {
+ const session = await getServerSession(authOptions);
+ return {
+ userId: session?.user?.id ? String(session.user.id) : null,
+ userName: session?.user?.name || null,
+ };
+}
+
+export async function createVendorRegistration(data: {
+ vendorId: number;
+ status?: string;
+ potentialCode?: string;
+ majorItems?: Record<string, unknown>[];
+ assignedDepartment?: string;
+ assignedDepartmentCode?: string;
+ assignedUser?: string;
+ assignedUserCode?: string;
+ remarks?: string;
+}) {
+ try {
+ const majorItemsJson = data.majorItems ? JSON.stringify(data.majorItems) : undefined;
+
+ const registration = await createVendorRegularRegistration({
+ ...data,
+ majorItems: majorItemsJson,
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return { success: true, data: registration };
+ } catch (error) {
+ console.error("Error in createVendorRegistration:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록을 생성하는 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+export async function updateVendorRegistration(
+ id: number,
+ data: Partial<{
+ status: string;
+ potentialCode: string;
+ majorItems: Record<string, unknown>[];
+ registrationRequestDate: string;
+ assignedDepartment: string;
+ assignedDepartmentCode: string;
+ assignedUser: string;
+ assignedUserCode: string;
+ remarks: string;
+ }>
+) {
+ try {
+ const updateData: Partial<{
+ status: string;
+ potentialCode: string;
+ majorItems: string;
+ registrationRequestDate: string;
+ assignedDepartment: string;
+ assignedDepartmentCode: string;
+ assignedUser: string;
+ assignedUserCode: string;
+ remarks: string;
+ }> = {};
+
+ // majorItems를 제외한 다른 필드들을 복사
+ Object.keys(data).forEach(key => {
+ if (key !== 'majorItems') {
+ updateData[key as keyof typeof updateData] = data[key as keyof typeof data] as never;
+ }
+ });
+
+ if (data.majorItems) {
+ updateData.majorItems = JSON.stringify(data.majorItems);
+ }
+
+ const registration = await updateVendorRegularRegistration(id, updateData);
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return { success: true, data: registration };
+ } catch (error) {
+ console.error("Error in updateVendorRegistration:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록을 수정하는 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+export async function fetchVendorRegistrationById(id: number) {
+ try {
+ const registration = await getVendorRegularRegistrationById(id);
+ return { success: true, data: registration };
+ } catch (error) {
+ console.error("Error in fetchVendorRegistrationById:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+
+
+export async function requestRegularRegistration(registrationId: number) {
+ try {
+ // 정규업체 등록 요청 처리
+ const now = new Date().toISOString().split('T')[0];
+
+ const registration = await updateVendorRegularRegistration(registrationId, {
+ status: "in_review",
+ registrationRequestDate: now,
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return { success: true, message: "정규업체 등록 요청이 완료되었습니다.", data: registration };
+ } catch (error) {
+ console.error("Error in requestRegularRegistration:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 요청 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+export async function approveRegularRegistration(registrationId: number) {
+ try {
+ // 정규업체 등록 승인 처리
+ const registration = await updateVendorRegularRegistration(registrationId, {
+ status: "approval_ready",
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return { success: true, message: "정규업체 등록이 승인되었습니다.", data: registration };
+ } catch (error) {
+ console.error("Error in approveRegularRegistration:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 승인 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+
+
+// 누락계약요청 이메일 발송
+export async function sendMissingContractRequestEmails(vendorIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ // 벤더 정보 조회
+ const vendorList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ if (vendorList.length === 0) {
+ return { success: false, error: "선택된 업체를 찾을 수 없습니다." };
+ }
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
+ const baseUrl = `${protocol}://${host}`;
+ const contractManagementUrl = `${baseUrl}/ko/login`; // 실제 기본계약 관리 페이지 URL로 수정 필요
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ // 각 벤더에게 이메일 발송
+ await Promise.all(
+ vendorList.map(async (vendor) => {
+ if (!vendor.email) {
+ errorCount++;
+ return;
+ }
+
+ try {
+
+ await sendEmail({
+ to: vendor.email,
+ subject: "[SHI] 정규업체 등록을 위한 기본계약/서약 진행 요청",
+ template: "vendor-missing-contract-request",
+ context: {
+ vendorName: vendor.vendorName,
+ contractManagementUrl,
+ senderName: session.user.name || "구매담당자",
+ senderEmail: session.user.email || "",
+ currentYear: new Date().getFullYear(),
+ },
+ });
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to send email to ${vendor.vendorName}:`, error);
+ errorCount++;
+ }
+ })
+ );
+
+ if (errorCount > 0) {
+ return {
+ success: false,
+ error: `${successCount}개 업체에 발송 성공, ${errorCount}개 업체 발송 실패`,
+ };
+ }
+
+ return {
+ success: true,
+ message: `${successCount}개 업체에 누락계약요청 이메일을 발송했습니다.`,
+ };
+ } catch (error) {
+ console.error("Error sending missing contract request emails:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "이메일 발송 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 추가정보요청 이메일 발송
+export async function sendAdditionalInfoRequestEmails(vendorIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ // 벤더 정보 조회
+ const vendorList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ if (vendorList.length === 0) {
+ return { success: false, error: "선택된 업체를 찾을 수 없습니다." };
+ }
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
+ const baseUrl = `${protocol}://${host}`;
+ const vendorInfoUrl = `${baseUrl}/ko/login`; // 실제 업체정보 관리 페이지 URL로 수정 필요
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ // 각 벤더에게 이메일 발송
+ await Promise.all(
+ vendorList.map(async (vendor) => {
+ if (!vendor.email) {
+ errorCount++;
+ return;
+ }
+
+ try {
+ await sendEmail({
+ to: vendor.email,
+ subject: "[SHI] 정규업체 등록을 위한 추가정보 입력 요청",
+ template: "vendor-regular-registration-request",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorInfoUrl,
+ senderName: session.user.name || "구매담당자",
+ senderEmail: session.user.email || "",
+ currentYear: new Date().getFullYear(),
+ },
+ });
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to send email to ${vendor.vendorName}:`, error);
+ errorCount++;
+ }
+ })
+ );
+
+ if (errorCount > 0) {
+ return {
+ success: false,
+ error: `${successCount}개 업체에 발송 성공, ${errorCount}개 업체 발송 실패`,
+ };
+ }
+
+ return {
+ success: true,
+ message: `${successCount}개 업체에 추가정보요청 이메일을 발송했습니다.`,
+ };
+ } catch (error) {
+ console.error("Error sending additional info request emails:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "이메일 발송 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 법무검토 Skip 기능
+export async function skipLegalReview(vendorIds: number[], skipReason: string) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const vendorId of vendorIds) {
+ try {
+ // 해당 벤더의 registration 찾기 또는 생성
+ const vendorList = await db
+ .select({ id: vendors.id })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId));
+
+ if (vendorList.length === 0) {
+ errorCount++;
+ continue;
+ }
+
+ // registration 조회
+ const existingRegistrations = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, vendorId));
+
+ let registrationId;
+ if (existingRegistrations.length === 0) {
+ // 새로 생성
+ const newRegistration = await createVendorRegularRegistration({
+ vendorId: vendorId,
+ status: "cp_finished", // CP완료로 변경
+ remarks: `법무검토 Skip: ${skipReason}`,
+ });
+ registrationId = newRegistration.id;
+ } else {
+ // 기존 registration 업데이트
+ registrationId = existingRegistrations[0].id;
+ const currentRemarks = existingRegistrations[0].remarks || "";
+ const newRemarks = currentRemarks
+ ? `${currentRemarks}\n법무검토 Skip: ${skipReason}`
+ : `법무검토 Skip: ${skipReason}`;
+
+ await updateVendorRegularRegistration(registrationId, {
+ status: "cp_finished", // CP완료로 변경
+ remarks: newRemarks,
+ });
+ }
+
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to skip legal review for vendor ${vendorId}:`, error);
+ errorCount++;
+ }
+ }
+
+ if (errorCount > 0) {
+ return {
+ success: false,
+ error: `${successCount}개 업체 처리 성공, ${errorCount}개 업체 처리 실패`,
+ };
+ }
+
+ return {
+ success: true,
+ message: `${successCount}개 업체의 법무검토를 Skip 처리했습니다.`,
+ };
+ } catch (error) {
+ console.error("Error skipping legal review:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "법무검토 Skip 처리 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 안전적격성평가 Skip 기능
+export async function skipSafetyQualification(vendorIds: number[], skipReason: string) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const vendorId of vendorIds) {
+ try {
+ // 해당 벤더의 registration 찾기 또는 생성
+ const vendorList = await db
+ .select({ id: vendors.id })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId));
+
+ if (vendorList.length === 0) {
+ errorCount++;
+ continue;
+ }
+
+ // registration 조회
+ const existingRegistrations = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, vendorId));
+
+ let registrationId;
+ if (existingRegistrations.length === 0) {
+ // 새로 생성
+ const newRegistration = await createVendorRegularRegistration({
+ vendorId: vendorId,
+ status: "audit_pass",
+ remarks: `안전적격성평가 Skip: ${skipReason}`,
+ });
+ registrationId = newRegistration.id;
+ } else {
+ // 기존 registration 업데이트
+ registrationId = existingRegistrations[0].id;
+ const currentRemarks = existingRegistrations[0].remarks || "";
+ const newRemarks = currentRemarks
+ ? `${currentRemarks}\n안전적격성평가 Skip: ${skipReason}`
+ : `안전적격성평가 Skip: ${skipReason}`;
+
+ await updateVendorRegularRegistration(registrationId, {
+ remarks: newRemarks,
+ });
+ }
+
+ // 안전적격성평가 상태를 완료로 처리 (계약 동의 현황은 이제 실시간으로 조회하므로 별도 처리 불필요)
+ // updateContractAgreement 함수는 제거되었으므로 계약 동의 현황은 basic_contract와 vendor_pq_submissions에서 실시간으로 조회됩니다.
+
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to skip safety qualification for vendor ${vendorId}:`, error);
+ errorCount++;
+ }
+ }
+
+ if (errorCount > 0) {
+ return {
+ success: false,
+ error: `${successCount}개 업체 처리 성공, ${errorCount}개 업체 처리 실패`,
+ };
+ }
+
+ return {
+ success: true,
+ message: `${successCount}개 업체의 안전적격성평가를 Skip 처리했습니다.`,
+ };
+ } catch (error) {
+ console.error("Error skipping safety qualification:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "안전적격성평가 Skip 처리 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 벤더용 현황 조회 함수들
+export async function fetchVendorRegistrationStatus(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 벤더 기본 정보
+ const vendor = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1)
+
+ if (!vendor[0]) {
+ return {
+ success: false,
+ error: "벤더 정보를 찾을 수 없습니다.",
+ }
+ }
+
+ // 정규업체 등록 정보
+ const registration = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, vendorId))
+ .limit(1)
+
+ // 벤더 첨부파일 조회
+ const vendorFiles = await db
+ .select()
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendorId))
+
+ // 실사 결과 조회 (vendor_investigation_attachments)
+ const investigationFiles = await db
+ .select({
+ attachmentId: vendorInvestigationAttachments.id,
+ createdAt: vendorInvestigationAttachments.createdAt,
+ })
+ .from(vendorInvestigationAttachments)
+ .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
+ .where(eq(vendorInvestigations.vendorId, vendorId))
+
+ // PQ 제출 정보
+ const pqSubmission = await db
+ .select()
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .orderBy(desc(vendorPQSubmissions.createdAt))
+ .limit(1)
+
+ // 기본계약 정보
+ const contractInfo = await db
+ .select()
+ .from(basicContract)
+ .where(eq(basicContract.vendorId, vendorId))
+ .limit(1)
+
+ // 업무담당자 정보
+ const businessContacts = await db
+ .select()
+ .from(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendorId))
+
+ // 추가정보
+ const additionalInfo = await db
+ .select()
+ .from(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ .limit(1)
+
+ // 문서 제출 현황 계산
+ const documentStatus = {
+ businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_EVALUATION"),
+ bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_COPY"),
+ auditResult: investigationFiles.length > 0, // DocumentStatusDialog에서 사용하는 키
+ cpDocument: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ gtc: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ standardSubcontract: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ safetyHealth: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ ethics: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ domesticCredit: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ safetyQualification: investigationFiles.length > 0,
+ }
+
+ // 미완성 항목 계산
+ const missingDocuments = Object.entries(documentStatus)
+ .filter(([, value]) => !value)
+ .map(([key]) => key)
+
+ const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"]
+ const existingContactTypes = businessContacts.map(contact => contact.contactType)
+ const missingContactTypes = requiredContactTypes.filter(type => !existingContactTypes.includes(type))
+
+ return {
+ success: true,
+ data: {
+ vendor: vendor[0],
+ registration: registration[0] || null,
+ documentStatus,
+ missingDocuments,
+ businessContacts,
+ missingContactTypes,
+ additionalInfo: additionalInfo[0] || null,
+ pqSubmission: pqSubmission[0] || null,
+ auditPassed: investigationFiles.length > 0,
+ contractInfo: contractInfo[0] || null,
+ incompleteItemsCount: {
+ documents: missingDocuments.length,
+ contacts: missingContactTypes.length,
+ additionalInfo: !additionalInfo[0] ? 1 : 0,
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Error in fetchVendorRegistrationStatus:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "현황 조회 중 오류가 발생했습니다.",
+ }
+ }
+ },
+ [`vendor-registration-status-${vendorId}`],
+ {
+ revalidate: 300, // 5분 캐시
+ tags: ["vendor-registration-status", `vendor-${vendorId}`],
+ }
+ )()
+}
+
+// 서명/직인 업로드 (임시 - 실제로는 파일 업로드 로직 필요)
+export async function uploadVendorSignature(vendorId: number, signatureData: {
+ type: "signature" | "seal"
+ signerName?: string
+ imageFile: string // base64 or file path
+}) {
+ try {
+ // TODO: 실제 파일 업로드 및 저장 로직 구현
+ console.log("Signature upload for vendor:", vendorId, signatureData)
+
+ // 캐시 무효화
+ revalidateTag(`vendor-registration-status`)
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "서명/직인이 등록되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error uploading signature:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "서명/직인 등록 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 업무담당자 정보 저장
+export async function saveVendorBusinessContacts(
+ vendorId: number,
+ contacts: Array<{
+ contactType: "sales" | "design" | "delivery" | "quality" | "tax_invoice"
+ contactName: string
+ position: string
+ department: string
+ responsibility: string
+ email: string
+ }>
+) {
+ try {
+ // 기존 데이터 삭제
+ await db
+ .delete(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendorId))
+
+ // 새 데이터 삽입
+ if (contacts.length > 0) {
+ await db
+ .insert(vendorBusinessContacts)
+ .values(contacts.map(contact => ({
+ ...contact,
+ vendorId,
+ })))
+ }
+
+ // 캐시 무효화
+ revalidateTag("vendor-registration-status")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "업무담당자 정보가 저장되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error saving business contacts:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "업무담당자 정보 저장 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 추가정보 저장
+export async function saveVendorAdditionalInfo(
+ vendorId: number,
+ info: {
+ businessType?: string
+ industryType?: string
+ companySize?: string
+ revenue?: string
+ factoryEstablishedDate?: string
+ preferredContractTerms?: string
+ }
+) {
+ try {
+ const existing = await db
+ .select()
+ .from(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ .limit(1)
+
+ if (existing[0]) {
+ // 업데이트
+ await db
+ .update(vendorAdditionalInfo)
+ .set({
+ ...info,
+ factoryEstablishedDate: info.factoryEstablishedDate || null,
+ revenue: info.revenue || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ } else {
+ // 신규 삽입
+ await db
+ .insert(vendorAdditionalInfo)
+ .values({
+ ...info,
+ vendorId,
+ factoryEstablishedDate: info.factoryEstablishedDate || null,
+ revenue: info.revenue || null,
+ })
+ }
+
+ // 캐시 무효화
+ revalidateTag("vendor-registration-status")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "추가정보가 저장되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error saving additional info:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "추가정보 저장 중 오류가 발생했습니다.",
+ }
+ }
+}
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
new file mode 100644
index 00000000..023bcfba
--- /dev/null
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
@@ -0,0 +1,270 @@
+"use client"
+
+import { type ColumnDef } from "@tanstack/react-table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { format } from "date-fns"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
+import { DocumentStatusDialog } from "@/components/vendor-regular-registrations/document-status-dialog"
+import { Button } from "@/components/ui/button"
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
+import { Eye, FileText, Ellipsis } from "lucide-react"
+import { toast } from "sonner"
+import { useState } from "react"
+
+const statusLabels = {
+ audit_pass: "실사통과",
+ cp_submitted: "CP등록",
+ cp_review: "CP검토",
+ cp_finished: "CP완료",
+ approval_ready: "조건충족",
+ in_review: "정규등록검토",
+ 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",
+ approval_ready: "bg-emerald-100 text-emerald-800",
+ in_review: "bg-orange-100 text-orange-800",
+ pending_approval: "bg-red-100 text-red-800",
+}
+
+export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
+
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-[2px]"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-[2px]"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string
+ return (
+ <Badge
+ variant="secondary"
+ className={statusColors[status as keyof typeof statusColors]}
+ >
+ {statusLabels[status as keyof typeof statusLabels] || status}
+ </Badge>
+ )
+ },
+ filterFn: (row, id, value) => {
+ return Array.isArray(value) && value.includes(row.getValue(id))
+ },
+ },
+ {
+ accessorKey: "potentialCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="잠재코드" />
+ ),
+ cell: ({ row }) => row.getValue("potentialCode") || "-",
+ },
+ {
+ accessorKey: "businessNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="사업자번호" />
+ ),
+ },
+ {
+ accessorKey: "companyName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체명" />
+ ),
+ },
+ {
+ accessorKey: "majorItems",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="주요품목" />
+ ),
+ cell: ({ row }) => {
+ const majorItems = row.getValue("majorItems") as string
+ try {
+ const items = majorItems ? JSON.parse(majorItems) : []
+ if (items.length === 0) return "-"
+
+ // 첫 번째 아이템을 itemCode-itemName 형태로 표시
+ const firstItem = items[0]
+ let displayText = ""
+
+ if (typeof firstItem === 'string') {
+ displayText = firstItem
+ } else if (typeof firstItem === 'object') {
+ const code = firstItem.itemCode || firstItem.code || ""
+ const name = firstItem.itemName || firstItem.name || firstItem.materialGroupName || ""
+ if (code && name) {
+ displayText = `${code}-${name}`
+ } else {
+ displayText = name || code || String(firstItem)
+ }
+ } else {
+ displayText = String(firstItem)
+ }
+
+ // 나머지 개수 표시
+ if (items.length > 1) {
+ displayText += ` 외 ${items.length - 1}개`
+ }
+
+ return displayText
+ } catch {
+ return majorItems || "-"
+ }
+ },
+ },
+ {
+ accessorKey: "establishmentDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설립일자" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("establishmentDate") as string
+ return date ? format(new Date(date), "yyyy.MM.dd") : "-"
+ },
+ },
+ {
+ accessorKey: "representative",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="대표자명" />
+ ),
+ cell: ({ row }) => row.getValue("representative") || "-",
+ },
+ {
+ id: "documentStatus",
+ header: "문서/자료 접수 현황",
+ cell: ({ row }) => {
+ const DocumentStatusCell = () => {
+ const [documentDialogOpen, setDocumentDialogOpen] = useState(false)
+ const registration = row.original
+
+ return (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setDocumentDialogOpen(true)}
+ >
+ <Eye className="w-4 h-4" />
+ 현황보기
+ </Button>
+ <DocumentStatusDialog
+ open={documentDialogOpen}
+ onOpenChange={setDocumentDialogOpen}
+ registration={registration}
+ />
+ </>
+ )
+ }
+
+ return <DocumentStatusCell />
+ },
+ },
+ {
+ accessorKey: "registrationRequestDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록요청일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("registrationRequestDate") as string
+ return date ? format(new Date(date), "yyyy.MM.dd") : "-"
+ },
+ },
+ {
+ accessorKey: "assignedDepartment",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당부서" />
+ ),
+ cell: ({ row }) => row.getValue("assignedDepartment") || "-",
+ },
+ {
+ accessorKey: "assignedUser",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자" />
+ ),
+ cell: ({ row }) => row.getValue("assignedUser") || "-",
+ },
+ {
+ accessorKey: "remarks",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => row.getValue("remarks") || "-",
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ const ActionsDropdownCell = () => {
+ const [documentDialogOpen, setDocumentDialogOpen] = useState(false)
+ const registration = row.original
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ <DropdownMenuItem
+ onClick={() => setDocumentDialogOpen(true)}
+ >
+ <Eye className="mr-2 h-4 w-4" />
+ 현황보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => {
+ toast.info("정규업체 등록 요청 기능은 준비 중입니다.")
+ }}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 등록요청
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <DocumentStatusDialog
+ open={documentDialogOpen}
+ onOpenChange={setDocumentDialogOpen}
+ registration={registration}
+ />
+ </>
+ )
+ }
+
+ return <ActionsDropdownCell />
+ },
+ },
+ ]
+}
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
new file mode 100644
index 00000000..c3b4739a
--- /dev/null
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
@@ -0,0 +1,248 @@
+"use client"
+
+import { type Table } from "@tanstack/react-table"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import { FileText, RefreshCw, Download, Mail, FileWarning, Scale, Shield } from "lucide-react"
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
+import {
+ sendMissingContractRequestEmails,
+ sendAdditionalInfoRequestEmails,
+ skipLegalReview,
+ skipSafetyQualification
+} from "../service"
+import { useState } from "react"
+import { SkipReasonDialog } from "@/components/vendor-regular-registrations/skip-reason-dialog"
+
+interface VendorRegularRegistrationsTableToolbarActionsProps {
+ table: Table<VendorRegularRegistration>
+}
+
+export function VendorRegularRegistrationsTableToolbarActions({
+ table,
+}: VendorRegularRegistrationsTableToolbarActionsProps) {
+ const [syncLoading, setSyncLoading] = useState<{
+ missingContract: boolean;
+ additionalInfo: boolean;
+ legalSkip: boolean;
+ safetySkip: boolean;
+ }>({
+ missingContract: false,
+ additionalInfo: false,
+ legalSkip: false,
+ safetySkip: false,
+ })
+
+ const [skipDialogs, setSkipDialogs] = useState<{
+ legalReview: boolean;
+ safetyQualification: boolean;
+ }>({
+ legalReview: false,
+ safetyQualification: false,
+ })
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
+
+
+
+ const handleSendMissingContractRequest = async () => {
+ if (selectedRows.length === 0) {
+ toast.error("이메일을 발송할 업체를 선택해주세요.")
+ return
+ }
+
+ setSyncLoading(prev => ({ ...prev, missingContract: true }))
+ try {
+ const vendorIds = selectedRows.map(row => row.vendorId)
+ const result = await sendMissingContractRequestEmails(vendorIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ console.error("Error sending missing contract request:", error)
+ toast.error("누락계약요청 이메일 발송 중 오류가 발생했습니다.")
+ } finally {
+ setSyncLoading(prev => ({ ...prev, missingContract: false }))
+ }
+ }
+
+ const handleSendAdditionalInfoRequest = async () => {
+ if (selectedRows.length === 0) {
+ toast.error("이메일을 발송할 업체를 선택해주세요.")
+ return
+ }
+
+ setSyncLoading(prev => ({ ...prev, additionalInfo: true }))
+ try {
+ const vendorIds = selectedRows.map(row => row.vendorId)
+ const result = await sendAdditionalInfoRequestEmails(vendorIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ console.error("Error sending additional info request:", error)
+ toast.error("추가정보요청 이메일 발송 중 오류가 발생했습니다.")
+ } finally {
+ setSyncLoading(prev => ({ ...prev, additionalInfo: false }))
+ }
+ }
+
+ const handleLegalReviewSkip = async (reason: string) => {
+ const cpReviewRows = selectedRows.filter(row => row.status === "cp_review");
+ if (cpReviewRows.length === 0) {
+ toast.error("CP검토 상태인 업체를 선택해주세요.");
+ return;
+ }
+
+ setSyncLoading(prev => ({ ...prev, legalSkip: true }));
+ try {
+ const vendorIds = cpReviewRows.map(row => row.vendorId);
+ const result = await skipLegalReview(vendorIds, reason);
+
+ if (result.success) {
+ toast.success(result.message);
+ window.location.reload();
+ } else {
+ toast.error(result.error);
+ }
+ } catch (error) {
+ console.error("Error skipping legal review:", error);
+ toast.error("법무검토 Skip 처리 중 오류가 발생했습니다.");
+ } finally {
+ setSyncLoading(prev => ({ ...prev, legalSkip: false }));
+ }
+ };
+
+ const handleSafetyQualificationSkip = async (reason: string) => {
+ if (selectedRows.length === 0) {
+ toast.error("업체를 선택해주세요.");
+ return;
+ }
+
+ setSyncLoading(prev => ({ ...prev, safetySkip: true }));
+ try {
+ const vendorIds = selectedRows.map(row => row.vendorId);
+ const result = await skipSafetyQualification(vendorIds, reason);
+
+ if (result.success) {
+ toast.success(result.message);
+ window.location.reload();
+ } else {
+ toast.error(result.error);
+ }
+ } catch (error) {
+ console.error("Error skipping safety qualification:", error);
+ toast.error("안전적격성평가 Skip 처리 중 오류가 발생했습니다.");
+ } finally {
+ setSyncLoading(prev => ({ ...prev, safetySkip: false }));
+ }
+ };
+
+ // CP검토 상태인 선택된 행들 개수
+ const cpReviewCount = selectedRows.filter(row => row.status === "cp_review").length;
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSyncDocuments}
+ disabled={syncLoading.documents || selectedRows.length === 0}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ {syncLoading.documents ? "동기화 중..." : "문서 동기화"}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSyncAgreements}
+ disabled={syncLoading.agreements || selectedRows.length === 0}
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ {syncLoading.agreements ? "동기화 중..." : "계약 동기화"}
+ </Button> */}
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendMissingContractRequest}
+ disabled={syncLoading.missingContract || selectedRows.length === 0}
+ >
+ <FileWarning className="mr-2 h-4 w-4" />
+ {syncLoading.missingContract ? "발송 중..." : "누락계약요청"}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendAdditionalInfoRequest}
+ disabled={syncLoading.additionalInfo || selectedRows.length === 0}
+ >
+ <Mail className="mr-2 h-4 w-4" />
+ {syncLoading.additionalInfo ? "발송 중..." : "추가정보요청"}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ if (selectedRows.length === 0) {
+ toast.error("내보낼 항목을 선택해주세요.")
+ return
+ }
+ toast.info("엑셀 내보내기 기능은 준비 중입니다.")
+ }}
+ disabled={selectedRows.length === 0}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ 엑셀 내보내기
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setSkipDialogs(prev => ({ ...prev, legalReview: true }))}
+ disabled={syncLoading.legalSkip || cpReviewCount === 0}
+ >
+ <Scale className="mr-2 h-4 w-4" />
+ {syncLoading.legalSkip ? "처리 중..." : "법무검토 Skip"}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setSkipDialogs(prev => ({ ...prev, safetyQualification: true }))}
+ disabled={syncLoading.safetySkip || selectedRows.length === 0}
+ >
+ <Shield className="mr-2 h-4 w-4" />
+ {syncLoading.safetySkip ? "처리 중..." : "안전 Skip"}
+ </Button>
+
+ <SkipReasonDialog
+ open={skipDialogs.legalReview}
+ onOpenChange={(open) => setSkipDialogs(prev => ({ ...prev, legalReview: open }))}
+ title="법무검토 Skip"
+ description={`선택된 ${cpReviewCount}개 업체의 법무검토를 Skip하고 CP완료 상태로 변경합니다. Skip 사유를 입력해주세요.`}
+ onConfirm={handleLegalReviewSkip}
+ loading={syncLoading.legalSkip}
+ />
+
+ <SkipReasonDialog
+ open={skipDialogs.safetyQualification}
+ onOpenChange={(open) => setSkipDialogs(prev => ({ ...prev, safetyQualification: open }))}
+ title="안전적격성평가 Skip"
+ description={`선택된 ${selectedRows.length}개 업체의 안전적격성평가를 Skip하고 완료 상태로 변경합니다. Skip 사유를 입력해주세요.`}
+ onConfirm={handleSafetyQualificationSkip}
+ loading={syncLoading.safetySkip}
+ />
+ </div>
+ )
+}
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx
new file mode 100644
index 00000000..8b477dba
--- /dev/null
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx
@@ -0,0 +1,104 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./vendor-regular-registrations-table-columns"
+import { VendorRegularRegistrationsTableToolbarActions } from "./vendor-regular-registrations-table-toolbar-actions"
+import { fetchVendorRegularRegistrations } from "../service"
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
+
+interface VendorRegularRegistrationsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof fetchVendorRegularRegistrations>>,
+ ]
+ >
+}
+
+export function VendorRegularRegistrationsTable({ promises }: VendorRegularRegistrationsTableProps) {
+ // Suspense로 받아온 데이터
+ const [result] = React.use(promises)
+
+ if (!result.success || !result.data) {
+ throw new Error(result.error || "데이터를 불러오는데 실패했습니다.")
+ }
+
+ const data = result.data
+ const pageCount = Math.ceil(data.length / 10) // 임시로 10개씩 페이징
+
+
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const filterFields: DataTableFilterField<VendorRegularRegistration>[] = [
+ { id: "companyName", label: "업체명" },
+ { id: "businessNumber", label: "사업자번호" },
+ { id: "status", label: "상태" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorRegularRegistration>[] = [
+ { id: "companyName", label: "업체명", type: "text" },
+ { id: "businessNumber", label: "사업자번호", type: "text" },
+ { id: "potentialCode", label: "잠재코드", type: "text" },
+ { id: "representative", label: "대표자명", type: "text" },
+ {
+ id: "status",
+ label: "상태",
+ type: "select",
+ options: [
+ { label: "실사통과", value: "audit_pass" },
+ { label: "CP등록", value: "cp_submitted" },
+ { label: "CP검토", value: "cp_review" },
+ { label: "CP완료", value: "cp_finished" },
+ { label: "조건충족", value: "approval_ready" },
+ { label: "정규등록검토", value: "in_review" },
+ { label: "장기미등록", value: "pending_approval" },
+ ]
+ },
+ { id: "assignedDepartment", label: "담당부서", type: "text" },
+ { id: "assignedUser", label: "담당자", type: "text" },
+ { id: "registrationRequestDate", label: "등록요청일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "id", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorRegularRegistrationsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+}
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index d91fbd03..2a927069 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -1,6 +1,6 @@
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { revalidateTag, unstable_noStore } from "next/cache";
+import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendorPossibleMateirals, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema";
import logger from '@/lib/logger';
@@ -2615,7 +2615,7 @@ export async function requestPQVendors(input: ApproveVendorsInput & {
dueDate?: string | null,
type?: "GENERAL" | "PROJECT" | "NON_INSPECTION",
extraNote?: string,
- pqItems?: string,
+ pqItems?: string | Array<{itemCode: string, itemName: string}>,
templateId?: number | null
}) {
unstable_noStore();
@@ -2759,8 +2759,21 @@ export async function requestPQVendors(input: ApproveVendorsInput & {
.map(([name, _]) => name)
: [];
- // PQ 대상 품목
- const pqItems = input.pqItems || " - ";
+ // PQ 대상 품목 파싱
+ let pqItemsForEmail = " - ";
+ if (input.pqItems) {
+ try {
+ const items = typeof input.pqItems === 'string' ? JSON.parse(input.pqItems) : input.pqItems;
+ if (Array.isArray(items) && items.length > 0) {
+ pqItemsForEmail = items.map(item => `${item.itemCode} - ${item.itemName}`).join(', ');
+ } else if (typeof input.pqItems === 'string') {
+ pqItemsForEmail = input.pqItems;
+ }
+ } catch (error) {
+ // JSON 파싱 실패 시 문자열 그대로 사용
+ pqItemsForEmail = typeof input.pqItems === 'string' ? input.pqItems : " - ";
+ }
+ }
await sendEmail({
to: vendor.email,
@@ -2773,7 +2786,7 @@ export async function requestPQVendors(input: ApproveVendorsInput & {
senderName: session?.user?.name || "eVCP",
senderEmail: session?.user?.email || "noreply@evcp.com",
dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "",
- pqItems,
+ pqItems: pqItemsForEmail,
contracts,
extraNote: input.extraNote || "",
currentYear: new Date().getFullYear().toString(),
@@ -2798,6 +2811,8 @@ export async function requestPQVendors(input: ApproveVendorsInput & {
revalidateTag("vendor-status-counts");
revalidateTag("vendor-pq-submissions");
revalidateTag("pq-submissions");
+ revalidatePath("/evcp/pq_new");
+ revalidatePath("/partners/pq");
if (input.projectId) {
revalidateTag(`project-${input.projectId}`);
@@ -2959,6 +2974,10 @@ export async function requestBasicContractInfo({
// 5. 캐시 무효화
revalidateTag("basic-contract-requests");
+ revalidatePath("/evcp/basic-contract");
+ revalidatePath("/partners/basic-contract");
+ revalidatePath("/ko/partners/basic-contract");
+ revalidatePath("/en/partners/basic-contract");
return { success: true };
} catch (error) {
diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx
index 226a053f..a0c24dc6 100644
--- a/lib/vendors/table/request-pq-dialog.tsx
+++ b/lib/vendors/table/request-pq-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Row } from "@tanstack/react-table"
-import { Loader, SendHorizonal } from "lucide-react"
+import { Loader, SendHorizonal, Search, X, Plus } from "lucide-react"
import { toast } from "sonner"
import { useMediaQuery } from "@/hooks/use-media-query"
import { Button } from "@/components/ui/button"
@@ -35,6 +35,8 @@ import {
} from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
import { Vendor } from "@/db/schema/vendors"
import { requestBasicContractInfo, requestPQVendors } from "../service"
import { getProjectsWithPQList } from "@/lib/pq/service"
@@ -43,6 +45,7 @@ import { useSession } from "next-auth/react"
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 { PQContractViewer } from "../pq-contract-viewer" // 더 이상 사용하지 않음
interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -62,6 +65,12 @@ const AGREEMENT_LIST = [
"GTC 합의",
]
+// PQ 대상 품목 타입 정의
+interface PQItem {
+ itemCode: string
+ itemName: string
+}
+
export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...props }: RequestPQDialogProps) {
const [isApprovePending, startApproveTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
@@ -73,12 +82,42 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
const [agreements, setAgreements] = React.useState<Record<string, boolean>>({})
const [extraNote, setExtraNote] = React.useState<string>("")
- const [pqItems, setPqItems] = React.useState<string>("")
+ const [pqItems, setPqItems] = React.useState<PQItem[]>([])
+
+ // 아이템 검색 관련 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState<string>("")
+ const [filteredItems, setFilteredItems] = React.useState<PQItem[]>([])
+ const [showItemDropdown, setShowItemDropdown] = React.useState<boolean>(false)
const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
+ // 아이템 검색 필터링
+ React.useEffect(() => {
+ if (itemSearchQuery.trim() === "") {
+ setFilteredItems([])
+ setShowItemDropdown(false)
+ return
+ }
+
+ const searchItems = async () => {
+ try {
+ const results = await searchItemsForPQ(itemSearchQuery)
+ setFilteredItems(results)
+ setShowItemDropdown(true)
+ } catch (error) {
+ console.error("아이템 검색 오류:", error)
+ toast.error("아이템 검색 중 오류가 발생했습니다.")
+ setFilteredItems([])
+ setShowItemDropdown(false)
+ }
+ }
+
+ // 디바운싱: 300ms 후에 검색 실행
+ const timeoutId = setTimeout(searchItems, 300)
+ return () => clearTimeout(timeoutId)
+ }, [itemSearchQuery])
React.useEffect(() => {
if (type === "PROJECT") {
@@ -103,13 +142,37 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
setSelectedProjectId(null)
setAgreements({})
setDueDate(null)
- setPqItems("")
+ setPqItems([])
setExtraNote("")
setSelectedTemplateIds([])
-
+ setItemSearchQuery("")
+ setFilteredItems([])
+ setShowItemDropdown(false)
}
}, [props.open])
+ // 아이템 선택 함수
+ const handleSelectItem = (item: PQItem) => {
+ // 이미 선택된 아이템인지 확인
+ const isAlreadySelected = pqItems.some(selectedItem =>
+ selectedItem.itemCode === item.itemCode
+ )
+
+ if (!isAlreadySelected) {
+ setPqItems(prev => [...prev, item])
+ }
+
+ // 검색 초기화
+ setItemSearchQuery("")
+ setFilteredItems([])
+ setShowItemDropdown(false)
+ }
+
+ // 아이템 제거 함수
+ const handleRemoveItem = (itemCode: string) => {
+ setPqItems(prev => prev.filter(item => item.itemCode !== itemCode))
+ }
+
const onApprove = () => {
if (!type) return toast.error("PQ 유형을 선택하세요.")
if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.")
@@ -128,7 +191,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
projectId: type === "PROJECT" ? selectedProjectId : null,
type: type || "GENERAL",
extraNote,
- pqItems,
+ pqItems: JSON.stringify(pqItems),
templateId: selectedTemplateIds.length > 0 ? selectedTemplateIds[0] : null,
})
@@ -355,14 +418,68 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
{/* PQ 대상품목 */}
<div className="space-y-2">
- <Label htmlFor="pqItems">PQ 대상품목</Label>
- <textarea
- id="pqItems"
- value={pqItems}
- onChange={(e) => setPqItems(e.target.value)}
- placeholder="PQ 대상품목을 입력하세요 (선택사항)"
- className="w-full rounded-md border px-3 py-2 text-sm min-h-20 resize-none"
- />
+ <Label>PQ 대상품목</Label>
+
+ {/* 선택된 아이템들 표시 */}
+ {pqItems.length > 0 && (
+ <div className="flex flex-wrap gap-2 mb-2">
+ {pqItems.map((item) => (
+ <Badge key={item.itemCode} variant="secondary" className="flex items-center gap-1">
+ <span className="text-xs">
+ {item.itemCode} - {item.itemName}
+ </span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={() => handleRemoveItem(item.itemCode)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </Badge>
+ ))}
+ </div>
+ )}
+
+ {/* 검색 입력 */}
+ <div className="relative">
+ <div className="relative">
+ <Input
+ placeholder="아이템 코드 또는 이름으로 검색하세요"
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-9"
+ />
+ </div>
+
+ {/* 검색 결과 드롭다운 */}
+ {showItemDropdown && (
+ <div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-background border rounded-md shadow-lg">
+ {filteredItems.length > 0 ? (
+ filteredItems.map((item) => (
+ <button
+ key={item.itemCode}
+ type="button"
+ className="w-full px-3 py-2 text-left text-sm hover:bg-muted focus:bg-muted focus:outline-none"
+ onClick={() => handleSelectItem(item)}
+ >
+ <div className="font-medium">{item.itemCode}</div>
+ <div className="text-muted-foreground text-xs">{item.itemName}</div>
+ </button>
+ ))
+ ) : (
+ <div className="px-3 py-2 text-sm text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ <div className="text-xs text-muted-foreground">
+ 아이템 코드나 이름을 입력하여 검색하고 선택하세요. (선택사항)
+ </div>
</div>
{/* 추가 안내사항 */}