summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-30 03:36:16 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-30 03:36:16 +0000
commit2fdce8d7a57c792bba0ac36fa554dca9c9cc31e3 (patch)
tree32383a85f691c04619abafc4bd9dedf269e78128
parent6ce9a6e26fea92f82ab26cf4bb0837f170162dd0 (diff)
(김준회) 기술영업 조선 RFQ 오류 수정 및 선종 필터 기능 추가
-rw-r--r--lib/tbe-tech/table/tbe-table.tsx2
-rw-r--r--lib/techsales-rfq/service.ts149
-rw-r--r--lib/techsales-rfq/table/create-rfq-dialog.tsx749
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx2
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx41
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx52
6 files changed, 707 insertions, 288 deletions
diff --git a/lib/tbe-tech/table/tbe-table.tsx b/lib/tbe-tech/table/tbe-table.tsx
index 3537f16a..16f86786 100644
--- a/lib/tbe-tech/table/tbe-table.tsx
+++ b/lib/tbe-tech/table/tbe-table.tsx
@@ -145,7 +145,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
// -----------------------------------------------------------
// 댓글 시트 열기
// -----------------------------------------------------------
- async function openCommentSheet(vendorId: number, rfqId: number, tbeId: number) {
+ async function openCommentSheet(vendorId: number, rfqId: number, tbeId?: number) {
setInitialComments([])
setIsLoadingComments(true)
const comments = rowAction?.row.original.comments?.filter(c => c.evaluationId === tbeId)
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<typeof techSalesRfqs>[], 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<WorkType | null>(null)
+ const [selectedShipType, setSelectedShipType] = React.useState<string | null>(null)
const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([])
- const [isSearchingItems, setIsSearchingItems] = React.useState(false)
// 데이터 상태
const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
- const [availableItems, setAvailableItems] = React.useState<ShipbuildingItem[]>([])
+ const [allItems, setAllItems] = React.useState<ShipbuildingItem[]>([])
+ const [shipTypes, setShipTypes] = React.useState<string[]>([])
const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+ const [dataLoadError, setDataLoadError] = React.useState<string | null>(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<CreateRfqFormValues>({
@@ -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 (
- <Dialog
+ <Dialog
open={isDialogOpen}
onOpenChange={(open) => {
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) {
<span className="hidden sm:inline">RFQ 생성</span>
</Button>
</DialogTrigger>
- <DialogContent className="max-w-4xl w-[90vw] h-[90vh] overflow-hidden flex flex-col">
- <DialogHeader>
+ <DialogContent
+ className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
+ style={{ width: '1200px' }}
+ >
+ <DialogHeader className="border-b pb-4">
<DialogTitle>RFQ 생성</DialogTitle>
</DialogHeader>
- <div className="flex-1 overflow-y-auto">
+ <div className="space-y-6 p-1 overflow-y-auto">
<Form {...form}>
- <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-4">
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
{/* 프로젝트 선택 */}
- <FormField
- control={form.control}
- name="biddingProjectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>입찰 프로젝트</FormLabel>
- <FormControl>
- <EstimateProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="입찰 프로젝트를 선택하세요"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- {/* 마감일 설정 */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>마감일</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, "PPP", { locale: ko })
- ) : (
- <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
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="biddingProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰 프로젝트</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="입찰 프로젝트를 선택하세요"
/>
- </PopoverContent>
- </Popover>
- <FormDescription>
- 벤더가 견적을 제출해야 하는 마감일입니다.
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- {!selectedProject ? (
- <div className="text-sm text-muted-foreground italic text-center py-8">
- 먼저 프로젝트를 선택해주세요
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* 선종 선택 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>선종 선택</FormLabel>
+ </div>
+
+ {/* 데이터 로딩 에러 표시 */}
+ {dataLoadError && (
+ <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <X className="h-4 w-4 text-destructive" />
+ <span className="text-sm text-destructive">{dataLoadError}</span>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8 text-xs"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="w-full justify-between"
+ disabled={!selectedProject || isLoadingItems || dataLoadError !== null}
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ 데이터 로딩 중...
+ </>
+ ) : dataLoadError ? (
+ "데이터 로딩 실패"
+ ) : selectedShipType ? (
+ selectedShipType
+ ) : (
+ "미선택 (전체)"
+ )}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-full max-h-60 overflow-y-auto">
+ <DropdownMenuCheckboxItem
+ checked={selectedShipType === null}
+ onCheckedChange={() => {
+ setSelectedShipType(null)
+ setSelectedItems([])
+ form.setValue("materialCodes", [])
+ }}
+ >
+ 전체 선종
+ </DropdownMenuCheckboxItem>
+ {availableShipTypes.map(shipType => (
+ <DropdownMenuCheckboxItem
+ key={shipType}
+ checked={selectedShipType === shipType}
+ onCheckedChange={() => {
+ setSelectedShipType(shipType)
+ setSelectedItems([])
+ form.setValue("materialCodes", [])
+ }}
+ >
+ {shipType}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
- ) : (
+
+ <Separator className="my-4" />
+
+ {/* 마감일 설정 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</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, "PPP", { locale: ko })
+ ) : (
+ <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>
+ )}
+ />
+
+ <Separator className="my-4" />
+
<div className="space-y-6">
{/* 아이템 선택 영역 */}
<div className="space-y-4">
<div>
<FormLabel>조선 아이템 선택</FormLabel>
<FormDescription>
- 공종별 아이템을 선택하세요
+ {selectedShipType ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` : "먼저 선종을 선택해주세요"}
</FormDescription>
</div>
@@ -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 && (
<Button
@@ -415,19 +604,21 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setItemSearchQuery("")}
+ disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
>
<X className="h-4 w-4" />
</Button>
)}
- {isSearchingItems && (
- <Loader2 className="absolute right-8 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
- )}
</div>
{/* 공종 필터 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
- <Button variant="outline" className="gap-1">
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
+ >
{selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
@@ -457,10 +648,41 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
<div className="border rounded-md">
<ScrollArea className="h-[300px]">
<div className="p-2 space-y-1">
- {isLoadingItems ? (
+ {dataLoadError ? (
+ <div className="text-center py-8">
+ <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
+ <div className="flex flex-col items-center gap-3">
+ <X className="h-8 w-8 text-destructive" />
+ <div className="text-center">
+ <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
+ <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : isLoadingItems ? (
<div className="text-center py-8 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
아이템을 불러오는 중...
+ {retryCount > 0 && (
+ <p className="text-xs mt-1">재시도 {retryCount}회</p>
+ )}
</div>
) : availableItems.length > 0 ? (
[...availableItems]
@@ -547,146 +769,149 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
/>
{/* RFQ 그룹핑 미리보기 */}
- {selectedItems.length > 0 && (
- <div className="space-y-3">
- <FormLabel>생성될 RFQ 그룹 미리보기</FormLabel>
- <div className="border rounded-md p-3 bg-background">
- {(() => {
- // 아이템명(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<string, typeof selectedItems>)
+ <div className="space-y-3">
+ <FormLabel>생성될 RFQ 그룹 미리보기</FormLabel>
+ <div className="border rounded-md bg-background">
+ {(() => {
+ // 아이템명(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<string, typeof selectedItems>)
- 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 (
- <div className="space-y-3">
- <div className="text-sm text-muted-foreground">
- 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑)
- </div>
- {rfqGroups.map((group, index) => (
- <div
- key={group.actualItemName}
- className={cn(
- "p-3 border rounded-md space-y-2",
- group.isOverLimit && "border-destructive bg-destructive/5"
- )}
- >
- <div className="flex items-center justify-between">
- <div className="font-medium">
- RFQ #{index + 1}: {group.actualItemName}
- </div>
- <Badge variant={group.isOverLimit ? "destructive" : "secondary"}>
- {group.itemCodes.length}개 자재코드 ({group.codeLength}/255자)
- </Badge>
- </div>
- <div className="text-sm text-muted-foreground">
- 자재코드: {group.joinedItemCodes}
- </div>
- {group.isOverLimit && (
- <div className="text-sm text-destructive">
- ⚠️ 자재코드 길이가 255자를 초과합니다. 일부 아이템을 제거해주세요.
- </div>
- )}
- <div className="text-xs text-muted-foreground">
- 포함된 아이템: {group.items.map(item => `${item.itemCode}`).join(', ')}
- </div>
- </div>
- ))}
+ return (
+ <div className="space-y-3">
+ <div className="text-sm text-muted-foreground p-3 border-b">
+ 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑)
</div>
- )
- })()}
- </div>
+ <ScrollArea className="h-[200px]">
+ <Table>
+ <TableHeader className="sticky top-0 bg-background">
+ <TableRow>
+ <TableHead className="w-[80px]">RFQ #</TableHead>
+ <TableHead>아이템명</TableHead>
+ <TableHead className="w-[120px]">자재그룹코드 개수</TableHead>
+ <TableHead className="w-[100px]">길이</TableHead>
+ <TableHead className="w-[80px]">상태</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {rfqGroups.map((group, index) => (
+ <TableRow
+ key={group.actualItemName}
+ className={group.isOverLimit ? "bg-destructive/5" : ""}
+ >
+ <TableCell className="font-medium">#{index + 1}</TableCell>
+ <TableCell>
+ <div className="max-w-[200px] truncate" title={group.actualItemName}>
+ {group.actualItemName}
+ </div>
+ </TableCell>
+ <TableCell>{group.itemCodes.length}개</TableCell>
+ <TableCell>
+ <span className={group.isOverLimit ? "text-destructive font-medium" : ""}>
+ {group.codeLength}/255자
+ </span>
+ </TableCell>
+ <TableCell>
+ {group.isOverLimit ? (
+ <Badge variant="destructive" className="text-xs">초과</Badge>
+ ) : (
+ <Badge variant="secondary" className="text-xs">정상</Badge>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+ </div>
+ )
+ })()}
</div>
- )}
+ </div>
</div>
</div>
- )}
-
- {/* 안내 메시지 */}
- {/* {selectedProject && (
- <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
- <p>• 공종별 조선 아이템을 선택하세요.</p>
- <p>• <strong>같은 아이템명의 다른 자재코드들은 하나의 RFQ로 그룹핑됩니다.</strong></p>
- <p>• 그룹핑된 자재코드들은 comma로 구분되어 저장됩니다.</p>
- <p>• 자재코드 길이는 최대 255자까지 가능합니다.</p>
- <p>• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.</p>
- </div>
- )} */}
-
- <div className="flex justify-end space-x-2 pt-4">
- <Button
- type="button"
- variant="outline"
- onClick={() => setIsDialogOpen(false)}
- disabled={isProcessing}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={
- isProcessing ||
- !selectedProject ||
- selectedItems.length === 0 ||
- // 255자 초과 그룹이 있는지 확인
- (() => {
- const groupedItems = selectedItems.reduce((groups, item) => {
- const actualItemName = item.itemList // 실제 조선 아이템명
- if (!actualItemName) {
- return groups // itemList가 없는 경우 제외
- }
- if (!groups[actualItemName]) {
- groups[actualItemName] = []
- }
- groups[actualItemName].push(item.itemCode)
- return groups
- }, {} as Record<string, string[]>)
-
- return Object.values(groupedItems).some(itemCodes => itemCodes.join(',').length > 255)
- })()
- }
- >
- {isProcessing ? "처리 중..." : (() => {
- const groupedItems = selectedItems.reduce((groups, item) => {
- const actualItemName = item.itemList // 실제 조선 아이템명
- if (!actualItemName) {
- return groups // itemList가 없는 경우 제외
- }
- if (!groups[actualItemName]) {
- groups[actualItemName] = []
- }
- groups[actualItemName].push(item.itemCode)
- return groups
- }, {} as Record<string, string[]>)
-
- const groupCount = Object.keys(groupedItems).length
- return `${groupCount}개 아이템 그룹으로 생성하기`
- })()}
- </Button>
</div>
</form>
</Form>
</div>
+
+ {/* Footer - Sticky 버튼 영역 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(handleCreateRfq)}
+ disabled={
+ isProcessing ||
+ !selectedProject ||
+ selectedItems.length === 0 ||
+ // 255자 초과 그룹이 있는지 확인
+ (() => {
+ const groupedItems = selectedItems.reduce((groups, item) => {
+ const actualItemName = item.itemList // 실제 조선 아이템명
+ if (!actualItemName) {
+ return groups // itemList가 없는 경우 제외
+ }
+ if (!groups[actualItemName]) {
+ groups[actualItemName] = []
+ }
+ groups[actualItemName].push(item.itemCode)
+ return groups
+ }, {} as Record<string, string[]>)
+
+ return Object.values(groupedItems).some(itemCodes => itemCodes.join(',').length > 255)
+ })()
+ }
+ >
+ {isProcessing ? "처리 중..." : (() => {
+ const groupedItems = selectedItems.reduce((groups, item) => {
+ const actualItemName = item.itemList // 실제 조선 아이템명
+ if (!actualItemName) {
+ return groups // itemList가 없는 경우 제외
+ }
+ if (!groups[actualItemName]) {
+ groups[actualItemName] = []
+ }
+ groups[actualItemName].push(item.itemCode)
+ return groups
+ }, {} as Record<string, string[]>)
+
+ const groupCount = Object.keys(groupedItems).length
+ return `${groupCount}개 아이템 그룹으로 생성하기`
+ })()}
+ </Button>
+ </div>
+ </div>
</DialogContent>
</Dialog>
)
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<string, unknown>
- seriesSnapshot: Record<string, unknown>
+ // 스키마와 일치하도록 타입 수정
+ 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<string, unknown> // legacy 호환성을 위해 유지
+ seriesSnapshot: Array<{
+ pspid: string;
+ sersNo: string;
+ scDt?: string;
+ klDt?: string;
+ lcDt?: string;
+ dlDt?: string;
+ dockNo?: string;
+ dockNm?: string;
+ projNo?: string;
+ post1?: string;
+ }> | Record<string, unknown> // legacy 호환성을 위해 유지
pspid: string
projNm: string
sector: string
@@ -43,11 +75,6 @@ type TechSalesRfq = {
[key: string]: unknown
}
-// 프로젝트 상세정보 타입 추가를 위한 확장
-// interface ExtendedDataTableRowAction<TData> extends DataTableRowAction<TData> {
-// type: DataTableRowAction<TData>["type"] | "project-detail"
-// }
-
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | 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<string, unknown>
- seriesSnapshot: Record<string, unknown>
+ // 스키마와 일치하도록 타입 수정
+ 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<string, unknown> // legacy 호환성을 위해 유지
+ seriesSnapshot: Array<{
+ pspid: string;
+ sersNo: string;
+ scDt?: string;
+ klDt?: string;
+ lcDt?: string;
+ dlDt?: string;
+ dockNo?: string;
+ dockNm?: string;
+ projNo?: string;
+ post1?: string;
+ }> | Record<string, unknown> // 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}
>
<div className="flex items-center gap-2">
- <TablePresetManager<TechSalesRfq>
+ {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */}
+ {/* <TablePresetManager<TechSalesRfq>
presets={presets}
activePresetId={activePresetId}
currentSettings={currentSettings}
@@ -530,7 +558,7 @@ export function RFQListTable({
onApplyPreset={applyPreset}
onSetDefaultPreset={setDefaultPreset}
onRenamePreset={renamePreset}
- />
+ /> */}
<RFQTableToolbarActions
selection={table}