summaryrefslogtreecommitdiff
path: root/components/common/selectors/currency
diff options
context:
space:
mode:
Diffstat (limited to 'components/common/selectors/currency')
-rw-r--r--components/common/selectors/currency/README.md108
-rw-r--r--components/common/selectors/currency/currency-selector-multi-dialog.tsx419
-rw-r--r--components/common/selectors/currency/currency-selector-single-dialog.tsx362
-rw-r--r--components/common/selectors/currency/currency-selector.tsx299
-rw-r--r--components/common/selectors/currency/currency-service.ts98
-rw-r--r--components/common/selectors/currency/index.ts19
6 files changed, 1305 insertions, 0 deletions
diff --git a/components/common/selectors/currency/README.md b/components/common/selectors/currency/README.md
new file mode 100644
index 00000000..55fc969e
--- /dev/null
+++ b/components/common/selectors/currency/README.md
@@ -0,0 +1,108 @@
+# 통화 선택기 컴포넌트
+
+## 개요
+
+Oracle 데이터베이스에서 통화 목록을 조회하고 선택할 수 있는 공통 컴포넌트입니다.
+
+## 데이터 조회
+
+통화 목록을 조회하고, 코드와 설명을 함께 제공해 선택하게 만듭니다.
+
+```sql
+SELECT CD.CD , NM.GRP_DSC
+ FROM CMCTB_CD CD , CMCTB_CDNM NM
+ WHERE CD.CD_CLF = NM.CD_CLF
+ AND CD.CD = NM.CD
+ AND CD.CD2 = NM.CD2
+ AND CD.CD3 = NM.CD3
+ AND CD.CD_CLF = 'SPB032'
+ORDER BY USR_DF_CHAR_8 ASC, CD.CD ASC
+```
+
+## 소수점 자리수
+
+각 통화별 입력가능 소수점 자리수를 자동으로 반환합니다.
+
+- 원화(KRW): 0자리 (정수)
+- 엔화(JPY): 0자리 (정수)
+- 기타 통화: 2자리
+
+## 컴포넌트 구성
+
+### 1. CurrencySelector
+
+기본 선택기 컴포넌트 (버튼 + 다이얼로그 통합형)
+
+```tsx
+import { CurrencySelector } from '@/components/common/selectors/currency'
+
+<CurrencySelector
+ selectedCurrency={selectedCurrency}
+ onCurrencySelect={(currency) => {
+ console.log(currency.CURRENCY_CODE)
+ console.log(currency.CURRENCY_NAME)
+ console.log(currency.DECIMAL_PLACES) // 소수점 자리수
+ }}
+ placeholder="통화를 선택하세요"
+ disabled={false}
+/>
+```
+
+### 2. CurrencySelectorSingleDialog
+
+단일 선택 다이얼로그 (open 상태를 외부에서 제어)
+
+```tsx
+import { CurrencySelectorSingleDialog } from '@/components/common/selectors/currency'
+
+<CurrencySelectorSingleDialog
+ open={isOpen}
+ onOpenChange={setIsOpen}
+ selectedCurrency={selectedCurrency}
+ onCurrencySelect={(currency) => console.log(currency)}
+ showConfirmButtons={true}
+ onConfirm={(currency) => console.log('확인:', currency)}
+ onCancel={() => console.log('취소')}
+/>
+```
+
+### 3. CurrencySelectorMultiDialog
+
+다중 선택 다이얼로그
+
+```tsx
+import { CurrencySelectorMultiDialog } from '@/components/common/selectors/currency'
+
+<CurrencySelectorMultiDialog
+ open={isOpen}
+ onOpenChange={setIsOpen}
+ selectedCurrencies={selectedCurrencies}
+ onCurrenciesSelect={(currencies) => console.log(currencies)}
+ maxSelection={5}
+ onConfirm={(currencies) => console.log('확인:', currencies)}
+ onCancel={() => console.log('취소')}
+/>
+```
+
+## Currency 타입
+
+```typescript
+interface Currency {
+ CURRENCY_CODE: string // 통화 코드 (예: KRW, USD, JPY)
+ CURRENCY_NAME: string // 통화 이름/설명
+ DECIMAL_PLACES: number // 입력가능 소수점 자리수
+}
+```
+
+## 유틸리티 함수
+
+### getDecimalPlaces
+
+통화 코드에 따른 소수점 자리수를 반환합니다.
+
+```tsx
+import { getDecimalPlaces } from '@/components/common/selectors/currency'
+
+const places = getDecimalPlaces('KRW') // 0
+const places2 = getDecimalPlaces('USD') // 2
+```
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>
+ )
+}
+
diff --git a/components/common/selectors/currency/currency-selector-single-dialog.tsx b/components/common/selectors/currency/currency-selector-single-dialog.tsx
new file mode 100644
index 00000000..21fb0543
--- /dev/null
+++ b/components/common/selectors/currency/currency-selector-single-dialog.tsx
@@ -0,0 +1,362 @@
+'use client'
+
+/**
+ * 통화 단일 선택 다이얼로그
+ *
+ * @description
+ * - 통화를 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 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 {
+ getCurrencies,
+ Currency
+} from './currency-service'
+import { toast } from 'sonner'
+
+export interface CurrencySelectorSingleDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCurrency?: Currency
+ onCurrencySelect: (currency: Currency) => void
+ onConfirm?: (currency: Currency | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+}
+
+export function CurrencySelectorSingleDialog({
+ open,
+ onOpenChange,
+ selectedCurrency,
+ onCurrencySelect,
+ onConfirm,
+ onCancel,
+ title = "통화 선택",
+ description = "통화를 선택하세요",
+ showConfirmButtons = false
+}: CurrencySelectorSingleDialogProps) {
+ 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 [tempSelectedCurrency, setTempSelectedCurrency] = useState<Currency | undefined>(selectedCurrency)
+
+ // 통화 선택 핸들러
+ const handleCurrencySelect = useCallback((currency: Currency) => {
+ if (showConfirmButtons) {
+ setTempSelectedCurrency(currency)
+ } else {
+ onCurrencySelect(currency)
+ onOpenChange(false)
+ }
+ }, [onCurrencySelect, onOpenChange, showConfirmButtons])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCurrency) {
+ onCurrencySelect(tempSelectedCurrency)
+ }
+ onConfirm?.(tempSelectedCurrency)
+ onOpenChange(false)
+ }, [tempSelectedCurrency, onCurrencySelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCurrency(selectedCurrency)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCurrency, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<Currency>[] = useMemo(() => [
+ {
+ 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>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCurrency?.CURRENCY_CODE === row.original.CURRENCY_CODE
+ : selectedCurrency?.CURRENCY_CODE === row.original.CURRENCY_CODE
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCurrencySelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCurrencySelect, selectedCurrency, tempSelectedCurrency, showConfirmButtons])
+
+ // 통화 테이블 설정
+ 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)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || '통화 목록을 불러오는데 실패했습니다.')
+ setCurrencies([])
+ }
+ } catch (error) {
+ console.error('통화 목록 로드 실패:', error)
+ toast.error('통화 목록을 불러오는 중 오류가 발생했습니다.')
+ setCurrencies([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열릴 때 통화 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCurrency(selectedCurrency)
+ if (currencies.length === 0) {
+ console.log('🚀 [CurrencySelectorSingleDialog] 다이얼로그 열림 - loadCurrencies 호출')
+ loadCurrencies()
+ } else {
+ console.log('📦 [CurrencySelectorSingleDialog] 다이얼로그 열림 - 기존 데이터 사용 (' + currencies.length + '건)')
+ }
+ }
+ }, [open, selectedCurrency, loadCurrencies, currencies.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCurrency = showConfirmButtons ? tempSelectedCurrency : selectedCurrency
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 통화 표시 */}
+ {currentSelectedCurrency && (
+ <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 font-semibold">[{currentSelectedCurrency.CURRENCY_CODE}]</span>
+ <span>{currentSelectedCurrency.CURRENCY_NAME}</span>
+ <span className="text-xs text-muted-foreground">
+ (소수점 {currentSelectedCurrency.DECIMAL_PLACES}자리)
+ </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 = currentSelectedCurrency?.CURRENCY_CODE === row.original.CURRENCY_CODE
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCurrencySelect(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={!tempSelectedCurrency}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/currency/currency-selector.tsx b/components/common/selectors/currency/currency-selector.tsx
new file mode 100644
index 00000000..1e56c972
--- /dev/null
+++ b/components/common/selectors/currency/currency-selector.tsx
@@ -0,0 +1,299 @@
+'use client'
+
+/**
+ * 통화 선택기
+ *
+ * @description
+ * - 오라클에서 CMCTB_CD, CMCTB_CDNM 테이블에서 CD_CLF = 'SPB032' 인 건들을 조회
+ * - CURRENCY_CODE: 통화 코드 (예: KRW, USD, JPY)
+ * - CURRENCY_NAME: 통화 이름/설명
+ * - DECIMAL_PLACES: 입력가능 소수점 자리수 (원화/엔화: 0, 기타: 2)
+ */
+
+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 {
+ 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 CurrencySelectorProps {
+ selectedCurrency?: Currency
+ onCurrencySelect: (currency: Currency) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+}
+
+export function CurrencySelector({
+ selectedCurrency,
+ onCurrencySelect,
+ disabled,
+ placeholder = "통화를 선택하세요",
+ className
+}: CurrencySelectorProps) {
+ const [open, setOpen] = useState(false)
+ 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 handleCurrencySelect = useCallback((currency: Currency) => {
+ onCurrencySelect(currency)
+ setOpen(false)
+ }, [onCurrencySelect])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<Currency>[] = useMemo(() => [
+ {
+ 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>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCurrencySelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ], [handleCurrencySelect])
+
+ // 통화 테이블 설정
+ 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) => {
+ setOpen(newOpen)
+ if (newOpen && currencies.length === 0) {
+ loadCurrencies()
+ }
+ }, [loadCurrencies, currencies.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ disabled={disabled}
+ className={`w-full justify-start ${className || ''}`}
+ >
+ {selectedCurrency ? (
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-mono text-sm font-semibold">[{selectedCurrency.CURRENCY_CODE}]</span>
+ <span className="truncate flex-1 text-left">{selectedCurrency.CURRENCY_NAME}</span>
+ <span className="text-xs text-muted-foreground">({selectedCurrency.DECIMAL_PLACES}자리)</span>
+ </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">
+ 통화 코드(CD_CLF=SPB032) 조회
+ </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) => 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) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleCurrencySelect(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>
+ )
+}
+
diff --git a/components/common/selectors/currency/currency-service.ts b/components/common/selectors/currency/currency-service.ts
new file mode 100644
index 00000000..c911d6b1
--- /dev/null
+++ b/components/common/selectors/currency/currency-service.ts
@@ -0,0 +1,98 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// 통화 타입 정의
+export interface Currency {
+ CURRENCY_CODE: string // 통화 코드 (예: KRW, USD, JPY)
+ CURRENCY_NAME: string // 통화 이름/설명
+ DECIMAL_PLACES: number // 입력가능 소수점 자리수
+}
+
+// 테스트 환경용 폴백 데이터
+const FALLBACK_TEST_DATA: Currency[] = [
+ { CURRENCY_CODE: 'KRW', CURRENCY_NAME: '원화(테스트데이터 - 오라클 페칭 실패시)', DECIMAL_PLACES: 0 },
+ { CURRENCY_CODE: 'USD', CURRENCY_NAME: '미국 달러(테스트데이터 - 오라클 페칭 실패시)', DECIMAL_PLACES: 2 },
+ { CURRENCY_CODE: 'EUR', CURRENCY_NAME: '유로(테스트데이터 - 오라클 페칭 실패시)', DECIMAL_PLACES: 2 },
+ { CURRENCY_CODE: 'JPY', CURRENCY_NAME: '일본 엔화(테스트데이터 - 오라클 페칭 실패시)', DECIMAL_PLACES: 0 },
+ { CURRENCY_CODE: 'CNY', CURRENCY_NAME: '중국 위안(테스트데이터 - 오라클 페칭 실패시)', DECIMAL_PLACES: 2 },
+ { CURRENCY_CODE: 'GBP', CURRENCY_NAME: '영국 파운드(테스트데이터 - 오라클 페칭 실패시)', DECIMAL_PLACES: 2 },
+]
+
+/**
+ * 통화 코드별 소수점 자리수 반환
+ * - 원화(KRW): 0
+ * - 엔화(JPY): 0
+ * - 기타: 2
+ */
+export function getDecimalPlaces(currencyCode: string): number {
+ const code = currencyCode.toUpperCase()
+ if (code === 'KRW' || code === 'JPY') {
+ return 0
+ }
+ return 2
+}
+
+/**
+ * 모든 통화 목록 조회 (Oracle에서 전체 조회, 실패 시 폴백 데이터 사용)
+ * CMCTB_CD, CMCTB_CDNM 테이블에서 CD_CLF = 'SPB032' 조건으로 조회
+ * 클라이언트에서 검색/필터링 수행
+ */
+export async function getCurrencies(): Promise<{
+ success: boolean
+ data: Currency[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getCurrencies] Oracle 쿼리 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT CD.CD AS CURRENCY_CODE, NM.GRP_DSC AS CURRENCY_NAME
+ FROM CMCTB_CD CD, CMCTB_CDNM NM
+ WHERE CD.CD_CLF = NM.CD_CLF
+ AND CD.CD = NM.CD
+ AND CD.CD2 = NM.CD2
+ AND CD.CD3 = NM.CD3
+ AND CD.CD_CLF = 'SPB032'
+ ORDER BY CD.USR_DF_CHAR_8 ASC, CD.CD ASC
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getCurrencies] Oracle 쿼리 성공 - ${rows.length}건 조회`)
+
+ // null 값 필터링 및 소수점 자리수 추가
+ const cleanedResult = rows
+ .filter((item) =>
+ item.CURRENCY_CODE &&
+ item.CURRENCY_NAME
+ )
+ .map((item) => {
+ const currencyCode = String(item.CURRENCY_CODE)
+ return {
+ CURRENCY_CODE: currencyCode,
+ CURRENCY_NAME: String(item.CURRENCY_NAME),
+ DECIMAL_PLACES: getDecimalPlaces(currencyCode)
+ }
+ })
+
+ console.log(`✅ [getCurrencies] 필터링 후 ${cleanedResult.length}건`)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getCurrencies] Oracle 오류:', error)
+ console.log('🔄 [getCurrencies] 폴백 테스트 데이터 사용 (' + FALLBACK_TEST_DATA.length + '건)')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
diff --git a/components/common/selectors/currency/index.ts b/components/common/selectors/currency/index.ts
new file mode 100644
index 00000000..1eb0cfbb
--- /dev/null
+++ b/components/common/selectors/currency/index.ts
@@ -0,0 +1,19 @@
+// 통화 선택기 관련 컴포넌트와 타입 내보내기
+
+export { CurrencySelector } from './currency-selector'
+export type { CurrencySelectorProps } from './currency-selector'
+
+export { CurrencySelectorSingleDialog } from './currency-selector-single-dialog'
+export type { CurrencySelectorSingleDialogProps } from './currency-selector-single-dialog'
+
+export { CurrencySelectorMultiDialog } from './currency-selector-multi-dialog'
+export type { CurrencySelectorMultiDialogProps } from './currency-selector-multi-dialog'
+
+export {
+ getCurrencies,
+ getDecimalPlaces
+} from './currency-service'
+export type {
+ Currency
+} from './currency-service'
+