summaryrefslogtreecommitdiff
path: root/lib/pq
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pq')
-rw-r--r--lib/pq/pq-review-table-new/edit-investigation-dialog.tsx123
-rw-r--r--lib/pq/pq-review-table-new/request-investigation-dialog.tsx4
-rw-r--r--lib/pq/pq-review-table-new/site-visit-dialog.tsx179
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx20
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx55
-rw-r--r--lib/pq/pq-review-table/feature-flags-provider.tsx108
-rw-r--r--lib/pq/pq-review-table/vendors-table-columns.tsx212
-rw-r--r--lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx41
-rw-r--r--lib/pq/pq-review-table/vendors-table.tsx97
-rw-r--r--lib/pq/service.ts182
10 files changed, 451 insertions, 570 deletions
diff --git a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
index 4df7a7ec..7fd1c3f8 100644
--- a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx
@@ -3,7 +3,7 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
-import { CalendarIcon, Loader } from "lucide-react"
+import { CalendarIcon, Loader, Upload, X, FileText } from "lucide-react"
import { format } from "date-fns"
import { toast } from "sonner"
@@ -49,6 +49,7 @@ const editInvestigationSchema = z.object({
]).optional(),
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
+ attachments: z.array(z.instanceof(File)).optional(),
})
type EditInvestigationSchema = z.infer<typeof editInvestigationSchema>
@@ -72,6 +73,8 @@ export function EditInvestigationDialog({
onSubmit,
}: EditInvestigationDialogProps) {
const [isPending, startTransition] = React.useTransition()
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
const form = useForm<EditInvestigationSchema>({
resolver: zodResolver(editInvestigationSchema),
@@ -79,6 +82,7 @@ export function EditInvestigationDialog({
confirmedAt: investigation?.confirmedAt || undefined,
evaluationResult: investigation?.evaluationResult as "APPROVED" | "SUPPLEMENT" | "REJECTED" | undefined,
investigationNotes: investigation?.investigationNotes || "",
+ attachments: [],
},
})
@@ -89,14 +93,47 @@ export function EditInvestigationDialog({
confirmedAt: investigation.confirmedAt || undefined,
evaluationResult: investigation.evaluationResult as "APPROVED" | "SUPPLEMENT" | "REJECTED" | undefined,
investigationNotes: investigation.investigationNotes || "",
+ attachments: [],
})
+ setSelectedFiles([])
}
}, [investigation, form])
+ // 파일 선택 핸들러
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(event.target.files || [])
+ if (files.length > 0) {
+ const newFiles = [...selectedFiles, ...files]
+ setSelectedFiles(newFiles)
+ form.setValue('attachments', newFiles, { shouldValidate: true })
+ }
+ }
+
+ // 파일 제거 핸들러
+ const removeFile = (index: number) => {
+ const updatedFiles = selectedFiles.filter((_, i) => i !== index)
+ setSelectedFiles(updatedFiles)
+ form.setValue('attachments', updatedFiles, { shouldValidate: true })
+ }
+
+ // 파일 크기 포맷팅
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
const handleSubmit = async (values: EditInvestigationSchema) => {
startTransition(async () => {
try {
- await onSubmit(values)
+ // 선택된 파일들을 values에 포함
+ const submitData = {
+ ...values,
+ attachments: selectedFiles,
+ }
+ await onSubmit(submitData)
toast.success("실사 정보가 업데이트되었습니다!")
onClose()
} catch (error) {
@@ -181,16 +218,16 @@ export function EditInvestigationDialog({
)}
/>
- {/* QM 의견 */}
+ {/* 구매 의견 */}
<FormField
control={form.control}
name="investigationNotes"
render={({ field }) => (
<FormItem>
- <FormLabel>QM 의견</FormLabel>
+ <FormLabel>구매 의견</FormLabel>
<FormControl>
<Textarea
- placeholder="실사에 대한 QM 의견을 입력하세요..."
+ placeholder="실사에 대한 구매 의견을 입력하세요..."
{...field}
className="min-h-[80px]"
/>
@@ -200,6 +237,82 @@ export function EditInvestigationDialog({
)}
/>
+ {/* 첨부파일 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={() => (
+ <FormItem>
+ <FormLabel>첨부파일</FormLabel>
+ <FormControl>
+ <div className="space-y-4">
+ {/* 파일 선택 영역 */}
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
+ <input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif"
+ onChange={handleFileSelect}
+ className="hidden"
+ />
+ <Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
+ <div className="text-sm text-gray-600 mb-2">
+ 파일을 드래그하거나 클릭하여 선택하세요
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isPending}
+ >
+ 파일 선택
+ </Button>
+ <div className="text-xs text-gray-500 mt-2">
+ 지원 형식: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG, GIF (최대 10MB)
+ </div>
+ </div>
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles.length > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">선택된 파일:</div>
+ {selectedFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-2 bg-gray-50 rounded border"
+ >
+ <div className="flex items-center space-x-2">
+ <FileText className="h-4 w-4 text-gray-500" />
+ <div className="flex-1 min-w-0">
+ <div className="text-sm font-medium truncate">
+ {file.name}
+ </div>
+ <div className="text-xs text-gray-500">
+ {formatFileSize(file.size)}
+ </div>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFile(index)}
+ disabled={isPending}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isPending}>
취소
diff --git a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
index b9648e74..6941adbb 100644
--- a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
@@ -241,7 +241,7 @@ export function RequestInvestigationDialog({
)}
/>
- <FormField
+ {/* <FormField
control={form.control}
name="investigationMethod"
render={({ field }) => (
@@ -257,7 +257,7 @@ export function RequestInvestigationDialog({
<FormMessage />
</FormItem>
)}
- />
+ /> */}
<FormField
control={form.control}
diff --git a/lib/pq/pq-review-table-new/site-visit-dialog.tsx b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
index b6bd3624..172aed98 100644
--- a/lib/pq/pq-review-table-new/site-visit-dialog.tsx
+++ b/lib/pq/pq-review-table-new/site-visit-dialog.tsx
@@ -36,6 +36,7 @@ import {
} from "@/components/ui/popover"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "sonner"
import { getSiteVisitRequestAction } from "@/lib/site-visit/service"
import {
@@ -444,82 +445,96 @@ export function SiteVisitDialog({
삼성중공업에 어떤 부문의 담당자가 몇 명 실사 참석 예정인지에 대한 정보를 입력하세요.
</div>
- <div className="space-y-4">
- {[
- { key: "technicalSales", label: "기술영업" },
- { key: "design", label: "설계" },
- { key: "procurement", label: "구매" },
- { key: "quality", label: "품질" },
- { key: "production", label: "생산" },
- { key: "commissioning", label: "시운전" },
- { key: "other", label: "기타" },
- ].map((item) => (
- <div key={item.key} className="border rounded-lg p-4 space-y-3">
- <div className="flex items-center space-x-3">
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.checked` as `shiAttendees.${typeof item.key}.checked`}
- render={({ field }) => (
- <FormItem className="flex flex-row items-center space-x-2 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- disabled={isPending}
- />
- </FormControl>
- <FormLabel className="text-sm font-medium">{item.label}</FormLabel>
- </FormItem>
- )}
- />
- </div>
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.count` as `shiAttendees.${typeof item.key}.count`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-sm">참석 인원</FormLabel>
- <div className="flex items-center space-x-2">
- <FormControl>
- <Input
- type="number"
- min="0"
- placeholder="0"
- {...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
- disabled={isPending}
- className="w-20"
- />
- </FormControl>
- <span className="text-sm text-muted-foreground">명</span>
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.details` as `shiAttendees.${typeof item.key}.details`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-sm">참석자 정보</FormLabel>
- <FormControl>
- <Input
- placeholder="부서 및 이름 등"
- {...field}
- disabled={isPending}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
- ))}
+ <div className="border rounded-lg overflow-hidden">
+ <Table>
+ <TableHeader>
+ <TableRow className="bg-muted/50">
+ <TableHead className="w-[100px]">참석여부</TableHead>
+ <TableHead className="w-[120px]">부문</TableHead>
+ <TableHead className="w-[100px]">참석인원</TableHead>
+ <TableHead>참석자 정보</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {[
+ { key: "technicalSales", label: "기술영업" },
+ { key: "design", label: "설계" },
+ { key: "procurement", label: "구매" },
+ { key: "quality", label: "품질" },
+ { key: "production", label: "생산" },
+ { key: "commissioning", label: "시운전" },
+ { key: "other", label: "기타" },
+ ].map((item) => (
+ <TableRow key={item.key}>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.checked` as any}
+ render={({ field }) => (
+ <FormItem className="flex items-center space-x-2 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value as boolean}
+ onCheckedChange={field.onChange}
+ disabled={isPending}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <span className="font-medium">{item.label}</span>
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.count` as any}
+ render={({ field }) => (
+ <FormItem className="space-y-0">
+ <div className="flex items-center space-x-2">
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ placeholder="0"
+ value={field.value as number}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+ disabled={isPending}
+ className="w-16 h-8"
+ />
+ </FormControl>
+ <span className="text-xs text-muted-foreground">명</span>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.details` as any}
+ render={({ field }) => (
+ <FormItem className="space-y-0">
+ <FormControl>
+ <Input
+ placeholder="부서 및 이름 등"
+ value={field.value as string}
+ onChange={field.onChange}
+ disabled={isPending}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
</div>
{/* 전체 참석자 상세정보 */}
@@ -544,10 +559,10 @@ export function SiteVisitDialog({
</div>
{/* 협력업체 요청정보 및 자료 */}
- <div>
+ {/* <div>
<FormLabel className="text-sm font-medium">협력업체 요청정보 및 자료</FormLabel>
<div className="text-sm text-muted-foreground mb-2">
- 협력업체에게 요청할 정보를 선택하세요. 선택된 항목들은 협력업체 정보 입력 폼에 포함됩니다.
+ 협력업체에게 요청할 정보를 선택하세요.
</div>
<div className="mt-2 space-y-2">
{[
@@ -564,7 +579,7 @@ export function SiteVisitDialog({
<FormField
key={item.key}
control={form.control}
- name={`vendorRequests.${item.key}` as `vendorRequests.${typeof item.key}`}
+ name={`vendorRequests.${item.key}` as any}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
@@ -580,7 +595,7 @@ export function SiteVisitDialog({
/>
))}
</div>
- <FormField
+ {/* <FormField
control={form.control}
name="otherVendorRequests"
render={({ field }) => (
@@ -597,8 +612,8 @@ export function SiteVisitDialog({
<FormMessage />
</FormItem>
)}
- />
- </div>
+ />
+ </div> */}
{/* 추가 요청사항 */}
<FormField
diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
index d3fada0d..449b69be 100644
--- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
@@ -55,7 +55,7 @@ export interface PQSubmission {
pqTypeLabel: string
// PQ 대상품목
- pqItems: string | null
+ pqItems: string | null | Array<{itemCode: string, itemName: string}>
// 방문실사 요청 정보
siteVisitRequestId: number | null // 방문실사 요청 ID
@@ -457,11 +457,19 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
return <span className="text-muted-foreground">-</span>;
}
- return (
- <div className="flex items-center gap-2">
- <span className="text-sm">{pqItems}</span>
- </div>
- )
+ // JSON 파싱하여 첫 번째 아이템 표시
+ const items = typeof pqItems === 'string' ? JSON.parse(pqItems) : pqItems;
+ if (Array.isArray(items) && items.length > 0) {
+ const firstItem = items[0];
+ return (
+ <div className="flex items-center gap-2">
+ <span className="text-sm">{firstItem.itemCode} - {firstItem.itemName}</span>
+ {items.length > 1 && (
+ <span className="text-xs text-muted-foreground">외 {items.length - 1}건</span>
+ )}
+ </div>
+ );
+ }
},
}
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
index f731a922..8398c2e7 100644
--- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -309,20 +309,69 @@ const handleOpenRequestDialog = async () => {
const investigation = row.original.investigation!
const pqSubmission = row.original
+ // pqItems를 상세하게 포맷팅 (itemCode-itemName 형태로 모든 항목 표시)
+ const formatAuditItem = (pqItems: any): string => {
+ if (!pqItems) return pqSubmission.projectName || "N/A";
+
+ try {
+ // 이미 파싱된 객체 배열인 경우
+ if (Array.isArray(pqItems)) {
+ return pqItems.map(item => {
+ if (typeof item === 'string') return item;
+ if (typeof item === 'object') {
+ const code = item.itemCode || item.code || "";
+ const name = item.itemName || item.name || "";
+ if (code && name) return `${code}-${name}`;
+ return name || code || String(item);
+ }
+ return String(item);
+ }).join(', ');
+ }
+
+ // JSON 문자열인 경우
+ if (typeof pqItems === 'string') {
+ try {
+ const parsed = JSON.parse(pqItems);
+ if (Array.isArray(parsed)) {
+ return parsed.map(item => {
+ if (typeof item === 'string') return item;
+ if (typeof item === 'object') {
+ const code = item.itemCode || item.code || "";
+ const name = item.itemName || item.name || "";
+ if (code && name) return `${code}-${name}`;
+ return name || code || String(item);
+ }
+ return String(item);
+ }).join(', ');
+ }
+ return String(parsed);
+ } catch {
+ return String(pqItems);
+ }
+ }
+
+ // 기타 경우
+ return String(pqItems);
+ } catch {
+ return pqSubmission.projectName || "N/A";
+ }
+ };
+
return {
id: investigation.id,
vendorCode: row.original.vendorCode || "N/A",
vendorName: row.original.vendorName || "N/A",
vendorEmail: row.original.email || "N/A",
+ vendorContactPerson: (row.original as any).representativeName || row.original.vendorName || "N/A",
pqNumber: pqSubmission.pqNumber || "N/A",
- auditItem: pqSubmission.pqItems || pqSubmission.projectName || "N/A",
+ auditItem: formatAuditItem(pqSubmission.pqItems),
auditFactoryAddress: investigation.investigationAddress || "N/A",
auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""),
auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" :
investigation.evaluationResult === "SUPPLEMENT" ? "Pass(조건부승인)" :
investigation.evaluationResult === "REJECTED" ? "Fail(미승인)" : "N/A",
- additionalNotes: investigation.investigationNotes,
- investigationNotes: investigation.investigationNotes,
+ additionalNotes: investigation.investigationNotes || undefined,
+ investigationNotes: investigation.investigationNotes || undefined,
}
})
diff --git a/lib/pq/pq-review-table/feature-flags-provider.tsx b/lib/pq/pq-review-table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/pq/pq-review-table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/pq/pq-review-table/vendors-table-columns.tsx b/lib/pq/pq-review-table/vendors-table-columns.tsx
deleted file mode 100644
index 8673443f..00000000
--- a/lib/pq/pq-review-table/vendors-table-columns.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, PaperclipIcon } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
-import { useRouter } from "next/navigation"
-
-import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
-import { Separator } from "@/components/ui/separator"
-
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>;
- router: NextRouter;
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<Vendor> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼 (Dropdown 메뉴)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<Vendor> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
-
- <DropdownMenuItem
- onSelect={() => {
- // 1) 만약 rowAction을 열고 싶다면
- // setRowAction({ row, type: "update" })
-
- // 2) 자세히 보기 페이지로 클라이언트 라우팅
- router.push(`/evcp/pq/${row.original.id}`);
- }}
- >
- Details
- </DropdownMenuItem>
-
-
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
-
- // ----------------------------------------------------------------
- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
- // ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] }
- const groupMap: Record<string, ColumnDef<Vendor>[]> = {}
-
- vendorColumnsConfig.forEach((cfg) => {
- // 만약 group가 없으면 "_noGroup" 처리
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // child column 정의
- const childCol: ColumnDef<Vendor> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- cell: ({ row, cell }) => {
-
-
- if (cfg.id === "status") {
- const statusVal = row.original.status
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
- return (
- <div className="flex w-[6.25rem] items-center">
- {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */}
- <span className="capitalize">{statusVal}</span>
- </div>
- )
- }
-
-
- if (cfg.id === "createdAt") {
- const dateVal = cell.getValue() as Date
- return formatDate(dateVal)
- }
-
- if (cfg.id === "updatedAt") {
- const dateVal = cell.getValue() as Date
- return formatDate(dateVal)
- }
-
-
- // code etc...
- return row.getValue(cfg.id) ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // ----------------------------------------------------------------
- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
- // ----------------------------------------------------------------
- const nestedColumns: ColumnDef<Vendor>[] = []
-
- // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
- // 여기서는 그냥 Object.entries 순서
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- // 그룹 없음 → 그냥 최상위 레벨 컬럼
- nestedColumns.push(...colDefs)
- } else {
- // 상위 컬럼
- nestedColumns.push({
- id: groupName,
- header: groupName, // "Basic Info", "Metadata" 등
- columns: colDefs,
- })
- }
- })
-
-
-
-
- // ----------------------------------------------------------------
- // 4) 최종 컬럼 배열: select, nestedColumns, actions
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- actionsColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx
deleted file mode 100644
index 98fef170..00000000
--- a/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Check } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { Vendor } from "@/db/schema/vendors"
-
-interface VendorsTableToolbarActionsProps {
- table: Table<Vendor>
-}
-
-export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
-
-
- return (
- <div className="flex items-center gap-2">
-
-
- {/** 4) Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "vendors",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/pq/pq-review-table/vendors-table.tsx b/lib/pq/pq-review-table/vendors-table.tsx
deleted file mode 100644
index 7eb8f7de..00000000
--- a/lib/pq/pq-review-table/vendors-table.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./vendors-table-columns"
-import { Vendor, vendors } from "@/db/schema/vendors"
-import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
-import { getVendorsInPQ } from "../service"
-
-
-interface VendorsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getVendorsInPQ>>,
- ]
- >
-}
-
-export function VendorsPQReviewTable({ promises }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
-
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null)
-
- // **router** 획득
- const router = useRouter()
-
- // getColumns() 호출 시, router를 주입
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router }),
- [setRowAction, router]
- )
-
- const filterFields: DataTableFilterField<Vendor>[] = [
-
-
- { id: "vendorCode", label: "Vendor Code" },
-
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
-
- { id: "createdAt", label: "Created at", type: "date" },
- { id: "updatedAt", label: "Updated at", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable
- table={table}
- // floatingBar={<VendorsTableFloatingBar table={table} />}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <VendorsTableToolbarActions table={table} />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- </>
- )
-} \ No newline at end of file
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index ba0ce3c5..f15790eb 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -9,11 +9,12 @@ import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count,isNull,SQL
import { z } from "zod"
import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache";
import { format } from "date-fns"
-import { pqCriterias, vendorCriteriaAttachments, vendorInvestigations, vendorPQSubmissions, vendorPqCriteriaAnswers, vendorPqReviewLogs, siteVisitRequests, vendorSiteVisitInfo, siteVisitRequestAttachments } from "@/db/schema/pq"
+import { pqCriterias, vendorCriteriaAttachments, vendorInvestigations, vendorInvestigationAttachments, vendorPQSubmissions, vendorPqCriteriaAnswers, vendorPqReviewLogs, siteVisitRequests, vendorSiteVisitInfo, siteVisitRequestAttachments } from "@/db/schema/pq"
import { sendEmail } from "../mail/sendEmail";
import { decryptWithServerAction } from '@/components/drm/drmUtils'
import { vendorAttachments, vendors } from "@/db/schema/vendors";
+import { vendorRegularRegistrations } from "@/db/schema/vendorRegistrations";
import { saveFile, saveDRMFile } from "@/lib/file-stroage";
import { GetVendorsSchema } from "../vendors/validations";
import { selectVendors } from "../vendors/repository";
@@ -2462,6 +2463,8 @@ export async function requestInvestigationAction(
// 캐시 무효화
revalidateTag("vendor-investigations");
revalidateTag("pq-submissions");
+ revalidateTag("vendor-pq-submissions");
+ revalidatePath("/evcp/pq_new");
return {
success: true,
@@ -2589,8 +2592,32 @@ export async function sendInvestigationResultsAction(input: {
vendorCode: investigation.vendorCode || "N/A",
vendorName: investigation.vendorName || "N/A",
- // 실사 정보
- auditItem: investigation.pqItems || investigation.projectName || "N/A",
+ // 실사 정보 - pqItems를 itemCode-itemName 형태로 모든 항목 표시
+ auditItem: (() => {
+ if (investigation.pqItems) {
+ try {
+ const parsed = typeof investigation.pqItems === 'string'
+ ? JSON.parse(investigation.pqItems)
+ : investigation.pqItems;
+ if (Array.isArray(parsed)) {
+ return parsed.map(item => {
+ if (typeof item === 'string') return item;
+ if (typeof item === 'object') {
+ const code = item.itemCode || item.code || "";
+ const name = item.itemName || item.name || "";
+ if (code && name) return `${code}-${name}`;
+ return name || code || String(item);
+ }
+ return String(item);
+ }).join(', ');
+ }
+ return String(parsed);
+ } catch {
+ return String(investigation.pqItems);
+ }
+ }
+ return investigation.projectName || "N/A";
+ })(),
auditFactoryAddress: investigation.investigationAddress || "N/A",
auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""),
auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" :
@@ -2607,12 +2634,16 @@ export async function sendInvestigationResultsAction(input: {
}
// 이메일 발송
- await sendEmail({
- to: investigation.vendorEmail,
- subject: emailContext.subject,
- template: "audit-result-notice",
- context: emailContext,
- })
+ if (investigation.vendorEmail) {
+ await sendEmail({
+ to: investigation.vendorEmail,
+ subject: emailContext.subject,
+ template: "audit-result-notice",
+ context: emailContext,
+ })
+ } else {
+ throw new Error("벤더 이메일 주소가 없습니다.")
+ }
return { success: true, investigationId: investigation.id }
} catch (error) {
@@ -2636,6 +2667,65 @@ export async function sendInvestigationResultsAction(input: {
updatedAt: new Date(),
})
.where(inArray(vendorInvestigations.id, successfulInvestigationIds))
+
+ // 정규업체등록관리에 레코드 생성 로직
+ const successfulInvestigations = investigations.filter(inv =>
+ successfulInvestigationIds.includes(inv.id)
+ );
+
+ for (const investigation of successfulInvestigations) {
+ // 1. 미실사 PQ는 제외 (이미 COMPLETED 상태인 것만 처리하므로 실사된 것들)
+ // 2. 승인된 실사만 정규업체등록 대상
+ if (investigation.evaluationResult === "APPROVED") {
+ try {
+ // 기존 정규업체등록 레코드 확인
+ const existingRegistration = await tx
+ .select({ id: vendorRegularRegistrations.id })
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, investigation.vendorId))
+ .limit(1);
+
+ // 프로젝트 PQ의 경우 기존 레코드가 있으면 skip, 없으면 생성
+ // 일반 PQ의 경우 무조건 생성 (이미 체크는 위에서 함)
+ if (existingRegistration.length === 0) {
+ // pqItems를 majorItems로 변환 - JSON 통째로 넘겨줌
+ let majorItemsJson = null;
+ if (investigation.pqItems) {
+ try {
+ // 이미 파싱된 객체거나 JSON 문자열인 경우 모두 처리
+ const parsed = typeof investigation.pqItems === 'string'
+ ? JSON.parse(investigation.pqItems)
+ : investigation.pqItems;
+
+ // 원본 구조를 최대한 보존하면서 JSON으로 저장
+ majorItemsJson = JSON.stringify(parsed);
+ } catch {
+ // 파싱 실패 시 문자열로 저장
+ majorItemsJson = JSON.stringify([{
+ itemCode: "UNKNOWN",
+ itemName: String(investigation.pqItems)
+ }]);
+ }
+ }
+
+ await tx.insert(vendorRegularRegistrations).values({
+ vendorId: investigation.vendorId,
+ status: "audit_pass", // 실사 통과 상태로 시작
+ majorItems: majorItemsJson,
+ registrationRequestDate: new Date().toISOString().split('T')[0], // date 타입으로 변환
+ remarks: `PQ 실사 통과로 자동 생성 (PQ번호: ${investigation.pqNumber || 'N/A'})`,
+ });
+
+ console.log(`✅ 정규업체등록 레코드 생성: 벤더 ID ${investigation.vendorId}`);
+ } else {
+ console.log(`⏭️ 정규업체등록 레코드 이미 존재: 벤더 ID ${investigation.vendorId} (Skip)`);
+ }
+ } catch (error) {
+ console.error(`❌ 정규업체등록 레코드 생성 실패 (벤더 ID: ${investigation.vendorId}):`, error);
+ // 정규업체등록 생성 실패는 전체 프로세스를 중단하지 않음
+ }
+ }
+ }
}
return {
@@ -2649,6 +2739,7 @@ export async function sendInvestigationResultsAction(input: {
// 캐시 무효화
revalidateTag("vendor-investigations")
revalidateTag("pq-submissions")
+ revalidateTag("vendor-regular-registrations")
return {
success: true,
@@ -3578,6 +3669,7 @@ export async function updateInvestigationDetailsAction(input: {
confirmedAt?: Date;
evaluationResult?: "APPROVED" | "SUPPLEMENT" | "REJECTED";
investigationNotes?: string;
+ attachments?: File[];
}) {
try {
const updateData: any = {
@@ -3595,11 +3687,72 @@ export async function updateInvestigationDetailsAction(input: {
if (input.investigationNotes !== undefined) {
updateData.investigationNotes = input.investigationNotes;
}
+ // evaluationResult가 APPROVED라면 investigationStatus를 "COMPLETED"(완료됨)로 변경
+ if (input.evaluationResult === "APPROVED") {
+ updateData.investigationStatus = "COMPLETED";
+ }
- await db
- .update(vendorInvestigations)
- .set(updateData)
- .where(eq(vendorInvestigations.id, input.investigationId));
+ // 트랜잭션으로 실사 정보 업데이트와 첨부파일 저장을 함께 처리
+ await db.transaction(async (tx) => {
+ // 1. 실사 정보 업데이트
+ await tx
+ .update(vendorInvestigations)
+ .set(updateData)
+ .where(eq(vendorInvestigations.id, input.investigationId));
+
+ // 2. 첨부파일 처리
+ if (input.attachments && input.attachments.length > 0) {
+ for (const file of input.attachments) {
+ try {
+ console.log(`📁 실사 첨부파일 처리 중: ${file.name} (${file.size} bytes)`);
+
+ // saveFile을 사용하여 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `vendor-investigation/${input.investigationId}`,
+ originalName: file.name,
+ userId: "investigation-update"
+ });
+
+ if (!saveResult.success) {
+ console.error(`❌ 파일 저장 실패: ${file.name}`, saveResult.error);
+ throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`);
+ }
+
+ console.log(`✅ 파일 저장 완료: ${file.name} -> ${saveResult.fileName}`);
+
+ // 파일 타입 결정
+ let attachmentType = "OTHER";
+ if (file.type.includes("pdf")) {
+ attachmentType = "REPORT";
+ } else if (file.type.includes("image")) {
+ attachmentType = "PHOTO";
+ } else if (
+ file.type.includes("word") ||
+ file.type.includes("document") ||
+ file.name.toLowerCase().includes("report")
+ ) {
+ attachmentType = "DOCUMENT";
+ }
+
+ // DB에 첨부파일 레코드 생성
+ await tx.insert(vendorInvestigationAttachments).values({
+ investigationId: input.investigationId,
+ fileName: saveResult.originalName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ mimeType: file.type || 'application/octet-stream',
+ attachmentType: attachmentType as "REPORT" | "PHOTO" | "DOCUMENT" | "CERTIFICATE" | "OTHER",
+ });
+
+ } catch (error) {
+ console.error(`❌ 첨부파일 처리 오류: ${file.name}`, error);
+ throw new Error(`첨부파일 처리 중 오류가 발생했습니다: ${file.name}`);
+ }
+ }
+ }
+ });
revalidateTag("pq-submissions");
revalidatePath("/evcp/pq_new");
@@ -3611,9 +3764,10 @@ export async function updateInvestigationDetailsAction(input: {
} catch (error) {
console.error("실사 정보 업데이트 오류:", error);
+ const errorMessage = error instanceof Error ? error.message : "실사 정보 업데이트 중 오류가 발생했습니다.";
return {
success: false,
- error: "실사 정보 업데이트 중 오류가 발생했습니다."
+ error: errorMessage
};
}
}