summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/[pqListId]/page.tsx7
-rw-r--r--components/notice/notice-client.tsx2
-rw-r--r--components/pq-input/pq-input-tabs.tsx92
-rw-r--r--components/pq-input/pq-review-wrapper.tsx36
-rw-r--r--config/menuConfig.ts6
-rw-r--r--config/vendorInvestigationsColumnsConfig.ts8
-rw-r--r--db/schema/pq.ts9
-rw-r--r--i18n/locales/en/menu.json6
-rw-r--r--i18n/locales/ko/menu.json2
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx46
-rw-r--r--lib/evaluation/table/evaluation-table.tsx11
-rw-r--r--lib/evaluation/table/vendor-submission-dialog.tsx623
-rw-r--r--lib/evaluation/vendor-submission-service.ts369
-rw-r--r--lib/export.ts6
-rw-r--r--lib/mail/templates/audit-result-notice.hbs3
-rw-r--r--lib/mail/templates/data-room-invitation.hbs210
-rw-r--r--lib/pq/pq-criteria/add-pq-dialog.tsx2
-rw-r--r--lib/pq/pq-criteria/pq-table-column.tsx50
-rw-r--r--lib/pq/pq-criteria/pq-table-toolbar-actions.tsx48
-rw-r--r--lib/pq/pq-criteria/pq-table.tsx10
-rw-r--r--lib/pq/pq-criteria/update-pq-sheet.tsx1
-rw-r--r--lib/pq/pq-review-table-new/edit-investigation-dialog.tsx2
-rw-r--r--lib/pq/pq-review-table-new/request-investigation-dialog.tsx6
-rw-r--r--lib/pq/pq-review-table-new/send-results-dialog.tsx8
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx80
-rw-r--r--lib/pq/service.ts583
-rw-r--r--lib/pq/table/pq-lists-table.tsx2
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx4
-rw-r--r--lib/vendor-investigation/table/update-investigation-sheet.tsx6
-rw-r--r--lib/vendors/service.ts60
30 files changed, 1908 insertions, 390 deletions
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/[pqListId]/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/[pqListId]/page.tsx
index 15cb3bf3..cc356f0e 100644
--- a/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/[pqListId]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/pq-criteria/[pqListId]/page.tsx
@@ -4,7 +4,7 @@ import { getValidFilters } from "@/lib/data-table"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Shell } from "@/components/shell"
import { searchParamsCache } from "@/lib/pq/validations"
-import { getPQsByListId } from "@/lib/pq/service"
+import { getPQsByListId, getPQListInfo } from "@/lib/pq/service"
import { PqsTable } from "@/lib/pq/pq-criteria/pq-table"
import { notFound } from "next/navigation"
@@ -26,12 +26,13 @@ export default async function PQDetailPage(props: PQDetailPageProps) {
// filters가 없는 경우를 처리
const validFilters = getValidFilters(search.filters)
- // PQ 항목들 가져오기
+ // PQ 리스트 정보와 항목들 가져오기
const promises = Promise.all([
getPQsByListId(pqListId, {
...search,
filters: validFilters,
- })
+ }),
+ getPQListInfo(pqListId)
])
return (
diff --git a/components/notice/notice-client.tsx b/components/notice/notice-client.tsx
index ae8ccebc..c68add0b 100644
--- a/components/notice/notice-client.tsx
+++ b/components/notice/notice-client.tsx
@@ -296,7 +296,7 @@ export function NoticeClient({ initialData = [], currentUserId }: NoticeClientPr
</button>
</TableHead>
<TableHead>팝업</TableHead>
- <TableHead>게시기간</TableHead>
+ <TableHead>팝업게시기간</TableHead>
{/* <TableHead>다시보지않기</TableHead> */}
<TableHead>작성자</TableHead>
<TableHead>상태</TableHead>
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx
index 3f7e1718..4e6b7ed2 100644
--- a/components/pq-input/pq-input-tabs.tsx
+++ b/components/pq-input/pq-input-tabs.tsx
@@ -15,6 +15,13 @@ import {
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown, Download, Loader2 } from "lucide-react"
import prettyBytes from "pretty-bytes"
import { useToast } from "@/hooks/use-toast"
@@ -129,6 +136,12 @@ const pqFormSchema = z.object({
type PQFormValues = z.infer<typeof pqFormSchema>
+// 통화 단위 옵션
+const currencyUnits = [
+ "USD", "EUR", "GBP", "JPY", "CNY", "KRW", "AUD", "CAD", "CHF", "HKD",
+ "SGD", "THB", "PHP", "IDR", "MYR", "VND", "INR", "BRL", "MXN", "RUB"
+]
+
// ----------------------------------------------------------------------
// 3) Main Component: PQInputTabs
// ----------------------------------------------------------------------
@@ -369,12 +382,12 @@ export function PQInputTabs({
break
case "PHONE":
case "FAX":
- // 전화번호/팩스번호는 숫자만 허용
- const phoneRegex = /^\d+$/
+ // 전화번호/팩스번호는 숫자와 하이픈 허용
+ const phoneRegex = /^[\d-]+$/
if (!phoneRegex.test(answerData.answer)) {
toast({
title: `${inputFormat === "PHONE" ? "전화번호" : "팩스번호"} 형식 오류`,
- description: `숫자만 입력해주세요.`,
+ description: `숫자와 하이픈(-)만 입력해주세요.`,
variant: "destructive",
})
return
@@ -391,6 +404,9 @@ export function PQInputTabs({
return
}
break
+ case "NUMBER_WITH_UNIT":
+ // 숫자+단위는 별도 검증 없음 (숫자와 단위가 분리되어 있음)
+ break
case "TEXT":
case "TEXT_FILE":
case "FILE":
@@ -767,7 +783,7 @@ export function PQInputTabs({
{data.map((group) => (
<TabsContent key={group.groupName} value={group.groupName}>
{/* 2-column grid */}
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4">
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 pb-4">
{sortByCode(group.items).map((item) => {
const { criteriaId, code, checkPoint, remarks, description, contractInfo, additionalRequirement } = item
const answerIndex = getAnswerIndex(criteriaId)
@@ -789,7 +805,7 @@ export function PQInputTabs({
return (
<Collapsible key={criteriaId} defaultOpen={isReadOnly || !isSaved} className="w-full">
- <Card className={isSaved ? "border-green-200" : ""}>
+ <Card className={`${isSaved ? "border-green-200" : ""} h-fit min-h-[400px]`}>
<CardHeader className="pb-1">
<div className="flex justify-between">
<div className="flex-1">
@@ -849,7 +865,7 @@ export function PQInputTabs({
</CardHeader>
<CollapsibleContent>
- <CardContent className="pt-3 space-y-3">
+ <CardContent className="pt-3 space-y-3 h-full flex flex-col">
{/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */}
{projectId && contractInfo && (
<div className="space-y-1">
@@ -884,8 +900,12 @@ export function PQInputTabs({
return "이메일 주소";
case "PHONE":
return "전화번호";
+ case "FAX":
+ return "팩스번호";
case "NUMBER":
return "숫자 값";
+ case "NUMBER_WITH_UNIT":
+ return "숫자+단위";
case "TEXT_FILE":
return "텍스트 답변";
default:
@@ -905,6 +925,7 @@ export function PQInputTabs({
type="email"
disabled={isDisabled}
placeholder="example@company.com"
+ className="h-12"
onChange={(e) => {
field.onChange(e)
form.setValue(
@@ -923,6 +944,7 @@ export function PQInputTabs({
type="tel"
disabled={isDisabled}
placeholder="02-1234-5678"
+ className="h-12"
onChange={(e) => {
// 전화번호 형식만 허용 (숫자, -, +, 공백)
const value = e.target.value;
@@ -943,6 +965,7 @@ export function PQInputTabs({
type="text"
disabled={isDisabled}
placeholder="숫자를 입력하세요"
+ className="h-12"
onChange={(e) => {
// 숫자만 허용
const value = e.target.value;
@@ -957,13 +980,60 @@ export function PQInputTabs({
}}
/>
);
+ case "NUMBER_WITH_UNIT":
+ return (
+ <div className="flex gap-2">
+ <Input
+ type="text"
+ disabled={isDisabled}
+ placeholder="숫자 입력"
+ className="h-12 flex-1"
+ value={field.value?.split(' ')[0] || ''}
+ onChange={(e) => {
+ const unit = field.value?.split(' ')[1] || ''
+ const newValue = e.target.value + (unit ? ` ${unit}` : '')
+ field.onChange(newValue)
+ form.setValue(
+ `answers.${answerIndex}.saved`,
+ false,
+ { shouldDirty: true }
+ )
+ }}
+ />
+ <Select
+ disabled={isDisabled}
+ value={field.value?.split(' ')[1] || ''}
+ onValueChange={(unit) => {
+ const number = field.value?.split(' ')[0] || ''
+ const newValue = number + (unit ? ` ${unit}` : '')
+ field.onChange(newValue)
+ form.setValue(
+ `answers.${answerIndex}.saved`,
+ false,
+ { shouldDirty: true }
+ )
+ }}
+ >
+ <SelectTrigger className="h-12 w-24">
+ <SelectValue placeholder="단위" />
+ </SelectTrigger>
+ <SelectContent>
+ {currencyUnits.map((unit) => (
+ <SelectItem key={unit} value={unit}>
+ {unit}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ );
case "TEXT_FILE":
return (
<div className="space-y-2">
<Textarea
{...field}
disabled={isDisabled}
- className="min-h-24"
+ className="min-h-32 h-32"
placeholder="텍스트 답변을 입력하세요"
onChange={(e) => {
field.onChange(e)
@@ -984,7 +1054,7 @@ export function PQInputTabs({
<Textarea
{...field}
disabled={isDisabled}
- className="min-h-24"
+ className="min-h-32 h-32"
placeholder="답변을 입력해주세요."
onChange={(e) => {
field.onChange(e)
@@ -1029,7 +1099,7 @@ export function PQInputTabs({
>
{() => (
<FormItem>
- <DropzoneZone className="flex justify-center h-24">
+ <DropzoneZone className="flex justify-center h-32">
<FormControl>
<DropzoneInput />
</FormControl>
@@ -1171,7 +1241,7 @@ export function PQInputTabs({
<Textarea
{...field}
disabled={true}
- className="min-h-20 bg-muted/50"
+ className="min-h-24 h-24 bg-muted/50"
placeholder="SHI 코멘트가 없습니다."
/>
</FormControl>
@@ -1192,7 +1262,7 @@ export function PQInputTabs({
<Textarea
{...field}
disabled={isDisabled}
- className="min-h-20 bg-muted/50"
+ className="min-h-24 h-24 bg-muted/50"
placeholder="벤더 Reply를 입력하세요."
onChange={(e) => {
field.onChange(e)
diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx
index 44916dce..1545314c 100644
--- a/components/pq-input/pq-review-wrapper.tsx
+++ b/components/pq-input/pq-review-wrapper.tsx
@@ -366,7 +366,7 @@ export function PQReviewWrapper({
<div key={group.groupName} className="space-y-4">
<h3 className="text-lg font-medium">{group.groupName}</h3>
- <div className="grid grid-cols-1 gap-4">
+ <div className="grid grid-cols-2 gap-4">
{sortByCode(group.items).map((item) => (
<Card key={item.criteriaId}>
<CardHeader>
@@ -439,7 +439,9 @@ export function PQReviewWrapper({
{item.inputFormat === "TEXT" && "텍스트"}
{item.inputFormat === "EMAIL" && "이메일"}
{item.inputFormat === "PHONE" && "전화번호"}
+ {item.inputFormat === "FAX" && "팩스번호"}
{item.inputFormat === "NUMBER" && "숫자"}
+ {item.inputFormat === "NUMBER_WITH_UNIT" && "숫자+단위"}
{item.inputFormat === "FILE" && "파일"}
{item.inputFormat === "TEXT_FILE" && "텍스트+파일"}
</Badge>
@@ -468,6 +470,15 @@ export function PQReviewWrapper({
</div>
</div>
);
+ case "FAX":
+ return (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">팩스번호:</div>
+ <div className="whitespace-pre-wrap">
+ {item.answer || <span className="text-muted-foreground">답변 없음</span>}
+ </div>
+ </div>
+ );
case "NUMBER":
return (
<div className="space-y-2">
@@ -477,6 +488,29 @@ export function PQReviewWrapper({
</div>
</div>
);
+ case "NUMBER_WITH_UNIT":
+ const numberWithUnit = item.answer || "";
+ const [number, unit] = numberWithUnit.split(' ');
+ return (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">숫자+단위:</div>
+ <div className="flex items-center gap-2">
+ {number && (
+ <span className="font-mono text-lg font-semibold text-blue-600">
+ {number}
+ </span>
+ )}
+ {unit && (
+ <Badge variant="outline" className="text-xs">
+ {unit}
+ </Badge>
+ )}
+ {!numberWithUnit && (
+ <span className="text-muted-foreground">답변 없음</span>
+ )}
+ </div>
+ </div>
+ );
case "FILE":
return (
<div className="space-y-2">
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index d28b1838..06aa45c7 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -902,6 +902,12 @@ export const mainNavVendor: MenuSection[] = [
useGrouping: true,
items: [
{
+ titleKey: "menu.vendor.procurement.pq_new",
+ href: `/partners/pq_new`,
+ descriptionKey: "menu.vendor.procurement.pq_new_desc",
+ groupKey: "groups.propose"
+ },
+ {
titleKey: "menu.vendor.procurement.basic_contract_sign",
href: `/partners/basic-contract`,
descriptionKey: "menu.vendor.procurement.basic_contract_sign_desc",
diff --git a/config/vendorInvestigationsColumnsConfig.ts b/config/vendorInvestigationsColumnsConfig.ts
index 7da096be..4e15bd34 100644
--- a/config/vendorInvestigationsColumnsConfig.ts
+++ b/config/vendorInvestigationsColumnsConfig.ts
@@ -144,15 +144,15 @@ export const vendorInvestigationsColumnsConfig: VendorInvestigationsColumnConfig
},
{
id: "forecastedAt",
- label: "실사 예정일",
- excelHeader: "실사 예정일",
+ label: "실사 수행 예정일",
+ excelHeader: "실사 수행 예정일",
group: "일정",
},
{
id: "confirmedAt",
- label: "실사 확정일",
- excelHeader: "실사 확정일",
+ label: "실사 계획 확정일",
+ excelHeader: "실사 계획 확정일",
group: "일정",
},
{
diff --git a/db/schema/pq.ts b/db/schema/pq.ts
index 0bc720f6..56f2cc40 100644
--- a/db/schema/pq.ts
+++ b/db/schema/pq.ts
@@ -143,15 +143,6 @@ export const vendorPQSubmissions = pgTable("vendor_pq_submissions", {
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
-}, (table) => {
- return {
- // 협력업체별로 일반 PQ는 하나만, 프로젝트 PQ는 프로젝트당 하나만
- uniqueConstraint: uniqueIndex("unique_pq_submission").on(
- table.vendorId,
- table.projectId,
- table.type
- ),
- };
});
// 기존 vendorPqCriteriaAnswers 테이블에 projectId 필드 추가
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index 5fabc5c4..712b1d12 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -241,8 +241,10 @@
"pcr_desc": "Purchase Change Request management",
"general_contract": "General Contract",
"general_contract_desc": "Order list confirmation and electronic signature",
- "rfq_response":"견적 응답",
- "rfq_response_desc":"견적 요청에 대한 응답 작성"
+ "pq_new": "PQ Submission",
+ "pq_new_desc": "Submit PQ",
+ "rfq_response":"RFQ Response",
+ "rfq_response_desc":"Create response to quotation request"
},
"engineering": {
"tbe": "TBE",
diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json
index 28a97373..ce69f274 100644
--- a/i18n/locales/ko/menu.json
+++ b/i18n/locales/ko/menu.json
@@ -243,6 +243,8 @@
"pcr_desc": "PCR 관리",
"general_contract": "일반 계약",
"general_contract_desc": "발주 리스트 확인 및 전자서명",
+ "pq_new": "PQ 제출",
+ "pq_new_desc": "PQ 제출",
"rfq_response": "견적 응답",
"rfq_response_desc": "견적 요청에 대한 응답 작성"
},
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index e8b51b57..b68aa70d 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -6,13 +6,19 @@ import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis, BarChart3 } from "lucide-react";
+import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis, BarChart3, ChevronDown } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema";
import { DataTableRowAction } from "@/types/table";
@@ -466,16 +472,34 @@ export function getPeriodicEvaluationsColumns({
cell: ({ row }) => {
return (
<div className="flex items-center gap-1">
- <Button
- variant="ghost"
- size="icon"
- className="size-8"
- onClick={() => setRowAction({ row, type: "view" })}
- aria-label="상세보기"
- title="상세보기"
- >
- <Ellipsis className="size-4" />
- </Button>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ aria-label="평가 상세 메뉴"
+ >
+ <Ellipsis className="size-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "view" })}
+ className="flex items-center gap-2"
+ >
+ <Eye className="h-4 w-4" />
+ 평가 상세
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "vendor-submission" as any })}
+ className="flex items-center gap-2"
+ >
+ <FileText className="h-4 w-4" />
+ 협력업체 제출 상세
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
);
},
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index 4404967a..1a5b450b 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -37,6 +37,7 @@ import {
} from "../service"
import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions"
import { EvaluationDetailsDialog } from "./evaluation-details-dialog"
+import { VendorSubmissionDialog } from "./vendor-submission-dialog"
import { searchParamsEvaluationsCache } from "../validation"
interface PeriodicEvaluationsTableProps {
@@ -745,6 +746,16 @@ export function PeriodicEvaluationsTable({
}}
evaluation={rowAction?.row.original || null}
/>
+
+ <VendorSubmissionDialog
+ open={rowAction?.type === "vendor-submission"}
+ onOpenChange={(open) => {
+ if (!open) {
+ setRowAction(null);
+ }
+ }}
+ evaluation={rowAction?.row.original || null}
+ />
</div>
</div>
</div>
diff --git a/lib/evaluation/table/vendor-submission-dialog.tsx b/lib/evaluation/table/vendor-submission-dialog.tsx
new file mode 100644
index 00000000..aff8dc56
--- /dev/null
+++ b/lib/evaluation/table/vendor-submission-dialog.tsx
@@ -0,0 +1,623 @@
+"use client"
+
+import * as React from "react"
+import {
+ Eye,
+ Building2,
+ User,
+ Calendar,
+ CheckCircle2,
+ Clock,
+ MessageSquare,
+ Award,
+ FileText,
+ Paperclip,
+ Download,
+ File,
+ BarChart3,
+ ChevronDown,
+ ChevronRight
+} from "lucide-react"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Separator } from "@/components/ui/separator"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { PeriodicEvaluationView } from "@/db/schema"
+import { getVendorSubmissionDetails, type VendorSubmissionDetail } from "../vendor-submission-service"
+import { formatFileSize, getFileInfo } from "@/lib/file-download"
+
+interface VendorSubmissionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ evaluation: PeriodicEvaluationView | null
+}
+
+// 상태별 배지 색상
+const getSubmissionStatusBadge = (status: string) => {
+ switch (status) {
+ case "submitted":
+ return <Badge variant="default" className="bg-green-600">제출완료</Badge>
+ case "draft":
+ return <Badge variant="secondary">임시저장</Badge>
+ case "reviewed":
+ return <Badge variant="outline">검토완료</Badge>
+ default:
+ return <Badge variant="outline">{status}</Badge>
+ }
+}
+
+// 진행률 계산
+const getProgressPercentage = (completed: number, total: number) => {
+ if (total === 0) return 0
+ return Math.round((completed / total) * 100)
+}
+
+export function VendorSubmissionDialog({
+ open,
+ onOpenChange,
+ evaluation,
+}: VendorSubmissionDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [submissionDetails, setSubmissionDetails] = React.useState<VendorSubmissionDetail | null>(null)
+ const [expandedGeneralItems, setExpandedGeneralItems] = React.useState<Set<number>>(new Set())
+ const [expandedEsgItems, setExpandedEsgItems] = React.useState<Set<number>>(new Set())
+
+ // 첨부파일 다운로드 핸들러
+ const handleDownloadAttachment = async (filePath: string, fileName: string) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(filePath, fileName, {
+ action: 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 실패:", error)
+ },
+ })
+ } catch (error) {
+ console.error("파일 다운로드 실패:", error)
+ }
+ }
+
+ // 일반평가 항목 토글
+ const toggleGeneralItem = (itemId: number) => {
+ const newExpanded = new Set(expandedGeneralItems)
+ if (newExpanded.has(itemId)) {
+ newExpanded.delete(itemId)
+ } else {
+ newExpanded.add(itemId)
+ }
+ setExpandedGeneralItems(newExpanded)
+ }
+
+ // ESG 평가 항목 토글
+ const toggleEsgItem = (itemId: number) => {
+ const newExpanded = new Set(expandedEsgItems)
+ if (newExpanded.has(itemId)) {
+ newExpanded.delete(itemId)
+ } else {
+ newExpanded.add(itemId)
+ }
+ setExpandedEsgItems(newExpanded)
+ }
+
+ // 제출 상세 정보 로드
+ React.useEffect(() => {
+ if (open && evaluation?.id) {
+ const loadSubmissionDetails = async () => {
+ try {
+ setIsLoading(true)
+ const details = await getVendorSubmissionDetails(evaluation.id)
+ setSubmissionDetails(details)
+ } catch (error) {
+ console.error("Failed to load vendor submission details:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSubmissionDetails()
+ }
+ }, [open, evaluation?.id])
+
+ // 다이얼로그 닫을 때 데이터 리셋
+ React.useEffect(() => {
+ if (!open) {
+ setSubmissionDetails(null)
+ setExpandedGeneralItems(new Set())
+ setExpandedEsgItems(new Set())
+ }
+ }, [open])
+
+ if (!evaluation) return null
+
+ return (
+ <TooltipProvider>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col">
+ {/* 고정 헤더 */}
+ <DialogHeader className="space-y-4 flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5 text-blue-600" />
+ 협력업체 제출 상세
+ </DialogTitle>
+
+ {/* 평가 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <Building2 className="h-5 w-5" />
+ 평가 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex flex-wrap items-center gap-6 text-sm mb-4">
+ {/* 협력업체 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">협력업체:</span>
+ <span className="font-medium">{evaluation.vendorName}</span>
+ <span className="text-muted-foreground">({evaluation.vendorCode})</span>
+ </div>
+
+ {/* 평가년도 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">년도:</span>
+ <span className="font-medium">{evaluation.evaluationYear}년</span>
+ </div>
+
+ {/* 구분 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">구분:</span>
+ <Badge variant="outline" className="text-xs">
+ {evaluation.division === "PLANT" ? "해양" : "조선"}
+ </Badge>
+ </div>
+
+ {/* 진행상태 */}
+ <div className="flex items-center gap-2">
+ <span className="text-muted-foreground">상태:</span>
+ <Badge variant="secondary" className="text-xs">{evaluation.status}</Badge>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto min-h-0">
+ {isLoading ? (
+ <div className="space-y-4 p-1">
+ <Card>
+ <CardHeader>
+ <Skeleton className="h-6 w-48" />
+ </CardHeader>
+ <CardContent>
+ <Skeleton className="h-64 w-full" />
+ </CardContent>
+ </Card>
+ </div>
+ ) : submissionDetails ? (
+ <div className="space-y-6 p-1">
+ {/* 제출 정보 요약 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 제출 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
+ <div className="space-y-1">
+ <div className="text-muted-foreground">제출 상태</div>
+ <div>{getSubmissionStatusBadge(submissionDetails.submissionStatus)}</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">제출일</div>
+ <div className="font-medium">
+ {submissionDetails.submittedAt
+ ? new Date(submissionDetails.submittedAt).toLocaleDateString('ko-KR')
+ : "-"
+ }
+ </div>
+ </div>
+ {/* <div className="space-y-1">
+ <div className="text-muted-foreground">ESG 평균 점수</div>
+ <div className="font-medium">
+ {submissionDetails.averageEsgScore
+ ? `${submissionDetails.averageEsgScore.toFixed(1)}점`
+ : "-"
+ }
+ </div>
+ </div> */}
+ {/* <div className="space-y-1">
+ <div className="text-muted-foreground">검토자</div>
+ <div className="font-medium">
+ {submissionDetails.reviewedBy || "-"}
+ </div>
+ </div> */}
+ </div>
+
+ {/* 진행률 표시 */}
+ {/* <div className="mt-6 space-y-4">
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="text-muted-foreground">일반평가 진행률</span>
+ <span className="font-medium">
+ {submissionDetails.completedGeneralItems}/{submissionDetails.totalGeneralItems}
+ ({getProgressPercentage(submissionDetails.completedGeneralItems, submissionDetails.totalGeneralItems)}%)
+ </span>
+ </div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-blue-600 h-2 rounded-full transition-all duration-300"
+ style={{
+ width: `${getProgressPercentage(submissionDetails.completedGeneralItems, submissionDetails.totalGeneralItems)}%`
+ }}
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="text-muted-foreground">ESG 평가 진행률</span>
+ <span className="font-medium">
+ {submissionDetails.completedEsgItems}/{submissionDetails.totalEsgItems}
+ ({getProgressPercentage(submissionDetails.completedEsgItems, submissionDetails.totalEsgItems)}%)
+ </span>
+ </div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-green-600 h-2 rounded-full transition-all duration-300"
+ style={{
+ width: `${getProgressPercentage(submissionDetails.completedEsgItems, submissionDetails.totalEsgItems)}%`
+ }}
+ />
+ </div>
+ </div>
+ </div> */}
+ </CardContent>
+ </Card>
+
+ {/* 탭으로 일반평가와 ESG 평가 구분 */}
+ <Tabs defaultValue="general" className="w-full">
+ <TabsList className={`grid w-full ${submissionDetails.vendor.country === "KR" ? "grid-cols-2" : "grid-cols-1"}`}>
+ <TabsTrigger value="general" className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ 일반평가 ({submissionDetails.generalEvaluations.length}개)
+ </TabsTrigger>
+ {submissionDetails.vendor.country === "KR" && (
+ <TabsTrigger value="esg" className="flex items-center gap-2">
+ <Award className="h-4 w-4" />
+ ESG 평가 ({submissionDetails.esgEvaluations.length}개)
+ </TabsTrigger>
+ )}
+ </TabsList>
+
+ {/* 일반평가 탭 */}
+ <TabsContent value="general" className="space-y-4">
+ {submissionDetails.generalEvaluations.length > 0 ? (
+ <div className="space-y-4">
+ {submissionDetails.generalEvaluations.map((item) => (
+ <Card key={item.id}>
+ <Collapsible>
+ <CollapsibleTrigger asChild>
+ <CardHeader
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ onClick={() => toggleGeneralItem(item.id)}
+ >
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {expandedGeneralItems.has(item.id) ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <CardTitle className="text-base">
+ {item.serialNumber}. {item.category}
+ </CardTitle>
+ </div>
+ <div className="flex items-center gap-2">
+ {item.response ? (
+ <Badge variant="default" className="bg-green-600">응답완료</Badge>
+ ) : (
+ <Badge variant="outline">미응답</Badge>
+ )}
+ {item.response?.hasAttachments && (
+ <Badge variant="secondary" className="text-xs">
+ <Paperclip className="h-3 w-3 mr-1" />
+ 첨부파일
+ </Badge>
+ )}
+ </div>
+ </div>
+ </CardHeader>
+ </CollapsibleTrigger>
+ <CollapsibleContent>
+ <CardContent className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">평가 항목</div>
+ <div className="text-sm text-muted-foreground">{item.inspectionItem}</div>
+ {item.remarks && (
+ <div className="mt-2">
+ <div className="text-sm font-medium mb-1">비고</div>
+ <div className="text-sm text-muted-foreground">{item.remarks}</div>
+ </div>
+ )}
+ </div>
+
+ {item.response ? (
+ <div className="space-y-4">
+ <Separator />
+ <div>
+ <div className="text-sm font-medium mb-2">협력업체 응답</div>
+ <div className="bg-muted p-3 rounded-md text-sm">
+ {item.response.responseText || "응답 내용이 없습니다."}
+ </div>
+ </div>
+
+ {/* 첨부파일 */}
+ {item.response.attachments.length > 0 && (
+ <div>
+ <div className="text-sm font-medium mb-2">첨부파일</div>
+ <div className="space-y-2">
+ {item.response.attachments.map((attachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+ return (
+ <div key={attachment.id} className="flex items-center gap-2 p-2 bg-muted rounded">
+ <span className="text-sm">{fileInfo.icon}</span>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="text-sm truncate flex-1 cursor-help">
+ {attachment.originalFileName}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="text-xs space-y-1">
+ <div className="font-medium">{attachment.originalFileName}</div>
+ <div>크기: {formatFileSize(attachment.fileSize)}</div>
+ <div>타입: {fileInfo.type}</div>
+ <div>업로드: {attachment.uploadedBy}</div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => handleDownloadAttachment(attachment.filePath, attachment.originalFileName)}
+ >
+ <Download className="h-3 w-3" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* 검토자 의견 */}
+ {item.response.reviewComments && (
+ <div>
+ <div className="text-sm font-medium mb-2">검토자 의견</div>
+ <div className="bg-blue-50 p-3 rounded-md text-sm">
+ {item.response.reviewComments}
+ </div>
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-center text-muted-foreground py-4">
+ <Clock className="h-8 w-8 mx-auto mb-2" />
+ <div>아직 응답하지 않았습니다</div>
+ </div>
+ )}
+ </CardContent>
+ </CollapsibleContent>
+ </Collapsible>
+ </Card>
+ ))}
+ </div>
+ ) : (
+ <Card>
+ <CardContent className="py-8">
+ <div className="text-center text-muted-foreground">
+ <FileText className="h-8 w-8 mx-auto mb-2" />
+ <div>일반평가 항목이 없습니다</div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+
+ {/* ESG 평가 탭 */}
+ <TabsContent value="esg" className="space-y-4">
+ {submissionDetails.esgEvaluations.length > 0 ? (
+ <div className="space-y-4">
+ {submissionDetails.esgEvaluations.map((esgEvaluation) => (
+ <Card key={esgEvaluation.id}>
+ <Collapsible>
+ <CollapsibleTrigger asChild>
+ <CardHeader
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ onClick={() => toggleEsgItem(esgEvaluation.id)}
+ >
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {expandedEsgItems.has(esgEvaluation.id) ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <CardTitle className="text-base">
+ {esgEvaluation.serialNumber}. {esgEvaluation.category}
+ </CardTitle>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="text-xs">
+ {esgEvaluation.evaluationItems.length}개 항목
+ </Badge>
+ </div>
+ </div>
+ </CardHeader>
+ </CollapsibleTrigger>
+ <CollapsibleContent>
+ <CardContent className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">평가 항목</div>
+ <div className="text-sm text-muted-foreground">{esgEvaluation.inspectionItem}</div>
+ </div>
+
+ <Separator />
+
+ {/* ESG 평가 세부 항목들 */}
+ <div className="space-y-3">
+ {esgEvaluation.evaluationItems.map((item) => (
+ <div key={item.id} className="border rounded-lg p-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium">{item.evaluationItem}</div>
+ {item.evaluationItemDescription && (
+ <div className="text-xs text-muted-foreground">
+ {item.evaluationItemDescription}
+ </div>
+ )}
+
+ {item.response ? (
+ <div className="space-y-2">
+ <div className="flex items-center gap-2">
+ <Badge variant="default" className="bg-green-600 text-xs">
+ 선택된 답변
+ </Badge>
+ <span className="text-sm font-medium">
+ {item.response.answerOption.answerText}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {item.response.selectedScore}점
+ </Badge>
+ </div>
+ {item.response.additionalComments && (
+ <div className="bg-muted p-2 rounded text-xs">
+ <div className="font-medium mb-1">추가 의견:</div>
+ <div>{item.response.additionalComments}</div>
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-center text-muted-foreground py-2">
+ <Clock className="h-4 w-4 mx-auto mb-1" />
+ <div className="text-xs">아직 응답하지 않았습니다</div>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </CollapsibleContent>
+ </Collapsible>
+ </Card>
+ ))}
+ </div>
+ ) : (
+ <Card>
+ <CardContent className="py-8">
+ <div className="text-center text-muted-foreground">
+ <Award className="h-8 w-8 mx-auto mb-2" />
+ <div>ESG 평가 항목이 없습니다</div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+ </Tabs>
+
+ {/* 첨부파일 요약 */}
+ {submissionDetails.attachmentStats.totalFiles > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <BarChart3 className="h-5 w-5" />
+ 첨부파일 요약
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
+ <div className="space-y-1">
+ <div className="text-muted-foreground">전체 파일 수</div>
+ <div className="font-bold text-lg">{submissionDetails.attachmentStats.totalFiles}개</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">전체 파일 크기</div>
+ <div className="font-bold text-lg">{formatFileSize(submissionDetails.attachmentStats.totalSize)}</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">일반평가 첨부파일</div>
+ <div className="font-bold text-lg">{submissionDetails.attachmentStats.generalEvaluationFiles}개</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">ESG 평가 첨부파일</div>
+ <div className="font-bold text-lg">{submissionDetails.attachmentStats.esgEvaluationFiles}개</div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 검토자 의견 */}
+ {submissionDetails.reviewComments && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 검토자 의견
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="bg-muted p-3 rounded-md text-sm">
+ {submissionDetails.reviewComments}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ ) : (
+ <Card className="m-1">
+ <CardContent className="py-8">
+ <div className="text-center text-muted-foreground">
+ <User className="h-8 w-8 mx-auto mb-2" />
+ <div>제출 내용이 없습니다</div>
+ <div className="text-xs mt-1">협력업체가 아직 평가를 제출하지 않았습니다</div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 고정 푸터 */}
+ <div className="flex justify-end pt-4 border-t flex-shrink-0">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </TooltipProvider>
+ )
+}
diff --git a/lib/evaluation/vendor-submission-service.ts b/lib/evaluation/vendor-submission-service.ts
new file mode 100644
index 00000000..388f382a
--- /dev/null
+++ b/lib/evaluation/vendor-submission-service.ts
@@ -0,0 +1,369 @@
+'use server'
+
+import db from "@/db/db"
+import {
+ evaluationSubmissions,
+ generalEvaluations,
+ generalEvaluationResponses,
+ esgEvaluations,
+ esgEvaluationItems,
+ esgAnswerOptions,
+ esgEvaluationResponses,
+ vendorEvaluationAttachments,
+ periodicEvaluations,
+ evaluationTargets,
+ vendors,
+} from "@/db/schema"
+import { eq, and, asc, desc, sql } from "drizzle-orm"
+
+// 협력업체 제출 상세 정보 타입 정의
+export interface VendorSubmissionDetail {
+ // 제출 기본 정보
+ submissionId: string
+ evaluationYear: number
+ evaluationRound: string | null
+ submissionStatus: string
+ submittedAt: Date | null
+ reviewedAt: Date | null
+ reviewedBy: string | null
+ reviewComments: string | null
+ averageEsgScore: number | null
+
+ // 진행률 통계
+ totalGeneralItems: number
+ completedGeneralItems: number
+ totalEsgItems: number
+ completedEsgItems: number
+
+ // 협력업체 정보
+ vendor: {
+ id: number
+ vendorCode: string
+ vendorName: string
+ email: string | null
+ country: string | null
+ }
+
+ // 일반평가 응답
+ generalEvaluations: {
+ id: number
+ serialNumber: string
+ category: string
+ inspectionItem: string
+ remarks: string | null
+ response: {
+ responseText: string
+ hasAttachments: boolean
+ reviewComments: string | null
+ attachments: {
+ id: number
+ fileId: string
+ originalFileName: string
+ storedFileName: string
+ filePath: string
+ fileSize: number
+ mimeType: string | null
+ uploadedBy: string
+ createdAt: Date
+ }[]
+ } | null
+ }[]
+
+ // ESG 평가 응답
+ esgEvaluations: {
+ id: number
+ serialNumber: string
+ category: string
+ inspectionItem: string
+ evaluationItems: {
+ id: number
+ evaluationItem: string
+ evaluationItemDescription: string | null
+ orderIndex: number
+ response: {
+ selectedScore: number
+ additionalComments: string | null
+ answerOption: {
+ id: number
+ answerText: string
+ score: number
+ }
+ } | null
+ }[]
+ }[]
+
+ // 첨부파일 통계
+ attachmentStats: {
+ totalFiles: number
+ totalSize: number
+ generalEvaluationFiles: number
+ esgEvaluationFiles: number
+ }
+}
+
+/**
+ * 특정 정기평가에 대한 협력업체 제출 상세 정보를 조회합니다
+ */
+export async function getVendorSubmissionDetails(periodicEvaluationId: number): Promise<VendorSubmissionDetail | null> {
+ try {
+ // 1. 제출 정보 조회
+ const submissionResult = await db
+ .select({
+ // 제출 기본 정보
+ id: evaluationSubmissions.id,
+ submissionId: evaluationSubmissions.submissionId,
+ evaluationYear: evaluationSubmissions.evaluationYear,
+ evaluationRound: evaluationSubmissions.evaluationRound,
+ submissionStatus: evaluationSubmissions.submissionStatus,
+ submittedAt: evaluationSubmissions.submittedAt,
+ reviewedAt: evaluationSubmissions.reviewedAt,
+ reviewedBy: evaluationSubmissions.reviewedBy,
+ reviewComments: evaluationSubmissions.reviewComments,
+ averageEsgScore: evaluationSubmissions.averageEsgScore,
+
+ // 진행률 통계
+ totalGeneralItems: evaluationSubmissions.totalGeneralItems,
+ completedGeneralItems: evaluationSubmissions.completedGeneralItems,
+ totalEsgItems: evaluationSubmissions.totalEsgItems,
+ completedEsgItems: evaluationSubmissions.completedEsgItems,
+
+ // 협력업체 정보
+ vendorId: vendors.id,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ vendorEmail: vendors.email,
+ vendorCountry: vendors.country,
+ })
+ .from(evaluationSubmissions)
+ .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id))
+ .where(
+ and(
+ eq(evaluationSubmissions.periodicEvaluationId, periodicEvaluationId),
+ eq(evaluationSubmissions.isActive, true)
+ )
+ )
+ .limit(1)
+
+ if (submissionResult.length === 0) {
+ return null // 제출 내용이 없음
+ }
+
+ const submission = submissionResult[0]
+ const submissionId = submission.id // evaluationSubmissions.id (integer)
+ const submissionUuid = submission.submissionId // evaluationSubmissions.submissionId (UUID)
+
+ console.log("=== 협력업체 제출 상세 조회 시작 ===")
+ console.log("submissionId (integer):", submissionId)
+ console.log("submissionUuid:", submissionUuid)
+ console.log("submission:", submission)
+
+ // 2. 일반평가 항목과 응답 조회
+ const generalEvaluationsResult = await db
+ .select({
+ // 일반평가 항목 정보
+ generalEvaluationId: generalEvaluations.id,
+ serialNumber: generalEvaluations.serialNumber,
+ category: generalEvaluations.category,
+ inspectionItem: generalEvaluations.inspectionItem,
+ remarks: generalEvaluations.remarks,
+
+ // 응답 정보
+ responseId: generalEvaluationResponses.id,
+ responseText: generalEvaluationResponses.responseText,
+ hasAttachments: generalEvaluationResponses.hasAttachments,
+ reviewComments: generalEvaluationResponses.reviewComments,
+
+ // 첨부파일 정보
+ attachmentId: vendorEvaluationAttachments.id,
+ fileId: vendorEvaluationAttachments.fileId,
+ originalFileName: vendorEvaluationAttachments.originalFileName,
+ storedFileName: vendorEvaluationAttachments.storedFileName,
+ filePath: vendorEvaluationAttachments.filePath,
+ fileSize: vendorEvaluationAttachments.fileSize,
+ mimeType: vendorEvaluationAttachments.mimeType,
+ uploadedBy: vendorEvaluationAttachments.uploadedBy,
+ attachmentCreatedAt: vendorEvaluationAttachments.createdAt,
+ })
+ .from(generalEvaluations)
+ .leftJoin(
+ generalEvaluationResponses,
+ and(
+ eq(generalEvaluationResponses.generalEvaluationId, generalEvaluations.id),
+ eq(generalEvaluationResponses.submissionId, submissionId),
+ eq(generalEvaluationResponses.isActive, true)
+ )
+ )
+ .leftJoin(
+ vendorEvaluationAttachments,
+ eq(vendorEvaluationAttachments.generalEvaluationResponseId, generalEvaluationResponses.id)
+ )
+ .where(eq(generalEvaluations.isActive, true))
+ .orderBy(asc(generalEvaluations.serialNumber))
+
+ // 3. ESG 평가 항목과 응답 조회
+ const esgEvaluationsResult = await db
+ .select({
+ // ESG 평가표 정보
+ esgEvaluationId: esgEvaluations.id,
+ esgSerialNumber: esgEvaluations.serialNumber,
+ esgCategory: esgEvaluations.category,
+ esgInspectionItem: esgEvaluations.inspectionItem,
+
+ // ESG 평가항목 정보
+ esgEvaluationItemId: esgEvaluationItems.id,
+ evaluationItem: esgEvaluationItems.evaluationItem,
+ evaluationItemDescription: esgEvaluationItems.evaluationItemDescription,
+ orderIndex: esgEvaluationItems.orderIndex,
+
+ // ESG 응답 정보
+ esgResponseId: esgEvaluationResponses.id,
+ selectedScore: esgEvaluationResponses.selectedScore,
+ additionalComments: esgEvaluationResponses.additionalComments,
+
+ // ESG 답변 옵션 정보
+ answerOptionId: esgAnswerOptions.id,
+ answerText: esgAnswerOptions.answerText,
+ answerScore: esgAnswerOptions.score,
+ })
+ .from(esgEvaluations)
+ .innerJoin(esgEvaluationItems, eq(esgEvaluationItems.esgEvaluationId, esgEvaluations.id))
+ .leftJoin(
+ esgEvaluationResponses,
+ and(
+ eq(esgEvaluationResponses.esgEvaluationItemId, esgEvaluationItems.id),
+ eq(esgEvaluationResponses.submissionId, submissionId),
+ eq(esgEvaluationResponses.isActive, true)
+ )
+ )
+ .leftJoin(
+ esgAnswerOptions,
+ eq(esgAnswerOptions.id, esgEvaluationResponses.esgAnswerOptionId)
+ )
+ .where(
+ and(
+ eq(esgEvaluations.isActive, true),
+ eq(esgEvaluationItems.isActive, true)
+ )
+ )
+ .orderBy(
+ asc(esgEvaluations.serialNumber),
+ asc(esgEvaluationItems.orderIndex)
+ )
+
+ // 4. 데이터 가공
+ // 일반평가 데이터 그룹화
+ const generalEvaluationsMap = new Map<number, any>()
+ generalEvaluationsResult.forEach(row => {
+ if (!generalEvaluationsMap.has(row.generalEvaluationId)) {
+ generalEvaluationsMap.set(row.generalEvaluationId, {
+ id: row.generalEvaluationId,
+ serialNumber: row.serialNumber,
+ category: row.category,
+ inspectionItem: row.inspectionItem,
+ remarks: row.remarks,
+ response: row.responseId ? {
+ responseText: row.responseText,
+ hasAttachments: row.hasAttachments,
+ reviewComments: row.reviewComments,
+ attachments: []
+ } : null
+ })
+ }
+
+ // 첨부파일 추가
+ if (row.attachmentId && generalEvaluationsMap.get(row.generalEvaluationId)?.response) {
+ generalEvaluationsMap.get(row.generalEvaluationId).response.attachments.push({
+ id: row.attachmentId,
+ fileId: row.fileId,
+ originalFileName: row.originalFileName,
+ storedFileName: row.storedFileName,
+ filePath: row.filePath,
+ fileSize: row.fileSize,
+ mimeType: row.mimeType,
+ uploadedBy: row.uploadedBy,
+ createdAt: new Date(row.attachmentCreatedAt)
+ })
+ }
+ })
+
+ // ESG 평가 데이터 그룹화
+ const esgEvaluationsMap = new Map<number, any>()
+ esgEvaluationsResult.forEach(row => {
+ if (!esgEvaluationsMap.has(row.esgEvaluationId)) {
+ esgEvaluationsMap.set(row.esgEvaluationId, {
+ id: row.esgEvaluationId,
+ serialNumber: row.esgSerialNumber,
+ category: row.esgCategory,
+ inspectionItem: row.esgInspectionItem,
+ evaluationItems: []
+ })
+ }
+
+ const esgEvaluation = esgEvaluationsMap.get(row.esgEvaluationId)
+
+ // 평가항목 추가 (중복 방지)
+ const existingItem = esgEvaluation.evaluationItems.find((item: any) => item.id === row.esgEvaluationItemId)
+ if (!existingItem) {
+ esgEvaluation.evaluationItems.push({
+ id: row.esgEvaluationItemId,
+ evaluationItem: row.evaluationItem,
+ evaluationItemDescription: row.evaluationItemDescription,
+ orderIndex: row.orderIndex,
+ response: row.esgResponseId ? {
+ selectedScore: Number(row.selectedScore),
+ additionalComments: row.additionalComments,
+ answerOption: {
+ id: row.answerOptionId,
+ answerText: row.answerText,
+ score: Number(row.answerScore)
+ }
+ } : null
+ })
+ }
+ })
+
+ // 5. 첨부파일 통계 계산
+ const allAttachments = generalEvaluationsResult
+ .filter(row => row.attachmentId)
+ .map(row => ({
+ id: row.attachmentId,
+ fileSize: row.fileSize
+ }))
+
+ const attachmentStats = {
+ totalFiles: allAttachments.length,
+ totalSize: allAttachments.reduce((sum, att) => sum + att.fileSize, 0),
+ generalEvaluationFiles: allAttachments.length,
+ esgEvaluationFiles: 0 // ESG는 첨부파일 없음
+ }
+
+ return {
+ submissionId: submission.submissionId,
+ evaluationYear: submission.evaluationYear,
+ evaluationRound: submission.evaluationRound,
+ submissionStatus: submission.submissionStatus,
+ submittedAt: submission.submittedAt,
+ reviewedAt: submission.reviewedAt,
+ reviewedBy: submission.reviewedBy,
+ reviewComments: submission.reviewComments,
+ averageEsgScore: submission.averageEsgScore ? Number(submission.averageEsgScore) : null,
+
+
+ vendor: {
+ id: submission.vendorId,
+ vendorCode: submission.vendorCode || "",
+ vendorName: submission.vendorName,
+ email: submission.vendorEmail,
+ country: submission.vendorCountry,
+ },
+
+ generalEvaluations: Array.from(generalEvaluationsMap.values()),
+ esgEvaluations: Array.from(esgEvaluationsMap.values()),
+ attachmentStats
+ }
+
+ } catch (error) {
+ console.error("Error fetching vendor submission details:", error)
+ throw new Error("협력업체 제출 상세 정보 조회 중 오류가 발생했습니다")
+ }
+} \ No newline at end of file
diff --git a/lib/export.ts b/lib/export.ts
index 71fae264..c4edbbc8 100644
--- a/lib/export.ts
+++ b/lib/export.ts
@@ -52,8 +52,8 @@ export async function exportTableToExcel<TData>(
const maybeGroup = (col.columnDef.meta as any)?.group
row1.push(maybeGroup ?? "")
- // excelHeader
- const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader
+ // excelHeader (meta 또는 직접 속성에서 찾기)
+ const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader || (col.columnDef as any)?.excelHeader
if (typeof maybeExcelHeader === "string") {
row2.push(maybeExcelHeader)
} else {
@@ -79,7 +79,7 @@ export async function exportTableToExcel<TData>(
} else {
// ────────────── 기존 1줄 헤더 ──────────────
const headerRow = columns.map((col) => {
- const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader
+ const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader || (col.columnDef as any)?.excelHeader
return typeof maybeExcelHeader === "string" ? maybeExcelHeader : col.id
})
diff --git a/lib/mail/templates/audit-result-notice.hbs b/lib/mail/templates/audit-result-notice.hbs
index 1e5f7c65..68907a4e 100644
--- a/lib/mail/templates/audit-result-notice.hbs
+++ b/lib/mail/templates/audit-result-notice.hbs
@@ -125,8 +125,7 @@
<p>귀사 일익 번창하심을 기원합니다.</p>
- <p>당사에선 귀사와의 정기적 거래를 위하여 PQ 검토 및 실사를 진행하였으며,<br>
- 아래와 같이 최종 실사 결과가 확정되어 공유하여 드립니다.</p>
+ <p>귀사와 당사 간의 거래를 위하여 실시한 귀사의 거래 기준 충족 여부 검토 결과를 아래와 같이 공유드립니다</p>
<h3>- 아 래 -</h3>
diff --git a/lib/mail/templates/data-room-invitation.hbs b/lib/mail/templates/data-room-invitation.hbs
new file mode 100644
index 00000000..023173b1
--- /dev/null
+++ b/lib/mail/templates/data-room-invitation.hbs
@@ -0,0 +1,210 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Data Room Access Invitation</title>
+ <style>
+ body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ background-color: #f5f5f5;
+ margin: 0;
+ padding: 0;
+ }
+ .container {
+ max-width: 600px;
+ margin: 40px auto;
+ background-color: #ffffff;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ }
+ .header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 30px;
+ text-align: center;
+ }
+ .header h1 {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 600;
+ }
+ .content {
+ padding: 40px 30px;
+ }
+ .greeting {
+ font-size: 18px;
+ margin-bottom: 20px;
+ color: #2c3e50;
+ }
+ .info-box {
+ background-color: #f8f9fa;
+ border-left: 4px solid #667eea;
+ padding: 20px;
+ margin: 25px 0;
+ border-radius: 4px;
+ }
+ .info-box h3 {
+ margin-top: 0;
+ color: #667eea;
+ font-size: 16px;
+ }
+ .info-item {
+ margin: 10px 0;
+ display: flex;
+ align-items: center;
+ }
+ .info-label {
+ font-weight: 600;
+ color: #555;
+ min-width: 100px;
+ }
+ .info-value {
+ color: #333;
+ }
+ .button-container {
+ text-align: center;
+ margin: 35px 0;
+ }
+ .button {
+ display: inline-block;
+ padding: 14px 35px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ text-decoration: none;
+ border-radius: 6px;
+ font-weight: 600;
+ font-size: 16px;
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
+ transition: transform 0.2s;
+ }
+ .button:hover {
+ transform: translateY(-2px);
+ }
+ .security-notice {
+ background-color: #fff3cd;
+ border: 1px solid #ffc107;
+ border-radius: 4px;
+ padding: 15px;
+ margin: 25px 0;
+ }
+ .security-notice h4 {
+ margin-top: 0;
+ color: #856404;
+ font-size: 14px;
+ }
+ .security-notice ul {
+ margin: 10px 0;
+ padding-left: 20px;
+ color: #856404;
+ font-size: 13px;
+ }
+ .footer {
+ background-color: #f8f9fa;
+ padding: 25px;
+ text-align: center;
+ color: #6c757d;
+ font-size: 13px;
+ border-top: 1px solid #e9ecef;
+ }
+ .footer a {
+ color: #667eea;
+ text-decoration: none;
+ }
+ .divider {
+ height: 1px;
+ background-color: #e9ecef;
+ margin: 25px 0;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <!-- Header -->
+ <div class="header">
+ <h1>🔐 Data Room Access Granted</h1>
+ </div>
+
+ <!-- Content -->
+ <div class="content">
+ <div class="greeting">
+ Hello {{name}},
+ </div>
+
+ <p>
+ Great news! You've been invited to access a secure Data Room by <strong>{{inviterName}}</strong>.
+ </p>
+
+ <!-- Data Room Information -->
+ <div class="info-box">
+ <h3>📁 Data Room Details</h3>
+ <div class="info-item">
+ <span class="info-label">Room Name:</span>
+ <span class="info-value"><strong>{{dataRoomName}}</strong></span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">Your Role:</span>
+ <span class="info-value">{{role}}</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">Invited by:</span>
+ <span class="info-value">{{inviterName}}</span>
+ </div>
+ </div>
+
+ <!-- Access Instructions -->
+ <p>
+ As a <strong>{{role}}</strong> member, you now have access to view and manage documents
+ in this secure data room according to your permission level.
+ </p>
+
+ <!-- CTA Button -->
+ <div class="button-container">
+ <a href="{{dataRoomUrl}}" class="button">Access Data Room</a>
+ </div>
+
+ <!-- Security Notice -->
+ <div class="security-notice">
+ <h4>🔒 Security Reminder</h4>
+ <ul>
+ <li>This data room contains confidential information</li>
+ <li>Please do not share your access credentials with others</li>
+ <li>All activities within the data room are logged for security purposes</li>
+ <li>If you're a new user, you may need to <a href="{{loginUrl}}">create an account</a> first</li>
+ </ul>
+ </div>
+
+ <div class="divider"></div>
+
+ <!-- Additional Information -->
+ <p style="color: #6c757d; font-size: 14px;">
+ <strong>Need help?</strong><br>
+ If you have any questions about accessing the data room or your permissions,
+ please contact the person who invited you or your system administrator.
+ </p>
+
+ <p style="color: #6c757d; font-size: 14px;">
+ <strong>First time user?</strong><br>
+ If this is your first time accessing our platform, you'll need to create an account
+ using this email address ({{email}}) to gain access to the data room.
+ </p>
+ </div>
+
+ <!-- Footer -->
+ <div class="footer">
+ <p>
+ This is an automated message from your Data Room Management System.<br>
+ Please do not reply to this email.
+ </p>
+ <p style="margin-top: 15px;">
+ © {{year}} DT Solution. All rights reserved.<br>
+ <a href="{{dataRoomUrl}}">Visit Data Rooms</a> |
+ <a href="{{loginUrl}}">Login to Platform</a>
+ </p>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/lib/pq/pq-criteria/add-pq-dialog.tsx b/lib/pq/pq-criteria/add-pq-dialog.tsx
index 33e656c2..660eb360 100644
--- a/lib/pq/pq-criteria/add-pq-dialog.tsx
+++ b/lib/pq/pq-criteria/add-pq-dialog.tsx
@@ -68,7 +68,9 @@ const inputFormatOptions = [
{ value: "FILE", label: "파일" },
{ value: "EMAIL", label: "이메일" },
{ value: "PHONE", label: "전화번호" },
+ { value: "FAX", label: "팩스번호" },
{ value: "NUMBER", label: "숫자" },
+ { value: "NUMBER_WITH_UNIT", label: "숫자+단위" },
{ value: "TEXT_FILE", label: "텍스트 + 파일" },
];
diff --git a/lib/pq/pq-criteria/pq-table-column.tsx b/lib/pq/pq-criteria/pq-table-column.tsx
index 32d6cc32..ed1180f7 100644
--- a/lib/pq/pq-criteria/pq-table-column.tsx
+++ b/lib/pq/pq-criteria/pq-table-column.tsx
@@ -18,13 +18,16 @@ import { Button } from "@/components/ui/button"
import { Ellipsis } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { PqCriterias } from "@/db/schema/pq"
+import { toast } from "sonner"
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PqCriterias> | null>>
+ pqListInfo?: Awaited<ReturnType<typeof import("../service").getPQListInfo>>
}
export function getColumns({
setRowAction,
+ pqListInfo,
}: GetColumnsProps): ColumnDef<PqCriterias>[] {
return [
{
@@ -205,6 +208,12 @@ export function getColumns({
id: "actions",
enableHiding: false,
cell: function Cell({ row }) {
+ const isActive = pqListInfo?.success && pqListInfo.data.status === "ACTIVE";
+
+ const handleRestrictedAction = () => {
+ toast.error("활성화된 PQ 목록은 수정할 수 없습니다. 먼저 PQ 목록을 비활성화해주세요.");
+ };
+
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -217,18 +226,37 @@ export function getColumns({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
- </DropdownMenuItem>
+ {isActive ? (
+ <DropdownMenuItem
+ onSelect={handleRestrictedAction}
+ className="text-muted-foreground"
+ >
+ Edit
+ </DropdownMenuItem>
+ ) : (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+ )}
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- Delete
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
+ {isActive ? (
+ <DropdownMenuItem
+ onSelect={handleRestrictedAction}
+ className="text-muted-foreground"
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ ) : (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ )}
</DropdownMenuContent>
</DropdownMenu>
)
diff --git a/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx b/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx
index f168b83d..cdc4f813 100644
--- a/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx
+++ b/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx
@@ -17,37 +17,59 @@ import { type Table } from "@tanstack/react-table"
import { DeletePqsDialog } from "./delete-pqs-dialog"
import { AddPqDialog } from "./add-pq-dialog"
import { PqCriterias } from "@/db/schema/pq"
+import { toast } from "sonner"
// import { ImportPqButton } from "./import-pq-button"
// import { exportPqTemplate } from "./pq-excel-template"
interface PqTableToolbarActionsProps {
table: Table<PqCriterias>
pqListId: number
+ pqListInfo: Awaited<ReturnType<typeof import("../service").getPQListInfo>>
}
export function PqTableToolbarActions({
table,
- pqListId
+ pqListId,
+ pqListInfo
}: PqTableToolbarActionsProps) {
- // const [refreshKey, setRefreshKey] = React.useState(0)
+ // PQ 리스트가 ACTIVE 상태인지 확인
+ const isActive = pqListInfo.success && pqListInfo.data.status === "ACTIVE";
- // // Import 성공 후 테이블 갱신
- // const handleImportSuccess = () => {
- // setRefreshKey(prev => prev + 1)
- // }
+ // ACTIVE 상태일 때 기능 제한
+ const handleRestrictedAction = () => {
+ toast.error("활성화된 PQ 목록은 수정할 수 없습니다. 먼저 PQ 목록을 비활성화해주세요.");
+ };
return (
<div className="flex items-center gap-2">
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <DeletePqsDialog
- pqs={table
- .getFilteredSelectedRowModel()
- .rows.map((row) => row.original)}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
+ isActive ? (
+ <button
+ onClick={handleRestrictedAction}
+ className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-3 text-destructive hover:text-destructive"
+ >
+ PQ 항목 삭제
+ </button>
+ ) : (
+ <DeletePqsDialog
+ pqs={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )
) : null}
- <AddPqDialog pqListId={pqListId} />
+ {isActive ? (
+ <button
+ onClick={handleRestrictedAction}
+ className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-3"
+ >
+ PQ 항목 추가
+ </button>
+ ) : (
+ <AddPqDialog pqListId={pqListId} />
+ )}
{/* Import 버튼 */}
{/* <ImportPqButton
diff --git a/lib/pq/pq-criteria/pq-table.tsx b/lib/pq/pq-criteria/pq-table.tsx
index e0e3dee5..187a727b 100644
--- a/lib/pq/pq-criteria/pq-table.tsx
+++ b/lib/pq/pq-criteria/pq-table.tsx
@@ -18,7 +18,7 @@ import { getColumns } from "./pq-table-column"
import { UpdatePqSheet } from "./update-pq-sheet"
interface DocumentListTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getPQsByListId>>]>
+ promises: Promise<[Awaited<ReturnType<typeof getPQsByListId>>, Awaited<ReturnType<typeof getPQListInfo>>]>
pqListId: number
}
@@ -27,14 +27,14 @@ export function PqsTable({
pqListId
}: DocumentListTableProps) {
// 1) 데이터를 가져옴 (server component -> use(...) pattern)
- const [{ data, pageCount }] = React.use(promises)
+ const [{ data, pageCount }, pqListInfo] = React.use(promises)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<PqCriterias> | null>(null)
const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction]
+ () => getColumns({ setRowAction, pqListInfo }),
+ [setRowAction, pqListInfo]
)
// Filter fields
@@ -105,7 +105,7 @@ export function PqsTable({
filterFields={advancedFilterFields}
shallow={false}
>
- <PqTableToolbarActions table={table} pqListId={pqListId}/>
+ <PqTableToolbarActions table={table} pqListId={pqListId} pqListInfo={pqListInfo}/>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/pq/pq-criteria/update-pq-sheet.tsx b/lib/pq/pq-criteria/update-pq-sheet.tsx
index 245627e6..fb298e9b 100644
--- a/lib/pq/pq-criteria/update-pq-sheet.tsx
+++ b/lib/pq/pq-criteria/update-pq-sheet.tsx
@@ -63,6 +63,7 @@ const inputFormatOptions = [
{ value: "EMAIL", label: "이메일" },
{ value: "PHONE", label: "전화번호" },
{ value: "NUMBER", label: "숫자" },
+ { value: "NUMBER_WITH_UNIT", label: "숫자+단위" },
{ value: "TEXT_FILE", label: "텍스트 + 파일" }
];
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 c5470e47..c4057798 100644
--- a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
@@ -224,7 +224,7 @@ export function EditInvestigationDialog({
name="confirmedAt"
render={({ field }) => (
<FormItem className="flex flex-col">
- <FormLabel>실사 확정일</FormLabel>
+ <FormLabel>실사 계획 확정일</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
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 6941adbb..aaf10a71 100644
--- a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
@@ -55,7 +55,7 @@ const requestInvestigationFormSchema = z.object({
required_error: "QM 담당자를 선택해주세요.",
}),
forecastedAt: z.date({
- required_error: "실사 예정일을 선택해주세요.",
+ required_error: "실사 수행 예정일을 선택해주세요.",
}),
investigationAddress: z.string().min(1, "실사 장소를 입력해주세요."),
investigationMethod: z.string().optional(),
@@ -189,7 +189,7 @@ export function RequestInvestigationDialog({
name="forecastedAt"
render={({ field }) => (
<FormItem className="flex flex-col">
- <FormLabel>실사 예정일</FormLabel>
+ <FormLabel>실사 수행 예정일</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -201,7 +201,7 @@ export function RequestInvestigationDialog({
{field.value ? (
format(field.value, "yyyy년 MM월 dd일")
) : (
- <span>실사 예정일을 선택하세요</span>
+ <span>실사 수행 예정일을 선택하세요</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
diff --git a/lib/pq/pq-review-table-new/send-results-dialog.tsx b/lib/pq/pq-review-table-new/send-results-dialog.tsx
index 3c8614cc..6c75e6ca 100644
--- a/lib/pq/pq-review-table-new/send-results-dialog.tsx
+++ b/lib/pq/pq-review-table-new/send-results-dialog.tsx
@@ -123,7 +123,9 @@ export function SendResultsDialog({
</div>
<div className="grid grid-cols-3 gap-4">
<div className="font-medium text-muted-foreground">Vendor</div>
- <div className="col-span-2">{result.vendorCode} | {result.vendorName}</div>
+ <div className="col-span-2">
+ {(result.vendorCode === "N/A" ? "미등록" : result.vendorCode)} | {result.vendorName}
+ </div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="font-medium text-muted-foreground">수신자</div>
@@ -151,7 +153,9 @@ export function SendResultsDialog({
</div>
{result.investigationNotes && (
<div className="grid grid-cols-3 gap-4">
- <div className="font-medium text-muted-foreground">실사합격조건</div>
+ <div className="font-medium text-muted-foreground">
+ {result.auditResult.includes("Pass") ? "QM 의견" : "실사합격조건"}
+ </div>
<div className="col-span-2">{result.investigationNotes}</div>
</div>
)}
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 ffa15e56..30b1c83f 100644
--- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
@@ -3,6 +3,11 @@
import * as React from "react"
import { type DataTableRowAction } from "@/types/table"
import { type ColumnDef } from "@tanstack/react-table"
+
+// ColumnDef 타입 확장
+type ExtendedColumnDef<T> = ColumnDef<T> & {
+ excelHeader?: string;
+}
import { Ellipsis, Eye, FileEdit, Trash2, Building2, FileText, Edit } from "lucide-react"
import { formatDate } from "@/lib/utils"
@@ -116,11 +121,11 @@ function getStatusBadge(status: string) {
/**
* tanstack table 컬럼 정의
*/
-export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<PQSubmission>[] {
+export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedColumnDef<PQSubmission>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
- const selectColumn: ColumnDef<PQSubmission> = {
+ const selectColumn: ExtendedColumnDef<PQSubmission> = {
id: "select",
header: ({ table }) => {
const selectedRows = table.getSelectedRowModel().rows;
@@ -180,7 +185,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
// --------------------------
// --------------------------------------
- const pqNoColumn: ColumnDef<PQSubmission> = {
+ const pqNoColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "pqNumber",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="PQ No." />
@@ -190,10 +195,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
<span className="font-medium">{row.getValue("pqNumber")}</span>
</div>
),
+ excelHeader: "PQ No.",
}
// 협력업체 컬럼
- const vendorColumn: ColumnDef<PQSubmission> = {
+ const vendorColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "vendorName",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="협력업체" />
@@ -206,10 +212,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
),
enableSorting: true,
enableHiding: true,
+ excelHeader: "협력업체",
}
// PQ 유형 컬럼
- const typeColumn: ColumnDef<PQSubmission> = {
+ const typeColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "type",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="PQ 유형" />
@@ -233,10 +240,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
},
enableSorting: true,
enableHiding: true,
+ excelHeader: "PQ 유형",
}
// 프로젝트 컬럼
- const projectColumn: ColumnDef<PQSubmission> = {
+ const projectColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "projectName",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트" />
@@ -260,10 +268,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
},
enableSorting: true,
enableHiding: true,
+ excelHeader: "프로젝트",
}
// 상태 컬럼
- const statusColumn: ColumnDef<PQSubmission> = {
+ const statusColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "combinedStatus",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="진행현황" />
@@ -278,6 +287,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
},
enableSorting: true,
enableHiding: true,
+ excelHeader: "진행현황",
};
// PQ 상태와 실사 상태를 결합하는 헬퍼 함수
@@ -371,7 +381,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
// };
- const evaluationResultColumn: ColumnDef<PQSubmission> = {
+ const evaluationResultColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "evaluationResult",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="평가 결과" />
@@ -401,10 +411,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
},
enableSorting: true,
enableHiding: true,
+ excelHeader: "평가 결과",
};
// 답변 수 컬럼
- const answerCountColumn: ColumnDef<PQSubmission> = {
+ const answerCountColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "answerCount",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="답변 수" />
@@ -416,9 +427,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
</div>
)
},
+ excelHeader: "답변 수",
}
- const investigationAddressColumn: ColumnDef<PQSubmission> = {
+ const investigationAddressColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "investigationAddress",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="실사 주소" />
@@ -436,8 +448,9 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
</div>
)
},
+ excelHeader: "실사 주소",
}
- const investigationRequestedAtColumn: ColumnDef<PQSubmission> = {
+ const investigationRequestedAtColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "investigationRequestedAt",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="실사 의뢰일" />
@@ -456,9 +469,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
</div>
)
},
+ excelHeader: "실사 의뢰일",
}
- const investigationNotesColumn: ColumnDef<PQSubmission> = {
+ const investigationNotesColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "investigationNotes",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="QM 의견" />
@@ -476,8 +490,9 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
</div>
)
},
+ excelHeader: "QM 의견",
}
- const investigationMethodColumn: ColumnDef<PQSubmission> = {
+ const investigationMethodColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "investigationMethod",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="QM실사방법" />
@@ -501,10 +516,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
return <span>{investigation.investigationMethod}</span>;
}
},
+ excelHeader: "실사품목",
}
// 실사품목 컬럼
- const pqItemsColumn: ColumnDef<PQSubmission> = {
+ const pqItemsColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "pqItems",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="실사품목" />
@@ -536,10 +552,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
- const investigationForecastedAtColumn: ColumnDef<PQSubmission> = {
+ const investigationForecastedAtColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "investigationForecastedAt",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="실사 예정일" />
+ <DataTableColumnHeaderSimple column={column} title="실사 수행 예정일" />
),
cell: ({ row }) => {
const investigation = row.original.investigation;
@@ -555,12 +571,13 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
</div>
)
},
+ excelHeader: "실사 수행 예정일",
}
- const investigationConfirmedAtColumn: ColumnDef<PQSubmission> = {
+ const investigationConfirmedAtColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "investigationConfirmedAt",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="실사 확정일" />
+ <DataTableColumnHeaderSimple column={column} title="실사 계획 확정일" />
),
cell: ({ row }) => {
const investigation = row.original.investigation;
@@ -576,9 +593,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
</div>
)
},
+ excelHeader: "실사 계획 확정일",
}
- const investigationCompletedAtColumn: ColumnDef<PQSubmission> = {
+ const investigationCompletedAtColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "investigationCompletedAt",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="실제 실사일" />
@@ -597,10 +615,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
</div>
)
},
+ excelHeader: "실제 실사일",
}
// 제출일 컬럼
- const createdAtColumn: ColumnDef<PQSubmission> = {
+ const createdAtColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="PQ 전송일" />
@@ -609,10 +628,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
const dateVal = row.original.createdAt as Date
return formatDate(dateVal, 'KR')
},
+ excelHeader: "PQ 전송일",
}
// 제출일 컬럼
- const submittedAtColumn: ColumnDef<PQSubmission> = {
+ const submittedAtColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "submittedAt",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="PQ 회신일" />
@@ -621,10 +641,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
const dateVal = row.original.submittedAt as Date
return dateVal ? formatDate(dateVal, 'KR') : "-"
},
+ excelHeader: "PQ 회신일",
}
// 승인/거부일 컬럼
- const approvalDateColumn: ColumnDef<PQSubmission> = {
+ const approvalDateColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "approvedAt",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="PQ 승인/거부일" />
@@ -638,12 +659,13 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
}
return "-"
},
+ excelHeader: "PQ 승인/거부일",
}
// ----------------------------------------------------------------
// 3) actions 컬럼 (Dropdown 메뉴)
// ----------------------------------------------------------------
- const actionsColumn: ColumnDef<PQSubmission> = {
+ const actionsColumn: ExtendedColumnDef<PQSubmission> = {
id: "actions",
enableHiding: false,
cell: function Cell({ row }) {
@@ -676,7 +698,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
) : (
<>
<Eye className="mr-2 h-4 w-4" />
- 보기
+ PQ 현황
</>
)}
</DropdownMenuItem>
@@ -697,7 +719,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
}}
>
<Building2 className="mr-2 h-4 w-4" />
- 방문실사
+ 실사 정보 전달 및 요청
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
@@ -710,7 +732,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
}}
>
<FileText className="mr-2 h-4 w-4" />
- 협력업체 정보 조회
+ 실사 실시 확정 정보
</DropdownMenuItem>
</>
)}
@@ -758,7 +780,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
}
// 요청자 컬럼 추가
-const requesterColumn: ColumnDef<PQSubmission> = {
+const requesterColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "requesterName",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="PQ/실사 요청자" />
@@ -779,8 +801,9 @@ const requesterColumn: ColumnDef<PQSubmission> = {
? <span>{pqRequesterName}</span>
: <span className="text-muted-foreground">-</span>;
},
+ excelHeader: "PQ/실사 요청자",
};
-const qmManagerColumn: ColumnDef<PQSubmission> = {
+const qmManagerColumn: ExtendedColumnDef<PQSubmission> = {
accessorKey: "qmManager",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="QM 담당자" />
@@ -801,6 +824,7 @@ const qmManagerColumn: ColumnDef<PQSubmission> = {
</div>
);
},
+ excelHeader: "QM 담당자",
};
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index 7296b836..54459a6c 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -5,7 +5,7 @@ import { CopyPqListInput, CreatePqListInput, UpdatePqValidToInput, copyPqListSch
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
import { getErrorMessage } from "@/lib/handle-error";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, ne, count,isNull,SQL, sql, lt, isNotNull} from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, ne, count,isNull,SQL, sql, lt, gt, isNotNull} from "drizzle-orm";
import { z } from "zod"
import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache";
import { format } from "date-fns"
@@ -91,13 +91,25 @@ export async function getPQProjectsByVendorId(vendorId: number): Promise<Project
export async function getPQDataByVendorId(
vendorId: number,
- projectId?: number
+ projectId?: number,
+ pqType?: "GENERAL" | "PROJECT" | "NON_INSPECTION"
): Promise<PQGroupData[]> {
try {
// 파라미터 유효성 검증
if (isNaN(vendorId)) {
throw new Error("Invalid vendorId parameter");
}
+
+ // 타입 결정 로직
+ let finalPqType: "GENERAL" | "PROJECT" | "NON_INSPECTION";
+ if (pqType) {
+ finalPqType = pqType;
+ } else if (projectId) {
+ finalPqType = "PROJECT";
+ } else {
+ finalPqType = "GENERAL";
+ }
+
// 기본 쿼리 구성
const selectObj = {
criteriaId: pqCriterias.id,
@@ -127,65 +139,45 @@ export async function getPQDataByVendorId(
fileSize: vendorCriteriaAttachments.fileSize,
};
- // Create separate queries for each case instead of modifying the same query variable
- if (projectId) {
- // 프로젝트별 PQ 쿼리 - PQ 리스트 기반으로 변경
- const rows = await db
- .select(selectObj)
- .from(pqCriterias)
- .innerJoin(
- pqLists,
- and(
- eq(pqCriterias.pqListId, pqLists.id),
- eq(pqLists.projectId, projectId),
- eq(pqLists.type, "PROJECT"),
- eq(pqLists.isDeleted, false)
- )
- )
- .leftJoin(
- vendorPqCriteriaAnswers,
- and(
- eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
- eq(vendorPqCriteriaAnswers.vendorId, vendorId),
- eq(vendorPqCriteriaAnswers.projectId, projectId)
- )
- )
- .leftJoin(
- vendorCriteriaAttachments,
- eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
- )
- .orderBy(pqCriterias.groupName, pqCriterias.code);
-
- return processQueryResults(rows);
- } else {
- // 일반 PQ 쿼리 - PQ 리스트 기반으로 변경
- const rows = await db
- .select(selectObj)
- .from(pqCriterias)
- .innerJoin(
- pqLists,
- and(
- eq(pqCriterias.pqListId, pqLists.id),
- eq(pqLists.type, "GENERAL"),
- eq(pqLists.isDeleted, false)
- )
- )
- .leftJoin(
- vendorPqCriteriaAnswers,
- and(
- eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
- eq(vendorPqCriteriaAnswers.vendorId, vendorId),
- isNull(vendorPqCriteriaAnswers.projectId)
- )
- )
- .leftJoin(
- vendorCriteriaAttachments,
- eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
- )
- .orderBy(pqCriterias.groupName, pqCriterias.code);
-
- return processQueryResults(rows);
+ // 타입별 쿼리 조건 구성
+ const pqListConditions = [
+ eq(pqCriterias.pqListId, pqLists.id),
+ eq(pqLists.type, finalPqType),
+ eq(pqLists.isDeleted, false)
+ ];
+
+ const answerConditions = [
+ eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId)
+ ];
+
+ // 프로젝트별 조건 추가
+ if (finalPqType === "PROJECT" && projectId) {
+ pqListConditions.push(eq(pqLists.projectId, projectId));
+ answerConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
+ } else if (finalPqType === "GENERAL" || finalPqType === "NON_INSPECTION") {
+ pqListConditions.push(isNull(pqLists.projectId));
+ answerConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
}
+
+ const rows = await db
+ .select(selectObj)
+ .from(pqCriterias)
+ .innerJoin(
+ pqLists,
+ and(...pqListConditions)
+ )
+ .leftJoin(
+ vendorPqCriteriaAnswers,
+ and(...answerConditions)
+ )
+ .leftJoin(
+ vendorCriteriaAttachments,
+ eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
+ )
+ .orderBy(pqCriterias.groupName, pqCriterias.code);
+
+ return processQueryResults(rows);
} catch (error) {
console.error("Error fetching PQ data:", error);
return [];
@@ -790,199 +782,199 @@ export async function uploadSHIMultipleFilesAction(files: File[], userId?: strin
}
}
-export async function getVendorsInPQ(input: GetVendorsSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터
- const advancedWhere = filterColumns({
- table: vendors,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // 2) 글로벌 검색
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(vendors.vendorName, s),
- ilike(vendors.vendorCode, s),
- ilike(vendors.email, s),
- ilike(vendors.status, s)
- );
- }
-
- // 트랜잭션 내에서 데이터 조회
- const { data, total } = await db.transaction(async (tx) => {
- // 협력업체 ID 모음 (중복 제거용)
- const vendorIds = new Set<number>();
+// export async function getVendorsInPQ(input: GetVendorsSchema) {
+// return unstable_cache(
+// async () => {
+// try {
+// const offset = (input.page - 1) * input.perPage;
+
+// // 1) 고급 필터
+// const advancedWhere = filterColumns({
+// table: vendors,
+// filters: input.filters,
+// joinOperator: input.joinOperator,
+// });
+
+// // 2) 글로벌 검색
+// let globalWhere: SQL<unknown> | undefined = undefined;
+// if (input.search) {
+// const s = `%${input.search}%`;
+// globalWhere = or(
+// ilike(vendors.vendorName, s),
+// ilike(vendors.vendorCode, s),
+// ilike(vendors.email, s),
+// ilike(vendors.status, s)
+// );
+// }
+
+// // 트랜잭션 내에서 데이터 조회
+// const { data, total } = await db.transaction(async (tx) => {
+// // 협력업체 ID 모음 (중복 제거용)
+// const vendorIds = new Set<number>();
- // 1-A) 일반 PQ 답변이 있는 협력업체 찾기 (status와 상관없이)
- const generalPqVendors = await tx
- .select({
- vendorId: vendorPqCriteriaAnswers.vendorId
- })
- .from(vendorPqCriteriaAnswers)
- .innerJoin(
- vendors,
- eq(vendorPqCriteriaAnswers.vendorId, vendors.id)
- )
- .where(
- and(
- isNull(vendorPqCriteriaAnswers.projectId), // 일반 PQ만 (프로젝트 PQ 아님)
- advancedWhere,
- globalWhere
- )
- )
- .groupBy(vendorPqCriteriaAnswers.vendorId); // 각 벤더당 한 번만 카운트
+// // 1-A) 일반 PQ 답변이 있는 협력업체 찾기 (status와 상관없이)
+// const generalPqVendors = await tx
+// .select({
+// vendorId: vendorPqCriteriaAnswers.vendorId
+// })
+// .from(vendorPqCriteriaAnswers)
+// .innerJoin(
+// vendors,
+// eq(vendorPqCriteriaAnswers.vendorId, vendors.id)
+// )
+// .where(
+// and(
+// isNull(vendorPqCriteriaAnswers.projectId), // 일반 PQ만 (프로젝트 PQ 아님)
+// advancedWhere,
+// globalWhere
+// )
+// )
+// .groupBy(vendorPqCriteriaAnswers.vendorId); // 각 벤더당 한 번만 카운트
- generalPqVendors.forEach(v => vendorIds.add(v.vendorId));
+// generalPqVendors.forEach(v => vendorIds.add(v.vendorId));
- // 1-B) 프로젝트 PQ 답변이 있는 협력업체 ID 조회 (status와 상관없이)
- const projectPqVendors = await tx
- .select({
- vendorId: vendorPQSubmissions.vendorId
- })
- .from(vendorPQSubmissions)
- .innerJoin(
- vendors,
- eq(vendorPQSubmissions.vendorId, vendors.id)
- )
- .where(
- and(
- eq(vendorPQSubmissions.type, "PROJECT"),
- // 최소한 IN_PROGRESS부터는 작업이 시작된 상태이므로 포함
- not(eq(vendorPQSubmissions.status, "REQUESTED")), // REQUESTED 상태는 제외
- advancedWhere,
- globalWhere
- )
- );
+// // 1-B) 프로젝트 PQ 답변이 있는 협력업체 ID 조회 (status와 상관없이)
+// const projectPqVendors = await tx
+// .select({
+// vendorId: vendorPQSubmissions.vendorId
+// })
+// .from(vendorPQSubmissions)
+// .innerJoin(
+// vendors,
+// eq(vendorPQSubmissions.vendorId, vendors.id)
+// )
+// .where(
+// and(
+// eq(vendorPQSubmissions.type, "PROJECT"),
+// // 최소한 IN_PROGRESS부터는 작업이 시작된 상태이므로 포함
+// not(eq(vendorPQSubmissions.status, "REQUESTED")), // REQUESTED 상태는 제외
+// advancedWhere,
+// globalWhere
+// )
+// );
- projectPqVendors.forEach(v => vendorIds.add(v.vendorId));
+// projectPqVendors.forEach(v => vendorIds.add(v.vendorId));
- // 중복 제거된 협력업체 ID 배열
- const uniqueVendorIds = Array.from(vendorIds);
+// // 중복 제거된 협력업체 ID 배열
+// const uniqueVendorIds = Array.from(vendorIds);
- // 총 개수 (중복 제거 후)
- const total = uniqueVendorIds.length;
+// // 총 개수 (중복 제거 후)
+// const total = uniqueVendorIds.length;
- if (total === 0) {
- return { data: [], total: 0 };
- }
+// if (total === 0) {
+// return { data: [], total: 0 };
+// }
- // 페이징 처리 (정렬 후 limit/offset 적용)
- const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage);
+// // 페이징 처리 (정렬 후 limit/offset 적용)
+// const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage);
- // 2) 페이징된 협력업체 상세 정보 조회
- const vendorsData = await selectVendors(tx, {
- where: inArray(vendors.id, paginatedIds),
- orderBy: input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(vendors.vendorName) : asc(vendors.vendorName)
- )
- : [asc(vendors.createdAt)],
- });
+// // 2) 페이징된 협력업체 상세 정보 조회
+// const vendorsData = await selectVendors(tx, {
+// where: inArray(vendors.id, paginatedIds),
+// orderBy: input.sort.length > 0
+// ? input.sort.map((item) =>
+// item.desc ? desc(vendors.vendorName) : asc(vendors.vendorName)
+// )
+// : [asc(vendors.createdAt)],
+// });
- // 3) 각 벤더별 PQ 상태 정보 추가
- const vendorsWithPqInfo = await Promise.all(
- vendorsData.map(async (vendor) => {
- // 3-A) 첨부 파일 조회
- const attachments = await tx
- .select({
- id: vendorAttachments.id,
- fileName: vendorAttachments.fileName,
- filePath: vendorAttachments.filePath,
- })
- .from(vendorAttachments)
- .where(eq(vendorAttachments.vendorId, vendor.id));
+// // 3) 각 벤더별 PQ 상태 정보 추가
+// const vendorsWithPqInfo = await Promise.all(
+// vendorsData.map(async (vendor) => {
+// // 3-A) 첨부 파일 조회
+// const attachments = await tx
+// .select({
+// id: vendorAttachments.id,
+// fileName: vendorAttachments.fileName,
+// filePath: vendorAttachments.filePath,
+// })
+// .from(vendorAttachments)
+// .where(eq(vendorAttachments.vendorId, vendor.id));
- // 3-B) 일반 PQ 제출 여부 확인 (PQ 답변이 있는지)
- const generalPqAnswers = await tx
- .select({ count: count() })
- .from(vendorPqCriteriaAnswers)
- .where(
- and(
- eq(vendorPqCriteriaAnswers.vendorId, vendor.id),
- isNull(vendorPqCriteriaAnswers.projectId)
- )
- );
+// // 3-B) 일반 PQ 제출 여부 확인 (PQ 답변이 있는지)
+// const generalPqAnswers = await tx
+// .select({ count: count() })
+// .from(vendorPqCriteriaAnswers)
+// .where(
+// and(
+// eq(vendorPqCriteriaAnswers.vendorId, vendor.id),
+// isNull(vendorPqCriteriaAnswers.projectId)
+// )
+// );
- const hasGeneralPq = generalPqAnswers[0]?.count > 0;
+// const hasGeneralPq = generalPqAnswers[0]?.count > 0;
- // 3-C) 프로젝트 PQ 정보 조회 (모든 상태 포함)
- const projectPqs = await tx
- .select({
- projectId: vendorPQSubmissions.projectId,
- projectName: projects.name,
- status: vendorPQSubmissions.status,
- submittedAt: vendorPQSubmissions.submittedAt,
- approvedAt: vendorPQSubmissions.approvedAt,
- rejectedAt: vendorPQSubmissions.rejectedAt
- })
- .from(vendorPQSubmissions)
- .innerJoin(
- projects,
- eq(vendorPQSubmissions.projectId, projects.id)
- )
- .where(
- and(
- eq(vendorPQSubmissions.vendorId, vendor.id),
- eq(vendorPQSubmissions.type, "PROJECT"),
- not(eq(vendorPQSubmissions.status, "REQUESTED")) // REQUESTED 상태는 제외
- )
- );
+// // 3-C) 프로젝트 PQ 정보 조회 (모든 상태 포함)
+// const projectPqs = await tx
+// .select({
+// projectId: vendorPQSubmissions.projectId,
+// projectName: projects.name,
+// status: vendorPQSubmissions.status,
+// submittedAt: vendorPQSubmissions.submittedAt,
+// approvedAt: vendorPQSubmissions.approvedAt,
+// rejectedAt: vendorPQSubmissions.rejectedAt
+// })
+// .from(vendorPQSubmissions)
+// .innerJoin(
+// projects,
+// eq(vendorPQSubmissions.projectId, projects.id)
+// )
+// .where(
+// and(
+// eq(vendorPQSubmissions.vendorId, vendor.id),
+// eq(vendorPQSubmissions.type, "PROJECT"),
+// not(eq(vendorPQSubmissions.status, "REQUESTED")) // REQUESTED 상태는 제외
+// )
+// );
- const hasProjectPq = projectPqs.length > 0;
+// const hasProjectPq = projectPqs.length > 0;
- // 프로젝트 PQ 상태별 카운트
- const projectPqStatusCounts = {
- inProgress: projectPqs.filter(p => p.status === "IN_PROGRESS").length,
- submitted: projectPqs.filter(p => p.status === "SUBMITTED").length,
- approved: projectPqs.filter(p => p.status === "APPROVED").length,
- rejected: projectPqs.filter(p => p.status === "REJECTED").length,
- total: projectPqs.length
- };
+// // 프로젝트 PQ 상태별 카운트
+// const projectPqStatusCounts = {
+// inProgress: projectPqs.filter(p => p.status === "IN_PROGRESS").length,
+// submitted: projectPqs.filter(p => p.status === "SUBMITTED").length,
+// approved: projectPqs.filter(p => p.status === "APPROVED").length,
+// rejected: projectPqs.filter(p => p.status === "REJECTED").length,
+// total: projectPqs.length
+// };
- // 3-D) PQ 상태 정보 추가
- return {
- ...vendor,
- hasAttachments: attachments.length > 0,
- attachmentsList: attachments,
- pqInfo: {
- hasGeneralPq,
- hasProjectPq,
- projectPqs,
- projectPqStatusCounts,
- // 현재 PQ 상태 (UI에 표시 용도)
- pqStatus: getPqStatusDisplay(vendor.status, hasGeneralPq, hasProjectPq, projectPqStatusCounts)
- }
- };
- })
- );
+// // 3-D) PQ 상태 정보 추가
+// return {
+// ...vendor,
+// hasAttachments: attachments.length > 0,
+// attachmentsList: attachments,
+// pqInfo: {
+// hasGeneralPq,
+// hasProjectPq,
+// projectPqs,
+// projectPqStatusCounts,
+// // 현재 PQ 상태 (UI에 표시 용도)
+// pqStatus: getPqStatusDisplay(vendor.status, hasGeneralPq, hasProjectPq, projectPqStatusCounts)
+// }
+// };
+// })
+// );
- return { data: vendorsWithPqInfo, total };
- });
-
- // 페이지 수
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- console.error("Error in getVendorsInPQ:", err);
- // 에러 발생 시
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)], // 캐싱 키
- {
- revalidate: 3600,
- tags: ["vendors-in-pq", "project-pqs"], // revalidateTag 호출 시 무효화
- }
- )();
-}
+// return { data: vendorsWithPqInfo, total };
+// });
+
+// // 페이지 수
+// const pageCount = Math.ceil(total / input.perPage);
+
+// return { data, pageCount };
+// } catch (err) {
+// console.error("Error in getVendorsInPQ:", err);
+// // 에러 발생 시
+// return { data: [], pageCount: 0 };
+// }
+// },
+// [JSON.stringify(input)], // 캐싱 키
+// {
+// revalidate: 3600,
+// tags: ["vendors-in-pq", "project-pqs"], // revalidateTag 호출 시 무효화
+// }
+// )();
+// }
// PQ 상태 표시 함수
function getPqStatusDisplay(
@@ -3105,6 +3097,66 @@ export async function togglePQListsAction(ids: number[], newIsDeleted: boolean)
const session = await getServerSession(authOptions);
const userId = session?.user?.id ? Number(session.user.id) : null;
const now = new Date();
+
+ // 활성화하려는 경우 중복 활성화 체크
+ if (!newIsDeleted) {
+ // 선택된 PQ 리스트들의 정보를 먼저 가져옴
+ const selectedPqLists = await db
+ .select({
+ id: pqLists.id,
+ name: pqLists.name,
+ type: pqLists.type,
+ projectId: pqLists.projectId,
+ })
+ .from(pqLists)
+ .where(inArray(pqLists.id, ids));
+
+ // 현재 활성화된 PQ 리스트 확인
+ const activePqLists = await db
+ .select({
+ id: pqLists.id,
+ name: pqLists.name,
+ type: pqLists.type,
+ projectId: pqLists.projectId,
+ })
+ .from(pqLists)
+ .where(and(
+ eq(pqLists.isDeleted, false),
+ not(inArray(pqLists.id, ids))
+ ));
+
+ // 각 선택된 PQ 리스트에 대해 중복 체크
+ for (const selectedPq of selectedPqLists) {
+ // 일반 PQ 또는 미실사 PQ인 경우
+ if (selectedPq.type === "GENERAL" || selectedPq.type === "NON_INSPECTION") {
+ const activeSameType = activePqLists.filter(pq => pq.type === selectedPq.type);
+
+ if (activeSameType.length > 0) {
+ const activeNames = activeSameType.map(pq => pq.name).join(", ");
+ return {
+ success: false,
+ error: `${selectedPq.type === "GENERAL" ? "일반" : "미실사"} PQ는 하나만 활성화할 수 있습니다.먼저 활성화된 ${selectedPq.type === "GENERAL" ? "일반" : "미실사"} PQ를 비활성화한 후 활성화해주세요.`
+ };
+ }
+ }
+
+ // 프로젝트 PQ인 경우
+ if (selectedPq.type === "PROJECT" && selectedPq.projectId) {
+ const activeSameProject = activePqLists.filter(pq =>
+ pq.type === "PROJECT" && pq.projectId === selectedPq.projectId
+ );
+
+ if (activeSameProject.length > 0) {
+ const activeNames = activeSameProject.map(pq => pq.name).join(", ");
+ return {
+ success: false,
+ error: `프로젝트 PQ는 프로젝트별로 하나만 활성화할 수 있습니다. 먼저 활성화된 프로젝트 PQ를 비활성화한 후 활성화해주세요.`
+ };
+ }
+ }
+ }
+ }
+
const updated = await db
.update(pqLists)
.set({ isDeleted: newIsDeleted, updatedAt: now, updatedBy: userId })
@@ -3726,6 +3778,65 @@ export async function deletePQSubmissionAction(pqSubmissionId: number) {
}
// PQ 목록별 항목 조회 (특정 pqListId에 속한 PQ 항목들)
+// PQ 리스트 정보 조회 (상태 포함)
+export async function getPQListInfo(pqListId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const pqList = await db
+ .select({
+ id: pqLists.id,
+ name: pqLists.name,
+ type: pqLists.type,
+ projectId: pqLists.projectId,
+ validTo: pqLists.validTo,
+ isDeleted: pqLists.isDeleted,
+ createdAt: pqLists.createdAt,
+ updatedAt: pqLists.updatedAt,
+ })
+ .from(pqLists)
+ .where(and(
+ eq(pqLists.id, pqListId),
+ eq(pqLists.isDeleted, false)
+ ))
+ .limit(1)
+ .then(rows => rows[0]);
+
+ if (!pqList) {
+ return {
+ success: false,
+ error: "PQ 목록을 찾을 수 없습니다"
+ };
+ }
+
+ // 현재 시간과 비교하여 상태 결정
+ const now = new Date();
+ const isValid = !pqList.validTo || pqList.validTo > now;
+ const status = isValid ? "ACTIVE" : "INACTIVE";
+
+ return {
+ success: true,
+ data: {
+ ...pqList,
+ status
+ }
+ };
+ } catch (error) {
+ console.error("Error in getPQListInfo:", error);
+ return {
+ success: false,
+ error: "PQ 목록 정보를 가져오는 중 오류가 발생했습니다"
+ };
+ }
+ },
+ [`pq-list-info-${pqListId}`],
+ {
+ tags: ["pq-lists"],
+ revalidate: 3600, // 1시간
+ }
+ )();
+}
+
export async function getPQsByListId(pqListId: number, input: GetPQSchema) {
return unstable_cache(
async () => {
diff --git a/lib/pq/table/pq-lists-table.tsx b/lib/pq/table/pq-lists-table.tsx
index 1be0a1c7..e7c0ab0d 100644
--- a/lib/pq/table/pq-lists-table.tsx
+++ b/lib/pq/table/pq-lists-table.tsx
@@ -85,7 +85,7 @@ export function PqListsTable({ promises }: PqListsTableProps) {
toast.success(newIsDeleted ? "PQ 목록이 비활성화되었습니다" : "PQ 목록이 활성화되었습니다")
router.refresh()
} else {
- toast.error("PQ 목록 상태 변경 실패")
+ toast.error(result.error || "PQ 목록 상태 변경 실패")
}
})
}
diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx
index fcd2d0be..b7663629 100644
--- a/lib/vendor-investigation/table/investigation-table.tsx
+++ b/lib/vendor-investigation/table/investigation-table.tsx
@@ -127,9 +127,9 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
},
// 주요 날짜 필터
- { id: "forecastedAt", label: "실사 예정일", type: "date" },
+ { id: "forecastedAt", label: "실사 수행 예정일", type: "date" },
{ id: "requestedAt", label: "실사 의뢰일", type: "date" },
- { id: "confirmedAt", label: "실사 확정일", type: "date" },
+ { id: "confirmedAt", label: "실사 계획 확정일", type: "date" },
{ id: "completedAt", label: "실제 실사일", type: "date" },
// 메모 필터
diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx
index 37d1b2cd..9f7c8994 100644
--- a/lib/vendor-investigation/table/update-investigation-sheet.tsx
+++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx
@@ -627,13 +627,13 @@ export function UpdateVendorInvestigationSheet({
)}
/>
- {/* 실사 예정일 */}
+ {/* 실사 수행 예정일 */}
<FormField
control={form.control}
name="forecastedAt"
render={({ field }) => (
<FormItem className="flex flex-col">
- <FormLabel>실사 예정일</FormLabel>
+ <FormLabel>실사 수행 예정일</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -670,7 +670,7 @@ export function UpdateVendorInvestigationSheet({
name="confirmedAt"
render={({ field }) => (
<FormItem className="flex flex-col">
- <FormLabel>실사 확정일</FormLabel>
+ <FormLabel>실사 계획 확정일</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 6813f717..0c61c270 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -2935,6 +2935,9 @@ export async function requestPQVendors(input: ApproveVendorsInput & {
const session = await getServerSession(authOptions);
const requesterId = session?.user?.id ? Number(session.user.id) : null;
+ // 타입 기본값 설정
+ const pqType = input.type || "GENERAL";
+
try {
let projectInfo = null;
if (input.projectId) {
@@ -2954,7 +2957,6 @@ export async function requestPQVendors(input: ApproveVendorsInput & {
}
// PQ 리스트 정보 조회 및 문항 검사
- const pqType = input.type || "GENERAL";
const pqListConditions = [
eq(pqLists.type, pqType),
eq(pqLists.isDeleted, false)
@@ -3008,48 +3010,30 @@ export async function requestPQVendors(input: ApproveVendorsInput & {
.from(vendors)
.where(inArray(vendors.id, input.ids));
- const pqType = input.type;
const currentDate = new Date();
- const existingSubmissions = await tx
- .select({ vendorId: vendorPQSubmissions.vendorId })
- .from(vendorPQSubmissions)
- .where(
- and(
- inArray(vendorPQSubmissions.vendorId, input.ids),
- pqType ? eq(vendorPQSubmissions.type, pqType) : undefined,
- input.projectId
- ? eq(vendorPQSubmissions.projectId, input.projectId)
- : isNull(vendorPQSubmissions.projectId)
- )
- );
-
- const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId));
- const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id));
-
- if (newVendorIds.length > 0) {
- const vendorPQDataPromises = newVendorIds.map(async (vendorId) => {
- const pqNumber = await generatePQNumber(pqType === "PROJECT");
+ // 중복 체크 제거 - 같은 벤더에게 같은 타입의 PQ를 여러 번 요청 가능
+ const vendorPQDataPromises = input.ids.map(async (vendorId) => {
+ const pqNumber = await generatePQNumber(pqType === "PROJECT");
- return {
- vendorId,
- pqNumber,
- projectId: input.projectId || null,
- type: pqType,
- status: "REQUESTED",
- requesterId: input.userId || requesterId,
- dueDate: input.dueDate ? new Date(input.dueDate) : null,
- agreements: input.agreements ?? {},
- pqItems: input.pqItems || null,
- createdAt: currentDate,
- updatedAt: currentDate,
- };
- });
+ return {
+ vendorId,
+ pqNumber,
+ projectId: input.projectId || null,
+ type: pqType,
+ status: "REQUESTED",
+ requesterId: input.userId || requesterId,
+ dueDate: input.dueDate ? new Date(input.dueDate) : null,
+ agreements: input.agreements ?? {},
+ pqItems: input.pqItems || null,
+ createdAt: currentDate,
+ updatedAt: currentDate,
+ };
+ });
- const vendorPQData = await Promise.all(vendorPQDataPromises);
+ const vendorPQData = await Promise.all(vendorPQDataPromises);
- await tx.insert(vendorPQSubmissions).values(vendorPQData);
- }
+ await tx.insert(vendorPQSubmissions).values(vendorPQData);
await Promise.all(
vendorsBeforeUpdate.map(async (vendorBefore) => {