From 2fdce8d7a57c792bba0ac36fa554dca9c9cc31e3 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 30 May 2025 03:36:16 +0000 Subject: (김준회) 기술영업 조선 RFQ 오류 수정 및 선종 필터 기능 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/service.ts | 149 +++- lib/techsales-rfq/table/create-rfq-dialog.tsx | 749 ++++++++++++++------- .../table/detail-table/rfq-detail-table.tsx | 2 +- lib/techsales-rfq/table/rfq-table-column.tsx | 41 +- lib/techsales-rfq/table/rfq-table.tsx | 52 +- 5 files changed, 706 insertions(+), 287 deletions(-) (limited to 'lib/techsales-rfq') diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index f3bb2e59..735fcf68 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -47,6 +47,91 @@ interface SeriesSnapshot { post1?: string; } +// JSON 필드 식별 함수 +function isJsonField(fieldId: string): boolean { + const jsonFields = ['projNm', 'ptypeNm', 'projMsrm', 'sector', 'pspid']; + return jsonFields.includes(fieldId); +} + +// JSON 필드 필터링 함수 +function filterJsonFields(filters: Filter[], joinOperator: "and" | "or") { + const joinFn = joinOperator === "and" ? and : or; + + const conditions = filters.map(filter => { + const fieldId = filter.id as string; + const value = filter.value; + + switch (fieldId) { + case 'projNm': + return createJsonFieldCondition('projNm', filter.operator, value); + case 'ptypeNm': + return createJsonFieldCondition('ptypeNm', filter.operator, value); + case 'sector': + return createJsonFieldCondition('sector', filter.operator, value); + case 'pspid': + return createJsonFieldCondition('pspid', filter.operator, value); + case 'projMsrm': + // 숫자 필드는 특별 처리 + return createJsonNumberFieldCondition('projMsrm', filter.operator, value); + default: + return undefined; + } + }).filter(Boolean); + + return conditions.length > 0 ? joinFn(...conditions) : undefined; +} + +// JSON 텍스트 필드 조건 생성 +function createJsonFieldCondition(fieldName: string, operator: string, value: unknown) { + const jsonPath = `${techSalesRfqs.projectSnapshot}->>'${fieldName}'`; + + switch (operator) { + case 'eq': + return sql`${sql.raw(jsonPath)} = ${value}`; + case 'ne': + return sql`${sql.raw(jsonPath)} != ${value}`; + case 'iLike': + return sql`${sql.raw(jsonPath)} ILIKE ${'%' + value + '%'}`; + case 'notILike': + return sql`${sql.raw(jsonPath)} NOT ILIKE ${'%' + value + '%'}`; + case 'isEmpty': + return sql`(${sql.raw(jsonPath)} IS NULL OR ${sql.raw(jsonPath)} = '')`; + case 'isNotEmpty': + return sql`(${sql.raw(jsonPath)} IS NOT NULL AND ${sql.raw(jsonPath)} != '')`; + default: + return undefined; + } +} + +// JSON 숫자 필드 조건 생성 +function createJsonNumberFieldCondition(fieldName: string, operator: string, value: unknown) { + const jsonPath = `(${techSalesRfqs.projectSnapshot}->>'${fieldName}')::int`; + const numValue = parseInt(value as string, 10); + + if (isNaN(numValue)) return undefined; + + switch (operator) { + case 'eq': + return sql`${sql.raw(jsonPath)} = ${numValue}`; + case 'ne': + return sql`${sql.raw(jsonPath)} != ${numValue}`; + case 'gt': + return sql`${sql.raw(jsonPath)} > ${numValue}`; + case 'gte': + return sql`${sql.raw(jsonPath)} >= ${numValue}`; + case 'lt': + return sql`${sql.raw(jsonPath)} < ${numValue}`; + case 'lte': + return sql`${sql.raw(jsonPath)} <= ${numValue}`; + case 'isEmpty': + return sql`${techSalesRfqs.projectSnapshot}->>'${fieldName}' IS NULL`; + case 'isNotEmpty': + return sql`${techSalesRfqs.projectSnapshot}->>'${fieldName}' IS NOT NULL`; + default: + return undefined; + } +} + /** * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원) * 형식: RFQ-YYYY-001, RFQ-YYYY-002, ... @@ -251,14 +336,26 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { }); } - // 고급 필터 조건 생성 + // 고급 필터 조건 생성 (JSON 필드 지원) let advancedWhere; if (advancedFilters.length > 0) { - advancedWhere = filterColumns({ + // 일반 필드와 JSON 필드 분리 + const normalFilters = advancedFilters.filter(f => !isJsonField(f.id as string)); + const jsonFilters = advancedFilters.filter(f => isJsonField(f.id as string)); + + const normalWhere = normalFilters.length > 0 ? filterColumns({ table: techSalesRfqs, - filters: advancedFilters, + filters: normalFilters, joinOperator: advancedJoinOperator, - }); + }) : undefined; + + const jsonWhere = jsonFilters.length > 0 ? filterJsonFields(jsonFilters, advancedJoinOperator) : undefined; + + if (normalWhere && jsonWhere) { + advancedWhere = advancedJoinOperator === "and" ? and(normalWhere, jsonWhere) : or(normalWhere, jsonWhere); + } else { + advancedWhere = normalWhere || jsonWhere; + } } // 전역 검색 조건 @@ -292,17 +389,59 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { if (input.sort?.length) { // 안전하게 접근하여 정렬 기준 설정 orderBy = input.sort.map(item => { - switch (item.id) { + // TypeScript 에러 방지를 위한 타입 단언 + const sortField = item.id as string; + + switch (sortField) { case 'id': return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id; case 'rfqCode': return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; case 'materialCode': return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode; + case 'itemName': + // itemName은 조인된 itemShipbuilding.itemList 필드 + return item.desc ? desc(sql`item_shipbuilding.item_list`) : sql`item_shipbuilding.item_list`; case 'status': return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; case 'dueDate': return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; + case 'rfqSendDate': + return item.desc ? desc(techSalesRfqs.rfqSendDate) : techSalesRfqs.rfqSendDate; + case 'projNm': + // JSON 필드에서 추출된 프로젝트명 + return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'projNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'projNm'`; + case 'projMsrm': + // JSON 필드에서 추출된 척수 (정수 캐스팅) + return item.desc ? desc(sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`) : sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`; + case 'ptypeNm': + // JSON 필드에서 추출된 선종명 + return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`; + case 'quotationCount': + // 서브쿼리로 계산된 견적수 - repository의 SELECT에서 정의한 컬럼명 사용 + return item.desc ? desc(sql`( + SELECT COUNT(*) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + )`) : sql`( + SELECT COUNT(*) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + )`; + case 'attachmentCount': + // 서브쿼리로 계산된 첨부파일수 - repository의 SELECT에서 정의한 컬럼명 사용 + return item.desc ? desc(sql`( + SELECT COUNT(*) + FROM tech_sales_attachments + WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} + )`) : sql`( + SELECT COUNT(*) + FROM tech_sales_attachments + WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} + )`; + case 'createdByName': + // 조인된 사용자명 + return item.desc ? desc(sql`created_user.name`) : sql`created_user.name`; case 'createdAt': return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; case 'updatedAt': diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx index 5faa3a0b..bacec02e 100644 --- a/lib/techsales-rfq/table/create-rfq-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-dialog.tsx @@ -44,12 +44,20 @@ import { } from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" import { ScrollArea } from "@/components/ui/scroll-area" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" // 실제 데이터 서비스 import import { - getShipbuildingItemsByWorkType, - searchShipbuildingItems, getWorkTypes, + getAllShipbuildingItemsForCache, + getShipTypes, type ShipbuildingItem, type WorkType } from "@/lib/items-tech/service" @@ -81,7 +89,7 @@ interface CreateRfqDialogProps { onCreated?: () => void; } -export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { +export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { const { data: session } = useSession() const [isProcessing, setIsProcessing] = React.useState(false) const [isDialogOpen, setIsDialogOpen] = React.useState(false) @@ -90,13 +98,115 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { // 검색 및 필터링 상태 const [itemSearchQuery, setItemSearchQuery] = React.useState("") const [selectedWorkType, setSelectedWorkType] = React.useState(null) + const [selectedShipType, setSelectedShipType] = React.useState(null) const [selectedItems, setSelectedItems] = React.useState([]) - const [isSearchingItems, setIsSearchingItems] = React.useState(false) // 데이터 상태 const [workTypes, setWorkTypes] = React.useState([]) - const [availableItems, setAvailableItems] = React.useState([]) + const [allItems, setAllItems] = React.useState([]) + const [shipTypes, setShipTypes] = React.useState([]) const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState(null) + const [retryCount, setRetryCount] = React.useState(0) + + // 데이터 로딩 함수를 useCallback으로 메모이제이션 + const loadData = React.useCallback(async (isRetry = false) => { + try { + if (!isRetry) { + setIsLoadingItems(true) + setDataLoadError(null) + } + + console.log(`데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`) + + const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([ + getWorkTypes(), + getAllShipbuildingItemsForCache(), + getShipTypes() + ]) + + console.log("WorkTypes 결과:", workTypesResult) + console.log("Items 결과:", itemsResult) + console.log("ShipTypes 결과:", shipTypesResult) + + // WorkTypes 설정 + if (Array.isArray(workTypesResult)) { + setWorkTypes(workTypesResult) + } else { + console.error("WorkTypes 데이터 형식 오류:", workTypesResult) + throw new Error("공종 데이터를 불러올 수 없습니다.") + } + + // Items 설정 + if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) { + setAllItems(itemsResult.data) + console.log("아이템 설정 완료:", itemsResult.data.length, "개") + } else { + console.error("아이템 로딩 실패:", itemsResult.error) + throw new Error(itemsResult.error || "아이템 데이터를 불러올 수 없습니다.") + } + + // ShipTypes 설정 + if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) { + setShipTypes(shipTypesResult.data) + console.log("선종 설정 완료:", shipTypesResult.data) + } else { + console.error("선종 로딩 실패:", shipTypesResult.error) + throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.") + } + + // 성공 시 재시도 카운터 리셋 + setRetryCount(0) + setDataLoadError(null) + console.log("데이터 로딩 완료") + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + console.error("데이터 로딩 오류:", errorMessage) + + setDataLoadError(errorMessage) + + // 3회까지 자동 재시도 (500ms 간격) + if (retryCount < 2) { + console.log(`${500 * (retryCount + 1)}ms 후 재시도...`) + setTimeout(() => { + setRetryCount(prev => prev + 1) + loadData(true) + }, 500 * (retryCount + 1)) + } else { + // 재시도 실패 시 사용자에게 알림 + toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`) + } + } finally { + if (!isRetry) { + setIsLoadingItems(false) + } + } + }, [retryCount]) + + // 다이얼로그가 열릴 때마다 데이터 로딩 + React.useEffect(() => { + if (isDialogOpen) { + // 다이얼로그가 열릴 때마다 데이터 상태 초기화 및 로딩 + setDataLoadError(null) + setRetryCount(0) + + // 이미 데이터가 있고 에러가 없다면 로딩하지 않음 (성능 최적화) + if (allItems.length > 0 && workTypes.length > 0 && shipTypes.length > 0 && !dataLoadError) { + console.log("기존 데이터 사용 (캐시)") + return + } + + loadData() + } + }, [isDialogOpen, loadData, allItems.length, workTypes.length, shipTypes.length, dataLoadError]) + + // 수동 새로고침 함수 + const handleRefreshData = React.useCallback(() => { + setDataLoadError(null) + setRetryCount(0) + loadData() + }, [loadData]) // RFQ 생성 폼 const form = useForm({ @@ -104,55 +214,42 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { defaultValues: { biddingProjectId: undefined, materialCodes: [], - dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후 + dueDate: undefined, // 기본값 제거 } }) - // 공종 목록 로드 - React.useEffect(() => { - const loadWorkTypes = async () => { - const types = await getWorkTypes() - setWorkTypes(types) + // 필터링된 아이템 목록 가져오기 + const availableItems = React.useMemo(() => { + let filtered = [...allItems] + + // 선종 필터 + if (selectedShipType) { + filtered = filtered.filter(item => item.shipTypes === selectedShipType) } - loadWorkTypes() - }, []) - // 아이템 데이터 로드 - const loadItems = React.useCallback(async () => { - setIsLoadingItems(true) - try { - let result - if (itemSearchQuery.trim()) { - result = await searchShipbuildingItems(itemSearchQuery, selectedWorkType || undefined) - } else { - result = await getShipbuildingItemsByWorkType(selectedWorkType || undefined) - } - - if (result.error) { - toast.error(`아이템 로드 오류: ${result.error}`) - setAvailableItems([]) - } else { - setAvailableItems(result.data || []) - } - } catch (error) { - console.error("아이템 로드 오류:", error) - toast.error("아이템을 불러오는 중 오류가 발생했습니다") - setAvailableItems([]) - } finally { - setIsLoadingItems(false) + // 공종 필터 + if (selectedWorkType) { + filtered = filtered.filter(item => item.workType === selectedWorkType) } - }, [itemSearchQuery, selectedWorkType]) - // 아이템 검색 디바운스 - React.useEffect(() => { - setIsSearchingItems(true) - const timer = setTimeout(() => { - loadItems() - setIsSearchingItems(false) - }, 300) + // 검색어 필터 + if (itemSearchQuery && itemSearchQuery.trim()) { + const query = itemSearchQuery.toLowerCase().trim() + filtered = filtered.filter(item => + item.itemCode.toLowerCase().includes(query) || + item.itemName.toLowerCase().includes(query) || + (item.description && item.description.toLowerCase().includes(query)) || + (item.itemList && item.itemList.toLowerCase().includes(query)) + ) + } + + return filtered + }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType]) - return () => clearTimeout(timer) - }, [loadItems]) + // 사용 가능한 선종 목록 가져오기 + const availableShipTypes = React.useMemo(() => { + return shipTypes + }, [shipTypes]) // 프로젝트 선택 처리 const handleProjectSelect = (project: Project) => { @@ -160,6 +257,9 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { form.setValue("biddingProjectId", project.id) // 선택 초기화 setSelectedItems([]) + setSelectedShipType(null) + setSelectedWorkType(null) + setItemSearchQuery("") form.setValue("materialCodes", []) } @@ -255,13 +355,16 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { form.reset({ biddingProjectId: undefined, materialCodes: [], - dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정 + dueDate: undefined, // 기본값 제거 }) setSelectedProject(null) setItemSearchQuery("") setSelectedWorkType(null) + setSelectedShipType(null) setSelectedItems([]) - setAvailableItems([]) + // 에러 상태 및 재시도 카운터 초기화 + setDataLoadError(null) + setRetryCount(0) // 생성 후 콜백 실행 if (onCreated) { @@ -277,7 +380,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { } return ( - { setIsDialogOpen(open) @@ -285,13 +388,16 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { form.reset({ biddingProjectId: undefined, materialCodes: [], - dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정 + dueDate: undefined, // 기본값 제거 }) setSelectedProject(null) setItemSearchQuery("") setSelectedWorkType(null) + setSelectedShipType(null) setSelectedItems([]) - setAvailableItems([]) + // 에러 상태 및 재시도 카운터 초기화 + setDataLoadError(null) + setRetryCount(0) } }} > @@ -306,95 +412,177 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { RFQ 생성 - - + + RFQ 생성 -
+
- + {/* 프로젝트 선택 */} - ( - - 입찰 프로젝트 - - - - - - )} - /> - - - - {/* 마감일 설정 */} - ( - - 마감일 - - - - - - - - - date < new Date() || date < new Date("1900-01-01") - } - initialFocus +
+ ( + + 입찰 프로젝트 + + - - - - 벤더가 견적을 제출해야 하는 마감일입니다. - - - - )} - /> - - - - {!selectedProject ? ( -
- 먼저 프로젝트를 선택해주세요 + + + + )} + /> + + + + {/* 선종 선택 */} +
+
+ 선종 선택 +
+ + {/* 데이터 로딩 에러 표시 */} + {dataLoadError && ( +
+
+
+ + {dataLoadError} +
+ +
+
+ )} + + + + + + + { + setSelectedShipType(null) + setSelectedItems([]) + form.setValue("materialCodes", []) + }} + > + 전체 선종 + + {availableShipTypes.map(shipType => ( + { + setSelectedShipType(shipType) + setSelectedItems([]) + form.setValue("materialCodes", []) + }} + > + {shipType} + + ))} + +
- ) : ( + + + + {/* 마감일 설정 */} + ( + + 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + +
{/* 아이템 선택 영역 */}
조선 아이템 선택 - 공종별 아이템을 선택하세요 + {selectedShipType ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` : "먼저 선종을 선택해주세요"}
@@ -408,6 +596,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { value={itemSearchQuery} onChange={(e) => setItemSearchQuery(e.target.value)} className="pl-8 pr-8" + disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} /> {itemSearchQuery && ( )} - {isSearchingItems && ( - - )}
{/* 공종 필터 */} - @@ -457,10 +648,41 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
- {isLoadingItems ? ( + {dataLoadError ? ( +
+
+
+ +
+

데이터 로딩에 실패했습니다

+

{dataLoadError}

+
+ +
+
+
+ ) : isLoadingItems ? (
아이템을 불러오는 중... + {retryCount > 0 && ( +

재시도 {retryCount}회

+ )}
) : availableItems.length > 0 ? ( [...availableItems] @@ -547,146 +769,149 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { /> {/* RFQ 그룹핑 미리보기 */} - {selectedItems.length > 0 && ( -
- 생성될 RFQ 그룹 미리보기 -
- {(() => { - // 아이템명(itemList)으로 그룹핑 - const groupedItems = selectedItems.reduce((groups, item) => { - const actualItemName = item.itemList // 실제 조선 아이템명 - if (!actualItemName) { - return groups // itemList가 없는 경우 제외 - } - if (!groups[actualItemName]) { - groups[actualItemName] = [] - } - groups[actualItemName].push(item) - return groups - }, {} as Record) +
+ 생성될 RFQ 그룹 미리보기 +
+ {(() => { + // 아이템명(itemList)으로 그룹핑 + const groupedItems = selectedItems.reduce((groups, item) => { + const actualItemName = item.itemList // 실제 조선 아이템명 + if (!actualItemName) { + return groups // itemList가 없는 경우 제외 + } + if (!groups[actualItemName]) { + groups[actualItemName] = [] + } + groups[actualItemName].push(item) + return groups + }, {} as Record) - const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { - const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 - const joinedItemCodes = itemCodes.join(',') - return { - actualItemName, - items, - itemCodes, - joinedItemCodes, - codeLength: joinedItemCodes.length, - isOverLimit: joinedItemCodes.length > 255 - } - }) + const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { + const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 + const joinedItemCodes = itemCodes.join(',') + return { + actualItemName, + items, + itemCodes, + joinedItemCodes, + codeLength: joinedItemCodes.length, + isOverLimit: joinedItemCodes.length > 255 + } + }) - return ( -
-
- 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑) -
- {rfqGroups.map((group, index) => ( -
-
-
- RFQ #{index + 1}: {group.actualItemName} -
- - {group.itemCodes.length}개 자재코드 ({group.codeLength}/255자) - -
-
- 자재코드: {group.joinedItemCodes} -
- {group.isOverLimit && ( -
- ⚠️ 자재코드 길이가 255자를 초과합니다. 일부 아이템을 제거해주세요. -
- )} -
- 포함된 아이템: {group.items.map(item => `${item.itemCode}`).join(', ')} -
-
- ))} + return ( +
+
+ 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑)
- ) - })()} -
+ + + + + RFQ # + 아이템명 + 자재그룹코드 개수 + 길이 + 상태 + + + + {rfqGroups.map((group, index) => ( + + #{index + 1} + +
+ {group.actualItemName} +
+
+ {group.itemCodes.length}개 + + + {group.codeLength}/255자 + + + + {group.isOverLimit ? ( + 초과 + ) : ( + 정상 + )} + +
+ ))} +
+
+
+
+ ) + })()}
- )} +
- )} - - {/* 안내 메시지 */} - {/* {selectedProject && ( -
-

• 공종별 조선 아이템을 선택하세요.

-

같은 아이템명의 다른 자재코드들은 하나의 RFQ로 그룹핑됩니다.

-

• 그룹핑된 자재코드들은 comma로 구분되어 저장됩니다.

-

• 자재코드 길이는 최대 255자까지 가능합니다.

-

• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.

-
- )} */} - -
- -
+ + {/* Footer - Sticky 버튼 영역 */} +
+
+ + +
+
) diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index dbaeae0c..ba530fe3 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -15,9 +15,9 @@ import { Button } from "@/components/ui/button" import { Loader2, UserPlus, BarChart2, Send, Trash2 } from "lucide-react" import { ClientDataTable } from "@/components/client-data-table/data-table" import { AddVendorDialog } from "./add-vendor-dialog" -import { DeleteVendorsDialog } from "./delete-vendors-dialog" import { VendorCommunicationDrawer } from "./vendor-communication-drawer" import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" +import { DeleteVendorsDialog } from "../delete-vendors-dialog" // 기본적인 RFQ 타입 정의 interface TechSalesRfq { diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx index 4f7bd499..dfb85420 100644 --- a/lib/techsales-rfq/table/rfq-table-column.tsx +++ b/lib/techsales-rfq/table/rfq-table-column.tsx @@ -30,8 +30,40 @@ type TechSalesRfq = { updatedByName: string sentBy: number | null sentByName: string | null - projectSnapshot: Record - seriesSnapshot: Record + // 스키마와 일치하도록 타입 수정 + projectSnapshot: { + pspid: string; + projNm?: string; + sector?: string; + projMsrm?: number; + kunnr?: string; + kunnrNm?: string; + cls1?: string; + cls1Nm?: string; + ptype?: string; + ptypeNm?: string; + pmodelCd?: string; + pmodelNm?: string; + pmodelSz?: string; + pmodelUom?: string; + txt04?: string; + txt30?: string; + estmPm?: string; + pspCreatedAt?: Date | string; + pspUpdatedAt?: Date | string; + } | Record // legacy 호환성을 위해 유지 + seriesSnapshot: Array<{ + pspid: string; + sersNo: string; + scDt?: string; + klDt?: string; + lcDt?: string; + dlDt?: string; + dockNo?: string; + dockNm?: string; + projNo?: string; + post1?: string; + }> | Record // legacy 호환성을 위해 유지 pspid: string projNm: string sector: string @@ -43,11 +75,6 @@ type TechSalesRfq = { [key: string]: unknown } -// 프로젝트 상세정보 타입 추가를 위한 확장 -// interface ExtendedDataTableRowAction extends DataTableRowAction { -// type: DataTableRowAction["type"] | "project-detail" -// } - interface GetColumnsProps { setRowAction: React.Dispatch | null>>; openAttachmentsSheet: (rfqId: number) => void; diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx index c79e2ecf..f1570577 100644 --- a/lib/techsales-rfq/table/rfq-table.tsx +++ b/lib/techsales-rfq/table/rfq-table.tsx @@ -51,8 +51,40 @@ interface TechSalesRfq { updatedByName: string sentBy: number | null sentByName: string | null - projectSnapshot: Record - seriesSnapshot: Record + // 스키마와 일치하도록 타입 수정 + projectSnapshot: { + pspid: string; + projNm?: string; + sector?: string; + projMsrm?: number; + kunnr?: string; + kunnrNm?: string; + cls1?: string; + cls1Nm?: string; + ptype?: string; + ptypeNm?: string; + pmodelCd?: string; + pmodelNm?: string; + pmodelSz?: string; + pmodelUom?: string; + txt04?: string; + txt30?: string; + estmPm?: string; + pspCreatedAt?: Date | string; + pspUpdatedAt?: Date | string; + } | Record // legacy 호환성을 위해 유지 + seriesSnapshot: Array<{ + pspid: string; + sersNo: string; + scDt?: string; + klDt?: string; + lcDt?: string; + dlDt?: string; + dockNo?: string; + dockNm?: string; + projNo?: string; + post1?: string; + }> | Record // legacy 호환성을 위해 유지 pspid: string projNm: string sector: string @@ -61,7 +93,6 @@ interface TechSalesRfq { attachmentCount: number quotationCount: number // 필요에 따라 다른 필드들 추가 - // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: unknown } @@ -279,7 +310,7 @@ export function RFQListTable({ setSelectedRfqForAttachments({ ...rfq, projectSnapshot: rfq.projectSnapshot || {}, - seriesSnapshot: rfq.seriesSnapshot || {}, + seriesSnapshot: Array.isArray(rfq.seriesSnapshot) ? rfq.seriesSnapshot : {}, }) setAttachmentsOpen(true) } catch (error) { @@ -290,19 +321,15 @@ export function RFQListTable({ // 첨부파일 업데이트 콜백 const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => { - // TODO: 실제로는 테이블 데이터를 다시 조회하거나 상태를 업데이트해야 함 - // 현재는 로그만 출력하고 토스트 메시지로 피드백 제공 + // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨 console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`) - // 성공 피드백 (중복되지 않도록 짧은 지연 후 표시) + // 성공 피드백 setTimeout(() => { toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, { duration: 3000 }) }, 500) - - // TODO: 나중에 실제 테이블 데이터 업데이트 로직 구현 - // 예: setTableData() 또는 데이터 재조회 }, []) const columns = React.useMemo( @@ -518,7 +545,8 @@ export function RFQListTable({ shallow={false} >
- + {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */} + {/* presets={presets} activePresetId={activePresetId} currentSettings={currentSettings} @@ -530,7 +558,7 @@ export function RFQListTable({ onApplyPreset={applyPreset} onSetDefaultPreset={setDefaultPreset} onRenamePreset={renamePreset} - /> + /> */}