summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-20 10:25:41 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-20 10:25:41 +0000
commitb75b1cd920efd61923f7b2dbc4c49987b7b0c4e1 (patch)
tree9e4195e697df6df21b5896b0d33acc97d698b4a7
parent4df8d72b79140919c14df103b45bbc8b1afa37c2 (diff)
(최겸) 구매 입찰 수정
-rw-r--r--app/api/files/[...path]/route.ts6
-rw-r--r--app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts2
-rw-r--r--components/bidding/ProjectSelectorBid.tsx183
-rw-r--r--components/bidding/bidding-info-header.tsx193
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx118
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx250
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx74
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx137
-rw-r--r--components/common/selectors/cost-center/cost-center-selector.tsx30
-rw-r--r--components/common/selectors/cost-center/cost-center-single-selector.tsx785
-rw-r--r--components/common/selectors/gl-account/gl-account-selector.tsx30
-rw-r--r--components/common/selectors/gl-account/gl-account-single-selector.tsx745
-rw-r--r--components/common/selectors/wbs-code/wbs-code-selector.tsx30
-rw-r--r--components/common/selectors/wbs-code/wbs-code-single-selector.tsx33
-rw-r--r--db/schema/bidding.ts390
-rw-r--r--i18n/locales/ko/menu.json2
-rw-r--r--lib/bidding/detail/service.ts135
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx4
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx4
-rw-r--r--lib/bidding/detail/table/bidding-invitation-dialog.tsx337
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx4
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx57
-rw-r--r--lib/bidding/list/biddings-table.tsx1
-rw-r--r--lib/bidding/list/biddings-transmission-dialog.tsx34
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx4
-rw-r--r--lib/bidding/pre-quote/service.ts78
-rw-r--r--lib/bidding/selection/actions.ts156
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx4
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx46
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx22
-rw-r--r--lib/bidding/service.ts84
-rw-r--r--lib/bidding/validation.ts2
-rw-r--r--lib/bidding/vendor/partners-bidding-attachments-dialog.tsx9
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx81
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx2
35 files changed, 1803 insertions, 2269 deletions
diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts
index 90c95458..ebc79786 100644
--- a/app/api/files/[...path]/route.ts
+++ b/app/api/files/[...path]/route.ts
@@ -56,7 +56,11 @@ const isAllowedPath = (requestedPath: string): boolean => {
'general-contract-templates',
'purchase-requests',
'projects',
- 'vendor-evaluation/attachments'
+ 'vendor-evaluation/attachments',
+ 'biddings',
+ 'bidding',
+ 'bid_attachment',
+ 'bid'
];
return allowedPaths.some(allowed =>
diff --git a/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts b/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
index ac17766f..4578d4c5 100644
--- a/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
+++ b/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
@@ -190,7 +190,7 @@ export async function POST(
for (const file of files) {
const saveResult = await saveFile({
file,
- directory: `tech-sales-rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}`,
+ directory: `techsales-rfq/${rfqId}/vendor-${vendorId}/comment-${comment.id}`,
originalName: file.name
})
diff --git a/components/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx
new file mode 100644
index 00000000..de9e435e
--- /dev/null
+++ b/components/bidding/ProjectSelectorBid.tsx
@@ -0,0 +1,183 @@
+"use client"
+
+import * as React from "react"
+import { Check, ChevronsUpDown, X } from "lucide-react"
+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"
+
+interface ProjectSelectorProps {
+ selectedProjectId?: number | null;
+ onProjectSelect: (project: Project) => void;
+ placeholder?: string;
+ filterType?: string; // 옵션으로 필터 타입 지정 가능
+}
+
+export function ProjectSelector({
+ selectedProjectId,
+ onProjectSelect,
+ placeholder = "프로젝트 선택...",
+ filterType
+}: ProjectSelectorProps) {
+ const [open, setOpen] = React.useState(false)
+ const [searchTerm, setSearchTerm] = React.useState("")
+ const [projects, setProjects] = React.useState<Project[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ // 모든 프로젝트 데이터 로드 후 plant 타입만 필터링
+ React.useEffect(() => {
+ async function loadAllProjects() {
+ setIsLoading(true);
+ try {
+ const allProjects = await getProjects();
+
+ // filterType이 지정된 경우 해당 타입만 필터링
+ const filteredByType = filterType
+ ? allProjects.filter(p => p.type === filterType)
+ : allProjects;
+
+ console.log(`Loaded ${filteredByType.length} ${filterType || 'all'} projects`);
+ setProjects(filteredByType);
+
+ // 초기 선택된 프로젝트가 있으면 설정
+ if (selectedProjectId) {
+ const selected = filteredByType.find(p => p.id === selectedProjectId);
+ if (selected) {
+ setSelectedProject(selected);
+ }
+ }
+ } catch (error) {
+ console.error("프로젝트 목록 로드 오류:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ loadAllProjects();
+ }, [selectedProjectId, filterType]);
+
+ // 클라이언트 측에서 검색어로 필터링
+ const filteredProjects = React.useMemo(() => {
+ if (!searchTerm.trim()) return projects;
+
+ const lowerSearch = searchTerm.toLowerCase();
+ return projects.filter(
+ project =>
+ project.projectCode.toLowerCase().includes(lowerSearch) ||
+ project.projectName.toLowerCase().includes(lowerSearch)
+ );
+ }, [projects, searchTerm]);
+
+ // 프로젝트 선택 처리
+ const handleSelectProject = (project: Project) => {
+ // 이미 선택된 프로젝트를 다시 선택하면 선택 해제
+ if (selectedProject?.id === project.id) {
+ setSelectedProject(null);
+ onProjectSelect(null as any); // 선택 해제를 위해 null 전달
+ setOpen(false);
+ return;
+ }
+
+ setSelectedProject(project);
+ onProjectSelect(project);
+ setOpen(false);
+ };
+
+ // 프로젝트 선택 해제
+ const handleClearSelection = (e: React.MouseEvent) => {
+ e.stopPropagation(); // Popover가 열리지 않도록 방지
+ setSelectedProject(null);
+ onProjectSelect(null as any); // 선택 해제를 위해 null 전달
+ };
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between"
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ "프로젝트 로딩 중..."
+ ) : selectedProject ? (
+ <div className="flex items-center justify-between w-full">
+ <span>{selectedProject.projectCode}</span>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={handleClearSelection}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
+ </div>
+ </div>
+ ) : (
+ <div className="flex items-center justify-between w-full">
+ <span>{placeholder}</span>
+ <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
+ </div>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="프로젝트 코드/이름 검색..."
+ onValueChange={setSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ onWheel={(e) => {
+ e.stopPropagation();
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY;
+ }}
+ >
+ {isLoading ? (
+ <div className="py-6 text-center text-sm">로딩 중...</div>
+ ) : filteredProjects.length === 0 ? (
+ <CommandEmpty>
+ {searchTerm
+ ? "검색 결과가 없습니다"
+ : `${filterType || '해당 타입의'} 프로젝트가 없습니다`}
+ </CommandEmpty>
+ ) : (
+ <CommandGroup>
+ {filteredProjects.map((project) => (
+ <CommandItem
+ key={project.id}
+ value={`${project.projectCode} ${project.projectName}`}
+ onSelect={() => handleSelectProject(project)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedProject?.id === project.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{project.projectCode}</span>
+ <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span>
+ {selectedProject?.id === project.id && (
+ <span className="ml-auto text-xs text-muted-foreground">(선택됨)</span>
+ )}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+} \ No newline at end of file
diff --git a/components/bidding/bidding-info-header.tsx b/components/bidding/bidding-info-header.tsx
deleted file mode 100644
index 0b2d2b47..00000000
--- a/components/bidding/bidding-info-header.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { Bidding } from '@/db/schema/bidding'
-import { Building2, User, DollarSign, Calendar, FileText } from 'lucide-react'
-import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema/bidding'
-import { formatDate } from '@/lib/utils'
-
-interface BiddingInfoHeaderProps {
- bidding: Bidding | null
-}
-
-export function BiddingInfoHeader({ bidding }: BiddingInfoHeaderProps) {
- if (!bidding) {
- return (
- <div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
- <div className="text-center text-gray-500">입찰 정보를 불러오는 중...</div>
- </div>
- )
- }
-
- return (
- <div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
- {/* 4개 섹션을 Grid로 배치 */}
- <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
- {/* 1. 프로젝트 및 품목 정보 */}
- <div className="w-full space-y-4">
- <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
- <Building2 className="w-4 h-4" />
- <span>기본 정보</span>
- </div>
-
- {bidding.projectName && (
- <div>
- <div className="text-xs text-gray-500 mb-1">프로젝트</div>
- <div className="font-medium text-gray-900 text-sm">{bidding.projectName}</div>
- </div>
- )}
-
- {bidding.itemName && (
- <div>
- <div className="text-xs text-gray-500 mb-1">품목</div>
- <div className="font-medium text-gray-900 text-sm">{bidding.itemName}</div>
- </div>
- )}
-
- {bidding.prNumber && (
- <div>
- <div className="text-xs text-gray-500 mb-1">PR No.</div>
- <div className="font-mono text-sm font-medium text-gray-900">{bidding.prNumber}</div>
- </div>
- )}
-
- {bidding.purchasingOrganization && (
- <div>
- <div className="text-xs text-gray-500 mb-1">구매조직</div>
- <div className="font-medium text-gray-900 text-sm">{bidding.purchasingOrganization}</div>
- </div>
- )}
- </div>
-
- {/* 2. 담당자 및 예산 정보 */}
- <div className="w-full border-l border-gray-100 pl-6 space-y-4">
- <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
- <User className="w-4 h-4" />
- <span>담당자 정보</span>
- </div>
-
- {bidding.bidPicName && (
- <div>
- <div className="text-xs text-gray-500 mb-1">입찰담당자</div>
- <div className="font-medium text-gray-900 text-sm">
- {bidding.bidPicName}
- {bidding.bidPicCode && (
- <span className="ml-2 text-xs text-gray-500">({bidding.bidPicCode})</span>
- )}
- </div>
- </div>
- )}
-
- {bidding.supplyPicName && (
- <div>
- <div className="text-xs text-gray-500 mb-1">조달담당자</div>
- <div className="font-medium text-gray-900 text-sm">
- {bidding.supplyPicName}
- {bidding.supplyPicCode && (
- <span className="ml-2 text-xs text-gray-500">({bidding.supplyPicCode})</span>
- )}
- </div>
- </div>
- )}
-
- {bidding.budget && (
- <div>
- <div className="flex items-center gap-1.5 text-xs text-gray-500 mb-1">
- <DollarSign className="w-3 h-3" />
- <span>예산</span>
- </div>
- <div className="font-semibold text-gray-900 text-sm">
- {new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: bidding.currency || 'KRW',
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(Number(bidding.budget))}
- </div>
- </div>
- )}
- </div>
-
- {/* 3. 계약 정보 */}
- <div className="w-full border-l border-gray-100 pl-6 space-y-4">
- <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
- <FileText className="w-4 h-4" />
- <span>계약 정보</span>
- </div>
-
- <div className="grid grid-cols-2 gap-3">
- <div>
- <div className="text-xs text-gray-500 mb-1">계약구분</div>
- <div className="font-medium text-sm text-gray-900">{contractTypeLabels[bidding.contractType]}</div>
- </div>
-
- <div>
- <div className="text-xs text-gray-500 mb-1">입찰유형</div>
- <div className="font-medium text-sm text-gray-900">{biddingTypeLabels[bidding.biddingType]}</div>
- </div>
-
- <div>
- <div className="text-xs text-gray-500 mb-1">낙찰수</div>
- <div className="font-medium text-sm text-gray-900">
- {bidding.awardCount ? awardCountLabels[bidding.awardCount] : '-'}
- </div>
- </div>
-
- <div>
- <div className="text-xs text-gray-500 mb-1">통화</div>
- <div className="font-mono font-medium text-sm text-gray-900">{bidding.currency}</div>
- </div>
- </div>
-
- {(bidding.contractStartDate || bidding.contractEndDate) && (
- <div>
- <div className="text-xs text-gray-500 mb-1">계약기간</div>
- <div className="font-medium text-sm text-gray-900">
- {bidding.contractStartDate && formatDate(bidding.contractStartDate, 'KR')}
- {bidding.contractStartDate && bidding.contractEndDate && ' ~ '}
- {bidding.contractEndDate && formatDate(bidding.contractEndDate, 'KR')}
- </div>
- </div>
- )}
- </div>
-
- {/* 4. 일정 정보 */}
- <div className="w-full border-l border-gray-100 pl-6 space-y-4">
- <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
- <Calendar className="w-4 h-4" />
- <span>일정 정보</span>
- </div>
-
- {bidding.biddingRegistrationDate && (
- <div>
- <div className="text-xs text-gray-500 mb-1">입찰등록일</div>
- <div className="font-medium text-sm text-gray-900">{formatDate(bidding.biddingRegistrationDate, 'KR')}</div>
- </div>
- )}
-
- {bidding.preQuoteDate && (
- <div>
- <div className="text-xs text-gray-500 mb-1">사전견적일</div>
- <div className="font-medium text-sm text-gray-900">{formatDate(bidding.preQuoteDate, 'KR')}</div>
- </div>
- )}
-
- {bidding.submissionStartDate && bidding.submissionEndDate && (
- <div>
- <div className="text-xs text-gray-500 mb-1">제출기간</div>
- <div className="font-medium text-sm text-gray-900">
- {formatDate(bidding.submissionStartDate, 'KR')}
- <div className="text-xs text-gray-400">~</div>
- {formatDate(bidding.submissionEndDate, 'KR')}
- </div>
- </div>
- )}
-
- {bidding.evaluationDate && (
- <div>
- <div className="text-xs text-gray-500 mb-1">평가일</div>
- <div className="font-medium text-sm text-gray-900">{formatDate(bidding.evaluationDate, 'KR')}</div>
- </div>
- )}
- </div>
- </div>
- </div>
- )
-}
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx
index ad9555db..9b0a6f66 100644
--- a/components/bidding/create/bidding-create-dialog.tsx
+++ b/components/bidding/create/bidding-create-dialog.tsx
@@ -45,6 +45,7 @@ import {
DropzoneDescription,
DropzoneInput,
DropzoneTitle,
+ DropzoneTrigger,
DropzoneUploadIcon,
DropzoneZone,
} from "@/components/ui/dropzone"
@@ -198,28 +199,28 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
React.useEffect(() => {
const loadNoticeTemplate = async () => {
- if (selectedNoticeType) {
- setIsLoadingTemplate(true)
- try {
- const template = await getBiddingNoticeTemplate(selectedNoticeType)
- if (template) {
- setNoticeTemplate(template.content)
- // 폼의 content 필드도 업데이트
- form.setValue('content', template.content)
- } else {
- // 템플릿이 없으면 표준 템플릿 사용
- const defaultTemplate = await getBiddingNoticeTemplate('standard')
- if (defaultTemplate) {
- setNoticeTemplate(defaultTemplate.content)
- form.setValue('content', defaultTemplate.content)
- }
+ setIsLoadingTemplate(true)
+ try {
+ // 처음 로드할 때는 무조건 standard 템플릿 사용
+ const templateType = selectedNoticeType || 'standard'
+ const template = await getBiddingNoticeTemplate(templateType)
+ if (template) {
+ setNoticeTemplate(template.content)
+ // 폼의 content 필드도 업데이트
+ form.setValue('content', template.content)
+ } else {
+ // 템플릿이 없으면 표준 템플릿 사용
+ const defaultTemplate = await getBiddingNoticeTemplate('standard')
+ if (defaultTemplate) {
+ setNoticeTemplate(defaultTemplate.content)
+ form.setValue('content', defaultTemplate.content)
}
- } catch (error) {
- console.error('Failed to load notice template:', error)
- toast.error('입찰공고 템플릿을 불러오는데 실패했습니다.')
- } finally {
- setIsLoadingTemplate(false)
}
+ } catch (error) {
+ console.error('Failed to load notice template:', error)
+ toast.error('입찰공고 템플릿을 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoadingTemplate(false)
}
}
@@ -279,30 +280,13 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
return
}
- // 첨부파일 정보 설정 (실제로는 파일 업로드 후 저장해야 함)
- const attachments = shiAttachmentFiles.map((file, index) => ({
- id: `shi_${Date.now()}_${index}`,
- fileName: file.name,
- fileSize: file.size,
- filePath: '', // 실제 업로드 후 경로
- uploadedAt: new Date().toISOString(),
- type: 'shi' as const,
- }))
-
- const vendorAttachments = vendorAttachmentFiles.map((file, index) => ({
- id: `vendor_${Date.now()}_${index}`,
- fileName: file.name,
- fileSize: file.size,
- filePath: '', // 실제 업로드 후 경로
- uploadedAt: new Date().toISOString(),
- type: 'vendor' as const,
- }))
+ // 첨부파일 정보 설정
// sparePartOptions가 undefined인 경우 빈 문자열로 설정
const biddingData = {
...data,
- attachments,
- vendorAttachments,
+ attachments: shiAttachmentFiles, // 실제 파일 객체들 전달
+ vendorAttachments: vendorAttachmentFiles, // 실제 파일 객체들 전달
biddingConditions: {
...data.biddingConditions,
sparePartOptions: data.biddingConditions.sparePartOptions || '',
@@ -396,7 +380,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
- {/* 1행: 입찰명, 낙찰수, 입찰유형, 계약구분 */}
+ {/* 1행: 입찰명, 낙찰업체 수, 입찰유형, 계약구분 */}
<div className="grid grid-cols-4 gap-4">
<FormField
control={form.control}
@@ -417,11 +401,11 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
name="awardCount"
render={({ field }) => (
<FormItem>
- <FormLabel>낙찰수 <span className="text-red-500">*</span></FormLabel>
+ <FormLabel>낙찰업체 수<span className="text-red-500">*</span></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="낙찰수 선택" />
+ <SelectValue placeholder="낙찰업체 수 선택" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -982,9 +966,15 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
</SelectTrigger>
</FormControl>
<SelectContent>
- <SelectItem value="조선">조선</SelectItem>
- <SelectItem value="해양">해양</SelectItem>
- <SelectItem value="기타">기타</SelectItem>
+ <SelectItem value="Shipbuild & Offshore">Shipbuild & Offshore</SelectItem>
+ <SelectItem value="Wind Energy">Wind Energy</SelectItem>
+ <SelectItem value="Power & Control Sys.">Power & Control Sys.</SelectItem>
+ <SelectItem value="SHI NINGBO Co., LTD">SHI NINGBO Co., LTD</SelectItem>
+ <SelectItem value="RONGCHENG Co.LTD">RONGCHENG Co.LTD</SelectItem>
+ <SelectItem value="RONGCHENGGAYA Co.LTD">RONGCHENGGAYA Co.LTD</SelectItem>
+ <SelectItem value="S&Sys">S&Sys</SelectItem>
+ <SelectItem value="Energy & Infra Solut">Energy & Infra Solut</SelectItem>
+ <SelectItem value="test pur.org222">test pur.org222</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -1138,12 +1128,12 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
)}
/>
- {isLoadingTemplate && (
+ {/* {isLoadingTemplate && (
<div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
입찰공고 템플릿을 불러오는 중...
</div>
- )}
+ )} */}
</CardContent>
</Card>
@@ -1174,14 +1164,16 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
}}
>
{() => (
- <DropzoneZone className="flex justify-center h-32">
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ <DropzoneTrigger asChild>
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
</div>
- </div>
- </DropzoneZone>
+ </DropzoneZone>
+ </DropzoneTrigger>
)}
</Dropzone>
@@ -1246,14 +1238,16 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
}}
>
{() => (
- <DropzoneZone className="flex justify-center h-32">
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ <DropzoneTrigger asChild>
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
</div>
- </div>
- </DropzoneZone>
+ </DropzoneZone>
+ </DropzoneTrigger>
)}
</Dropzone>
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
index f0d56689..c2c668a4 100644
--- a/components/bidding/manage/bidding-basic-info-editor.tsx
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -51,6 +51,7 @@ import {
DropzoneDescription,
DropzoneInput,
DropzoneTitle,
+ DropzoneTrigger,
DropzoneUploadIcon,
DropzoneZone,
} from "@/components/ui/dropzone"
@@ -113,8 +114,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
const [noticeTemplate, setNoticeTemplate] = React.useState('')
// 첨부파일 관련 상태
- const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
- const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
const [existingDocuments, setExistingDocuments] = React.useState<UploadedDocument[]>([])
const [isLoadingDocuments, setIsLoadingDocuments] = React.useState(false)
@@ -371,7 +370,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
const result = await uploadBiddingDocument(
biddingId,
file,
- 'bid_attachment',
+ 'evaluation_doc', // SHI용 문서 타입
file.name,
'SHI용 첨부파일',
'1' // TODO: 실제 사용자 ID 가져오기
@@ -381,17 +380,12 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}
}
await loadExistingDocuments()
- setShiAttachmentFiles([])
} catch (error) {
console.error('Failed to upload SHI files:', error)
toast.error('파일 업로드에 실패했습니다.')
}
}
- const removeShiFile = (index: number) => {
- setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index))
- }
-
// 협력업체용 파일 첨부 핸들러
const handleVendorFileUpload = async (files: File[]) => {
try {
@@ -400,7 +394,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
const result = await uploadBiddingDocument(
biddingId,
file,
- 'bid_attachment',
+ 'company_proposal', // 협력업체용 문서 타입
file.name,
'협력업체용 첨부파일',
'1' // TODO: 실제 사용자 ID 가져오기
@@ -410,17 +404,12 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}
}
await loadExistingDocuments()
- setVendorAttachmentFiles([])
} catch (error) {
console.error('Failed to upload vendor files:', error)
toast.error('파일 업로드에 실패했습니다.')
}
}
- const removeVendorFile = (index: number) => {
- setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index))
- }
-
// 파일 삭제
const handleDeleteDocument = async (documentId: number) => {
if (!confirm('이 파일을 삭제하시겠습니까?')) {
@@ -623,7 +612,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
</div>
)}
- {/* 2행: 예산, 실적가, 내정가, 낙찰수 */}
+ {/* 2행: 예산, 실적가, 내정가, 낙찰업체 수 */}
<div className="grid grid-cols-4 gap-4">
<FormField control={form.control} name="budget" render={({ field }) => (
<FormItem>
@@ -666,11 +655,11 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormField control={form.control} name="awardCount" render={({ field }) => (
<FormItem>
- <FormLabel>낙찰수</FormLabel>
+ <FormLabel>낙찰업체 수</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="낙찰수 선택" />
+ <SelectValue placeholder="낙찰업체 수 선택" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -741,9 +730,15 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
</SelectTrigger>
</FormControl>
<SelectContent>
- <SelectItem value="조선">조선</SelectItem>
- <SelectItem value="해양">해양</SelectItem>
- <SelectItem value="기타">기타</SelectItem>
+ <SelectItem value="Shipbuild & Offshore">Shipbuild & Offshore</SelectItem>
+ <SelectItem value="Wind Energy">Wind Energy</SelectItem>
+ <SelectItem value="Power & Control Sys.">Power & Control Sys.</SelectItem>
+ <SelectItem value="SHI NINGBO Co., LTD">SHI NINGBO Co., LTD</SelectItem>
+ <SelectItem value="RONGCHENG Co.LTD">RONGCHENG Co.LTD</SelectItem>
+ <SelectItem value="RONGCHENGGAYA Co.LTD">RONGCHENGGAYA Co.LTD</SelectItem>
+ <SelectItem value="S&Sys">S&Sys</SelectItem>
+ <SelectItem value="Energy & Infra Solut">Energy & Infra Solut</SelectItem>
+ <SelectItem value="test pur.org222">test pur.org222</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -825,69 +820,8 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormMessage />
</FormItem>
)} />
-
- {/* <FormField control={form.control} name="submissionStartDate" render={({ field }) => (
- <FormItem>
- <FormLabel>입찰서 제출 시작</FormLabel>
- <FormControl>
- <Input type="datetime-local" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )} />
-
- <FormField control={form.control} name="submissionEndDate" render={({ field }) => (
- <FormItem>
- <FormLabel>입찰서 제출 마감</FormLabel>
- <FormControl>
- <Input type="datetime-local" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )} /> */}
</div>
- {/* 5행: 개찰 일시, 사양설명회, PR문서 */}
- {/* <div className="grid grid-cols-3 gap-4">
- <FormField control={form.control} name="evaluationDate" render={({ field }) => (
- <FormItem>
- <FormLabel>개찰 일시</FormLabel>
- <FormControl>
- <Input type="datetime-local" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )} /> */}
-
- {/* <FormField control={form.control} name="hasSpecificationMeeting" render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel className="text-base">사양설명회</FormLabel>
- <div className="text-sm text-muted-foreground">
- 사양설명회가 필요한 경우 체크
- </div>
- </div>
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItem>
- )} /> */}
-
- {/* <FormField control={form.control} name="hasPrDocument" render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel className="text-base">PR 문서</FormLabel>
- <div className="text-sm text-muted-foreground">
- PR 문서가 있는 경우 체크
- </div>
- </div>
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItem>
- )} /> */}
- {/* </div> */}
-
{/* 입찰개요 */}
<div className="pt-2">
<FormField control={form.control} name="description" render={({ field }) => (
@@ -902,7 +836,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
</div>
{/* 비고 */}
- <div className="pt-2">
+ {/* <div className="pt-2">
<FormField control={form.control} name="remarks" render={({ field }) => (
<FormItem>
<FormLabel>비고</FormLabel>
@@ -912,7 +846,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormMessage />
</FormItem>
)} />
- </div>
+ </div> */}
{/* 입찰 조건 */}
<div className="pt-4 border-t">
@@ -1100,24 +1034,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}}
/>
</div>
-
- {/* <div className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel className="text-base">연동제 적용 가능</FormLabel>
- <div className="text-sm text-muted-foreground">
- 연동제 적용 요건 여부
- </div>
- </div>
- <Switch
- checked={biddingConditions.isPriceAdjustmentApplicable}
- onCheckedChange={(checked) => {
- setBiddingConditions(prev => ({
- ...prev,
- isPriceAdjustmentApplicable: checked
- }))
- }}
- />
- </div> */}
</div>
{/* 5행: 스페어파트 옵션 */}
@@ -1159,12 +1075,12 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
</FormItem>
)} />
- {isLoadingTemplate && (
+ {/* {isLoadingTemplate && (
<div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
입찰공고 템플릿을 불러오는 중...
</div>
- )}
+ )} */}
</div>
{/* 액션 버튼 */}
@@ -1195,9 +1111,10 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<CardContent className="space-y-4">
<Dropzone
maxSize={6e8} // 600MB
- onDropAccepted={(files) => {
+ onDropAccepted={async (files) => {
const newFiles = Array.from(files)
- setShiAttachmentFiles(prev => [...prev, ...newFiles])
+ // 파일을 즉시 업로드
+ await handleShiFileUpload(newFiles)
}}
onDropRejected={() => {
toast({
@@ -1208,60 +1125,19 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}}
>
{() => (
- <DropzoneZone className="flex justify-center h-32">
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ <DropzoneTrigger asChild>
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
</div>
- </div>
- </DropzoneZone>
+ </DropzoneZone>
+ </DropzoneTrigger>
)}
</Dropzone>
- {shiAttachmentFiles.length > 0 && (
- <div className="space-y-2">
- <h4 className="text-sm font-medium">업로드 예정 파일</h4>
- <div className="space-y-2">
- {shiAttachmentFiles.map((file, index) => (
- <div
- key={index}
- className="flex items-center justify-between p-3 bg-muted rounded-lg"
- >
- <div className="flex items-center gap-3">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <div>
- <p className="text-sm font-medium">{file.name}</p>
- <p className="text-xs text-muted-foreground">
- {(file.size / 1024 / 1024).toFixed(2)} MB
- </p>
- </div>
- </div>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => {
- handleShiFileUpload([file])
- }}
- >
- 업로드
- </Button>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeShiFile(index)}
- >
- 제거
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
{/* 기존 문서 목록 */}
{isLoadingDocuments ? (
@@ -1329,9 +1205,10 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<CardContent className="space-y-4">
<Dropzone
maxSize={6e8} // 600MB
- onDropAccepted={(files) => {
+ onDropAccepted={async (files) => {
const newFiles = Array.from(files)
- setVendorAttachmentFiles(prev => [...prev, ...newFiles])
+ // 파일을 즉시 업로드
+ await handleVendorFileUpload(newFiles)
}}
onDropRejected={() => {
toast({
@@ -1342,60 +1219,19 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}}
>
{() => (
- <DropzoneZone className="flex justify-center h-32">
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ <DropzoneTrigger asChild>
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
</div>
- </div>
- </DropzoneZone>
+ </DropzoneZone>
+ </DropzoneTrigger>
)}
</Dropzone>
- {vendorAttachmentFiles.length > 0 && (
- <div className="space-y-2">
- <h4 className="text-sm font-medium">업로드 예정 파일</h4>
- <div className="space-y-2">
- {vendorAttachmentFiles.map((file, index) => (
- <div
- key={index}
- className="flex items-center justify-between p-3 bg-muted rounded-lg"
- >
- <div className="flex items-center gap-3">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <div>
- <p className="text-sm font-medium">{file.name}</p>
- <p className="text-xs text-muted-foreground">
- {(file.size / 1024 / 1024).toFixed(2)} MB
- </p>
- </div>
- </div>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => {
- handleVendorFileUpload([file])
- }}
- >
- 업로드
- </Button>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeVendorFile(index)}
- >
- 제거
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
{/* 기존 문서 목록 */}
{existingDocuments.length > 0 && (
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
index 38113dfa..f0287ae4 100644
--- a/components/bidding/manage/bidding-items-editor.tsx
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -18,7 +18,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
-import { ProjectSelector } from '@/components/ProjectSelector'
+import { ProjectSelector } from '@/components/bidding/ProjectSelectorBid'
import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
@@ -255,12 +255,12 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
costCenterName: item.costCenterName || null,
glAccountCode: item.glAccountCode || null,
glAccountName: item.glAccountName || null,
- targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null,
+ targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice.replace(/,/g, '')) : null,
targetAmount: targetAmount ? parseFloat(targetAmount) : null,
targetCurrency: item.targetCurrency || 'KRW',
- budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null,
+ budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount.replace(/,/g, '')) : null,
budgetCurrency: item.budgetCurrency || 'KRW',
- actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null,
+ actualAmount: item.actualAmount ? parseFloat(item.actualAmount.replace(/,/g, '')) : null,
actualCurrency: item.actualCurrency || 'KRW',
requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null,
currency: item.currency || 'KRW',
@@ -291,12 +291,12 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
costCenterName: item.costCenterName ?? null,
glAccountCode: item.glAccountCode ?? null,
glAccountName: item.glAccountName ?? null,
- targetUnitPrice: item.targetUnitPrice ?? null,
- targetAmount: targetAmount ?? null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.replace(/,/g, '') : null,
+ targetAmount: targetAmount,
targetCurrency: item.targetCurrency || 'KRW',
- budgetAmount: item.budgetAmount ?? null,
+ budgetAmount: item.budgetAmount ? item.budgetAmount.replace(/,/g, '') : null,
budgetCurrency: item.budgetCurrency || 'KRW',
- actualAmount: item.actualAmount ?? null,
+ actualAmount: item.actualAmount ? item.actualAmount.replace(/,/g, '') : null,
actualCurrency: item.actualCurrency || 'KRW',
requestedDeliveryDate: item.requestedDeliveryDate ?? null,
currency: item.currency || 'KRW',
@@ -519,8 +519,20 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
setQuantityWeightMode(mode)
}
- const calculateTargetAmount = (item: PRItemInfo) => {
- const unitPrice = parseFloat(item.targetUnitPrice || '0') || 0
+ // 천단위 콤마 포맷팅 헬퍼 함수들
+ const formatNumberWithCommas = (value: string | number | null | undefined): string => {
+ if (!value) return ''
+ const numValue = typeof value === 'number' ? value : parseFloat(value.toString().replace(/,/g, ''))
+ if (isNaN(numValue)) return ''
+ return numValue.toLocaleString()
+ }
+
+ const parseNumberFromCommas = (value: string): string => {
+ return value.replace(/,/g, '')
+ }
+
+ const calculateTargetAmount = (item: PRItemInfo): string => {
+ const unitPrice = parseFloat(item.targetUnitPrice?.replace(/,/g, '') || '0') || 0
const purchaseUnit = parseFloat(item.purchaseUnit || '1') || 1
let amount = 0
@@ -560,6 +572,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">PR 번호</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
@@ -580,7 +593,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일 <span className="text-red-500">*</span></th>
<th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
액션
</th>
@@ -621,6 +634,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
/>
</td>
<td className="border-r px-3 py-2">
+ <Input
+ placeholder="PR 번호"
+ value={item.prNumber || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
{biddingType !== 'equipment' ? (
<ProcurementItemSelectorDialogSingle
triggerLabel={item.materialGroupNumber || "품목 선택"}
@@ -784,23 +805,19 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</td>
<td className="border-r px-3 py-2">
<Input
- type="number"
- min="0"
- step="1"
+ type="text"
placeholder="내정단가"
- value={item.targetUnitPrice || ''}
- onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
+ value={formatNumberWithCommas(item.targetUnitPrice)}
+ onChange={(e) => updatePRItem(item.id, { targetUnitPrice: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
/>
</td>
<td className="border-r px-3 py-2">
<Input
- type="number"
- min="0"
- step="1"
+ type="text"
placeholder="내정금액"
readOnly
- value={item.targetAmount || ''}
+ value={formatNumberWithCommas(item.targetAmount)}
className="h-8 text-xs bg-muted/50"
/>
</td>
@@ -822,12 +839,10 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</td>
<td className="border-r px-3 py-2">
<Input
- type="number"
- min="0"
- step="1"
+ type="text"
placeholder="예산금액"
- value={item.budgetAmount || ''}
- onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
+ value={formatNumberWithCommas(item.budgetAmount)}
+ onChange={(e) => updatePRItem(item.id, { budgetAmount: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
/>
</td>
@@ -849,12 +864,10 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</td>
<td className="border-r px-3 py-2">
<Input
- type="number"
- min="0"
- step="1"
+ type="text"
placeholder="실적금액"
- value={item.actualAmount || ''}
- onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
+ value={formatNumberWithCommas(item.actualAmount)}
+ onChange={(e) => updatePRItem(item.id, { actualAmount: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
/>
</td>
@@ -1030,6 +1043,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
value={item.requestedDeliveryDate || ''}
onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
className="h-8 text-xs"
+ required
/>
</td>
<td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index f3260f04..b5f4aaf0 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -16,7 +16,7 @@ import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
import { requestBiddingInvitationWithApproval } from '@/lib/bidding/approval-actions'
import { prepareBiddingApprovalData } from '@/lib/bidding/approval-actions'
import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog'
-import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service'
+import { sendBiddingBasicContracts, getSelectedVendorsForBidding, getPrItemsForBidding } from '@/lib/bidding/pre-quote/service'
import { registerBidding } from '@/lib/bidding/detail/service'
import { useToast } from '@/hooks/use-toast'
import { format } from 'date-fns'
@@ -61,6 +61,13 @@ interface VendorContractRequirement {
agreementYn?: boolean
biddingCompanyId: number
biddingId: number
+ isPreQuoteSelected?: boolean
+ contacts?: Array<{
+ id: number
+ contactName: string
+ contactEmail: string
+ contactNumber?: string | null
+ }>
}
interface VendorWithContactInfo extends VendorContractRequirement {
@@ -216,6 +223,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
agreementYn: vendor.agreementYn,
biddingCompanyId: vendor.biddingCompanyId,
biddingId: vendor.biddingId,
+ isPreQuoteSelected: vendor.isPreQuoteSelected,
+ contacts: vendor.contacts || [],
}))
} else {
console.error('선정된 업체 조회 실패:', 'error' in result ? result.error : '알 수 없는 오류')
@@ -237,8 +246,64 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
}, [isBiddingInvitationDialogOpen, getSelectedVendors])
// 입찰공고 버튼 클릭 핸들러 - 입찰 초대 다이얼로그 열기
- const handleBiddingInvitationClick = () => {
- setIsBiddingInvitationDialogOpen(true)
+ const handleBiddingInvitationClick = async () => {
+ try {
+ // 1. 입찰서 제출기간 검증
+ if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ toast({
+ title: '입찰서 제출기간 미설정',
+ description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 2. 선정된 업체들 조회 및 검증
+ const vendors = await getSelectedVendors()
+ if (vendors.length === 0) {
+ toast({
+ title: '선정된 업체 없음',
+ description: '입찰에 참여할 업체가 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 3. 업체 담당자 검증
+ const vendorsWithoutContacts = vendors.filter(vendor =>
+ !vendor.contacts || vendor.contacts.length === 0
+ )
+ if (vendorsWithoutContacts.length > 0) {
+ toast({
+ title: '업체 담당자 정보 부족',
+ description: `${vendorsWithoutContacts.length}개 업체의 담당자가 없습니다. 각 업체에 담당자를 추가해주세요.`,
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 4. 입찰 품목 검증
+ const prItems = await getPrItemsForBidding(biddingId)
+ if (!prItems || prItems.length === 0) {
+ toast({
+ title: '입찰 품목 없음',
+ description: '입찰에 포함할 품목이 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 모든 검증 통과 시 다이얼로그 열기
+ setSelectedVendors(vendors)
+ setIsBiddingInvitationDialogOpen(true)
+ } catch (error) {
+ console.error('입찰공고 검증 중 오류 발생:', error)
+ toast({
+ title: '오류',
+ description: '입찰공고 검증 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
}
// 결재 상신 핸들러 - 결재 완료 시 실제 입찰 등록 실행
@@ -331,7 +396,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
// 입찰 초대 발송 핸들러 - 결재 준비 및 결재 다이얼로그 열기
const handleBiddingInvitationSend = async (data: BiddingInvitationData) => {
try {
- if (!session?.user?.id || !session.user.epId) {
+ if (!session?.user?.id) {
toast({
title: '오류',
description: '사용자 정보가 없습니다.',
@@ -384,7 +449,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
setIsSubmitting(true)
try {
const userId = session?.user?.id?.toString() || '1'
-
+
+ // 입찰서 제출기간 필수 검증
+ if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ toast({
+ title: '입찰서 제출기간 미설정',
+ description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+
// 사양설명회 정보 유효성 검사
if (schedule.hasSpecificationMeeting) {
if (!specMeetingInfo.meetingDate || !specMeetingInfo.location || !specMeetingInfo.contactPerson) {
@@ -430,8 +506,45 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
}
const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => {
+ // 마감일시 검증 - 현재일 이전 설정 불가
+ if (field === 'submissionEndDate' && typeof value === 'string' && value) {
+ const selectedDate = new Date(value)
+ const now = new Date()
+ now.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정하여 날짜만 비교
+
+ if (selectedDate < now) {
+ toast({
+ title: '마감일시 오류',
+ description: '마감일시는 현재일 이전으로 설정할 수 없습니다.',
+ variant: 'destructive',
+ })
+ return // 변경을 적용하지 않음
+ }
+ }
+
+ // 긴급여부 미선택 시 당일 제출시작 불가
+ if (field === 'submissionStartDate' && typeof value === 'string' && value) {
+ const selectedDate = new Date(value)
+ const today = new Date()
+ today.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정
+ selectedDate.setHours(0, 0, 0, 0)
+
+ // 현재 긴급 여부 확인 (field가 'isUrgent'인 경우 value 사용, 아니면 기존 schedule 값)
+ const isUrgent = field === 'isUrgent' ? (value as boolean) : schedule.isUrgent || false
+
+ // 긴급이 아닌 경우 당일 시작 불가
+ if (!isUrgent && selectedDate.getTime() === today.getTime()) {
+ toast({
+ title: '제출 시작일시 오류',
+ description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.',
+ variant: 'destructive',
+ })
+ return // 변경을 적용하지 않음
+ }
+ }
+
setSchedule(prev => ({ ...prev, [field]: value }))
-
+
// 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
if (field === 'hasSpecificationMeeting' && value === false) {
setSpecMeetingInfo({
@@ -480,22 +593,30 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
- <Label htmlFor="submission-start">제출 시작일시</Label>
+ <Label htmlFor="submission-start">제출 시작일시 <span className="text-red-500">*</span></Label>
<Input
id="submission-start"
type="datetime-local"
value={schedule.submissionStartDate}
onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)}
+ className={!schedule.submissionStartDate ? 'border-red-200' : ''}
/>
+ {!schedule.submissionStartDate && (
+ <p className="text-sm text-red-500">제출 시작일시는 필수입니다</p>
+ )}
</div>
<div className="space-y-2">
- <Label htmlFor="submission-end">제출 마감일시</Label>
+ <Label htmlFor="submission-end">제출 마감일시 <span className="text-red-500">*</span></Label>
<Input
id="submission-end"
type="datetime-local"
value={schedule.submissionEndDate}
onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)}
+ className={!schedule.submissionEndDate ? 'border-red-200' : ''}
/>
+ {!schedule.submissionEndDate && (
+ <p className="text-sm text-red-500">제출 마감일시는 필수입니다</p>
+ )}
</div>
</div>
</div>
diff --git a/components/common/selectors/cost-center/cost-center-selector.tsx b/components/common/selectors/cost-center/cost-center-selector.tsx
index 32c37973..3aad5787 100644
--- a/components/common/selectors/cost-center/cost-center-selector.tsx
+++ b/components/common/selectors/cost-center/cost-center-selector.tsx
@@ -16,7 +16,7 @@ import { useState, useCallback, useMemo, useTransition } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
-import { Search, Check } from 'lucide-react'
+import { Search, Check, X } from 'lucide-react'
import {
ColumnDef,
flexRender,
@@ -85,8 +85,20 @@ export function CostCenterSelector({
// Cost Center 선택 핸들러
const handleCodeSelect = useCallback(async (code: CostCenter) => {
+ // 이미 선택된 코드를 다시 선택하면 선택 해제
+ if (selectedCode && selectedCode.KOSTL === code.KOSTL) {
+ onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달
+ setOpen(false)
+ return
+ }
+
onCodeSelect(code)
setOpen(false)
+ }, [onCodeSelect, selectedCode])
+
+ // 선택 해제 핸들러
+ const handleClearSelection = useCallback(() => {
+ onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달
}, [onCodeSelect])
// 테이블 컬럼 정의
@@ -219,6 +231,17 @@ export function CostCenterSelector({
<div className="flex items-center gap-2 w-full">
<span className="font-mono text-sm">[{selectedCode.KOSTL}]</span>
<span className="truncate flex-1 text-left">{selectedCode.KTEXT}</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground ml-1"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleClearSelection()
+ }}
+ >
+ <X className="h-3 w-3" />
+ </Button>
</div>
) : (
<span className="text-muted-foreground">{placeholder}</span>
@@ -284,6 +307,11 @@ export function CostCenterSelector({
)}
</TableCell>
))}
+ {selectedCode && selectedCode.KOSTL === row.original.KOSTL && (
+ <TableCell className="text-right">
+ <span className="text-xs text-muted-foreground">(선택됨)</span>
+ </TableCell>
+ )}
</TableRow>
))
) : (
diff --git a/components/common/selectors/cost-center/cost-center-single-selector.tsx b/components/common/selectors/cost-center/cost-center-single-selector.tsx
index 94d9a730..e09f782b 100644
--- a/components/common/selectors/cost-center/cost-center-single-selector.tsx
+++ b/components/common/selectors/cost-center/cost-center-single-selector.tsx
@@ -1,378 +1,407 @@
-'use client'
-
-/**
- * Cost Center 단일 선택 다이얼로그
- *
- * @description
- * - Cost Center를 하나만 선택할 수 있는 다이얼로그
- * - 트리거 버튼과 다이얼로그가 분리된 구조
- * - 외부에서 open 상태를 제어 가능
- */
-
-import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
-import { Button } from '@/components/ui/button'
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Search, Check, X } from 'lucide-react'
-import {
- ColumnDef,
- flexRender,
- getCoreRowModel,
- getFilteredRowModel,
- getPaginationRowModel,
- getSortedRowModel,
- useReactTable,
- SortingState,
- ColumnFiltersState,
- VisibilityState,
- RowSelectionState,
-} from '@tanstack/react-table'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
-import {
- getCostCenters,
- CostCenter
-} from './cost-center-service'
-import { toast } from 'sonner'
-
-export interface CostCenterSingleSelectorProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedCode?: CostCenter
- onCodeSelect: (code: CostCenter) => void
- onConfirm?: (code: CostCenter | undefined) => void
- onCancel?: () => void
- title?: string
- description?: string
- showConfirmButtons?: boolean
-}
-
-export function CostCenterSingleSelector({
- open,
- onOpenChange,
- selectedCode,
- onCodeSelect,
- onConfirm,
- onCancel,
- title = "코스트센터 선택",
- description = "코스트센터를 선택하세요",
- showConfirmButtons = false
-}: CostCenterSingleSelectorProps) {
- const [codes, setCodes] = useState<CostCenter[]>([])
- const [sorting, setSorting] = useState<SortingState>([])
- const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
- const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
- const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
- const [globalFilter, setGlobalFilter] = useState('')
- const [isPending, startTransition] = useTransition()
- const [tempSelectedCode, setTempSelectedCode] = useState<CostCenter | undefined>(selectedCode)
-
- // 날짜 포맷 함수 (YYYYMMDD -> YYYY-MM-DD)
- const formatDate = (dateStr: string) => {
- if (!dateStr || dateStr.length !== 8) return dateStr
- return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`
- }
-
- // Cost Center 선택 핸들러
- const handleCodeSelect = useCallback((code: CostCenter) => {
- if (showConfirmButtons) {
- setTempSelectedCode(code)
- } else {
- onCodeSelect(code)
- onOpenChange(false)
- }
- }, [onCodeSelect, onOpenChange, showConfirmButtons])
-
- // 확인 버튼 핸들러
- const handleConfirm = useCallback(() => {
- if (tempSelectedCode) {
- onCodeSelect(tempSelectedCode)
- }
- onConfirm?.(tempSelectedCode)
- onOpenChange(false)
- }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
-
- // 취소 버튼 핸들러
- const handleCancel = useCallback(() => {
- setTempSelectedCode(selectedCode)
- onCancel?.()
- onOpenChange(false)
- }, [selectedCode, onCancel, onOpenChange])
-
- // 테이블 컬럼 정의
- const columns: ColumnDef<CostCenter>[] = useMemo(() => [
- {
- accessorKey: 'KOSTL',
- header: '코스트센터',
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.getValue('KOSTL')}</div>
- ),
- },
- {
- accessorKey: 'KTEXT',
- header: '단축명',
- cell: ({ row }) => (
- <div>{row.getValue('KTEXT')}</div>
- ),
- },
- {
- accessorKey: 'LTEXT',
- header: '설명',
- cell: ({ row }) => (
- <div>{row.getValue('LTEXT')}</div>
- ),
- },
- {
- accessorKey: 'DATAB',
- header: '시작일',
- cell: ({ row }) => (
- <div className="text-sm">{formatDate(row.getValue('DATAB'))}</div>
- ),
- },
- {
- accessorKey: 'DATBI',
- header: '종료일',
- cell: ({ row }) => (
- <div className="text-sm">{formatDate(row.getValue('DATBI'))}</div>
- ),
- },
- {
- id: 'actions',
- header: '선택',
- cell: ({ row }) => {
- const isSelected = showConfirmButtons
- ? tempSelectedCode?.KOSTL === row.original.KOSTL
- : selectedCode?.KOSTL === row.original.KOSTL
-
- return (
- <Button
- variant={isSelected ? "default" : "ghost"}
- size="sm"
- onClick={(e) => {
- e.stopPropagation()
- handleCodeSelect(row.original)
- }}
- >
- <Check className="h-4 w-4" />
- </Button>
- )
- },
- },
- ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
-
- // Cost Center 테이블 설정
- const table = useReactTable({
- data: codes,
- columns,
- onSortingChange: setSorting,
- onColumnFiltersChange: setColumnFilters,
- onColumnVisibilityChange: setColumnVisibility,
- onRowSelectionChange: setRowSelection,
- onGlobalFilterChange: setGlobalFilter,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- getSortedRowModel: getSortedRowModel(),
- getFilteredRowModel: getFilteredRowModel(),
- state: {
- sorting,
- columnFilters,
- columnVisibility,
- rowSelection,
- globalFilter,
- },
- })
-
- // 서버에서 Cost Center 전체 목록 로드 (한 번만)
- const loadCodes = useCallback(async () => {
- startTransition(async () => {
- try {
- const result = await getCostCenters()
-
- if (result.success) {
- setCodes(result.data)
-
- // 폴백 데이터를 사용하는 경우 알림
- if (result.isUsingFallback) {
- toast.info('Oracle 연결 실패', {
- description: '테스트 데이터를 사용합니다.',
- duration: 4000,
- })
- }
- } else {
- toast.error(result.error || '코스트센터를 불러오는데 실패했습니다.')
- setCodes([])
- }
- } catch (error) {
- console.error('코스트센터 목록 로드 실패:', error)
- toast.error('코스트센터를 불러오는 중 오류가 발생했습니다.')
- setCodes([])
- }
- })
- }, [])
-
- // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
- useEffect(() => {
- if (open) {
- setTempSelectedCode(selectedCode)
- if (codes.length === 0) {
- console.log('🚀 [CostCenterSingleSelector] 다이얼로그 열림 - loadCodes 호출')
- loadCodes()
- } else {
- console.log('📦 [CostCenterSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
- }
- }
- }, [open, selectedCode, loadCodes, codes.length])
-
- // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
- const handleSearchChange = useCallback((value: string) => {
- setGlobalFilter(value)
- }, [])
-
- const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-5xl max-h-[80vh]">
- <DialogHeader>
- <DialogTitle>{title}</DialogTitle>
- <div className="text-sm text-muted-foreground">
- {description}
- </div>
- </DialogHeader>
-
- <div className="space-y-4">
- {/* 현재 선택된 코스트센터 표시 */}
- {currentSelectedCode && (
- <div className="p-3 bg-muted rounded-md">
- <div className="text-sm font-medium">선택된 코스트센터:</div>
- <div className="flex items-center gap-2 mt-1">
- <span className="font-mono text-sm">[{currentSelectedCode.KOSTL}]</span>
- <span>{currentSelectedCode.KTEXT}</span>
- <span className="text-muted-foreground">- {currentSelectedCode.LTEXT}</span>
- </div>
- </div>
- )}
-
- <div className="flex items-center space-x-2">
- <Search className="h-4 w-4" />
- <Input
- placeholder="코스트센터 코드, 단축명, 설명으로 검색..."
- value={globalFilter}
- onChange={(e) => handleSearchChange(e.target.value)}
- className="flex-1"
- />
- </div>
-
- {isPending ? (
- <div className="flex justify-center py-8">
- <div className="text-sm text-muted-foreground">코스트센터를 불러오는 중...</div>
- </div>
- ) : (
- <div className="border rounded-md">
- <Table>
- <TableHeader>
- {table.getHeaderGroups().map((headerGroup) => (
- <TableRow key={headerGroup.id}>
- {headerGroup.headers.map((header) => (
- <TableHead key={header.id}>
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
- </TableHead>
- ))}
- </TableRow>
- ))}
- </TableHeader>
- <TableBody>
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map((row) => {
- const isRowSelected = currentSelectedCode?.KOSTL === row.original.KOSTL
- return (
- <TableRow
- key={row.id}
- data-state={isRowSelected && "selected"}
- className={`cursor-pointer hover:bg-muted/50 ${
- isRowSelected ? 'bg-muted' : ''
- }`}
- onClick={() => handleCodeSelect(row.original)}
- >
- {row.getVisibleCells().map((cell) => (
- <TableCell key={cell.id}>
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
- </TableCell>
- ))}
- </TableRow>
- )
- })
- ) : (
- <TableRow>
- <TableCell
- colSpan={columns.length}
- className="h-24 text-center"
- >
- 검색 결과가 없습니다.
- </TableCell>
- </TableRow>
- )}
- </TableBody>
- </Table>
- </div>
- )}
-
- <div className="flex items-center justify-between">
- <div className="text-sm text-muted-foreground">
- 총 {table.getFilteredRowModel().rows.length}개 코스트센터
- </div>
- <div className="flex items-center space-x-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => table.previousPage()}
- disabled={!table.getCanPreviousPage()}
- >
- 이전
- </Button>
- <div className="text-sm">
- {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={() => table.nextPage()}
- disabled={!table.getCanNextPage()}
- >
- 다음
- </Button>
- </div>
- </div>
- </div>
-
- {showConfirmButtons && (
- <DialogFooter>
- <Button variant="outline" onClick={handleCancel}>
- <X className="h-4 w-4 mr-2" />
- 취소
- </Button>
- <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
- <Check className="h-4 w-4 mr-2" />
- 확인
- </Button>
- </DialogFooter>
- )}
- </DialogContent>
- </Dialog>
- )
-}
-
+'use client'
+
+/**
+ * Cost Center 단일 선택 다이얼로그
+ *
+ * @description
+ * - Cost Center를 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 open 상태를 제어 가능
+ */
+
+import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check, X } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getCostCenters,
+ CostCenter
+} from './cost-center-service'
+import { toast } from 'sonner'
+
+export interface CostCenterSingleSelectorProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCode?: CostCenter
+ onCodeSelect: (code: CostCenter) => void
+ onConfirm?: (code: CostCenter | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+}
+
+export function CostCenterSingleSelector({
+ open,
+ onOpenChange,
+ selectedCode,
+ onCodeSelect,
+ onConfirm,
+ onCancel,
+ title = "코스트센터 선택",
+ description = "코스트센터를 선택하세요",
+ showConfirmButtons = false
+}: CostCenterSingleSelectorProps) {
+ const [codes, setCodes] = useState<CostCenter[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+ const [tempSelectedCode, setTempSelectedCode] = useState<CostCenter | undefined>(selectedCode)
+
+ // 날짜 포맷 함수 (YYYYMMDD -> YYYY-MM-DD)
+ const formatDate = (dateStr: string) => {
+ if (!dateStr || dateStr.length !== 8) return dateStr
+ return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`
+ }
+
+ // Cost Center 선택 핸들러
+ const handleCodeSelect = useCallback((code: CostCenter) => {
+ // 이미 선택된 코드를 다시 선택하면 선택 해제
+ const currentSelected = showConfirmButtons ? tempSelectedCode : selectedCode
+ if (currentSelected && currentSelected.KOSTL === code.KOSTL) {
+ if (showConfirmButtons) {
+ setTempSelectedCode(undefined)
+ } else {
+ onCodeSelect(undefined as any)
+ onOpenChange(false)
+ }
+ return
+ }
+
+ if (showConfirmButtons) {
+ setTempSelectedCode(code)
+ } else {
+ onCodeSelect(code)
+ onOpenChange(false)
+ }
+ }, [onCodeSelect, onOpenChange, showConfirmButtons, selectedCode, tempSelectedCode])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCode) {
+ onCodeSelect(tempSelectedCode)
+ }
+ onConfirm?.(tempSelectedCode)
+ onOpenChange(false)
+ }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCode(selectedCode)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCode, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<CostCenter>[] = useMemo(() => [
+ {
+ accessorKey: 'KOSTL',
+ header: '코스트센터',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('KOSTL')}</div>
+ ),
+ },
+ {
+ accessorKey: 'KTEXT',
+ header: '단축명',
+ cell: ({ row }) => (
+ <div>{row.getValue('KTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'LTEXT',
+ header: '설명',
+ cell: ({ row }) => (
+ <div>{row.getValue('LTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATAB',
+ header: '시작일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATAB'))}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATBI',
+ header: '종료일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATBI'))}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCode?.KOSTL === row.original.KOSTL
+ : selectedCode?.KOSTL === row.original.KOSTL
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
+
+ // Cost Center 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 Cost Center 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getCostCenters()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || '코스트센터를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('코스트센터 목록 로드 실패:', error)
+ toast.error('코스트센터를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCode(selectedCode)
+ if (codes.length === 0) {
+ console.log('🚀 [CostCenterSingleSelector] 다이얼로그 열림 - loadCodes 호출')
+ loadCodes()
+ } else {
+ console.log('📦 [CostCenterSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
+ }
+ }
+ }, [open, selectedCode, loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 코스트센터 표시 */}
+ {currentSelectedCode && (
+ <div className="p-3 bg-muted rounded-md">
+ <div className="text-sm font-medium flex items-center justify-between">
+ <span>선택된 코스트센터:</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={() => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(undefined)
+ } else {
+ onCodeSelect(undefined as any)
+ onOpenChange(false)
+ }
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono text-sm">[{currentSelectedCode.KOSTL}]</span>
+ <span>{currentSelectedCode.KTEXT}</span>
+ <span className="text-muted-foreground">- {currentSelectedCode.LTEXT}</span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="코스트센터 코드, 단축명, 설명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">코스트센터를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isRowSelected = currentSelectedCode?.KOSTL === row.original.KOSTL
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 코스트센터
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/gl-account/gl-account-selector.tsx b/components/common/selectors/gl-account/gl-account-selector.tsx
index 81a33944..7e47a072 100644
--- a/components/common/selectors/gl-account/gl-account-selector.tsx
+++ b/components/common/selectors/gl-account/gl-account-selector.tsx
@@ -14,7 +14,7 @@ import { useState, useCallback, useMemo, useTransition } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
-import { Search, Check } from 'lucide-react'
+import { Search, Check, X } from 'lucide-react'
import {
ColumnDef,
flexRender,
@@ -75,8 +75,20 @@ export function GlAccountSelector({
// GL 계정 선택 핸들러
const handleCodeSelect = useCallback(async (code: GlAccount) => {
+ // 이미 선택된 계정을 다시 선택하면 선택 해제
+ if (selectedCode && selectedCode.SAKNR === code.SAKNR && selectedCode.FIPEX === code.FIPEX) {
+ onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달
+ setOpen(false)
+ return
+ }
+
onCodeSelect(code)
setOpen(false)
+ }, [onCodeSelect, selectedCode])
+
+ // 선택 해제 핸들러
+ const handleClearSelection = useCallback(() => {
+ onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달
}, [onCodeSelect])
// 테이블 컬럼 정의
@@ -196,6 +208,17 @@ export function GlAccountSelector({
<span className="font-mono text-sm">[{selectedCode.SAKNR}]</span>
<span className="font-mono text-sm">{selectedCode.FIPEX}</span>
<span className="truncate flex-1 text-left">{selectedCode.TEXT1}</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground ml-1"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleClearSelection()
+ }}
+ >
+ <X className="h-3 w-3" />
+ </Button>
</div>
) : (
<span className="text-muted-foreground">{placeholder}</span>
@@ -261,6 +284,11 @@ export function GlAccountSelector({
)}
</TableCell>
))}
+ {selectedCode && selectedCode.SAKNR === row.original.SAKNR && selectedCode.FIPEX === row.original.FIPEX && (
+ <TableCell className="text-right">
+ <span className="text-xs text-muted-foreground">(선택됨)</span>
+ </TableCell>
+ )}
</TableRow>
))
) : (
diff --git a/components/common/selectors/gl-account/gl-account-single-selector.tsx b/components/common/selectors/gl-account/gl-account-single-selector.tsx
index 2a6a7915..55a58a1f 100644
--- a/components/common/selectors/gl-account/gl-account-single-selector.tsx
+++ b/components/common/selectors/gl-account/gl-account-single-selector.tsx
@@ -1,358 +1,387 @@
-'use client'
-
-/**
- * GL 계정 단일 선택 다이얼로그
- *
- * @description
- * - GL 계정을 하나만 선택할 수 있는 다이얼로그
- * - 트리거 버튼과 다이얼로그가 분리된 구조
- * - 외부에서 open 상태를 제어 가능
- */
-
-import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
-import { Button } from '@/components/ui/button'
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Search, Check, X } from 'lucide-react'
-import {
- ColumnDef,
- flexRender,
- getCoreRowModel,
- getFilteredRowModel,
- getPaginationRowModel,
- getSortedRowModel,
- useReactTable,
- SortingState,
- ColumnFiltersState,
- VisibilityState,
- RowSelectionState,
-} from '@tanstack/react-table'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
-import {
- getGlAccounts,
- GlAccount
-} from './gl-account-service'
-import { toast } from 'sonner'
-
-export interface GlAccountSingleSelectorProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedCode?: GlAccount
- onCodeSelect: (code: GlAccount) => void
- onConfirm?: (code: GlAccount | undefined) => void
- onCancel?: () => void
- title?: string
- description?: string
- showConfirmButtons?: boolean
-}
-
-export function GlAccountSingleSelector({
- open,
- onOpenChange,
- selectedCode,
- onCodeSelect,
- onConfirm,
- onCancel,
- title = "GL 계정 선택",
- description = "GL 계정을 선택하세요",
- showConfirmButtons = false
-}: GlAccountSingleSelectorProps) {
- const [codes, setCodes] = useState<GlAccount[]>([])
- const [sorting, setSorting] = useState<SortingState>([])
- const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
- const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
- const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
- const [globalFilter, setGlobalFilter] = useState('')
- const [isPending, startTransition] = useTransition()
- const [tempSelectedCode, setTempSelectedCode] = useState<GlAccount | undefined>(selectedCode)
-
- // GL 계정 선택 핸들러
- const handleCodeSelect = useCallback((code: GlAccount) => {
- if (showConfirmButtons) {
- setTempSelectedCode(code)
- } else {
- onCodeSelect(code)
- onOpenChange(false)
- }
- }, [onCodeSelect, onOpenChange, showConfirmButtons])
-
- // 확인 버튼 핸들러
- const handleConfirm = useCallback(() => {
- if (tempSelectedCode) {
- onCodeSelect(tempSelectedCode)
- }
- onConfirm?.(tempSelectedCode)
- onOpenChange(false)
- }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
-
- // 취소 버튼 핸들러
- const handleCancel = useCallback(() => {
- setTempSelectedCode(selectedCode)
- onCancel?.()
- onOpenChange(false)
- }, [selectedCode, onCancel, onOpenChange])
-
- // 테이블 컬럼 정의
- const columns: ColumnDef<GlAccount>[] = useMemo(() => [
- {
- accessorKey: 'SAKNR',
- header: '계정(G/L)',
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.getValue('SAKNR')}</div>
- ),
- },
- {
- accessorKey: 'FIPEX',
- header: '세부계정',
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.getValue('FIPEX')}</div>
- ),
- },
- {
- accessorKey: 'TEXT1',
- header: '계정명',
- cell: ({ row }) => (
- <div>{row.getValue('TEXT1')}</div>
- ),
- },
- {
- id: 'actions',
- header: '선택',
- cell: ({ row }) => {
- const isSelected = showConfirmButtons
- ? tempSelectedCode?.SAKNR === row.original.SAKNR
- : selectedCode?.SAKNR === row.original.SAKNR
-
- return (
- <Button
- variant={isSelected ? "default" : "ghost"}
- size="sm"
- onClick={(e) => {
- e.stopPropagation()
- handleCodeSelect(row.original)
- }}
- >
- <Check className="h-4 w-4" />
- </Button>
- )
- },
- },
- ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
-
- // GL 계정 테이블 설정
- const table = useReactTable({
- data: codes,
- columns,
- onSortingChange: setSorting,
- onColumnFiltersChange: setColumnFilters,
- onColumnVisibilityChange: setColumnVisibility,
- onRowSelectionChange: setRowSelection,
- onGlobalFilterChange: setGlobalFilter,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- getSortedRowModel: getSortedRowModel(),
- getFilteredRowModel: getFilteredRowModel(),
- state: {
- sorting,
- columnFilters,
- columnVisibility,
- rowSelection,
- globalFilter,
- },
- })
-
- // 서버에서 GL 계정 전체 목록 로드 (한 번만)
- const loadCodes = useCallback(async () => {
- startTransition(async () => {
- try {
- const result = await getGlAccounts()
-
- if (result.success) {
- setCodes(result.data)
-
- // 폴백 데이터를 사용하는 경우 알림
- if (result.isUsingFallback) {
- toast.info('Oracle 연결 실패', {
- description: '테스트 데이터를 사용합니다.',
- duration: 4000,
- })
- }
- } else {
- toast.error(result.error || 'GL 계정을 불러오는데 실패했습니다.')
- setCodes([])
- }
- } catch (error) {
- console.error('GL 계정 목록 로드 실패:', error)
- toast.error('GL 계정을 불러오는 중 오류가 발생했습니다.')
- setCodes([])
- }
- })
- }, [])
-
- // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
- useEffect(() => {
- if (open) {
- setTempSelectedCode(selectedCode)
- if (codes.length === 0) {
- console.log('🚀 [GlAccountSingleSelector] 다이얼로그 열림 - loadCodes 호출')
- loadCodes()
- } else {
- console.log('📦 [GlAccountSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
- }
- }
- }, [open, selectedCode, loadCodes, codes.length])
-
- // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
- const handleSearchChange = useCallback((value: string) => {
- setGlobalFilter(value)
- }, [])
-
- const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-5xl max-h-[80vh]">
- <DialogHeader>
- <DialogTitle>{title}</DialogTitle>
- <div className="text-sm text-muted-foreground">
- {description}
- </div>
- </DialogHeader>
-
- <div className="space-y-4">
- {/* 현재 선택된 GL 계정 표시 */}
- {currentSelectedCode && (
- <div className="p-3 bg-muted rounded-md">
- <div className="text-sm font-medium">선택된 GL 계정:</div>
- <div className="flex items-center gap-2 mt-1">
- <span className="font-mono text-sm">[{currentSelectedCode.SAKNR}]</span>
- <span className="font-mono text-sm">{currentSelectedCode.FIPEX}</span>
- <span>- {currentSelectedCode.TEXT1}</span>
- </div>
- </div>
- )}
-
- <div className="flex items-center space-x-2">
- <Search className="h-4 w-4" />
- <Input
- placeholder="계정, 세부계정, 계정명으로 검색..."
- value={globalFilter}
- onChange={(e) => handleSearchChange(e.target.value)}
- className="flex-1"
- />
- </div>
-
- {isPending ? (
- <div className="flex justify-center py-8">
- <div className="text-sm text-muted-foreground">GL 계정을 불러오는 중...</div>
- </div>
- ) : (
- <div className="border rounded-md">
- <Table>
- <TableHeader>
- {table.getHeaderGroups().map((headerGroup) => (
- <TableRow key={headerGroup.id}>
- {headerGroup.headers.map((header) => (
- <TableHead key={header.id}>
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
- </TableHead>
- ))}
- </TableRow>
- ))}
- </TableHeader>
- <TableBody>
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map((row) => {
- const isRowSelected = currentSelectedCode?.SAKNR === row.original.SAKNR
- return (
- <TableRow
- key={row.id}
- data-state={isRowSelected && "selected"}
- className={`cursor-pointer hover:bg-muted/50 ${
- isRowSelected ? 'bg-muted' : ''
- }`}
- onClick={() => handleCodeSelect(row.original)}
- >
- {row.getVisibleCells().map((cell) => (
- <TableCell key={cell.id}>
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
- </TableCell>
- ))}
- </TableRow>
- )
- })
- ) : (
- <TableRow>
- <TableCell
- colSpan={columns.length}
- className="h-24 text-center"
- >
- 검색 결과가 없습니다.
- </TableCell>
- </TableRow>
- )}
- </TableBody>
- </Table>
- </div>
- )}
-
- <div className="flex items-center justify-between">
- <div className="text-sm text-muted-foreground">
- 총 {table.getFilteredRowModel().rows.length}개 GL 계정
- </div>
- <div className="flex items-center space-x-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => table.previousPage()}
- disabled={!table.getCanPreviousPage()}
- >
- 이전
- </Button>
- <div className="text-sm">
- {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={() => table.nextPage()}
- disabled={!table.getCanNextPage()}
- >
- 다음
- </Button>
- </div>
- </div>
- </div>
-
- {showConfirmButtons && (
- <DialogFooter>
- <Button variant="outline" onClick={handleCancel}>
- <X className="h-4 w-4 mr-2" />
- 취소
- </Button>
- <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
- <Check className="h-4 w-4 mr-2" />
- 확인
- </Button>
- </DialogFooter>
- )}
- </DialogContent>
- </Dialog>
- )
-}
-
+'use client'
+
+/**
+ * GL 계정 단일 선택 다이얼로그
+ *
+ * @description
+ * - GL 계정을 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 open 상태를 제어 가능
+ */
+
+import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check, X } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getGlAccounts,
+ GlAccount
+} from './gl-account-service'
+import { toast } from 'sonner'
+
+export interface GlAccountSingleSelectorProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCode?: GlAccount
+ onCodeSelect: (code: GlAccount) => void
+ onConfirm?: (code: GlAccount | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+}
+
+export function GlAccountSingleSelector({
+ open,
+ onOpenChange,
+ selectedCode,
+ onCodeSelect,
+ onConfirm,
+ onCancel,
+ title = "GL 계정 선택",
+ description = "GL 계정을 선택하세요",
+ showConfirmButtons = false
+}: GlAccountSingleSelectorProps) {
+ const [codes, setCodes] = useState<GlAccount[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+ const [tempSelectedCode, setTempSelectedCode] = useState<GlAccount | undefined>(selectedCode)
+
+ // GL 계정 선택 핸들러
+ const handleCodeSelect = useCallback((code: GlAccount) => {
+ // 이미 선택된 계정을 다시 선택하면 선택 해제
+ const currentSelected = showConfirmButtons ? tempSelectedCode : selectedCode
+ if (currentSelected && currentSelected.SAKNR === code.SAKNR && currentSelected.FIPEX === code.FIPEX) {
+ if (showConfirmButtons) {
+ setTempSelectedCode(undefined)
+ } else {
+ onCodeSelect(undefined as any)
+ onOpenChange(false)
+ }
+ return
+ }
+
+ if (showConfirmButtons) {
+ setTempSelectedCode(code)
+ } else {
+ onCodeSelect(code)
+ onOpenChange(false)
+ }
+ }, [onCodeSelect, onOpenChange, showConfirmButtons, selectedCode, tempSelectedCode])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCode) {
+ onCodeSelect(tempSelectedCode)
+ }
+ onConfirm?.(tempSelectedCode)
+ onOpenChange(false)
+ }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCode(selectedCode)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCode, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<GlAccount>[] = useMemo(() => [
+ {
+ accessorKey: 'SAKNR',
+ header: '계정(G/L)',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('SAKNR')}</div>
+ ),
+ },
+ {
+ accessorKey: 'FIPEX',
+ header: '세부계정',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('FIPEX')}</div>
+ ),
+ },
+ {
+ accessorKey: 'TEXT1',
+ header: '계정명',
+ cell: ({ row }) => (
+ <div>{row.getValue('TEXT1')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCode?.SAKNR === row.original.SAKNR
+ : selectedCode?.SAKNR === row.original.SAKNR
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
+
+ // GL 계정 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 GL 계정 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getGlAccounts()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'GL 계정을 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('GL 계정 목록 로드 실패:', error)
+ toast.error('GL 계정을 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCode(selectedCode)
+ if (codes.length === 0) {
+ console.log('🚀 [GlAccountSingleSelector] 다이얼로그 열림 - loadCodes 호출')
+ loadCodes()
+ } else {
+ console.log('📦 [GlAccountSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
+ }
+ }
+ }, [open, selectedCode, loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 GL 계정 표시 */}
+ {currentSelectedCode && (
+ <div className="p-3 bg-muted rounded-md">
+ <div className="text-sm font-medium flex items-center justify-between">
+ <span>선택된 GL 계정:</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={() => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(undefined)
+ } else {
+ onCodeSelect(undefined as any)
+ onOpenChange(false)
+ }
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono text-sm">[{currentSelectedCode.SAKNR}]</span>
+ <span className="font-mono text-sm">{currentSelectedCode.FIPEX}</span>
+ <span>- {currentSelectedCode.TEXT1}</span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="계정, 세부계정, 계정명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">GL 계정을 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isRowSelected = currentSelectedCode?.SAKNR === row.original.SAKNR
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 GL 계정
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/wbs-code/wbs-code-selector.tsx b/components/common/selectors/wbs-code/wbs-code-selector.tsx
index b701d090..aa5a6a64 100644
--- a/components/common/selectors/wbs-code/wbs-code-selector.tsx
+++ b/components/common/selectors/wbs-code/wbs-code-selector.tsx
@@ -15,7 +15,7 @@ import { useState, useCallback, useMemo, useTransition } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
-import { Search, Check } from 'lucide-react'
+import { Search, Check, X } from 'lucide-react'
import {
ColumnDef,
flexRender,
@@ -80,8 +80,20 @@ export function WbsCodeSelector({
// WBS 코드 선택 핸들러
const handleCodeSelect = useCallback(async (code: WbsCode) => {
+ // 이미 선택된 코드를 다시 선택하면 선택 해제
+ if (selectedCode && selectedCode.PROJ_NO === code.PROJ_NO && selectedCode.WBS_ELMT === code.WBS_ELMT) {
+ onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달
+ setOpen(false)
+ return
+ }
+
onCodeSelect(code)
setOpen(false)
+ }, [onCodeSelect, selectedCode])
+
+ // 선택 해제 핸들러
+ const handleClearSelection = useCallback(() => {
+ onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달
}, [onCodeSelect])
// 테이블 컬럼 정의
@@ -208,6 +220,17 @@ export function WbsCodeSelector({
<span className="font-mono text-sm">[{selectedCode.PROJ_NO}]</span>
<span className="font-mono text-sm">{selectedCode.WBS_ELMT}</span>
<span className="truncate flex-1 text-left">{selectedCode.WBS_ELMT_NM}</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground ml-1"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleClearSelection()
+ }}
+ >
+ <X className="h-3 w-3" />
+ </Button>
</div>
) : (
<span className="text-muted-foreground">{placeholder}</span>
@@ -273,6 +296,11 @@ export function WbsCodeSelector({
)}
</TableCell>
))}
+ {selectedCode && selectedCode.PROJ_NO === row.original.PROJ_NO && selectedCode.WBS_ELMT === row.original.WBS_ELMT && (
+ <TableCell className="text-right">
+ <span className="text-xs text-muted-foreground">(선택됨)</span>
+ </TableCell>
+ )}
</TableRow>
))
) : (
diff --git a/components/common/selectors/wbs-code/wbs-code-single-selector.tsx b/components/common/selectors/wbs-code/wbs-code-single-selector.tsx
index 34cbc975..77a32afe 100644
--- a/components/common/selectors/wbs-code/wbs-code-single-selector.tsx
+++ b/components/common/selectors/wbs-code/wbs-code-single-selector.tsx
@@ -75,13 +75,25 @@ export function WbsCodeSingleSelector({
// WBS 코드 선택 핸들러
const handleCodeSelect = useCallback((code: WbsCode) => {
+ // 이미 선택된 코드를 다시 선택하면 선택 해제
+ const currentSelected = showConfirmButtons ? tempSelectedCode : selectedCode
+ if (currentSelected && currentSelected.WBS_ELMT === code.WBS_ELMT && currentSelected.PROJ_NO === code.PROJ_NO) {
+ if (showConfirmButtons) {
+ setTempSelectedCode(undefined)
+ } else {
+ onCodeSelect(undefined as any)
+ onOpenChange(false)
+ }
+ return
+ }
+
if (showConfirmButtons) {
setTempSelectedCode(code)
} else {
onCodeSelect(code)
onOpenChange(false)
}
- }, [onCodeSelect, onOpenChange, showConfirmButtons])
+ }, [onCodeSelect, onOpenChange, showConfirmButtons, selectedCode, tempSelectedCode])
// 확인 버튼 핸들러
const handleConfirm = useCallback(() => {
@@ -237,7 +249,24 @@ export function WbsCodeSingleSelector({
{/* 현재 선택된 WBS 코드 표시 */}
{currentSelectedCode && (
<div className="p-3 bg-muted rounded-md">
- <div className="text-sm font-medium">선택된 WBS 코드:</div>
+ <div className="text-sm font-medium flex items-center justify-between">
+ <span>선택된 WBS 코드:</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={() => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(undefined)
+ } else {
+ onCodeSelect(undefined as any)
+ onOpenChange(false)
+ }
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
<div className="flex items-center gap-2 mt-1">
<span className="font-mono text-sm">[{currentSelectedCode.PROJ_NO}]</span>
<span className="font-mono text-sm">{currentSelectedCode.WBS_ELMT}</span>
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index d657086f..3db9108d 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -74,7 +74,7 @@ export const biddingTypeEnum = pgEnum('bidding_type', [
'other' // 기타(직접입력)
])
-// 4. 낙찰수 enum
+// 4. 낙찰업체 수 enum
export const awardCountEnum = pgEnum('award_count', [
'single', // 단수
'multiple' // 복수
@@ -114,13 +114,13 @@ export const documentTypeEnum = pgEnum('document_type', [
'specification', // 사양서
'specification_meeting', // 사양설명회
'contract_draft', // 계약서 초안
- 'company_proposal', // 업체 제안서
+ 'company_proposal', // 협력업체용 첨부파일
'financial_doc', // 재무 관련 문서
'technical_doc', // 기술 관련 문서
'certificate', // 인증서류
'pr_document', // PR 문서
'spec_document', // SPEC 문서
- 'evaluation_doc', // 평가 관련 문서
+ 'evaluation_doc', // SHI용 첨부파일 (평가 관련 문서)
'bid_attachment', // 입찰 첨부파일
'selection_result', // 선정결과 첨부파일
'other' // 기타
@@ -166,7 +166,7 @@ export const biddings = pgTable('biddings', {
contractType: contractTypeEnum('contract_type').notNull(), // 계약구분
noticeType: varchar('notice_type', { length: 50 }).default('standard'), // 입찰공고 타입
biddingType: biddingTypeEnum('bidding_type').notNull(), // 입찰유형
- awardCount: awardCountEnum('award_count').default('single'), // 낙찰수
+ awardCount: awardCountEnum('award_count').default('single'), // 낙찰업체 수
// contractPeriod: varchar('contract_period', { length: 100 }), // 계약기간
//시작일 (기본값: 현재 날짜)
contractStartDate: date('contract_start_date').defaultNow(),
@@ -752,385 +752,3 @@ export const weightUnitLabels = {
lb: 'lb',
g: 'g'
} as const
-
-
-// export const biddingListView = pgView('bidding_list_view').as((qb) =>
-// qb
-// .select({
-// // ═══════════════════════════════════════════════════════════════
-// // 기본 입찰 정보
-// // ═══════════════════════════════════════════════════════════════
-// id: biddings.id,
-// biddingNumber: biddings.biddingNumber,
-// revision: biddings.revision,
-// projectName: biddings.projectName,
-// itemName: biddings.itemName,
-// title: biddings.title,
-// description: biddings.description,
-// biddingSourceType: biddings.biddingSourceType,
-// isUrgent: biddings.isUrgent,
-
-// // ═══════════════════════════════════════════════════════════════
-// // 계약 정보
-// // ═══════════════════════════════════════════════════════════════
-// 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,
-
-// // ═══════════════════════════════════════════════════════════════
-// // 회의 및 문서
-// // ═══════════════════════════════════════════════════════════════
-// hasSpecificationMeeting: biddings.hasSpecificationMeeting,
-// hasPrDocument: biddings.hasPrDocument,
-// prNumber: biddings.prNumber,
-
-// // ═══════════════════════════════════════════════════════════════
-// // 가격 정보
-// // ═══════════════════════════════════════════════════════════════
-// currency: biddings.currency,
-// budget: biddings.budget,
-// targetPrice: biddings.targetPrice,
-// finalBidPrice: biddings.finalBidPrice,
-
-// // ═══════════════════════════════════════════════════════════════
-// // 상태 및 담당자
-// // ═══════════════════════════════════════════════════════════════
-// status: biddings.status,
-// isPublic: biddings.isPublic,
-// managerName: biddings.managerName,
-// managerEmail: biddings.managerEmail,
-// managerPhone: biddings.managerPhone,
-
-// // ═══════════════════════════════════════════════════════════════
-// // 메타 정보
-// // ═══════════════════════════════════════════════════════════════
-// remarks: biddings.remarks,
-// createdBy: biddings.createdBy,
-// createdAt: biddings.createdAt,
-// updatedAt: biddings.updatedAt,
-// updatedBy: biddings.updatedBy,
-
-// // ═══════════════════════════════════════════════════════════════
-// // 사양설명회 상세 정보
-// // ═══════════════════════════════════════════════════════════════
-// hasSpecificationMeetingDetails: sql<boolean>`${specificationMeetings.id} IS NOT NULL`.as('has_specification_meeting_details'),
-// meetingDate: specificationMeetings.meetingDate,
-// meetingLocation: specificationMeetings.location,
-// meetingContactPerson: specificationMeetings.contactPerson,
-// meetingIsRequired: specificationMeetings.isRequired,
-
-// // ═══════════════════════════════════════════════════════════════
-// // PR 문서 집계
-// // ═══════════════════════════════════════════════════════════════
-// prDocumentCount: sql<number>`
-// COALESCE((
-// SELECT count(*)
-// FROM pr_documents
-// WHERE bidding_id = ${biddings.id}
-// ), 0)
-// `.as('pr_document_count'),
-
-// // PR 문서 목록 (최대 5개 문서명)
-// prDocumentNames: sql<string[]>`
-// (
-// SELECT array_agg(document_name ORDER BY registered_at DESC)
-// FROM pr_documents
-// WHERE bidding_id = ${biddings.id}
-// LIMIT 5
-// )
-// `.as('pr_document_names'),
-
-// // ═══════════════════════════════════════════════════════════════
-// // 참여 현황 집계 (핵심)
-// // ═══════════════════════════════════════════════════════════════
-// participantExpected: sql<number>`
-// COALESCE((
-// SELECT count(*)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// ), 0)
-// `.as('participant_expected'),
-
-// participantParticipated: sql<number>`
-// COALESCE((
-// SELECT count(*)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND invitation_status = 'submitted'
-// ), 0)
-// `.as('participant_participated'),
-
-// participantDeclined: sql<number>`
-// COALESCE((
-// SELECT count(*)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND invitation_status = 'declined'
-// ), 0)
-// `.as('participant_declined'),
-
-// participantPending: sql<number>`
-// COALESCE((
-// SELECT count(*)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND invitation_status IN ('pending', 'sent')
-// ), 0)
-// `.as('participant_pending'),
-
-// participantAccepted: sql<number>`
-// COALESCE((
-// SELECT count(*)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND invitation_status = 'accepted'
-// ), 0)
-// `.as('participant_accepted'),
-
-// // ═══════════════════════════════════════════════════════════════
-// // 참여율 계산
-// // ═══════════════════════════════════════════════════════════════
-// participationRate: sql<number>`
-// CASE
-// WHEN (
-// SELECT count(*)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// ) > 0
-// THEN ROUND(
-// (
-// SELECT count(*)::decimal
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND invitation_status = 'submitted'
-// ) / (
-// SELECT count(*)::decimal
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// ) * 100, 1
-// )
-// ELSE 0
-// END
-// `.as('participation_rate'),
-
-// // ═══════════════════════════════════════════════════════════════
-// // 견적 금액 통계
-// // ═══════════════════════════════════════════════════════════════
-// avgPreQuoteAmount: sql<number>`
-// (
-// SELECT AVG(pre_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND pre_quote_amount IS NOT NULL
-// )
-// `.as('avg_pre_quote_amount'),
-
-// minPreQuoteAmount: sql<number>`
-// (
-// SELECT MIN(pre_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND pre_quote_amount IS NOT NULL
-// )
-// `.as('min_pre_quote_amount'),
-
-// maxPreQuoteAmount: sql<number>`
-// (
-// SELECT MAX(pre_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND pre_quote_amount IS NOT NULL
-// )
-// `.as('max_pre_quote_amount'),
-
-// avgFinalQuoteAmount: sql<number>`
-// (
-// SELECT AVG(final_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND final_quote_amount IS NOT NULL
-// )
-// `.as('avg_final_quote_amount'),
-
-// minFinalQuoteAmount: sql<number>`
-// (
-// SELECT MIN(final_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND final_quote_amount IS NOT NULL
-// )
-// `.as('min_final_quote_amount'),
-
-// maxFinalQuoteAmount: sql<number>`
-// (
-// SELECT MAX(final_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND final_quote_amount IS NOT NULL
-// )
-// `.as('max_final_quote_amount'),
-
-// // ═══════════════════════════════════════════════════════════════
-// // 선정 및 낙찰 정보
-// // ═══════════════════════════════════════════════════════════════
-// selectedForFinalBidCount: sql<number>`
-// COALESCE((
-// SELECT count(*)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND is_pre_quote_selected = true
-// ), 0)
-// `.as('selected_for_final_bid_count'),
-
-// winnerCount: sql<number>`
-// COALESCE((
-// SELECT count(*)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND is_winner = true
-// ), 0)
-// `.as('winner_count'),
-
-// // 낙찰 업체명 목록
-// winnerCompanyNames: sql<string[]>`
-// (
-// SELECT array_agg(v.vendor_name ORDER BY v.vendor_name)
-// FROM bidding_companies bc
-// JOIN vendors v ON bc.company_id = v.id
-// WHERE bc.bidding_id = ${biddings.id}
-// AND bc.is_winner = true
-// )
-// `.as('winner_company_names'),
-
-// // ═══════════════════════════════════════════════════════════════
-// // 일정 상태 계산
-// // ═══════════════════════════════════════════════════════════════
-// submissionStatus: sql<string>`
-// CASE
-// WHEN ${biddings.submissionStartDate} IS NULL OR ${biddings.submissionEndDate} IS NULL
-// THEN 'not_scheduled'
-// WHEN NOW() < ${biddings.submissionStartDate}
-// THEN 'scheduled'
-// WHEN NOW() BETWEEN ${biddings.submissionStartDate} AND ${biddings.submissionEndDate}
-// THEN 'active'
-// WHEN NOW() > ${biddings.submissionEndDate}
-// THEN 'closed'
-// ELSE 'unknown'
-// END
-// `.as('submission_status'),
-
-// // 마감까지 남은 일수
-// daysUntilDeadline: sql<number>`
-// CASE
-// WHEN ${biddings.submissionEndDate} IS NOT NULL
-// AND NOW() < ${biddings.submissionEndDate}
-// THEN EXTRACT(DAYS FROM (${biddings.submissionEndDate} - NOW()))::integer
-// ELSE NULL
-// END
-// `.as('days_until_deadline'),
-
-// // 시작까지 남은 일수
-// daysUntilStart: sql<number>`
-// CASE
-// WHEN ${biddings.submissionStartDate} IS NOT NULL
-// AND NOW() < ${biddings.submissionStartDate}
-// THEN EXTRACT(DAYS FROM (${biddings.submissionStartDate} - NOW()))::integer
-// ELSE NULL
-// END
-// `.as('days_until_start'),
-
-// // ═══════════════════════════════════════════════════════════════
-// // 추가 유용한 계산 필드들
-// // ═══════════════════════════════════════════════════════════════
-
-// // 예산 대비 최저 견적 비율
-// budgetEfficiencyRate: sql<number>`
-// CASE
-// WHEN ${biddings.budget} IS NOT NULL AND ${biddings.budget} > 0
-// AND (
-// SELECT MIN(final_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND final_quote_amount IS NOT NULL
-// ) IS NOT NULL
-// THEN ROUND(
-// (
-// SELECT MIN(final_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND final_quote_amount IS NOT NULL
-// ) / ${biddings.budget} * 100, 1
-// )
-// ELSE NULL
-// END
-// `.as('budget_efficiency_rate'),
-
-// // 내정가 대비 최저 견적 비율
-// targetPriceEfficiencyRate: sql<number>`
-// CASE
-// WHEN ${biddings.targetPrice} IS NOT NULL AND ${biddings.targetPrice} > 0
-// AND (
-// SELECT MIN(final_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND final_quote_amount IS NOT NULL
-// ) IS NOT NULL
-// THEN ROUND(
-// (
-// SELECT MIN(final_quote_amount)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// AND final_quote_amount IS NOT NULL
-// ) / ${biddings.targetPrice} * 100, 1
-// )
-// ELSE NULL
-// END
-// `.as('target_price_efficiency_rate'),
-
-// // 입찰 진행 단계 점수 (0-100)
-// progressScore: sql<number>`
-// CASE ${biddings.status}
-// WHEN 'bidding_generated' THEN 10
-// WHEN 'request_for_quotation' THEN 20
-// WHEN 'received_quotation' THEN 40
-// WHEN 'set_target_price' THEN 60
-// WHEN 'bidding_opened' THEN 70
-// WHEN 'bidding_closed' THEN 80
-// WHEN 'evaluation_of_bidding' THEN 90
-// WHEN 'vendor_selected' THEN 100
-// WHEN 'bidding_disposal' THEN 0
-// ELSE 0
-// END
-// `.as('progress_score'),
-
-// // 마지막 활동일 (가장 최근 업체 응답일)
-// lastActivityDate: sql<Date>`
-// GREATEST(
-// ${biddings.updatedAt},
-// COALESCE((
-// SELECT MAX(updated_at)
-// FROM bidding_companies
-// WHERE bidding_id = ${biddings.id}
-// ), ${biddings.updatedAt})
-// )
-// `.as('last_activity_date'),
-// })
-// .from(biddings)
-// .leftJoin(
-// specificationMeetings,
-// sql`${biddings.id} = ${specificationMeetings.biddingId}`
-// )
-// )
-
-// export type BiddingListView = typeof biddingListView.$inferSelect
diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json
index dd50b9fd..249118b1 100644
--- a/i18n/locales/ko/menu.json
+++ b/i18n/locales/ko/menu.json
@@ -184,7 +184,7 @@
"general_contract_desc": "일반 계약 관리",
"bid_failure": "폐찰 및 재입찰",
"bid_failure_desc": "유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다",
- "bid_receive": "입찰서접수및마감",
+ "bid_receive": "입찰서 접수 및 마감",
"bid_receive_desc": "입찰서 접수 현황을 확인하고 개찰을 진행할 수 있습니다",
"bid_selection": "입찰선정",
"bid_selection_desc": "개찰 이후 입찰가를 확인하고 낙찰업체를 선정할 수 있습니다"
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 39bf0c46..d0f8070f 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1505,6 +1505,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
respondedAt: biddingCompanies.respondedAt,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
isWinner: biddingCompanies.isWinner,
isAttendingMeeting: biddingCompanies.isAttendingMeeting,
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
@@ -1624,6 +1625,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
isBiddingParticipated: biddingCompanies.isBiddingParticipated,
isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
hasSpecificationMeeting: biddings.hasSpecificationMeeting,
// 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리
paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
@@ -1811,37 +1813,37 @@ export async function submitPartnerResponse(
// 임시저장: invitationStatus는 변경하지 않음 (bidding_accepted 유지)
}
- // 스냅샷은 임시저장/최종제출 관계없이 항상 생성
- if (response.prItemQuotations && response.prItemQuotations.length > 0) {
- // 기존 스냅샷 조회
- const existingCompany = await tx
- .select({ quotationSnapshots: biddingCompanies.quotationSnapshots })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, biddingCompanyId))
- .limit(1)
-
- const existingSnapshots = existingCompany[0]?.quotationSnapshots as any[] || []
-
- // 새로운 스냅샷 생성
- const newSnapshot = {
- id: Date.now().toString(), // 고유 ID
- round: existingSnapshots.length + 1, // 차수
- submittedAt: new Date().toISOString(),
- totalAmount: response.finalQuoteAmount,
- currency: 'KRW',
- isFinalSubmission: !!response.isFinalSubmission,
- items: response.prItemQuotations.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice,
- bidAmount: item.bidAmount,
- proposedDeliveryDate: item.proposedDeliveryDate,
- technicalSpecification: item.technicalSpecification
- }))
- }
-
- // 스냅샷 배열에 추가
- companyUpdateData.quotationSnapshots = [...existingSnapshots, newSnapshot]
- }
+ // // 스냅샷은 임시저장/최종제출 관계없이 항상 생성
+ // if (response.prItemQuotations && response.prItemQuotations.length > 0) {
+ // // 기존 스냅샷 조회
+ // const existingCompany = await tx
+ // .select({ quotationSnapshots: biddingCompanies.quotationSnapshots })
+ // .from(biddingCompanies)
+ // .where(eq(biddingCompanies.id, biddingCompanyId))
+ // .limit(1)
+
+ // const existingSnapshots = existingCompany[0]?.quotationSnapshots as any[] || []
+
+ // // 새로운 스냅샷 생성
+ // const newSnapshot = {
+ // id: Date.now().toString(), // 고유 ID
+ // round: existingSnapshots.length + 1, // 차수
+ // submittedAt: new Date().toISOString(),
+ // totalAmount: response.finalQuoteAmount,
+ // currency: 'KRW',
+ // isFinalSubmission: !!response.isFinalSubmission,
+ // items: response.prItemQuotations.map(item => ({
+ // prItemId: item.prItemId,
+ // bidUnitPrice: item.bidUnitPrice,
+ // bidAmount: item.bidAmount,
+ // proposedDeliveryDate: item.proposedDeliveryDate,
+ // technicalSpecification: item.technicalSpecification
+ // }))
+ // }
+
+ // // 스냅샷 배열에 추가
+ // companyUpdateData.quotationSnapshots = [...existingSnapshots, newSnapshot]
+ // }
}
await tx
@@ -2342,47 +2344,40 @@ export async function deleteBiddingDocument(documentId: number, biddingId: numbe
}
}
-// 협력업체용 발주처 문서 조회 (캐시 적용)
+// 협력업체용 발주처 문서 조회 (협력업체용 첨부파일만)
export async function getBiddingDocumentsForPartners(biddingId: number) {
- return unstable_cache(
- async () => {
- try {
- const documents = await db
- .select({
- id: biddingDocuments.id,
- biddingId: biddingDocuments.biddingId,
- companyId: biddingDocuments.companyId,
- documentType: biddingDocuments.documentType,
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- fileSize: biddingDocuments.fileSize,
- filePath: biddingDocuments.filePath,
- title: biddingDocuments.title,
- description: biddingDocuments.description,
- uploadedAt: biddingDocuments.uploadedAt,
- uploadedBy: biddingDocuments.uploadedBy
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.biddingId, biddingId),
- sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만
- eq(biddingDocuments.isPublic, true) // 공개 문서만
- )
- )
- .orderBy(desc(biddingDocuments.uploadedAt))
+ try {
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ biddingId: biddingDocuments.biddingId,
+ companyId: biddingDocuments.companyId,
+ documentType: biddingDocuments.documentType,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.documentType, 'company_proposal'), // 협력업체용 첨부파일만
+ sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만
+ eq(biddingDocuments.isPublic, true) // 공개 문서만
+ )
+ )
+ .orderBy(desc(biddingDocuments.uploadedAt))
- return documents
- } catch (error) {
- console.error('Failed to get bidding documents for partners:', error)
- return []
- }
- },
- [`bidding-documents-partners-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'bidding-documents']
- }
- )()
+ return documents
+ } catch (error) {
+ console.error('Failed to get bidding documents for partners:', error)
+ return []
+ }
}
// =================================================
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
index 6e5481f4..5bc85fdb 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
@@ -22,7 +22,7 @@ interface BiddingDetailVendorEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
- biddingAwardCount?: string // 낙찰수 정보 추가
+ biddingAwardCount?: string // 낙찰업체 수 정보 추가
biddingStatus?: string // 입찰 상태 정보 추가
allVendors?: QuotationVendor[] // 전체 벤더 목록 추가
}
@@ -55,7 +55,7 @@ export function BiddingDetailVendorEditDialog({
// vendor가 변경되면 폼 데이터 업데이트
React.useEffect(() => {
if (vendor) {
- // 낙찰수가 단수인 경우 발주비율을 100%로 자동 설정
+ // 낙찰업체 수가 단수인 경우 발주비율을 100%로 자동 설정
const defaultAwardRatio = biddingAwardCount === 'single' ? 100 : (vendor.awardRatio || 0)
setFormData({
awardRatio: defaultAwardRatio,
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index 34ee690f..53fe05f9 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -286,14 +286,14 @@ export function BiddingDetailVendorToolbarActions({
bidding.status === 'bidding_disposal') && (
<div className="h-4 w-px bg-border mx-1" />
)}
- <Button
+ {/* <Button
variant="outline"
size="sm"
onClick={handleDocumentUpload}
>
<FileText className="mr-2 h-4 w-4" />
입찰문서 등록
- </Button>
+ </Button> */}
</div>
diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
index ffb1fcb3..582622d9 100644
--- a/lib/bidding/detail/table/bidding-invitation-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
@@ -33,7 +33,6 @@ import {
} from 'lucide-react'
import { getExistingBasicContractsForBidding } from '../../pre-quote/service'
import { getActiveContractTemplates } from '../../service'
-import { getVendorContacts } from '@/lib/vendors/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
import { SelectTrigger } from '@/components/ui/select'
@@ -269,47 +268,23 @@ export function BiddingInvitationDialog({
}));
setSelectedContracts(initialSelected);
- // 벤더 담당자 정보 병렬로 가져오기
- const vendorContactsPromises = selectedVendors.map(vendor =>
- getVendorContacts({
- page: 1,
- perPage: 100,
- flags: [],
- sort: [],
- filters: [],
- joinOperator: 'and',
- search: '',
- contactName: '',
- contactPosition: '',
- contactEmail: '',
- contactPhone: ''
- }, vendor.vendorId)
- .then(result => ({
- vendorId: vendor.vendorId,
- contacts: (result.data || []).map(contact => ({
- id: contact.id,
- contactName: contact.contactName,
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone,
- contactPosition: contact.contactPosition,
- contactDepartment: contact.contactDepartment
- }))
- }))
- .catch(() => ({
- vendorId: vendor.vendorId,
- contacts: []
- }))
- );
-
- const vendorContactsResults = await Promise.all(vendorContactsPromises);
- const vendorContactsMap = new Map(vendorContactsResults.map(result => [result.vendorId, result.contacts]));
+ // 담당자 정보는 selectedVendors에 이미 포함되어 있음
// vendorData 초기화 (담당자 정보 포함)
const initialVendorData: VendorWithContactInfo[] = selectedVendors.map(vendor => {
const hasExistingContract = typedContracts.some((ec) =>
ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
);
- const vendorContacts = vendorContactsMap.get(vendor.vendorId) || [];
+
+ // contacts 정보가 이미 selectedVendors에 포함되어 있음
+ const vendorContacts = (vendor.contacts || []).map(contact => ({
+ id: contact.id,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactNumber,
+ contactPosition: null,
+ contactDepartment: null
+ }));
// 주 수신자 기본값: 벤더의 기본 이메일 (vendorEmail)
const defaultEmail = vendor.vendorEmail || (vendorContacts.length > 0 ? vendorContacts[0].contactEmail : '');
@@ -569,296 +544,6 @@ export function BiddingInvitationDialog({
)}
{/* 대상 업체 정보 - 테이블 형식 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2 text-sm font-medium">
- <Building2 className="h-4 w-4" />
- 초대 대상 업체 ({vendorData.length})
- </div>
- <Badge variant="outline" className="flex items-center gap-1">
- <Users className="h-3 w-3" />
- 총 {totalRecipientCount}명
- </Badge>
- </div>
-
- {vendorData.length === 0 ? (
- <div className="text-center py-6 text-muted-foreground border rounded-lg">
- 초대 가능한 업체가 없습니다.
- </div>
- ) : (
- <div className="border rounded-lg overflow-hidden">
- <table className="w-full">
- <thead className="bg-muted/50 border-b">
- <tr>
- <th className="text-left p-2 text-xs font-medium">No.</th>
- <th className="text-left p-2 text-xs font-medium">업체명</th>
- <th className="text-left p-2 text-xs font-medium">주 수신자</th>
- <th className="text-left p-2 text-xs font-medium">CC</th>
- <th className="text-left p-2 text-xs font-medium">작업</th>
- </tr>
- </thead>
- <tbody>
- {vendorData.map((vendor, index) => {
- const allContacts = vendor.contacts || [];
- const allEmails = [
- // 벤더의 기본 이메일을 첫 번째로 표시
- ...(vendor.vendorEmail ? [{
- value: vendor.vendorEmail,
- label: `${vendor.vendorEmail}`,
- email: vendor.vendorEmail,
- type: 'vendor' as const
- }] : []),
- // 담당자 이메일들
- ...allContacts.map(c => ({
- value: c.contactEmail,
- label: `${c.contactName} ${c.contactPosition ? `(${c.contactPosition})` : ''}`,
- email: c.contactEmail,
- type: 'contact' as const
- })),
- // 커스텀 이메일들
- ...vendor.customEmails.map(c => ({
- value: c.email,
- label: c.name || c.email,
- email: c.email,
- type: 'custom' as const
- }))
- ];
-
- const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail);
- const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail);
- const isFormOpen = showCustomEmailForm[vendor.vendorId];
-
- return (
- <React.Fragment key={vendor.vendorId}>
- <tr className="border-b hover:bg-muted/20">
- <td className="p-2">
- <div className="flex items-center gap-1">
- <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium">
- {index + 1}
- </div>
- </div>
- </td>
- <td className="p-2">
- <div className="space-y-1">
- <div className="font-medium text-sm">{vendor.vendorName}</div>
- <div className="flex items-center gap-1">
- <Badge variant="outline" className="text-xs">
- {vendor.vendorCountry || vendor.vendorCode}
- </Badge>
- </div>
- </div>
- </td>
- <td className="p-2">
- <Select
- value={vendor.selectedMainEmail}
- onValueChange={(value) => updateVendor(vendor.vendorId, { selectedMainEmail: value })}
- >
- <SelectTrigger className="h-7 text-xs w-[200px]">
- <SelectValue placeholder="선택하세요">
- {selectedMainEmailInfo && (
- <div className="flex items-center gap-1">
- {selectedMainEmailInfo.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />}
- <span className="truncate">{selectedMainEmailInfo.label}</span>
- </div>
- )}
- </SelectValue>
- </SelectTrigger>
- <SelectContent>
- {allEmails.map((email) => (
- <SelectItem key={email.value} value={email.value} className="text-xs">
- <div className="flex items-center gap-1">
- {email.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />}
- <span>{email.label}</span>
- </div>
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- {!vendor.selectedMainEmail && (
- <span className="text-xs text-red-500">필수</span>
- )}
- </td>
- <td className="p-2">
- <Popover>
- <PopoverTrigger asChild>
- <Button variant="outline" className="h-7 text-xs">
- {vendor.additionalEmails.length > 0
- ? `${vendor.additionalEmails.length}명`
- : "선택"
- }
- <ChevronDown className="ml-1 h-3 w-3" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-48 p-2">
- <div className="max-h-48 overflow-y-auto space-y-1">
- {ccEmails.map((email) => (
- <div key={email.value} className="flex items-center space-x-1 p-1">
- <Checkbox
- checked={vendor.additionalEmails.includes(email.value)}
- onCheckedChange={() => toggleAdditionalEmail(vendor.vendorId, email.value)}
- className="h-3 w-3"
- />
- <label className="text-xs cursor-pointer flex-1 truncate">
- {email.label}
- </label>
- </div>
- ))}
- </div>
- </PopoverContent>
- </Popover>
- </td>
- <td className="p-2">
- <div className="flex items-center gap-1">
- <Button
- variant={isFormOpen ? "default" : "ghost"}
- size="sm"
- className="h-6 w-6 p-0"
- onClick={() => {
- setShowCustomEmailForm(prev => ({
- ...prev,
- [vendor.vendorId]: !prev[vendor.vendorId]
- }));
- }}
- >
- {isFormOpen ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
- </Button>
- {vendor.customEmails.length > 0 && (
- <Badge variant="secondary" className="text-xs">
- +{vendor.customEmails.length}
- </Badge>
- )}
- </div>
- </td>
- </tr>
-
- {/* 인라인 수신자 추가 폼 */}
- {isFormOpen && (
- <tr className="bg-muted/10 border-b">
- <td colSpan={5} className="p-4">
- <div className="space-y-3">
- <div className="flex items-center justify-between mb-2">
- <div className="flex items-center gap-2 text-sm font-medium">
- <UserPlus className="h-4 w-4" />
- 수신자 추가 - {vendor.vendorName}
- </div>
- <Button
- variant="ghost"
- size="sm"
- className="h-6 w-6 p-0"
- onClick={() => setShowCustomEmailForm(prev => ({
- ...prev,
- [vendor.vendorId]: false
- }))}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
-
- <div className="flex gap-2 items-end">
- <div className="w-[150px]">
- <Label className="text-xs mb-1 block">이름 (선택)</Label>
- <Input
- placeholder="홍길동"
- className="h-8 text-sm"
- value={customEmailInputs[vendor.vendorId]?.name || ''}
- onChange={(e) => setCustomEmailInputs(prev => ({
- ...prev,
- [vendor.vendorId]: {
- ...prev[vendor.vendorId],
- name: e.target.value
- }
- }))}
- />
- </div>
- <div className="flex-1">
- <Label className="text-xs mb-1 block">이메일 <span className="text-red-500">*</span></Label>
- <Input
- type="email"
- placeholder="example@company.com"
- className="h-8 text-sm"
- value={customEmailInputs[vendor.vendorId]?.email || ''}
- onChange={(e) => setCustomEmailInputs(prev => ({
- ...prev,
- [vendor.vendorId]: {
- ...prev[vendor.vendorId],
- email: e.target.value
- }
- }))}
- onKeyPress={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addCustomEmail(vendor.vendorId);
- }
- }}
- />
- </div>
- <Button
- size="sm"
- className="h-8 px-4"
- onClick={() => addCustomEmail(vendor.vendorId)}
- disabled={!customEmailInputs[vendor.vendorId]?.email}
- >
- <Plus className="h-3 w-3 mr-1" />
- 추가
- </Button>
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-4"
- onClick={() => {
- setCustomEmailInputs(prev => ({
- ...prev,
- [vendor.vendorId]: { email: '', name: '' }
- }));
- setShowCustomEmailForm(prev => ({
- ...prev,
- [vendor.vendorId]: false
- }));
- }}
- >
- 취소
- </Button>
- </div>
-
- {/* 추가된 커스텀 이메일 목록 */}
- {vendor.customEmails.length > 0 && (
- <div className="mt-3 pt-3 border-t">
- <div className="text-xs text-muted-foreground mb-2">추가된 수신자 목록</div>
- <div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
- {vendor.customEmails.map((custom) => (
- <div key={custom.id} className="flex items-center justify-between bg-background rounded-md p-2">
- <div className="flex items-center gap-2 min-w-0">
- <UserPlus className="h-3 w-3 text-green-500 flex-shrink-0" />
- <div className="min-w-0">
- <div className="text-sm font-medium truncate">{custom.name}</div>
- <div className="text-xs text-muted-foreground truncate">{custom.email}</div>
- </div>
- </div>
- <Button
- variant="ghost"
- size="sm"
- className="h-6 w-6 p-0 flex-shrink-0"
- onClick={() => removeCustomEmail(vendor.vendorId, custom.id)}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
- </div>
- </td>
- </tr>
- )}
- </React.Fragment>
- );
- })}
- </tbody>
- </table>
- </div>
- )}
- </div>
<Separator />
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 40c7f271..36abd03c 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -122,14 +122,14 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
// ░░░ 프로젝트명 ░░░
{
accessorKey: "projectName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 No." />,
cell: ({ row }) => (
<div className="truncate max-w-[150px]" title={row.original.projectName || ''}>
{row.original.projectName || '-'}
</div>
),
size: 150,
- meta: { excelHeader: "프로젝트명" },
+ meta: { excelHeader: "프로젝트 No." },
},
// ░░░ 입찰명 ░░░
{
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 0cb87b11..3f65f559 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -80,26 +80,12 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
.getFilteredSelectedRowModel()
.rows
.map(row => row.original)
- }, [table])
+ }, [table.getFilteredSelectedRowModel().rows])
// 업체선정이 완료된 입찰만 전송 가능
- const canTransmit = selectedBiddings.length === 1 && selectedBiddings[0].status === 'vendor_selected'
-
- const handleExport = async () => {
- try {
- setIsExporting(true)
- await exportTableToExcel(table, {
- filename: "biddings",
- excludeColumns: ["select", "actions"],
- })
- toast.success("입찰 목록이 성공적으로 내보내졌습니다.")
- } catch {
- toast.error("내보내기 중 오류가 발생했습니다.")
- } finally {
- setIsExporting(false)
- }
- }
-
+ const canTransmit = true
+ console.log(canTransmit, 'canTransmit')
+ console.log(selectedBiddings, 'selectedBiddings')
return (
<>
@@ -121,41 +107,6 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
<Send className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">전송하기</span>
</Button>
-
- {/* 개찰 (입찰 오픈) */}
- {/* {openEligibleBiddings.length > 0 && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleBiddingOpen}
- >
- <Gavel className="mr-2 h-4 w-4" />
- 개찰 ({openEligibleBiddings.length})
- </Button>
- )} */}
-
- {/* Export */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={isExporting}
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {isExporting ? "내보내는 중..." : "Export"}
- </span>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={handleExport} disabled={isExporting}>
- <FileSpreadsheet className="mr-2 size-4" />
- <span>입찰 목록 내보내기</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
</div>
{/* 전송 다이얼로그 */}
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 89b6260c..35d57726 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -128,6 +128,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
filterFields,
enablePinning: true,
enableAdvancedFilter: true,
+ enableMultiRowSelection: false,
initialState: {
sorting: [{ id: "createdAt", desc: true }],
columnPinning: { right: ["actions"] },
diff --git a/lib/bidding/list/biddings-transmission-dialog.tsx b/lib/bidding/list/biddings-transmission-dialog.tsx
index de28bf54..7eb7ffd1 100644
--- a/lib/bidding/list/biddings-transmission-dialog.tsx
+++ b/lib/bidding/list/biddings-transmission-dialog.tsx
@@ -92,6 +92,40 @@ export function TransmissionDialog({ open, onOpenChange, bidding, userId }: Tran
if (!bidding) return null
+ // 업체선정이 완료되지 않은 경우 에러 표시
+ if (bidding.status !== 'vendor_selected') {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[400px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2 text-red-600">
+ <Send className="w-5 h-5" />
+ 전송 불가
+ </DialogTitle>
+ <DialogDescription>
+ 업체선정이 완료된 입찰만 전송할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="py-4">
+ <div className="text-center">
+ <p className="text-sm text-muted-foreground">
+ 현재 상태: <span className="font-medium">{bidding.status}</span>
+ </p>
+ <p className="text-xs text-muted-foreground mt-2">
+ 업체선정이 완료된 후 다시 시도해주세요.
+ </p>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button onClick={() => onOpenChange(false)}>
+ 확인
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
const handleToContract = async () => {
try {
setIsLoading(true)
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index ff68e739..2f458873 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -1438,11 +1438,11 @@ export function CreateBiddingDialog() {
name="awardCount"
render={({ field }) => (
<FormItem>
- <FormLabel>낙찰수 <span className="text-red-500">*</span></FormLabel>
+ <FormLabel>낙찰업체 수 <span className="text-red-500">*</span></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="낙찰수 선택" />
+ <SelectValue placeholder="낙찰업체 수 선택" />
</SelectTrigger>
</FormControl>
<SelectContent>
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 19b418ae..81daf506 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -1,7 +1,7 @@
'use server'
import db from '@/db/db'
-import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+import { biddingCompanies, biddingCompaniesContacts, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
import { basicContractTemplates } from '@/db/schema'
import { vendors } from '@/db/schema/vendors'
import { users } from '@/db/schema'
@@ -1565,10 +1565,11 @@ export async function getExistingBasicContractsForBidding(biddingId: number) {
}
}
-// 선정된 업체들 조회 (서버 액션)
+// 입찰 참여 업체들 조회 (벤더와 담당자 정보 포함)
export async function getSelectedVendorsForBidding(biddingId: number) {
try {
- const selectedCompanies = await db
+ // 1. 입찰에 참여하는 모든 업체 조회
+ const companies = await db
.select({
id: biddingCompanies.id,
companyId: biddingCompanies.companyId,
@@ -1579,37 +1580,66 @@ export async function getSelectedVendorsForBidding(biddingId: number) {
contactPerson: biddingCompanies.contactPerson,
contactEmail: biddingCompanies.contactEmail,
biddingId: biddingCompanies.biddingId,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true)
- ))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ // 2. 각 업체의 담당자 정보 조회
+ const vendorsWithContacts = await Promise.all(
+ companies.map(async (company) => {
+ let contacts: any[] = []
+
+ if (company.companyId) {
+ // biddingCompaniesContacts에서 담당자 조회
+ const contactsResult = await db
+ .select({
+ id: biddingCompaniesContacts.id,
+ contactName: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactNumber: biddingCompaniesContacts.contactNumber,
+ })
+ .from(biddingCompaniesContacts)
+ .where(
+ and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, company.companyId)
+ )
+ )
+
+ contacts = contactsResult
+ }
+
+ return {
+ vendorId: company.companyId,
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorEmail: company.companyEmail,
+ vendorCountry: company.companyCountry || '대한민국',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id,
+ biddingId: company.biddingId,
+ isPreQuoteSelected: company.isPreQuoteSelected,
+ ndaYn: true,
+ generalGtcYn: true,
+ projectGtcYn: true,
+ agreementYn: true,
+ contacts: contacts // 담당자 목록 추가
+ }
+ })
+ )
return {
success: true,
- vendors: selectedCompanies.map(company => ({
- vendorId: company.companyId, // 실제 vendor ID
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorEmail: company.companyEmail,
- vendorCountry: company.companyCountry || '대한민국',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id, // biddingCompany ID
- biddingId: company.biddingId,
- ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
- generalGtcYn: true,
- projectGtcYn: true,
- agreementYn: true
- }))
+ vendors: vendorsWithContacts
}
} catch (error) {
- console.error('선정된 업체 조회 실패:', error)
+ console.error('입찰 참여 업체 조회 실패:', error)
return {
success: false,
- error: '선정된 업체 조회에 실패했습니다.',
+ error: '입찰 참여 업체 조회에 실패했습니다.',
vendors: []
}
}
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts
index 16b2c083..0d2a8a75 100644
--- a/lib/bidding/selection/actions.ts
+++ b/lib/bidding/selection/actions.ts
@@ -115,20 +115,17 @@ export async function saveSelectionResult(data: SaveSelectionResultData) {
// 견적 히스토리 조회
export async function getQuotationHistory(biddingId: number, vendorId: number) {
try {
- // biddingCompanies에서 해당 벤더의 스냅샷 데이터 조회
- const companyData = await db
+ // 현재 bidding의 biddingNumber와 originalBiddingNumber 조회
+ const currentBiddingInfo = await db
.select({
- quotationSnapshots: biddingCompanies.quotationSnapshots
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber
})
- .from(biddingCompanies)
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, vendorId)
- ))
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
.limit(1)
- // 데이터 존재 여부 및 유효성 체크
- if (!companyData.length || !companyData[0]?.quotationSnapshots) {
+ if (!currentBiddingInfo.length) {
return {
success: true,
data: {
@@ -137,40 +134,62 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) {
}
}
- let snapshots = companyData[0].quotationSnapshots
+ const baseNumber = currentBiddingInfo[0].originalBiddingNumber || currentBiddingInfo[0].biddingNumber.split('-')[0]
- // quotationSnapshots가 JSONB 타입이므로 파싱이 필요할 수 있음
- if (typeof snapshots === 'string') {
- try {
- snapshots = JSON.parse(snapshots)
- } catch (parseError) {
- console.error('Failed to parse quotationSnapshots:', parseError)
- return {
- success: true,
- data: {
- history: []
- }
- }
+ // 동일한 originalBiddingNumber를 가진 모든 bidding 조회
+ const relatedBiddings = await db
+ .select({
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ targetPrice: biddings.targetPrice,
+ currency: biddings.currency,
+ createdAt: biddings.createdAt
+ })
+ .from(biddings)
+ .where(eq(biddings.originalBiddingNumber, baseNumber))
+ .orderBy(biddings.createdAt)
+
+ // 각 bidding에 대한 벤더의 견적 정보 조회
+ const historyPromises = relatedBiddings.map(async (bidding) => {
+ const biddingCompanyData = await db
+ .select({
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ responseSubmittedAt: biddingCompanies.responseSubmittedAt,
+ isFinalSubmission: biddingCompanies.isFinalSubmission
+ })
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, bidding.id),
+ eq(biddingCompanies.companyId, vendorId)
+ ))
+ .limit(1)
+
+ if (!biddingCompanyData.length || !biddingCompanyData[0].finalQuoteAmount || !biddingCompanyData[0].responseSubmittedAt) {
+ return null
}
- }
- // snapshots가 배열인지 확인
- if (!Array.isArray(snapshots)) {
- console.error('quotationSnapshots is not an array:', typeof snapshots)
return {
- success: true,
- data: {
- history: []
- }
+ biddingId: bidding.id,
+ biddingNumber: bidding.biddingNumber,
+ finalQuoteAmount: biddingCompanyData[0].finalQuoteAmount,
+ responseSubmittedAt: biddingCompanyData[0].responseSubmittedAt,
+ isFinalSubmission: biddingCompanyData[0].isFinalSubmission,
+ targetPrice: bidding.targetPrice,
+ currency: bidding.currency
}
- }
+ })
- // PR 항목 정보 조회 (스냅샷의 prItemId로 매핑하기 위해)
- const prItemIds = snapshots.flatMap(snapshot =>
- snapshot.items?.map((item: any) => item.prItemId) || []
- ).filter((id: number, index: number, arr: number[]) => arr.indexOf(id) === index)
+ const historyData = (await Promise.all(historyPromises)).filter(Boolean)
+
+ // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등)
+ const sortedHistory = historyData.sort((a, b) => {
+ const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0
+ const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0
+ return aSuffix - bSuffix
+ })
- const prItems = prItemIds.length > 0 ? await db
+ // PR 항목 정보 조회 (현재 bidding 기준)
+ const prItems = await db
.select({
id: prItemsForBidding.id,
itemNumber: prItemsForBidding.itemNumber,
@@ -180,53 +199,54 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) {
requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate
})
.from(prItemsForBidding)
- .where(sql`${prItemsForBidding.id} IN ${prItemIds}`) : []
+ .where(eq(prItemsForBidding.biddingId, biddingId))
- // PR 항목을 Map으로 변환하여 빠른 조회를 위해
- const prItemMap = new Map(prItems.map(item => [item.id, item]))
-
- // bidding 정보 조회 (targetPrice, currency)
- const biddingInfo = await db
- .select({
- targetPrice: biddings.targetPrice,
- currency: biddings.currency
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
+ // 각 히스토리 항목에 대한 PR 아이템 견적 조회
+ const history = await Promise.all(sortedHistory.map(async (item, index) => {
+ // 각 bidding에 대한 PR 아이템 견적 조회
+ const prItemBids = await db
+ .select({
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate
+ })
+ .from(companyPrItemBids)
+ .where(and(
+ eq(companyPrItemBids.biddingId, item!.biddingId),
+ eq(companyPrItemBids.companyId, vendorId)
+ ))
- const targetPrice = biddingInfo[0]?.targetPrice ? parseFloat(biddingInfo[0].targetPrice.toString()) : null
- const currency = biddingInfo[0]?.currency || 'KRW'
+ const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null
+ const totalAmount = parseFloat(item!.finalQuoteAmount.toString())
- // 스냅샷 데이터를 변환
- const history = snapshots.map((snapshot: any) => {
const vsTargetPrice = targetPrice && targetPrice > 0
- ? ((snapshot.totalAmount - targetPrice) / targetPrice) * 100
+ ? ((totalAmount - targetPrice) / targetPrice) * 100
: 0
- const items = snapshot.items?.map((item: any) => {
- const prItem = prItemMap.get(item.prItemId)
+ const items = prItemBids.map(bid => {
+ const prItem = prItems.find(p => p.id === bid.prItemId)
return {
- itemCode: prItem?.itemNumber || `ITEM${item.prItemId}`,
+ itemCode: prItem?.itemNumber || `ITEM${bid.prItemId}`,
itemName: prItem?.itemInfo || '품목 정보 없음',
quantity: prItem?.quantity || 0,
unit: prItem?.quantityUnit || 'EA',
- unitPrice: item.bidUnitPrice,
- totalPrice: item.bidAmount,
- deliveryDate: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date()
+ unitPrice: parseFloat(bid.bidUnitPrice.toString()),
+ totalPrice: parseFloat(bid.bidAmount.toString()),
+ deliveryDate: bid.proposedDeliveryDate ? new Date(bid.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date()
}
- }) || []
+ })
return {
- id: snapshot.id,
- round: snapshot.round,
- submittedAt: new Date(snapshot.submittedAt),
- totalAmount: snapshot.totalAmount,
- currency: snapshot.currency || currency,
+ id: item!.biddingId,
+ round: index + 1, // 1차, 2차, 3차...
+ submittedAt: new Date(item!.responseSubmittedAt),
+ totalAmount,
+ currency: item!.currency || 'KRW',
vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)),
items
}
- })
+ }))
return {
success: true,
diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx
index f6f0bc69..8864e7db 100644
--- a/lib/bidding/selection/bidding-info-card.tsx
+++ b/lib/bidding/selection/bidding-info-card.tsx
@@ -65,9 +65,9 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) {
<label className="text-sm font-medium text-muted-foreground">
진행상태
</label>
- <Badge variant="secondary">
+ <div className="text-sm font-medium">
{biddingStatusLabels[bidding.status as keyof typeof biddingStatusLabels] || bidding.status}
- </Badge>
+ </div>
</div>
{/* 입찰담당자 */}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index 8351a0dd..9efa849b 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -221,20 +221,20 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
},
// ░░░ 참여업체수 ░░░
- {
- id: "participantCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여업체수" />,
- cell: ({ row }) => {
- const count = row.original.participantCount || 0
- return (
- <div className="flex items-center gap-1">
- <span className="text-sm font-medium">{count}</span>
- </div>
- )
- },
- size: 100,
- meta: { excelHeader: "참여업체수" },
- },
+ // {
+ // id: "participantCount",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여업체수" />,
+ // cell: ({ row }) => {
+ // const count = row.original.participantCount || 0
+ // return (
+ // <div className="flex items-center gap-1">
+ // <span className="text-sm font-medium">{count}</span>
+ // </div>
+ // )
+ // },
+ // size: 100,
+ // meta: { excelHeader: "참여업체수" },
+ // },
// ═══════════════════════════════════════════════════════════════
@@ -256,24 +256,6 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
<Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- {/* {row.original.status === 'bidding_opened' && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "close_bidding" })}>
- <Calendar className="mr-2 h-4 w-4" />
- 입찰마감
- </DropdownMenuItem>
- </>
- )} */}
- {row.original.status === 'bidding_closed' && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "evaluate_bidding" })}>
- <DollarSign className="mr-2 h-4 w-4" />
- 평가하기
- </DropdownMenuItem>
- </>
- )}
</DropdownMenuContent>
</DropdownMenu>
),
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
index 9545fe09..c3990e7b 100644
--- a/lib/bidding/selection/biddings-selection-table.tsx
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -19,6 +19,7 @@ import {
contractTypeLabels,
} from "@/db/schema"
import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { toast } from "@/hooks/use-toast"
type BiddingSelectionItem = {
id: number
@@ -83,17 +84,16 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps
switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
- break
- case "close_bidding":
- // 입찰마감 (추후 구현)
- console.log('입찰마감:', rowAction.row.original)
- break
- case "evaluate_bidding":
- // 평가하기 (추후 구현)
- console.log('평가하기:', rowAction.row.original)
- break
- default:
+ // 입찰평가중일때만 상세보기 가능
+ if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
+ } else {
+ toast({
+ title: '접근 제한',
+ description: '입찰평가중이 아닙니다.',
+ variant: 'destructive',
+ })
+ }
break
}
}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 14bed105..2474d464 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -865,8 +865,12 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
meetingFiles: File[]
} | null
+ // 첨부파일들 (선택사항)
+ attachments?: File[]
+ vendorAttachments?: File[]
+
// noticeType 필드 명시적 추가 (CreateBiddingSchema에 포함되어 있지만 타입 추론 문제 해결)
- noticeType?: 'standard' | 'facility' | 'unit_price'
+ noticeType: 'standard' | 'facility' | 'unit_price'
// PR 아이템들 (선택사항)
prItems?: Array<{
@@ -1420,10 +1424,80 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
}
}
}
-
+
+ // 4. 첨부파일들 저장 (있는 경우)
+ if (input.attachments && input.attachments.length > 0) {
+ for (const file of input.attachments) {
+ try {
+ const saveResult = await saveFile({
+ file,
+ directory: `biddings/${biddingId}/attachments/shi`,
+ originalName: file.name,
+ userId
+ })
+
+ if (saveResult.success) {
+ await tx.insert(biddingDocuments).values({
+ biddingId,
+ documentType: 'evaluation_doc', // SHI용 문서 타입
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName!,
+ fileSize: saveResult.fileSize!,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!,
+ title: `SHI용 첨부파일 - ${file.name}`,
+ description: 'SHI용 첨부파일',
+ isPublic: true, // 발주처 문서이므로 공개
+ isRequired: false,
+ uploadedBy: userName,
+ })
+ } else {
+ console.error(`Failed to save SHI attachment file: ${file.name}`, saveResult.error)
+ }
+ } catch (error) {
+ console.error(`Error saving SHI attachment file: ${file.name}`, error)
+ }
+ }
+ }
+
+ // Vendor 첨부파일들 저장 (있는 경우)
+ if (input.vendorAttachments && input.vendorAttachments.length > 0) {
+ for (const file of input.vendorAttachments) {
+ try {
+ const saveResult = await saveFile({
+ file,
+ directory: `biddings/${biddingId}/attachments/vendor`,
+ originalName: file.name,
+ userId
+ })
+
+ if (saveResult.success) {
+ await tx.insert(biddingDocuments).values({
+ biddingId,
+ documentType: 'company_proposal', // 협력업체용 문서 타입
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName!,
+ fileSize: saveResult.fileSize!,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!,
+ title: `협력업체용 첨부파일 - ${file.name}`,
+ description: '협력업체용 첨부파일',
+ isPublic: true, // 발주처 문서이므로 공개
+ isRequired: false,
+ uploadedBy: userName,
+ })
+ } else {
+ console.error(`Failed to save vendor attachment file: ${file.name}`, saveResult.error)
+ }
+ } catch (error) {
+ console.error(`Error saving vendor attachment file: ${file.name}`, error)
+ }
+ }
+ }
+
// 캐시 무효화
revalidatePath('/evcp/bid')
-
+
return {
success: true,
message: '입찰이 성공적으로 생성되었습니다.',
@@ -3200,13 +3274,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
}
}
+ //원입찰번호는 -0n 제외하고 저장
+ const originalBiddingNumber = existingBidding.biddingNumber.split('-')[0]
// 3. 새로운 입찰 생성 (기존 정보 복제)
const [newBidding] = await tx
.insert(biddings)
.values({
biddingNumber: newBiddingNumber,
- originalBiddingNumber: existingBidding.biddingNumber, // 원입찰번호 설정
+ originalBiddingNumber: originalBiddingNumber, // 원입찰번호 설정
revision: 0,
biddingSourceType: existingBidding.biddingSourceType,
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 5cf296e1..f70e498e 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -79,7 +79,7 @@ export const createBiddingSchema = z.object({
}),
biddingTypeCustom: z.string().optional(),
awardCount: z.enum(biddings.awardCount.enumValues, {
- required_error: "낙찰수를 선택해주세요"
+ required_error: "낙찰업체 수를 선택해주세요"
}),
// ✅ 가격 정보 (조회용으로 readonly 처리)
diff --git a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
index f5206c71..14d42a46 100644
--- a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
@@ -57,12 +57,13 @@ const documentTypes = [
{ value: 'specification', label: '사양서' },
{ value: 'specification_meeting', label: '사양설명회' },
{ value: 'contract_draft', label: '계약서 초안' },
+ { value: 'company_proposal', label: '협력업체용 첨부파일' },
{ value: 'financial_doc', label: '재무 관련 문서' },
{ value: 'technical_doc', label: '기술 관련 문서' },
{ value: 'certificate', label: '인증서류' },
{ value: 'pr_document', label: 'PR 문서' },
{ value: 'spec_document', label: 'SPEC 문서' },
- { value: 'evaluation_doc', label: '평가 관련 문서' },
+ { value: 'evaluation_doc', label: 'SHI용 첨부파일' },
{ value: 'bid_attachment', label: '입찰 첨부파일' },
{ value: 'other', label: '기타' }
]
@@ -183,7 +184,7 @@ export function PartnersBiddingAttachmentsDialog({
<TableHead>파일명</TableHead>
<TableHead>크기</TableHead>
<TableHead>업로드일</TableHead>
- <TableHead>작성자</TableHead>
+ {/* <TableHead>작성자</TableHead> */}
<TableHead className="w-24">작업</TableHead>
</TableRow>
</TableHeader>
@@ -212,12 +213,12 @@ export function PartnersBiddingAttachmentsDialog({
{new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
</div>
</TableCell>
- <TableCell className="text-sm text-gray-500">
+ {/* <TableCell className="text-sm text-gray-500">
<div className="flex items-center gap-1">
<User className="w-3 h-3" />
{doc.uploadedBy}
</div>
- </TableCell>
+ </TableCell> */}
<TableCell>
<Button
variant="outline"
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 66c90eaf..0215bcb6 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -94,6 +94,7 @@ interface BiddingDetail {
responseSubmittedAt: Date | null
priceAdjustmentResponse: boolean | null // 연동제 적용 여부
isPreQuoteParticipated: boolean | null // 사전견적 참여 여부
+ isPriceAdjustmentApplicableQuestion: boolean // 연동제 적용요건 문의 여부
}
interface PrItem {
@@ -485,7 +486,21 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
// 임시 저장 핸들러
const handleSaveDraft = async () => {
if (!biddingDetail || !userId) return
-
+
+ // 제출 마감일 체크
+ if (biddingDetail.submissionEndDate) {
+ const now = new Date()
+ const deadline = new Date(biddingDetail.submissionEndDate)
+ if (deadline < now) {
+ toast({
+ title: "접근 제한",
+ description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ }
+
// 입찰 마감 상태 체크
const biddingStatus = biddingDetail.status
const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
@@ -606,6 +621,21 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const handleSubmitResponse = () => {
if (!biddingDetail) return
+
+ // 제출 마감일 체크
+ if (biddingDetail.submissionEndDate) {
+ const now = new Date()
+ const deadline = new Date(biddingDetail.submissionEndDate)
+ if (deadline < now) {
+ toast({
+ title: "접근 제한",
+ description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ }
+
// 입찰 마감 상태 체크
const biddingStatus = biddingDetail.status
const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
@@ -661,6 +691,9 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
additionalProposals: responseData.additionalProposals,
isFinalSubmission, // 최종제출 여부 추가
+ // 연동제 데이터 추가 (연동제 적용요건 문의가 있는 경우만)
+ priceAdjustmentResponse: biddingDetail.isPriceAdjustmentApplicableQuestion ? responseData.priceAdjustmentResponse : undefined,
+ priceAdjustmentForm: biddingDetail.isPriceAdjustmentApplicableQuestion && responseData.priceAdjustmentResponse !== null ? priceAdjustmentForm : undefined,
prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({
prItemId: q.prItemId,
bidUnitPrice: q.bidUnitPrice,
@@ -781,7 +814,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div>
</div>
<div>
- <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label>
+ <Label className="text-sm font-medium text-muted-foreground">낙찰업체 수</Label>
<div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : biddingDetail.awardCount === 'multiple' ? '복수' : '미설정'}</div>
</div>
<div>
@@ -816,7 +849,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
{/* 제출 마감일 D-day */}
- {/* {biddingDetail.submissionEndDate && (
+ {biddingDetail.submissionEndDate && (
<div className="pt-4 border-t">
<Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label>
{(() => {
@@ -826,13 +859,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const timeLeft = deadline.getTime() - now.getTime()
const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
-
+
return (
<div className={`p-3 rounded-lg border-2 ${
- isExpired
- ? 'border-red-200 bg-red-50'
- : daysLeft <= 1
- ? 'border-orange-200 bg-orange-50'
+ isExpired
+ ? 'border-red-200 bg-red-50'
+ : daysLeft <= 1
+ ? 'border-orange-200 bg-orange-50'
: 'border-green-200 bg-green-50'
}`}>
<div className="flex items-center justify-between">
@@ -866,7 +899,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
)
})()}
</div>
- )} */}
+ )}
{/* 일정 정보 */}
<div className="pt-4 border-t">
@@ -991,12 +1024,12 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</div>
- <div>
+ {/* <div>
<Label className="text-muted-foreground">연동제 적용</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
<p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
</div>
- </div>
+ </div> */}
<div >
@@ -1110,8 +1143,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
/>
)}
- {/* 연동제 적용 여부 - SHI가 연동제를 요구하고, 사전견적에서 답변하지 않은 경우만 표시 */}
- {biddingConditions?.isPriceAdjustmentApplicable && biddingDetail.priceAdjustmentResponse === null && (
+ {/* 연동제 적용 여부 - 협력업체 별 연동제 적용요건 문의 여부에 따라 표시 */}
+ {biddingDetail.isPriceAdjustmentApplicableQuestion && (
<>
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<Label className="font-semibold text-base">연동제 적용 여부 *</Label>
@@ -1346,28 +1379,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</>
)}
- {/* 사전견적에서 이미 답변한 경우 - 읽기 전용으로 표시 */}
- {biddingDetail.priceAdjustmentResponse !== null && (
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">연동제 적용 정보 (사전견적 제출 완료)</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="p-4 bg-muted/30 rounded-lg">
- <div className="flex items-center gap-2 mb-2">
- <CheckCircle className="w-5 h-5 text-green-600" />
- <span className="font-semibold">
- {biddingDetail.priceAdjustmentResponse ? '연동제 적용' : '연동제 미적용'}
- </span>
- </div>
- <p className="text-sm text-muted-foreground">
- 사전견적에서 이미 연동제 관련 정보를 제출하였습니다. 본입찰에서는 별도의 연동제 정보 입력이 필요하지 않습니다.
- </p>
- </div>
- </CardContent>
- </Card>
- )}
-
{/* 최종제출 체크박스 */}
{!biddingDetail.isFinalSubmission && (
<div className="flex items-center space-x-2 p-4 border rounded-lg bg-muted/30">
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index ba8efae6..ba47ce50 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -119,7 +119,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
// 첨부파일
columnHelper.display({
id: 'attachments',
- header: 'SHI 첨부파일',
+ header: '첨부파일',
cell: ({ row }) => {
const handleViewDocumentsClick = (e: React.MouseEvent) => {
e.stopPropagation()