summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/bidding/ProjectSelectorBid.tsx183
-rw-r--r--components/bidding/bidding-info-header.tsx193
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx118
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx250
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx74
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx137
-rw-r--r--components/common/selectors/cost-center/cost-center-selector.tsx30
-rw-r--r--components/common/selectors/cost-center/cost-center-single-selector.tsx785
-rw-r--r--components/common/selectors/gl-account/gl-account-selector.tsx30
-rw-r--r--components/common/selectors/gl-account/gl-account-single-selector.tsx745
-rw-r--r--components/common/selectors/wbs-code/wbs-code-selector.tsx30
-rw-r--r--components/common/selectors/wbs-code/wbs-code-single-selector.tsx33
12 files changed, 1367 insertions, 1241 deletions
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>