diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-13 11:05:09 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-13 11:05:09 +0000 |
| commit | 33be47506f0aa62b969d82521580a29e95080268 (patch) | |
| tree | 6b7e232f2d78ef8775944ea085a36b3ccbce7d95 /lib | |
| parent | 2ac95090157c355ea1bd0b8eb1e1e5e2bd56faf4 (diff) | |
(대표님) 입찰, 법무검토, EDP 변경사항 대응, dolce 개선, form-data 개선, 정규업체 등록관리 추가
(최겸) pq 미사용 컴포넌트 및 페이지 제거, 파일 라우트에 pq 적용
Diffstat (limited to 'lib')
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>
{/* 추가 안내사항 */}
|
