diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-08 03:08:19 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-08 03:08:19 +0000 |
| commit | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (patch) | |
| tree | f84750fa6cac954d5e31221fc47a54c655fc06a9 /lib/rfqs | |
| parent | 230ce796836c25df26c130dbcd616ef97d12b2ec (diff) | |
로그인 및 미들웨어 처리. 구조 변경
Diffstat (limited to 'lib/rfqs')
| -rw-r--r-- | lib/rfqs/service.ts | 122 | ||||
| -rw-r--r-- | lib/rfqs/table/ParentRfqSelector.tsx (renamed from lib/rfqs/table/BudgetaryRfqSelector.tsx) | 118 | ||||
| -rw-r--r-- | lib/rfqs/table/add-rfq-dialog.tsx | 227 | ||||
| -rw-r--r-- | lib/rfqs/table/rfqs-table.tsx | 1 | ||||
| -rw-r--r-- | lib/rfqs/table/update-rfq-sheet.tsx | 169 | ||||
| -rw-r--r-- | lib/rfqs/validations.ts | 10 |
6 files changed, 490 insertions, 157 deletions
diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts index 6b8b4738..b56349e2 100644 --- a/lib/rfqs/service.ts +++ b/lib/rfqs/service.ts @@ -208,6 +208,7 @@ export async function modifyRfq(input: UpdateRfqSchema & { id: number }) { rfqCode: input.rfqCode, projectId: input.projectId || null, dueDate: input.dueDate, + rfqType: input.rfqType, status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", createdBy: input.createdBy, }); @@ -1246,6 +1247,11 @@ export async function getTBE(input: GetTBESchema, rfqId: number) { } export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { + + if (isNaN(vendorId) || vendorId === null || vendorId === undefined) { + throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다"); + } + return unstable_cache( async () => { // 1) 페이징 @@ -1801,13 +1807,6 @@ export interface BudgetaryRfq { projectName: string | null; } -interface GetBudgetaryRfqsParams { - search?: string; - projectId?: number; - limit?: number; - offset?: number; -} - type GetBudgetaryRfqsResponse = | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never } | { error: string; rfqs?: never; totalCount: number } @@ -1816,16 +1815,40 @@ type GetBudgetaryRfqsResponse = * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함 * 페이징 및 필터링 기능 포함 */ +export interface GetBudgetaryRfqsParams { + search?: string; + projectId?: number; + rfqId?: number; // 특정 ID로 단일 RFQ 검색 + rfqTypes?: RfqType[]; // 특정 RFQ 타입들로 필터링 + limit?: number; + offset?: number; +} + export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> { - const { search, projectId, limit = 50, offset = 0 } = params; - const cacheKey = `budgetary-rfqs-${JSON.stringify(params)}`; + const { search, projectId, rfqId, rfqTypes, limit = 50, offset = 0 } = params; + const cacheKey = `rfqs-query-${JSON.stringify(params)}`; + return unstable_cache( async () => { try { - - const baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY); - - let where1 + // 기본 검색 조건 구성 + let baseCondition; + + // 특정 RFQ 타입들로 필터링 (rfqTypes 배열이 주어진 경우) + if (rfqTypes && rfqTypes.length > 0) { + // 여러 타입으로 필터링 (OR 조건) + baseCondition = inArray(rfqs.rfqType, rfqTypes); + } else { + // 기본적으로 BUDGETARY 타입만 검색 (이전 동작 유지) + baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY); + } + + // 특정 ID로 검색하는 경우 + if (rfqId) { + baseCondition = and(baseCondition, eq(rfqs.id, rfqId)); + } + + let where1; // 검색어 조건 추가 (있을 경우) if (search && search.trim()) { const searchTerm = `%${search.trim()}%`; @@ -1835,30 +1858,31 @@ export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Pro ilike(projects.code, searchTerm), ilike(projects.name, searchTerm) ); - where1 = searchCondition + where1 = searchCondition; } - - let where2 + + let where2; // 프로젝트 ID 조건 추가 (있을 경우) if (projectId) { where2 = eq(rfqs.projectId, projectId); } - - const finalWhere = and(where1, where2, baseCondition) - + + const finalWhere = and(baseCondition, where1, where2); + // 총 개수 조회 const [countResult] = await db .select({ count: count() }) .from(rfqs) .leftJoin(projects, eq(rfqs.projectId, projects.id)) .where(finalWhere); - + // 실제 데이터 조회 - const budgetaryRfqs = await db + const resultRfqs = await db .select({ id: rfqs.id, rfqCode: rfqs.rfqCode, description: rfqs.description, + rfqType: rfqs.rfqType, // RFQ 타입 필드 추가 projectId: rfqs.projectId, projectCode: projects.code, projectName: projects.name, @@ -1869,15 +1893,15 @@ export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Pro .orderBy(desc(rfqs.createdAt)) .limit(limit) .offset(offset); - + return { - rfqs: budgetaryRfqs as BudgetaryRfq[], // 타입 단언으로 호환성 보장 + rfqs: resultRfqs, totalCount: Number(countResult?.count) || 0 }; } catch (error) { - console.error("Error fetching budgetary RFQs:", error); + console.error("Error fetching RFQs:", error); return { - error: "Failed to fetch budgetary RFQs", + error: "Failed to fetch RFQs", totalCount: 0 }; } @@ -1885,11 +1909,10 @@ export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Pro [cacheKey], { revalidate: 60, // 1분 캐시 - tags: ["rfqs-budgetary"], + tags: ["rfqs-query"], } )(); } - export async function getAllVendors() { // Adjust the query as needed (add WHERE, ORDER, etc.) const allVendors = await db.select().from(vendors) @@ -2812,4 +2835,49 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { tags: ["cbe-vendors"], } )(); +} + + +export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> { + try { + if (!rfqType) { + return { code: "", error: 'RFQ 타입이 필요합니다' }; + } + + // 현재 연도 가져오기 + const currentYear = new Date().getFullYear(); + + // 현재 연도와 타입에 맞는 최신 RFQ 코드 찾기 + const latestRfqs = await db.select({ rfqCode: rfqs.rfqCode }) + .from(rfqs) + .where(and( + sql`SUBSTRING(${rfqs.rfqCode}, 5, 4) = ${currentYear.toString()}`, + eq(rfqs.rfqType, rfqType) + )) + .orderBy(desc(rfqs.rfqCode)) + .limit(1); + + let sequenceNumber = 1; + + if (latestRfqs.length > 0 && latestRfqs[0].rfqCode) { + // null 체크 추가 - TypeScript 오류 해결 + const latestCode = latestRfqs[0].rfqCode; + const matches = latestCode.match(/[A-Z]+-\d{4}-(\d{3})/); + + if (matches && matches[1]) { + sequenceNumber = parseInt(matches[1], 10) + 1; + } + } + + // 새로운 RFQ 코드 포맷팅 + const typePrefix = rfqType === RfqType.BUDGETARY ? 'BUD' : + rfqType === RfqType.PURCHASE_BUDGETARY ? 'PBU' : 'RFQ'; + + const newCode = `${typePrefix}-${currentYear}-${String(sequenceNumber).padStart(3, '0')}`; + + return { code: newCode }; + } catch (error) { + console.error('Error generating next RFQ code:', error); + return { code: "", error: '코드 생성에 실패했습니다' }; + } }
\ No newline at end of file diff --git a/lib/rfqs/table/BudgetaryRfqSelector.tsx b/lib/rfqs/table/ParentRfqSelector.tsx index cea53c1d..0edb1233 100644 --- a/lib/rfqs/table/BudgetaryRfqSelector.tsx +++ b/lib/rfqs/table/ParentRfqSelector.tsx @@ -8,48 +8,70 @@ import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover import { cn } from "@/lib/utils" import { useDebounce } from "@/hooks/use-debounce" import { getBudgetaryRfqs, type BudgetaryRfq } from "../service" +import { RfqType } from "../validations" -interface BudgetaryRfqSelectorProps { +// ParentRfq 타입 정의 (서비스의 BudgetaryRfq와 호환되어야 함) +interface ParentRfq { + id: number; + rfqCode: string; + description: string | null; + rfqType: RfqType; + projectId: number | null; + projectCode: string | null; + projectName: string | null; +} + +interface ParentRfqSelectorProps { selectedRfqId?: number; - onRfqSelect: (rfq: BudgetaryRfq | null) => void; + onRfqSelect: (rfq: ParentRfq | null) => void; + rfqType: RfqType; // 현재 생성 중인 RFQ 타입 + parentRfqTypes: RfqType[]; // 선택 가능한 부모 RFQ 타입 목록 placeholder?: string; } -export function BudgetaryRfqSelector({ +export function ParentRfqSelector({ selectedRfqId, onRfqSelect, - placeholder = "Budgetary RFQ 선택..." -}: BudgetaryRfqSelectorProps) { + rfqType, + parentRfqTypes, + placeholder = "부모 RFQ 선택..." +}: ParentRfqSelectorProps) { const [searchTerm, setSearchTerm] = React.useState(""); const debouncedSearchTerm = useDebounce(searchTerm, 300); const [open, setOpen] = React.useState(false); const [loading, setLoading] = React.useState(false); - const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]); - const [selectedRfq, setSelectedRfq] = React.useState<BudgetaryRfq | null>(null); + const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]); + const [selectedRfq, setSelectedRfq] = React.useState<ParentRfq | null>(null); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(true); const [totalCount, setTotalCount] = React.useState(0); const listRef = React.useRef<HTMLDivElement>(null); + // 타입별로 적절한 검색 placeholder 생성 + const getSearchPlaceholder = () => { + if (rfqType === RfqType.PURCHASE) { + return "BUDGETARY/PURCHASE_BUDGETARY RFQ 검색..."; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "BUDGETARY RFQ 검색..."; + } + return "RFQ 코드/설명/프로젝트 검색..."; + }; + // 초기 선택된 RFQ가 있을 경우 로드 React.useEffect(() => { if (selectedRfqId && open) { const loadSelectedRfq = async () => { try { + // 단일 RFQ를 id로 조회하는 API 호출 const result = await getBudgetaryRfqs({ limit: 1, - // null을 undefined로 변환하여 타입 오류 해결 - projectId: selectedRfq?.projectId ?? undefined + rfqId: selectedRfqId }); - if ('rfqs' in result && result.rfqs) { - // 옵셔널 체이닝 또는 조건부 검사로 undefined 체크 - const foundRfq = result.rfqs.find(rfq => rfq.id === selectedRfqId); - if (foundRfq) { - setSelectedRfq(foundRfq); - } + if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) { + setSelectedRfq(result.rfqs[0] as unknown as ParentRfq); } } catch (error) { console.error("선택된 RFQ 로드 오류:", error); @@ -67,14 +89,14 @@ export function BudgetaryRfqSelector({ if (open) { setPage(1); setHasMore(true); - setBudgetaryRfqs([]); - loadBudgetaryRfqs(1, true); + setParentRfqs([]); + loadParentRfqs(1, true); } - }, [debouncedSearchTerm, open]); + }, [debouncedSearchTerm, open, parentRfqTypes]); // 데이터 로드 함수 - const loadBudgetaryRfqs = async (pageToLoad: number, reset = false) => { - if (!open) return; + const loadParentRfqs = async (pageToLoad: number, reset = false) => { + if (!open || parentRfqTypes.length === 0) return; setLoading(true); try { @@ -83,13 +105,14 @@ export function BudgetaryRfqSelector({ search: debouncedSearchTerm, limit, offset: (pageToLoad - 1) * limit, + rfqTypes: parentRfqTypes // 현재 RFQ 타입에 맞는 부모 RFQ 타입들로 필터링 }); if ('rfqs' in result && result.rfqs) { if (reset) { - setBudgetaryRfqs(result.rfqs); + setParentRfqs(result.rfqs as unknown as ParentRfq[]); } else { - setBudgetaryRfqs(prev => [...prev, ...result.rfqs]); + setParentRfqs(prev => [...prev, ...(result.rfqs as unknown as ParentRfq[])]); } setTotalCount(result.totalCount); @@ -97,7 +120,7 @@ export function BudgetaryRfqSelector({ setPage(pageToLoad); } } catch (error) { - console.error("Budgetary RFQs 로드 오류:", error); + console.error("부모 RFQ 로드 오류:", error); } finally { setLoading(false); } @@ -110,18 +133,18 @@ export function BudgetaryRfqSelector({ // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드 if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) { - loadBudgetaryRfqs(page + 1); + loadParentRfqs(page + 1); } } }; // RFQ를 프로젝트별로 그룹화하는 함수 - const groupRfqsByProject = (rfqs: BudgetaryRfq[]) => { + const groupRfqsByProject = (rfqs: ParentRfq[]) => { const groups: Record<string, { projectId: number | null; projectCode: string | null; projectName: string | null; - rfqs: BudgetaryRfq[]; + rfqs: ParentRfq[]; }> = {}; // 'No Project' 그룹 기본 생성 @@ -154,16 +177,30 @@ export function BudgetaryRfqSelector({ // 그룹화된 RFQ 목록 const groupedRfqs = React.useMemo(() => { - return groupRfqsByProject(budgetaryRfqs); - }, [budgetaryRfqs]); + return groupRfqsByProject(parentRfqs); + }, [parentRfqs]); // RFQ 선택 처리 - const handleRfqSelect = (rfq: BudgetaryRfq | null) => { + const handleRfqSelect = (rfq: ParentRfq | null) => { setSelectedRfq(rfq); onRfqSelect(rfq); setOpen(false); }; + // RFQ 타입에 따른 표시 형식 + const getRfqTypeLabel = (type: RfqType) => { + switch(type) { + case RfqType.BUDGETARY: + return "BUDGETARY"; + case RfqType.PURCHASE_BUDGETARY: + return "PURCHASE_BUDGETARY"; + case RfqType.PURCHASE: + return "PURCHASE"; + default: + return type; + } + }; + return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> @@ -182,7 +219,7 @@ export function BudgetaryRfqSelector({ <PopoverContent className="w-[400px] p-0"> <Command> <CommandInput - placeholder="Budgetary RFQ 코드/설명/프로젝트 검색..." + placeholder={getSearchPlaceholder()} value={searchTerm} onValueChange={setSearchTerm} /> @@ -233,10 +270,19 @@ export function BudgetaryRfqSelector({ : "opacity-0" )} /> - <span className="font-medium">{rfq.rfqCode || ""}</span> - <span className="ml-2 text-gray-500 truncate"> - - {rfq.description || ""} - </span> + <div className="flex flex-col"> + <div className="flex items-center"> + <span className="font-medium">{rfq.rfqCode || ""}</span> + <span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-700"> + {getRfqTypeLabel(rfq.rfqType)} + </span> + </div> + {rfq.description && ( + <span className="text-sm text-gray-500 truncate"> + {rfq.description} + </span> + )} + </div> </CommandItem> ))} </CommandGroup> @@ -248,9 +294,9 @@ export function BudgetaryRfqSelector({ </div> )} - {!loading && !hasMore && budgetaryRfqs.length > 0 && ( + {!loading && !hasMore && parentRfqs.length > 0 && ( <div className="py-2 text-center text-sm text-muted-foreground"> - 총 {totalCount}개 중 {budgetaryRfqs.length}개 표시됨 + 총 {totalCount}개 중 {parentRfqs.length}개 표시됨 </div> )} </CommandList> diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx index 45390cd0..41055608 100644 --- a/lib/rfqs/table/add-rfq-dialog.tsx +++ b/lib/rfqs/table/add-rfq-dialog.tsx @@ -3,38 +3,29 @@ import * as React from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" -import { Check, ChevronsUpDown } from "lucide-react" import { toast } from "sonner" import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" import { useSession } from "next-auth/react" import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations" -import { createRfq, getBudgetaryRfqs } from "../service" +import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service" import { ProjectSelector } from "@/components/ProjectSelector" import { type Project } from "../service" -import { cn } from "@/lib/utils" -import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector" -import { type BudgetaryRfq as ServiceBudgetaryRfq } from "../service"; +import { ParentRfqSelector } from "./ParentRfqSelector" // 부모 RFQ 정보 타입 정의 -interface BudgetaryRfq { +interface ParentRfq { id: number; rfqCode: string; description: string | null; + rfqType: RfqType; + projectId: number | null; + projectCode: string | null; + projectName: string | null; } interface AddRfqDialogProps { @@ -44,11 +35,10 @@ interface AddRfqDialogProps { export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) { const [open, setOpen] = React.useState(false) const { data: session, status } = useSession() - const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]) - const [isLoadingBudgetary, setIsLoadingBudgetary] = React.useState(false) - const [budgetarySearchOpen, setBudgetarySearchOpen] = React.useState(false) - const [budgetarySearchTerm, setBudgetarySearchTerm] = React.useState("") - const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null) + const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]) + const [isLoadingParents, setIsLoadingParents] = React.useState(false) + const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null) + const [isLoadingRfqCode, setIsLoadingRfqCode] = React.useState(false) // Get the user ID safely, ensuring it's a valid number const userId = React.useMemo(() => { @@ -64,9 +54,30 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) // RfqType에 따른 타이틀 생성 const getTitle = () => { - return rfqType === RfqType.PURCHASE - ? "Purchase RFQ" - : "Budgetary RFQ"; + switch(rfqType) { + case RfqType.PURCHASE: + return "Purchase RFQ"; + case RfqType.BUDGETARY: + return "Budgetary RFQ"; + case RfqType.PURCHASE_BUDGETARY: + return "Purchase Budgetary RFQ"; + default: + return "RFQ"; + } + }; + + // RfqType 설명 가져오기 + const getTypeDescription = () => { + switch(rfqType) { + case RfqType.PURCHASE: + return "실제 구매 발주 전에 가격을 요청"; + case RfqType.BUDGETARY: + return "기술영업 단계에서 입찰가 산정을 위한 견적 요청"; + case RfqType.PURCHASE_BUDGETARY: + return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청"; + default: + return ""; + } }; // RHF + Zod @@ -92,40 +103,79 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) } }, [status, userId, form]); - // Budgetary RFQ 목록 로드 (Purchase RFQ 생성 시만) + // 다이얼로그가 열릴 때 자동으로 RFQ 코드 생성 React.useEffect(() => { - if (rfqType === RfqType.PURCHASE && open) { - const loadBudgetaryRfqs = async () => { - setIsLoadingBudgetary(true); + if (open) { + const generateRfqCode = async () => { + setIsLoadingRfqCode(true); try { - const result = await getBudgetaryRfqs(); - if ('rfqs' in result) { - setBudgetaryRfqs(result.rfqs as unknown as BudgetaryRfq[]); - } else if ('error' in result) { - console.error("Budgetary RFQs 로드 오류:", result.error); + // 서버 액션 호출 + const result = await generateNextRfqCode(rfqType); + + if (result.error) { + toast.error(`RFQ 코드 생성 실패: ${result.error}`); + return; } + + // 생성된 코드를 폼에 설정 + form.setValue("rfqCode", result.code); } catch (error) { - console.error("Budgetary RFQs 로드 오류:", error); + console.error("RFQ 코드 생성 오류:", error); + toast.error("RFQ 코드 생성에 실패했습니다"); } finally { - setIsLoadingBudgetary(false); + setIsLoadingRfqCode(false); } }; - - loadBudgetaryRfqs(); + + generateRfqCode(); } - }, [rfqType, open]); + }, [open, rfqType, form]); + + // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 + const getParentRfqTypes = (): RfqType[] => { + switch(rfqType) { + case RfqType.PURCHASE: + // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 + return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; + case RfqType.PURCHASE_BUDGETARY: + // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음 + return [RfqType.BUDGETARY]; + default: + return []; + } + }; - // 검색어로 필터링된 Budgetary RFQ 목록 - const filteredBudgetaryRfqs = React.useMemo(() => { - if (!budgetarySearchTerm.trim()) return budgetaryRfqs; + // 선택 가능한 부모 RFQ 목록 로드 + React.useEffect(() => { + if ((rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY) && open) { + const loadParentRfqs = async () => { + setIsLoadingParents(true); + try { + // 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기 + const parentTypes = getParentRfqTypes(); + + // 부모 RFQ 타입이 있을 때만 API 호출 + if (parentTypes.length > 0) { + const result = await getBudgetaryRfqs({ + rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요 + }); + + if ('rfqs' in result) { + setParentRfqs(result.rfqs as unknown as ParentRfq[]); + } else if ('error' in result) { + console.error("부모 RFQ 로드 오류:", result.error); + } + } + } catch (error) { + console.error("부모 RFQ 로드 오류:", error); + } finally { + setIsLoadingParents(false); + } + }; - const lowerSearch = budgetarySearchTerm.toLowerCase(); - return budgetaryRfqs.filter( - rfq => - rfq.rfqCode.toLowerCase().includes(lowerSearch) || - (rfq.description && rfq.description.toLowerCase().includes(lowerSearch)) - ); - }, [budgetaryRfqs, budgetarySearchTerm]); + loadParentRfqs(); + } + }, [rfqType, open]); // 프로젝트 선택 처리 const handleProjectSelect = (project: Project | null) => { @@ -136,11 +186,10 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) form.setValue("projectId", project.id); }; - // Budgetary RFQ 선택 처리 - const handleBudgetaryRfqSelect = (rfq: BudgetaryRfq) => { - setSelectedBudgetaryRfq(rfq); - form.setValue("parentRfqId", rfq.id); - setBudgetarySearchOpen(false); + // 부모 RFQ 선택 처리 + const handleParentRfqSelect = (rfq: ParentRfq | null) => { + setSelectedParentRfq(rfq); + form.setValue("parentRfqId", rfq?.id); }; async function onSubmit(data: CreateRfqSchema) { @@ -166,14 +215,14 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) toast.success("RFQ가 성공적으로 생성되었습니다."); form.reset(); - setSelectedBudgetaryRfq(null); + setSelectedParentRfq(null); setOpen(false); } function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { form.reset(); - setSelectedBudgetaryRfq(null); + setSelectedParentRfq(null); } setOpen(nextOpen); } @@ -183,6 +232,28 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) return <Button variant="outline" size="sm" disabled>Loading...</Button>; } + // 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정 + const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; + + // 부모 RFQ 선택기 레이블 및 설명 가져오기 + const getParentRfqSelectorLabel = () => { + if (rfqType === RfqType.PURCHASE) { + return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)"; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "부모 RFQ (BUDGETARY)"; + } + return "부모 RFQ"; + }; + + const getParentRfqDescription = () => { + if (rfqType === RfqType.PURCHASE) { + return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다."; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다."; + } + return ""; + }; + return ( <Dialog open={open} onOpenChange={handleDialogOpenChange}> {/* 모달을 열기 위한 버튼 */} @@ -197,6 +268,9 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) <DialogTitle>Create New {getTitle()}</DialogTitle> <DialogDescription> 새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + <div className="mt-1 text-xs text-muted-foreground"> + {getTypeDescription()} + </div> </DialogDescription> </DialogHeader> @@ -231,31 +305,37 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) )} /> - {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */} - {rfqType === RfqType.PURCHASE && ( + {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */} + {shouldShowParentRfqSelector && ( <FormField control={form.control} name="parentRfqId" render={({ field }) => ( <FormItem> - <FormLabel>Budgetary RFQ (Optional)</FormLabel> + <FormLabel>{getParentRfqSelectorLabel()}</FormLabel> <FormControl> - <BudgetaryRfqSelector + <ParentRfqSelector selectedRfqId={field.value as number | undefined} - onRfqSelect={(rfq) => { - setSelectedBudgetaryRfq(rfq as any); - form.setValue("parentRfqId", rfq?.id); - }} - placeholder="Budgetary RFQ 선택..." + onRfqSelect={handleParentRfqSelect} + rfqType={rfqType} + parentRfqTypes={getParentRfqTypes()} + placeholder={ + rfqType === RfqType.PURCHASE + ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." + : "BUDGETARY RFQ 선택..." + } /> </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + {getParentRfqDescription()} + </div> <FormMessage /> </FormItem> )} /> )} - {/* rfqCode */} + {/* rfqCode - 자동 생성되고 읽기 전용 */} <FormField control={form.control} name="rfqCode" @@ -263,8 +343,23 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) <FormItem> <FormLabel>RFQ Code</FormLabel> <FormControl> - <Input placeholder="e.g. RFQ-2025-001" {...field} /> + <div className="flex"> + <Input + placeholder="자동으로 생성 중..." + {...field} + disabled={true} + className="bg-muted" + /> + {isLoadingRfqCode && ( + <div className="ml-2 flex items-center"> + <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div> + </div> + )} + </div> </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + RFQ 타입과 현재 날짜를 기준으로 자동 생성됩니다 + </div> <FormMessage /> </FormItem> )} diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx index 48c04930..e4ff47d8 100644 --- a/lib/rfqs/table/rfqs-table.tsx +++ b/lib/rfqs/table/rfqs-table.tsx @@ -231,7 +231,6 @@ export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTablePro open={rowAction?.type === "update"} onOpenChange={() => setRowAction(null)} rfq={rowAction?.row.original ?? null} - rfqType={rfqType} /> <DeleteRfqsDialog diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx index 769f25e7..22ca2c37 100644 --- a/lib/rfqs/table/update-rfq-sheet.tsx +++ b/lib/rfqs/table/update-rfq-sheet.tsx @@ -37,31 +37,127 @@ import { Input } from "@/components/ui/input" import { Rfq, RfqWithItemCount } from "@/db/schema/rfq" import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations" -import { modifyRfq } from "../service" +import { modifyRfq, getBudgetaryRfqs } from "../service" import { ProjectSelector } from "@/components/ProjectSelector" import { type Project } from "../service" -import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector" +import { ParentRfqSelector } from "./ParentRfqSelector" interface UpdateRfqSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { rfq: RfqWithItemCount | null - rfqType?: RfqType; } - -interface BudgetaryRfq { +// 부모 RFQ 정보 타입 정의 +interface ParentRfq { id: number; rfqCode: string; description: string | null; + rfqType: RfqType; + projectId: number | null; + projectCode: string | null; + projectName: string | null; } - -export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: UpdateRfqSheetProps) { +export function UpdateRfqSheet({ rfq, ...props }: UpdateRfqSheetProps) { const [isUpdatePending, startUpdateTransition] = React.useTransition() const { data: session } = useSession() const userId = Number(session?.user?.id || 1) - const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null) + const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null) + + // RFQ의 타입 가져오기 + const rfqType = rfq?.rfqType || RfqType.PURCHASE; + + // 초기 부모 RFQ ID 가져오기 + const initialParentRfqId = rfq?.parentRfqId; + + // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 + const getParentRfqTypes = (): RfqType[] => { + switch(rfqType) { + case RfqType.PURCHASE: + // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 + return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; + case RfqType.PURCHASE_BUDGETARY: + // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음 + return [RfqType.BUDGETARY]; + default: + return []; + } + }; + + // 부모 RFQ 타입들 + const parentRfqTypes = getParentRfqTypes(); + + // 부모 RFQ를 보여줄지 결정 + const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; + + // 타입에 따른 타이틀 생성 + const getTypeTitle = () => { + switch(rfqType) { + case RfqType.PURCHASE: + return "Purchase RFQ"; + case RfqType.BUDGETARY: + return "Budgetary RFQ"; + case RfqType.PURCHASE_BUDGETARY: + return "Purchase Budgetary RFQ"; + default: + return "RFQ"; + } + }; + + // 타입 설명 가져오기 + const getTypeDescription = () => { + switch(rfqType) { + case RfqType.PURCHASE: + return "실제 구매 발주 전에 가격을 요청"; + case RfqType.BUDGETARY: + return "기술영업 단계에서 입찰가 산정을 위한 견적 요청"; + case RfqType.PURCHASE_BUDGETARY: + return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청"; + default: + return ""; + } + }; + // 부모 RFQ 선택기 레이블 및 설명 가져오기 + const getParentRfqSelectorLabel = () => { + if (rfqType === RfqType.PURCHASE) { + return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)"; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "부모 RFQ (BUDGETARY)"; + } + return "부모 RFQ"; + }; + + const getParentRfqDescription = () => { + if (rfqType === RfqType.PURCHASE) { + return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다."; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다."; + } + return ""; + }; + + // 초기 부모 RFQ 로드 + React.useEffect(() => { + if (initialParentRfqId && shouldShowParentRfqSelector) { + const loadInitialParentRfq = async () => { + try { + const result = await getBudgetaryRfqs({ + rfqId: initialParentRfqId + }); + + if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) { + setSelectedParentRfq(result.rfqs[0] as unknown as ParentRfq); + } + } catch (error) { + console.error("부모 RFQ 로드 오류:", error); + } + }; + + loadInitialParentRfq(); + } + }, [initialParentRfqId, shouldShowParentRfqSelector]); + // RHF setup const form = useForm<UpdateRfqSchema>({ resolver: zodResolver(updateRfqSchema), @@ -70,6 +166,7 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up rfqCode: rfq?.rfqCode ?? "", description: rfq?.description ?? "", projectId: rfq?.projectId, // 프로젝트 ID + parentRfqId: rfq?.parentRfqId, // 부모 RFQ ID dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환 status: rfq?.status ?? "DRAFT", createdBy: rfq?.createdBy ?? userId, @@ -77,16 +174,27 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up }); // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project) => { + const handleProjectSelect = (project: Project | null) => { + if (project === null) { + return; + } form.setValue("projectId", project.id); }; + // 부모 RFQ 선택 처리 + const handleParentRfqSelect = (rfq: ParentRfq | null) => { + setSelectedParentRfq(rfq); + form.setValue("parentRfqId", rfq?.id); + }; + async function onSubmit(input: UpdateRfqSchema) { startUpdateTransition(async () => { if (!rfq) return const { error } = await modifyRfq({ ...input, + rfqType: rfqType as RfqType, + }) if (error) { @@ -104,9 +212,12 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up <Sheet {...props}> <SheetContent className="flex flex-col gap-6 sm:max-w-md"> <SheetHeader className="text-left"> - <SheetTitle>Update RFQ</SheetTitle> + <SheetTitle>Update {getTypeTitle()}</SheetTitle> <SheetDescription> - Update the RFQ details and save the changes + Update the {getTypeTitle()} details and save the changes + <div className="mt-1 text-xs text-muted-foreground"> + {getTypeDescription()} + </div> </SheetDescription> </SheetHeader> @@ -122,6 +233,15 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up <input type="hidden" {...field} /> )} /> + + {/* Hidden rfqType field */} + {/* <FormField + control={form.control} + name="rfqType" + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> */} {/* Project Selector - 재사용 컴포넌트 사용 */} <FormField @@ -142,31 +262,36 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up )} /> - {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */} - {rfqType === RfqType.PURCHASE && ( + {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */} + {shouldShowParentRfqSelector && ( <FormField control={form.control} name="parentRfqId" render={({ field }) => ( <FormItem> - <FormLabel>Budgetary RFQ (Optional)</FormLabel> + <FormLabel>{getParentRfqSelectorLabel()}</FormLabel> <FormControl> - <BudgetaryRfqSelector + <ParentRfqSelector selectedRfqId={field.value as number | undefined} - onRfqSelect={(rfq) => { - setSelectedBudgetaryRfq(rfq as any); - form.setValue("parentRfqId", rfq?.id); - }} - placeholder="Budgetary RFQ 선택..." + onRfqSelect={handleParentRfqSelect} + rfqType={rfqType} + parentRfqTypes={parentRfqTypes} + placeholder={ + rfqType === RfqType.PURCHASE + ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." + : "BUDGETARY RFQ 선택..." + } /> </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + {getParentRfqDescription()} + </div> <FormMessage /> </FormItem> )} /> )} - {/* rfqCode */} <FormField control={form.control} @@ -197,8 +322,6 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up )} /> - - {/* dueDate (type="date") */} <FormField control={form.control} diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts index 369e426c..9e9e96cc 100644 --- a/lib/rfqs/validations.ts +++ b/lib/rfqs/validations.ts @@ -11,6 +11,7 @@ import { Rfq, rfqs, RfqsView, VendorCbeView, VendorRfqViewBase, VendorTbeVi import { Vendor, vendors } from "@/db/schema/vendors"; export const RfqType = { + PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY", PURCHASE: "PURCHASE", BUDGETARY: "BUDGETARY" } as const; @@ -41,7 +42,7 @@ export const searchParamsCache = createSearchParamsCache({ filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), search: parseAsString.withDefault(""), - rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"), + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), }); @@ -106,7 +107,7 @@ export const searchParamsTBECache = createSearchParamsCache({ tbeResult: parseAsString.withDefault(""), tbeNote: parseAsString.withDefault(""), tbeUpdated: parseAsString.withDefault(""), - rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"), + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 @@ -131,7 +132,7 @@ export const createRfqSchema = z.object({ parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) dueDate: z.date(), status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), - rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY]).default(RfqType.PURCHASE), + rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]).default(RfqType.PURCHASE), createdBy: z.number(), }); @@ -170,6 +171,7 @@ export const updateRfqSchema = z.object({ (val) => (val === null || val === '') ? undefined : val, z.date().optional() ), + rfqType: z.enum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).optional(), status: z.union([ z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), z.string().refine( @@ -251,7 +253,7 @@ export const searchParamsCBECache = createSearchParamsCache({ cbeResult: parseAsString.withDefault(""), cbeNote: parseAsString.withDefault(""), cbeUpdated: parseAsString.withDefault(""), - rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"), + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), totalCost: parseAsInteger.withDefault(0), |
