diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-22 12:17:48 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-22 12:17:48 +0900 |
| commit | bd3df05b4bdc07cef1bd79cf23c08a757e9ee6eb (patch) | |
| tree | aa726fc9dce49e3d346f0fdde282b6726ad1d815 /components/common/project/unified-project-selector.tsx | |
| parent | 087fc383a662d45a69b5971a6ad821209bcbaf5b (diff) | |
(김준회) AVL 수정요구 처리
- 제목추가
- 프로젝트선택기
Diffstat (limited to 'components/common/project/unified-project-selector.tsx')
| -rw-r--r-- | components/common/project/unified-project-selector.tsx | 318 |
1 files changed, 318 insertions, 0 deletions
diff --git a/components/common/project/unified-project-selector.tsx b/components/common/project/unified-project-selector.tsx new file mode 100644 index 00000000..b997ff2f --- /dev/null +++ b/components/common/project/unified-project-selector.tsx @@ -0,0 +1,318 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } 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 { Badge } from '@/components/ui/badge' +import { Search, Check } 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 { searchUnifiedProjects, UnifiedProject, ProjectSearchOptions } from './project-service' +import { toast } from 'sonner' + +export interface UnifiedProjectSelectorProps { + selectedProject?: UnifiedProject + onProjectSelect: (project: UnifiedProject) => void + disabled?: boolean + searchOptions?: Partial<ProjectSearchOptions> + placeholder?: string + className?: string +} + +export function UnifiedProjectSelector({ + selectedProject, + onProjectSelect, + disabled, + searchOptions = {}, + placeholder = "프로젝트를 선택하세요", + className +}: UnifiedProjectSelectorProps) { + const [open, setOpen] = useState(false) + const [projects, setProjects] = useState<UnifiedProject[]>([]) + const [loading, setLoading] = useState(false) + 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 finalSearchOptions: ProjectSearchOptions = useMemo(() => ({ + searchFrom: 'both', + limit: 100, + ...searchOptions + }), [searchOptions]) + + // 테이블 컬럼 정의 + const columns: ColumnDef<UnifiedProject>[] = useMemo(() => [ + { + accessorKey: 'code', + header: '프로젝트 코드', + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.getValue('code')}</div> + ), + }, + { + accessorKey: 'name', + header: '프로젝트명', + cell: ({ row }) => ( + <div className="max-w-[200px] truncate">{row.getValue('name')}</div> + ), + }, + { + accessorKey: 'type', + header: '타입', + cell: ({ row }) => ( + <Badge variant="outline">{row.getValue('type')}</Badge> + ), + }, + { + accessorKey: 'source', + header: '출처', + cell: ({ row }) => { + const source = row.getValue('source') as string + return ( + <Badge variant={source === 'projects' ? 'default' : 'secondary'}> + {source === 'projects' ? '프로젝트' : '견적프로젝트'} + </Badge> + ) + }, + }, + { + id: 'actions', + header: '선택', + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={(e) => { + e.stopPropagation() + handleProjectSelect(row.original) + }} + > + <Check className="h-4 w-4" /> + </Button> + ), + }, + ], []) + + // 프로젝트 테이블 설정 + const table = useReactTable({ + data: projects, + 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, + }, + }) + + // 프로젝트 목록 로드 + const loadProjects = useCallback(async (searchTerm?: string) => { + setLoading(true) + try { + const result = await searchUnifiedProjects({ + ...finalSearchOptions, + searchTerm: searchTerm || globalFilter + }) + + if (result.success) { + setProjects(result.data) + } else { + toast.error(result.error || '프로젝트를 불러오는데 실패했습니다.') + setProjects([]) + } + } catch (error) { + console.error('프로젝트 목록 로드 실패:', error) + toast.error('프로젝트를 불러오는 중 오류가 발생했습니다.') + setProjects([]) + } finally { + setLoading(false) + } + }, [finalSearchOptions, globalFilter]) + + // 프로젝트 선택 핸들러 + const handleProjectSelect = useCallback((project: UnifiedProject) => { + onProjectSelect(project) + setOpen(false) + }, [onProjectSelect]) + + // 다이얼로그 열릴 때 프로젝트 목록 로드 + useEffect(() => { + if (open && projects.length === 0) { + loadProjects() + } + }, [open, projects.length, loadProjects]) + + // 검색어 변경 시 디바운스된 검색 + useEffect(() => { + if (!open) return + + const timeoutId = setTimeout(() => { + loadProjects(globalFilter) + }, 300) + + return () => clearTimeout(timeoutId) + }, [globalFilter, open, loadProjects]) + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button + variant="outline" + disabled={disabled} + className={`w-full justify-start ${className || ''}`} + > + {selectedProject ? ( + <div className="flex items-center gap-2 w-full"> + <span className="font-mono text-sm">[{selectedProject.code}]</span> + <span className="truncate flex-1 text-left">{selectedProject.name}</span> + <Badge variant={selectedProject.source === 'projects' ? 'default' : 'secondary'} className="text-xs"> + {selectedProject.source === 'projects' ? 'P' : 'B'} + </Badge> + </div> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>프로젝트 선택</DialogTitle> + <div className="text-sm text-muted-foreground"> + {finalSearchOptions.searchFrom === 'both' && '프로젝트 및 견적프로젝트에서 검색'} + {finalSearchOptions.searchFrom === 'projects' && '프로젝트에서 검색'} + {finalSearchOptions.searchFrom === 'biddingProjects' && '견적프로젝트에서 검색'} + </div> + </DialogHeader> + + <div className="space-y-4"> + <div className="flex items-center space-x-2"> + <Search className="h-4 w-4" /> + <Input + placeholder="프로젝트 코드, 이름으로 검색..." + value={globalFilter} + onChange={(e) => setGlobalFilter(e.target.value)} + className="flex-1" + /> + </div> + + {loading ? ( + <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) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + className="cursor-pointer hover:bg-muted/50" + onClick={() => handleProjectSelect(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> + </DialogContent> + </Dialog> + ) +} |
