summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx51
-rw-r--r--components/ProjectSelector.tsx2
-rw-r--r--components/bidding/ProjectSelectorBid.tsx2
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx421
-rw-r--r--db/schema/bidding.ts1
-rw-r--r--db/schema/rfqLast.ts20
-rw-r--r--i18n/locales/en/menu.json2
-rw-r--r--i18n/locales/ko/menu.json2
-rw-r--r--lib/bidding/actions.ts26
-rw-r--r--lib/bidding/detail/service.ts14
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx22
-rw-r--r--lib/bidding/pre-quote/service.ts1143
-rw-r--r--lib/bidding/service.ts306
-rw-r--r--lib/general-contracts/detail/general-contract-info-header.tsx21
-rw-r--r--lib/general-contracts/main/create-general-contract-dialog.tsx20
-rw-r--r--lib/general-contracts/main/general-contracts-table-columns.tsx37
-rw-r--r--lib/general-contracts/main/general-contracts-table.tsx21
-rw-r--r--lib/general-contracts/types.ts26
-rw-r--r--lib/rfq-last/quotation-compare-view.tsx22
-rw-r--r--lib/rfq-last/service.ts37
-rw-r--r--lib/rfq-last/shared/rfq-items-dialog.tsx2
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx338
-rw-r--r--lib/rfq-last/table/rfq-table.tsx9
-rw-r--r--lib/rfq-last/validations.ts7
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx2
25 files changed, 1134 insertions, 1420 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
index ab63c14f..6830dbe9 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
@@ -37,14 +37,16 @@ interface RfqPageProps {
// 탭별 데이터 카운트를 가져오는 함수
async function getTabCounts() {
try {
- const [generalData, itbData, rfqData] = await Promise.all([
+ const [generalData, preBiddingData, itbData, rfqData] = await Promise.all([
getRfqs({ page: 1, perPage: 1, sort: [], filters: [], joinOperator: "and", search: "", rfqCategory: "general" }),
+ getRfqs({ page: 1, perPage: 1, sort: [], filters: [], joinOperator: "and", search: "", rfqCategory: "pre_bidding" }),
getRfqs({ page: 1, perPage: 1, sort: [], filters: [], joinOperator: "and", search: "", rfqCategory: "itb" }),
getRfqs({ page: 1, perPage: 1, sort: [], filters: [], joinOperator: "and", search: "", rfqCategory: "rfq" }),
]);
return {
general: generalData.total || 0,
+ pre_bidding: preBiddingData.total || 0,
itb: itbData.total || 0,
rfq: rfqData?.total || 0,
};
@@ -52,6 +54,7 @@ async function getTabCounts() {
console.error("Error fetching tab counts:", error);
return {
general: 0,
+ pre_bidding: 0,
itb: 0,
rfq: 0,
};
@@ -76,6 +79,7 @@ export default async function RfqPage(props: RfqPageProps) {
// 각 탭별로 데이터 프리패칭
// const allData = await getRfqs({ ...search, rfqCategory: "all" });
const generalData = await getRfqs({ ...search, rfqCategory: "general" });
+ const preBiddingData = await getRfqs({ ...search, rfqCategory: "pre_bidding" });
const itbData = await getRfqs({ ...search, rfqCategory: "itb" });
const rfqData = await getRfqs({ ...search, rfqCategory: "rfq" });
@@ -94,7 +98,7 @@ export default async function RfqPage(props: RfqPageProps) {
{/* 탭 컨테이너 */}
<Tabs defaultValue={currentTab} className="w-full flex-1 flex flex-col overflow-hidden">
- <TabsList className="grid w-full max-w-[600px] grid-cols-3 flex-shrink-0">
+ <TabsList className="grid w-full max-w-[800px] grid-cols-4 flex-shrink-0">
<TabsTrigger value="itb" className="relative">
<Package className="mr-2 h-4 w-4" />
@@ -123,6 +127,15 @@ export default async function RfqPage(props: RfqPageProps) {
</Badge>
)}
</TabsTrigger>
+ <TabsTrigger value="pre_bidding" className="relative">
+ <FileText className="mr-2 h-4 w-4" />
+ 사전견적(입찰)
+ {tabCounts.pre_bidding > 0 && (
+ <Badge variant="secondary" className="ml-2 text-xs">
+ {tabCounts.pre_bidding}
+ </Badge>
+ )}
+ </TabsTrigger>
</TabsList>
@@ -160,6 +173,40 @@ export default async function RfqPage(props: RfqPageProps) {
</React.Suspense>
</TabsContent>
+ {/* 사전견적(입찰) 탭 */}
+ <TabsContent value="pre_bidding" className="mt-4 flex-1 overflow-hidden relative">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={13}
+ searchableColumnCount={4}
+ filterableColumnCount={8}
+ cellWidths={[
+ "3rem", // checkbox
+ "9rem", // rfqCode
+ "7rem", // status
+ "8rem", // rfqType
+ "15rem", // rfqTitle
+ "8rem", // projectCode
+ "12rem", // projectName
+ "8rem", // picName
+ "5rem", // rfqSendDate
+ "5rem", // dueDate
+ "5rem", // vendorCount
+ "5rem", // quotationReceived
+ "5rem", // actions
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqTable
+ data={preBiddingData}
+ rfqCategory="pre_bidding"
+ />
+ </React.Suspense>
+ </TabsContent>
+
{/* ITB 탭 */}
<TabsContent value="itb" className="mt-4 flex-1 overflow-hidden relative">
<React.Suspense
diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx
index 58fa2c23..6b16668b 100644
--- a/components/ProjectSelector.tsx
+++ b/components/ProjectSelector.tsx
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
import { cn } from "@/lib/utils"
-import { getProjects, type Project } from "@/lib/rfqs/service"
+import { getProjects, type Project } from "@/lib/rfq-last/service"
interface ProjectSelectorProps {
selectedProjectId?: number | null;
diff --git a/components/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx
index a87c8dce..f0167e82 100644
--- a/components/bidding/ProjectSelectorBid.tsx
+++ b/components/bidding/ProjectSelectorBid.tsx
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
import { cn } from "@/lib/utils"
-import { getProjects, type Project } from "@/lib/rfqs/service"
+import { getProjects, type Project } from "@/lib/rfq-last/service"
interface ProjectSelectorProps {
selectedProjectId?: number | null;
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
index cdcf1ef1..de3c19ff 100644
--- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -46,12 +46,14 @@ import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
-import { createPreQuoteRfqAction, previewGeneralRfqCode } from "@/lib/bidding/service"
+import { createPreQuoteRfqAction } from "@/lib/bidding/pre-quote/service"
+import { previewGeneralRfqCode } from "@/lib/rfq-last/service"
import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
import { MaterialSearchItem } from "@/lib/material/material-group-service"
import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
import { PurchaseGroupCodeSelector } from "@/components/common/selectors/purchase-group-code/purchase-group-code-selector"
+import type { PurchaseGroupCodeWithUser } from "@/components/common/selectors/purchase-group-code"
import { getBiddingById } from "@/lib/bidding/service"
// 아이템 스키마
@@ -67,14 +69,17 @@ const itemSchema = z.object({
// 사전견적용 일반견적 생성 폼 스키마
const createPreQuoteRfqSchema = z.object({
- rfqType: z.string().min(1, "견적 종류를 선택해주세요"),
+ rfqType: z.string().optional(),
rfqTitle: z.string().min(1, "견적명을 입력해주세요"),
dueDate: z.date({
required_error: "제출마감일을 선택해주세요",
- }),
+ }).optional(), // 필수값 해제
picUserId: z.number().optional(),
projectId: z.number().optional(),
remark: z.string().optional(),
+ biddingNumber: z.string().optional(), // 입찰 No. 추가
+ contractStartDate: z.date().optional(), // 계약기간 시작
+ contractEndDate: z.date().optional(), // 계약기간 종료
items: z.array(itemSchema).min(1, "최소 하나의 자재를 추가해주세요"),
})
@@ -122,7 +127,7 @@ export function CreatePreQuoteRfqDialog({
const [isLoading, setIsLoading] = React.useState(false)
const [previewCode, setPreviewCode] = React.useState("")
const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
- const [selectedBidPic, setSelectedBidPic] = React.useState<any | undefined>(undefined)
+ const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
const { data: session } = useSession()
const userId = React.useMemo(() => {
@@ -165,24 +170,31 @@ export function CreatePreQuoteRfqDialog({
},
})
+ /*
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "items",
})
+ */
- // 입찰담당자 정보 로드
+ // 견적담당자 정보 로드
React.useEffect(() => {
const loadBiddingInfo = async () => {
if (!biddingId || !open) return
try {
const bidding = await getBiddingById(biddingId)
- if (bidding && bidding.bidPicId) {
- // 입찰담당자 정보를 로드하는 로직 추가 필요
- // 현재는 임시로 bidPicId를 사용
+ if (bidding) {
setSelectedBidPic({
- USER_ID: bidding.bidPicId,
- DISPLAY_NAME: bidding.bidPicName || '입찰담당자'
+ DISPLAY_NAME: bidding.bidPicName || '',
+ PURCHASE_GROUP_CODE: bidding.bidPicCode || '',
+ EMPLOYEE_NUMBER: '',
+ user: bidding.bidPicId ? {
+ id: bidding.bidPicId,
+ name: bidding.bidPicName || '',
+ email: '',
+ employeeNumber: null
+ } : undefined
})
}
} catch (error) {
@@ -192,17 +204,58 @@ export function CreatePreQuoteRfqDialog({
loadBiddingInfo()
}, [biddingId, open])
+
+ // 프로젝트 정보 상태 추가
+ const [projectInfo, setProjectInfo] = React.useState<string>("")
// 다이얼로그가 열릴 때 폼 초기화
React.useEffect(() => {
if (open) {
+ // 입찰 정보를 기반으로 기본값 설정
+ let rfqTitle = "";
+ let projectId: number | undefined = undefined;
+ let contractStartDate: Date | undefined = undefined;
+ let contractEndDate: Date | undefined = undefined;
+ let biddingNumber = "";
+
+ const loadDetailedBiddingInfo = async () => {
+ if (biddingId) {
+ try {
+ const bidding = await getBiddingById(biddingId)
+ if (bidding) {
+ rfqTitle = bidding.title;
+ biddingNumber = bidding.biddingNumber;
+
+ // 프로젝트 정보 설정
+ const pCode = bidding.projectCode || "";
+ const pName = bidding.projectName || "";
+ setProjectInfo(pCode && pName ? `${pCode} - ${pName}` : pCode || pName || "");
+
+ // 폼 값 설정
+ form.setValue("rfqTitle", rfqTitle);
+ form.setValue("rfqType", "pre_bidding"); // 기본값 설정
+ if (biddingNumber) form.setValue("biddingNumber", biddingNumber);
+
+ if (bidding.contractStartDate) form.setValue("contractStartDate", new Date(bidding.contractStartDate));
+ if (bidding.contractEndDate) form.setValue("contractEndDate", new Date(bidding.contractEndDate));
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ };
+ loadDetailedBiddingInfo();
+
form.reset({
- rfqType: "",
+ rfqType: "pre_bidding", // 기본값
rfqTitle: "",
- dueDate: undefined,
- picUserId: selectedBidPic?.USER_ID,
+ dueDate: undefined, // 필수값 해제되었으므로 undefined 가능
+ picUserId: selectedBidPic?.user?.id,
projectId: undefined,
remark: "",
+ biddingNumber: "",
+ contractStartDate: undefined,
+ contractEndDate: undefined,
items: initialItems.length > 0 ? initialItems : [
{
itemCode: "",
@@ -217,11 +270,11 @@ export function CreatePreQuoteRfqDialog({
})
setPreviewCode("")
}
- }, [open, initialItems, form, selectedBidPic])
+ }, [open, initialItems, form, selectedBidPic, biddingId])
// 견적담당자 선택 시 RFQ 코드 미리보기 생성
React.useEffect(() => {
- if (!selectedBidPic?.USER_ID) {
+ if (!selectedBidPic?.user?.id) {
setPreviewCode("")
return
}
@@ -230,7 +283,7 @@ export function CreatePreQuoteRfqDialog({
(async () => {
setIsLoadingPreview(true)
try {
- const code = await previewGeneralRfqCode(selectedBidPic.USER_ID)
+ const code = await previewGeneralRfqCode(selectedBidPic.user!.id)
setPreviewCode(code)
} catch (error) {
console.error("코드 미리보기 오류:", error)
@@ -277,25 +330,26 @@ export function CreatePreQuoteRfqDialog({
return
}
- if (!selectedBidPic?.USER_ID) {
- toast.error("입찰담당자를 선택해주세요")
- return
- }
- const picUserId = selectedBidPic.USER_ID
+
+ const picUserId = selectedBidPic?.user?.id || session?.user?.id
setIsLoading(true)
try {
// 서버 액션 호출 (입찰 조건 포함)
const result = await createPreQuoteRfqAction({
+ // biddingId, // createPreQuoteRfqAction 인터페이스 변경됨
biddingId,
- rfqType: data.rfqType,
+ rfqType: data.rfqType || "pre_bidding",
rfqTitle: data.rfqTitle,
- dueDate: data.dueDate,
+ dueDate: data.dueDate ? new Date(data.dueDate) : undefined, // optional이지만 submit시에는 값이 있을 수 있음 (없으면 서비스에서 처리)
picUserId,
projectId: data.projectId,
remark: data.remark || "",
+ biddingNumber: data.biddingNumber, // 추가
+ contractStartDate: data.contractStartDate, // 추가
+ contractEndDate: data.contractEndDate, // 추가
items: data.items as Array<{
itemCode: string;
itemName: string;
@@ -336,7 +390,8 @@ export function CreatePreQuoteRfqDialog({
}
}
- // 아이템 추가
+ // 아이템 추가 (사용안함)
+ /*
const handleAddItem = () => {
append({
itemCode: "",
@@ -348,6 +403,7 @@ export function CreatePreQuoteRfqDialog({
remark: "",
})
}
+ */
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -371,7 +427,7 @@ export function CreatePreQuoteRfqDialog({
<div className="grid grid-cols-2 gap-4">
{/* 견적 종류 */}
- <div className="space-y-2">
+ {/* <div className="space-y-2">
<FormField
control={form.control}
name="rfqType"
@@ -380,23 +436,14 @@ export function CreatePreQuoteRfqDialog({
<FormLabel>
견적 종류 <span className="text-red-500">*</span>
</FormLabel>
- <Select onValueChange={handleRfqTypeChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="견적 종류 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="단가계약">단가계약</SelectItem>
- <SelectItem value="매각계약">매각계약</SelectItem>
- <SelectItem value="일반계약">일반계약</SelectItem>
- </SelectContent>
- </Select>
+ <FormControl>
+ <Input {...field} value="사전견적(입찰)" readOnly className="bg-muted" />
+ </FormControl>
<FormMessage />
</FormItem>
)}
/>
- </div>
+ </div> */}
{/* 제출마감일 */}
<FormField
@@ -405,7 +452,7 @@ export function CreatePreQuoteRfqDialog({
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
- 제출마감일 <span className="text-red-500">*</span>
+ 제출마감일
</FormLabel>
<Popover>
<PopoverTrigger asChild>
@@ -420,7 +467,7 @@ export function CreatePreQuoteRfqDialog({
{field.value ? (
format(field.value, "yyyy-MM-dd")
) : (
- <span>제출마감일을 선택하세요</span>
+ <span>제출마감일을 선택하세요 (선택)</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
@@ -468,23 +515,24 @@ export function CreatePreQuoteRfqDialog({
/>
{/* 프로젝트 선택 */}
- <FormField
- control={form.control}
- name="projectId"
- render={({ field }) => (
+ <div className="space-y-2">
<FormItem className="flex flex-col">
<FormLabel>프로젝트</FormLabel>
<FormControl>
- {/* ProjectSelector는 별도 컴포넌트 필요 */}
<Input
- placeholder="프로젝트 ID (선택사항)"
- type="number"
- {...field}
- onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+ value={projectInfo}
+ readOnly
+ className="bg-muted"
+ placeholder="프로젝트 정보 없음"
/>
</FormControl>
- <FormMessage />
</FormItem>
+ </div>
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <input type="hidden" {...field} value={field.value || ''} />
)}
/>
@@ -502,7 +550,7 @@ export function CreatePreQuoteRfqDialog({
selectedCode={selectedBidPic}
onCodeSelect={(code) => {
setSelectedBidPic(code)
- field.onChange(code.USER_ID)
+ field.onChange(code.user?.id)
}}
placeholder="입찰담당자 선택"
/>
@@ -526,6 +574,87 @@ export function CreatePreQuoteRfqDialog({
</div>
)}
+ {/* 계약기간 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="contractStartDate"
+ 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, "yyyy-MM-dd")
+ ) : (
+ <span>시작일 선택</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contractEndDate"
+ 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, "yyyy-MM-dd")
+ ) : (
+ <span>종료일 선택</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
{/* 비고 */}
<FormField
control={form.control}
@@ -549,192 +678,10 @@ export function CreatePreQuoteRfqDialog({
<Separator />
- {/* 아이템 정보 섹션 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-lg font-semibold">자재 정보</h3>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={handleAddItem}
- >
- <PlusCircle className="mr-2 h-4 w-4" />
- 자재 추가
- </Button>
- </div>
-
- <div className="space-y-3">
- {fields.map((field, index) => (
- <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50">
- <div className="flex items-center justify-between mb-3">
- <span className="text-sm font-medium text-gray-700">
- 자재 #{index + 1}
- </span>
- {fields.length > 1 && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => remove(index)}
- className="h-6 w-6 p-0 text-destructive hover:text-destructive"
- >
- <Trash2 className="h-3 w-3" />
- </Button>
- )}
- </div>
-
- {/* 자재그룹 선택 */}
- <div className="mb-3">
- <FormLabel className="text-xs">
- 자재그룹(자재그룹명) <span className="text-red-500">*</span>
- </FormLabel>
- <div className="mt-1">
- <MaterialGroupSelectorDialogSingle
- triggerLabel="자재그룹 선택"
- selectedMaterial={(() => {
- const itemCode = form.watch(`items.${index}.itemCode`);
- const itemName = form.watch(`items.${index}.itemName`);
- if (itemCode && itemName) {
- return {
- materialGroupCode: itemCode,
- materialGroupDescription: itemName,
- displayText: `${itemCode} - ${itemName}`
- } as MaterialSearchItem;
- }
- return null;
- })()}
- onMaterialSelect={(material) => {
- form.setValue(`items.${index}.itemCode`, material?.materialGroupCode || '');
- form.setValue(`items.${index}.itemName`, material?.materialGroupDescription || '');
- }}
- placeholder="자재그룹을 검색하세요..."
- title="자재그룹 선택"
- description="원하는 자재그룹을 검색하고 선택해주세요."
- triggerVariant="outline"
- />
- </div>
- </div>
-
- {/* 자재코드 선택 */}
- <div className="mb-3">
- <FormLabel className="text-xs">
- 자재코드(자재명)
- </FormLabel>
- <div className="mt-1">
- <MaterialSelectorDialogSingle
- triggerLabel="자재코드 선택"
- selectedMaterial={(() => {
- const materialCode = form.watch(`items.${index}.materialCode`);
- const materialName = form.watch(`items.${index}.materialName`);
- if (materialCode && materialName) {
- return {
- materialCode: materialCode,
- materialName: materialName,
- displayText: `${materialCode} - ${materialName}`
- } as SAPMaterialSearchItem;
- }
- return null;
- })()}
- onMaterialSelect={(material) => {
- form.setValue(`items.${index}.materialCode`, material?.materialCode || '');
- form.setValue(`items.${index}.materialName`, material?.materialName || '');
- }}
- placeholder="자재코드를 검색하세요..."
- title="자재코드 선택"
- description="원하는 자재코드를 검색하고 선택해주세요."
- triggerVariant="outline"
- />
- </div>
- </div>
-
- <div className="grid grid-cols-2 gap-3">
- {/* 수량 */}
- <FormField
- control={form.control}
- name={`items.${index}.quantity`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs">
- 수량 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- type="number"
- min="1"
- placeholder="1"
- className="h-8 text-sm"
- {...field}
- onChange={(e) => field.onChange(Number(e.target.value))}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 단위 */}
- <FormField
- control={form.control}
- name={`items.${index}.uom`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs">
- 단위 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger className="h-8 text-sm">
- <SelectValue placeholder="단위 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="EA">EA (Each)</SelectItem>
- <SelectItem value="KG">KG (Kilogram)</SelectItem>
- <SelectItem value="M">M (Meter)</SelectItem>
- <SelectItem value="L">L (Liter)</SelectItem>
- <SelectItem value="PC">PC (Piece)</SelectItem>
- <SelectItem value="BOX">BOX (Box)</SelectItem>
- <SelectItem value="SET">SET (Set)</SelectItem>
- <SelectItem value="LOT">LOT (Lot)</SelectItem>
- <SelectItem value="PCS">PCS (Pieces)</SelectItem>
- <SelectItem value="TON">TON (Ton)</SelectItem>
- <SelectItem value="G">G (Gram)</SelectItem>
- <SelectItem value="ML">ML (Milliliter)</SelectItem>
- <SelectItem value="CM">CM (Centimeter)</SelectItem>
- <SelectItem value="MM">MM (Millimeter)</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* 비고 */}
- <div className="mt-3">
- <FormField
- control={form.control}
- name={`items.${index}.remark`}
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs">비고</FormLabel>
- <FormControl>
- <Input
- placeholder="자재별 비고사항"
- className="h-8 text-sm"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
- ))}
- </div>
- </div>
+ {/* 아이템 정보 섹션 (자동 매핑되므로 UI 제거) */}
+ {/* <div className="space-y-4">
+ ...
+ </div> */}
</form>
</Form>
</ScrollArea>
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index cc79f482..c08ea921 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -156,6 +156,7 @@ export const biddings = pgTable('biddings', {
biddingSourceType: varchar('bidding_source_type', { length: 20 }).notNull().default('manual'),
// 기본 정보
projectName: varchar('project_name', { length: 300 }), // 프로젝트명
+ projectCode: varchar('project_code', { length: 100 }), // 프로젝트 코드 (새로 추가)
itemName: varchar('item_name', { length: 300 }), // 품목명
title: varchar('title', { length: 300 }).notNull(), // 입찰명
description: text('description'),
diff --git a/db/schema/rfqLast.ts b/db/schema/rfqLast.ts
index 2f2a6710..325942f4 100644
--- a/db/schema/rfqLast.ts
+++ b/db/schema/rfqLast.ts
@@ -83,6 +83,13 @@ export const rfqsLast = pgTable(
rfqType: varchar("rfq_type", { length: 255 }),
rfqTitle: varchar("rfq_title", { length: 255 }),
+ // 입찰 사전견적 추가 필드
+ biddingNumber: varchar("bidding_number", { length: 50 }),
+ contractStartDate: date("contract_start_date", { mode: "date" })
+ .$type<Date | null>(),
+ contractEndDate: date("contract_end_date", { mode: "date" })
+ .$type<Date | null>(),
+
//ITB 추가 필드
projectCompany: varchar("project_company", { length: 255 }),
projectFlag: varchar("project_flag", { length: 255 }),
@@ -309,6 +316,11 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => {
rfqType: sql<string | null>`${rfqsLast.rfqType}`.as("rfq_type"),
rfqTitle: sql<string | null>`${rfqsLast.rfqTitle}`.as("rfq_title"),
+ // 입찰 사전견적 추가 필드
+ biddingNumber: sql<string | null>`${rfqsLast.biddingNumber}`.as("bidding_number"),
+ contractStartDate: sql<Date | null>`${rfqsLast.contractStartDate}`.as("contract_start_date"),
+ contractEndDate: sql<Date | null>`${rfqsLast.contractEndDate}`.as("contract_end_date"),
+
// ITB 관련 필드
projectCompany: sql<string | null>`${rfqsLast.projectCompany}`.as("project_company"),
projectFlag: sql<string | null>`${rfqsLast.projectFlag}`.as("project_flag"),
@@ -480,6 +492,11 @@ export const rfqLastDetailsView = pgView("rfq_last_details_view").as((qb) => {
rfqType: sql<string | null>`${rfqsTable.rfqType}`.as("rfq_type"),
rfqTitle: sql<string | null>`${rfqsTable.rfqTitle}`.as("rfq_title"),
+ // 입찰 사전견적 추가 필드
+ biddingNumber: sql<string | null>`${rfqsTable.biddingNumber}`.as("bidding_number"),
+ contractStartDate: sql<Date | null>`${rfqsTable.contractStartDate}`.as("contract_start_date"),
+ contractEndDate: sql<Date | null>`${rfqsTable.contractEndDate}`.as("contract_end_date"),
+
// ITB 관련 정보
projectCompany: sql<string | null>`${rfqsTable.projectCompany}`.as("project_company"),
projectFlag: sql<string | null>`${rfqsTable.projectFlag}`.as("project_flag"),
@@ -677,6 +694,9 @@ export const prItemsLastView = pgView("pr_items_last_view").as((qb) => {
rfqCode: rfqsLast.rfqCode,
rfqType: rfqsLast.rfqType,
rfqTitle: rfqsLast.rfqTitle,
+ biddingNumber: rfqsLast.biddingNumber,
+ contractStartDate: rfqsLast.contractStartDate,
+ contractEndDate: rfqsLast.contractEndDate,
itemCode: rfqsLast.itemCode,
itemName: rfqsLast.itemName,
prNumber: rfqsLast.prNumber,
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index 368dbd92..bb99f0ef 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -239,7 +239,7 @@
"ship_tbe_desc": "Create response to order TBE request",
"rfb_response": "Order RFB Response",
"rfb_response_desc": "Create response to bid request",
- "po": "PO",
+ "po": "PO/Contract Management",
"po_desc": "Order list confirmation and electronic signature",
"po_amendment": "PO Amendment",
"po_amendment_desc": "Order list confirmation and electronic signature",
diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json
index 249118b1..d5c159e2 100644
--- a/i18n/locales/ko/menu.json
+++ b/i18n/locales/ko/menu.json
@@ -242,7 +242,7 @@
"ship_tbe_desc": "발주용 TBE 요청에 대한 응답 작성",
"rfb_response": "발주 RFB 응답",
"rfb_response_desc": "입찰 요청에 대한 응답 작성",
- "po": "PO",
+ "po": "PO/계약 관리",
"po_desc": "발주 리스트 확인 및 전자서명",
"po_amendment": "PO Amendment",
"po_amendment_desc": "발주 리스트 확인 및 전자서명",
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index 02501b27..4e7da36c 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -96,9 +96,11 @@ export async function transmitToContract(biddingId: number, userId: number) {
bidAmount: companyPrItemBids.bidAmount,
currency: companyPrItemBids.currency,
// PR 아이템 정보도 함께 조회
- itemNumber: prItemsForBidding.itemNumber,
- itemInfo: prItemsForBidding.itemInfo,
- materialDescription: prItemsForBidding.materialDescription,
+ projectId: prItemsForBidding.projectId,
+ materialGroupNumber: prItemsForBidding.materialGroupNumber,
+ materialGroupInfo: prItemsForBidding.materialGroupInfo,
+ materialInfo: prItemsForBidding.materialInfo,
+ specification: prItemsForBidding.specification,
quantity: prItemsForBidding.quantity,
quantityUnit: prItemsForBidding.quantityUnit,
})
@@ -119,7 +121,8 @@ export async function transmitToContract(biddingId: number, userId: number) {
}
// 계약 번호 자동 생성 (실제 규칙에 맞게)
- const contractNumber = await generateContractNumber(userId.toString(), biddingData.contractType)
+ const safeUserId = userId ? String(userId) : '0';
+ const contractNumber = await generateContractNumber(safeUserId, biddingData.contractType)
console.log('Generated contractNumber:', contractNumber)
// general-contract 생성 (발주비율 계산된 최종 금액 사용)
@@ -132,7 +135,7 @@ export async function transmitToContract(biddingId: number, userId: number) {
name: biddingData.title,
vendorId: winnerCompany.companyId,
linkedBidNumber: biddingData.biddingNumber,
- contractAmount: totalContractAmount ? totalContractAmount.toString() as any : null, // 발주비율 계산된 최종 금액 사용
+ contractAmount: !isNaN(totalContractAmount) ? String(totalContractAmount) : null, // 발주비율 계산된 최종 금액 사용
startDate: biddingData.contractStartDate || null,
endDate: biddingData.contractEndDate || null,
currency: biddingData.currency || 'KRW',
@@ -161,16 +164,17 @@ export async function transmitToContract(biddingId: number, userId: number) {
await db.insert(generalContractItems).values({
contractId: contractId,
- itemCode: bid.itemNumber || '',
- itemInfo: bid.itemInfo || '',
- specification: bid.materialDescription || '',
- quantity: finalQuantity || null,
+ projectId: bid.projectId,
+ itemCode: bid.materialGroupNumber || '',
+ itemInfo: bid.materialGroupInfo || '',
+ specification: bid.specification || '',
+ quantity: !isNaN(finalQuantity) ? String(finalQuantity) : null,
quantityUnit: bid.quantityUnit || '',
totalWeight: null, // 중량 정보 제외
weightUnit: '', // 중량 단위 제외
contractDeliveryDate: bid.proposedDeliveryDate || null,
- contractUnitPrice: bid.bidUnitPrice ? String(bid.bidUnitPrice) : null,
- contractAmount: finalAmount ? String(finalAmount) : null,
+ contractUnitPrice: !isNaN(bidUnitPrice) ? String(bidUnitPrice) : null,
+ contractAmount: !isNaN(finalAmount) ? String(finalAmount) : null,
contractCurrency: bid.currency || biddingData.currency || 'KRW',
})
}
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index e425959c..f52ecb1e 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -2086,20 +2086,6 @@ export async function submitPartnerResponse(
const biddingId = biddingCompanyInfo[0]?.biddingId
- // 최종제출인 경우, 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만)
- if (biddingId && response.finalQuoteAmount !== undefined && response.isFinalSubmission) {
- await tx
- .update(biddings)
- .set({
- status: 'evaluation_of_bidding',
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, biddingId),
- eq(biddings.status, 'bidding_opened')
- ))
- }
-
return biddingId
})
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 9b8c19c5..62d4dbe7 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -122,14 +122,20 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
// ░░░ 프로젝트명 ░░░
{
accessorKey: "projectName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 No." />,
- cell: ({ row }) => (
- <div className="truncate max-w-[150px]" title={row.original.projectName || ''}>
- {row.original.projectName || '-'}
- </div>
- ),
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트" />,
+ cell: ({ row }) => {
+ const code = row.original.projectCode;
+ const name = row.original.projectName;
+ const displayText = code && name ? `${code} (${name})` : (code || name || '-');
+
+ return (
+ <div className="truncate max-w-[150px]" title={displayText}>
+ {displayText}
+ </div>
+ )
+ },
size: 150,
- meta: { excelHeader: "프로젝트 No." },
+ meta: { excelHeader: "프로젝트" },
},
// ░░░ 입찰명 ░░░
{
@@ -241,7 +247,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
accessorKey: "biddingRegistrationDate",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.biddingRegistrationDate , "KR")}</span>
+ <span className="text-sm">{row.original.biddingRegistrationDate ? formatDate(row.original.biddingRegistrationDate, "KR") : '-'}</span>
),
size: 100,
meta: { excelHeader: "입찰등록일" },
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 0f938b24..08cb0e2c 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -2,16 +2,16 @@
import db from '@/db/db'
import { biddingCompanies, biddingCompaniesContacts, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
-import { basicContractTemplates } from '@/db/schema'
+import { basicContractTemplates, rfqLastDetails, rfqLastVendorResponses, rfqLastVendorResponseHistory, rfqsLast, rfqPrItems, users } from '@/db/schema'
import { vendors } from '@/db/schema/vendors'
-import { users } from '@/db/schema'
import { sendEmail } from '@/lib/mail/sendEmail'
-import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
+import { eq, inArray, and, ilike, sql, desc, like } from 'drizzle-orm'
import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { revalidateTag, revalidatePath } from 'next/cache'
import { basicContract } from '@/db/schema/basicContractDocumnet'
import { saveFile } from '@/lib/file-stroage'
+import { getDefaultDueDate } from '@/lib/rfq-last/service'
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise<string> {
@@ -151,50 +151,6 @@ export async function updateBiddingCompany(id: number, input: UpdateBiddingCompa
}
}
-// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
-export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) {
- try {
- // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
- const companies = await db
- .select({ biddingId: biddingCompanies.biddingId })
- .from(biddingCompanies)
- .where(inArray(biddingCompanies.id, companyIds))
- .limit(1)
-
- await db.update(biddingCompanies)
- .set({
- isPreQuoteSelected: isSelected,
- invitationStatus: 'pending', // 초기 상태: 초대 대기
- updatedAt: new Date()
- })
- .where(inArray(biddingCompanies.id, companyIds))
-
- // 캐시 무효화
- if (companies.length > 0) {
- const biddingId = companies[0].biddingId
- revalidateTag(`bidding-${biddingId}`)
- revalidateTag('bidding-detail')
- revalidateTag('quotation-vendors')
- revalidateTag('quotation-details')
- revalidatePath(`/evcp/bid/${biddingId}`)
- }
-
- const message = isSelected
- ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
- : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to update pre-quote selection:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.'
- }
- }
-}
// 사전견적용 업체 삭제
export async function deleteBiddingCompany(id: number) {
@@ -244,84 +200,6 @@ export async function deleteBiddingCompany(id: number) {
}
}
-// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인)
-export async function getBiddingCompanies(biddingId: number) {
- try {
- const companies = await db
- .select({
- // bidding_companies 필드들
- id: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- companyId: biddingCompanies.companyId,
- invitationStatus: biddingCompanies.invitationStatus,
- invitedAt: biddingCompanies.invitedAt,
- respondedAt: biddingCompanies.respondedAt,
- preQuoteAmount: biddingCompanies.preQuoteAmount,
- preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
- preQuoteDeadline: biddingCompanies.preQuoteDeadline,
- isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
- isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
- isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- notes: biddingCompanies.notes,
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- contactPhone: biddingCompanies.contactPhone,
- createdAt: biddingCompanies.createdAt,
- updatedAt: biddingCompanies.updatedAt,
-
- // vendors 테이블에서 업체 정보
- companyName: vendors.vendorName,
- companyCode: vendors.vendorCode,
- companyEmail: vendors.email, // 벤더의 기본 이메일
-
- // company_condition_responses 필드들
- paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
- taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
- proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
- priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
- isInitialResponse: companyConditionResponses.isInitialResponse,
- incotermsResponse: companyConditionResponses.incotermsResponse,
- proposedShippingPort: companyConditionResponses.proposedShippingPort,
- proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
- sparePartResponse: companyConditionResponses.sparePartResponse,
- additionalProposals: companyConditionResponses.additionalProposals,
- })
- .from(biddingCompanies)
- .leftJoin(
- vendors,
- eq(biddingCompanies.companyId, vendors.id)
- )
- .leftJoin(
- companyConditionResponses,
- eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
- )
- .where(eq(biddingCompanies.biddingId, biddingId))
-
- // 디버깅: 서버에서 가져온 데이터 확인
- console.log('=== getBiddingCompanies Server Log ===')
- console.log('Total companies:', companies.length)
- if (companies.length > 0) {
- console.log('First company:', {
- companyName: companies[0].companyName,
- companyEmail: companies[0].companyEmail,
- companyCode: companies[0].companyCode,
- companyId: companies[0].companyId
- })
- }
- console.log('======================================')
-
- return {
- success: true,
- data: companies
- }
- } catch (error) {
- console.error('Failed to get bidding companies:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
- }
- }
-}
// 선택된 업체들에게 사전견적 초대 발송
interface CompanyWithContacts {
@@ -332,512 +210,6 @@ interface CompanyWithContacts {
additionalEmails: string[]
}
-export async function sendPreQuoteInvitations(companiesData: CompanyWithContacts[], preQuoteDeadline?: Date | string) {
- try {
- console.log('=== sendPreQuoteInvitations called ===');
- console.log('companiesData:', JSON.stringify(companiesData, null, 2));
-
- if (companiesData.length === 0) {
- return {
- success: false,
- error: '선택된 업체가 없습니다.'
- }
- }
-
- const companyIds = companiesData.map(c => c.id);
- console.log('companyIds:', companyIds);
-
- // 선택된 업체들의 정보와 입찰 정보 조회
- const companiesInfo = await db
- .select({
- biddingCompanyId: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- biddingId: biddingCompanies.biddingId,
- companyName: vendors.vendorName,
- companyEmail: vendors.email,
- // 입찰 정보
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- biddingTitle: biddings.title,
- itemName: biddings.itemName,
- preQuoteDate: biddings.preQuoteDate,
- budget: biddings.budget,
- currency: biddings.currency,
- bidPicName: biddings.bidPicName,
- supplyPicName: biddings.supplyPicName,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
- .where(inArray(biddingCompanies.id, companyIds))
-
- console.log('companiesInfo fetched:', JSON.stringify(companiesInfo, null, 2));
-
- if (companiesInfo.length === 0) {
- return {
- success: false,
- error: '업체 정보를 찾을 수 없습니다.'
- }
- }
-
- // 모든 필드가 null이 아닌지 확인하고 안전하게 변환
- const safeCompaniesInfo = companiesInfo.map(company => ({
- ...company,
- companyName: company.companyName ?? '',
- companyEmail: company.companyEmail ?? '',
- biddingNumber: company.biddingNumber ?? '',
- revision: company.revision ?? '',
- projectName: company.projectName ?? '',
- biddingTitle: company.biddingTitle ?? '',
- itemName: company.itemName ?? '',
- preQuoteDate: company.preQuoteDate ?? null,
- budget: company.budget ?? null,
- currency: company.currency ?? '',
- bidPicName: company.bidPicName ?? '',
- supplyPicName: company.supplyPicName ?? '',
- }));
-
- console.log('safeCompaniesInfo prepared:', JSON.stringify(safeCompaniesInfo, null, 2));
-
- await db.transaction(async (tx) => {
- // 선택된 업체들의 상태를 '사전견적 초대 발송'으로 변경
- for (const id of companyIds) {
- await tx.update(biddingCompanies)
- .set({
- invitationStatus: 'pre_quote_sent', // 사전견적 초대 발송 상태
- invitedAt: new Date(),
- preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null,
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, id))
- }
- })
-
- // 각 업체별로 이메일 발송 (담당자 정보 포함)
- console.log('=== Starting email sending ===');
- for (const company of safeCompaniesInfo) {
- console.log(`Processing company: ${company.companyName} (biddingCompanyId: ${company.biddingCompanyId})`);
-
- const companyData = companiesData.find(c => c.id === company.biddingCompanyId);
- if (!companyData) {
- console.log(`No companyData found for biddingCompanyId: ${company.biddingCompanyId}`);
- continue;
- }
-
- console.log('companyData found:', JSON.stringify(companyData, null, 2));
-
- const mainEmail = companyData.selectedMainEmail || '';
- const ccEmails = Array.isArray(companyData.additionalEmails) ? companyData.additionalEmails : [];
-
- console.log(`mainEmail: ${mainEmail}, ccEmails: ${JSON.stringify(ccEmails)}`);
-
- if (mainEmail) {
- try {
- console.log('Preparing to send email...');
-
- const emailContext = {
- companyName: company.companyName,
- biddingNumber: company.biddingNumber,
- revision: company.revision,
- projectName: company.projectName,
- biddingTitle: company.biddingTitle,
- itemName: company.itemName,
- preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : '',
- budget: company.budget ? String(company.budget) : '',
- currency: company.currency,
- bidPicName: company.bidPicName,
- supplyPicName: company.supplyPicName,
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
- currentYear: new Date().getFullYear(),
- language: 'ko'
- };
-
- console.log('Email context prepared:', JSON.stringify(emailContext, null, 2));
-
- await sendEmail({
- to: mainEmail,
- cc: ccEmails.length > 0 ? ccEmails : undefined,
- template: 'pre-quote-invitation',
- context: emailContext
- })
-
- console.log(`Email sent successfully to ${mainEmail}`);
- } catch (emailError) {
- console.error(`Failed to send email to ${mainEmail}:`, emailError)
- // 이메일 발송 실패해도 전체 프로세스는 계속 진행
- }
- }
- }
- // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만)
- for (const company of companiesInfo) {
- await db.transaction(async (tx) => {
- await tx
- .update(biddings)
- .set({
- status: 'request_for_quotation',
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, company.biddingId),
- eq(biddings.status, 'bidding_generated')
- ))
- })
- }
- return {
- success: true,
- message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.`
- }
- } catch (error) {
- console.error('Failed to send pre-quote invitations:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.'
- }
- }
-}
-
-// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계)
-export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) {
- try {
- // 1. 먼저 입찰 기본 정보를 가져옴
- const biddingResult = await db
- .select({
- id: biddings.id,
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- itemName: biddings.itemName,
- title: biddings.title,
- description: biddings.description,
- contractType: biddings.contractType,
- biddingType: biddings.biddingType,
- awardCount: biddings.awardCount,
- contractStartDate: biddings.contractStartDate,
- contractEndDate: biddings.contractEndDate,
- preQuoteDate: biddings.preQuoteDate,
- biddingRegistrationDate: biddings.biddingRegistrationDate,
- submissionStartDate: biddings.submissionStartDate,
- submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
- currency: biddings.currency,
- budget: biddings.budget,
- targetPrice: biddings.targetPrice,
- status: biddings.status,
- bidPicName: biddings.bidPicName,
- supplyPicName: biddings.supplyPicName,
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (biddingResult.length === 0) {
- return null
- }
-
- const biddingData = biddingResult[0]
-
- // 2. 해당 업체의 biddingCompanies 정보 조회
- const companyResult = await db
- .select({
- biddingCompanyId: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- invitationStatus: biddingCompanies.invitationStatus,
- preQuoteAmount: biddingCompanies.preQuoteAmount,
- preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
- preQuoteDeadline: biddingCompanies.preQuoteDeadline,
- isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
- isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
- isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- // company_condition_responses 정보
- paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
- taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
- incotermsResponse: companyConditionResponses.incotermsResponse,
- proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
- proposedShippingPort: companyConditionResponses.proposedShippingPort,
- proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
- priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
- sparePartResponse: companyConditionResponses.sparePartResponse,
- isInitialResponse: companyConditionResponses.isInitialResponse,
- additionalProposals: companyConditionResponses.additionalProposals,
- })
- .from(biddingCompanies)
- .leftJoin(
- companyConditionResponses,
- eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
- )
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, companyId)
- )
- )
- .limit(1)
-
- // 3. 결과 조합
- if (companyResult.length === 0) {
- // 아직 초대되지 않은 상태
- return {
- ...biddingData,
- biddingCompanyId: null,
- biddingId: biddingData.id,
- invitationStatus: null,
- preQuoteAmount: null,
- preQuoteSubmittedAt: null,
- preQuoteDeadline: null,
- isPreQuoteSelected: false,
- isPreQuoteParticipated: null,
- isAttendingMeeting: null,
- paymentTermsResponse: null,
- taxConditionsResponse: null,
- incotermsResponse: null,
- proposedContractDeliveryDate: null,
- proposedShippingPort: null,
- proposedDestinationPort: null,
- priceAdjustmentResponse: null,
- sparePartResponse: null,
- isInitialResponse: null,
- additionalProposals: null,
- }
- }
-
- const companyData = companyResult[0]
-
- return {
- ...biddingData,
- ...companyData,
- biddingId: biddingData.id, // bidding ID 보장
- }
- } catch (error) {
- console.error('Failed to get bidding companies for partners:', error)
- throw error
- }
-}
-
-// Partners에서 사전견적 응답 제출
-export async function submitPreQuoteResponse(
- biddingCompanyId: number,
- responseData: {
- preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional
- prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가
- paymentTermsResponse?: string
- taxConditionsResponse?: string
- incotermsResponse?: string
- proposedContractDeliveryDate?: string
- proposedShippingPort?: string
- proposedDestinationPort?: string
- priceAdjustmentResponse?: boolean
- isInitialResponse?: boolean
- sparePartResponse?: string
- additionalProposals?: string
- priceAdjustmentForm?: any
- },
- userId: string
-) {
- try {
- let finalAmount = responseData.preQuoteAmount || 0
-
- await db.transaction(async (tx) => {
- // 1. 품목별 견적 정보 최종 저장 (사전견적 제출)
- if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
- // 기존 사전견적 품목 삭제 후 새로 생성
- await tx.delete(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // 품목별 견적 최종 저장
- for (const item of responseData.prItemQuotations) {
- await tx.insert(companyPrItemBids)
- .values({
- biddingCompanyId,
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice.toString(),
- bidAmount: item.bidAmount.toString(),
- proposedDeliveryDate: item.proposedDeliveryDate || null,
- technicalSpecification: item.technicalSpecification || null,
- currency: 'KRW',
- isPreQuote: true,
- submittedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- })
- }
-
- // 총 금액 다시 계산
- finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
- }
-
- // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경)
- await tx.update(biddingCompanies)
- .set({
- preQuoteAmount: finalAmount.toString(),
- preQuoteSubmittedAt: new Date(),
- invitationStatus: 'pre_quote_submitted', // 사전견적제출완료 상태로 변경
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- // 3. company_condition_responses 업데이트
- const finalConditionResult = await tx.update(companyConditionResponses)
- .set({
- paymentTermsResponse: responseData.paymentTermsResponse,
- taxConditionsResponse: responseData.taxConditionsResponse,
- incotermsResponse: responseData.incotermsResponse,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
- proposedShippingPort: responseData.proposedShippingPort,
- proposedDestinationPort: responseData.proposedDestinationPort,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse,
- isInitialResponse: responseData.isInitialResponse,
- sparePartResponse: responseData.sparePartResponse,
- additionalProposals: responseData.additionalProposals,
- updatedAt: new Date()
- })
- .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
- .returning()
-
- // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
- if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) {
- const companyConditionResponseId = finalConditionResult[0].id
-
- const priceAdjustmentData = {
- companyConditionResponsesId: companyConditionResponseId,
- itemName: responseData.priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
- comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
- adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
- notes: responseData.priceAdjustmentForm.notes,
- adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
- adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
- nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
- } as any
-
- // 기존 연동제 정보가 있는지 확인
- const existingPriceAdjustment = await tx
- .select()
- .from(priceAdjustmentForms)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- .limit(1)
-
- if (existingPriceAdjustment.length > 0) {
- // 업데이트
- await tx
- .update(priceAdjustmentForms)
- .set(priceAdjustmentData)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- } else {
- // 새로 생성
- await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
- }
- }
-
- // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만)
- // 또한 사전견적 접수일 업데이트
- const biddingCompany = await tx
- .select({ biddingId: biddingCompanies.biddingId })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, biddingCompanyId))
- .limit(1)
-
- if (biddingCompany.length > 0) {
- await tx
- .update(biddings)
- .set({
- status: 'received_quotation',
- preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, biddingCompany[0].biddingId),
- eq(biddings.status, 'request_for_quotation')
- ))
- }
- })
-
- return {
- success: true,
- message: '사전견적이 성공적으로 제출되었습니다.'
- }
- } catch (error) {
- console.error('Failed to submit pre-quote response:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.'
- }
- }
-}
-
-// Partners에서 사전견적 참여 의사 결정 (참여/미참여)
-export async function respondToPreQuoteInvitation(
- biddingCompanyId: number,
- response: 'pre_quote_accepted' | 'pre_quote_declined'
-) {
- try {
- await db.update(biddingCompanies)
- .set({
- invitationStatus: response, // pre_quote_accepted 또는 pre_quote_declined
- respondedAt: new Date(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- const message = response === 'pre_quote_accepted' ?
- '사전견적 참여를 수락했습니다.' :
- '사전견적 참여를 거절했습니다.'
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to respond to pre-quote invitation:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.'
- }
- }
-}
-
-// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
-export async function setPreQuoteParticipation(
- biddingCompanyId: number,
- isParticipating: boolean
-) {
- try {
- await db.update(biddingCompanies)
- .set({
- isPreQuoteParticipated: isParticipating,
- respondedAt: new Date(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- const message = isParticipating ?
- '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' :
- '사전견적 참여를 거절했습니다.'
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to set pre-quote participation:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.'
- }
- }
-}
// PR 아이템 조회 (입찰에 포함된 품목들)
export async function getPrItemsForBidding(biddingId: number, companyId?: number) {
@@ -937,146 +309,6 @@ export async function getSpecDocumentsForPrItem(prItemId: number) {
}
}
-// 사전견적 임시저장
-export async function savePreQuoteDraft(
- biddingCompanyId: number,
- responseData: {
- prItemQuotations?: PrItemQuotation[]
- paymentTermsResponse?: string
- taxConditionsResponse?: string
- incotermsResponse?: string
- proposedContractDeliveryDate?: string
- proposedShippingPort?: string
- proposedDestinationPort?: string
- priceAdjustmentResponse?: boolean
- isInitialResponse?: boolean
- sparePartResponse?: string
- additionalProposals?: string
- priceAdjustmentForm?: any
- },
- userId: string
-) {
- try {
- let totalAmount = 0
- console.log('responseData', responseData)
-
- await db.transaction(async (tx) => {
- // 품목별 견적 정보 저장
- if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
- // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기)
- await tx.delete(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // 새로운 품목별 견적 저장
- for (const item of responseData.prItemQuotations) {
- await tx.insert(companyPrItemBids)
- .values({
- biddingCompanyId,
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice.toString(),
- bidAmount: item.bidAmount.toString(),
- proposedDeliveryDate: item.proposedDeliveryDate || null,
- technicalSpecification: item.technicalSpecification || null,
- currency: 'KRW',
- isPreQuote: true, // 사전견적 표시
- submittedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- })
- }
-
- // 총 금액 계산
- totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
-
- // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음)
- await tx.update(biddingCompanies)
- .set({
- preQuoteAmount: totalAmount.toString(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
- }
-
- // company_condition_responses 업데이트 (임시저장)
- const conditionResult = await tx.update(companyConditionResponses)
- .set({
- paymentTermsResponse: responseData.paymentTermsResponse || null,
- taxConditionsResponse: responseData.taxConditionsResponse || null,
- incotermsResponse: responseData.incotermsResponse || null,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null,
- proposedShippingPort: responseData.proposedShippingPort || null,
- proposedDestinationPort: responseData.proposedDestinationPort || null,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse || null,
- isInitialResponse: responseData.isInitialResponse || null,
- sparePartResponse: responseData.sparePartResponse || null,
- additionalProposals: responseData.additionalProposals || null,
- updatedAt: new Date()
- })
- .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
- .returning()
-
- // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
- if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) {
- const companyConditionResponseId = conditionResult[0].id
-
- const priceAdjustmentData = {
- companyConditionResponsesId: companyConditionResponseId,
- itemName: responseData.priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
- comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
- adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
- notes: responseData.priceAdjustmentForm.notes,
- adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
- adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
- nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
- } as any
-
- // 기존 연동제 정보가 있는지 확인
- const existingPriceAdjustment = await tx
- .select()
- .from(priceAdjustmentForms)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- .limit(1)
-
- if (existingPriceAdjustment.length > 0) {
- // 업데이트
- await tx
- .update(priceAdjustmentForms)
- .set(priceAdjustmentData)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- } else {
- // 새로 생성
- await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
- }
- }
- })
-
- return {
- success: true,
- message: '임시저장이 완료되었습니다.',
- totalAmount
- }
- } catch (error) {
- console.error('Failed to save pre-quote draft:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '임시저장에 실패했습니다.'
- }
- }
-}
-
// 견적 문서 업로드
export async function uploadPreQuoteDocument(
biddingId: number,
@@ -1165,40 +397,6 @@ export async function getPreQuoteDocuments(biddingId: number, companyId: number)
}
}
-// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
-export async function getSavedPrItemQuotations(biddingCompanyId: number) {
- try {
- const savedQuotations = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- bidUnitPrice: companyPrItemBids.bidUnitPrice,
- bidAmount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- technicalSpecification: companyPrItemBids.technicalSpecification,
- currency: companyPrItemBids.currency
- })
- .from(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // Decimal 타입을 number로 변환
- return savedQuotations.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: parseFloat(item.bidUnitPrice || '0'),
- bidAmount: parseFloat(item.bidAmount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate,
- technicalSpecification: item.technicalSpecification,
- currency: item.currency
- }))
- } catch (error) {
- console.error('Failed to get saved PR item quotations:', error)
- return []
- }
- }
// 견적 문서 정보 조회 (다운로드용)
export async function getPreQuoteDocumentForDownload(
@@ -1673,4 +871,337 @@ export async function getSelectedVendorsForBidding(biddingId: number) {
vendors: []
}
}
-} \ No newline at end of file
+}
+
+//입찰 사전견적 생성 서버액션
+interface CreatePreQuoteRfqInput {
+ rfqType: string;
+ rfqTitle: string;
+ dueDate: Date;
+ picUserId: number;
+ projectId?: number;
+ remark?: string;
+ biddingNumber?: string;
+ biddingId?: number; // 추가
+ contractStartDate?: Date;
+ contractEndDate?: Date;
+ items: Array<{
+ itemCode: string;
+ itemName: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ materialCode?: string;
+ materialName?: string;
+ }>;
+ biddingConditions?: {
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ };
+ createdBy: number;
+ updatedBy: number;
+}
+
+export async function createPreQuoteRfqAction(input: CreatePreQuoteRfqInput) {
+ try {
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 1. 구매 담당자 정보 조회
+ const picUser = await tx
+ .select({
+ name: users.name,
+ email: users.email,
+ userCode: users.userCode
+ })
+ .from(users)
+ .where(eq(users.id, input.picUserId))
+ .limit(1);
+
+ if (!picUser || picUser.length === 0) {
+ throw new Error("구매 담당자를 찾을 수 없습니다");
+ }
+
+ // 2. userCode 확인 (3자리)
+ const userCode = picUser[0].userCode;
+ if (!userCode || userCode.length !== 3) {
+ throw new Error("구매 담당자의 userCode가 올바르지 않습니다 (3자리 필요)");
+ }
+
+ // 3. RFQ 코드 생성 (B + userCode + 00001)
+ const rfqCode = await generatePreQuoteRfqCode(userCode);
+
+ // 4. 대표 아이템 정보 추출 (첫 번째 아이템)
+ const representativeItem = input.items[0];
+
+ // 5. 마감일 기본값 설정 (입력값 없으면 생성일 + 7일)
+ const dueDate = input.dueDate || await getDefaultDueDate();
+
+ // 6. rfqsLast 테이블에 기본 정보 삽입
+ const [newRfq] = await tx
+ .insert(rfqsLast)
+ .values({
+ rfqCode,
+ rfqType: 'pre_bidding',
+ rfqTitle: input.rfqTitle,
+ status: "RFQ 생성",
+ dueDate: dueDate, // 마감일 기본값 설정
+ biddingNumber: input.biddingNumber || null,
+ contractStartDate: input.contractStartDate || null,
+ contractEndDate: input.contractEndDate || null,
+
+ // 프로젝트 정보 (선택사항)
+ projectId: input.projectId || null,
+
+ // 대표 아이템 정보
+ itemCode: representativeItem.materialCode || representativeItem.itemCode,
+ itemName: representativeItem.materialName || representativeItem.itemName,
+
+ // 담당자 정보
+ pic: input.picUserId,
+ picCode: userCode, // userCode를 picCode로 사용
+ picName: picUser[0].name || '',
+
+ // 기타 정보
+ remark: input.remark || null,
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ // 7. rfqPrItems 테이블에 아이템들 삽입
+ const prItemsData = input.items.map((item, index) => ({
+ rfqsLastId: newRfq.id,
+ rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ...
+ prItem: null, // 일반견적에서는 PR 아이템 번호를 null로 설정
+ prNo: null, // 일반견적에서는 PR 번호를 null로 설정
+
+ // 자재그룹 정보
+ materialCategory: item.itemCode, // 자재그룹코드
+ materialDescription: item.itemName, // 자재그룹명
+
+ // 자재 정보
+ materialCode: item.materialCode, // SAP 자재코드
+ acc: item.materialName || null, // 자재명 (ACC 컬럼에 저장)
+ quantity: item.quantity, // 수량
+ uom: item.uom, // 단위
+
+ majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
+ remark: item.remark || null, // 비고
+ }));
+
+ await tx.insert(rfqPrItems).values(prItemsData);
+
+ // 8. 벤더 및 조건 생성 (biddingId가 있는 경우)
+ if (input.biddingId) {
+ // 입찰 조건 매핑
+ const rfqConditions = mapBiddingConditionsToRfqConditions(input.biddingConditions);
+
+ // 입찰 업체 조회
+ const biddingVendors = await tx
+ .select({
+ companyId: biddingCompanies.companyId,
+ })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.biddingId, input.biddingId));
+
+ if (biddingVendors.length > 0) {
+ for (const vendor of biddingVendors) {
+ if (!vendor.companyId) continue;
+
+ // rfqLastDetails 생성
+ const [rfqDetail] = await tx
+ .insert(rfqLastDetails)
+ .values({
+ rfqsLastId: newRfq.id,
+ vendorsId: vendor.companyId,
+ currency: rfqConditions.currency,
+ paymentTermsCode: rfqConditions.paymentTermsCode || null,
+ incotermsCode: rfqConditions.incotermsCode || null,
+ incotermsDetail: rfqConditions.incotermsDetail || null,
+ deliveryDate: rfqConditions.deliveryDate || null,
+ taxCode: rfqConditions.taxCode || null,
+ placeOfShipping: rfqConditions.placeOfShipping || null,
+ placeOfDestination: rfqConditions.placeOfDestination || null,
+ materialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ sparepartYn: rfqConditions.sparepartYn,
+ sparepartDescription: rfqConditions.sparepartDescription || null,
+ updatedBy: input.updatedBy,
+ createdBy: input.createdBy,
+ isLatest: true,
+ })
+ .returning();
+
+ // rfqLastVendorResponses 생성
+ const [vendorResponse] = await tx
+ .insert(rfqLastVendorResponses)
+ .values({
+ rfqsLastId: newRfq.id,
+ rfqLastDetailsId: rfqDetail.id,
+ vendorId: vendor.companyId,
+ status: '대기중',
+ responseVersion: 1,
+ isLatest: true,
+ participationStatus: '미응답',
+ currency: rfqConditions.currency,
+ // 구매자 제시 조건을 벤더 제안 조건의 초기값으로 복사
+ vendorCurrency: rfqConditions.currency,
+ vendorPaymentTermsCode: rfqConditions.paymentTermsCode || null,
+ vendorIncotermsCode: rfqConditions.incotermsCode || null,
+ vendorIncotermsDetail: rfqConditions.incotermsDetail || null,
+ vendorDeliveryDate: rfqConditions.vendorDeliveryDate || null,
+ vendorTaxCode: rfqConditions.taxCode || null,
+ vendorPlaceOfShipping: rfqConditions.placeOfShipping || null,
+ vendorPlaceOfDestination: rfqConditions.placeOfDestination || null,
+ vendorMaterialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ vendorSparepartYn: rfqConditions.sparepartYn,
+ vendorSparepartDescription: rfqConditions.sparepartDescription || null,
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ })
+ .returning();
+
+ // 이력 기록
+ await tx
+ .insert(rfqLastVendorResponseHistory)
+ .values({
+ vendorResponseId: vendorResponse.id,
+ action: '생성',
+ newStatus: '대기중',
+ changeDetails: {
+ action: '사전견적용 일반견적 생성',
+ biddingId: input.biddingId,
+ conditions: rfqConditions,
+ },
+ performedBy: input.createdBy,
+ });
+ }
+ }
+ }
+
+ return newRfq;
+ });
+
+ return {
+ success: true,
+ message: "입찰 사전견적이 성공적으로 생성되었습니다",
+ data: {
+ id: result.id,
+ rfqCode: result.rfqCode,
+ },
+ };
+
+ } catch (error) {
+ console.error("입찰 사전견적 생성 오류:", error);
+
+ if (error instanceof Error) {
+ return {
+ success: false,
+ error: error.message,
+ };
+ }
+
+ return {
+ success: false,
+ error: "입찰 사전견적 생성 중 오류가 발생했습니다",
+ };
+ }
+}
+
+// 사전견적(입찰) RFQ 코드 생성 (B+userCode(3자리)+일련번호5자리 형식)
+async function generatePreQuoteRfqCode(userCode: string): Promise<string> {
+ // circular dependency check: use dynamic import for schema if needed, but generatePreQuoteRfqCode is used inside the action
+ // rfqsLast is already imported at top.
+
+ try {
+ // 동일한 userCode를 가진 마지막 사전견적 번호 조회
+ const lastRfq = await db
+ .select({ rfqCode: rfqsLast.rfqCode })
+ .from(rfqsLast)
+ .where(
+ and(
+ like(rfqsLast.rfqCode, `B${userCode}%`) // 같은 userCode로 시작하는 RFQ만 조회
+ )
+ )
+ .orderBy(desc(rfqsLast.createdAt))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
+ // B+userCode(3자리)+일련번호(5자리) 형식에서 마지막 5자리 숫자 추출
+ const rfqCode = lastRfq[0].rfqCode;
+ const serialNumber = rfqCode.slice(-5); // 마지막 5자리 추출
+
+ // 숫자인지 확인하고 다음 번호 생성
+ if (/^\d{5}$/.test(serialNumber)) {
+ nextNumber = parseInt(serialNumber) + 1;
+ }
+ }
+
+ // 5자리 숫자로 패딩
+ const paddedNumber = String(nextNumber).padStart(5, '0');
+ return `B${userCode}${paddedNumber}`;
+ } catch (error) {
+ console.error("Error generating Pre-Quote RFQ code:", error);
+ // 에러 발생 시 타임스탬프 기반 코드 생성
+ const timestamp = Date.now().toString().slice(-5);
+ return `B${userCode}${timestamp}`;
+ }
+}
+
+// Helper function to map bidding conditions
+function mapBiddingConditionsToRfqConditions(conditions?: CreatePreQuoteRfqInput['biddingConditions']) {
+ if (!conditions) {
+ return {
+ currency: 'KRW',
+ paymentTermsCode: undefined,
+ incotermsCode: undefined,
+ incotermsDetail: undefined,
+ deliveryDate: undefined,
+ taxCode: undefined,
+ placeOfShipping: undefined,
+ placeOfDestination: undefined,
+ materialPriceRelatedYn: false,
+ sparepartYn: false,
+ sparepartDescription: undefined,
+ vendorDeliveryDate: undefined
+ }
+ }
+
+ // contractDeliveryDate 문자열을 Date로 변환 (timestamp 타입용)
+ let deliveryDate: Date | undefined = undefined
+ if (conditions.contractDeliveryDate) {
+ try {
+ const date = new Date(conditions.contractDeliveryDate)
+ if (!isNaN(date.getTime())) {
+ deliveryDate = date
+ }
+ } catch (error) {
+ console.warn('Failed to parse contractDeliveryDate:', error)
+ }
+ }
+
+ return {
+ currency: 'KRW', // 기본값
+ paymentTermsCode: conditions.paymentTerms || undefined,
+ incotermsCode: conditions.incoterms || undefined,
+ incotermsDetail: conditions.incotermsOption || undefined,
+ deliveryDate: deliveryDate, // timestamp 타입 (rfqLastDetails용)
+ vendorDeliveryDate: deliveryDate, // date 타입 (rfqLastVendorResponses용)
+ taxCode: conditions.taxConditions || undefined,
+ placeOfShipping: conditions.shippingPort || undefined,
+ placeOfDestination: conditions.destinationPort || undefined,
+ materialPriceRelatedYn: conditions.isPriceAdjustmentApplicable ?? false,
+ sparepartYn: !!conditions.sparePartOptions, // sparePartOptions가 있으면 true
+ sparepartDescription: conditions.sparePartOptions || undefined,
+ }
+}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 0064b66f..a658ee6a 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -391,6 +391,7 @@ export async function getBiddings(input: GetBiddingsSchema) {
id: biddings.id,
biddingNumber: biddings.biddingNumber,
originalBiddingNumber: biddings.originalBiddingNumber,
+ projectCode: biddings.projectCode,
projectName: biddings.projectName,
title: biddings.title,
@@ -2150,6 +2151,7 @@ export async function updateBiddingProjectInfo(biddingId: number) {
try {
const firstItem = await db
.select({
+ projectId: prItemsForBidding.projectId,
projectInfo: prItemsForBidding.projectInfo
})
.from(prItemsForBidding)
@@ -2157,16 +2159,36 @@ export async function updateBiddingProjectInfo(biddingId: number) {
.orderBy(prItemsForBidding.id)
.limit(1)
- if (firstItem.length > 0 && firstItem[0].projectInfo) {
+ if (firstItem.length > 0) {
+ let projectName = firstItem[0].projectInfo
+ let projectCode = null
+
+ if (firstItem[0].projectId) {
+ const project = await db
+ .select({
+ name: projects.name,
+ code: projects.code
+ })
+ .from(projects)
+ .where(eq(projects.id, firstItem[0].projectId))
+ .limit(1)
+
+ if (project.length > 0) {
+ projectName = project[0].name
+ projectCode = project[0].code
+ }
+ }
+
await db
.update(biddings)
.set({
- projectName: firstItem[0].projectInfo,
+ projectName: projectName,
+ projectCode: projectCode,
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
- console.log(`Bidding ${biddingId} project info updated to: ${firstItem[0].projectInfo}`)
+ console.log(`Bidding ${biddingId} project info updated to: ${projectName} (${projectCode})`)
}
} catch (error) {
console.error('Failed to update bidding project info:', error)
@@ -2556,227 +2578,6 @@ export async function updateBiddingConditions(
}
}
-// 사전견적용 일반견적 생성 액션
-export async function createPreQuoteRfqAction(input: {
- biddingId: number
- rfqType: string
- rfqTitle: string
- dueDate: Date
- picUserId: number
- projectId?: number
- remark?: string
- items: Array<{
- itemCode: string
- itemName: string
- materialCode?: string
- materialName?: string
- quantity: number
- uom: string
- remark?: string
- }>
- biddingConditions?: {
- paymentTerms?: string | null
- taxConditions?: string | null
- incoterms?: string | null
- incotermsOption?: string | null
- contractDeliveryDate?: string | null
- shippingPort?: string | null
- destinationPort?: string | null
- isPriceAdjustmentApplicable?: boolean | null
- sparePartOptions?: string | null
- }
- createdBy: number
- updatedBy: number
-}) {
- try {
- // 일반견적 생성 서버 액션 및 필요한 스키마 import
- const { createGeneralRfqAction } = await import('@/lib/rfq-last/service')
- const { rfqLastDetails, rfqLastVendorResponses, rfqLastVendorResponseHistory } = await import('@/db/schema')
-
- // 일반견적 생성
- const result = await createGeneralRfqAction({
- rfqType: input.rfqType,
- rfqTitle: input.rfqTitle,
- dueDate: input.dueDate,
- picUserId: input.picUserId,
- projectId: input.projectId,
- remark: input.remark || '',
- items: input.items.map(item => ({
- itemCode: item.itemCode,
- itemName: item.itemName,
- quantity: item.quantity,
- uom: item.uom,
- remark: item.remark,
- materialCode: item.materialCode,
- materialName: item.materialName,
- })),
- createdBy: input.createdBy,
- updatedBy: input.updatedBy,
- })
-
- if (!result.success || !result.data) {
- return {
- success: false,
- error: result.error || '사전견적용 일반견적 생성에 실패했습니다',
- }
- }
-
- const rfqId = result.data.id
- const conditions = input.biddingConditions
-
- // 입찰 조건을 RFQ 조건으로 매핑
- const mapBiddingConditionsToRfqConditions = () => {
- if (!conditions) {
- return {
- currency: 'KRW',
- paymentTermsCode: undefined,
- incotermsCode: undefined,
- incotermsDetail: undefined,
- deliveryDate: undefined,
- taxCode: undefined,
- placeOfShipping: undefined,
- placeOfDestination: undefined,
- materialPriceRelatedYn: false,
- sparepartYn: false,
- sparepartDescription: undefined,
- }
- }
-
- // contractDeliveryDate 문자열을 Date로 변환 (timestamp 타입용)
- let deliveryDate: Date | undefined = undefined
- if (conditions.contractDeliveryDate) {
- try {
- const date = new Date(conditions.contractDeliveryDate)
- if (!isNaN(date.getTime())) {
- deliveryDate = date
- }
- } catch (error) {
- console.warn('Failed to parse contractDeliveryDate:', error)
- }
- }
-
- return {
- currency: 'KRW', // 기본값
- paymentTermsCode: conditions.paymentTerms || undefined,
- incotermsCode: conditions.incoterms || undefined,
- incotermsDetail: conditions.incotermsOption || undefined,
- deliveryDate: deliveryDate, // timestamp 타입 (rfqLastDetails용)
- vendorDeliveryDate: deliveryDate, // date 타입 (rfqLastVendorResponses용)
- taxCode: conditions.taxConditions || undefined,
- placeOfShipping: conditions.shippingPort || undefined,
- placeOfDestination: conditions.destinationPort || undefined,
- materialPriceRelatedYn: conditions.isPriceAdjustmentApplicable ?? false,
- sparepartYn: !!conditions.sparePartOptions, // sparePartOptions가 있으면 true
- sparepartDescription: conditions.sparePartOptions || undefined,
- }
- }
-
- const rfqConditions = mapBiddingConditionsToRfqConditions()
-
- // 입찰에 참여한 업체 목록 조회
- const vendorsResult = await getBiddingVendors(input.biddingId)
- if (!vendorsResult.success || !vendorsResult.data || vendorsResult.data.length === 0) {
- return {
- success: true,
- message: '사전견적용 일반견적이 생성되었습니다. (참여 업체 없음)',
- data: {
- rfqCode: result.data.rfqCode,
- rfqId: result.data.id,
- },
- }
- }
-
- // 각 업체에 대해 rfqLastDetails와 rfqLastVendorResponses 생성
- await db.transaction(async (tx) => {
- for (const vendor of vendorsResult.data) {
- if (!vendor.companyId) continue
-
- // 1. rfqLastDetails 생성 (구매자 제시 조건)
- const [rfqDetail] = await tx
- .insert(rfqLastDetails)
- .values({
- rfqsLastId: rfqId,
- vendorsId: vendor.companyId,
- currency: rfqConditions.currency,
- paymentTermsCode: rfqConditions.paymentTermsCode || null,
- incotermsCode: rfqConditions.incotermsCode || null,
- incotermsDetail: rfqConditions.incotermsDetail || null,
- deliveryDate: rfqConditions.deliveryDate || null,
- taxCode: rfqConditions.taxCode || null,
- placeOfShipping: rfqConditions.placeOfShipping || null,
- placeOfDestination: rfqConditions.placeOfDestination || null,
- materialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
- sparepartYn: rfqConditions.sparepartYn,
- sparepartDescription: rfqConditions.sparepartDescription || null,
- updatedBy: input.updatedBy,
- createdBy: input.createdBy,
- isLatest: true,
- })
- .returning()
-
- // 2. rfqLastVendorResponses 생성 (초기 응답 레코드)
- const [vendorResponse] = await tx
- .insert(rfqLastVendorResponses)
- .values({
- rfqsLastId: rfqId,
- rfqLastDetailsId: rfqDetail.id,
- vendorId: vendor.companyId,
- status: '대기중',
- responseVersion: 1,
- isLatest: true,
- participationStatus: '미응답',
- currency: rfqConditions.currency,
- // 구매자 제시 조건을 벤더 제안 조건의 초기값으로 복사
- vendorCurrency: rfqConditions.currency,
- vendorPaymentTermsCode: rfqConditions.paymentTermsCode || null,
- vendorIncotermsCode: rfqConditions.incotermsCode || null,
- vendorIncotermsDetail: rfqConditions.incotermsDetail || null,
- vendorDeliveryDate: rfqConditions.vendorDeliveryDate || null,
- vendorTaxCode: rfqConditions.taxCode || null,
- vendorPlaceOfShipping: rfqConditions.placeOfShipping || null,
- vendorPlaceOfDestination: rfqConditions.placeOfDestination || null,
- vendorMaterialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
- vendorSparepartYn: rfqConditions.sparepartYn,
- vendorSparepartDescription: rfqConditions.sparepartDescription || null,
- createdBy: input.createdBy,
- updatedBy: input.updatedBy,
- })
- .returning()
-
- // 3. 이력 기록
- await tx
- .insert(rfqLastVendorResponseHistory)
- .values({
- vendorResponseId: vendorResponse.id,
- action: '생성',
- newStatus: '대기중',
- changeDetails: {
- action: '사전견적용 일반견적 생성',
- biddingId: input.biddingId,
- conditions: rfqConditions,
- },
- performedBy: input.createdBy,
- })
- }
- })
-
- return {
- success: true,
- message: `사전견적용 일반견적이 성공적으로 생성되었습니다. (${vendorsResult.data.length}개 업체 추가)`,
- data: {
- rfqCode: result.data.rfqCode,
- rfqId: result.data.id,
- },
- }
- } catch (error) {
- console.error('Failed to create pre-quote RFQ:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '사전견적용 일반견적 생성에 실패했습니다',
- }
- }
-}
-
// 일반견적 RFQ 코드 미리보기 (rfq-last/service에서 재사용)
export async function previewGeneralRfqCode(picUserId: number): Promise<string> {
try {
@@ -3404,8 +3205,65 @@ export async function getVendorContactsByVendorId(vendorId: number) {
// bid-receive 페이지용 함수들
// ═══════════════════════════════════════════════════════════════
+// 입찰서 접수 기간 만료 체크 및 상태 업데이트
+export async function checkAndCloseExpiredBiddings() {
+ try {
+ const now = new Date()
+
+ // 1. 기간이 만료되었는데 아직 진행중인 입찰 조회
+ const expiredBiddings = await db
+ .select({ id: biddings.id })
+ .from(biddings)
+ .where(
+ and(
+ or(
+ eq(biddings.status, 'bidding_opened')
+ ),
+ lte(biddings.submissionEndDate, now)
+ )
+ )
+
+ if (expiredBiddings.length === 0) {
+ return
+ }
+
+ const expiredBiddingIds = expiredBiddings.map(b => b.id)
+
+ // 2. 입찰 상태를 '입찰마감(bidding_closed)'으로 변경
+ await db
+ .update(biddings)
+ .set({ status: 'bidding_closed' })
+ .where(inArray(biddings.id, expiredBiddingIds))
+
+ // 3. 최종 제출 버튼을 누르지 않은 벤더들의 가장 마지막 견적을 최종 제출로 처리
+ // biddingCompanies 테이블에 이미 마지막 견적 정보가 저장되어 있다고 가정
+ await db
+ .update(biddingCompanies)
+ .set({
+ isFinalSubmission: true,
+ invitationStatus: 'bidding_submitted' // 상태도 최종 응찰로 변경
+ })
+ .where(
+ and(
+ inArray(biddingCompanies.biddingId, expiredBiddingIds),
+ eq(biddingCompanies.isFinalSubmission, false),
+ isNotNull(biddingCompanies.finalQuoteAmount) // 견적 금액이 있는 경우만 (참여한 경우)
+ )
+ )
+
+ // 데이터 갱신을 위해 경로 재검증
+ revalidatePath('/evcp/bid-receive')
+
+ } catch (error) {
+ console.error('Error in checkAndCloseExpiredBiddings:', error)
+ }
+}
+
// bid-receive: 입찰서접수및마감 페이지용 입찰 목록 조회
export async function getBiddingsForReceive(input: GetBiddingsSchema) {
+ // 조회 전 만료된 입찰 상태 업데이트
+ await checkAndCloseExpiredBiddings()
+
try {
const offset = (input.page - 1) * input.perPage
diff --git a/lib/general-contracts/detail/general-contract-info-header.tsx b/lib/general-contracts/detail/general-contract-info-header.tsx
index c0a79d09..c966685e 100644
--- a/lib/general-contracts/detail/general-contract-info-header.tsx
+++ b/lib/general-contracts/detail/general-contract-info-header.tsx
@@ -1,5 +1,6 @@
import { Building2, Package, DollarSign, Calendar, FileText } from 'lucide-react'
import { formatDate } from '@/lib/utils'
+import { GENERAL_CONTRACT_TYPE_LABELS, GeneralContractType } from '@/lib/general-contracts/types'
interface GeneralContractInfoHeaderProps {
contract: {
@@ -45,24 +46,6 @@ const categoryLabels = {
'매각계약': '매각계약'
}
-const typeLabels = {
- 'UP': '자재단가계약',
- 'LE': '임대차계약',
- 'IL': '개별운송계약',
- 'AL': '연간운송계약',
- 'OS': '외주용역계약',
- 'OW': '도급계약',
- 'LO': 'LOI',
- 'FA': 'FA',
- 'SC': '납품합의계약',
- 'OF': '클레임상계계약',
- 'AW': '사전작업합의',
- 'AD': '사전납품합의',
- 'SG': '임치(물품보관)계약',
- 'SR': '폐기물매각계약',
- 'SP': 'S-PEpC'
-}
-
export function GeneralContractInfoHeader({ contract }: GeneralContractInfoHeaderProps) {
return (
<div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
@@ -137,7 +120,7 @@ export function GeneralContractInfoHeader({ contract }: GeneralContractInfoHeade
<div className="flex flex-col gap-1">
<span className="text-gray-500 text-sm">계약종류</span>
- <span className="font-medium">{typeLabels[contract.type as keyof typeof typeLabels] || contract.type}</span>
+ <span className="font-medium">{GENERAL_CONTRACT_TYPE_LABELS[contract.type as GeneralContractType] || contract.type}</span>
</div>
<div className="flex flex-col gap-1">
diff --git a/lib/general-contracts/main/create-general-contract-dialog.tsx b/lib/general-contracts/main/create-general-contract-dialog.tsx
index 8a506e4f..720192d8 100644
--- a/lib/general-contracts/main/create-general-contract-dialog.tsx
+++ b/lib/general-contracts/main/create-general-contract-dialog.tsx
@@ -22,6 +22,7 @@ import { createContract } from "@/lib/general-contracts/service"
import {
GENERAL_CONTRACT_CATEGORIES,
GENERAL_CONTRACT_TYPES,
+ GENERAL_CONTRACT_TYPE_LABELS,
GENERAL_EXECUTION_METHODS
} from "@/lib/general-contracts/types"
import { useSession } from "next-auth/react"
@@ -209,26 +210,9 @@ export function CreateGeneralContractDialog() {
</SelectTrigger>
<SelectContent>
{GENERAL_CONTRACT_TYPES.map((type) => {
- const typeLabels = {
- 'UP': '자재단가계약',
- 'LE': '임대차계약',
- 'IL': '개별운송계약',
- 'AL': '연간운송계약',
- 'OS': '외주용역계약',
- 'OW': '도급계약',
- 'LO': 'LOI',
- 'FA': 'FA',
- 'SC': '납품합의계약',
- 'OF': '클레임상계계약',
- 'AW': '사전작업합의',
- 'AD': '사전납품합의',
- 'SG': '임치(물품보관)계약',
- 'SR': '폐기물매각계약',
- 'SP': 'S-PEpC'
- }
return (
<SelectItem key={type} value={type}>
- {type} - {typeLabels[type as keyof typeof typeLabels]}
+ {type} - {GENERAL_CONTRACT_TYPE_LABELS[type]}
</SelectItem>
)
})}
diff --git a/lib/general-contracts/main/general-contracts-table-columns.tsx b/lib/general-contracts/main/general-contracts-table-columns.tsx
index c43bb383..ce51b791 100644
--- a/lib/general-contracts/main/general-contracts-table-columns.tsx
+++ b/lib/general-contracts/main/general-contracts-table-columns.tsx
@@ -17,6 +17,7 @@ import {
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { DataTableRowAction } from "@/types/table"
import { formatDate } from "@/lib/utils"
+import { GENERAL_CONTRACT_TYPE_LABELS, GeneralContractType, isGeneralContractType } from "@/lib/general-contracts/types"
// 일반계약 리스트 아이템 타입 정의
export interface GeneralContractListItem {
@@ -115,40 +116,10 @@ const getCategoryText = (category: string) => {
// 계약종류 텍스트 변환
const getTypeText = (type: string) => {
- switch (type) {
- case 'UP':
- return '자재단가계약'
- case 'LE':
- return '임대차계약'
- case 'IL':
- return '개별운송계약'
- case 'AL':
- return '연간운송계약'
- case 'OS':
- return '외주용역계약'
- case 'OW':
- return '도급계약'
- case 'LO':
- return 'LOI'
- case 'FA':
- return 'FA'
- case 'SC':
- return '납품합의계약'
- case 'OF':
- return '클레임상계계약'
- case 'AW':
- return '사전작업합의'
- case 'AD':
- return '사전납품합의'
- case 'SG':
- return '임치(물품보관)계약'
- case 'SR':
- return '폐기물매각계약'
- case 'SP':
- return 'S-PEpC'
- default:
- return type
+ if (isGeneralContractType(type)) {
+ return GENERAL_CONTRACT_TYPE_LABELS[type];
}
+ return type;
}
// 체결방식 텍스트 변환
diff --git a/lib/general-contracts/main/general-contracts-table.tsx b/lib/general-contracts/main/general-contracts-table.tsx
index 5428435e..95bfe602 100644
--- a/lib/general-contracts/main/general-contracts-table.tsx
+++ b/lib/general-contracts/main/general-contracts-table.tsx
@@ -16,7 +16,8 @@ import { getGeneralContracts, getGeneralContractStatusCounts } from "@/lib/gener
import { GeneralContractsTableToolbarActions } from "./general-contracts-table-toolbar-actions"
import { GeneralContractUpdateSheet } from "./general-contract-update-sheet"
import {
- GENERAL_EXECUTION_METHODS
+ GENERAL_EXECUTION_METHODS,
+ GENERAL_CONTRACT_TYPE_LABELS
} from "@/lib/general-contracts/types"
// 상태 라벨 매핑
@@ -42,23 +43,7 @@ const contractCategoryLabels = {
}
// 계약종류 라벨 매핑
-const contractTypeLabels = {
- 'UP': '자재단가계약',
- 'LE': '임대차계약',
- 'IL': '개별운송계약',
- 'AL': '연간운송계약',
- 'OS': '외주용역계약',
- 'OW': '도급계약',
- 'LO': 'LOI',
- 'FA': 'FA',
- 'SC': '납품합의계약',
- 'OF': '클레임상계계약',
- 'AW': '사전작업합의',
- 'AD': '사전납품합의',
- 'SG': '임치(물품보관)계약',
- 'SR': '폐기물매각계약',
- 'SP': 'S-PEpC'
-}
+const contractTypeLabels = GENERAL_CONTRACT_TYPE_LABELS;
interface GeneralContractsTableProps {
promises: Promise<
diff --git a/lib/general-contracts/types.ts b/lib/general-contracts/types.ts
index 6793d76c..9761b414 100644
--- a/lib/general-contracts/types.ts
+++ b/lib/general-contracts/types.ts
@@ -25,11 +25,35 @@ export const GENERAL_CONTRACT_TYPES = [
'AD', // 사전납품합의 (Advanced Delivery)
'SG', // 임치(물품보관)계약
'SR', // 폐기물매각계약 (Scrap)
- 'SP' // S-PEpC
+ 'SP', // S-PEpC
+ 'CC', // 소모품 단가계약
+ 'DC', // 수출입가공 납품확인서
+ 'SA' // 정산합의
] as const;
export type GeneralContractType = typeof GENERAL_CONTRACT_TYPES[number];
+export const GENERAL_CONTRACT_TYPE_LABELS: Record<GeneralContractType, string> = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약',
+ 'SP': 'S-PEpC',
+ 'CC': '소모품 단가계약',
+ 'DC': '수출입가공 납품확인서',
+ 'SA': '정산합의'
+};
+
// 3. 계약상태
export const GENERAL_CONTRACT_STATUSES = [
'Draft', // 임시 저장
diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx
index be19f738..86ef4444 100644
--- a/lib/rfq-last/quotation-compare-view.tsx
+++ b/lib/rfq-last/quotation-compare-view.tsx
@@ -55,6 +55,7 @@ import { ComparisonData, selectVendor, cancelVendorSelection, VendorResponseVers
import { createPO, createGeneralContract, createBidding } from "./contract-actions";
import { toast } from "sonner";
import { useRouter } from "next/navigation"
+import { GENERAL_CONTRACT_TYPES, GENERAL_CONTRACT_TYPE_LABELS } from "@/lib/general-contracts/types";
interface QuotationCompareViewProps {
data: ComparisonData;
@@ -82,23 +83,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
const [contractEndDate, setContractEndDate] = React.useState("");
// 계약종류 옵션
- const contractTypes = [
- { value: 'UP', label: 'UP - 자재단가계약' },
- { value: 'LE', label: 'LE - 임대차계약' },
- { value: 'IL', label: 'IL - 개별운송계약' },
- { value: 'AL', label: 'AL - 연간운송계약' },
- { value: 'OS', label: 'OS - 외주용역계약' },
- { value: 'OW', label: 'OW - 도급계약' },
- { value: 'IS', label: 'IS - 검사계약' },
- { value: 'LO', label: 'LO - LOI (의향서)' },
- { value: 'FA', label: 'FA - Frame Agreement' },
- { value: 'SC', label: 'SC - 납품합의계약' },
- { value: 'OF', label: 'OF - 클레임상계계약' },
- { value: 'AW', label: 'AW - 사전작업합의' },
- { value: 'AD', label: 'AD - 사전납품합의' },
- { value: 'AM', label: 'AM - 설계계약' },
- { value: 'SC_SELL', label: 'SC - 폐기물매각계약' },
- ];
+ const contractTypes = GENERAL_CONTRACT_TYPES.map(type => ({
+ value: type,
+ label: `${type} - ${GENERAL_CONTRACT_TYPE_LABELS[type]}`
+ }));
// 입찰 관련 state 추가
const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale">("unit_price");
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 7ebec795..68cfdac7 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -27,6 +27,37 @@ export async function getDefaultDueDate(): Promise<Date> {
return defaultDueDate;
}
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+ type: string;
+}
+
+export async function getProjects(): Promise<Project[]> {
+ try {
+ // 트랜잭션을 사용하여 프로젝트 데이터 조회
+ const projectList = await db.transaction(async (tx) => {
+ // 모든 프로젝트 조회
+ const results = await tx
+ .select({
+ id: projects.id,
+ projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정
+ projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정
+ type: projects.type, // 테이블의 실제 컬럼명에 맞게 조정
+ })
+ .from(projects)
+ .orderBy(projects.code);
+
+ return results;
+ });
+
+ return projectList;
+ } catch (error) {
+ console.error("프로젝트 목록 가져오기 실패:", error);
+ return []; // 오류 발생 시 빈 배열 반환
+ }
+}
export async function getRfqs(input: GetRfqsSchema) {
unstable_noStore();
@@ -47,6 +78,11 @@ export async function getRfqs(input: GetRfqsSchema) {
typeFilter =
like(rfqsLastView.rfqCode,'F%');
break;
+ case "pre_bidding":
+ // 사전견적(입찰): rfqCode가 B로 시작하는 경우
+ typeFilter =
+ like(rfqsLastView.rfqCode,'B%');
+ break;
case "itb":
// ITB: projectCompany가 있는 경우
typeFilter =
@@ -106,6 +142,7 @@ export async function getRfqs(input: GetRfqsSchema) {
ilike(rfqsLastView.projectName, s),
ilike(rfqsLastView.rfqTitle, s),
ilike(rfqsLastView.prNumber, s),
+ ilike(rfqsLastView.biddingNumber, s),
].filter(Boolean);
if (searchConditions.length > 0) {
diff --git a/lib/rfq-last/shared/rfq-items-dialog.tsx b/lib/rfq-last/shared/rfq-items-dialog.tsx
index eed3d154..afed9576 100644
--- a/lib/rfq-last/shared/rfq-items-dialog.tsx
+++ b/lib/rfq-last/shared/rfq-items-dialog.tsx
@@ -345,7 +345,7 @@ export function RfqItemsDialog({
<span className="font-mono text-sm font-medium">{item.materialCode || "-"}</span>
{item.acc && (
<span className="text-xs text-muted-foreground font-mono">
- ACC: {item.acc}
+ {item.acc}
</span>
)}
</div>
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx
index 62f14579..58c45aa0 100644
--- a/lib/rfq-last/table/rfq-table-columns.tsx
+++ b/lib/rfq-last/table/rfq-table-columns.tsx
@@ -24,7 +24,7 @@ type NextRouter = ReturnType<typeof useRouter>;
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqsLastView> | null>>;
- rfqCategory?: "general" | "itb" | "rfq";
+ rfqCategory?: "general" | "itb" | "rfq" | "pre_bidding";
router: NextRouter;
}
@@ -756,6 +756,342 @@ export function getRfqColumns({
}
// ═══════════════════════════════════════════════════════════════
+ // 사전견적(입찰) 컬럼 정의
+ // ═══════════════════════════════════════════════════════════════
+ if (rfqCategory === "pre_bidding") {
+ return [
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // 견적 No.
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 No." />,
+ cell: ({ row }) => (
+ <span className="font-mono font-medium">{row.original.rfqCode}</span>
+ ),
+ size: 120,
+ },
+
+ // 입찰 No. (추가)
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰 No." />,
+ cell: ({ row }) => (
+ <span className="font-mono font-medium">{row.original.biddingNumber || "-"}</span>
+ ),
+ size: 120,
+ },
+
+ // 상세 - 수정됨
+ {
+ id: "detail",
+ header: "상세",
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => router.push(`/evcp/rfq-last/${row.original.id}`)}
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ ),
+ size: 60,
+ },
+
+ // 견적상태
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {row.original.status}
+ </Badge>
+ ),
+ size: 120,
+ },
+
+ // 프로젝트 (프로젝트명)
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 (프로젝트명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.projectCode}
+ </span>
+ <span className="max-w-[200px] truncate" title={row.original.projectName || ""}>
+ {row.original.projectName || "-"}
+ </span>
+ </div>
+ ),
+ size: 220,
+ },
+
+ // // 시리즈
+ // {
+ // accessorKey: "series",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />,
+ // cell: ({ row }) => {
+ // const series = row.original.series;
+ // if (!series) return "-";
+ // const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series;
+ // return <Badge variant="outline">{label}</Badge>;
+ // },
+ // size: 100,
+ // },
+
+ // // 선급
+ // {
+ // accessorKey: "classNo",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="선급" />,
+ // cell: ({ row }) => row.original.classNo || "-",
+ // size: 80,
+ // },
+
+ // 견적명
+ {
+ accessorKey: "rfqTitle",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적명" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.rfqTitle || ""}>
+ {row.original.rfqTitle || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+
+ // 자재그룹 (자재그룹명)
+ {
+ accessorKey: "majorItemMaterialDescription",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.majorItemMaterialCategory}
+ </span>
+ <span className="max-w-[150px] truncate" title={row.original.majorItemMaterialDescription || ""}>
+ {row.original.majorItemMaterialDescription || "-"}
+ </span>
+ </div>
+ ),
+ size: 180,
+ },
+
+ // 자재코드
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.itemCode || "-"}</span>
+ ),
+ size: 100,
+ },
+ // 자재명
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재명" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.itemName || "-"}</span>
+ ),
+ size: 100,
+ },
+
+ // 견적 자료
+ {
+ id: "rfqDocument",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 자료" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setRowAction({ row, type: "attachment" })}
+ >
+ <FileText className="h-4 w-4" />
+ </Button>
+ ),
+ size: 80,
+ },
+
+ // 견적품목수
+ {
+ accessorKey: "prItemsCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적품목수" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="font-mono text-sm p-1 h-auto"
+ onClick={() => setRowAction({ row, type: "items" })}
+ >
+ {row.original.prItemsCount || 0}
+ </Button>
+ ),
+ size: 90,
+ },
+
+ // 견적생성일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적생성일" />,
+ cell: ({ row }) => {
+ const date = row.original.createdAt;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
+ },
+ size: 100,
+ },
+
+ // 견적발송일
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적발송일" />,
+ cell: ({ row }) => {
+ const date = row.original.rfqSendDate;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
+ },
+ size: 100,
+ },
+
+ // 견적마감일
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적마감일" />,
+ cell: ({ row }) => {
+ const date = row.original.dueDate;
+ if (!date) return "-";
+
+ const now = new Date();
+ const dueDate = new Date(date);
+ const daysLeft = differenceInDays(dueDate, now);
+
+ // 상태별 스타일과 아이콘 설정
+ let statusIcon;
+ let statusText;
+ let statusClass;
+
+ if (daysLeft < 0) {
+ const daysOverdue = Math.abs(daysLeft);
+ statusIcon = <XCircle className="h-4 w-4" />;
+ statusText = `${daysOverdue}일 지남`;
+ statusClass = "text-red-600";
+ } else if (daysLeft === 0) {
+ statusIcon = <AlertTriangle className="h-4 w-4" />;
+ statusText = "오늘 마감";
+ statusClass = "text-orange-600";
+ } else if (daysLeft <= 3) {
+ statusIcon = <AlertCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-amber-600";
+ } else if (daysLeft <= 7) {
+ statusIcon = <Clock className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-blue-600";
+ } else {
+ statusIcon = <CheckCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-green-600";
+ }
+
+ return (
+ <div className="flex flex-col gap-1">
+ <span className="text-sm text-muted-foreground">
+ {format(dueDate, "yyyy-MM-dd")}
+ </span>
+ <div className={`flex items-center gap-1 ${statusClass}`}>
+ {statusIcon}
+ <span className="text-xs font-medium">{statusText}</span>
+ </div>
+ </div>
+ );
+ },
+ size: 120,
+ },
+
+ // 계약기간 (추가)
+ {
+ id: "contractPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
+ cell: ({ row }) => {
+ const start = row.original.contractStartDate;
+ const end = row.original.contractEndDate;
+
+ if (!start && !end) return "-";
+
+ return (
+ <div className="flex flex-col text-xs">
+ <span>{start ? format(new Date(start), "yyyy-MM-dd") : "미정"}</span>
+ <span className="text-muted-foreground">~</span>
+ <span>{end ? format(new Date(end), "yyyy-MM-dd") : "미정"}</span>
+ </div>
+ );
+ },
+ size: 120,
+ },
+
+ // 구매담당자
+ {
+ accessorKey: "picUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />,
+ cell: ({ row }) => {
+ const name = row.original.picUserName || row.original.picName || "-";
+ const picCode = row.original.picCode || "";
+ return name === "-" ? "-" : `${name}(${picCode})`;
+ },
+ size: 100,
+ },
+
+ // 최종수정일
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
+ cell: ({ row }) => {
+ const date = row.original.updatedAt;
+ return date ? format(new Date(date), "yyyy-MM-dd HH:mm") : "-";
+ },
+ size: 120,
+ },
+
+ // 최종수정자
+ {
+ accessorKey: "updatedByUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
+ cell: ({ row }) => row.original.updatedByUserName || "-",
+ size: 100,
+ },
+
+ // 비고
+ {
+ accessorKey: "remark",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />,
+ cell: ({ row }) => row.original.remark || "-",
+ size: 150,
+ },
+ ];
+ }
+
+ // ═══════════════════════════════════════════════════════════════
// 일반견적 컬럼 정의
// ═══════════════════════════════════════════════════════════════
if (rfqCategory === "general") {
diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx
index 80f1422e..e8dd299d 100644
--- a/lib/rfq-last/table/rfq-table.tsx
+++ b/lib/rfq-last/table/rfq-table.tsx
@@ -29,7 +29,7 @@ import { RfqItemsDialog } from "../shared/rfq-items-dialog";
interface RfqTableProps {
data: Awaited<ReturnType<typeof getRfqs>>;
- rfqCategory?: "general" | "itb" | "rfq";
+ rfqCategory?: "general" | "itb" | "rfq" | "pre_bidding";
className?: string;
}
@@ -274,7 +274,7 @@ export function RfqTable({
{ id: "vendorCount", label: "업체수", type: "number" },
{ id: "dueDate", label: "마감일", type: "date" },
{ id: "rfqSendDate", label: "발송일", type: "date" },
- ...(rfqCategory === "general" ? [
+ ...(rfqCategory === "general" || rfqCategory === "pre_bidding" ? [
{
id: "rfqType",
label: "견적 유형",
@@ -283,10 +283,14 @@ export function RfqTable({
{ label: "단가계약", value: "단가계약" },
{ label: "매각계약", value: "매각계약" },
{ label: "일반계약", value: "일반계약" },
+ ...(rfqCategory === "pre_bidding" ? [{ label: "사전견적(입찰)", value: "사전견적(입찰)" }] : [])
]
},
{ id: "rfqTitle", label: "견적 제목", type: "text" },
] as DataTableAdvancedFilterField<RfqsLastView>[] : []),
+ ...(rfqCategory === "pre_bidding" ? [
+ { id: "biddingNumber", label: "입찰 No.", type: "text" },
+ ] as DataTableAdvancedFilterField<RfqsLastView>[] : []),
...(rfqCategory === "itb" ? [
{ id: "smCode", label: "SM 코드", type: "text" },
] as DataTableAdvancedFilterField<RfqsLastView>[] : []),
@@ -414,6 +418,7 @@ export function RfqTable({
<Badge variant="outline" className="text-sm">
{rfqCategory === "general" ? "일반견적" :
+ rfqCategory === "pre_bidding" ? "사전견적(입찰)" :
rfqCategory === "itb" ? "ITB" : "RFQ"}
</Badge>
diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts
index 6b39d52d..a7f9a405 100644
--- a/lib/rfq-last/validations.ts
+++ b/lib/rfq-last/validations.ts
@@ -35,12 +35,13 @@ import { RfqLastAttachments } from "@/db/schema";
export const RFQ_CATEGORY_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "general", label: "일반견적" },
+ { value: "pre_bidding", label: "사전견적(입찰)" },
{ value: "itb", label: "ITB" },
{ value: "rfq", label: "RFQ" },
];
-
+
// ============= 메인 검색 파라미터 스키마 =============
-
+
export const searchParamsRfqCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
page: parseAsInteger.withDefault(1),
@@ -56,7 +57,7 @@ import { RfqLastAttachments } from "@/db/schema";
search: parseAsString.withDefault(""),
// RFQ 카테고리 (전체/일반견적/ITB/RFQ)
- rfqCategory: parseAsStringEnum(["all", "general", "itb", "rfq"]),
+ rfqCategory: parseAsStringEnum(["all", "general", "pre_bidding", "itb", "rfq"]),
});
// ============= 타입 정의 =============
diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
index 281316eb..f8f11327 100644
--- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
+++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
@@ -741,7 +741,7 @@ export default function QuotationItemsTable({ prItems, decimalPlaces = 2 }: Quot
<span className="font-mono text-sm font-medium">{prItem?.materialCode || "-"}</span>
{prItem?.acc && (
<span className="text-xs text-muted-foreground font-mono">
- ACC: {prItem.acc}
+ {prItem.acc}
</span>
)}
</div>