From 81373281eafff4f18d68fcd6221afe64e9d94fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 10 Mar 2026 11:35:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EA=B3=84=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=A0=84=EB=A9=B4=20=EA=B0=9C=EC=84=A0=20=E2=80=94=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=EA=B3=BC=EB=AA=A9=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=ED=99=94=C2=B7=EC=A0=84=ED=91=9C=C2=B7=EC=84=B8=EA=B8=88?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=C2=B7=EC=96=B4=EC=9D=8C=C2=B7?= =?UTF-8?q?=EC=83=81=ED=92=88=EA=B6=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountSubjectSelect 공통 컴포넌트 신규 (계정과목 선택 통합) - 일반전표 수동입력/수정 모달 계정과목 연동 - 세금계산서 관리 타입 시스템 재정의 + 전표 연동 모달 - 어음관리 리팩토링 + 상품권 접대비 연동 - 카드거래 조회 전표 연동 모달 개선 - 악성채권/입출금/매입매출/거래처 상세 뷰 보강 --- .../(protected)/accounting/vendors/page.tsx | 2 +- .../BadDebtCollection/BadDebtDetail.tsx | 4 + .../accounting/BadDebtCollection/index.tsx | 2 + .../accounting/BillManagement/BillDetail.tsx | 5 + .../BillManagement/BillManagementClient.tsx | 50 ++-- .../accounting/BillManagement/actions.ts | 2 +- .../accounting/BillManagement/index.tsx | 3 + .../JournalEntryModal.tsx | 26 +-- .../ManualInputModal.tsx | 24 +- .../CardTransactionInquiry/index.tsx | 24 +- .../DepositDetailClientV2.tsx | 18 +- .../accounting/DepositManagement/index.tsx | 2 + .../ExpectedExpenseManagement/actions.ts | 2 +- .../ExpectedExpenseManagement/index.tsx | 27 +-- .../GeneralJournalEntry/JournalEditModal.tsx | 32 +-- .../ManualJournalEntryModal.tsx | 36 +-- .../accounting/GeneralJournalEntry/actions.ts | 153 ------------- .../accounting/GeneralJournalEntry/index.tsx | 5 +- .../accounting/GeneralJournalEntry/types.ts | 54 ----- .../GiftCertificateDetail.tsx | 7 +- .../GiftCertificateManagement/actions.ts | 6 +- .../GiftCertificateManagement/index.tsx | 14 +- .../PurchaseManagement/PurchaseDetail.tsx | 3 + .../accounting/PurchaseManagement/index.tsx | 2 + .../SalesManagement/SalesDetail.tsx | 3 + .../accounting/TaxInvoiceIssuance/actions.ts | 2 +- .../JournalEntryModal.tsx | 21 +- .../TaxInvoiceManagement/ManualEntryModal.tsx | 15 +- .../TaxInvoiceManagement/actions.ts | 106 ++++----- .../accounting/TaxInvoiceManagement/types.ts | 155 ++++++++----- .../VendorManagement/VendorDetailClient.tsx | 3 + .../accounting/VendorManagement/index.tsx | 2 + .../WithdrawalDetailClientV2.tsx | 3 + .../accounting/WithdrawalManagement/index.tsx | 12 +- .../common/AccountSubjectSelect.tsx | 215 ++++++++++++++++++ .../AccountSubjectSettingModal.tsx | 83 +++++-- src/components/accounting/common/actions.ts | 123 ++++++++++ src/components/accounting/common/index.ts | 18 ++ src/components/accounting/common/types.ts | 118 ++++++++++ .../dev/generators/accountingData.ts | 12 +- 40 files changed, 863 insertions(+), 531 deletions(-) create mode 100644 src/components/accounting/common/AccountSubjectSelect.tsx rename src/components/accounting/{GeneralJournalEntry => common}/AccountSubjectSettingModal.tsx (79%) create mode 100644 src/components/accounting/common/actions.ts create mode 100644 src/components/accounting/common/index.ts create mode 100644 src/components/accounting/common/types.ts diff --git a/src/app/[locale]/(protected)/accounting/vendors/page.tsx b/src/app/[locale]/(protected)/accounting/vendors/page.tsx index af3ad101..af4ec820 100644 --- a/src/app/[locale]/(protected)/accounting/vendors/page.tsx +++ b/src/app/[locale]/(protected)/accounting/vendors/page.tsx @@ -17,7 +17,7 @@ export default function VendorsPage() { useEffect(() => { if (mode !== 'new') { - getClients({ size: 100 }) + getClients({ size: 1000 }) .then(result => { setData(result.data); setTotal(result.total); diff --git a/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx b/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx index c519a1c3..9f451a88 100644 --- a/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx +++ b/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx @@ -6,6 +6,7 @@ */ import { useState, useCallback, useMemo } from 'react'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; import { useDaumPostcode } from '@/hooks/useDaumPostcode'; import { useRouter } from 'next/navigation'; import { format } from 'date-fns'; @@ -137,12 +138,14 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp if (isNewMode) { const result = await createBadDebt(formData); if (result.success) { + invalidateDashboard('badDebt'); return { success: true }; } return { success: false, error: result.error || '등록에 실패했습니다.' }; } else { const result = await updateBadDebt(recordId!, formData); if (result.success) { + invalidateDashboard('badDebt'); return { success: true }; } return { success: false, error: result.error || '수정에 실패했습니다.' }; @@ -159,6 +162,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp try { const result = await deleteBadDebt(String(id)); if (result.success) { + invalidateDashboard('badDebt'); return { success: true }; } return { success: false, error: result.error || '삭제에 실패했습니다.' }; diff --git a/src/components/accounting/BadDebtCollection/index.tsx b/src/components/accounting/BadDebtCollection/index.tsx index 95d5e297..b3a2750a 100644 --- a/src/components/accounting/BadDebtCollection/index.tsx +++ b/src/components/accounting/BadDebtCollection/index.tsx @@ -14,6 +14,7 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2'; */ import { useState, useMemo, useCallback, useTransition } from 'react'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; import { useRouter } from 'next/navigation'; import { AlertTriangle, Pencil, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -176,6 +177,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec deleteItem: async (id: string) => { const result = await deleteBadDebt(id); if (result.success) { + invalidateDashboard('badDebt'); setData((prev) => prev.filter((item) => item.id !== id)); } return { success: result.success, error: result.error }; diff --git a/src/components/accounting/BillManagement/BillDetail.tsx b/src/components/accounting/BillManagement/BillDetail.tsx index a7b65937..bd95a0d7 100644 --- a/src/components/accounting/BillManagement/BillDetail.tsx +++ b/src/components/accounting/BillManagement/BillDetail.tsx @@ -9,6 +9,7 @@ import { apiDataToFormData, transformFormDataToApi } from './types'; import type { BillApiData } from './types'; import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions'; import { useBillForm } from './hooks/useBillForm'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; import { useBillConditions } from './hooks/useBillConditions'; import { BasicInfoSection, @@ -130,6 +131,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) { if (isNewMode) { const result = await createBillRaw(apiPayload); if (result.success) { + invalidateDashboard('bill'); toast.success('등록되었습니다.'); router.push('/ko/accounting/bills'); return { success: false, error: '' }; @@ -137,6 +139,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) { return result; } else { const result = await updateBillRaw(String(billId), apiPayload); + if (result.success) { + invalidateDashboard('bill'); + } return result; } } finally { diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx index b2c94167..434b4a38 100644 --- a/src/components/accounting/BillManagement/BillManagementClient.tsx +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -22,8 +22,7 @@ import { import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; -import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; -import { useDeleteDialog } from '@/hooks/useDeleteDialog'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; import { TableRow, TableCell } from '@/components/ui/table'; import { Select, @@ -89,24 +88,6 @@ export function BillManagementClient({ const [currentPage, setCurrentPage] = useState(initialPagination.currentPage); const itemsPerPage = initialPagination.perPage; - // 삭제 다이얼로그 - const deleteDialog = useDeleteDialog({ - onDelete: async (id) => { - const result = await deleteBill(id); - if (result.success) { - // 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장) - await loadData(currentPage); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(id); - return newSet; - }); - } - return result; - }, - entityName: '어음', - }); - // 날짜 범위 상태 const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear'); @@ -304,6 +285,7 @@ export function BillManagementClient({ } if (successCount > 0) { + invalidateDashboard('bill'); toast.success(`${successCount}건이 저장되었습니다.`); loadData(currentPage); setSelectedItems(new Set()); @@ -334,6 +316,25 @@ export function BillManagementClient({ totalCount: pagination.total, }; }, + deleteItem: async (id: string) => { + const result = await deleteBill(id); + if (result.success) { + invalidateDashboard('bill'); + await loadData(currentPage); + setSelectedItems(prev => { + const newSet = new Set(prev); + newSet.delete(id); + return newSet; + }); + } + return { success: result.success, error: result.error }; + }, + }, + + // 삭제 확인 메시지 + deleteConfirmMessage: { + title: '어음 삭제', + description: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.', }, // 테이블 컬럼 @@ -445,6 +446,7 @@ export function BillManagementClient({ isLoading, router, loadData, + currentPage, handleSave, renderTableRow, renderMobileCard, @@ -471,14 +473,6 @@ export function BillManagementClient({ }} /> - ); } diff --git a/src/components/accounting/BillManagement/actions.ts b/src/components/accounting/BillManagement/actions.ts index 7aac9fd4..89c31ff6 100644 --- a/src/components/accounting/BillManagement/actions.ts +++ b/src/components/accounting/BillManagement/actions.ts @@ -158,7 +158,7 @@ export async function updateBillRaw(id: string, data: Record): // ===== 거래처 목록 조회 ===== export async function getClients(): Promise> { return executeServerAction({ - url: buildApiUrl('/api/v1/clients', { per_page: 100 }), + url: buildApiUrl('/api/v1/clients', { size: 1000 }), transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => { type ClientApi = { id: number; name: string }; const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || []; diff --git a/src/components/accounting/BillManagement/index.tsx b/src/components/accounting/BillManagement/index.tsx index 6fea5981..77b0b86d 100644 --- a/src/components/accounting/BillManagement/index.tsx +++ b/src/components/accounting/BillManagement/index.tsx @@ -16,6 +16,7 @@ import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { formatNumber } from '@/lib/utils/amount'; import { getBills, deleteBill, updateBillStatus } from './actions'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; import { useDateRange } from '@/hooks'; import { extractUniqueOptions } from '../shared'; import { @@ -209,6 +210,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem } if (successCount > 0) { + invalidateDashboard('bill'); toast.success(`${successCount}건의 상태가 변경되었습니다.`); await loadBills(); } @@ -247,6 +249,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem deleteItem: async (id: string) => { const result = await deleteBill(id); if (result.success) { + invalidateDashboard('bill'); // 서버에서 재조회 (pagination 메타데이터 포함) await loadBills(); } diff --git a/src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx b/src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx index c287fdbf..abe30e96 100644 --- a/src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx +++ b/src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx @@ -23,7 +23,8 @@ import { SelectValue, } from '@/components/ui/select'; import type { CardTransaction, JournalEntryItem } from './types'; -import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types'; +import { DEDUCTION_OPTIONS } from './types'; +import { AccountSubjectSelect } from '@/components/accounting/common'; import { saveJournalEntries } from './actions'; interface JournalEntryModalProps { @@ -194,23 +195,16 @@ export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess } {/* 계정과목 + 공제 + 증빙/판매자상호 */}
- {/* Select - FormField 예외 */}
- +
+ updateItem(index, 'accountSubject', v)} + placeholder="선택" + size="sm" + /> +
{/* Select - FormField 예외 */}
diff --git a/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx b/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx index d7af2fe0..121138fd 100644 --- a/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx +++ b/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx @@ -25,7 +25,8 @@ import { } from '@/components/ui/select'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import type { ManualInputFormData } from './types'; -import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types'; +import { DEDUCTION_OPTIONS } from './types'; +import { AccountSubjectSelect } from '@/components/accounting/common'; import { getCardList, createCardTransaction } from './actions'; import { getTodayString } from '@/lib/utils/date'; @@ -254,20 +255,13 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
- +
+ handleChange('accountSubject', v)} + placeholder="선택" + /> +
diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 6357ef6d..e95c7182 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -42,6 +42,7 @@ import type { CardTransaction, InlineEditData, SortOption } from './types'; import { SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS, } from './types'; +import { AccountSubjectSelect } from '@/components/accounting/common'; import { getCardTransactionList, getCardTransactionSummary, @@ -89,7 +90,7 @@ const tableColumns = [ { key: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false }, { key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' }, { key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' }, - { key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false }, + { key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[160px]', sortable: false }, { key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false }, { key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right' }, { key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false }, @@ -599,20 +600,13 @@ export function CardTransactionInquiry() { {/* 계정과목 (인라인 Select) */} e.stopPropagation()}> - + handleInlineEdit(item.id, 'accountSubject', v)} + placeholder="선택" + size="sm" + className="min-w-[90px] w-auto" + /> {/* 분개 버튼 */} e.stopPropagation()}> diff --git a/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx b/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx index f86581ed..ab9a0984 100644 --- a/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx +++ b/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx @@ -16,6 +16,7 @@ import { getBankAccounts, } from './actions'; import { useDevFill, generateDepositData } from '@/components/dev'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; // ===== Props ===== interface DepositDetailClientV2Props { @@ -81,14 +82,17 @@ export default function DepositDetailClientV2({ : await updateDeposit(depositId!, submitData as Partial); if (result.success && mode === 'create') { + invalidateDashboard('deposit'); toast.success('등록되었습니다.'); router.push('/ko/accounting/deposits'); return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지 } - return result.success - ? { success: true } - : { success: false, error: result.error }; + if (result.success) { + invalidateDashboard('deposit'); + return { success: true }; + } + return { success: false, error: result.error }; }, [mode, depositId, router] ); @@ -98,9 +102,11 @@ export default function DepositDetailClientV2({ if (!depositId) return { success: false, error: 'ID가 없습니다.' }; const result = await deleteDeposit(depositId); - return result.success - ? { success: true } - : { success: false, error: result.error }; + if (result.success) { + invalidateDashboard('deposit'); + return { success: true }; + } + return { success: false, error: result.error }; }, [depositId]); // ===== 모드 변경 핸들러 ===== diff --git a/src/components/accounting/DepositManagement/index.tsx b/src/components/accounting/DepositManagement/index.tsx index e6fa3d9d..74a743ca 100644 --- a/src/components/accounting/DepositManagement/index.tsx +++ b/src/components/accounting/DepositManagement/index.tsx @@ -73,6 +73,7 @@ import { deleteDeposit, updateDepositTypes, getDeposits } from './actions'; import { formatNumber } from '@/lib/utils/amount'; import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search'; import { toast } from 'sonner'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; import { useDateRange } from '@/hooks'; import { extractUniqueOptions, @@ -225,6 +226,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan deleteItem: async (id: string) => { const result = await deleteDeposit(id); if (result.success) { + invalidateDashboard('deposit'); toast.success('입금 내역이 삭제되었습니다.'); // 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장) await handleRefresh(); diff --git a/src/components/accounting/ExpectedExpenseManagement/actions.ts b/src/components/accounting/ExpectedExpenseManagement/actions.ts index 32dc8f8d..15cfcfe2 100644 --- a/src/components/accounting/ExpectedExpenseManagement/actions.ts +++ b/src/components/accounting/ExpectedExpenseManagement/actions.ts @@ -184,7 +184,7 @@ export async function getClients(): Promise<{ success: boolean; data: { id: string; name: string }[]; error?: string; }> { const result = await executeServerAction({ - url: buildApiUrl('/api/v1/clients', { per_page: 100 }), + url: buildApiUrl('/api/v1/clients', { size: 1000 }), transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => { type ClientApi = { id: number; name: string }; const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || []; diff --git a/src/components/accounting/ExpectedExpenseManagement/index.tsx b/src/components/accounting/ExpectedExpenseManagement/index.tsx index b1cd6cab..98793138 100644 --- a/src/components/accounting/ExpectedExpenseManagement/index.tsx +++ b/src/components/accounting/ExpectedExpenseManagement/index.tsx @@ -15,6 +15,7 @@ import { useState, useMemo, useCallback, useTransition, useEffect } from 'react' import { useRouter } from 'next/navigation'; import { format } from 'date-fns'; import { toast } from 'sonner'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; import { Receipt, Calendar as CalendarIcon, @@ -88,8 +89,8 @@ import { CurrencyInput } from '@/components/ui/currency-input'; import { TRANSACTION_TYPE_FILTER_OPTIONS, PAYMENT_STATUS_FILTER_OPTIONS, - ACCOUNT_SUBJECT_OPTIONS, } from './types'; +import { AccountSubjectSelect } from '@/components/accounting/common'; import { extractUniqueOptions } from '../shared'; import { formatNumber } from '@/lib/utils/amount'; import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search'; @@ -247,6 +248,7 @@ export function ExpectedExpenseManagement({ // 수정 const result = await updateExpectedExpense(editingItem.id, formData); if (result.success && result.data) { + invalidateDashboard('expectedExpense'); setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item)); toast.success('미지급비용이 수정되었습니다.'); setShowFormDialog(false); @@ -258,6 +260,7 @@ export function ExpectedExpenseManagement({ // 등록 const result = await createExpectedExpense(formData); if (result.success && result.data) { + invalidateDashboard('expectedExpense'); setData(prev => [result.data!, ...prev]); toast.success('미지급비용이 등록되었습니다.'); setShowFormDialog(false); @@ -278,6 +281,7 @@ export function ExpectedExpenseManagement({ startTransition(async () => { const result = await deleteExpectedExpenses(selectedIds); if (result.success) { + invalidateDashboard('expectedExpense'); setData(prev => prev.filter(item => !selectedItems.has(item.id))); setSelectedItems(new Set()); toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`); @@ -492,6 +496,7 @@ export function ExpectedExpenseManagement({ startTransition(async () => { const result = await deleteExpectedExpense(deleteTargetId); if (result.success) { + invalidateDashboard('expectedExpense'); setData(prev => prev.filter(item => item.id !== deleteTargetId)); setSelectedItems(prev => { const newSet = new Set(prev); @@ -522,6 +527,7 @@ export function ExpectedExpenseManagement({ startTransition(async () => { const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate); if (result.success) { + invalidateDashboard('expectedExpense'); setData(prev => prev.map(item => selectedItems.has(item.id) ? { ...item, expectedPaymentDate: newExpectedDate } @@ -1185,21 +1191,12 @@ export function ExpectedExpenseManagement({
- + placeholder="계정과목 선택" + category="expense" + />
diff --git a/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx b/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx index 1785dbb4..df80c786 100644 --- a/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx +++ b/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx @@ -33,6 +33,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { AccountSubjectSelect } from '@/components/accounting/common'; import { Table, TableBody, @@ -56,14 +57,12 @@ import { getJournalDetail, updateJournalDetail, deleteJournalDetail, - getAccountSubjects, getVendorList, } from './actions'; import type { GeneralJournalRecord, JournalEntryRow, JournalSide, - AccountSubject, VendorOption, } from './types'; import { JOURNAL_SIDE_OPTIONS, JOURNAL_DIVISION_LABELS } from './types'; @@ -109,7 +108,6 @@ export function JournalEditModal({ const [accountNumber, setAccountNumber] = useState(''); // 옵션 데이터 - const [accountSubjects, setAccountSubjects] = useState([]); const [vendors, setVendors] = useState([]); // 데이터 로드 @@ -119,15 +117,11 @@ export function JournalEditModal({ const loadData = async () => { setIsLoading(true); try { - const [detailRes, subjectsRes, vendorsRes] = await Promise.all([ + const [detailRes, vendorsRes] = await Promise.all([ getJournalDetail(record.id), - getAccountSubjects({ category: 'all' }), getVendorList(), ]); - if (subjectsRes.success && subjectsRes.data) { - setAccountSubjects(subjectsRes.data.filter((s) => s.isActive)); - } if (vendorsRes.success && vendorsRes.data) { setVendors(vendorsRes.data); } @@ -361,24 +355,14 @@ export function JournalEditModal({
- + size="sm" + placeholder="선택" + /> - handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v) + handleRowChange(row.id, 'accountSubjectId', v) } - > - - - - - 선택 - {accountSubjects.map((s) => ( - - {s.name} - - ))} - - + size="sm" + placeholder="선택" + /> - + placeholder="선택" + size="sm" + /> handleChange('vendorName', value)} placeholder="공급자명" /> - handleChange('vendorBusinessNumber', value)} - placeholder="사업자번호" - /> +
+ + handleChange('vendorBusinessNumber', value)} + placeholder="000-00-00000" + /> +
diff --git a/src/components/accounting/TaxInvoiceManagement/actions.ts b/src/components/accounting/TaxInvoiceManagement/actions.ts index 743bf1ab..bf1b312b 100644 --- a/src/components/accounting/TaxInvoiceManagement/actions.ts +++ b/src/components/accounting/TaxInvoiceManagement/actions.ts @@ -8,8 +8,8 @@ import type { TaxInvoiceMgmtApiData, TaxInvoiceSummary, TaxInvoiceSummaryApiData, - CardHistoryRecord, CardHistoryApiData, + CardHistoryRecord, ManualEntryFormData, JournalEntryRow, } from './types'; @@ -20,17 +20,6 @@ import { transformSummaryApi, } from './types'; -// ===== 세금계산서 목록 Mock ===== -// TODO: 실제 API 연동 시 Mock 제거 -const MOCK_INVOICES: TaxInvoiceMgmtRecord[] = [ - { id: '1', division: 'sales', writeDate: '2026-01-15', issueDate: '2026-01-16', vendorName: '(주)삼성전자', vendorBusinessNumber: '124-81-00998', taxType: 'taxable', itemName: '전자부품', supplyAmount: 500000, taxAmount: 50000, totalAmount: 550000, receiptType: 'receipt', documentNumber: 'TI-001', status: 'journalized', source: 'hometax', memo: '' }, - { id: '2', division: 'sales', writeDate: '2026-01-20', issueDate: '2026-01-20', vendorName: '현대건설(주)', vendorBusinessNumber: '211-85-12345', taxType: 'taxable', itemName: '건축자재', supplyAmount: 1200000, taxAmount: 120000, totalAmount: 1320000, receiptType: 'claim', documentNumber: 'TI-002', status: 'pending', source: 'hometax', memo: '' }, - { id: '3', division: 'sales', writeDate: '2026-02-03', issueDate: null, vendorName: '(주)한국사무용품', vendorBusinessNumber: '107-86-55432', taxType: 'taxable', itemName: '사무용품', supplyAmount: 300000, taxAmount: 30000, totalAmount: 330000, receiptType: 'receipt', documentNumber: '', status: 'pending', source: 'manual', memo: '수기 입력' }, - { id: '4', division: 'purchase', writeDate: '2026-01-10', issueDate: '2026-01-11', vendorName: 'CJ대한통운', vendorBusinessNumber: '110-81-28388', taxType: 'taxable', itemName: '운송비', supplyAmount: 40000, taxAmount: 4000, totalAmount: 44000, receiptType: 'receipt', documentNumber: 'TI-003', status: 'journalized', source: 'hometax', memo: '' }, - { id: '5', division: 'purchase', writeDate: '2026-02-01', issueDate: '2026-02-01', vendorName: '스타벅스 역삼역점', vendorBusinessNumber: '201-86-99012', taxType: 'tax_free', itemName: '복리후생', supplyAmount: 14000, taxAmount: 1400, totalAmount: 15400, receiptType: 'receipt', documentNumber: 'TI-004', status: 'pending', source: 'hometax', memo: '' }, - { id: '6', division: 'purchase', writeDate: '2026-02-10', issueDate: null, vendorName: '(주)코스트코코리아', vendorBusinessNumber: '301-81-67890', taxType: 'taxable', itemName: '비품', supplyAmount: 200000, taxAmount: 20000, totalAmount: 220000, receiptType: 'claim', documentNumber: '', status: 'error', source: 'manual', memo: '수기 입력' }, -]; - // ===== 세금계산서 목록 조회 ===== export async function getTaxInvoices(params: { division?: string; @@ -41,45 +30,39 @@ export async function getTaxInvoices(params: { page?: number; perPage?: number; }) { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // return executePaginatedAction({ - // url: buildApiUrl('/api/v1/tax-invoices', { ... }), - // transform: transformApiToFrontend, - // errorMessage: '세금계산서 목록 조회에 실패했습니다.', - // }); - const filtered = MOCK_INVOICES.filter((inv) => inv.division === (params.division || 'sales')); - return { - success: true as const, - data: filtered, - error: undefined as string | undefined, - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: filtered.length }, - }; + // frontend 'purchase' → backend 'purchases' + const direction = params.division === 'purchase' ? 'purchases' : params.division; + + return executePaginatedAction({ + url: buildApiUrl('/api/v1/tax-invoices', { + direction, + issue_date_from: params.startDate, + issue_date_to: params.endDate, + corp_name: params.vendorSearch || undefined, + page: params.page, + per_page: params.perPage, + }), + transform: transformApiToFrontend, + errorMessage: '세금계산서 목록 조회에 실패했습니다.', + }); } // ===== 세금계산서 요약 조회 ===== -export async function getTaxInvoiceSummary(_params: { +export async function getTaxInvoiceSummary(params: { dateType?: string; startDate?: string; endDate?: string; vendorSearch?: string; }): Promise> { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // return executeServerAction({ ... }); - const sales = MOCK_INVOICES.filter((inv) => inv.division === 'sales'); - const purchase = MOCK_INVOICES.filter((inv) => inv.division === 'purchase'); - return { - success: true, - data: { - salesSupplyAmount: sales.reduce((s, i) => s + i.supplyAmount, 0), - salesTaxAmount: sales.reduce((s, i) => s + i.taxAmount, 0), - salesTotalAmount: sales.reduce((s, i) => s + i.totalAmount, 0), - salesCount: sales.length, - purchaseSupplyAmount: purchase.reduce((s, i) => s + i.supplyAmount, 0), - purchaseTaxAmount: purchase.reduce((s, i) => s + i.taxAmount, 0), - purchaseTotalAmount: purchase.reduce((s, i) => s + i.totalAmount, 0), - purchaseCount: purchase.length, - }, - }; + return executeServerAction({ + url: buildApiUrl('/api/v1/tax-invoices/summary', { + issue_date_from: params.startDate, + issue_date_to: params.endDate, + corp_name: params.vendorSearch || undefined, + }), + transform: transformSummaryApi, + errorMessage: '세금계산서 요약 조회에 실패했습니다.', + }); } // ===== 세금계산서 수기 등록 ===== @@ -96,35 +79,24 @@ export async function createTaxInvoice( } // ===== 카드 내역 조회 ===== -// TODO: 실제 API 연동 시 Mock 제거 -const MOCK_CARD_HISTORY: CardHistoryRecord[] = [ - { id: '1', transactionDate: '2026-01-20', merchantName: '(주)삼성전자', amount: 550000, approvalNumber: 'AP-20260120-001', businessNumber: '124-81-00998' }, - { id: '2', transactionDate: '2026-01-25', merchantName: '현대오일뱅크 강남점', amount: 82500, approvalNumber: 'AP-20260125-003', businessNumber: '211-85-12345' }, - { id: '3', transactionDate: '2026-02-03', merchantName: '(주)한국사무용품', amount: 330000, approvalNumber: 'AP-20260203-007', businessNumber: '107-86-55432' }, - { id: '4', transactionDate: '2026-02-10', merchantName: 'CJ대한통운', amount: 44000, approvalNumber: 'AP-20260210-012', businessNumber: '110-81-28388' }, - { id: '5', transactionDate: '2026-02-14', merchantName: '스타벅스 역삼역점', amount: 15400, approvalNumber: 'AP-20260214-019', businessNumber: '201-86-99012' }, -]; - -export async function getCardHistory(_params: { +export async function getCardHistory(params: { startDate?: string; endDate?: string; search?: string; page?: number; perPage?: number; -}): Promise> { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // return executePaginatedAction({ - // url: buildApiUrl('/api/v1/card-transactions/history', { - // start_date: _params.startDate, - // end_date: _params.endDate, - // search: _params.search || undefined, - // page: _params.page, - // per_page: _params.perPage, - // }), - // transform: transformCardHistoryApi, - // errorMessage: '카드 내역 조회에 실패했습니다.', - // }); - return { success: true, data: MOCK_CARD_HISTORY }; +}) { + return executePaginatedAction({ + url: buildApiUrl('/api/v1/card-transactions', { + start_date: params.startDate, + end_date: params.endDate, + search: params.search || undefined, + page: params.page, + per_page: params.perPage, + }), + transform: transformCardHistoryApi, + errorMessage: '카드 내역 조회에 실패했습니다.', + }); } // ===== 분개 내역 조회 ===== diff --git a/src/components/accounting/TaxInvoiceManagement/types.ts b/src/components/accounting/TaxInvoiceManagement/types.ts index 26ee5854..0db94ca8 100644 --- a/src/components/accounting/TaxInvoiceManagement/types.ts +++ b/src/components/accounting/TaxInvoiceManagement/types.ts @@ -45,12 +45,14 @@ export const RECEIPT_TYPE_LABELS: Record = { }; // ===== 세금계산서 상태 ===== -export type InvoiceStatus = 'pending' | 'journalized' | 'error'; +export type InvoiceStatus = 'draft' | 'issued' | 'sent' | 'cancelled' | 'failed'; export const INVOICE_STATUS_MAP: Record = { - pending: { label: '미분개', color: 'bg-yellow-100 text-yellow-700' }, - journalized: { label: '분개완료', color: 'bg-green-100 text-green-700' }, - error: { label: '오류', color: 'bg-red-100 text-red-700' }, + draft: { label: '임시저장', color: 'bg-gray-100 text-gray-700' }, + issued: { label: '발급완료', color: 'bg-blue-100 text-blue-700' }, + sent: { label: '전송완료', color: 'bg-green-100 text-green-700' }, + cancelled: { label: '취소', color: 'bg-red-100 text-red-700' }, + failed: { label: '실패', color: 'bg-orange-100 text-orange-700' }, }; // ===== 소스 구분 (수기/홈택스) ===== @@ -87,24 +89,25 @@ export interface TaxInvoiceMgmtRecord { memo: string; } -// ===== API 응답 타입 (snake_case) ===== +// ===== API 응답 타입 (백엔드 TaxInvoice 모델 기준) ===== export interface TaxInvoiceMgmtApiData { id: number; - division: string; - write_date: string; + direction: string; + supplier_corp_num: string | null; + supplier_corp_name: string | null; + buyer_corp_num: string | null; + buyer_corp_name: string | null; issue_date: string | null; - vendor_name: string; - vendor_business_number: string; - tax_type: string; - item_name: string; supply_amount: string | number; tax_amount: string | number; total_amount: string | number; - receipt_type: string; - document_number: string; status: string; - source: string; - memo: string | null; + invoice_type: string | null; + issue_type: string | null; + nts_confirm_num: string | null; + description: string | null; + barobill_invoice_id: string | null; + items: Array<{ name?: string; [key: string]: unknown }> | null; created_at: string; updated_at: string; } @@ -121,15 +124,20 @@ export interface TaxInvoiceSummary { purchaseCount: number; } +// 백엔드 summary API는 by_direction 중첩 구조로 응답 +interface DirectionSummary { + count: number; + supply_amount: number; + tax_amount: number; + total_amount: number; +} + export interface TaxInvoiceSummaryApiData { - sales_supply_amount: number; - sales_tax_amount: number; - sales_total_amount: number; - sales_count: number; - purchase_supply_amount: number; - purchase_tax_amount: number; - purchase_total_amount: number; - purchase_count: number; + by_direction: { + sales: DirectionSummary; + purchases: DirectionSummary; + }; + by_status: Record; } // ===== 분개 항목 ===== @@ -165,11 +173,12 @@ export interface CardHistoryRecord { export interface CardHistoryApiData { id: number; - transaction_date: string; + used_at: string; merchant_name: string; amount: string | number; - approval_number: string; - business_number: string; + approval_number?: string; + business_number?: string; + description?: string | null; } // ===== 수기 입력 폼 데이터 ===== @@ -202,40 +211,66 @@ export const ACCOUNT_SUBJECT_OPTIONS = [ ]; // ===== API → Frontend 변환 ===== +const VALID_STATUSES: InvoiceStatus[] = ['draft', 'issued', 'sent', 'cancelled', 'failed']; + +const INVOICE_TYPE_TO_TAX_TYPE: Record = { + tax_invoice: 'taxable', + modified: 'taxable', + invoice: 'tax_free', +}; + +const ISSUE_TYPE_TO_RECEIPT_TYPE: Record = { + receipt: 'receipt', + claim: 'claim', +}; + export function transformApiToFrontend(apiData: TaxInvoiceMgmtApiData): TaxInvoiceMgmtRecord { + const isSales = apiData.direction === 'sales'; return { id: String(apiData.id), - division: apiData.division as InvoiceTab, - writeDate: apiData.write_date, + division: isSales ? 'sales' : 'purchase', + writeDate: apiData.issue_date || apiData.created_at?.split('T')[0] || '', issueDate: apiData.issue_date, - vendorName: apiData.vendor_name, - vendorBusinessNumber: apiData.vendor_business_number, - taxType: apiData.tax_type as TaxType, - itemName: apiData.item_name, - supplyAmount: Number(apiData.supply_amount), - taxAmount: Number(apiData.tax_amount), - totalAmount: Number(apiData.total_amount), - receiptType: apiData.receipt_type as ReceiptType, - documentNumber: apiData.document_number, - status: apiData.status as InvoiceStatus, - source: apiData.source as InvoiceSource, - memo: apiData.memo || '', + vendorName: isSales + ? (apiData.buyer_corp_name || '') + : (apiData.supplier_corp_name || ''), + vendorBusinessNumber: isSales + ? (apiData.buyer_corp_num || '') + : (apiData.supplier_corp_num || ''), + taxType: INVOICE_TYPE_TO_TAX_TYPE[apiData.invoice_type || ''] || 'taxable', + itemName: apiData.items?.[0]?.name || apiData.description || '', + supplyAmount: Number(apiData.supply_amount) || 0, + taxAmount: Number(apiData.tax_amount) || 0, + totalAmount: Number(apiData.total_amount) || 0, + receiptType: ISSUE_TYPE_TO_RECEIPT_TYPE[apiData.issue_type || ''] || 'receipt', + documentNumber: apiData.nts_confirm_num || '', + status: VALID_STATUSES.includes(apiData.status as InvoiceStatus) + ? (apiData.status as InvoiceStatus) + : 'draft', + source: apiData.barobill_invoice_id ? 'hometax' : 'manual', + memo: apiData.description || '', }; } // ===== Frontend → API 변환 ===== export function transformFrontendToApi(data: ManualEntryFormData): Record { + const isSales = data.division === 'sales'; return { - division: data.division, - write_date: data.writeDate, - vendor_name: data.vendorName, - vendor_business_number: data.vendorBusinessNumber, + direction: isSales ? 'sales' : 'purchases', + issue_type: 'normal', + issue_date: data.writeDate, + // 매출: 거래처=공급받는자(buyer), 매입: 거래처=공급자(supplier) + // DB 컬럼이 NOT NULL이므로 빈 문자열로 전송 + supplier_corp_name: isSales ? '' : data.vendorName, + supplier_corp_num: isSales ? '' : data.vendorBusinessNumber, + buyer_corp_name: isSales ? data.vendorName : '', + buyer_corp_num: isSales ? data.vendorBusinessNumber : '', supply_amount: data.supplyAmount, tax_amount: data.taxAmount, total_amount: data.totalAmount, - item_name: data.itemName, - tax_type: data.taxType, - memo: data.memo || null, + invoice_type: data.taxType === 'tax_free' ? 'invoice' : 'tax_invoice', + description: data.memo || null, + items: data.itemName ? [{ name: data.itemName, amount: data.supplyAmount }] : [], }; } @@ -243,24 +278,28 @@ export function transformFrontendToApi(data: ManualEntryFormData): Record { const result = await deleteClient(id); if (result.success) { + invalidateDashboard('client'); toast.success('거래처가 삭제되었습니다.'); } return { success: result.success, error: result.error }; diff --git a/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx b/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx index dad9bccd..044f43b1 100644 --- a/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx +++ b/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx @@ -16,6 +16,7 @@ import { getBankAccounts, } from './actions'; import { useDevFill, generateWithdrawalData } from '@/components/dev'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; // ===== Props ===== interface WithdrawalDetailClientV2Props { @@ -82,6 +83,7 @@ export default function WithdrawalDetailClientV2({ : await updateWithdrawal(withdrawalId!, submitData as Partial); if (result.success) { + invalidateDashboard('withdrawal'); toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.'); router.push('/ko/accounting/withdrawals'); return { success: true }; @@ -99,6 +101,7 @@ export default function WithdrawalDetailClientV2({ const result = await deleteWithdrawal(withdrawalId); if (result.success) { + invalidateDashboard('withdrawal'); toast.success('출금 내역이 삭제되었습니다.'); router.push('/ko/accounting/withdrawals'); return { success: true }; diff --git a/src/components/accounting/WithdrawalManagement/index.tsx b/src/components/accounting/WithdrawalManagement/index.tsx index 1a92efc4..eaf2469c 100644 --- a/src/components/accounting/WithdrawalManagement/index.tsx +++ b/src/components/accounting/WithdrawalManagement/index.tsx @@ -72,9 +72,9 @@ import { deleteWithdrawal, updateWithdrawalTypes, getWithdrawals } from './actio import { formatNumber } from '@/lib/utils/amount'; import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search'; import { toast } from 'sonner'; +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; import { useDateRange } from '@/hooks'; import { - createDeleteItemHandler, extractUniqueOptions, createDateAmountSortFn, computeMonthlyTotal, @@ -237,7 +237,15 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra totalCount: initialData.length, }; }, - deleteItem: createDeleteItemHandler(deleteWithdrawal, setWithdrawalData, '출금 내역이 삭제되었습니다.'), + deleteItem: async (id: string) => { + const result = await deleteWithdrawal(id); + if (result.success) { + setWithdrawalData(prev => prev.filter(item => item.id !== id)); + invalidateDashboard('withdrawal'); + toast.success('출금 내역이 삭제되었습니다.'); + } + return { success: result.success, error: result.error }; + }, }, // 테이블 컬럼 diff --git a/src/components/accounting/common/AccountSubjectSelect.tsx b/src/components/accounting/common/AccountSubjectSelect.tsx new file mode 100644 index 00000000..38a96d2e --- /dev/null +++ b/src/components/accounting/common/AccountSubjectSelect.tsx @@ -0,0 +1,215 @@ +'use client'; + +/** + * 계정과목 Select 공용 컴포넌트 + * + * DB 마스터에서 활성 계정과목(소분류, depth=3)을 로드하여 검색 가능한 Select로 표시. + * "[코드] 계정과목명" 형태로 표시. 코드/이름으로 검색 가능. + * Popover + Command 패턴 (SearchableSelect 기반). + * props로 category 제한 가능. + */ + +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'; +import { cn } from '@/components/ui/utils'; +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { getAccountSubjects } from './actions'; +import type { AccountSubject, AccountSubjectCategory } from './types'; +import { formatAccountLabel } from './types'; + +interface AccountSubjectSelectProps { + value: string; + onValueChange: (value: string) => void; + /** 특정 대분류만 표시 */ + category?: AccountSubjectCategory; + /** 특정 중분류만 표시 */ + subCategory?: string; + /** 특정 부문만 표시 */ + departmentType?: string; + placeholder?: string; + disabled?: boolean; + className?: string; + /** 빈 값(전체) 옵션 표시 여부 */ + showAllOption?: boolean; + allOptionLabel?: string; + /** 트리거 크기 */ + size?: 'default' | 'sm'; + /** value/onValueChange에 사용할 필드 (기본: code) */ + valueField?: 'code' | 'id'; +} + +export function AccountSubjectSelect({ + value, + onValueChange, + category, + subCategory, + departmentType, + placeholder = '계정과목 선택', + disabled = false, + className, + showAllOption = false, + allOptionLabel = '전체', + size = 'default', + valueField = 'code', +}: AccountSubjectSelectProps) { + const [subjects, setSubjects] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const triggerRef = useRef(null); + + const loadSubjects = useCallback(async () => { + setIsLoading(true); + try { + const result = await getAccountSubjects({ + selectable: true, + isActive: true, + category: category || undefined, + subCategory: subCategory || undefined, + departmentType: departmentType || undefined, + }); + if (result.success && result.data) { + setSubjects(result.data); + } + } catch { + // 조회 실패 시 빈 목록 유지 + } finally { + setIsLoading(false); + } + }, [category, subCategory, departmentType]); + + useEffect(() => { + loadSubjects(); + }, [loadSubjects]); + + // subject에서 value로 사용할 필드 추출 + const getSubjectValue = useCallback( + (s: AccountSubject) => (valueField === 'id' ? s.id : s.code), + [valueField] + ); + + // 선택된 계정과목 찾기 + const selectedSubject = useMemo( + () => subjects.find((s) => getSubjectValue(s) === value), + [subjects, value, getSubjectValue] + ); + + // 트리거에 표시할 텍스트 + const displayLabel = useMemo(() => { + if (isLoading) return '로딩 중...'; + if (value === 'all' && showAllOption) return allOptionLabel; + if (selectedSubject) return formatAccountLabel(selectedSubject); + return ''; + }, [isLoading, value, showAllOption, allOptionLabel, selectedSubject]); + + const handleSelect = (subjectValue: string) => { + onValueChange(subjectValue); + setOpen(false); + setSearchQuery(''); + }; + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + setSearchQuery(''); + } + }; + + const triggerClassName = size === 'sm' ? 'h-8 text-sm' : 'h-9 text-sm'; + + return ( + + + + + + + + + 검색 결과가 없습니다 + + {showAllOption && ( + handleSelect('all')} + className="cursor-pointer" + > + + {allOptionLabel} + + )} + {subjects.map((subject) => { + const subjectVal = getSubjectValue(subject); + return ( + handleSelect(subjectVal)} + className="cursor-pointer" + > + + + {subject.code} + + {subject.name} + + ); + })} + + + + + + ); +} diff --git a/src/components/accounting/GeneralJournalEntry/AccountSubjectSettingModal.tsx b/src/components/accounting/common/AccountSubjectSettingModal.tsx similarity index 79% rename from src/components/accounting/GeneralJournalEntry/AccountSubjectSettingModal.tsx rename to src/components/accounting/common/AccountSubjectSettingModal.tsx index 74ead516..da911dcd 100644 --- a/src/components/accounting/GeneralJournalEntry/AccountSubjectSettingModal.tsx +++ b/src/components/accounting/common/AccountSubjectSettingModal.tsx @@ -1,17 +1,18 @@ 'use client'; /** - * 계정과목 설정 팝업 + * 계정과목 설정 모달 (공용) * * - 계정과목 추가: 코드, 계정과목명, 분류 Select, 추가 버튼 * - 검색: 검색 Input, 분류 필터 Select, 건수 표시 - * - 테이블: 코드 | 계정과목명 | 분류 | 상태(사용중/미사용 토글) | 작업(삭제) + * - 테이블: 코드 | 계정과목명 | 분류 | 부문 | 상태(사용중/미사용 토글) | 작업(삭제) + * - 기본 계정과목표 일괄 생성 버튼 * - 버튼: 닫기 */ import { useState, useCallback, useEffect, useMemo } from 'react'; import { toast } from 'sonner'; -import { Plus, Trash2, Loader2 } from 'lucide-react'; +import { Plus, Trash2, Loader2, Database } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; @@ -54,13 +55,16 @@ import { createAccountSubject, updateAccountSubjectStatus, deleteAccountSubject, + seedDefaultAccountSubjects, } from './actions'; import type { AccountSubject, AccountSubjectCategory } from './types'; import { ACCOUNT_CATEGORY_OPTIONS, ACCOUNT_CATEGORY_FILTER_OPTIONS, ACCOUNT_CATEGORY_LABELS, + DEPARTMENT_TYPE_LABELS, } from './types'; +import type { DepartmentType } from './types'; interface AccountSubjectSettingModalProps { open: boolean; @@ -84,6 +88,7 @@ export function AccountSubjectSettingModal({ // 데이터 const [subjects, setSubjects] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isSeeding, setIsSeeding] = useState(false); // 삭제 확인 const [deleteTarget, setDeleteTarget] = useState(null); @@ -195,10 +200,40 @@ export function AccountSubjectSettingModal({ } }, [deleteTarget, loadSubjects]); + // 기본 계정과목표 생성 + const handleSeedDefaults = useCallback(async () => { + setIsSeeding(true); + try { + const result = await seedDefaultAccountSubjects(); + if (result.success) { + const count = result.data?.inserted_count ?? 0; + if (count > 0) { + toast.success(`기본 계정과목 ${count}건이 생성되었습니다.`); + } else { + toast.info('이미 모든 기본 계정과목이 등록되어 있습니다.'); + } + loadSubjects(); + } else { + toast.error(result.error || '기본 계정과목 생성에 실패했습니다.'); + } + } catch { + toast.error('기본 계정과목 생성 중 오류가 발생했습니다.'); + } finally { + setIsSeeding(false); + } + }, [loadSubjects]); + + // depth에 따른 들여쓰기 + const getIndentClass = (depth: number) => { + if (depth === 1) return 'font-bold'; + if (depth === 2) return 'pl-4 font-medium'; + return 'pl-8'; + }; + return ( <> - + 계정과목 설정 계정과목을 추가, 검색, 상태변경, 삭제합니다 @@ -211,7 +246,7 @@ export function AccountSubjectSettingModal({ label="코드" value={newCode} onChange={setNewCode} - placeholder="코드" + placeholder="예: 10100" /> - - {filteredSubjects.length}개 + + {filteredSubjects.length}건 + @@ -289,30 +338,36 @@ export function AccountSubjectSettingModal({ - 코드 + 코드 계정과목명 - 분류 - 상태 - 작업 + 분류 + 부문 + 상태 + 작업 {filteredSubjects.length === 0 ? ( - - 계정과목이 없습니다. + + 계정과목이 없습니다. "기본 계정과목 생성" 버튼을 클릭하면 표준 계정과목표가 생성됩니다. ) : ( filteredSubjects.map((subject) => ( {subject.code} - {subject.name} + + {subject.name} + {ACCOUNT_CATEGORY_LABELS[subject.category]} + + {DEPARTMENT_TYPE_LABELS[subject.departmentType as DepartmentType] || '-'} +