diff options
Diffstat (limited to 'components')
5 files changed, 834 insertions, 15 deletions
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index 6634f528..4c3e6bbc 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Building, User, Plus, Trash2 } from 'lucide-react' +import { Building, User, Plus, Trash2, Users } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -11,7 +11,9 @@ import { createBiddingCompanyContact, deleteBiddingCompanyContact, getVendorContactsByVendorId, - updateBiddingCompanyPriceAdjustmentQuestion + updateBiddingCompanyPriceAdjustmentQuestion, + getBiddingCompaniesByBidPicId, + addBiddingCompanyFromOtherBidding } from '@/lib/bidding/service' import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' @@ -36,6 +38,7 @@ import { } from '@/components/ui/table' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector' interface QuotationVendor { id: number // biddingCompanies.id @@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false) const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null) + // 협력사 멀티 선택 다이얼로그 + const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false) + const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined) + const [biddingCompaniesList, setBiddingCompaniesList] = React.useState<Array<{ + biddingId: number + biddingNumber: string + biddingTitle: string + companyId: number + vendorCode: string + vendorName: string + updatedAt: Date + }>>([]) + const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false) + const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{ + biddingId: number + companyId: number + } | null>(null) + const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([]) + const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false) + // 업체 목록 다시 로딩 함수 const reloadVendors = React.useCallback(async () => { try { @@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </p> </div> {!readonly && ( - <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}> - <Plus className="h-4 w-4" /> - 업체 추가 - </Button> + <div className="flex gap-2"> + <Button onClick={() => setMultiSelectDialogOpen(true)} className="flex items-center gap-2" disabled={readonly} variant="outline"> + <Users className="h-4 w-4" /> + 협력사 멀티 선택 + </Button> + <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}> + <Plus className="h-4 w-4" /> + 업체 추가 + </Button> + </div> )} </CardHeader> <CardContent> @@ -740,6 +769,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </DialogContent> </Dialog> + {/* 협력사 멀티 선택 다이얼로그 */} + <Dialog open={multiSelectDialogOpen} onOpenChange={setMultiSelectDialogOpen}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>참여협력사 선택</DialogTitle> + <DialogDescription> + 입찰담당자를 선택하여 해당 담당자의 입찰 업체를 조회하고 선택할 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 입찰담당자 선택 */} + <div className="space-y-2"> + <Label>입찰담당자 선택</Label> + <PurchaseGroupCodeSelector + selectedCode={selectedBidPic} + onCodeSelect={async (code) => { + setSelectedBidPic(code) + if (code.user?.id) { + setIsLoadingBiddingCompanies(true) + try { + const result = await getBiddingCompaniesByBidPicId(code.user.id) + if (result.success && result.data) { + setBiddingCompaniesList(result.data) + } else { + toast.error(result.error || '입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } + } catch (error) { + console.error('Failed to load bidding companies:', error) + toast.error('입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } finally { + setIsLoadingBiddingCompanies(false) + } + } + }} + placeholder="입찰담당자 선택" + disabled={readonly} + /> + </div> + + {/* 입찰 업체 목록 */} + {isLoadingBiddingCompanies ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">입찰 업체를 불러오는 중...</span> + </div> + ) : biddingCompaniesList.length === 0 && selectedBidPic ? ( + <div className="text-center py-8 text-muted-foreground"> + 해당 입찰담당자의 입찰 업체가 없습니다. + </div> + ) : biddingCompaniesList.length > 0 ? ( + <div className="space-y-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">선택</TableHead> + <TableHead>입찰번호</TableHead> + <TableHead>입찰명</TableHead> + <TableHead>협력사코드</TableHead> + <TableHead>협력사명</TableHead> + <TableHead>입찰 업데이트일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {biddingCompaniesList.map((company) => { + const isSelected = selectedBiddingCompany?.biddingId === company.biddingId && + selectedBiddingCompany?.companyId === company.companyId + return ( + <TableRow + key={`${company.biddingId}-${company.companyId}`} + className={`cursor-pointer hover:bg-muted/50 ${ + isSelected ? 'bg-muted/50' : '' + }`} + onClick={async () => { + if (isSelected) { + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + return + } + setSelectedBiddingCompany({ + biddingId: company.biddingId, + companyId: company.companyId + }) + setIsLoadingCompanyContacts(true) + try { + const contactsResult = await getBiddingCompanyContacts(company.biddingId, company.companyId) + if (contactsResult.success && contactsResult.data) { + setSelectedBiddingCompanyContacts(contactsResult.data) + } else { + setSelectedBiddingCompanyContacts([]) + } + } catch (error) { + console.error('Failed to load company contacts:', error) + setSelectedBiddingCompanyContacts([]) + } finally { + setIsLoadingCompanyContacts(false) + } + }} + > + <TableCell onClick={(e) => e.stopPropagation()}> + <Checkbox + checked={isSelected} + onCheckedChange={() => { + // 클릭 이벤트는 TableRow의 onClick에서 처리 + }} + disabled={readonly} + /> + </TableCell> + <TableCell className="font-medium">{company.biddingNumber}</TableCell> + <TableCell>{company.biddingTitle}</TableCell> + <TableCell>{company.vendorCode}</TableCell> + <TableCell>{company.vendorName}</TableCell> + <TableCell> + {company.updatedAt ? new Date(company.updatedAt).toLocaleDateString('ko-KR') : '-'} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + + {/* 선택한 입찰 업체의 담당자 정보 */} + {selectedBiddingCompany !== null && ( + <div className="mt-4 p-4 border rounded-lg"> + <h4 className="font-medium mb-2">담당자 정보</h4> + {isLoadingCompanyContacts ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">담당자 정보를 불러오는 중...</span> + </div> + ) : selectedBiddingCompanyContacts.length === 0 ? ( + <div className="text-sm text-muted-foreground">등록된 담당자가 없습니다.</div> + ) : ( + <div className="space-y-2"> + {selectedBiddingCompanyContacts.map((contact) => ( + <div key={contact.id} className="text-sm"> + <span className="font-medium">{contact.contactName}</span> + <span className="text-muted-foreground ml-2">{contact.contactEmail}</span> + {contact.contactNumber && ( + <span className="text-muted-foreground ml-2">{contact.contactNumber}</span> + )} + </div> + ))} + </div> + )} + </div> + )} + </div> + ) : null} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setMultiSelectDialogOpen(false) + setSelectedBidPic(undefined) + setBiddingCompaniesList([]) + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + }} + > + 취소 + </Button> + <Button + onClick={async () => { + if (!selectedBiddingCompany) { + toast.error('입찰 업체를 선택해주세요.') + return + } + + const selectedCompany = biddingCompaniesList.find( + c => c.biddingId === selectedBiddingCompany.biddingId && + c.companyId === selectedBiddingCompany.companyId + ) + + if (!selectedCompany) { + toast.error('선택한 입찰 업체 정보를 찾을 수 없습니다.') + return + } + + try { + const contacts = selectedBiddingCompanyContacts.map(c => ({ + contactName: c.contactName, + contactEmail: c.contactEmail, + contactNumber: c.contactNumber || undefined, + })) + + const result = await addBiddingCompanyFromOtherBidding( + biddingId, + selectedCompany.biddingId, + selectedCompany.companyId, + contacts.length > 0 ? contacts : undefined + ) + + if (result.success) { + toast.success('업체가 성공적으로 추가되었습니다.') + setMultiSelectDialogOpen(false) + setSelectedBidPic(undefined) + setBiddingCompaniesList([]) + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + await reloadVendors() + } else { + toast.error(result.error || '업체 추가에 실패했습니다.') + } + } catch (error) { + console.error('Failed to add bidding company:', error) + toast.error('업체 추가에 실패했습니다.') + } + }} + disabled={!selectedBiddingCompany || readonly} + > + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + {/* 벤더 담당자에서 추가 다이얼로그 */} <Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}> <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 90e512d2..452cdc3c 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react' +import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react' import { getPRItemsForBidding } from '@/lib/bidding/detail/service' import { updatePrItem } from '@/lib/bidding/detail/service' import { toast } from 'sonner' @@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' // PR 아이템 정보 타입 (create-bidding-dialog와 동일) -interface PRItemInfo { +export interface PRItemInfo { id: number // 실제 DB ID prNumber?: string | null projectId?: number | null @@ -84,6 +84,16 @@ import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog' import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' +import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel' +import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) { const { data: session } = useSession() @@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems isPriceAdjustmentApplicable?: boolean | null sparePartOptions?: string | null } | null>(null) + const [importDialogOpen, setImportDialogOpen] = React.useState(false) + const [importFile, setImportFile] = React.useState<File | null>(null) + const [importErrors, setImportErrors] = React.useState<string[]>([]) + const [isImporting, setIsImporting] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드 React.useEffect(() => { @@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: 1, + priceUnit: '1', purchaseUnit: 'EA', materialWeight: null, wbsCode: null, @@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const totals = calculateTotals() + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + if (items.length === 0) { + toast.error('내보낼 품목이 없습니다.') + return + } + + try { + setIsExporting(true) + await exportBiddingItemsToExcel(items, { + filename: `입찰품목목록_${biddingId}`, + }) + toast.success('Excel 파일이 다운로드되었습니다.') + } catch (error) { + console.error('Excel export error:', error) + toast.error('Excel 내보내기 중 오류가 발생했습니다.') + } finally { + setIsExporting(false) + } + }, [items, biddingId]) + + // Excel 가져오기 핸들러 + const handleImportFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + setImportFile(file) + setImportErrors([]) + } + } + + const handleImport = async () => { + if (!importFile) return + + setIsImporting(true) + setImportErrors([]) + + try { + const result = await importBiddingItemsFromExcel(importFile) + + if (result.errors.length > 0) { + setImportErrors(result.errors) + toast.warning( + `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.` + ) + return + } + + if (result.items.length === 0) { + toast.error('가져올 품목이 없습니다.') + return + } + + // 기존 아이템에 추가 + setItems((prev) => [...prev, ...result.items]) + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + toast.success(`${result.items.length}개의 품목이 추가되었습니다.`) + } catch (error) { + console.error('Excel import error:', error) + toast.error('Excel 가져오기 중 오류가 발생했습니다.') + } finally { + setIsImporting(false) + } + } + if (isLoading) { return ( <div className="flex items-center justify-center p-8"> @@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <FileText className="h-4 w-4" /> 사전견적 </Button> + <Button onClick={handleExport} variant="outline" className="flex items-center gap-2" disabled={readonly || isExporting || items.length === 0}> + <FileSpreadsheet className="h-4 w-4" /> + {isExporting ? "내보내는 중..." : "Excel 내보내기"} + </Button> + <Button onClick={() => setImportDialogOpen(true)} variant="outline" className="flex items-center gap-2" disabled={readonly}> + <Upload className="h-4 w-4" /> + Excel 가져오기 + </Button> <Button onClick={handleAddItem} className="flex items-center gap-2" disabled={readonly}> <Plus className="h-4 w-4" /> 품목 추가 @@ -1492,6 +1585,88 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems toast.success('사전견적용 일반견적이 생성되었습니다') }} /> + + {/* Excel 가져오기 다이얼로그 */} + <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Excel 가져오기</DialogTitle> + <DialogDescription> + Excel 파일을 업로드하여 품목을 일괄 추가합니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-4"> + <div> + <Label htmlFor="import-file">Excel 파일 선택</Label> + <Input + id="import-file" + type="file" + accept=".xlsx,.xls" + onChange={handleImportFileSelect} + className="mt-2" + disabled={isImporting} + /> + {importFile && ( + <p className="text-sm text-muted-foreground mt-2"> + 선택된 파일: {importFile.name} + </p> + )} + </div> + + {importErrors.length > 0 && ( + <div className="space-y-2"> + <Label className="text-destructive">오류 목록</Label> + <div className="max-h-60 overflow-y-auto border rounded-md p-3 bg-destructive/5"> + <ul className="list-disc list-inside space-y-1"> + {importErrors.map((error, index) => ( + <li key={index} className="text-sm text-destructive"> + {error} + </li> + ))} + </ul> + </div> + </div> + )} + + <div className="text-sm text-muted-foreground space-y-1"> + <p className="font-semibold">필수 컬럼:</p> + <ul className="list-disc list-inside ml-2"> + <li>자재그룹코드, 자재그룹명</li> + <li>수량 또는 중량 (둘 중 하나 필수)</li> + <li>수량단위 또는 중량단위</li> + <li>납품요청일 (YYYY-MM-DD 형식)</li> + <li>내정단가</li> + </ul> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + }} + disabled={isImporting} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!importFile || isImporting} + > + {isImporting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 가져오는 중... + </> + ) : ( + "가져오기" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ) diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index de3c19ff..1ab7a40f 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -465,7 +465,7 @@ export function CreatePreQuoteRfqDialog({ )}
>
{field.value ? (
- format(field.value, "yyyy-MM-dd")
+ format(field.value, "yyyy-MM-dd HH:mm")
) : (
<span>제출마감일을 선택하세요 (선택)</span>
)}
@@ -477,12 +477,40 @@ export function CreatePreQuoteRfqDialog({ <Calendar
mode="single"
selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
+ onSelect={(date) => {
+ if (!date) {
+ field.onChange(undefined)
+ return
+ }
+ const newDate = new Date(date)
+ if (field.value) {
+ newDate.setHours(field.value.getHours(), field.value.getMinutes())
+ } else {
+ newDate.setHours(0, 0, 0, 0)
+ }
+ field.onChange(newDate)
+ }}
+ disabled={(date) => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ return date < today || date < new Date("1900-01-01")
+ }}
initialFocus
/>
+ <div className="p-3 border-t border-border">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value) {
+ const [hours, minutes] = e.target.value.split(':').map(Number)
+ const newDate = new Date(field.value)
+ newDate.setHours(hours, minutes)
+ field.onChange(newDate)
+ }
+ }}
+ />
+ </div>
</PopoverContent>
</Popover>
<FormMessage />
diff --git a/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx new file mode 100644 index 00000000..aeefbb84 --- /dev/null +++ b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { Loader, Database, Check } from "lucide-react" +import { toast } from "sonner" +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + getFilteredRowModel, + ColumnDef, + flexRender, +} from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" + +import { getCPVWWabQustListViewData, CPVWWabQustListView } from "@/lib/basic-contract/cpvw-service" + +interface CPVWWabQustListViewDialogProps { + onConfirm?: (selectedRows: CPVWWabQustListView[]) => void + requireSingleSelection?: boolean + triggerDisabled?: boolean + triggerTitle?: string +} + +export function CPVWWabQustListViewDialog({ + onConfirm, + requireSingleSelection = false, + triggerDisabled = false, + triggerTitle, +}: CPVWWabQustListViewDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [data, setData] = React.useState<CPVWWabQustListView[]>([]) + const [error, setError] = React.useState<string | null>(null) + const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({}) + + const loadData = async () => { + setIsLoading(true) + setError(null) + try { + const result = await getCPVWWabQustListViewData() + if (result.success) { + setData(result.data) + if (result.isUsingFallback) { + toast.info("테스트 데이터를 표시합니다.") + } + } else { + setError(result.error || "데이터 로딩 실패") + toast.error(result.error || "데이터 로딩 실패") + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류" + setError(errorMessage) + toast.error(errorMessage) + } finally { + setIsLoading(false) + } + } + + React.useEffect(() => { + if (open) { + loadData() + } else { + // 다이얼로그 닫힐 때 데이터 초기화 + setData([]) + setError(null) + setRowSelection({}) + } + }, [open]) + + // 테이블 컬럼 정의 (동적 생성) + const columns = React.useMemo<ColumnDef<CPVWWabQustListView>[]>(() => { + if (data.length === 0) return [] + + const dataKeys = Object.keys(data[0]) + + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모든 행 선택" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ...dataKeys.map((key) => ({ + accessorKey: key, + header: key, + cell: ({ getValue }: any) => { + const value = getValue() + return value !== null && value !== undefined ? String(value) : "" + }, + })), + ] + }, [data]) + + // 테이블 인스턴스 생성 + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + }) + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original) + + // 확인 버튼 핸들러 + const handleConfirm = () => { + if (selectedRows.length === 0) { + toast.error("행을 선택해주세요.") + return + } + + if (requireSingleSelection && selectedRows.length !== 1) { + toast.error("하나의 행만 선택해주세요.") + return + } + + if (onConfirm) { + onConfirm(selectedRows) + toast.success( + requireSingleSelection + ? "선택한 행으로 준법문의 상태를 동기화합니다." + : `${selectedRows.length}개의 행을 선택했습니다.` + ) + } else { + // 임시로 선택된 데이터 콘솔 출력 + console.log("선택된 행들:", selectedRows) + toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`) + } + + setOpen(false) + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + disabled={triggerDisabled} + title={triggerTitle} + > + <Database className="mr-2 size-4" aria-hidden="true" /> + 준법문의 요청 데이터 조회 + </Button> + </DialogTrigger> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col overflow-hidden"> + <DialogHeader> + <DialogTitle>준법문의 요청 데이터</DialogTitle> + <DialogDescription> + 준법문의 요청 데이터를 조회합니다. + {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`} + </DialogDescription> + </DialogHeader> + + <div className="flex flex-col flex-1 min-h-0"> + {isLoading ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px]"> + <Loader className="mr-2 size-6 animate-spin" /> + <span>데이터 로딩 중...</span> + </div> + ) : error ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px] text-red-500"> + <span>오류: {error}</span> + </div> + ) : data.length === 0 ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px] text-muted-foreground"> + <span>데이터가 없습니다.</span> + </div> + ) : ( + <div className="flex flex-col flex-1 min-h-0"> + {/* 테이블 영역 - 스크롤 가능 */} + <ScrollArea className="flex-1 overflow-auto border rounded-md"> + <Table className="min-w-full"> + <TableHeader className="sticky top-0 bg-background z-10"> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id} className="font-medium bg-background"> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id} className="text-sm"> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="h-24 text-center" + > + 데이터가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + + {/* 페이지네이션 컨트롤 - 고정 영역 */} + <div className="flex items-center justify-between px-2 py-4 border-t bg-background flex-shrink-0"> + <div className="flex-1 text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨 + </div> + <div className="flex items-center space-x-6 lg:space-x-8"> + <div className="flex items-center space-x-2"> + <p className="text-sm font-medium">페이지당 행 수</p> + <select + value={table.getState().pagination.pageSize} + onChange={(e) => { + table.setPageSize(Number(e.target.value)) + }} + className="h-8 w-[70px] rounded border border-input bg-transparent px-3 py-1 text-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2" + > + {[10, 20, 30, 40, 50].map((pageSize) => ( + <option key={pageSize} value={pageSize}> + {pageSize} + </option> + ))} + </select> + </div> + <div className="flex w-[100px] items-center justify-center text-sm font-medium"> + {table.getState().pagination.pageIndex + 1} /{" "} + {table.getPageCount()} + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">첫 페이지로</span> + {"<<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">이전 페이지</span> + {"<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">다음 페이지</span> + {">"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">마지막 페이지로</span> + {">>"} + </Button> + </div> + </div> + </div> + </div> + )} + </div> + + <DialogFooter className="gap-2 flex-shrink-0"> + <Button variant="outline" onClick={() => setOpen(false)}> + 닫기 + </Button> + <Button onClick={loadData} disabled={isLoading} variant="outline"> + {isLoading ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + 로딩 중... + </> + ) : ( + "새로고침" + )} + </Button> + <Button + onClick={handleConfirm} + disabled={ + requireSingleSelection + ? selectedRows.length !== 1 + : selectedRows.length === 0 + } + > + <Check className="mr-2 size-4" /> + 확인 ({selectedRows.length}) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx index 84fd85ff..a1b98468 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -23,6 +23,7 @@ export interface ProcurementItemSelectorDialogSingleProps { title?: string; description?: string; showConfirmButtons?: boolean; + disabled?: boolean; } /** @@ -78,6 +79,7 @@ export function ProcurementItemSelectorDialogSingle({ title = "1회성 품목 선택", description = "1회성 품목을 검색하고 선택해주세요.", showConfirmButtons = false, + disabled = false, }: ProcurementItemSelectorDialogSingleProps) { const [open, setOpen] = useState(false); const [tempSelectedProcurementItem, setTempSelectedProcurementItem] = @@ -128,7 +130,7 @@ export function ProcurementItemSelectorDialogSingle({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant={triggerVariant} size={triggerSize}> + <Button variant={triggerVariant} size={triggerSize} disabled={disabled}> {selectedProcurementItem ? ( <span className="truncate"> {`${selectedProcurementItem.itemCode}`} |
