summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/rfq-last/shared/rfq-items-dialog.tsx30
-rw-r--r--lib/rfq-last/table/create-general-rfq-dialog.tsx44
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx292
-rw-r--r--lib/rfq-last/vendor/vendor-detail-dialog.tsx120
-rw-r--r--lib/shi-api/shi-api-utils.ts53
5 files changed, 224 insertions, 315 deletions
diff --git a/lib/rfq-last/shared/rfq-items-dialog.tsx b/lib/rfq-last/shared/rfq-items-dialog.tsx
index afed9576..6a40dcfa 100644
--- a/lib/rfq-last/shared/rfq-items-dialog.tsx
+++ b/lib/rfq-last/shared/rfq-items-dialog.tsx
@@ -272,6 +272,8 @@ export function RfqItemsDialog({
<TableHead className="w-[60px]">아이템</TableHead>
<TableHead className="w-[120px]">자재코드</TableHead>
<TableHead>자재명</TableHead>
+ <TableHead className="w-[140px]">자재그룹</TableHead>
+ <TableHead className="w-[120px]">사이즈</TableHead>
<TableHead className="w-[200px]">Specification</TableHead>
<TableHead className="w-[80px]">수량</TableHead>
<TableHead className="w-[60px]">수량단위</TableHead>
@@ -280,7 +282,9 @@ export function RfqItemsDialog({
<TableHead className="w-[110px]">PR 발행일</TableHead>
<TableHead className="w-[100px]">PR납기 요청일</TableHead>
<TableHead className="w-[100px]">PR번호</TableHead>
+ <TableHead className="w-[100px]">PR 아이템 번호</TableHead>
<TableHead className="w-[120px]">사양/설계문서</TableHead>
+ <TableHead className="w-[100px]">프로젝트</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -298,6 +302,10 @@ export function RfqItemsDialog({
<TableCell><Skeleton className="h-8 w-full" /></TableCell>
<TableCell><Skeleton className="h-8 w-full" /></TableCell>
<TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
</TableRow>
))}
</TableBody>
@@ -314,6 +322,8 @@ export function RfqItemsDialog({
<TableHead className="w-[60px]">아이템</TableHead>
<TableHead className="w-[120px]">자재코드</TableHead>
<TableHead>자재명</TableHead>
+ <TableHead className="w-[140px]">자재그룹</TableHead>
+ <TableHead className="w-[120px]">사이즈</TableHead>
<TableHead className="w-[200px]">Specification</TableHead>
<TableHead className="w-[80px]">수량</TableHead>
<TableHead className="w-[60px]">수량단위</TableHead>
@@ -355,19 +365,19 @@ export function RfqItemsDialog({
<span className="text-sm font-medium" title={item.materialDescription || ""}>
{item.materialDescription || "-"}
</span>
- {item.materialCategory && (
- <span className="text-xs text-muted-foreground">
- {item.materialCategory}
- </span>
- )}
- {item.size && (
- <span className="text-xs text-muted-foreground">
- 크기: {item.size}
- </span>
- )}
</div>
</TableCell>
<TableCell>
+ <span className="text-sm text-muted-foreground">
+ {item.materialCategory || "-"}
+ </span>
+ </TableCell>
+ <TableCell>
+ <span className="text-sm text-muted-foreground">
+ {item.size || "-"}
+ </span>
+ </TableCell>
+ <TableCell>
{item.specification?.trim() ? (
<TooltipProvider>
<Tooltip>
diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx
index b46249f0..6f752bd0 100644
--- a/lib/rfq-last/table/create-general-rfq-dialog.tsx
+++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx
@@ -4,7 +4,7 @@ import * as React from "react"
import { useForm, useFieldArray } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
-import { format } from "date-fns"
+import { format, addDays } from "date-fns"
import { CalendarIcon, Plus, Loader2, Trash2, PlusCircle } from "lucide-react"
import { useSession } from "next-auth/react"
@@ -103,25 +103,25 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp
const form = useForm<CreateGeneralRfqFormValues>({
resolver: zodResolver(createGeneralRfqSchema),
- defaultValues: {
- rfqType: "",
- rfqTitle: "",
- dueDate: undefined,
- picUserId: userId || undefined,
- projectId: undefined,
- remark: "",
- items: [
- {
- itemCode: "",
- itemName: "",
- materialCode: "",
- materialName: "",
- quantity: 1,
- uom: "",
- remark: "",
- },
- ],
- },
+ defaultValues: {
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: addDays(new Date(), 7),
+ picUserId: userId || undefined,
+ projectId: undefined,
+ remark: "",
+ items: [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ },
})
const { fields, append, remove } = useFieldArray({
@@ -193,7 +193,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp
form.reset({
rfqType: "",
rfqTitle: "",
- dueDate: undefined,
+ dueDate: addDays(new Date(), 7),
picUserId: userId || undefined,
projectId: undefined,
remark: "",
@@ -219,7 +219,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp
form.reset({
rfqType: "",
rfqTitle: "",
- dueDate: undefined,
+ dueDate: addDays(new Date(), 7),
picUserId: userId || undefined,
projectId: undefined,
remark: "",
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx
index 58c45aa0..0d39e0d0 100644
--- a/lib/rfq-last/table/rfq-table-columns.tsx
+++ b/lib/rfq-last/table/rfq-table-columns.tsx
@@ -15,7 +15,7 @@ import {
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { RfqsLastView } from "@/db/schema";
import { DataTableRowAction } from "@/types/table";
-import { format, differenceInDays } from "date-fns";
+import { format, differenceInCalendarDays } from "date-fns";
import { ko } from "date-fns/locale";
import { useRouter } from "next/navigation";
import { RfqSealToggleCell } from "./rfq-seal-toggle-cell";
@@ -44,6 +44,53 @@ const getStatusBadgeVariant = (status: string) => {
}
};
+const renderDueDateCell = (date?: string | Date | null) => {
+ if (!date) return "-";
+
+ const now = new Date();
+ const dueDate = new Date(date);
+ const daysLeft = differenceInCalendarDays(dueDate, now);
+
+ let statusIcon;
+ let statusText;
+ let statusClass;
+
+ if (daysLeft < 0) {
+ const daysOverdue = Math.abs(daysLeft);
+ statusIcon = <XCircle className="h-4 w-4" />;
+ statusText = `${daysOverdue}일 지남`;
+ statusClass = "text-red-600";
+ } else if (daysLeft === 0) {
+ statusIcon = <AlertTriangle className="h-4 w-4" />;
+ statusText = "오늘 마감";
+ statusClass = "text-orange-600";
+ } else if (daysLeft <= 3) {
+ statusIcon = <AlertCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-amber-600";
+ } else if (daysLeft <= 7) {
+ statusIcon = <Clock className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-blue-600";
+ } else {
+ statusIcon = <CheckCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-green-600";
+ }
+
+ return (
+ <div className="flex flex-col gap-1">
+ <span className="text-sm text-muted-foreground">
+ {format(dueDate, "yyyy-MM-dd")}
+ </span>
+ <div className={`flex items-center gap-1 ${statusClass}`}>
+ {statusIcon}
+ <span className="text-xs font-medium">{statusText}</span>
+ </div>
+ </div>
+ );
+};
+
export function getRfqColumns({
setRowAction,
rfqCategory = "itb",
@@ -125,7 +172,7 @@ export function getRfqColumns({
cell: ({ row, table }) => (
<RfqSealToggleCell
rfqId={row.original.id}
- isSealed={row.original.rfqSealedYn}
+ isSealed={!!row.original.rfqSealedYn}
onUpdate={() => {
// 테이블 데이터를 새로고침하는 로직
// 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
@@ -134,7 +181,7 @@ export function getRfqColumns({
}}
/>
),
- size: 80,
+ size: 120,
},
// 구매담당자
@@ -258,59 +305,7 @@ export function getRfqColumns({
{
accessorKey: "dueDate",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />,
- cell: ({ row }) => {
- const date = row.original.dueDate;
- if (!date) return "-";
-
- const now = new Date();
- const dueDate = new Date(date);
- const daysLeft = differenceInDays(dueDate, now);
-
- // 상태별 스타일과 아이콘 설정
- let statusIcon;
- let statusText;
- let statusClass;
-
- if (daysLeft < 0) {
- // 마감일 지남
- const daysOverdue = Math.abs(daysLeft);
- statusIcon = <XCircle className="h-4 w-4" />;
- statusText = `${daysOverdue}일 지남`;
- statusClass = "text-red-600";
- } else if (daysLeft === 0) {
- // 오늘 마감
- statusIcon = <AlertTriangle className="h-4 w-4" />;
- statusText = "오늘 마감";
- statusClass = "text-orange-600";
- } else if (daysLeft <= 3) {
- // 3일 이내 마감 임박
- statusIcon = <AlertCircle className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-amber-600";
- } else if (daysLeft <= 7) {
- // 일주일 이내
- statusIcon = <Clock className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-blue-600";
- } else {
- // 여유 있음
- statusIcon = <CheckCircle className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-green-600";
- }
-
- return (
- <div className="flex flex-col gap-1">
- <span className="text-sm text-muted-foreground">
- {format(dueDate, "yyyy-MM-dd")}
- </span>
- <div className={`flex items-center gap-1 ${statusClass}`}>
- {statusIcon}
- <span className="text-xs font-medium">{statusText}</span>
- </div>
- </div>
- );
- },
+ cell: ({ row }) => renderDueDateCell(row.original.dueDate),
size: 120, // 크기를 약간 늘림
},
@@ -463,7 +458,7 @@ export function getRfqColumns({
cell: ({ row, table }) => (
<RfqSealToggleCell
rfqId={row.original.id}
- isSealed={row.original.rfqSealedYn}
+ isSealed={!!row.original.rfqSealedYn}
onUpdate={() => {
// 테이블 데이터를 새로고침하는 로직
// 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
@@ -472,7 +467,7 @@ export function getRfqColumns({
}}
/>
),
- size: 80,
+ size: 120,
},
// 구매담당자
@@ -628,59 +623,7 @@ export function getRfqColumns({
{
accessorKey: "dueDate",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />,
- cell: ({ row }) => {
- const date = row.original.dueDate;
- if (!date) return "-";
-
- const now = new Date();
- const dueDate = new Date(date);
- const daysLeft = differenceInDays(dueDate, now);
-
- // 상태별 스타일과 아이콘 설정
- let statusIcon;
- let statusText;
- let statusClass;
-
- if (daysLeft < 0) {
- // 마감일 지남
- const daysOverdue = Math.abs(daysLeft);
- statusIcon = <XCircle className="h-4 w-4" />;
- statusText = `${daysOverdue}일 지남`;
- statusClass = "text-red-600";
- } else if (daysLeft === 0) {
- // 오늘 마감
- statusIcon = <AlertTriangle className="h-4 w-4" />;
- statusText = "오늘 마감";
- statusClass = "text-orange-600";
- } else if (daysLeft <= 3) {
- // 3일 이내 마감 임박
- statusIcon = <AlertCircle className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-amber-600";
- } else if (daysLeft <= 7) {
- // 일주일 이내
- statusIcon = <Clock className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-blue-600";
- } else {
- // 여유 있음
- statusIcon = <CheckCircle className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-green-600";
- }
-
- return (
- <div className="flex flex-col gap-1">
- <span className="text-sm text-muted-foreground">
- {format(dueDate, "yyyy-MM-dd")}
- </span>
- <div className={`flex items-center gap-1 ${statusClass}`}>
- {statusIcon}
- <span className="text-xs font-medium">{statusText}</span>
- </div>
- </div>
- );
- },
+ cell: ({ row }) => renderDueDateCell(row.original.dueDate),
size: 120, // 크기를 약간 늘림
},
@@ -978,54 +921,7 @@ export function getRfqColumns({
{
accessorKey: "dueDate",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />,
- cell: ({ row }) => {
- const date = row.original.dueDate;
- if (!date) return "-";
-
- const now = new Date();
- const dueDate = new Date(date);
- const daysLeft = differenceInDays(dueDate, now);
-
- // 상태별 스타일과 아이콘 설정
- let statusIcon;
- let statusText;
- let statusClass;
-
- if (daysLeft < 0) {
- const daysOverdue = Math.abs(daysLeft);
- statusIcon = <XCircle className="h-4 w-4" />;
- statusText = `${daysOverdue}일 지남`;
- statusClass = "text-red-600";
- } else if (daysLeft === 0) {
- statusIcon = <AlertTriangle className="h-4 w-4" />;
- statusText = "오늘 마감";
- statusClass = "text-orange-600";
- } else if (daysLeft <= 3) {
- statusIcon = <AlertCircle className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-amber-600";
- } else if (daysLeft <= 7) {
- statusIcon = <Clock className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-blue-600";
- } else {
- statusIcon = <CheckCircle className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-green-600";
- }
-
- return (
- <div className="flex flex-col gap-1">
- <span className="text-sm text-muted-foreground">
- {format(dueDate, "yyyy-MM-dd")}
- </span>
- <div className={`flex items-center gap-1 ${statusClass}`}>
- {statusIcon}
- <span className="text-xs font-medium">{statusText}</span>
- </div>
- </div>
- );
- },
+ cell: ({ row }) => renderDueDateCell(row.original.dueDate),
size: 120,
},
@@ -1166,7 +1062,7 @@ export function getRfqColumns({
cell: ({ row, table }) => (
<RfqSealToggleCell
rfqId={row.original.id}
- isSealed={row.original.rfqSealedYn}
+ isSealed={!!row.original.rfqSealedYn}
onUpdate={() => {
// 테이블 데이터를 새로고침하는 로직
// 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
@@ -1204,17 +1100,17 @@ export function getRfqColumns({
},
// 시리즈
- {
- accessorKey: "series",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />,
- cell: ({ row }) => {
- const series = row.original.series;
- if (!series) return "-";
- const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series;
- return <Badge variant="outline">{label}</Badge>;
- },
- size: 100,
- },
+ // {
+ // accessorKey: "series",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />,
+ // cell: ({ row }) => {
+ // const series = row.original.series;
+ // if (!series) return "-";
+ // const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series;
+ // return <Badge variant="outline">{label}</Badge>;
+ // },
+ // size: 100,
+ // },
// 선급
{
@@ -1322,59 +1218,7 @@ export function getRfqColumns({
{
accessorKey: "dueDate",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />,
- cell: ({ row }) => {
- const date = row.original.dueDate;
- if (!date) return "-";
-
- const now = new Date();
- const dueDate = new Date(date);
- const daysLeft = differenceInDays(dueDate, now);
-
- // 상태별 스타일과 아이콘 설정
- let statusIcon;
- let statusText;
- let statusClass;
-
- if (daysLeft < 0) {
- // 마감일 지남
- const daysOverdue = Math.abs(daysLeft);
- statusIcon = <XCircle className="h-4 w-4" />;
- statusText = `${daysOverdue}일 지남`;
- statusClass = "text-red-600";
- } else if (daysLeft === 0) {
- // 오늘 마감
- statusIcon = <AlertTriangle className="h-4 w-4" />;
- statusText = "오늘 마감";
- statusClass = "text-orange-600";
- } else if (daysLeft <= 3) {
- // 3일 이내 마감 임박
- statusIcon = <AlertCircle className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-amber-600";
- } else if (daysLeft <= 7) {
- // 일주일 이내
- statusIcon = <Clock className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-blue-600";
- } else {
- // 여유 있음
- statusIcon = <CheckCircle className="h-4 w-4" />;
- statusText = `${daysLeft}일 남음`;
- statusClass = "text-green-600";
- }
-
- return (
- <div className="flex flex-col gap-1">
- <span className="text-sm text-muted-foreground">
- {format(dueDate, "yyyy-MM-dd")}
- </span>
- <div className={`flex items-center gap-1 ${statusClass}`}>
- {statusIcon}
- <span className="text-xs font-medium">{statusText}</span>
- </div>
- </div>
- );
- },
+ cell: ({ row }) => renderDueDateCell(row.original.dueDate),
size: 120, // 크기를 약간 늘림
},
diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
index 9c112efa..cbe4f919 100644
--- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx
+++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
@@ -55,6 +55,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
+import { formatFileSize, quickDownload, smartFileAction } from "@/lib/file-download";
// Props 타입 정의
interface VendorResponseDetailDialogProps {
@@ -183,10 +184,13 @@ export function VendorResponseDetailDialog({
<div className="flex-1 overflow-y-auto px-6 ">
<Tabs defaultValue="overview" className="mb-2">
- <TabsList className="grid w-full grid-cols-3">
+ <TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">개요</TabsTrigger>
<TabsTrigger value="quotation">견적정보</TabsTrigger>
<TabsTrigger value="items">품목상세</TabsTrigger>
+ <TabsTrigger value="documents">
+ 제출 문서
+ </TabsTrigger>
</TabsList>
{/* 개요 탭 */}
@@ -773,60 +777,78 @@ export function VendorResponseDetailDialog({
)}
</TabsContent>
- {/* 첨부파일 탭 */}
- {/* <TabsContent value="attachments" className="space-y-4">
- {attachments.length > 0 ? (
- <Card>
- <CardHeader>
- <CardTitle className="text-base">첨부파일</CardTitle>
- <CardDescription>
- 총 {attachments.length}개 파일
- </CardDescription>
- </CardHeader>
- <CardContent>
- <div className="space-y-2">
- {attachments.map((file: any) => (
- <div
- key={file.id}
- className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent"
- >
- <div className="flex items-center gap-3">
- <Paperclip className="h-4 w-4 text-muted-foreground" />
- <div>
- <p className="text-sm font-medium">{file.originalFileName}</p>
- <p className="text-xs text-muted-foreground">
- {file.attachmentType} • {file.fileSize ? `${(file.fileSize / 1024).toFixed(2)} KB` : "크기 미상"}
- {file.description && ` • ${file.description}`}
+ {/* 제출 문서 탭 */}
+ <TabsContent value="documents" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">제출 문서</CardTitle>
+ <CardDescription>
+ 총 {attachments.length}개 파일
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {attachments.length > 0 ? (
+ <div className="space-y-2 max-h-[52vh] overflow-y-auto pr-1">
+ {attachments.map((file: any) => {
+ const fileLabel = file.originalFileName || file.fileName || "파일명 없음";
+ const canOpen = !!file.filePath;
+ return (
+ <div
+ key={`${file.id}-${file.filePath}-${file.originalFileName}`}
+ className="flex items-start justify-between gap-4 rounded-lg border p-3 hover:bg-accent/50"
+ >
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 flex-wrap">
+ <Paperclip className="h-4 w-4 text-muted-foreground" />
+ <Badge variant="secondary">{file.attachmentType || "문서"}</Badge>
+ <span className="font-medium break-all">{fileLabel}</span>
+ </div>
+ <p className="text-xs text-muted-foreground space-x-2 flex flex-wrap">
+ {file.documentNo && <span>문서번호: {file.documentNo}</span>}
+ {file.description && <span>{file.description}</span>}
+ <span>{file.fileSize ? formatFileSize(file.fileSize) : "크기 정보 없음"}</span>
+ {file.uploadedAt && (
+ <span>
+ 업로드: {format(new Date(file.uploadedAt), "yyyy-MM-dd HH:mm", { locale: ko })}
+ </span>
+ )}
+ {file.uploadedByName && <span>작성: {file.uploadedByName}</span>}
</p>
</div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ disabled={!canOpen}
+ onClick={() => smartFileAction(file.filePath, fileLabel)}
+ className="h-8"
+ >
+ <Eye className="h-4 w-4 mr-1" />
+ 열기
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={!canOpen}
+ onClick={() => quickDownload(file.filePath, fileLabel)}
+ className="h-8"
+ >
+ <Download className="h-4 w-4 mr-1" />
+ 다운로드
+ </Button>
+ </div>
</div>
- <div className="flex items-center gap-2">
- <Button
- variant="ghost"
- size="sm"
- onClick={() => {
- // 파일 다운로드 로직
- window.open(file.filePath, "_blank");
- }}
- >
- <Download className="h-4 w-4" />
- </Button>
- </div>
- </div>
- ))}
+ );
+ })}
</div>
- </CardContent>
- </Card>
- ) : (
- <Card>
- <CardContent className="pt-6">
- <div className="text-center text-muted-foreground">
+ ) : (
+ <div className="text-sm text-muted-foreground">
아직 제출된 첨부파일이 없습니다.
</div>
- </CardContent>
- </Card>
- )}
- </TabsContent> */}
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
</Tabs>
</div>
diff --git a/lib/shi-api/shi-api-utils.ts b/lib/shi-api/shi-api-utils.ts
index 2203f6b2..1e420356 100644
--- a/lib/shi-api/shi-api-utils.ts
+++ b/lib/shi-api/shi-api-utils.ts
@@ -89,6 +89,11 @@ export const getAllNonsapUser = async () => {
let totalSkipped = 0;
// 청크 단위로 매핑과 DB 처리를 동시에 수행
+ // 전체 데이터 기준으로 "첫 번째 이메일만 반영" 정책을 지키기 위해
+ // 전역 Set 으로 이메일/USR_ID 중복을 관리한다.
+ const globalSeenEmails = new Set<string>();
+ const globalSeenNonsapUserIds = new Set<string>();
+
for (let i = 0; i < sourceData.length; i += CHUNK_SIZE) {
const chunk = sourceData.slice(i, i + CHUNK_SIZE);
debugLog(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] Processing ${chunk.length} users (${i + 1}-${Math.min(i + chunk.length, sourceData.length)}/${sourceData.length})`);
@@ -162,6 +167,20 @@ export const getAllNonsapUser = async () => {
continue;
}
+ const emailKey = mappedUser.email.toLowerCase();
+
+ // 데이터셋 전체 기준 중복 이메일/USR_ID 체크 (첫 번째 건만 반영)
+ if (globalSeenEmails.has(emailKey)) {
+ totalSkipped++;
+ debugWarn(`[GLOBAL] 중복 email 스킵: ${mappedUser.email}, nonsapUserId: ${mappedUser.nonsapUserId}`);
+ continue;
+ }
+ if (mappedUser.nonsapUserId && globalSeenNonsapUserIds.has(mappedUser.nonsapUserId)) {
+ totalSkipped++;
+ debugWarn(`[GLOBAL] 중복 nonsapUserId 스킵: ${mappedUser.nonsapUserId}, email: ${mappedUser.email}`);
+ continue;
+ }
+
// 청크 내 중복 체크
if (seenNonsapUserIds.has(mappedUser.nonsapUserId)) {
totalSkipped++;
@@ -169,7 +188,7 @@ export const getAllNonsapUser = async () => {
continue;
}
- if (seenEmails.has(mappedUser.email.toLowerCase())) {
+ if (seenEmails.has(emailKey)) {
totalSkipped++;
debugWarn(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] 중복 email 발견 (청크 내): ${mappedUser.email}, nonsapUserId: ${mappedUser.nonsapUserId}`);
continue;
@@ -177,20 +196,34 @@ export const getAllNonsapUser = async () => {
// 중복 체크 통과 시 추가
seenNonsapUserIds.add(mappedUser.nonsapUserId);
- seenEmails.add(mappedUser.email.toLowerCase());
+ seenEmails.add(emailKey);
+ globalSeenEmails.add(emailKey);
+ if (mappedUser.nonsapUserId) {
+ globalSeenNonsapUserIds.add(mappedUser.nonsapUserId);
+ }
mappedChunk.push(mappedUser as InsertUser);
}
// 매핑된 청크가 있을 경우에만 DB 처리 (트랜잭션으로 처리)
if (mappedChunk.length > 0) {
- await db.transaction(async (tx) => {
- // nonsapUserId를 기준으로 UPSERT (진정한 사용자 고유 ID)
- // email은 변경될 수 있지만 nonsapUserId는 변경되지 않음
- await bulkUpsert(tx, users, mappedChunk, 'nonsapUserId', 200); // 청크 내에서도 200개씩 세분화
- });
-
- totalUpserted += mappedChunk.length;
- debugLog(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] Successfully processed ${mappedChunk.length} users`);
+ try {
+ await db.transaction(async (tx) => {
+ // nonsapUserId를 기준으로 UPSERT (진정한 사용자 고유 ID)
+ // email은 변경될 수 있지만 nonsapUserId는 변경되지 않음
+ await bulkUpsert(tx, users, mappedChunk, 'nonsapUserId', 200); // 청크 내에서도 200개씩 세분화
+ });
+
+ totalUpserted += mappedChunk.length;
+ debugLog(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] Successfully processed ${mappedChunk.length} users`);
+ } catch (chunkTxError: unknown) {
+ // 이메일 UNIQUE 제약 위반 등은 건너뛰고 다음 청크 진행 (첫 건 반영 정책)
+ const pgCode = (chunkTxError as any)?.code;
+ if (pgCode === '23505') {
+ debugWarn(`[CHUNK ${Math.floor(i/CHUNK_SIZE) + 1}] Unique constraint 충돌로 청크 스킵: ${(chunkTxError as Error).message}`);
+ continue;
+ }
+ throw chunkTxError;
+ }
}
// 청크 간 잠시 대기하여 시스템 부하 방지