From 1b84707d2f7abba3349fc306b539ef661a22cd45 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 13 Nov 2025 18:16:05 +0900
Subject: (김준회) 공통컴포넌트: 통화(Currency) 선택기 추가
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../currency/currency-selector-multi-dialog.tsx    | 419 +++++++++++++++++++++
 1 file changed, 419 insertions(+)
 create mode 100644 components/common/selectors/currency/currency-selector-multi-dialog.tsx

(limited to 'components/common/selectors/currency/currency-selector-multi-dialog.tsx')

diff --git a/components/common/selectors/currency/currency-selector-multi-dialog.tsx b/components/common/selectors/currency/currency-selector-multi-dialog.tsx
new file mode 100644
index 00000000..87739dee
--- /dev/null
+++ b/components/common/selectors/currency/currency-selector-multi-dialog.tsx
@@ -0,0 +1,419 @@
+'use client'
+
+/**
+ * 통화 다중 선택 다이얼로그
+ * 
+ * @description
+ * - 여러 통화를 선택할 수 있는 다이얼로그
+ * - 체크박스를 통한 다중 선택
+ * - 선택된 통화들을 상단에 표시
+ * - 확인/취소 버튼으로 선택 확정
+ */
+
+import { useState, useCallback, useMemo, useTransition } 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 { Checkbox } from '@/components/ui/checkbox'
+import { Badge } from '@/components/ui/badge'
+import { Search, Check, X, Trash2 } 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 { 
+  getCurrencies, 
+  Currency
+} from './currency-service'
+import { toast } from 'sonner'
+
+export interface CurrencySelectorMultiDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  selectedCurrencies?: Currency[]
+  onCurrenciesSelect: (currencies: Currency[]) => void
+  onConfirm?: (currencies: Currency[]) => void
+  onCancel?: () => void
+  title?: string
+  description?: string
+  maxSelection?: number
+}
+
+export function CurrencySelectorMultiDialog({ 
+  open,
+  onOpenChange,
+  selectedCurrencies = [], 
+  onCurrenciesSelect, 
+  onConfirm,
+  onCancel,
+  title = "통화 다중 선택",
+  description = "여러 통화를 선택하세요",
+  maxSelection
+}: CurrencySelectorMultiDialogProps) {
+  const [currencies, setCurrencies] = useState<Currency[]>([])
+  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 [tempSelectedCurrencies, setTempSelectedCurrencies] = useState<Currency[]>(selectedCurrencies)
+
+  // 통화 선택/해제 핸들러
+  const handleCurrencyToggle = useCallback((currency: Currency, checked: boolean) => {
+    setTempSelectedCurrencies(prev => {
+      if (checked) {
+        // 최대 선택 수 제한 확인
+        if (maxSelection && prev.length >= maxSelection) {
+          toast.warning(`최대 ${maxSelection}개까지 선택할 수 있습니다.`)
+          return prev
+        }
+        // 이미 선택된 통화인지 확인
+        if (prev.some(c => c.CURRENCY_CODE === currency.CURRENCY_CODE)) {
+          return prev
+        }
+        return [...prev, currency]
+      } else {
+        return prev.filter(c => c.CURRENCY_CODE !== currency.CURRENCY_CODE)
+      }
+    })
+  }, [maxSelection])
+
+  // 개별 통화 제거 핸들러
+  const handleRemoveCurrency = useCallback((currencyCode: string) => {
+    setTempSelectedCurrencies(prev => prev.filter(c => c.CURRENCY_CODE !== currencyCode))
+  }, [])
+
+  // 모든 선택 해제 핸들러
+  const handleClearAll = useCallback(() => {
+    setTempSelectedCurrencies([])
+  }, [])
+
+  // 확인 버튼 핸들러
+  const handleConfirm = useCallback(() => {
+    onCurrenciesSelect(tempSelectedCurrencies)
+    onConfirm?.(tempSelectedCurrencies)
+    onOpenChange(false)
+  }, [tempSelectedCurrencies, onCurrenciesSelect, onConfirm, onOpenChange])
+
+  // 취소 버튼 핸들러
+  const handleCancel = useCallback(() => {
+    setTempSelectedCurrencies(selectedCurrencies)
+    onCancel?.()
+    onOpenChange(false)
+  }, [selectedCurrencies, onCancel, onOpenChange])
+
+  // 테이블 컬럼 정의
+  const columns: ColumnDef<Currency>[] = useMemo(() => [
+    {
+      id: 'select',
+      header: ({ table }) => (
+        <Checkbox
+          checked={table.getIsAllPageRowsSelected()}
+          onCheckedChange={(value) => {
+            if (value) {
+              // 페이지의 모든 행을 선택하되, 최대 선택 수 제한 확인
+              const currentPageRows = table.getRowModel().rows
+              const newSelections = currentPageRows
+                .map(row => row.original)
+                .filter(currency => !tempSelectedCurrencies.some(c => c.CURRENCY_CODE === currency.CURRENCY_CODE))
+              
+              if (maxSelection) {
+                const remainingSlots = maxSelection - tempSelectedCurrencies.length
+                if (newSelections.length > remainingSlots) {
+                  toast.warning(`최대 ${maxSelection}개까지 선택할 수 있습니다.`)
+                  return
+                }
+              }
+              
+              setTempSelectedCurrencies(prev => [...prev, ...newSelections])
+            } else {
+              // 페이지의 모든 행 선택 해제
+              const currentPageCodes = table.getRowModel().rows.map(row => row.original.CURRENCY_CODE)
+              setTempSelectedCurrencies(prev => prev.filter(c => !currentPageCodes.includes(c.CURRENCY_CODE)))
+            }
+          }}
+          aria-label="모든 행 선택"
+        />
+      ),
+      cell: ({ row }) => {
+        const isSelected = tempSelectedCurrencies.some(c => c.CURRENCY_CODE === row.original.CURRENCY_CODE)
+        return (
+          <Checkbox
+            checked={isSelected}
+            onCheckedChange={(value) => handleCurrencyToggle(row.original, !!value)}
+            aria-label="행 선택"
+          />
+        )
+      },
+      enableSorting: false,
+      enableHiding: false,
+    },
+    {
+      accessorKey: 'CURRENCY_CODE',
+      header: '통화코드',
+      cell: ({ row }) => (
+        <div className="font-mono text-sm font-semibold">{row.getValue('CURRENCY_CODE')}</div>
+      ),
+    },
+    {
+      accessorKey: 'CURRENCY_NAME',
+      header: '통화명',
+      cell: ({ row }) => (
+        <div>{row.getValue('CURRENCY_NAME')}</div>
+      ),
+    },
+    {
+      accessorKey: 'DECIMAL_PLACES',
+      header: '소수점',
+      cell: ({ row }) => (
+        <div className="text-center text-sm text-muted-foreground">
+          {row.getValue('DECIMAL_PLACES')}자리
+        </div>
+      ),
+    },
+  ], [handleCurrencyToggle, tempSelectedCurrencies, maxSelection])
+
+  // 통화 테이블 설정
+  const table = useReactTable({
+    data: currencies,
+    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 loadCurrencies = useCallback(async () => {
+    startTransition(async () => {
+      try {
+        const result = await getCurrencies()
+        
+        if (result.success) {
+          setCurrencies(result.data)
+        } else {
+          toast.error(result.error || '통화 목록을 불러오는데 실패했습니다.')
+          setCurrencies([])
+        }
+      } catch (error) {
+        console.error('통화 목록 로드 실패:', error)
+        toast.error('통화 목록을 불러오는 중 오류가 발생했습니다.')
+        setCurrencies([])
+      }
+    })
+  }, [])
+
+  // 다이얼로그 열기/닫기 핸들러
+  const handleDialogOpenChange = useCallback((newOpen: boolean) => {
+    onOpenChange(newOpen)
+    if (newOpen) {
+      setTempSelectedCurrencies(selectedCurrencies)
+      if (currencies.length === 0) {
+        loadCurrencies()
+      }
+    }
+  }, [onOpenChange, selectedCurrencies, loadCurrencies, currencies.length])
+
+  // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+  const handleSearchChange = useCallback((value: string) => {
+    setGlobalFilter(value)
+  }, [])
+
+  return (
+    <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+      <DialogContent className="max-w-5xl max-h-[85vh]">
+        <DialogHeader>
+          <DialogTitle>{title}</DialogTitle>
+          <div className="text-sm text-muted-foreground">
+            {description}
+            {maxSelection && ` (최대 ${maxSelection}개)`}
+          </div>
+        </DialogHeader>
+        
+        <div className="space-y-4">
+          {/* 선택된 통화들 표시 */}
+          {tempSelectedCurrencies.length > 0 && (
+            <div className="space-y-2">
+              <div className="flex items-center justify-between">
+                <div className="text-sm font-medium">
+                  선택된 통화 ({tempSelectedCurrencies.length}개)
+                  {maxSelection && ` / ${maxSelection}`}
+                </div>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={handleClearAll}
+                  disabled={tempSelectedCurrencies.length === 0}
+                >
+                  <Trash2 className="h-4 w-4 mr-2" />
+                  모두 제거
+                </Button>
+              </div>
+              <div className="flex flex-wrap gap-2 max-h-24 overflow-y-auto p-2 border rounded-md bg-muted/30">
+                {tempSelectedCurrencies.map((currency) => (
+                  <Badge
+                    key={currency.CURRENCY_CODE}
+                    variant="secondary"
+                    className="text-xs"
+                  >
+                    [{currency.CURRENCY_CODE}] {currency.CURRENCY_NAME}
+                    <button
+                      onClick={() => handleRemoveCurrency(currency.CURRENCY_CODE)}
+                      className="ml-1 hover:text-destructive"
+                    >
+                      <X className="h-3 w-3" />
+                    </button>
+                  </Badge>
+                ))}
+              </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 isSelected = tempSelectedCurrencies.some(c => c.CURRENCY_CODE === row.original.CURRENCY_CODE)
+                      return (
+                        <TableRow
+                          key={row.id}
+                          data-state={isSelected && "selected"}
+                          className={`cursor-pointer hover:bg-muted/50 ${
+                            isSelected ? 'bg-muted/30' : ''
+                          }`}
+                          onClick={() => {
+                            const isCurrentlySelected = tempSelectedCurrencies.some(c => c.CURRENCY_CODE === row.original.CURRENCY_CODE)
+                            handleCurrencyToggle(row.original, !isCurrentlySelected)
+                          }}
+                        >
+                          {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>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={handleCancel}>
+            <X className="h-4 w-4 mr-2" />
+            취소
+          </Button>
+          <Button onClick={handleConfirm}>
+            <Check className="h-4 w-4 mr-2" />
+            확인 ({tempSelectedCurrencies.length}개 선택)
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}
+
-- 
cgit v1.2.3

