summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/rfq-last/service.ts625
-rw-r--r--lib/rfq-last/table/create-general-rfq-dialog.tsx779
-rw-r--r--lib/rfq-last/table/rfq-attachments-dialog.tsx351
-rw-r--r--lib/rfq-last/table/rfq-items-dialog.tsx354
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx1206
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx15
-rw-r--r--lib/rfq-last/table/rfq-table.tsx26
-rw-r--r--lib/rfq-last/validations.ts24
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts233
-rw-r--r--lib/vendor-document-list/import-service.ts15
-rw-r--r--lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx428
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx37
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx77
-rw-r--r--lib/vendor-document-list/ship/enhanced-documents-table.tsx1
14 files changed, 3771 insertions, 400 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index f2710f02..0be8049b 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -1,12 +1,12 @@
// lib/rfq/service.ts
'use server'
-import { unstable_noStore } from "next/cache";
+import { unstable_cache, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { rfqsLastView } from "@/db/schema";
-import { and, desc, asc, ilike, or, eq, SQL, count, gte, lte,isNotNull,ne } from "drizzle-orm";
+import { RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView } from "@/db/schema";
+import {sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm";
import { filterColumns } from "@/lib/filter-columns";
-import { GetRfqsSchema } from "./validations";
+import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations";
export async function getRfqs(input: GetRfqsSchema) {
unstable_noStore();
@@ -44,17 +44,17 @@ export async function getRfqs(input: GetRfqsSchema) {
// 2. 고급 필터 처리
let advancedWhere: SQL<unknown> | undefined = undefined;
-
+
if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) {
console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`));
-
+
try {
advancedWhere = filterColumns({
table: rfqsLastView,
filters: input.filters,
joinOperator: input.joinOperator || 'and',
});
-
+
console.log("필터 조건 생성 완료");
} catch (error) {
console.error("필터 조건 생성 오류:", error);
@@ -111,8 +111,8 @@ export async function getRfqs(input: GetRfqsSchema) {
// 6. 정렬 및 페이징 처리
const orderByColumns = input.sort.map((sort) => {
const column = sort.id as keyof typeof rfqsLastView.$inferSelect;
- return sort.desc
- ? desc(rfqsLastView[column])
+ return sort.desc
+ ? desc(rfqsLastView[column])
: asc(rfqsLastView[column]);
});
@@ -139,3 +139,610 @@ export async function getRfqs(input: GetRfqsSchema) {
}
}
+const getRfqById = async (id: number): Promise<RfqsLastView | null> => {
+ // 1) RFQ 단건 조회
+ const rfqsRes = await db
+ .select()
+ .from(rfqsLastView)
+ .where(eq(rfqsLastView.id, id))
+ .limit(1);
+
+ if (rfqsRes.length === 0) return null;
+ const rfqRow = rfqsRes[0];
+
+ // 3) RfqWithItems 형태로 반환
+ const result: RfqsLastView = {
+ ...rfqRow,
+
+ };
+
+ return result;
+};
+
+
+export const findRfqLastById = async (id: number): Promise<RfqsLastView | null> => {
+ try {
+
+ const rfq = await getRfqById(id);
+
+ return rfq;
+ } catch (error) {
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export async function getRfqLastAttachments(
+ input: GetRfqLastAttachmentsSchema,
+ rfqId: number
+) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // Advanced Filter 처리 (메인 테이블 기준)
+ const advancedWhere = filterColumns({
+ table: rfqLastAttachments,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // 전역 검색 (첨부파일 + 리비전 파일명 검색)
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(rfqLastAttachments.serialNo, s),
+ ilike(rfqLastAttachments.description, s),
+ ilike(rfqLastAttachments.currentRevision, s),
+ ilike(rfqLastAttachmentRevisions.fileName, s),
+ ilike(rfqLastAttachmentRevisions.originalFileName, s)
+ )
+ }
+
+ // 기본 필터
+ let basicWhere
+ if (input.attachmentType.length > 0 || input.fileType.length > 0) {
+ basicWhere = and(
+ input.attachmentType.length > 0
+ ? inArray(rfqLastAttachments.attachmentType, input.attachmentType)
+ : undefined,
+ input.fileType.length > 0
+ ? inArray(rfqLastAttachmentRevisions.fileType, input.fileType)
+ : undefined
+ )
+ }
+
+ // 최종 WHERE 절
+ const finalWhere = and(
+ eq(rfqLastAttachments.rfqId, rfqId), // RFQ ID 필수 조건
+ advancedWhere,
+ globalWhere,
+ basicWhere
+ )
+
+ // 정렬 (메인 테이블 기준)
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments])
+ )
+ : [desc(rfqLastAttachments.createdAt)]
+
+ // 트랜잭션으로 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인)
+ const data = await tx
+ .select({
+ // 첨부파일 메인 정보
+ id: rfqLastAttachments.id,
+ attachmentType: rfqLastAttachments.attachmentType,
+ serialNo: rfqLastAttachments.serialNo,
+ rfqId: rfqLastAttachments.rfqId,
+ currentRevision: rfqLastAttachments.currentRevision,
+ latestRevisionId: rfqLastAttachments.latestRevisionId,
+ description: rfqLastAttachments.description,
+ createdBy: rfqLastAttachments.createdBy,
+ createdAt: rfqLastAttachments.createdAt,
+ updatedAt: rfqLastAttachments.updatedAt,
+
+ // 최신 리비전 파일 정보
+ fileName: rfqLastAttachmentRevisions.fileName,
+ originalFileName: rfqLastAttachmentRevisions.originalFileName,
+ filePath: rfqLastAttachmentRevisions.filePath,
+ fileSize: rfqLastAttachmentRevisions.fileSize,
+ fileType: rfqLastAttachmentRevisions.fileType,
+ revisionComment: rfqLastAttachmentRevisions.revisionComment,
+
+ // 생성자 정보
+ createdByName: users.name,
+ })
+ .from(rfqLastAttachments)
+ .leftJoin(
+ rfqLastAttachmentRevisions,
+ and(
+ eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id),
+ eq(rfqLastAttachmentRevisions.isLatest, true)
+ )
+ )
+ .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id))
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset)
+
+ // 전체 개수 조회
+ const totalResult = await tx
+ .select({ count: count() })
+ .from(rfqLastAttachments)
+ .leftJoin(
+ rfqLastAttachmentRevisions,
+ eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id)
+ )
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count ?? 0
+
+ return { data, total }
+ })
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount }
+ } catch (err) {
+ console.error("getRfqAttachments error:", err)
+ return { data: [], pageCount: 0 }
+ }
+
+}
+// 사용자 목록 조회 (필터용)
+export async function getPUsersForFilter() {
+
+ try {
+ return await db
+ .select({
+ id: users.id,
+ name: users.name,
+ userCode: users.userCode,
+ })
+ .from(users)
+ .where(and(eq(users.isActive, true), isNotNull(users.userCode,)))
+ .orderBy(asc(users.name))
+ } catch (err) {
+ console.error("Error fetching users for filter:", err)
+ return []
+ }
+}
+
+
+
+// 일반견적 RFQ 코드 생성 (F+userCode(3자리)+일련번호5자리 형식)
+async function generateGeneralRfqCode(userCode: string): Promise<string> {
+ try {
+ // 동일한 userCode를 가진 마지막 일반견적 번호 조회
+ const lastRfq = await db
+ .select({ rfqCode: rfqsLast.rfqCode })
+ .from(rfqsLast)
+ .where(
+ and(
+ eq(rfqsLast.rfqType, "일반견적"),
+ like(rfqsLast.rfqCode, `F${userCode}%`) // 같은 userCode로 시작하는 RFQ만 조회
+ )
+ )
+ .orderBy(desc(rfqsLast.createdAt))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
+ // F+userCode(3자리)+일련번호(5자리) 형식에서 마지막 5자리 숫자 추출
+ const rfqCode = lastRfq[0].rfqCode;
+ const serialNumber = rfqCode.slice(-5); // 마지막 5자리 추출
+
+ // 숫자인지 확인하고 다음 번호 생성
+ if (/^\d{5}$/.test(serialNumber)) {
+ nextNumber = parseInt(serialNumber) + 1;
+ }
+ }
+
+ // 5자리 숫자로 패딩
+ const paddedNumber = String(nextNumber).padStart(5, '0');
+ return `F${userCode}${paddedNumber}`;
+ } catch (error) {
+ console.error("Error generating General RFQ code:", error);
+ // 에러 발생 시 타임스탬프 기반 코드 생성
+ const timestamp = Date.now().toString().slice(-5);
+ return `F${userCode}${timestamp}`;
+ }
+}
+
+// 일반견적 생성 액션
+interface CreateGeneralRfqInput {
+ rfqType: string;
+ rfqTitle: string;
+ dueDate: Date;
+ picUserId: number;
+ remark?: string;
+ items: Array<{
+ itemCode: string;
+ itemName: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>;
+ createdBy: number;
+ updatedBy: number;
+}
+
+export async function createGeneralRfqAction(input: CreateGeneralRfqInput) {
+ try {
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 1. 구매 담당자 정보 조회
+ const picUser = await tx
+ .select({
+ name: users.name,
+ email: users.email,
+ userCode: users.userCode
+ })
+ .from(users)
+ .where(eq(users.id, input.picUserId))
+ .limit(1);
+
+ if (!picUser || picUser.length === 0) {
+ throw new Error("구매 담당자를 찾을 수 없습니다");
+ }
+
+ // 2. userCode 확인 (3자리)
+ const userCode = picUser[0].userCode;
+ if (!userCode || userCode.length !== 3) {
+ throw new Error("구매 담당자의 userCode가 올바르지 않습니다 (3자리 필요)");
+ }
+
+ // 3. RFQ 코드 생성 (userCode 사용)
+ const rfqCode = await generateGeneralRfqCode(userCode);
+
+ // 4. 대표 아이템 정보 추출 (첫 번째 아이템)
+ const representativeItem = input.items[0];
+
+ // 5. rfqsLast 테이블에 기본 정보 삽입
+ const [newRfq] = await tx
+ .insert(rfqsLast)
+ .values({
+ rfqCode,
+ rfqType: input.rfqType,
+ rfqTitle: input.rfqTitle,
+ status: "RFQ 생성",
+ dueDate: input.dueDate,
+
+ // 대표 아이템 정보
+ itemCode: representativeItem.itemCode,
+ itemName: representativeItem.itemName,
+
+ // 담당자 정보
+ pic: input.picUserId,
+ picCode: userCode, // userCode를 picCode로 사용
+ picName: picUser[0].name || '',
+
+ // 기타 정보
+ remark: input.remark || null,
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ // 6. rfqPrItems 테이블에 아이템들 삽입
+ const prItemsData = input.items.map((item, index) => ({
+ rfqsLastId: newRfq.id,
+ rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ...
+ prItem: `${index + 1}`.padStart(3, '0'),
+ prNo: rfqCode, // RFQ 코드를 PR 번호로 사용
+
+ materialCode: item.itemCode,
+ materialDescription: item.itemName,
+ quantity: item.quantity,
+ uom: item.uom,
+
+ majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
+ remark: item.remark || null,
+ }));
+
+ await tx.insert(rfqPrItems).values(prItemsData);
+
+ return newRfq;
+ });
+
+ return {
+ success: true,
+ message: "일반견적이 성공적으로 생성되었습니다",
+ data: {
+ id: result.id,
+ rfqCode: result.rfqCode,
+ },
+ };
+
+ } catch (error) {
+ console.error("일반견적 생성 오류:", error);
+
+ if (error instanceof Error) {
+ return {
+ success: false,
+ error: error.message,
+ };
+ }
+
+ return {
+ success: false,
+ error: "일반견적 생성 중 오류가 발생했습니다",
+ };
+ }
+}
+
+// 일반견적 미리보기 (선택적 기능)
+export async function previewGeneralRfqCode(picUserId: number): Promise<string> {
+ try {
+ // 구매 담당자 정보 조회
+ const picUser = await db
+ .select({
+ userCode: users.userCode
+ })
+ .from(users)
+ .where(eq(users.id, picUserId))
+ .limit(1);
+
+ if (!picUser || picUser.length === 0 || !picUser[0].userCode) {
+ return `F???00001`;
+ }
+
+ const userCode = picUser[0].userCode;
+ if (userCode.length !== 3) {
+ return `F???00001`;
+ }
+
+ // 동일한 userCode를 가진 마지막 일반견적 번호 조회
+ const lastRfq = await db
+ .select({ rfqCode: rfqsLast.rfqCode })
+ .from(rfqsLast)
+ .where(
+ and(
+ eq(rfqsLast.rfqType, "일반견적"),
+ like(rfqsLast.rfqCode, `F${userCode}%`)
+ )
+ )
+ .orderBy(desc(rfqsLast.createdAt))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
+ const rfqCode = lastRfq[0].rfqCode;
+ const serialNumber = rfqCode.slice(-5);
+
+ if (/^\d{5}$/.test(serialNumber)) {
+ nextNumber = parseInt(serialNumber) + 1;
+ }
+ }
+
+ const paddedNumber = String(nextNumber).padStart(5, '0');
+ return `F${userCode}${paddedNumber}`;
+ } catch (error) {
+ return `F???XXXXX`;
+ }
+}
+
+
+/**
+ * RFQ 첨부파일 목록 조회
+ */
+export async function getRfqAttachmentsAction(rfqId: number) {
+ try {
+ if (!rfqId || rfqId <= 0) {
+ return {
+ success: false,
+ error: "유효하지 않은 RFQ ID입니다",
+ data: []
+ }
+ }
+
+ // rfpAttachmentsWithLatestRevisionView 뷰 조회
+ const attachments = await db.execute(sql`
+ SELECT
+ attachment_id,
+ attachment_type,
+ serial_no,
+ rfq_id,
+ description,
+ current_revision,
+ revision_id,
+ file_name,
+ original_file_name,
+ file_path,
+ file_size,
+ file_type,
+ revision_comment,
+ created_by,
+ created_by_name,
+ created_at,
+ updated_at
+ FROM rfq_attachments_with_latest_revision
+ WHERE rfq_id = ${rfqId}
+ ORDER BY attachment_type, serial_no, created_at DESC
+ `)
+
+ const formattedAttachments = attachments.rows.map((row: any) => ({
+ attachmentId: row.attachment_id,
+ attachmentType: row.attachment_type,
+ serialNo: row.serial_no,
+ rfqId: row.rfq_id,
+ description: row.description,
+ currentRevision: row.current_revision,
+ revisionId: row.revision_id,
+ fileName: row.file_name,
+ originalFileName: row.original_file_name,
+ filePath: row.file_path,
+ fileSize: row.file_size,
+ fileType: row.file_type,
+ revisionComment: row.revision_comment,
+ createdBy: row.created_by,
+ createdByName: row.created_by_name,
+ createdAt: row.created_at ? new Date(row.created_at) : null,
+ updatedAt: row.updated_at ? new Date(row.updated_at) : null,
+ }))
+
+ return {
+ success: true,
+ data: formattedAttachments,
+ count: formattedAttachments.length
+ }
+
+ } catch (error) {
+ console.error("RFQ 첨부파일 조회 오류:", error)
+ return {
+ success: false,
+ error: "첨부파일 목록을 불러오는데 실패했습니다",
+ data: []
+ }
+ }
+}
+
+/**
+ * RFQ 품목 목록 조회
+ */
+export async function getRfqItemsAction(rfqId: number) {
+ try {
+ if (!rfqId || rfqId <= 0) {
+ return {
+ success: false,
+ error: "유효하지 않은 RFQ ID입니다",
+ data: []
+ }
+ }
+
+ // prItemsLastView 조회
+ const items = await db
+ .select()
+ .from(prItemsLastView)
+ .where(eq(prItemsLastView.rfqsLastId, rfqId))
+ .orderBy(prItemsLastView.majorYn, prItemsLastView.rfqItem, prItemsLastView.materialCode)
+
+ const formattedItems = items.map(item => ({
+ id: item.id,
+ rfqsLastId: item.rfqsLastId,
+ rfqItem: item.rfqItem,
+ prItem: item.prItem,
+ prNo: item.prNo,
+ materialCode: item.materialCode,
+ materialCategory: item.materialCategory,
+ acc: item.acc,
+ materialDescription: item.materialDescription,
+ size: item.size,
+ deliveryDate: item.deliveryDate,
+ quantity: item.quantity,
+ uom: item.uom,
+ grossWeight: item.grossWeight,
+ gwUom: item.gwUom,
+ specNo: item.specNo,
+ specUrl: item.specUrl,
+ trackingNo: item.trackingNo,
+ majorYn: item.majorYn,
+ remark: item.remark,
+ projectDef: item.projectDef,
+ projectSc: item.projectSc,
+ projectKl: item.projectKl,
+ projectLc: item.projectLc,
+ projectDl: item.projectDl,
+ // RFQ 관련 정보
+ rfqCode: item.rfqCode,
+ rfqType: item.rfqType,
+ rfqTitle: item.rfqTitle,
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ projectCode: item.projectCode,
+ projectName: item.projectName,
+ }))
+
+ // 주요 품목과 일반 품목 분리 및 통계
+ const majorItems = formattedItems.filter(item => item.majorYn)
+ const regularItems = formattedItems.filter(item => !item.majorYn)
+
+ return {
+ success: true,
+ data: formattedItems,
+ statistics: {
+ total: formattedItems.length,
+ major: majorItems.length,
+ regular: regularItems.length,
+ totalQuantity: formattedItems.reduce((sum, item) => sum + (item.quantity || 0), 0),
+ totalWeight: formattedItems.reduce((sum, item) => sum + (item.grossWeight || 0), 0),
+ }
+ }
+
+ } catch (error) {
+ console.error("RFQ 품목 조회 오류:", error)
+ return {
+ success: false,
+ error: "품목 목록을 불러오는데 실패했습니다",
+ data: [],
+ statistics: {
+ total: 0,
+ major: 0,
+ regular: 0,
+ totalQuantity: 0,
+ totalWeight: 0,
+ }
+ }
+ }
+}
+
+/**
+ * RFQ 기본 정보 조회 (첨부파일/품목 다이얼로그용)
+ */
+export async function getRfqBasicInfoAction(rfqId: number) {
+ try {
+ if (!rfqId || rfqId <= 0) {
+ return {
+ success: false,
+ error: "유효하지 않은 RFQ ID입니다",
+ data: null
+ }
+ }
+
+ const rfqInfo = await db
+ .select({
+ id: rfqsLast.id,
+ rfqCode: rfqsLast.rfqCode,
+ rfqType: rfqsLast.rfqType,
+ rfqTitle: rfqsLast.rfqTitle,
+ status: rfqsLast.status,
+ itemCode: rfqsLast.itemCode,
+ itemName: rfqsLast.itemName,
+ dueDate: rfqsLast.dueDate,
+ createdAt: rfqsLast.createdAt,
+ })
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1)
+
+ if (!rfqInfo.length) {
+ return {
+ success: false,
+ error: "RFQ를 찾을 수 없습니다",
+ data: null
+ }
+ }
+
+ return {
+ success: true,
+ data: rfqInfo[0]
+ }
+
+ } catch (error) {
+ console.error("RFQ 기본정보 조회 오류:", error)
+ return {
+ success: false,
+ error: "RFQ 정보를 불러오는데 실패했습니다",
+ data: null
+ }
+ }
+}
+
diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx
new file mode 100644
index 00000000..14564686
--- /dev/null
+++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx
@@ -0,0 +1,779 @@
+"use client"
+
+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 { CalendarIcon, Plus, Loader2, Trash2, PlusCircle, Check, ChevronsUpDown } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Calendar } from "@/components/ui/calendar"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } from "../service"
+
+// 아이템 스키마
+const itemSchema = z.object({
+ itemCode: z.string().min(1, "자재코드를 입력해주세요"),
+ itemName: z.string().min(1, "자재명을 입력해주세요"),
+ quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
+ uom: z.string().min(1, "단위를 입력해주세요"),
+ remark: z.string().optional(),
+})
+
+// 일반견적 생성 폼 스키마
+const createGeneralRfqSchema = z.object({
+ rfqType: z.string().min(1, "견적 종류를 선택해주세요"),
+ customRfqType: z.string().optional(),
+ rfqTitle: z.string().min(1, "견적명을 입력해주세요"),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요",
+ }),
+ picUserId: z.number().min(1, "구매 담당자를 선택해주세요"),
+ remark: z.string().optional(),
+ items: z.array(itemSchema).min(1, "최소 하나의 아이템을 추가해주세요"),
+}).refine((data) => {
+ if (data.rfqType === "기타") {
+ return data.customRfqType && data.customRfqType.trim().length > 0
+ }
+ return true
+}, {
+ message: "견적 종류를 직접 입력해주세요",
+ path: ["customRfqType"],
+})
+
+type CreateGeneralRfqFormValues = z.infer<typeof createGeneralRfqSchema>
+
+interface CreateGeneralRfqDialogProps {
+ onSuccess?: () => void;
+}
+
+export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [users, setUsers] = React.useState<Array<{ id: number; name: string }>>([])
+ const [isLoadingUsers, setIsLoadingUsers] = React.useState(false)
+ const [userPopoverOpen, setUserPopoverOpen] = React.useState(false)
+ const [userSearchTerm, setUserSearchTerm] = React.useState("")
+ const [previewCode, setPreviewCode] = React.useState("")
+ const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
+ const userOptionIdsRef = React.useRef<Record<number, string>>({})
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ // 고유 ID 생성
+ const buttonId = React.useMemo(() => `user-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, [])
+ const popoverContentId = React.useMemo(() => `user-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, [])
+ const commandId = React.useMemo(() => `user-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, [])
+
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null;
+ }, [session]);
+
+ const form = useForm<CreateGeneralRfqFormValues>({
+ resolver: zodResolver(createGeneralRfqSchema),
+ defaultValues: {
+ rfqType: "",
+ customRfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: userId || undefined,
+ remark: "",
+ items: [
+ {
+ itemCode: "",
+ itemName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ })
+
+ // 견적 종류 변경 시 customRfqType 필드 초기화
+ const handleRfqTypeChange = (value: string) => {
+ form.setValue("rfqType", value)
+ if (value !== "기타") {
+ form.setValue("customRfqType", "")
+ }
+ }
+
+ // RFQ 코드 미리보기 생성
+ const generatePreviewCode = React.useCallback(async () => {
+ const picUserId = form.watch("picUserId")
+
+ if (!picUserId) {
+ setPreviewCode("")
+ return
+ }
+
+ setIsLoadingPreview(true)
+ try {
+ const code = await previewGeneralRfqCode(picUserId)
+ setPreviewCode(code)
+ } catch (error) {
+ console.error("코드 미리보기 오류:", error)
+ setPreviewCode("")
+ } finally {
+ setIsLoadingPreview(false)
+ }
+ }, [form])
+
+ // 필드 변경 감지해서 미리보기 업데이트
+ React.useEffect(() => {
+ const subscription = form.watch((value, { name }) => {
+ if (name === "picUserId") {
+ generatePreviewCode()
+ }
+ })
+ return () => subscription.unsubscribe()
+ }, [form, generatePreviewCode])
+
+ // 사용자 목록 로드
+ React.useEffect(() => {
+ const loadUsers = async () => {
+ setIsLoadingUsers(true)
+ try {
+ const userList = await getPUsersForFilter()
+ setUsers(userList)
+ } catch (error) {
+ console.log("사용자 목록 로드 오류:", error)
+ toast.error("사용자 목록을 불러오는데 실패했습니다")
+ } finally {
+ setIsLoadingUsers(false)
+ }
+ }
+ loadUsers()
+ }, [])
+
+ // 세션 사용자 ID로 기본값 설정
+ React.useEffect(() => {
+ if (userId && !form.getValues("picUserId")) {
+ form.setValue("picUserId", userId)
+ }
+ }, [userId, form])
+
+ // 사용자 검색 필터링
+ const userOptions = React.useMemo(() => {
+ return users.filter((user) =>
+ user.name.toLowerCase().includes(userSearchTerm.toLowerCase())
+ )
+ }, [users, userSearchTerm])
+
+ // 선택된 사용자 찾기
+ const selectedUser = React.useMemo(() => {
+ const picUserId = form.watch("picUserId")
+ return users.find(user => user.id === picUserId)
+ }, [users, form.watch("picUserId")])
+
+ // 사용자 선택 핸들러
+ const handleSelectUser = (user: { id: number; name: string }) => {
+ form.setValue("picUserId", user.id)
+ setUserPopoverOpen(false)
+ setUserSearchTerm("")
+ }
+
+ // 다이얼로그 열림/닫힘 처리 및 폼 리셋
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen)
+
+ // 다이얼로그가 닫힐 때 폼과 상태 초기화
+ if (!newOpen) {
+ form.reset({
+ rfqType: "",
+ customRfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: userId || undefined,
+ remark: "",
+ items: [
+ {
+ itemCode: "",
+ itemName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setUserSearchTerm("")
+ setUserPopoverOpen(false)
+ setPreviewCode("")
+ setIsLoadingPreview(false)
+ }
+ }
+
+ const handleCancel = () => {
+ form.reset()
+ setOpen(false)
+ }
+
+ const onSubmit = async (data: CreateGeneralRfqFormValues) => {
+ if (!userId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ setIsLoading(true)
+
+ try {
+ // 견적 종류가 "기타"인 경우 customRfqType 사용
+ const finalRfqType = data.rfqType === "기타" ? data.customRfqType || "기타" : data.rfqType
+
+ // 서버 액션 호출
+ const result = await createGeneralRfqAction({
+ rfqType: finalRfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: data.dueDate,
+ picUserId: data.picUserId,
+ remark: data.remark || "",
+ items: data.items,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+
+ if (result.success) {
+ toast.success(result.message, {
+ description: `RFQ 코드: ${result.data?.rfqCode}`,
+ })
+
+ // 다이얼로그 닫기
+ setOpen(false)
+
+ // 성공 콜백 실행
+ if (onSuccess) {
+ onSuccess()
+ }
+
+ // RFQ 상세 페이지로 이동 (선택사항)
+ // router.push(`/rfq/${result.data?.rfqCode}`)
+
+ } else {
+ toast.error(result.error || "일반견적 생성에 실패했습니다")
+ }
+
+ } catch (error) {
+ console.error('일반견적 생성 오류:', error)
+ toast.error("일반견적 생성에 실패했습니다", {
+ description: "알 수 없는 오류가 발생했습니다",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 아이템 추가
+ const handleAddItem = () => {
+ append({
+ itemCode: "",
+ itemName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ })
+ }
+
+ const isCustomRfqType = form.watch("rfqType") === "기타"
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="samsung" size="sm" className="h-8 px-2 lg:px-3">
+ <Plus className="mr-2 h-4 w-4" />
+ 일반견적 생성
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col">
+ {/* 고정된 헤더 */}
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>일반견적 생성</DialogTitle>
+ <DialogDescription>
+ 새로운 일반견적을 생성합니다. 필수 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <ScrollArea className="flex-1 px-1">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
+
+ {/* 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 견적 종류 */}
+ <div className="space-y-2">
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적 종류 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={handleRfqTypeChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="견적 종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="정기견적">정기견적</SelectItem>
+ <SelectItem value="긴급견적">긴급견적</SelectItem>
+ <SelectItem value="단가계약">단가계약</SelectItem>
+ <SelectItem value="기술견적">기술견적</SelectItem>
+ <SelectItem value="예산견적">예산견적</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 기타 견적 종류 입력 필드 */}
+ {isCustomRfqType && (
+ <FormField
+ control={form.control}
+ name="customRfqType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적 종류 직접 입력 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="견적 종류를 입력해주세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </div>
+
+ {/* 마감일 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 마감일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy-MM-dd")
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 견적명 */}
+ <FormField
+ control={form.control}
+ name="rfqTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 2025년 1분기 사무용품 구매 견적"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 견적의 목적이나 내용을 간단명료하게 입력해주세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 구매 담당자 - 검색 가능한 셀렉터로 변경 */}
+ <FormField
+ control={form.control}
+ name="picUserId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 구매 담당자 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ key={buttonId}
+ type="button"
+ variant="outline"
+ className="w-full justify-between relative h-9"
+ disabled={isLoadingUsers}
+ >
+ {isLoadingUsers ? (
+ <>
+ <span>담당자 로딩 중...</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="truncate mr-1 flex-grow text-left">
+ {selectedUser ? selectedUser.name : "구매 담당자를 선택하세요"}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent key={popoverContentId} className="w-[300px] p-0">
+ <Command key={commandId}>
+ <CommandInput
+ key={`${commandId}-input`}
+ placeholder="담당자 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList
+ key={`${commandId}-list`}
+ className="max-h-[300px]"
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
+ <CommandEmpty key={`${commandId}-empty`}>담당자를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup key={`${commandId}-group`}>
+ {userOptions.map((user, userIndex) => {
+ if (!userOptionIdsRef.current[user.id]) {
+ userOptionIdsRef.current[user.id] =
+ `user-${user.id}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`
+ }
+ const optionId = userOptionIdsRef.current[user.id]
+
+ return (
+ <CommandItem
+ key={`${optionId}-${userIndex}`}
+ onSelect={() => handleSelectUser(user)}
+ value={user.name}
+ className="truncate"
+ title={user.name}
+ >
+ <span className="truncate">{user.name}</span>
+ <Check
+ key={`${optionId}-check`}
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ selectedUser?.id === user.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ {/* RFQ 코드 미리보기 */}
+ {previewCode && (
+ <div className="flex items-center gap-2 mt-2">
+ <Badge variant="secondary" className="font-mono text-sm">
+ 예상 RFQ 코드: {previewCode}
+ </Badge>
+ {isLoadingPreview && (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ )}
+ </div>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 비고사항을 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 아이템 정보 섹션 - 컴팩트한 UI */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">아이템 정보</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleAddItem}
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ 아이템 추가
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ {fields.map((field, index) => (
+ <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50">
+ <div className="flex items-center justify-between mb-3">
+ <span className="text-sm font-medium text-gray-700">
+ 아이템 #{index + 1}
+ </span>
+ {fields.length > 1 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => remove(index)}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+
+ <div className="grid grid-cols-4 gap-3">
+ {/* 자재코드 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.itemCode`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 자재코드 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="MAT-001"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재명 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.itemName`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 자재명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="A4 용지"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 수량 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 수량 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ placeholder="1"
+ className="h-8 text-sm"
+ {...field}
+ onChange={(e) => field.onChange(Number(e.target.value))}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단위 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.uom`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 단위 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="EA"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 비고 - 별도 행에 배치 */}
+ <div className="mt-3">
+ <FormField
+ control={form.control}
+ name={`items.${index}.remark`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">비고</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="아이템별 비고사항"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </ScrollArea>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "생성 중..." : "일반견적 생성"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-attachments-dialog.tsx b/lib/rfq-last/table/rfq-attachments-dialog.tsx
new file mode 100644
index 00000000..253daaa2
--- /dev/null
+++ b/lib/rfq-last/table/rfq-attachments-dialog.tsx
@@ -0,0 +1,351 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { Download, FileText, Eye, ExternalLink, Loader2 } from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Skeleton } from "@/components/ui/skeleton"
+import { toast } from "sonner"
+import { RfqsLastView } from "@/db/schema"
+import { getRfqAttachmentsAction } from "../service"
+import { downloadFile, quickPreview, smartFileAction, formatFileSize, getFileInfo } from "@/lib/file-download"
+
+// 첨부파일 타입
+interface RfqAttachment {
+ attachmentId: number
+ attachmentType: string
+ serialNo: string
+ description: string | null
+ currentRevision: string
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize: number | null
+ fileType: string | null
+ createdByName: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+ revisionComment?: string | null
+}
+
+interface RfqAttachmentsDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ rfqData: RfqsLastView
+}
+
+export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachmentsDialogProps) {
+ const [attachments, setAttachments] = React.useState<RfqAttachment[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [downloadingFiles, setDownloadingFiles] = React.useState<Set<number>>(new Set())
+
+ // 첨부파일 목록 로드
+ React.useEffect(() => {
+ if (!isOpen || !rfqData.id) return
+
+ const loadAttachments = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getRfqAttachmentsAction(rfqData.id)
+
+ if (result.success) {
+ setAttachments(result.data)
+ } else {
+ toast.error(result.error || "첨부파일을 불러오는데 실패했습니다")
+ setAttachments([])
+ }
+ } catch (error) {
+ console.error("첨부파일 로드 오류:", error)
+ toast.error("첨부파일을 불러오는데 실패했습니다")
+ setAttachments([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadAttachments()
+ }, [isOpen, rfqData.id])
+
+ // 파일 다운로드 핸들러
+ const handleDownload = async (attachment: RfqAttachment) => {
+ const attachmentId = attachment.attachmentId
+ setDownloadingFiles(prev => new Set([...prev, attachmentId]))
+
+ try {
+ const result = await downloadFile(
+ attachment.filePath,
+ attachment.originalFileName,
+ {
+ action: 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 완료: ${fileName} (${formatFileSize(fileSize || 0)})`)
+ },
+ onError: (error) => {
+ console.error(`다운로드 실패: ${error}`)
+ }
+ }
+ )
+
+ if (!result.success) {
+ console.error("다운로드 결과:", result)
+ }
+ } catch (error) {
+ console.error("파일 다운로드 오류:", error)
+ toast.error("파일 다운로드에 실패했습니다")
+ } finally {
+ setDownloadingFiles(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(attachmentId)
+ return newSet
+ })
+ }
+ }
+
+ // 파일 미리보기 핸들러
+ const handlePreview = async (attachment: RfqAttachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+
+ if (!fileInfo.canPreview) {
+ toast.info("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다.")
+ return handleDownload(attachment)
+ }
+
+ try {
+ const result = await quickPreview(attachment.filePath, attachment.originalFileName)
+
+ if (!result.success) {
+ console.error("미리보기 결과:", result)
+ }
+ } catch (error) {
+ console.error("파일 미리보기 오류:", error)
+ toast.error("파일 미리보기에 실패했습니다")
+ }
+ }
+
+ // 스마트 파일 액션 (미리보기 가능하면 미리보기, 아니면 다운로드)
+ const handleSmartAction = async (attachment: RfqAttachment) => {
+ const attachmentId = attachment.attachmentId
+ const fileInfo = getFileInfo(attachment.originalFileName)
+
+ if (fileInfo.canPreview) {
+ return handlePreview(attachment)
+ } else {
+ return handleDownload(attachment)
+ }
+ }
+
+ // 첨부파일 타입별 색상
+ const getAttachmentTypeBadgeVariant = (type: string) => {
+ switch (type.toLowerCase()) {
+ case "견적요청서": return "default"
+ case "기술사양서": return "secondary"
+ case "도면": return "outline"
+ default: return "outline"
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="max-w-6xl h-[85vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>견적 첨부파일</DialogTitle>
+ <DialogDescription>
+ {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "견적"}
+ {attachments.length > 0 && ` (${attachments.length}개 파일)`}
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="flex-1">
+ {isLoading ? (
+ <div className="space-y-3">
+ {[...Array(3)].map((_, i) => (
+ <div key={i} className="flex items-center space-x-4 p-3 border rounded-lg">
+ <Skeleton className="h-8 w-8" />
+ <div className="space-y-2 flex-1">
+ <Skeleton className="h-4 w-[300px]" />
+ <Skeleton className="h-3 w-[200px]" />
+ </div>
+ <div className="flex gap-2">
+ <Skeleton className="h-8 w-20" />
+ <Skeleton className="h-8 w-20" />
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[120px]">타입</TableHead>
+ <TableHead>파일명</TableHead>
+ <TableHead>설명</TableHead>
+ <TableHead className="w-[90px]">리비전</TableHead>
+ <TableHead className="w-[100px]">크기</TableHead>
+ <TableHead className="w-[120px]">생성자</TableHead>
+ <TableHead className="w-[120px]">생성일</TableHead>
+ <TableHead className="w-[140px]">액션</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {attachments.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={8} className="text-center text-muted-foreground py-12">
+ <div className="flex flex-col items-center gap-2">
+ <FileText className="h-8 w-8 text-muted-foreground" />
+ <span>첨부된 파일이 없습니다.</span>
+ </div>
+ </TableCell>
+ </TableRow>
+ ) : (
+ attachments.map((attachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+ const isDownloading = downloadingFiles.has(attachment.attachmentId)
+
+ return (
+ <TableRow key={attachment.attachmentId}>
+ <TableCell>
+ <Badge
+ variant={getAttachmentTypeBadgeVariant(attachment.attachmentType)}
+ className="text-xs"
+ >
+ {attachment.attachmentType}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <span className="text-lg">{fileInfo.icon}</span>
+ <div className="flex flex-col min-w-0">
+ <span className="text-sm font-medium truncate" title={attachment.originalFileName}>
+ {attachment.originalFileName}
+ </span>
+ {attachment.fileName !== attachment.originalFileName && (
+ <span className="text-xs text-muted-foreground truncate" title={attachment.fileName}>
+ {attachment.fileName}
+ </span>
+ )}
+ </div>
+ </div>
+ </TableCell>
+ <TableCell>
+ <span className="text-sm" title={attachment.description || ""}>
+ {attachment.description || "-"}
+ </span>
+ {attachment.revisionComment && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {attachment.revisionComment}
+ </div>
+ )}
+ </TableCell>
+ <TableCell>
+ <Badge variant="secondary" className="font-mono text-xs">
+ {attachment.currentRevision}
+ </Badge>
+ </TableCell>
+ <TableCell className="text-xs text-muted-foreground">
+ {attachment.fileSize ? formatFileSize(attachment.fileSize) : "-"}
+ </TableCell>
+ <TableCell className="text-sm">
+ {attachment.createdByName || "-"}
+ </TableCell>
+ <TableCell className="text-xs text-muted-foreground">
+ {attachment.createdAt ? format(new Date(attachment.createdAt), "MM-dd HH:mm") : "-"}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ {/* 미리보기 버튼 (미리보기 가능한 파일만) */}
+ {fileInfo.canPreview && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePreview(attachment)}
+ disabled={isDownloading}
+ title="미리보기"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ )}
+
+ {/* 다운로드 버튼 */}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownload(attachment)}
+ disabled={isDownloading}
+ title="다운로드"
+ >
+ {isDownloading ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Download className="h-4 w-4" />
+ )}
+ </Button>
+
+ {/* 스마트 액션 버튼 (메인 액션) */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleSmartAction(attachment)}
+ disabled={isDownloading}
+ className="ml-1"
+ >
+ {isDownloading ? (
+ <Loader2 className="h-4 w-4 animate-spin mr-1" />
+ ) : fileInfo.canPreview ? (
+ <Eye className="h-4 w-4 mr-1" />
+ ) : (
+ <Download className="h-4 w-4 mr-1" />
+ )}
+ {fileInfo.canPreview ? "보기" : "다운로드"}
+ </Button>
+ </div>
+ </TableCell>
+ </TableRow>
+ )
+ })
+ )}
+ </TableBody>
+ </Table>
+ )}
+ </ScrollArea>
+
+ {/* 하단 정보 */}
+ {attachments.length > 0 && !isLoading && (
+ <div className="border-t pt-4 text-xs text-muted-foreground">
+ <div className="flex justify-between items-center">
+ <span>
+ 총 {attachments.length}개 파일
+ {attachments.some(a => a.fileSize) &&
+ ` · 전체 크기: ${formatFileSize(
+ attachments.reduce((sum, a) => sum + (a.fileSize || 0), 0)
+ )}`
+ }
+ </span>
+ <span>
+ {fileInfo.icon} 미리보기 가능 | 📥 다운로드
+ </span>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx
new file mode 100644
index 00000000..5d7e0747
--- /dev/null
+++ b/lib/rfq-last/table/rfq-items-dialog.tsx
@@ -0,0 +1,354 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { Package, ExternalLink } from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Separator } from "@/components/ui/separator"
+import { toast } from "sonner"
+import { RfqsLastView } from "@/db/schema"
+import { getRfqItemsAction } from "../service"
+
+// 품목 타입
+interface RfqItem {
+ id: number
+ rfqsLastId: number | null
+ rfqItem: string | null
+ prItem: string | null
+ prNo: string | null
+ materialCode: string | null
+ materialCategory: string | null
+ acc: string | null
+ materialDescription: string | null
+ size: string | null
+ deliveryDate: Date | null
+ quantity: number | null
+ uom: string | null
+ grossWeight: number | null
+ gwUom: string | null
+ specNo: string | null
+ specUrl: string | null
+ trackingNo: string | null
+ majorYn: boolean | null
+ remark: string | null
+ projectDef: string | null
+ projectSc: string | null
+ projectKl: string | null
+ projectLc: string | null
+ projectDl: string | null
+ // RFQ 관련 정보
+ rfqCode: string | null
+ rfqType: string | null
+ rfqTitle: string | null
+ itemCode: string | null
+ itemName: string | null
+ projectCode: string | null
+ projectName: string | null
+}
+
+interface ItemStatistics {
+ total: number
+ major: number
+ regular: number
+ totalQuantity: number
+ totalWeight: number
+}
+
+interface RfqItemsDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ rfqData: RfqsLastView
+}
+
+export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) {
+ const [items, setItems] = React.useState<RfqItem[]>([])
+ const [statistics, setStatistics] = React.useState<ItemStatistics | null>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 품목 목록 로드
+ React.useEffect(() => {
+ if (!isOpen || !rfqData.id) return
+
+ const loadItems = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getRfqItemsAction(rfqData.id)
+
+ if (result.success) {
+ setItems(result.data)
+ setStatistics(result.statistics)
+ } else {
+ toast.error(result.error || "품목을 불러오는데 실패했습니다")
+ setItems([])
+ setStatistics(null)
+ }
+ } catch (error) {
+ console.error("품목 로드 오류:", error)
+ toast.error("품목을 불러오는데 실패했습니다")
+ setItems([])
+ setStatistics(null)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadItems()
+ }, [isOpen, rfqData.id])
+
+ // 사양서 링크 열기
+ const handleOpenSpec = (specUrl: string) => {
+ window.open(specUrl, '_blank', 'noopener,noreferrer')
+ }
+
+ // 수량 포맷팅
+ const formatQuantity = (quantity: number | null, uom: string | null) => {
+ if (!quantity) return "-"
+ return `${quantity.toLocaleString()}${uom ? ` ${uom}` : ""}`
+ }
+
+ // 중량 포맷팅
+ const formatWeight = (weight: number | null, uom: string | null) => {
+ if (!weight) return "-"
+ return `${weight.toLocaleString()} ${uom || "KG"}`
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>견적 품목 목록</DialogTitle>
+ <DialogDescription>
+ {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"}
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 통계 정보 */}
+ {statistics && !isLoading && (
+ <>
+ <div className="grid grid-cols-5 gap-3 py-3">
+ <div className="text-center">
+ <div className="text-2xl font-bold text-primary">{statistics.total}</div>
+ <div className="text-xs text-muted-foreground">전체 품목</div>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-blue-600">{statistics.major}</div>
+ <div className="text-xs text-muted-foreground">주요 품목</div>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div>
+ <div className="text-xs text-muted-foreground">일반 품목</div>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-green-600">{statistics.totalQuantity.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground">총 수량</div>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-orange-600">{statistics.totalWeight.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground">총 중량 (KG)</div>
+ </div>
+ </div>
+ <Separator />
+ </>
+ )}
+
+ <ScrollArea className="flex-1">
+ {isLoading ? (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[60px]">구분</TableHead>
+ <TableHead className="w-[120px]">자재코드</TableHead>
+ <TableHead>자재명</TableHead>
+ <TableHead className="w-[100px]">수량</TableHead>
+ <TableHead className="w-[100px]">중량</TableHead>
+ <TableHead className="w-[100px]">납기일</TableHead>
+ <TableHead className="w-[100px]">PR번호</TableHead>
+ <TableHead className="w-[80px]">사양</TableHead>
+ <TableHead>비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {[...Array(3)].map((_, i) => (
+ <TableRow key={i}>
+ <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>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ ) : items.length === 0 ? (
+ <div className="text-center text-muted-foreground py-12">
+ <Package className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
+ <p>품목이 없습니다.</p>
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[60px]">구분</TableHead>
+ <TableHead className="w-[120px]">자재코드</TableHead>
+ <TableHead>자재명</TableHead>
+ <TableHead className="w-[100px]">수량</TableHead>
+ <TableHead className="w-[100px]">중량</TableHead>
+ <TableHead className="w-[100px]">납기일</TableHead>
+ <TableHead className="w-[100px]">PR번호</TableHead>
+ <TableHead className="w-[100px]">사양</TableHead>
+ <TableHead className="w-[100px]">프로젝트</TableHead>
+ <TableHead>비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {items.map((item, index) => (
+ <TableRow key={item.id} className={item.majorYn ? "bg-blue-50 border-l-4 border-l-blue-500" : ""}>
+ <TableCell>
+ <div className="flex flex-col items-center gap-1">
+ <span className="text-xs font-mono">#{index + 1}</span>
+ {item.majorYn && (
+ <Badge variant="default" className="text-xs px-1 py-0">
+ 주요
+ </Badge>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex flex-col">
+ <span className="font-mono text-sm font-medium">{item.materialCode || "-"}</span>
+ {item.acc && (
+ <span className="text-xs text-muted-foreground font-mono">
+ ACC: {item.acc}
+ </span>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex flex-col">
+ <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 font-medium">
+ {formatQuantity(item.quantity, item.uom)}
+ </span>
+ </TableCell>
+ <TableCell>
+ <span className="text-sm">
+ {formatWeight(item.grossWeight, item.gwUom)}
+ </span>
+ </TableCell>
+ <TableCell>
+ <span className="text-sm">
+ {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"}
+ </span>
+ </TableCell>
+ <TableCell>
+ <div className="flex flex-col">
+ <span className="text-xs font-mono">{item.prNo || "-"}</span>
+ {item.prItem && item.prItem !== item.prNo && (
+ <span className="text-xs text-muted-foreground font-mono">
+ {item.prItem}
+ </span>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ {item.specNo && (
+ <span className="text-xs font-mono">{item.specNo}</span>
+ )}
+ {item.specUrl && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-5 w-5 p-0"
+ onClick={() => handleOpenSpec(item.specUrl!)}
+ title="사양서 열기"
+ >
+ <ExternalLink className="h-3 w-3" />
+ </Button>
+ )}
+ {item.trackingNo && (
+ <div className="text-xs text-muted-foreground">
+ TRK: {item.trackingNo}
+ </div>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-xs">
+ {[
+ item.projectDef && `DEF: ${item.projectDef}`,
+ item.projectSc && `SC: ${item.projectSc}`,
+ item.projectKl && `KL: ${item.projectKl}`,
+ item.projectLc && `LC: ${item.projectLc}`,
+ item.projectDl && `DL: ${item.projectDl}`
+ ].filter(Boolean).join(" | ") || "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <span className="text-xs" title={item.remark || ""}>
+ {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"}
+ </span>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </ScrollArea>
+
+ {/* 하단 통계 정보 */}
+ {statistics && !isLoading && (
+ <div className="border-t pt-3 text-xs text-muted-foreground">
+ <div className="flex justify-between items-center">
+ <span>
+ 총 {statistics.total}개 품목
+ (주요: {statistics.major}개, 일반: {statistics.regular}개)
+ </span>
+ <span>
+ 전체 수량: {statistics.totalQuantity.toLocaleString()} |
+ 전체 중량: {statistics.totalWeight.toLocaleString()} KG
+ </span>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx
index 3fac8881..1b523adc 100644
--- a/lib/rfq-last/table/rfq-table-columns.tsx
+++ b/lib/rfq-last/table/rfq-table-columns.tsx
@@ -5,7 +5,7 @@ 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 { Eye, FileText, Send, Package, Users, ChevronRight } from "lucide-react";
+import { Eye, FileText, Send, Lock, LockOpen } from "lucide-react";
import {
Tooltip,
TooltipContent,
@@ -17,10 +17,14 @@ import { RfqsLastView } from "@/db/schema";
import { DataTableRowAction } from "@/types/table";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
+import { useRouter } from "next/navigation";
+
+type NextRouter = ReturnType<typeof useRouter>;
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqsLastView> | null>>;
- rfqCategory?: "all" | "general" | "itb" | "rfq";
+ rfqCategory?: "general" | "itb" | "rfq";
+ router: NextRouter;
}
// RFQ 상태별 색상
@@ -38,408 +42,914 @@ const getStatusBadgeVariant = (status: string) => {
}
};
-// 시리즈 배지
-const getSeriesBadge = (series: string | null) => {
- if (!series) return null;
-
- const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series;
- const variant = series === "SS" ? "default" : series === "II" ? "secondary" : "outline";
-
- return <Badge variant={variant} className="text-xs">{label}</Badge>;
-};
-
export function getRfqColumns({
setRowAction,
- rfqCategory = "all"
+ rfqCategory = "itb",
+ router
}: GetColumnsProps): ColumnDef<RfqsLastView>[] {
-
- const baseColumns: ColumnDef<RfqsLastView>[] = [
- // ═══════════════════════════════════════════════════════════════
- // 선택 및 기본 정보
- // ═══════════════════════════════════════════════════════════════
-
- // Checkbox
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
- onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
- aria-label="select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(v) => row.toggleSelected(!!v)}
- aria-label="select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- },
-
- // RFQ 코드
- {
- accessorKey: "rfqCode",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />,
- cell: ({ row }) => {
- const rfqSealed = row.original.rfqSealedYn;
- return (
- <div className="flex items-center gap-1">
- <span className="font-mono font-medium">{row.original.rfqCode}</span>
- {rfqSealed && (
- <Badge variant="destructive" className="text-xs">봉인</Badge>
- )}
- </div>
- );
- },
- size: 140,
- },
-
- // 상태
- {
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />,
- cell: ({ row }) => (
- <Badge variant={getStatusBadgeVariant(row.original.status)}>
- {row.original.status}
- </Badge>
- ),
- size: 120,
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 일반견적 필드 (rfqCategory가 'general' 또는 'all'일 때만)
- // ═══════════════════════════════════════════════════════════════
- ...(rfqCategory === "general" || rfqCategory === "all" ? [
- {
- id: "rfqType",
- accessorKey: "rfqType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 유형" />,
- cell: ({ row }) => row.original.rfqType || "-",
+
+ // ═══════════════════════════════════════════════════════════════
+ // ITB 컬럼 정의
+ // ═══════════════════════════════════════════════════════════════
+ if (rfqCategory === "itb") {
+ return [
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ITB No.
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="ITB No." />,
+ cell: ({ row }) => (
+ <span className="font-mono font-medium">{row.original.rfqCode}</span>
+ ),
+ size: 120,
+ },
+
+ // 상세 (액션 버튼) - 수정됨
+ {
+ id: "detail",
+ header: "상세",
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)}
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ ),
+ size: 60,
+ },
+
+ // 견적상태
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {row.original.status}
+ </Badge>
+ ),
+ size: 120,
+ },
+
+ // 견적 밀봉
+ {
+ accessorKey: "rfqSealedYn",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
+ cell: ({ row }) => {
+ const isSealed = row.original.rfqSealedYn;
+ return (
+ <div className="flex justify-center">
+ {isSealed ? (
+ <Lock className="h-4 w-4 text-red-500" />
+ ) : (
+ <LockOpen className="h-4 w-4 text-gray-400" />
+ )}
+ </div>
+ );
+ },
+ size: 80,
+ },
+
+ // 구매담당자
+ {
+ accessorKey: "picUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />,
+ cell: ({ row }) => row.original.picUserName || row.original.picName || "-",
size: 100,
},
+
+ // 프로젝트 (프로젝트명)
{
- id: "rfqTitle",
- accessorKey: "rfqTitle",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 제목" />,
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />,
cell: ({ row }) => (
- <div className="max-w-[200px] truncate" title={row.original.rfqTitle || ""}>
- {row.original.rfqTitle || "-"}
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.projectCode}
+ </span>
+ <span className="max-w-[200px] truncate" title={row.original.projectName || ""}>
+ {row.original.projectName || "-"}
+ </span>
</div>
),
- size: 200,
+ size: 220,
+ },
+
+ // 선급
+ {
+ accessorKey: "classNo",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />,
+ cell: ({ row }) => row.original.classNo || "-",
+ size: 80,
+ },
+
+ // PKG No. (PKG명)
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PKG No. (PKG명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.packageNo}
+ </span>
+ <span className="max-w-[150px] truncate" title={row.original.packageName || ""}>
+ {row.original.packageName || "-"}
+ </span>
+ </div>
+ ),
+ size: 180,
+ },
+
+ // 자재그룹 (자재그룹명)
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.itemCode}
+ </span>
+ <span className="max-w-[150px] truncate" title={row.original.itemName || ""}>
+ {row.original.itemName || "-"}
+ </span>
+ </div>
+ ),
+ size: 180,
+ },
+
+ // SM Code
+ {
+ accessorKey: "smCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="SM Code" />,
+ cell: ({ row }) => row.original.smCode || "-",
+ size: 80,
+ },
+
+ // 견적문서 - 수정됨
+ {
+ id: "rfqDocument",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적문서" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setRowAction({ row, type: "attachment" })}
+ >
+ <FileText className="h-4 w-4" />
+ </Button>
+ ),
+ size: 80,
+ },
+
+ // 견적생성일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />,
+ cell: ({ row }) => {
+ const date = row.original.createdAt;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
+ },
+ size: 100,
+ },
+
+ // 견적발송일
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />,
+ cell: ({ row }) => {
+ const date = row.original.rfqSendDate;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
+ },
+ size: 100,
+ },
+
+ // 견적마감일
+ {
+ 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 isOverdue = now > dueDate;
+
+ return (
+ <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ {format(dueDate, "yyyy-MM-dd")}
+ </span>
+ );
+ },
+ size: 100,
+ },
+
+ // 설계담당자
+ {
+ accessorKey: "engPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />,
+ cell: ({ row }) => row.original.engPicName || "-",
+ size: 100,
},
- ] as ColumnDef<RfqsLastView>[] : []),
- // ═══════════════════════════════════════════════════════════════
- // ITB 필드 (rfqCategory가 'itb' 또는 'all'일 때만)
- // ═══════════════════════════════════════════════════════════════
- ...(rfqCategory === "itb" || rfqCategory === "all" ? [
+ // 프로젝트 Company
{
- id: "projectCompany",
accessorKey: "projectCompany",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 회사" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Company" />,
cell: ({ row }) => row.original.projectCompany || "-",
size: 120,
},
+
+ // TBE 결과접수
+ {
+ accessorKey: "tbeResultReceived",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="TBE 결과접수" />,
+ cell: ({ row }) => {
+ const received = row.original.quotationReceivedCount || 0;
+ const total = row.original.vendorCount || 0;
+ return `${received}/${total}`;
+ },
+ size: 100,
+ },
+
+ // 프로젝트 Flag
{
- id: "projectFlag",
accessorKey: "projectFlag",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 플래그" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Flag" />,
cell: ({ row }) => row.original.projectFlag || "-",
size: 100,
},
+
+ // 프로젝트 Site
{
- id: "projectSite",
accessorKey: "projectSite",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 사이트" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 Site" />,
cell: ({ row }) => row.original.projectSite || "-",
+ size: 100,
+ },
+
+ // 최종수정일
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
+ cell: ({ row }) => {
+ const date = row.original.updatedAt;
+ return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-";
+ },
size: 120,
},
+
+ // 최종수정자
{
- id: "smCode",
- accessorKey: "smCode",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="SM 코드" />,
- cell: ({ row }) => row.original.smCode || "-",
- size: 80,
+ accessorKey: "updatedByUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
+ cell: ({ row }) => row.original.updatedByUserName || "-",
+ size: 100,
},
- ] as ColumnDef<RfqsLastView>[] : []),
- // ═══════════════════════════════════════════════════════════════
- // RFQ(PR) 필드 (rfqCategory가 'rfq' 또는 'all'일 때만)
- // ═══════════════════════════════════════════════════════════════
- ...(rfqCategory === "rfq" || rfqCategory === "all" ? [
+ // 비고
{
- id: "prNumber",
- accessorKey: "prNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 번호" />,
+ accessorKey: "remark",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />,
+ cell: ({ row }) => row.original.remark || "-",
+ size: 150,
+ },
+ ];
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // RFQ 컬럼 정의
+ // ═══════════════════════════════════════════════════════════════
+ if (rfqCategory === "rfq") {
+ return [
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.prNumber || "-"}</span>
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // RFQ No.
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RFQ No." />,
+ cell: ({ row }) => (
+ <span className="font-mono font-medium">{row.original.rfqCode}</span>
),
size: 120,
},
+
+ // 상세 - 수정됨
{
- id: "prIssueDate",
- accessorKey: "prIssueDate",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 발행일" />,
+ id: "detail",
+ header: "상세",
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)}
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ ),
+ size: 60,
+ },
+
+ // 견적상태
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {row.original.status}
+ </Badge>
+ ),
+ size: 120,
+ },
+
+ // 견적 밀봉
+ {
+ accessorKey: "rfqSealedYn",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
cell: ({ row }) => {
- const date = row.original.prIssueDate;
- return date ? format(new Date(date), "yyyy-MM-dd") : "-";
+ const isSealed = row.original.rfqSealedYn;
+ return (
+ <div className="flex justify-center">
+ {isSealed ? (
+ <Lock className="h-4 w-4 text-red-500" />
+ ) : (
+ <LockOpen className="h-4 w-4 text-gray-400" />
+ )}
+ </div>
+ );
},
+ size: 80,
+ },
+
+ // 구매담당자
+ {
+ accessorKey: "picUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />,
+ cell: ({ row }) => row.original.picUserName || row.original.picName || "-",
size: 100,
},
+
+ // 프로젝트 (프로젝트명)
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.projectCode}
+ </span>
+ <span className="max-w-[200px] truncate" title={row.original.projectName || ""}>
+ {row.original.projectName || "-"}
+ </span>
+ </div>
+ ),
+ size: 220,
+ },
+
+ // 시리즈
{
- id: "series",
accessorKey: "series",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />,
- cell: ({ row }) => getSeriesBadge(row.original.series),
+ 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,
},
- ] as ColumnDef<RfqsLastView>[] : []),
-
- // ═══════════════════════════════════════════════════════════════
- // 공통 프로젝트 정보
- // ═══════════════════════════════════════════════════════════════
- {
- header: "프로젝트 정보",
- columns: [
- {
- accessorKey: "projectCode",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.projectCode || "-"}</span>
- ),
- size: 120,
- },
- {
- accessorKey: "projectName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
- cell: ({ row }) => (
- <div className="max-w-[200px] truncate" title={row.original.projectName || ""}>
- {row.original.projectName || "-"}
- </div>
- ),
- size: 200,
- },
- ]
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 품목 정보
- // ═══════════════════════════════════════════════════════════════
- {
- header: "품목 정보",
- columns: [
- {
- accessorKey: "itemCode",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.itemCode || "-"}</span>
- ),
- size: 100,
- },
- {
- accessorKey: "itemName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재명" />,
- cell: ({ row }) => (
- <div className="max-w-[200px] truncate" title={row.original.itemName || ""}>
+
+ // 선급
+ {
+ accessorKey: "classNo",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />,
+ cell: ({ row }) => row.original.classNo || "-",
+ size: 80,
+ },
+
+ // PKG No. (PKG명)
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PKG No. (PKG명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.packageNo}
+ </span>
+ <span className="max-w-[150px] truncate" title={row.original.packageName || ""}>
+ {row.original.packageName || "-"}
+ </span>
+ </div>
+ ),
+ size: 180,
+ },
+
+ // 자재그룹 (자재그룹명)
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.itemCode}
+ </span>
+ <span className="max-w-[150px] truncate" title={row.original.itemName || ""}>
{row.original.itemName || "-"}
- </div>
- ),
- size: 200,
- },
- {
- accessorKey: "packageNo",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="패키지 번호" />,
- cell: ({ row }) => row.original.packageNo || "-",
- size: 100,
+ </span>
+ </div>
+ ),
+ size: 180,
+ },
+
+ // 자재코드
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.itemCode || "-"}</span>
+ ),
+ size: 100,
+ },
+
+ // 견적문서 - 수정됨
+ {
+ id: "rfqDocument",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적문서" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setRowAction({ row, type: "attachment" })}
+ >
+ <FileText className="h-4 w-4" />
+ </Button>
+ ),
+ size: 80,
+ },
+
+ // PR건수 - 수정됨
+ {
+ accessorKey: "prItemsCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR건수" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="font-mono text-sm p-1 h-auto"
+ onClick={() => setRowAction({ row, type: "items" })}
+ >
+ {row.original.prItemsCount || 0}
+ </Button>
+ ),
+ size: 80,
+ },
+
+ // 견적생성일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />,
+ cell: ({ row }) => {
+ const date = row.original.createdAt;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
},
- {
- accessorKey: "packageName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="패키지명" />,
- cell: ({ row }) => (
- <div className="max-w-[200px] truncate" title={row.original.packageName || ""}>
- {row.original.packageName || "-"}
- </div>
- ),
- size: 200,
+ size: 100,
+ },
+
+ // 견적발송일
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />,
+ cell: ({ row }) => {
+ const date = row.original.rfqSendDate;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
},
- ]
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 담당자 정보
- // ═══════════════════════════════════════════════════════════════
- {
- header: "담당자",
- columns: [
- {
- accessorKey: "picUserName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />,
- cell: ({ row }) => row.original.picUserName || row.original.picName || "-",
- size: 100,
+ size: 100,
+ },
+
+ // 견적마감일
+ {
+ 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 isOverdue = now > dueDate;
+
+ return (
+ <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ {format(dueDate, "yyyy-MM-dd")}
+ </span>
+ );
},
- {
- accessorKey: "engPicName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="엔지니어링 담당" />,
- cell: ({ row }) => row.original.engPicName || "-",
- size: 120,
+ size: 100,
+ },
+
+ // 설계담당자
+ {
+ accessorKey: "engPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />,
+ cell: ({ row }) => row.original.engPicName || "-",
+ size: 100,
+ },
+
+ // TBE 결과접수
+ {
+ accessorKey: "tbeResultReceived",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="TBE 결과접수" />,
+ cell: ({ row }) => {
+ const received = row.original.quotationReceivedCount || 0;
+ const total = row.original.vendorCount || 0;
+ return `${received}/${total}`;
},
- ]
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 일정 정보
- // ═══════════════════════════════════════════════════════════════
- {
- header: "일정",
- columns: [
- {
- accessorKey: "rfqSendDate",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발송일" />,
- cell: ({ row }) => {
- const date = row.original.rfqSendDate;
- return date ? (
- <div className="flex items-center gap-1">
- <Send className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {format(new Date(date), "MM-dd", { locale: ko })}
- </span>
- </div>
- ) : "-";
- },
- size: 90,
+ size: 100,
+ },
+
+ // 대표 PR No.
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="대표 PR No." />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || "-"}</span>
+ ),
+ size: 120,
+ },
+
+ // PR발행일
+ {
+ accessorKey: "prIssueDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR발행일" />,
+ cell: ({ row }) => {
+ const date = row.original.prIssueDate;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
},
- {
- 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 isOverdue = now > dueDate;
-
- return (
- <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
- {format(dueDate, "MM-dd", { locale: ko })}
- </span>
- );
- },
- size: 90,
+ size: 100,
+ },
+
+ // 최종수정일
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
+ cell: ({ row }) => {
+ const date = row.original.updatedAt;
+ return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-";
},
- ]
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 벤더 및 견적 현황
- // ═══════════════════════════════════════════════════════════════
- {
- header: "견적 현황",
- columns: [
- {
- accessorKey: "vendorCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업체수" />,
- cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <Users className="h-3 w-3 text-muted-foreground" />
- <span className="font-medium">{row.original.vendorCount || 0}</span>
+ size: 120,
+ },
+
+ // 최종수정자
+ {
+ accessorKey: "updatedByUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
+ cell: ({ row }) => row.original.updatedByUserName || "-",
+ size: 100,
+ },
+
+ // 비고
+ {
+ accessorKey: "remark",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />,
+ cell: ({ row }) => row.original.remark || "-",
+ size: 150,
+ },
+ ];
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // 일반견적 컬럼 정의
+ // ═══════════════════════════════════════════════════════════════
+ if (rfqCategory === "general") {
+ return [
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // 견적 No.
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 No." />,
+ cell: ({ row }) => (
+ <span className="font-mono font-medium">{row.original.rfqCode}</span>
+ ),
+ size: 120,
+ },
+
+ // 상세 - 수정됨
+ {
+ id: "detail",
+ header: "상세",
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)}
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ ),
+ size: 60,
+ },
+
+ // 견적상태
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {row.original.status}
+ </Badge>
+ ),
+ size: 120,
+ },
+
+ // 견적 밀봉
+ {
+ accessorKey: "rfqSealedYn",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
+ cell: ({ row }) => {
+ const isSealed = row.original.rfqSealedYn;
+ return (
+ <div className="flex justify-center">
+ {isSealed ? (
+ <Lock className="h-4 w-4 text-red-500" />
+ ) : (
+ <LockOpen className="h-4 w-4 text-gray-400" />
+ )}
</div>
- ),
- size: 80,
- },
- {
- accessorKey: "shortListedVendorCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Short List" />,
- cell: ({ row }) => {
- const count = row.original.shortListedVendorCount || 0;
- return count > 0 ? (
- <Badge variant="default" className="font-mono">
- {count}
- </Badge>
- ) : "-";
- },
- size: 90,
+ );
},
- {
- accessorKey: "quotationReceivedCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적접수" />,
- cell: ({ row }) => {
- const received = row.original.quotationReceivedCount || 0;
- const total = row.original.vendorCount || 0;
-
- return (
- <div className="flex items-center gap-1">
- <FileText className="h-3 w-3 text-muted-foreground" />
- <span className={`text-sm ${received === total && total > 0 ? "text-green-600 font-medium" : ""}`}>
- {received}/{total}
- </span>
- </div>
- );
- },
- size: 90,
+ size: 80,
+ },
+
+ // 견적종류
+ {
+ accessorKey: "rfqType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적종류" />,
+ cell: ({ row }) => row.original.rfqType || "-",
+ size: 100,
+ },
+
+ // 프로젝트 (프로젝트명)
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.projectCode}
+ </span>
+ <span className="max-w-[200px] truncate" title={row.original.projectName || ""}>
+ {row.original.projectName || "-"}
+ </span>
+ </div>
+ ),
+ size: 220,
+ },
+
+ // 시리즈
+ {
+ 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>;
},
- ]
- },
-
- // PR Items 정보
- {
- accessorKey: "prItemsCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR Items" />,
- cell: ({ row }) => {
- const prItems = row.original.prItemsCount || 0;
- const majorItems = row.original.majorItemsCount || 0;
-
- return (
- <div className="flex flex-col gap-0.5">
- <span className="text-sm font-medium">{prItems}개</span>
- {majorItems > 0 && (
- <Badge variant="secondary" className="text-xs">
- 주요 {majorItems}
- </Badge>
- )}
+ size: 100,
+ },
+
+ // 선급
+ {
+ accessorKey: "classNo",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />,
+ cell: ({ row }) => row.original.classNo || "-",
+ size: 80,
+ },
+
+ // 견적명
+ {
+ accessorKey: "rfqTitle",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적명" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.rfqTitle || ""}>
+ {row.original.rfqTitle || "-"}
</div>
- );
- },
- size: 90,
- },
-
- // 액션
- {
- id: "actions",
- header: "액션",
- enableHiding: false,
- size: 80,
- minSize: 80,
- cell: ({ row }) => {
- return (
- <div className="flex items-center gap-1">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <Eye className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>상세보기</TooltipContent>
- </Tooltip>
- </TooltipProvider>
+ ),
+ size: 200,
+ },
+
+ // 자재그룹 (자재그룹명)
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.itemCode}
+ </span>
+ <span className="max-w-[150px] truncate" title={row.original.itemName || ""}>
+ {row.original.itemName || "-"}
+ </span>
</div>
- );
+ ),
+ size: 180,
+ },
+
+ // 자재코드
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.itemCode || "-"}</span>
+ ),
+ size: 100,
},
- },
- ];
- return baseColumns;
+ // 견적 자료 - 수정됨
+ {
+ id: "rfqDocument",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 자료" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setRowAction({ row, type: "attachment" })}
+ >
+ <FileText className="h-4 w-4" />
+ </Button>
+ ),
+ size: 80,
+ },
+
+ // 견적품목수 - 수정됨
+ {
+ accessorKey: "prItemsCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적품목수" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="font-mono text-sm p-1 h-auto"
+ onClick={() => setRowAction({ row, type: "items" })}
+ >
+ {row.original.prItemsCount || 0}
+ </Button>
+ ),
+ size: 90,
+ },
+
+ // 견적생성일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />,
+ cell: ({ row }) => {
+ const date = row.original.createdAt;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
+ },
+ size: 100,
+ },
+
+ // 견적발송일
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />,
+ cell: ({ row }) => {
+ const date = row.original.rfqSendDate;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
+ },
+ size: 100,
+ },
+
+ // 견적마감일
+ {
+ 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 isOverdue = now > dueDate;
+
+ return (
+ <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ {format(dueDate, "yyyy-MM-dd")}
+ </span>
+ );
+ },
+ size: 100,
+ },
+
+ // 구매담당자
+ {
+ accessorKey: "picUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />,
+ cell: ({ row }) => row.original.picUserName || row.original.picName || "-",
+ size: 100,
+ },
+
+ // 최종수정일
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
+ cell: ({ row }) => {
+ const date = row.original.updatedAt;
+ return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-";
+ },
+ size: 120,
+ },
+
+ // 최종수정자
+ {
+ accessorKey: "updatedByUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
+ cell: ({ row }) => row.original.updatedByUserName || "-",
+ size: 100,
+ },
+
+ // 비고
+ {
+ accessorKey: "remark",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />,
+ cell: ({ row }) => row.original.remark || "-",
+ size: 150,
+ },
+ ];
+ }
+
+ // 기본값 (fallback)
+ return [];
} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index 1f60da36..9b696cbd 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -13,15 +13,18 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RfqsLastView } from "@/db/schema";
+import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog";
interface RfqTableToolbarActionsProps {
table: Table<RfqsLastView>;
onRefresh?: () => void;
+ rfqCategory?: "general" | "itb" | "rfq";
}
export function RfqTableToolbarActions({
table,
onRefresh,
+ rfqCategory = "itb",
}: RfqTableToolbarActionsProps) {
const [isExporting, setIsExporting] = React.useState(false);
@@ -150,14 +153,10 @@ export function RfqTableToolbarActions({
</DropdownMenuContent>
</DropdownMenu>
- <Button
- variant="samsung"
- size="sm"
- className="h-8 px-2 lg:px-3"
- >
- <Plus className="mr-2 h-4 w-4" />
- RFQ 생성
- </Button>
+ {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */}
+ {rfqCategory === "general" && (
+ <CreateGeneralRfqDialog onSuccess={onRefresh} />
+ ) }
</div>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx
index 199695a0..e8db116b 100644
--- a/lib/rfq-last/table/rfq-table.tsx
+++ b/lib/rfq-last/table/rfq-table.tsx
@@ -24,6 +24,8 @@ import { getRfqColumns } from "./rfq-table-columns";
import { RfqsLastView } from "@/db/schema";
import { getRfqs } from "../service";
import { RfqTableToolbarActions } from "./rfq-table-toolbar-actions";
+import { RfqAttachmentsDialog } from "./rfq-attachments-dialog";
+import { RfqItemsDialog } from "./rfq-items-dialog";
interface RfqTableProps {
data: Awaited<ReturnType<typeof getRfqs>>;
@@ -66,7 +68,6 @@ export function RfqTable({
);
// 초기 데이터 설정
-// const [initialPromiseData] = React.use(promises);
const [tableData, setTableData] = React.useState(data);
const [isDataLoading, setIsDataLoading] = React.useState(false);
@@ -232,9 +233,10 @@ export function RfqTable({
const columns = React.useMemo(() => {
return getRfqColumns({
setRowAction,
- rfqCategory
+ rfqCategory ,
+ router
});
- }, [rfqCategory, setRowAction]);
+ }, [rfqCategory, setRowAction, router]);
const filterFields: DataTableFilterField<RfqsLastView>[] = [
{ id: "rfqCode", label: "RFQ 코드" },
@@ -442,6 +444,7 @@ export function RfqTable({
<RfqTableToolbarActions
table={table}
+ rfqCategory={rfqCategory}
onRefresh={refreshData}
/>
</div>
@@ -452,6 +455,23 @@ export function RfqTable({
</div>
</div>
</div>
+
+ {/* 다이얼로그들 */}
+ {rowAction?.type === "attachment" && (
+ <RfqAttachmentsDialog
+ isOpen={true}
+ onClose={() => setRowAction(null)}
+ rfqData={rowAction.row.original}
+ />
+ )}
+
+ {rowAction?.type === "items" && (
+ <RfqItemsDialog
+ isOpen={true}
+ onClose={() => setRowAction(null)}
+ rfqData={rowAction.row.original}
+ />
+ )}
</>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts
index 09fd2f6f..b133433f 100644
--- a/lib/rfq-last/validations.ts
+++ b/lib/rfq-last/validations.ts
@@ -10,6 +10,7 @@ import {
import * as z from "zod";
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
+import { RfqLastAttachments } from "@/db/schema";
// RFQ 상태 옵션
export const RFQ_STATUS_OPTIONS = [
@@ -62,4 +63,25 @@ import {
export type GetRfqsSchema = Awaited<
ReturnType<typeof searchParamsRfqCache.parse>
- >; \ No newline at end of file
+ >;
+
+
+ export const searchParamsRfqAttachmentsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<RfqLastAttachments>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ // 기본 필터
+ attachmentType: parseAsArrayOf(z.string()).withDefault([]),
+ fileType: parseAsArrayOf(z.string()).withDefault([]),
+ search: parseAsString.withDefault(""),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ })
+
+ // 스키마 타입들
+ export type GetRfqLastAttachmentsSchema = Awaited<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>>
+
diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts
index 7464b13f..43eea6eb 100644
--- a/lib/vendor-document-list/enhanced-document-service.ts
+++ b/lib/vendor-document-list/enhanced-document-service.ts
@@ -24,6 +24,7 @@ import { contracts, users, vendors } from "@/db/schema"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { countDocumentStagesOnly, selectDocumentStagesOnly } from "./repository"
+import { saveFile } from "../file-stroage"
// 스키마 타입 정의
export interface GetEnhancedDocumentsSchema {
@@ -1468,4 +1469,234 @@ export async function getDocumentDetails(documentId: number) {
error: error instanceof Error ? error.message : "Failed to delete revision"
}
}
- } \ No newline at end of file
+ }
+
+
+ interface UploadResult {
+ docNumber: string
+ revision: string
+ success: boolean
+ message?: string
+ error?: string
+ }
+
+ interface BulkUploadResult {
+ success: boolean
+ successCount?: number
+ failCount?: number
+ results?: UploadResult[]
+ error?: string
+ }
+
+ export async function bulkUploadB4Documents(formData: FormData): Promise<BulkUploadResult> {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return { success: false, error: "인증이 필요합니다" }
+ }
+
+ const projectId = formData.get("projectId") as string
+ const fileCount = parseInt(formData.get("fileCount") as string)
+
+ if (!projectId) {
+ return { success: false, error: "프로젝트를 선택해주세요" }
+ }
+
+ const results: UploadResult[] = []
+ let successCount = 0
+ let failCount = 0
+
+ // 문서번호별로 그룹화
+ const fileGroups = new Map<string, Array<{
+ file: File
+ revision: string
+ index: number
+ }>>()
+
+ // 파일들을 문서번호별로 그룹화
+ for (let i = 0; i < fileCount; i++) {
+ const file = formData.get(`file_${i}`) as File
+ const docNumber = formData.get(`docNumber_${i}`) as string
+ const revision = formData.get(`revision_${i}`) as string
+
+ if (!file || !docNumber) continue
+
+ if (!fileGroups.has(docNumber)) {
+ fileGroups.set(docNumber, [])
+ }
+
+ fileGroups.get(docNumber)!.push({
+ file,
+ revision: revision || "00",
+ index: i
+ })
+ }
+
+ // 각 문서번호 그룹 처리
+ for (const [docNumber, files] of fileGroups.entries()) {
+ try {
+ // 문서가 존재하는지 확인
+ const existingDoc = await db.query.documents.findFirst({
+ where: and(
+ eq(documents.docNumber, docNumber),
+ eq(documents.projectId, parseInt(projectId))
+ )
+ })
+
+ if (!existingDoc) {
+ // 문서가 없으면 모든 파일 스킵
+ for (const fileInfo of files) {
+ results.push({
+ docNumber,
+ revision: fileInfo.revision,
+ success: false,
+ error: `문서번호 ${docNumber}가 존재하지 않습니다`
+ })
+ failCount++
+ }
+ continue
+ }
+
+ // 기존 스테이지 조회
+ const existingStages = await db.query.issueStages.findMany({
+ where: eq(issueStages.documentId, existingDoc.id)
+ })
+
+ const preStage = existingStages.find(s => s.stageName === 'GTT → SHI (For Pre.DWG)')
+ const workStage = existingStages.find(s => s.stageName === 'GTT → SHI (For Work.DWG)')
+
+ // 파일별 처리 (첫 번째 리비전은 Pre.DWG, 나머지는 Work.DWG)
+ for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
+ const fileInfo = files[fileIndex]
+ let targetStageId: number
+
+ try {
+ // 스테이지 결정 및 생성
+ if (fileIndex === 0) {
+ // 첫 번째 리비전 - Pre.DWG 스테이지
+ if (preStage) {
+ targetStageId = preStage.id
+ } else {
+ // Pre.DWG 스테이지 생성
+ const [newStage] = await db.insert(issueStages).values({
+ documentId: existingDoc.id,
+ stageName: 'GTT → SHI (For Pre.DWG)',
+ stageOrder: 1,
+ stageStatus: 'PLANNED',
+ }).returning()
+ targetStageId = newStage.id
+ }
+ } else {
+ // 나머지 리비전 - Work.DWG 스테이지
+ if (workStage) {
+ targetStageId = workStage.id
+ } else {
+ // Work.DWG 스테이지 생성
+ const [newStage] = await db.insert(issueStages).values({
+ documentId: existingDoc.id,
+ stageName: 'GTT → SHI (For Work.DWG)',
+ stageOrder: 2,
+ stageStatus: 'PLANNED',
+ }).returning()
+ targetStageId = newStage.id
+ }
+ }
+
+ // 같은 리비전이 이미 있는지 확인
+ const existingRevision = await db.query.revisions.findFirst({
+ where: and(
+ eq(revisions.issueStageId, targetStageId),
+ eq(revisions.revision, fileInfo.revision)
+ )
+ })
+
+ let revisionId: number
+
+ if (existingRevision) {
+ // 기존 리비전 사용
+ revisionId = existingRevision.id
+ } else {
+ // 새 리비전 생성
+ const [newRevision] = await db.insert(revisions).values({
+ issueStageId: targetStageId,
+ revision: fileInfo.revision,
+ uploaderType: "vendor",
+ uploaderName: session.user.name || "System",
+ uploadedAt: new Date().toISOString().split('T')[0],
+ submittedDate: new Date().toISOString().split('T')[0],
+ revisionStatus: 'SUBMITTED',
+ }).returning()
+ revisionId = newRevision.id
+ }
+
+ // 파일 저장
+ const saveResult = await saveFile({
+ file: fileInfo.file,
+ directory: `documents/${existingDoc.id}/revisions/${revisionId}`,
+ originalName: fileInfo.file.name,
+ userId: session.user.id
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장 실패")
+ }
+
+ // 첨부파일 정보 저장
+ await db.insert(documentAttachments).values({
+ revisionId,
+ fileName: fileInfo.file.name,
+ filePath: saveResult.publicPath!,
+ fileType: fileInfo.file.type,
+ fileSize: fileInfo.file.size,
+ })
+
+ results.push({
+ docNumber,
+ revision: fileInfo.revision,
+ success: true,
+ message: `${fileIndex === 0 ? 'Pre.DWG' : 'Work.DWG'} 스테이지에 업로드 완료`
+ })
+ successCount++
+
+ } catch (fileError) {
+ results.push({
+ docNumber,
+ revision: fileInfo.revision,
+ success: false,
+ error: fileError instanceof Error ? fileError.message : "파일 처리 실패"
+ })
+ failCount++
+ }
+ }
+
+ } catch (docError) {
+ // 문서 그룹 전체 에러
+ for (const fileInfo of files) {
+ results.push({
+ docNumber,
+ revision: fileInfo.revision,
+ success: false,
+ error: docError instanceof Error ? docError.message : "문서 처리 실패"
+ })
+ failCount++
+ }
+ }
+ }
+
+ revalidatePath('/documents')
+
+ return {
+ success: true,
+ successCount,
+ failCount,
+ results
+ }
+
+ } catch (error) {
+ console.error("Bulk upload error:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다"
+ }
+ }
+ }
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts
index 61670c79..135dfb3a 100644
--- a/lib/vendor-document-list/import-service.ts
+++ b/lib/vendor-document-list/import-service.ts
@@ -657,7 +657,8 @@ class ImportService {
.from(documents)
.where(and(
eq(documents.projectId, projectId),
- eq(documents.docNumber, dolceDoc.DrawingNo)
+ eq(documents.docNumber, dolceDoc.DrawingNo),
+ eq(documents.discipline, dolceDoc.Discipline)
))
.limit(1)
@@ -765,7 +766,8 @@ class ImportService {
.from(documents)
.where(and(
eq(documents.projectId, projectId),
- eq(documents.docNumber, dolceDoc.DrawingNo)
+ eq(documents.docNumber, dolceDoc.DrawingNo),
+ eq(documents.discipline, dolceDoc.Discipline),
))
.limit(1)
@@ -1275,7 +1277,8 @@ class ImportService {
.from(documents)
.where(and(
eq(documents.projectId, projectId),
- eq(documents.docNumber, drawingNo)
+ eq(documents.docNumber, drawingNo),
+ eq(documents.discipline, dolceDoc.Discipline)
))
.limit(1)
@@ -1340,7 +1343,8 @@ class ImportService {
.from(documents)
.where(and(
eq(documents.projectId, projectId),
- eq(documents.docNumber, drawingNo)
+ eq(documents.docNumber, drawingNo),
+ eq(documents.discipline, dolceDoc.Discipline)
))
.limit(1)
@@ -1403,7 +1407,8 @@ class ImportService {
.from(documents)
.where(and(
eq(documents.projectId, projectId),
- eq(documents.docNumber, drawingNo)
+ eq(documents.docNumber, drawingNo),
+ eq(documents.discipline, dolceDoc.Discipline)
))
.limit(1)
diff --git a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx
new file mode 100644
index 00000000..65166bd6
--- /dev/null
+++ b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx
@@ -0,0 +1,428 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { Badge } from "@/components/ui/badge"
+import {
+ Upload,
+ X,
+ Loader2,
+ FileSpreadsheet,
+ Files,
+ CheckCircle2,
+ AlertCircle,
+ AlertTriangle,
+ FileText,
+} from "lucide-react"
+
+import { SimplifiedDocumentsView } from "@/db/schema"
+import { bulkUploadB4Documents } from "../enhanced-document-service"
+
+// 파일명 파싱 유틸리티
+function parseFileName(fileName: string): { docNumber: string | null; revision: string | null } {
+ // 파일 확장자 제거
+ const nameWithoutExt = fileName.replace(/\.[^.]+$/, "")
+
+ // revision 패턴 찾기 (R01, r01, REV01, rev01 등)
+ const revisionMatch = nameWithoutExt.match(/[Rr](?:EV)?(\d+)/g)
+ const revision = revisionMatch ? revisionMatch[revisionMatch.length - 1].toUpperCase() : null
+
+ // revision 제거한 나머지에서 docNumber 찾기
+ let cleanedName = nameWithoutExt
+ if (revision) {
+ // revision과 그 앞의 구분자를 제거
+ const revPattern = new RegExp(`[-_\\s]*${revision.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1")}.*$`, 'i')
+ cleanedName = cleanedName.replace(revPattern, "")
+ }
+
+ // docNumber 패턴 찾기 (XX-XX-XX 형태)
+ // 공백이나 언더스코어를 하이픈으로 정규화
+ const normalizedName = cleanedName.replace(/[\s_]+/g, '-')
+
+ // 2~3자리 코드가 2~3개 연결된 패턴 찾기
+ const docNumberPatterns = [
+ /\b([A-Za-z]{2,3})-([A-Za-z]{2,3})-([A-Za-z0-9]{2,4})\b/,
+ /\b([A-Za-z]{2,3})\s+([A-Za-z]{2,3})\s+([A-Za-z0-9]{2,4})\b/,
+ ]
+
+ let docNumber = null
+ for (const pattern of docNumberPatterns) {
+ const match = normalizedName.match(pattern) || cleanedName.match(pattern)
+ if (match) {
+ docNumber = `${match[1]}-${match[2]}-${match[3]}`.toUpperCase()
+ break
+ }
+ }
+
+ return { docNumber, revision }
+}
+
+// Form schema
+const formSchema = z.object({
+ projectId: z.string().min(1, "Please select a project"),
+ files: z.array(z.instanceof(File)).min(1, "Please select files"),
+})
+
+interface BulkB4UploadDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ allDocuments: SimplifiedDocumentsView[]
+}
+
+interface ParsedFile {
+ file: File
+ docNumber: string | null
+ revision: string | null
+ status: 'pending' | 'uploading' | 'success' | 'error' | 'ignored'
+ message?: string
+}
+
+export function BulkB4UploadDialog({
+ open,
+ onOpenChange,
+ allDocuments
+}: BulkB4UploadDialogProps) {
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [parsedFiles, setParsedFiles] = React.useState<ParsedFile[]>([])
+ const router = useRouter()
+
+ // 프로젝트 ID 추출
+ const projectOptions = React.useMemo(() => {
+ const projectIds = [...new Set(allDocuments.map(doc => doc.projectId).filter(Boolean))]
+ return projectIds.map(id => ({
+ id: String(id),
+ code: allDocuments.find(doc => doc.projectId === id)?.projectCode || `Project ${id}`
+ }))
+ }, [allDocuments])
+
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ projectId: "",
+ files: [],
+ },
+ })
+
+ // 파일 선택 시 파싱
+ const handleFilesChange = (files: File[]) => {
+ const parsed = files.map(file => {
+ const { docNumber, revision } = parseFileName(file.name)
+ return {
+ file,
+ docNumber,
+ revision,
+ status: docNumber ? 'pending' as const : 'ignored' as const,
+ message: !docNumber ? 'docNumber를 찾을 수 없음' : undefined
+ }
+ })
+
+ setParsedFiles(parsed)
+ form.setValue("files", files)
+ }
+
+ // 파일 제거
+ const removeFile = (index: number) => {
+ const newParsedFiles = parsedFiles.filter((_, i) => i !== index)
+ setParsedFiles(newParsedFiles)
+ form.setValue("files", newParsedFiles.map(pf => pf.file))
+ }
+
+ // 업로드 처리
+ async function onSubmit(values: z.infer<typeof formSchema>) {
+ setIsUploading(true)
+
+ try {
+ // 유효한 파일만 필터링
+ const validFiles = parsedFiles.filter(pf => pf.docNumber && pf.status === 'pending')
+
+ if (validFiles.length === 0) {
+ toast.error("업로드 가능한 파일이 없습니다")
+ return
+ }
+
+ // 파일별로 상태 업데이트
+ setParsedFiles(prev => prev.map(pf =>
+ pf.docNumber && pf.status === 'pending'
+ ? { ...pf, status: 'uploading' as const }
+ : pf
+ ))
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("projectId", values.projectId)
+
+ validFiles.forEach((pf, index) => {
+ formData.append(`file_${index}`, pf.file)
+ formData.append(`docNumber_${index}`, pf.docNumber!)
+ formData.append(`revision_${index}`, pf.revision || "00")
+ })
+
+ formData.append("fileCount", String(validFiles.length))
+
+ // 서버 액션 호출
+ const result = await bulkUploadB4Documents(formData)
+
+ if (result.success) {
+ // 성공한 파일들 표시
+ setParsedFiles(prev => prev.map(pf => {
+ const uploadResult = result.results?.find(r =>
+ r.docNumber === pf.docNumber && r.revision === (pf.revision || "00")
+ )
+
+ if (uploadResult?.success) {
+ return { ...pf, status: 'success' as const, message: uploadResult.message }
+ } else if (uploadResult) {
+ return { ...pf, status: 'error' as const, message: uploadResult.error }
+ }
+ return pf
+ }))
+
+ toast.success(`${result.successCount}/${validFiles.length} 파일 업로드 완료`)
+
+ // 모두 성공하면 닫기
+ if (result.successCount === validFiles.length) {
+ setTimeout(() => {
+ onOpenChange(false)
+ router.refresh()
+ }, 1500)
+ }
+ } else {
+ toast.error(result.error || "업로드 실패")
+ setParsedFiles(prev => prev.map(pf =>
+ pf.status === 'uploading'
+ ? { ...pf, status: 'error' as const, message: result.error }
+ : pf
+ ))
+ }
+ } catch (error) {
+ toast.error("업로드 중 오류가 발생했습니다")
+ setParsedFiles(prev => prev.map(pf =>
+ pf.status === 'uploading'
+ ? { ...pf, status: 'error' as const, message: '업로드 실패' }
+ : pf
+ ))
+ } finally {
+ setIsUploading(false)
+ }
+ }
+
+ // 다이얼로그 닫을 때 초기화
+ React.useEffect(() => {
+ if (!open) {
+ form.reset()
+ setParsedFiles([])
+ }
+ }, [open, form])
+
+ const validFileCount = parsedFiles.filter(pf => pf.docNumber).length
+ const ignoredFileCount = parsedFiles.filter(pf => !pf.docNumber).length
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle>B4 Document Bulk Upload</DialogTitle>
+ <DialogDescription>
+ Document numbers and revisions will be automatically extracted from file names.
+ Example: "agadfg de na oc R01.pdf" → Document Number: DE-NA-OC, Revision: R01
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Select Project *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Please select a project" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {projectOptions.map(project => (
+ <SelectItem key={project.id} value={project.id}>
+ {project.code}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="space-y-2">
+ <FormLabel>Select Files</FormLabel>
+ <div className="border-2 border-dashed rounded-lg p-6">
+ <input
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf"
+ onChange={(e) => handleFilesChange(Array.from(e.target.files || []))}
+ className="hidden"
+ id="file-upload"
+ />
+ <label
+ htmlFor="file-upload"
+ className="flex flex-col items-center justify-center cursor-pointer"
+ >
+ <Upload className="h-10 w-10 text-muted-foreground mb-2" />
+ <p className="text-sm text-muted-foreground">
+ Click or drag files to upload
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ PDF, DOC, DOCX, XLS, XLSX, DWG, DXF
+ </p>
+ </label>
+ </div>
+ </div>
+
+ {parsedFiles.length > 0 && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <FormLabel>Selected Files</FormLabel>
+ <div className="flex gap-2">
+ <Badge variant="default">
+ Valid: {validFileCount}
+ </Badge>
+ {ignoredFileCount > 0 && (
+ <Badge variant="secondary">
+ Ignored: {ignoredFileCount}
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ <ScrollArea className="h-[250px] border rounded-lg p-2">
+ <div className="space-y-2">
+ {parsedFiles.map((pf, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-2 rounded-lg bg-muted/30"
+ >
+ <div className="flex items-center gap-3 flex-1">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm font-medium truncate">
+ {pf.file.name}
+ </p>
+ <div className="flex items-center gap-2 mt-1">
+ {pf.docNumber ? (
+ <>
+ <Badge variant="outline" className="text-xs">
+ Doc: {pf.docNumber}
+ </Badge>
+ {pf.revision && (
+ <Badge variant="outline" className="text-xs">
+ Rev: {pf.revision}
+ </Badge>
+ )}
+ </>
+ ) : (
+ <span className="text-xs text-muted-foreground">
+ {pf.message}
+ </span>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {pf.status === 'uploading' && (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ )}
+ {pf.status === 'success' && (
+ <CheckCircle2 className="h-4 w-4 text-green-500" />
+ )}
+ {pf.status === 'error' && (
+ <AlertCircle className="h-4 w-4 text-red-500" />
+ )}
+ {pf.status === 'ignored' && (
+ <AlertTriangle className="h-4 w-4 text-yellow-500" />
+ )}
+
+ {!isUploading && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFile(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isUploading}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isUploading || validFileCount === 0 || !form.watch("projectId")}
+ >
+ {isUploading ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Uploading...
+ </>
+ ) : (
+ <>
+ <Upload className="mr-2 h-4 w-4" />
+ Upload ({validFileCount} files)
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
index 51c104dc..8370cd34 100644
--- a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
+++ b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
@@ -114,6 +114,43 @@ export function getSimplifiedDocumentColumns({
},
},
+ {
+ accessorKey: "discipline",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Discipline" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="text-center">
+ {row.original.discipline}
+ </div>
+ )
+ },
+ enableResizing: true,
+ maxSize:80,
+ meta: {
+ excelHeader: "discipline"
+ },
+ },
+
+ {
+ accessorKey: "managerENM",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Contact" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="text-center">
+ {row.original.managerENM}
+ </div>
+ )
+ },
+ enableResizing: true,
+ maxSize:80,
+ meta: {
+ excelHeader: "Contact"
+ },
+ },
// 프로젝트 코드
{
accessorKey: "projectCode",
diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
index 4ec57369..94252db5 100644
--- a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
+++ b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
@@ -1,4 +1,4 @@
-// enhanced-doc-table-toolbar-actions.tsx - 최적화된 버전
+// enhanced-doc-table-toolbar-actions.tsx - B4 업로드 기능 추가 버전
"use client"
import * as React from "react"
@@ -11,15 +11,18 @@ import { Button } from "@/components/ui/button"
import { SimplifiedDocumentsView } from "@/db/schema/vendorDocu"
import { SendToSHIButton } from "./send-to-shi-button"
import { ImportFromDOLCEButton } from "./import-from-dolce-button"
+import { BulkB4UploadDialog } from "./bulk-b4-upload-dialog"
interface EnhancedDocTableToolbarActionsProps {
table: Table<SimplifiedDocumentsView>
projectType: "ship" | "plant"
+ b4: boolean
}
export function EnhancedDocTableToolbarActions({
table,
projectType,
+ b4
}: EnhancedDocTableToolbarActionsProps) {
const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false)
@@ -68,31 +71,55 @@ export function EnhancedDocTableToolbarActions({
}, [table])
return (
- <div className="flex items-center gap-2">
- {/* SHIP: DOLCE에서 목록 가져오기 */}
- <ImportFromDOLCEButton
- allDocuments={allDocuments}
- projectIds={projectIds} // 🔥 미리 계산된 projectIds 전달
- onImportComplete={handleImportComplete}
- />
+ <>
+ <div className="flex items-center gap-2">
+ {/* SHIP: DOLCE에서 목록 가져오기 */}
+ <ImportFromDOLCEButton
+ allDocuments={allDocuments}
+ projectIds={projectIds} // 🔥 미리 계산된 projectIds 전달
+ onImportComplete={handleImportComplete}
+ />
- {/* Export 버튼 (공통) */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleExport}
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
+ {/* B4 일괄 업로드 버튼 - b4가 true일 때만 표시 */}
+ {b4 && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setBulkUploadDialogOpen(true)}
+ className="gap-2"
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">B4 업로드</span>
+ </Button>
+ )}
- {/* Send to SHI 버튼 (공통) */}
- <SendToSHIButton
- documents={allDocuments}
- onSyncComplete={handleSyncComplete}
- projectType={projectType}
- />
- </div>
+ {/* Export 버튼 (공통) */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+
+ {/* Send to SHI 버튼 (공통) */}
+ <SendToSHIButton
+ documents={allDocuments}
+ onSyncComplete={handleSyncComplete}
+ projectType={projectType}
+ />
+ </div>
+
+ {/* B4 일괄 업로드 다이얼로그 */}
+ {b4 && (
+ <BulkB4UploadDialog
+ open={bulkUploadDialogOpen}
+ onOpenChange={setBulkUploadDialogOpen}
+ allDocuments={allDocuments}
+ />
+ )}
+ </>
)
} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/enhanced-documents-table.tsx b/lib/vendor-document-list/ship/enhanced-documents-table.tsx
index 8051da7e..287df755 100644
--- a/lib/vendor-document-list/ship/enhanced-documents-table.tsx
+++ b/lib/vendor-document-list/ship/enhanced-documents-table.tsx
@@ -287,6 +287,7 @@ export function SimplifiedDocumentsTable({
<EnhancedDocTableToolbarActions
table={table}
projectType="ship"
+ b4={hasB4Documents}
/>
</DataTableAdvancedToolbar>
</DataTable>