From b75b1cd920efd61923f7b2dbc4c49987b7b0c4e1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 20 Nov 2025 10:25:41 +0000 Subject: (최겸) 구매 입찰 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/bidding/ProjectSelectorBid.tsx | 183 +++++ components/bidding/bidding-info-header.tsx | 193 ----- .../bidding/create/bidding-create-dialog.tsx | 118 ++-- .../bidding/manage/bidding-basic-info-editor.tsx | 250 ++----- components/bidding/manage/bidding-items-editor.tsx | 74 +- .../bidding/manage/bidding-schedule-editor.tsx | 137 +++- .../selectors/cost-center/cost-center-selector.tsx | 30 +- .../cost-center/cost-center-single-selector.tsx | 785 +++++++++++---------- .../selectors/gl-account/gl-account-selector.tsx | 30 +- .../gl-account/gl-account-single-selector.tsx | 745 +++++++++---------- .../selectors/wbs-code/wbs-code-selector.tsx | 30 +- .../wbs-code/wbs-code-single-selector.tsx | 33 +- 12 files changed, 1367 insertions(+), 1241 deletions(-) create mode 100644 components/bidding/ProjectSelectorBid.tsx delete mode 100644 components/bidding/bidding-info-header.tsx (limited to 'components') 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([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(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 ( + + + + + + + ) : ( +
+ {placeholder} + +
+ )} + +
+ + + + { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + {isLoading ? ( +
로딩 중...
+ ) : filteredProjects.length === 0 ? ( + + {searchTerm + ? "검색 결과가 없습니다" + : `${filterType || '해당 타입의'} 프로젝트가 없습니다`} + + ) : ( + + {filteredProjects.map((project) => ( + handleSelectProject(project)} + > + + {project.projectCode} + - {project.projectName} + {selectedProject?.id === project.id && ( + (선택됨) + )} + + ))} + + )} +
+
+
+
+ ); +} \ 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 ( -
-
입찰 정보를 불러오는 중...
-
- ) - } - - return ( -
- {/* 4개 섹션을 Grid로 배치 */} -
- {/* 1. 프로젝트 및 품목 정보 */} -
-
- - 기본 정보 -
- - {bidding.projectName && ( -
-
프로젝트
-
{bidding.projectName}
-
- )} - - {bidding.itemName && ( -
-
품목
-
{bidding.itemName}
-
- )} - - {bidding.prNumber && ( -
-
PR No.
-
{bidding.prNumber}
-
- )} - - {bidding.purchasingOrganization && ( -
-
구매조직
-
{bidding.purchasingOrganization}
-
- )} -
- - {/* 2. 담당자 및 예산 정보 */} -
-
- - 담당자 정보 -
- - {bidding.bidPicName && ( -
-
입찰담당자
-
- {bidding.bidPicName} - {bidding.bidPicCode && ( - ({bidding.bidPicCode}) - )} -
-
- )} - - {bidding.supplyPicName && ( -
-
조달담당자
-
- {bidding.supplyPicName} - {bidding.supplyPicCode && ( - ({bidding.supplyPicCode}) - )} -
-
- )} - - {bidding.budget && ( -
-
- - 예산 -
-
- {new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: bidding.currency || 'KRW', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(Number(bidding.budget))} -
-
- )} -
- - {/* 3. 계약 정보 */} -
-
- - 계약 정보 -
- -
-
-
계약구분
-
{contractTypeLabels[bidding.contractType]}
-
- -
-
입찰유형
-
{biddingTypeLabels[bidding.biddingType]}
-
- -
-
낙찰수
-
- {bidding.awardCount ? awardCountLabels[bidding.awardCount] : '-'} -
-
- -
-
통화
-
{bidding.currency}
-
-
- - {(bidding.contractStartDate || bidding.contractEndDate) && ( -
-
계약기간
-
- {bidding.contractStartDate && formatDate(bidding.contractStartDate, 'KR')} - {bidding.contractStartDate && bidding.contractEndDate && ' ~ '} - {bidding.contractEndDate && formatDate(bidding.contractEndDate, 'KR')} -
-
- )} -
- - {/* 4. 일정 정보 */} -
-
- - 일정 정보 -
- - {bidding.biddingRegistrationDate && ( -
-
입찰등록일
-
{formatDate(bidding.biddingRegistrationDate, 'KR')}
-
- )} - - {bidding.preQuoteDate && ( -
-
사전견적일
-
{formatDate(bidding.preQuoteDate, 'KR')}
-
- )} - - {bidding.submissionStartDate && bidding.submissionEndDate && ( -
-
제출기간
-
- {formatDate(bidding.submissionStartDate, 'KR')} -
~
- {formatDate(bidding.submissionEndDate, 'KR')} -
-
- )} - - {bidding.evaluationDate && ( -
-
평가일
-
{formatDate(bidding.evaluationDate, 'KR')}
-
- )} -
-
-
- ) -} 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 - {/* 1행: 입찰명, 낙찰수, 입찰유형, 계약구분 */} + {/* 1행: 입찰명, 낙찰업체 수, 입찰유형, 계약구분 */}
( - 낙찰수 * + 낙찰업체 수* @@ -1138,12 +1128,12 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp )} /> - {isLoadingTemplate && ( + {/* {isLoadingTemplate && (
입찰공고 템플릿을 불러오는 중...
- )} + )} */} @@ -1174,14 +1164,16 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp }} > {() => ( - -
- -
- 파일을 드래그하여 업로드 + + +
+ +
+ 파일을 드래그하여 업로드 +
-
- + + )} @@ -1246,14 +1238,16 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp }} > {() => ( - -
- -
- 파일을 드래그하여 업로드 + + +
+ +
+ 파일을 드래그하여 업로드 +
-
- + + )} 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([]) - const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState([]) const [existingDocuments, setExistingDocuments] = React.useState([]) 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
)} - {/* 2행: 예산, 실적가, 내정가, 낙찰수 */} + {/* 2행: 예산, 실적가, 내정가, 낙찰업체 수 */}
( @@ -666,11 +655,11 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB ( - 낙찰수 + 낙찰업체 수 @@ -825,69 +820,8 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB )} /> - - {/* ( - - 입찰서 제출 시작 - - - - - - )} /> - - ( - - 입찰서 제출 마감 - - - - - - )} /> */}
- {/* 5행: 개찰 일시, 사양설명회, PR문서 */} - {/*
- ( - - 개찰 일시 - - - - - - )} /> */} - - {/* ( - -
- 사양설명회 -
- 사양설명회가 필요한 경우 체크 -
-
- - - -
- )} /> */} - - {/* ( - -
- PR 문서 -
- PR 문서가 있는 경우 체크 -
-
- - - -
- )} /> */} - {/*
*/} - {/* 입찰개요 */}
( @@ -902,7 +836,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
{/* 비고 */} -
+ {/*
( 비고 @@ -912,7 +846,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB )} /> -
+
*/} {/* 입찰 조건 */}
@@ -1100,24 +1034,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB }} />
- - {/*
-
- 연동제 적용 가능 -
- 연동제 적용 요건 여부 -
-
- { - setBiddingConditions(prev => ({ - ...prev, - isPriceAdjustmentApplicable: checked - })) - }} - /> -
*/}
{/* 5행: 스페어파트 옵션 */} @@ -1159,12 +1075,12 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
)} /> - {isLoadingTemplate && ( + {/* {isLoadingTemplate && (
입찰공고 템플릿을 불러오는 중...
- )} + )} */}
{/* 액션 버튼 */} @@ -1195,9 +1111,10 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB { + 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 }} > {() => ( - -
- -
- 파일을 드래그하여 업로드 + + +
+ +
+ 파일을 드래그하여 업로드 +
-
- + + )} - {shiAttachmentFiles.length > 0 && ( -
-

업로드 예정 파일

-
- {shiAttachmentFiles.map((file, index) => ( -
-
- -
-

{file.name}

-

- {(file.size / 1024 / 1024).toFixed(2)} MB -

-
-
-
- - -
-
- ))} -
-
- )} {/* 기존 문서 목록 */} {isLoadingDocuments ? ( @@ -1329,9 +1205,10 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB { + 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 }} > {() => ( - -
- -
- 파일을 드래그하여 업로드 + + +
+ +
+ 파일을 드래그하여 업로드 +
-
- + + )} - {vendorAttachmentFiles.length > 0 && ( -
-

업로드 예정 파일

-
- {vendorAttachmentFiles.map((file, index) => ( -
-
- -
-

{file.name}

-

- {(file.size / 1024 / 1024).toFixed(2)} MB -

-
-
-
- - -
-
- ))} -
-
- )} {/* 기존 문서 목록 */} {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 프로젝트코드 프로젝트명 + PR 번호 자재그룹코드 * 자재그룹명 * 자재코드 @@ -580,7 +593,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems 코스트센터명 GL계정코드 GL계정명 - 납품요청일 + 납품요청일 * 액션 @@ -620,6 +633,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems className="h-8 text-xs bg-muted/50" /> + + + {biddingType !== 'equipment' ? ( 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" /> @@ -822,12 +839,10 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems 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" /> @@ -849,12 +864,10 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems 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" /> @@ -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 /> 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
- + handleScheduleChange('submissionStartDate', e.target.value)} + className={!schedule.submissionStartDate ? 'border-red-200' : ''} /> + {!schedule.submissionStartDate && ( +

제출 시작일시는 필수입니다

+ )}
- + handleScheduleChange('submissionEndDate', e.target.value)} + className={!schedule.submissionEndDate ? 'border-red-200' : ''} /> + {!schedule.submissionEndDate && ( +

제출 마감일시는 필수입니다

+ )}
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({
[{selectedCode.KOSTL}] {selectedCode.KTEXT} +
) : ( {placeholder} @@ -284,6 +307,11 @@ export function CostCenterSelector({ )} ))} + {selectedCode && selectedCode.KOSTL === row.original.KOSTL && ( + + (선택됨) + + )} )) ) : ( 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([]) - const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState([]) - const [columnVisibility, setColumnVisibility] = useState({}) - const [rowSelection, setRowSelection] = useState({}) - const [globalFilter, setGlobalFilter] = useState('') - const [isPending, startTransition] = useTransition() - const [tempSelectedCode, setTempSelectedCode] = useState(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[] = useMemo(() => [ - { - accessorKey: 'KOSTL', - header: '코스트센터', - cell: ({ row }) => ( -
{row.getValue('KOSTL')}
- ), - }, - { - accessorKey: 'KTEXT', - header: '단축명', - cell: ({ row }) => ( -
{row.getValue('KTEXT')}
- ), - }, - { - accessorKey: 'LTEXT', - header: '설명', - cell: ({ row }) => ( -
{row.getValue('LTEXT')}
- ), - }, - { - accessorKey: 'DATAB', - header: '시작일', - cell: ({ row }) => ( -
{formatDate(row.getValue('DATAB'))}
- ), - }, - { - accessorKey: 'DATBI', - header: '종료일', - cell: ({ row }) => ( -
{formatDate(row.getValue('DATBI'))}
- ), - }, - { - id: 'actions', - header: '선택', - cell: ({ row }) => { - const isSelected = showConfirmButtons - ? tempSelectedCode?.KOSTL === row.original.KOSTL - : selectedCode?.KOSTL === row.original.KOSTL - - return ( - - ) - }, - }, - ], [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 ( - - - - {title} -
- {description} -
-
- -
- {/* 현재 선택된 코스트센터 표시 */} - {currentSelectedCode && ( -
-
선택된 코스트센터:
-
- [{currentSelectedCode.KOSTL}] - {currentSelectedCode.KTEXT} - - {currentSelectedCode.LTEXT} -
-
- )} - -
- - handleSearchChange(e.target.value)} - className="flex-1" - /> -
- - {isPending ? ( -
-
코스트센터를 불러오는 중...
-
- ) : ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => { - const isRowSelected = currentSelectedCode?.KOSTL === row.original.KOSTL - return ( - handleCodeSelect(row.original)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - ) - }) - ) : ( - - - 검색 결과가 없습니다. - - - )} - -
-
- )} - -
-
- 총 {table.getFilteredRowModel().rows.length}개 코스트센터 -
-
- -
- {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} -
- -
-
-
- - {showConfirmButtons && ( - - - - - )} -
-
- ) -} - +'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([]) + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [isPending, startTransition] = useTransition() + const [tempSelectedCode, setTempSelectedCode] = useState(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[] = useMemo(() => [ + { + accessorKey: 'KOSTL', + header: '코스트센터', + cell: ({ row }) => ( +
{row.getValue('KOSTL')}
+ ), + }, + { + accessorKey: 'KTEXT', + header: '단축명', + cell: ({ row }) => ( +
{row.getValue('KTEXT')}
+ ), + }, + { + accessorKey: 'LTEXT', + header: '설명', + cell: ({ row }) => ( +
{row.getValue('LTEXT')}
+ ), + }, + { + accessorKey: 'DATAB', + header: '시작일', + cell: ({ row }) => ( +
{formatDate(row.getValue('DATAB'))}
+ ), + }, + { + accessorKey: 'DATBI', + header: '종료일', + cell: ({ row }) => ( +
{formatDate(row.getValue('DATBI'))}
+ ), + }, + { + id: 'actions', + header: '선택', + cell: ({ row }) => { + const isSelected = showConfirmButtons + ? tempSelectedCode?.KOSTL === row.original.KOSTL + : selectedCode?.KOSTL === row.original.KOSTL + + return ( + + ) + }, + }, + ], [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 ( + + + + {title} +
+ {description} +
+
+ +
+ {/* 현재 선택된 코스트센터 표시 */} + {currentSelectedCode && ( +
+
+ 선택된 코스트센터: + +
+
+ [{currentSelectedCode.KOSTL}] + {currentSelectedCode.KTEXT} + - {currentSelectedCode.LTEXT} +
+
+ )} + +
+ + handleSearchChange(e.target.value)} + className="flex-1" + /> +
+ + {isPending ? ( +
+
코스트센터를 불러오는 중...
+
+ ) : ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const isRowSelected = currentSelectedCode?.KOSTL === row.original.KOSTL + return ( + handleCodeSelect(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ) + }) + ) : ( + + + 검색 결과가 없습니다. + + + )} + +
+
+ )} + +
+
+ 총 {table.getFilteredRowModel().rows.length}개 코스트센터 +
+
+ +
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} +
+ +
+
+
+ + {showConfirmButtons && ( + + + + + )} +
+
+ ) +} + 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({ [{selectedCode.SAKNR}] {selectedCode.FIPEX} {selectedCode.TEXT1} +
) : ( {placeholder} @@ -261,6 +284,11 @@ export function GlAccountSelector({ )} ))} + {selectedCode && selectedCode.SAKNR === row.original.SAKNR && selectedCode.FIPEX === row.original.FIPEX && ( + + (선택됨) + + )} )) ) : ( 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([]) - const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState([]) - const [columnVisibility, setColumnVisibility] = useState({}) - const [rowSelection, setRowSelection] = useState({}) - const [globalFilter, setGlobalFilter] = useState('') - const [isPending, startTransition] = useTransition() - const [tempSelectedCode, setTempSelectedCode] = useState(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[] = useMemo(() => [ - { - accessorKey: 'SAKNR', - header: '계정(G/L)', - cell: ({ row }) => ( -
{row.getValue('SAKNR')}
- ), - }, - { - accessorKey: 'FIPEX', - header: '세부계정', - cell: ({ row }) => ( -
{row.getValue('FIPEX')}
- ), - }, - { - accessorKey: 'TEXT1', - header: '계정명', - cell: ({ row }) => ( -
{row.getValue('TEXT1')}
- ), - }, - { - id: 'actions', - header: '선택', - cell: ({ row }) => { - const isSelected = showConfirmButtons - ? tempSelectedCode?.SAKNR === row.original.SAKNR - : selectedCode?.SAKNR === row.original.SAKNR - - return ( - - ) - }, - }, - ], [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 ( - - - - {title} -
- {description} -
-
- -
- {/* 현재 선택된 GL 계정 표시 */} - {currentSelectedCode && ( -
-
선택된 GL 계정:
-
- [{currentSelectedCode.SAKNR}] - {currentSelectedCode.FIPEX} - - {currentSelectedCode.TEXT1} -
-
- )} - -
- - handleSearchChange(e.target.value)} - className="flex-1" - /> -
- - {isPending ? ( -
-
GL 계정을 불러오는 중...
-
- ) : ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => { - const isRowSelected = currentSelectedCode?.SAKNR === row.original.SAKNR - return ( - handleCodeSelect(row.original)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - ) - }) - ) : ( - - - 검색 결과가 없습니다. - - - )} - -
-
- )} - -
-
- 총 {table.getFilteredRowModel().rows.length}개 GL 계정 -
-
- -
- {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} -
- -
-
-
- - {showConfirmButtons && ( - - - - - )} -
-
- ) -} - +'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([]) + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [isPending, startTransition] = useTransition() + const [tempSelectedCode, setTempSelectedCode] = useState(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[] = useMemo(() => [ + { + accessorKey: 'SAKNR', + header: '계정(G/L)', + cell: ({ row }) => ( +
{row.getValue('SAKNR')}
+ ), + }, + { + accessorKey: 'FIPEX', + header: '세부계정', + cell: ({ row }) => ( +
{row.getValue('FIPEX')}
+ ), + }, + { + accessorKey: 'TEXT1', + header: '계정명', + cell: ({ row }) => ( +
{row.getValue('TEXT1')}
+ ), + }, + { + id: 'actions', + header: '선택', + cell: ({ row }) => { + const isSelected = showConfirmButtons + ? tempSelectedCode?.SAKNR === row.original.SAKNR + : selectedCode?.SAKNR === row.original.SAKNR + + return ( + + ) + }, + }, + ], [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 ( + + + + {title} +
+ {description} +
+
+ +
+ {/* 현재 선택된 GL 계정 표시 */} + {currentSelectedCode && ( +
+
+ 선택된 GL 계정: + +
+
+ [{currentSelectedCode.SAKNR}] + {currentSelectedCode.FIPEX} + - {currentSelectedCode.TEXT1} +
+
+ )} + +
+ + handleSearchChange(e.target.value)} + className="flex-1" + /> +
+ + {isPending ? ( +
+
GL 계정을 불러오는 중...
+
+ ) : ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const isRowSelected = currentSelectedCode?.SAKNR === row.original.SAKNR + return ( + handleCodeSelect(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ) + }) + ) : ( + + + 검색 결과가 없습니다. + + + )} + +
+
+ )} + +
+
+ 총 {table.getFilteredRowModel().rows.length}개 GL 계정 +
+
+ +
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} +
+ +
+
+
+ + {showConfirmButtons && ( + + + + + )} +
+
+ ) +} + 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({ [{selectedCode.PROJ_NO}] {selectedCode.WBS_ELMT} {selectedCode.WBS_ELMT_NM} + ) : ( {placeholder} @@ -273,6 +296,11 @@ export function WbsCodeSelector({ )} ))} + {selectedCode && selectedCode.PROJ_NO === row.original.PROJ_NO && selectedCode.WBS_ELMT === row.original.WBS_ELMT && ( + + (선택됨) + + )} )) ) : ( 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 && (
-
선택된 WBS 코드:
+
+ 선택된 WBS 코드: + +
[{currentSelectedCode.PROJ_NO}] {currentSelectedCode.WBS_ELMT} -- cgit v1.2.3