diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-20 10:25:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-20 10:25:41 +0000 |
| commit | b75b1cd920efd61923f7b2dbc4c49987b7b0c4e1 (patch) | |
| tree | 9e4195e697df6df21b5896b0d33acc97d698b4a7 | |
| parent | 4df8d72b79140919c14df103b45bbc8b1afa37c2 (diff) | |
(최겸) 구매 입찰 수정
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() |
