summaryrefslogtreecommitdiff
path: root/lib/rfqs
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfqs')
-rw-r--r--lib/rfqs/service.ts122
-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.tsx227
-rw-r--r--lib/rfqs/table/rfqs-table.tsx1
-rw-r--r--lib/rfqs/table/update-rfq-sheet.tsx169
-rw-r--r--lib/rfqs/validations.ts10
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),