From 23fa9c0ea29289c90f8e2d788e8685983adb8b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 22:19:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CEO=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=91=EB=8C=80=EB=B9=84/=EB=B3=B5=EB=A6=AC?= =?UTF-8?q?=ED=9B=84=EC=83=9D=EB=B9=84/=EB=A7=A4=EC=B6=9C=EC=B1=84?= =?UTF-8?q?=EA=B6=8C/=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=9A=8C=EA=B3=84=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 접대비/복리후생비 섹션: 리스크감지형 구조로 변경 - 매출채권 섹션: transformer/타입 정비 - 캘린더 섹션: ScheduleDetailModal 개선 - 카드관리 모달 transformer 확장 - useCEODashboard 훅 리팩토링 및 정리 - dashboard endpoints/types/transformers (expense, receivable, tax-benefits) 대폭 확장 - 회계 5개 페이지(은행거래, 카드거래, 매출채권, 세금계산서, 거래처원장) 기능 개선 - ApprovalBox 소폭 수정 - CLAUDE.md 업데이트 --- CLAUDE.md | 17 +- .../BankTransactionInquiry/index.tsx | 62 +++- .../CardTransactionInquiry/index.tsx | 65 +++- .../accounting/ReceivablesStatus/index.tsx | 59 ++-- .../accounting/TaxInvoiceManagement/index.tsx | 77 ++++- .../accounting/VendorLedger/index.tsx | 65 ++-- src/components/approval/ApprovalBox/index.tsx | 2 +- .../business/CEODashboard/CEODashboard.tsx | 288 +++++++++++++++--- .../business/CEODashboard/components.tsx | 4 +- .../cardManagementConfigTransformers.ts | 65 +++- .../modalConfigs/entertainmentConfigs.ts | 8 +- .../modals/DetailModalSections.tsx | 24 +- .../modals/ScheduleDetailModal.tsx | 83 ++--- .../CEODashboard/sections/CalendarSection.tsx | 32 +- .../sections/EntertainmentSection.tsx | 8 +- .../sections/ReceivableSection.tsx | 6 +- .../CEODashboard/sections/WelfareSection.tsx | 8 +- src/components/business/CEODashboard/types.ts | 1 + src/hooks/useCEODashboard.ts | 278 ++++++++--------- src/hooks/useCardManagementModals.ts | 18 +- src/hooks/useDashboardFetch.ts | 8 +- src/lib/api/dashboard/endpoints.ts | 25 +- src/lib/api/dashboard/transformers.ts | 2 +- src/lib/api/dashboard/transformers/expense.ts | 154 +++++++--- .../api/dashboard/transformers/receivable.ts | 101 ++---- .../dashboard/transformers/tax-benefits.ts | 285 +++++++++++++++-- src/lib/api/dashboard/types.ts | 193 +++++++++++- 27 files changed, 1427 insertions(+), 511 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bfc3edcf..d963b583 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,16 +326,19 @@ const [data, setData] = useState(() => { --- -## Backend API Analysis Policy +## Backend API Policy **Priority**: 🟡 -- Backend API 코드는 **분석만**, 직접 수정 안 함 -- 수정 필요 시 백엔드 요청 문서로 정리: +- **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리 +- **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능 +- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel) +- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수 +- 신규 API가 필요한 경우 요청 문서로 정리: ```markdown -## 백엔드 API 수정 요청 -### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX) -### 현재 문제: [설명] -### 수정 요청: [내용] +## 백엔드 API 신규 요청 +### 엔드포인트: [HTTP METHOD /api/v1/path] +### 목적: [설명] +### 요청/응답 구조: [내용] ``` --- diff --git a/src/components/accounting/BankTransactionInquiry/index.tsx b/src/components/accounting/BankTransactionInquiry/index.tsx index d58d9c7d..10eddf10 100644 --- a/src/components/accounting/BankTransactionInquiry/index.tsx +++ b/src/components/accounting/BankTransactionInquiry/index.tsx @@ -51,12 +51,27 @@ import { getBankAccountOptions, getFinancialInstitutions, batchSaveTransactions, - exportBankTransactionsExcel, type BankTransactionSummaryData, } from './actions'; import { TransactionFormModal } from './TransactionFormModal'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { formatNumber } from '@/lib/utils/amount'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; + +// ===== 엑셀 다운로드 컬럼 ===== +const excelColumns: ExcelColumn>[] = [ + { header: '거래일시', key: 'transactionDate', width: 12 }, + { header: '구분', key: 'type', width: 8, + transform: (v) => v === 'deposit' ? '입금' : '출금' }, + { header: '은행명', key: 'bankName', width: 12 }, + { header: '계좌명', key: 'accountName', width: 15 }, + { header: '적요/내용', key: 'note', width: 20 }, + { header: '입금', key: 'depositAmount', width: 14 }, + { header: '출금', key: 'withdrawalAmount', width: 14 }, + { header: '잔액', key: 'balance', width: 14 }, + { header: '취급점', key: 'branch', width: 12 }, + { header: '상대계좌예금주명', key: 'depositorName', width: 18 }, +]; // ===== 테이블 컬럼 정의 (체크박스 제외 10개) ===== const tableColumns = [ @@ -226,22 +241,45 @@ export function BankTransactionInquiry() { } }, [localChanges, loadData]); - // 엑셀 다운로드 + // 엑셀 다운로드 (프론트 xlsx 생성) const handleExcelDownload = useCallback(async () => { try { - const result = await exportBankTransactionsExcel({ - startDate, - endDate, - accountCategory: accountCategoryFilter, - financialInstitution: financialInstitutionFilter, - }); - if (result.success && result.data) { - window.open(result.data.downloadUrl, '_blank'); + toast.info('엑셀 파일 생성 중...'); + const allData: BankTransaction[] = []; + let page = 1; + let lastPage = 1; + + do { + const result = await getBankTransactionList({ + startDate, + endDate, + accountCategory: accountCategoryFilter, + financialInstitution: financialInstitutionFilter, + perPage: 100, + page, + }); + if (result.success && result.data.length > 0) { + allData.push(...result.data); + lastPage = result.pagination?.lastPage ?? 1; + } else { + break; + } + page++; + } while (page <= lastPage); + + if (allData.length > 0) { + await downloadExcel({ + data: allData as (BankTransaction & Record)[], + columns: excelColumns, + filename: '계좌입출금내역', + sheetName: '입출금내역', + }); + toast.success('엑셀 다운로드 완료'); } else { - toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); + toast.warning('다운로드할 데이터가 없습니다.'); } } catch { - toast.error('엑셀 다운로드 중 오류가 발생했습니다.'); + toast.error('엑셀 다운로드에 실패했습니다.'); } }, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]); diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 84e4e146..6357ef6d 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -55,6 +55,29 @@ import { JournalEntryModal } from './JournalEntryModal'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { formatNumber } from '@/lib/utils/amount'; import { filterByEnum } from '@/lib/utils/search'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; + +// ===== 엑셀 다운로드 컬럼 ===== +const excelColumns: ExcelColumn[] = [ + { header: '사용일시', key: 'usedAt', width: 18 }, + { header: '카드사', key: 'cardCompany', width: 10 }, + { header: '카드번호', key: 'card', width: 12 }, + { header: '카드명', key: 'cardName', width: 12 }, + { header: '공제', key: 'deductionType', width: 10, + transform: (v) => v === 'deductible' ? '공제' : '불공제' }, + { header: '사업자번호', key: 'businessNumber', width: 15 }, + { header: '가맹점명', key: 'merchantName', width: 15 }, + { header: '증빙/판매자상호', key: 'vendorName', width: 18 }, + { header: '내역', key: 'description', width: 15 }, + { header: '합계금액', key: 'totalAmount', width: 12 }, + { header: '공급가액', key: 'supplyAmount', width: 12 }, + { header: '세액', key: 'taxAmount', width: 10 }, + { header: '계정과목', key: 'accountSubject', width: 12, + transform: (v) => { + const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v); + return found?.label || String(v || ''); + }}, +]; // ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) ===== const tableColumns = [ @@ -269,9 +292,45 @@ export function CardTransactionInquiry() { setShowJournalEntry(true); }, []); - const handleExcelDownload = useCallback(() => { - toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.'); - }, []); + const handleExcelDownload = useCallback(async () => { + try { + toast.info('엑셀 파일 생성 중...'); + const allData: CardTransaction[] = []; + let page = 1; + let lastPage = 1; + + do { + const result = await getCardTransactionList({ + startDate, + endDate, + search: searchQuery || undefined, + perPage: 100, + page, + }); + if (result.success && result.data.length > 0) { + allData.push(...result.data); + lastPage = result.pagination?.lastPage ?? 1; + } else { + break; + } + page++; + } while (page <= lastPage); + + if (allData.length > 0) { + await downloadExcel>({ + data: allData as (CardTransaction & Record)[], + columns: excelColumns as ExcelColumn>[], + filename: '카드사용내역', + sheetName: '카드사용내역', + }); + toast.success('엑셀 다운로드 완료'); + } else { + toast.warning('다운로드할 데이터가 없습니다.'); + } + } catch { + toast.error('엑셀 다운로드에 실패했습니다.'); + } + }, [startDate, endDate, searchQuery]); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( diff --git a/src/components/accounting/ReceivablesStatus/index.tsx b/src/components/accounting/ReceivablesStatus/index.tsx index 347a2529..a79736cb 100644 --- a/src/components/accounting/ReceivablesStatus/index.tsx +++ b/src/components/accounting/ReceivablesStatus/index.tsx @@ -32,9 +32,10 @@ import { CATEGORY_LABELS, SORT_OPTIONS, } from './types'; -import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions'; +import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos } from './actions'; import { toast } from 'sonner'; import { filterByText } from '@/lib/utils/search'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; @@ -213,27 +214,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma }); }, []); - // ===== 엑셀 다운로드 핸들러 ===== + // ===== 엑셀 다운로드 핸들러 (프론트 xlsx 생성) ===== const handleExcelDownload = useCallback(async () => { - const result = await exportReceivablesExcel({ - year: selectedYear, - search: searchQuery || undefined, - }); - - if (result.success && result.data) { - const url = URL.createObjectURL(result.data); - const a = document.createElement('a'); - a.href = url; - a.download = result.filename || '채권현황.xlsx'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - toast.success('엑셀 파일이 다운로드되었습니다.'); - } else { - toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); + try { + toast.info('엑셀 파일 생성 중...'); + // 데이터가 이미 로드되어 있으므로 sortedData 사용 + if (sortedData.length === 0) { + toast.warning('다운로드할 데이터가 없습니다.'); + return; + } + // 동적 월 컬럼 포함 엑셀 컬럼 생성 + const columns: ExcelColumn>[] = [ + { header: '거래처', key: 'vendorName', width: 20 }, + { header: '연체', key: 'isOverdue', width: 8 }, + ...monthLabels.map((label, idx) => ({ + header: label, key: `month_${idx}`, width: 12, + })), + { header: '합계', key: 'total', width: 14 }, + { header: '메모', key: 'memo', width: 20 }, + ]; + // 미수금 카테고리 기준으로 플랫 데이터 생성 + const exportData = sortedData.map(vendor => { + const receivable = vendor.categories.find(c => c.category === 'receivable'); + const row: Record = { + vendorName: vendor.vendorName, + isOverdue: vendor.isOverdue ? '연체' : '', + }; + monthLabels.forEach((_, idx) => { + row[`month_${idx}`] = receivable?.amounts.values[idx] || 0; + }); + row.total = receivable?.amounts.total || 0; + row.memo = vendor.memo || ''; + return row; + }); + await downloadExcel({ data: exportData, columns, filename: '미수금현황', sheetName: '미수금현황' }); + toast.success('엑셀 다운로드 완료'); + } catch { + toast.error('엑셀 다운로드에 실패했습니다.'); } - }, [selectedYear, searchQuery]); + }, [sortedData, monthLabels]); // ===== 변경된 연체 항목 확인 ===== const changedOverdueItems = useMemo(() => { diff --git a/src/components/accounting/TaxInvoiceManagement/index.tsx b/src/components/accounting/TaxInvoiceManagement/index.tsx index ec534423..f64a2ca3 100644 --- a/src/components/accounting/TaxInvoiceManagement/index.tsx +++ b/src/components/accounting/TaxInvoiceManagement/index.tsx @@ -45,8 +45,8 @@ import { MobileCard } from '@/components/organisms/MobileCard'; import { getTaxInvoices, getTaxInvoiceSummary, - downloadTaxInvoiceExcel, } from './actions'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; const ManualEntryModal = dynamic( () => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })), @@ -58,6 +58,10 @@ import type { TaxInvoiceMgmtRecord, InvoiceTab, TaxInvoiceSummary, + TaxType, + ReceiptType, + InvoiceStatus, + InvoiceSource, } from './types'; import { TAB_OPTIONS, @@ -77,6 +81,26 @@ const QUARTER_BUTTONS = [ { value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 }, ]; +// ===== 엑셀 다운로드 컬럼 ===== +const excelColumns: ExcelColumn>[] = [ + { header: '작성일자', key: 'writeDate', width: 12 }, + { header: '발급일자', key: 'issueDate', width: 12 }, + { header: '거래처', key: 'vendorName', width: 20 }, + { header: '사업자번호', key: 'vendorBusinessNumber', width: 15 }, + { header: '과세형태', key: 'taxType', width: 10, + transform: (v) => TAX_TYPE_LABELS[v as TaxType] || String(v || '') }, + { header: '품목', key: 'itemName', width: 15 }, + { header: '공급가액', key: 'supplyAmount', width: 14 }, + { header: '세액', key: 'taxAmount', width: 14 }, + { header: '합계', key: 'totalAmount', width: 14 }, + { header: '영수청구', key: 'receiptType', width: 10, + transform: (v) => RECEIPT_TYPE_LABELS[v as ReceiptType] || String(v || '') }, + { header: '상태', key: 'status', width: 10, + transform: (v) => INVOICE_STATUS_MAP[v as InvoiceStatus]?.label || String(v || '') }, + { header: '발급형태', key: 'source', width: 10, + transform: (v) => INVOICE_SOURCE_LABELS[v as InvoiceSource] || String(v || '') }, +]; + // ===== 테이블 컬럼 ===== const tableColumns = [ { key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true }, @@ -224,19 +248,46 @@ export function TaxInvoiceManagement() { loadData(); }, [loadData]); - // ===== 엑셀 다운로드 ===== + // ===== 엑셀 다운로드 (프론트 xlsx 생성) ===== const handleExcelDownload = useCallback(async () => { - const result = await downloadTaxInvoiceExcel({ - division: activeTab, - dateType, - startDate, - endDate, - vendorSearch, - }); - if (result.success && result.data) { - window.open(result.data.url, '_blank'); - } else { - toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); + try { + toast.info('엑셀 파일 생성 중...'); + const allData: TaxInvoiceMgmtRecord[] = []; + let page = 1; + let lastPage = 1; + + do { + const result = await getTaxInvoices({ + division: activeTab, + dateType, + startDate, + endDate, + vendorSearch, + page, + perPage: 100, + }); + if (result.success && result.data.length > 0) { + allData.push(...result.data); + lastPage = result.pagination?.lastPage ?? 1; + } else { + break; + } + page++; + } while (page <= lastPage); + + if (allData.length > 0) { + await downloadExcel({ + data: allData as (TaxInvoiceMgmtRecord & Record)[], + columns: excelColumns, + filename: `세금계산서_${activeTab === 'sales' ? '매출' : '매입'}`, + sheetName: activeTab === 'sales' ? '매출' : '매입', + }); + toast.success('엑셀 다운로드 완료'); + } else { + toast.warning('다운로드할 데이터가 없습니다.'); + } + } catch { + toast.error('엑셀 다운로드에 실패했습니다.'); } }, [activeTab, dateType, startDate, endDate, vendorSearch]); diff --git a/src/components/accounting/VendorLedger/index.tsx b/src/components/accounting/VendorLedger/index.tsx index 629c986e..fcf1ba23 100644 --- a/src/components/accounting/VendorLedger/index.tsx +++ b/src/components/accounting/VendorLedger/index.tsx @@ -26,8 +26,9 @@ import { type StatCard, } from '@/components/templates/UniversalListPage'; import type { VendorLedgerItem, VendorLedgerSummary } from './types'; -import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions'; +import { getVendorLedgerList, getVendorLedgerSummary } from './actions'; import { formatNumber } from '@/lib/utils/amount'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; @@ -43,6 +44,16 @@ const tableColumns = [ { key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true }, ]; +// ===== 엑셀 컬럼 정의 ===== +const excelColumns: ExcelColumn>[] = [ + { header: '거래처명', key: 'vendorName', width: 20 }, + { header: '이월잔액', key: 'carryoverBalance', width: 14 }, + { header: '매출', key: 'sales', width: 14 }, + { header: '수금', key: 'collection', width: 14 }, + { header: '잔액', key: 'balance', width: 14 }, + { header: '결제일', key: 'paymentDate', width: 12 }, +]; + // ===== Props ===== interface VendorLedgerProps { initialData?: VendorLedgerItem[]; @@ -144,24 +155,42 @@ export function VendorLedger({ ); const handleExcelDownload = useCallback(async () => { - const result = await exportVendorLedgerExcel({ - startDate, - endDate, - search: searchQuery || undefined, - }); + try { + toast.info('엑셀 파일 생성 중...'); + const allData: VendorLedgerItem[] = []; + let page = 1; + let lastPage = 1; - if (result.success && result.data) { - const url = URL.createObjectURL(result.data); - const a = document.createElement('a'); - a.href = url; - a.download = result.filename || '거래처원장.xlsx'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - toast.success('엑셀 파일이 다운로드되었습니다.'); - } else { - toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); + do { + const result = await getVendorLedgerList({ + startDate, + endDate, + search: searchQuery || undefined, + perPage: 100, + page, + }); + if (result.success && result.data.length > 0) { + allData.push(...result.data); + lastPage = result.pagination?.lastPage ?? 1; + } else { + break; + } + page++; + } while (page <= lastPage); + + if (allData.length > 0) { + await downloadExcel>({ + data: allData as (VendorLedgerItem & Record)[], + columns: excelColumns, + filename: '거래처원장', + sheetName: '거래처원장', + }); + toast.success('엑셀 다운로드 완료'); + } else { + toast.warning('다운로드할 데이터가 없습니다.'); + } + } catch { + toast.error('엑셀 다운로드에 실패했습니다.'); } }, [startDate, endDate, searchQuery]); diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx index 02d8f50c..1ff38345 100644 --- a/src/components/approval/ApprovalBox/index.tsx +++ b/src/components/approval/ApprovalBox/index.tsx @@ -546,7 +546,7 @@ export function ApprovalBox() { dateRangeSelector: { enabled: true, - showPresets: false, + showPresets: true, startDate, endDate, onStartDateChange: setStartDate, diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index f83320b1..4f8da43c 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -34,15 +34,17 @@ import { ScheduleDetailModal, DetailModal } from './modals'; import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog'; import { LazySection } from './LazySection'; import { EmptySection } from './components'; -import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard'; +import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useEntertainmentDetail, useWelfare, useWelfareDetail, useVatDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard'; import { useCardManagementModals } from '@/hooks/useCardManagementModals'; import { getMonthlyExpenseModalConfig, getCardManagementModalConfig, + getCardManagementModalConfigWithData, getEntertainmentModalConfig, getWelfareModalConfig, getVatModalConfig, } from './modalConfigs'; +import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers'; export function CEODashboard() { const router = useRouter(); @@ -138,11 +140,17 @@ export function CEODashboard() { const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [dashboardSettings, setDashboardSettings] = useState(DEFAULT_DASHBOARD_SETTINGS); + // EntertainmentDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언 + const entertainmentDetailData = useEntertainmentDetail(); + // WelfareDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언 const welfareDetailData = useWelfareDetail({ calculationType: dashboardSettings.welfare.calculationType, }); + // VatDetail Hook (부가세 상세 모달용 API) + const vatDetailData = useVatDetail(); + // MonthlyExpenseDetail Hook (당월 예상 지출 모달용 상세 API) const monthlyExpenseDetailData = useMonthlyExpenseDetail(); @@ -231,9 +239,66 @@ export function CEODashboard() { } }, [monthlyExpenseDetailData]); - // 당월 예상 지출 모달 날짜/검색 필터 변경 → 재조회 + // 모달 날짜/검색 필터 변경 → 재조회 (당월 예상 지출 + 가지급금 + 접대비 상세) const handleDateFilterChange = useCallback(async (params: { startDate: string; endDate: string; search: string }) => { if (!currentModalCardId) return; + + // cm2: 가지급금 상세 모달 날짜 필터 + if (currentModalCardId === 'cm2') { + try { + const modalData = await cardManagementModals.fetchModalData('cm2', { + start_date: params.startDate, + end_date: params.endDate, + }); + const config = getCardManagementModalConfigWithData('cm2', modalData); + if (config) { + setDetailModalConfig(config); + } + } catch { + // 실패 시 기존 config 유지 + } + return; + } + + // 복리후생비 상세 모달 날짜 필터 + if (currentModalCardId === 'welfare_detail') { + try { + const response = await fetch( + `/api/proxy/welfare/detail?calculation_type=${dashboardSettings.welfare.calculationType}&start_date=${params.startDate}&end_date=${params.endDate}`, + ); + if (response.ok) { + const result = await response.json(); + if (result.success) { + const config = transformWelfareDetailResponse(result.data); + setDetailModalConfig(config); + } + } + } catch { + // 실패 시 기존 config 유지 + } + return; + } + + // 접대비 상세 모달 날짜 필터 + if (currentModalCardId === 'entertainment_detail') { + try { + const response = await fetch( + `/api/proxy/entertainment/detail?company_type=${dashboardSettings.entertainment.companyType}&start_date=${params.startDate}&end_date=${params.endDate}`, + ); + if (response.ok) { + const result = await response.json(); + if (result.success) { + const config = transformEntertainmentDetailResponse(result.data); + setDetailModalConfig(config); + } + } + } catch { + // 실패 시 기존 config 유지 + } + return; + } + + // 당월 예상 지출 모달 날짜 필터 const config = await monthlyExpenseDetailData.fetchData( currentModalCardId as MonthlyExpenseCardId, params, @@ -241,7 +306,7 @@ export function CEODashboard() { if (config) { setDetailModalConfig(config); } - }, [currentModalCardId, monthlyExpenseDetailData]); + }, [currentModalCardId, monthlyExpenseDetailData, cardManagementModals, dashboardSettings.entertainment, dashboardSettings.welfare]); // 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체) const handleMonthlyExpenseClick = useCallback(() => { @@ -249,41 +314,96 @@ export function CEODashboard() { // 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달 // 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달 - const handleCardManagementCardClick = useCallback((cardId: string) => { - const config = getCardManagementModalConfig('cm2'); - if (config) { - setDetailModalConfig(config); - setIsDetailModalOpen(true); + const handleCardManagementCardClick = useCallback(async (cardId: string) => { + try { + const modalData = await cardManagementModals.fetchModalData('cm2'); + const config = getCardManagementModalConfigWithData('cm2', modalData); + if (config) { + setCurrentModalCardId('cm2'); + setDetailModalConfig(config); + setIsDetailModalOpen(true); + } + } catch { + // API 실패 시 fallback mock 데이터 사용 + const config = getCardManagementModalConfig('cm2'); + if (config) { + setCurrentModalCardId('cm2'); + setDetailModalConfig(config); + setIsDetailModalOpen(true); + } } - }, []); + }, [cardManagementModals]); - // 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달) - const handleEntertainmentCardClick = useCallback((cardId: string) => { - const config = getEntertainmentModalConfig(cardId); + // 접대비 현황 카드 클릭 - API 데이터로 모달 열기 (fallback: 정적 config) + const handleEntertainmentCardClick = useCallback(async (cardId: string) => { + // et_sales 카드는 별도 정적 config 사용 (매출 상세) + if (cardId === 'et_sales') { + const config = getEntertainmentModalConfig(cardId); + if (config) { + setDetailModalConfig(config); + setIsDetailModalOpen(true); + } + return; + } + + // 리스크 카드 → API에서 상세 데이터 fetch, 반환값 직접 사용 + setCurrentModalCardId('entertainment_detail'); + const apiConfig = await entertainmentDetailData.refetch(); + const config = apiConfig ?? getEntertainmentModalConfig(cardId); if (config) { setDetailModalConfig(config); setIsDetailModalOpen(true); } - }, []); + }, [entertainmentDetailData]); // 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달) // 복리후생비 클릭 - API 데이터로 모달 열기 (fallback: 정적 config) const handleWelfareCardClick = useCallback(async () => { - // 1. 먼저 API에서 데이터 fetch 시도 - await welfareDetailData.refetch(); - - // 2. API 데이터가 있으면 사용, 없으면 fallback config 사용 - const config = welfareDetailData.modalConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType); + const apiConfig = await welfareDetailData.refetch(); + const config = apiConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType); setDetailModalConfig(config); + setCurrentModalCardId('welfare_detail'); setIsDetailModalOpen(true); }, [welfareDetailData, dashboardSettings.welfare.calculationType]); - // 부가세 클릭 (모든 카드가 동일한 상세 모달) - const handleVatClick = useCallback(() => { - const config = getVatModalConfig(); + // 신고기간 변경 시 API 재호출 + const handlePeriodChange = useCallback(async (periodValue: string) => { + // periodValue: "2026-quarter-1" → parse + const parts = periodValue.split('-'); + if (parts.length < 3) return; + const [year, periodType, period] = parts; + try { + const response = await fetch( + `/api/proxy/vat/detail?period_type=${periodType}&year=${year}&period=${period}`, + ); + if (response.ok) { + const result = await response.json(); + if (result.success) { + const config = transformVatDetailResponse(result.data); + // 새 config에도 onPeriodChange 콜백 주입 + if (config.periodSelect) { + config.periodSelect.onPeriodChange = handlePeriodChange; + } + setDetailModalConfig(config); + } + } + } catch { + // 실패 시 기존 config 유지 + } + }, []); + + // 부가세 클릭 (모든 카드가 동일한 상세 모달) - API 데이터로 열기 (fallback: 정적 config) + const handleVatClick = useCallback(async () => { + setCurrentModalCardId('vat_detail'); + const apiConfig = await vatDetailData.refetch(); + const config = apiConfig ?? getVatModalConfig(); + // onPeriodChange 콜백 주입 + if (config.periodSelect) { + config.periodSelect.onPeriodChange = handlePeriodChange; + } setDetailModalConfig(config); setIsDetailModalOpen(true); - }, []); + }, [vatDetailData, handlePeriodChange]); // 캘린더 일정 클릭 (기존 일정 수정) const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => { @@ -303,8 +423,8 @@ export function CEODashboard() { setSelectedSchedule(null); }, []); - // 일정 저장 - const handleScheduleSave = useCallback((formData: { + // 일정 저장 (optimistic update — refetch 없이 로컬 상태만 갱신) + const handleScheduleSave = useCallback(async (formData: { title: string; department: string; startDate: string; @@ -315,17 +435,114 @@ export function CEODashboard() { color: string; content: string; }) => { - // TODO: API 호출하여 일정 저장 - setIsScheduleModalOpen(false); - setSelectedSchedule(null); - }, []); + try { + // schedule_ 접두사에서 실제 ID 추출 + const rawId = selectedSchedule?.id; + const numericId = rawId?.startsWith('schedule_') ? rawId.replace('schedule_', '') : null; - // 일정 삭제 - const handleScheduleDelete = useCallback((id: string) => { - // TODO: API 호출하여 일정 삭제 - setIsScheduleModalOpen(false); - setSelectedSchedule(null); - }, []); + const body = { + title: formData.title, + description: formData.content, + start_date: formData.startDate, + end_date: formData.endDate, + start_time: formData.isAllDay ? null : (formData.startTime || null), + end_time: formData.isAllDay ? null : (formData.endTime || null), + is_all_day: formData.isAllDay, + color: formData.color || null, + }; + + const url = numericId + ? `/api/proxy/calendar/schedules/${numericId}` + : '/api/proxy/calendar/schedules'; + const method = numericId ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error('Failed to save schedule'); + + // API 응답에서 실제 ID 추출 (없으면 임시 ID) + let savedId = numericId; + try { + const result = await response.json(); + savedId = result.data?.id?.toString() || numericId || `temp_${Date.now()}`; + } catch { + savedId = numericId || `temp_${Date.now()}`; + } + + const updatedSchedule: CalendarScheduleItem = { + id: `schedule_${savedId}`, + title: formData.title, + startDate: formData.startDate, + endDate: formData.endDate, + startTime: formData.isAllDay ? undefined : formData.startTime, + endTime: formData.isAllDay ? undefined : formData.endTime, + isAllDay: formData.isAllDay, + type: 'schedule', + department: formData.department !== 'all' ? formData.department : undefined, + color: formData.color, + }; + + // Optimistic update: loading 변화 없이 데이터만 갱신 → 캘린더만 리렌더 + calendarData.setData((prev) => { + if (!prev) return { items: [updatedSchedule], totalCount: 1 }; + if (numericId) { + // 수정: 기존 항목 교체 + return { + ...prev, + items: prev.items.map((item) => + item.id === rawId ? updatedSchedule : item + ), + }; + } + // 신규: 추가 + return { + ...prev, + items: [...prev.items, updatedSchedule], + totalCount: prev.totalCount + 1, + }; + }); + } catch { + // 에러 시 서버 데이터로 동기화 + calendarData.refetch(); + } finally { + setIsScheduleModalOpen(false); + setSelectedSchedule(null); + } + }, [selectedSchedule, calendarData]); + + // 일정 삭제 (optimistic update) + const handleScheduleDelete = useCallback(async (id: string) => { + try { + // schedule_ 접두사에서 실제 ID 추출 + const numericId = id.startsWith('schedule_') ? id.replace('schedule_', '') : id; + + const response = await fetch(`/api/proxy/calendar/schedules/${numericId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete schedule'); + + // Optimistic update: 삭제된 항목만 제거 → 캘린더만 리렌더 + calendarData.setData((prev) => { + if (!prev) return prev; + return { + ...prev, + items: prev.items.filter((item) => item.id !== id), + totalCount: Math.max(0, prev.totalCount - 1), + }; + }); + } catch { + // 에러 시 서버 데이터로 동기화 + calendarData.refetch(); + } finally { + setIsScheduleModalOpen(false); + setSelectedSchedule(null); + } + }, [calendarData]); // 섹션 순서 const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; @@ -548,13 +765,14 @@ export function CEODashboard() { {sectionOrder.map(renderDashboardSection)} - {/* 일정 상세 모달 */} + {/* 일정 상세 모달 — schedule_ 접두사만 수정/삭제 가능 */} {/* 항목 설정 모달 */} diff --git a/src/components/business/CEODashboard/components.tsx b/src/components/business/CEODashboard/components.tsx index ae61ef70..ff59e89c 100644 --- a/src/components/business/CEODashboard/components.tsx +++ b/src/components/business/CEODashboard/components.tsx @@ -319,8 +319,8 @@ export const AmountCardItem = ({
{card.subItems.map((item, idx) => (
- {item.label} - {typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value} + {item.label} + {typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}
))}
diff --git a/src/components/business/CEODashboard/modalConfigs/cardManagementConfigTransformers.ts b/src/components/business/CEODashboard/modalConfigs/cardManagementConfigTransformers.ts index 6c7e688c..4ddae7cb 100644 --- a/src/components/business/CEODashboard/modalConfigs/cardManagementConfigTransformers.ts +++ b/src/components/business/CEODashboard/modalConfigs/cardManagementConfigTransformers.ts @@ -175,21 +175,39 @@ export function transformCm1ModalConfig( // cm2: 가지급금 상세 모달 변환기 // ============================================ +/** 카테고리 키 → 한글 라벨 매핑 + * - category_breakdown 키: 영문 (card, congratulatory, ...) + * - loans[].category: 한글 (카드, 경조사, ...) — 백엔드 category_label accessor + * 양쪽 모두 대응 + */ +const CATEGORY_LABELS: Record = { + // 영문 키 (category_breakdown용) + card: '카드', + congratulatory: '경조사', + gift_certificate: '상품권', + entertainment: '접대비', + // 한글 값 (loans[].category가 이미 한글인 경우 — 그대로 통과) + '카드': '카드', + '경조사': '경조사', + '상품권': '상품권', + '접대비': '접대비', +}; + /** * 가지급금 대시보드 API 응답을 cm2 모달 설정으로 변환 */ export function transformCm2ModalConfig( data: LoanDashboardApiResponse ): DetailModalConfig { - const { summary, items = [] } = data; + const { summary, category_breakdown, loans = [] } = data; - // 테이블 데이터 매핑 - const tableData = (items || []).map((item) => ({ + // 테이블 데이터 매핑 (백엔드 필드명 기준, 영문 키 → 한글 변환) + const tableData = (loans || []).map((item) => ({ date: item.loan_date, - classification: item.status_label || '카드', - category: '-', + classification: CATEGORY_LABELS[item.category] || item.category || '카드', + category: item.status_label || '-', amount: item.amount, - content: item.description, + response: item.content, })); // 분류 필터 옵션 동적 생성 @@ -202,22 +220,42 @@ export function transformCm2ModalConfig( })), ]; + // reviewCards: category_breakdown에서 4개 카테고리 카드 생성 + const reviewCards = category_breakdown + ? { + title: '가지급금 검토 필요', + cards: Object.entries(category_breakdown).map(([key, breakdown]) => ({ + label: CATEGORY_LABELS[key] || key, + amount: breakdown.outstanding_amount, + subLabel: breakdown.unverified_count > 0 + ? `미증빙 ${breakdown.unverified_count}건` + : `${breakdown.total_count}건`, + })), + } + : undefined; + return { title: '가지급금 상세', + dateFilter: { + enabled: true, + defaultPreset: '당월', + showSearch: true, + }, summaryCards: [ { label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) }, { label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' }, - { label: '미정리/미분류', value: `${summary.pending_count ?? 0}건` }, + { label: '건수', value: `${summary.outstanding_count ?? 0}건` }, ], + reviewCards, table: { - title: '가지급금 관련 내역', + title: '가지급금 내역', columns: [ { key: 'no', label: 'No.', align: 'center' }, { key: 'date', label: '발생일', align: 'center' }, { key: 'classification', label: '분류', align: 'center' }, { key: 'category', label: '구분', align: 'center' }, { key: 'amount', label: '금액', align: 'right', format: 'currency' }, - { key: 'content', label: '내용', align: 'left' }, + { key: 'response', label: '대응', align: 'left' }, ], data: tableData, filters: [ @@ -227,11 +265,12 @@ export function transformCm2ModalConfig( defaultValue: 'all', }, { - key: 'category', + key: 'sortOrder', options: [ - { value: 'all', label: '전체' }, - { value: '카드명', label: '카드명' }, - { value: '계좌명', label: '계좌명' }, + { value: 'all', label: '정렬' }, + { value: 'amountDesc', label: '금액 높은순' }, + { value: 'amountAsc', label: '금액 낮은순' }, + { value: 'latest', label: '최신순' }, ], defaultValue: 'all', }, diff --git a/src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts b/src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts index 39164a5b..dfb1bfb4 100644 --- a/src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts +++ b/src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts @@ -224,11 +224,15 @@ export function getEntertainmentModalConfig(cardId: string): DetailModalConfig | totalColumnKey: 'amount', }, }, - // et_limit, et_remaining, et_used는 모두 동일한 접대비 상세 모달 + // D1.7 리스크감지형 카드 ID → 접대비 상세 모달 + et_weekend: entertainmentDetailConfig, + et_prohibited: entertainmentDetailConfig, + et_high_amount: entertainmentDetailConfig, + et_no_receipt: entertainmentDetailConfig, + // 레거시 카드 ID (하위 호환) et_limit: entertainmentDetailConfig, et_remaining: entertainmentDetailConfig, et_used: entertainmentDetailConfig, - // 대시보드 카드 ID (et1~et4) → 접대비 상세 모달 et1: entertainmentDetailConfig, et2: entertainmentDetailConfig, et3: entertainmentDetailConfig, diff --git a/src/components/business/CEODashboard/modals/DetailModalSections.tsx b/src/components/business/CEODashboard/modals/DetailModalSections.tsx index 3015dd6c..d4c3a227 100644 --- a/src/components/business/CEODashboard/modals/DetailModalSections.tsx +++ b/src/components/business/CEODashboard/modals/DetailModalSections.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { Search as SearchIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -58,6 +58,16 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }); const [searchText, setSearchText] = useState(''); + const isInitialMount = useRef(true); + + // 날짜 변경 시 자동 조회 (다른 페이지와 동일한 UX) + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + onFilterChange?.({ startDate, endDate, search: searchText }); + }, [startDate, endDate]); const handleSearch = useCallback(() => { onFilterChange?.({ startDate, endDate, search: searchText }); @@ -88,11 +98,6 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt /> )} - {onFilterChange && ( - - )} } /> @@ -103,10 +108,15 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => { const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || ''); + const handleChange = useCallback((value: string) => { + setSelected(value); + config.onPeriodChange?.(value); + }, [config]); + return (
신고기간 - diff --git a/src/components/business/CEODashboard/modals/ScheduleDetailModal.tsx b/src/components/business/CEODashboard/modals/ScheduleDetailModal.tsx index 976d385c..7131661f 100644 --- a/src/components/business/CEODashboard/modals/ScheduleDetailModal.tsx +++ b/src/components/business/CEODashboard/modals/ScheduleDetailModal.tsx @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Checkbox } from '@/components/ui/checkbox'; import { TimePicker } from '@/components/ui/time-picker'; -import { DatePicker } from '@/components/ui/date-picker'; +import { DateRangePicker } from '@/components/ui/date-range-picker'; import { Dialog, DialogContent, @@ -21,6 +21,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; import type { CalendarScheduleItem } from '../types'; // 색상 옵션 @@ -59,6 +60,7 @@ interface ScheduleDetailModalProps { schedule: CalendarScheduleItem | null; onSave: (data: ScheduleFormData) => void; onDelete?: (id: string) => void; + isEditable?: boolean; } export function ScheduleDetailModal({ @@ -67,6 +69,7 @@ export function ScheduleDetailModal({ schedule, onSave, onDelete, + isEditable = true, }: ScheduleDetailModalProps) { const isEditMode = schedule && schedule.id !== ''; @@ -128,7 +131,14 @@ export function ScheduleDetailModal({ !open && handleCancel()}> - 일정 상세 + + 일정 상세 + {!isEditable && ( + + 읽기전용 + + )} +
@@ -139,6 +149,7 @@ export function ScheduleDetailModal({ value={formData.title} onChange={(e) => handleFieldChange('title', e.target.value)} placeholder="제목" + disabled={!isEditable} />
@@ -148,6 +159,7 @@ export function ScheduleDetailModal({