diff --git a/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx b/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx index a59937c4..897a5b4e 100644 --- a/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx @@ -55,7 +55,7 @@ export default function BadDebtCollectionPage() { return ( ); } diff --git a/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx b/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx index 0428751c..c519a1c3 100644 --- a/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx +++ b/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx @@ -39,8 +39,10 @@ import type { } from './types'; import { STATUS_SELECT_OPTIONS, + COLLECTION_END_REASON_OPTIONS, VENDOR_TYPE_LABELS, } from './types'; +import type { CollectionEndReason } from './types'; import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; @@ -87,6 +89,7 @@ const getEmptyRecord = (): Omit assignedManagerId: null, assignedManager: null, settingToggle: true, + collectionEndReason: undefined, badDebtCount: 0, badDebts: [], files: [], @@ -778,22 +781,47 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp {/* 상태 */}
- +
+ + {formData.status === 'collectionEnd' && ( + + )} +
{/* 연체일수 */}
diff --git a/src/components/accounting/BadDebtCollection/actions.ts b/src/components/accounting/BadDebtCollection/actions.ts index c90cb9c0..3dd07fe6 100644 --- a/src/components/accounting/BadDebtCollection/actions.ts +++ b/src/components/accounting/BadDebtCollection/actions.ts @@ -72,8 +72,9 @@ function mapApiStatusToFrontend(apiStatus: string): CollectionStatus { switch (apiStatus) { case 'collecting': return 'collecting'; case 'legal_action': return 'legalAction'; - case 'recovered': return 'recovered'; - case 'bad_debt': return 'badDebt'; + case 'recovered': + case 'bad_debt': + case 'collection_end': return 'collectionEnd'; default: return 'collecting'; } } @@ -82,8 +83,7 @@ function mapFrontendStatusToApi(status: CollectionStatus): string { switch (status) { case 'collecting': return 'collecting'; case 'legalAction': return 'legal_action'; - case 'recovered': return 'recovered'; - case 'badDebt': return 'bad_debt'; + case 'collectionEnd': return 'collection_end'; default: return 'collecting'; } } diff --git a/src/components/accounting/BadDebtCollection/index.tsx b/src/components/accounting/BadDebtCollection/index.tsx index 0e599edb..95d5e297 100644 --- a/src/components/accounting/BadDebtCollection/index.tsx +++ b/src/components/accounting/BadDebtCollection/index.tsx @@ -15,7 +15,8 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2'; import { useState, useMemo, useCallback, useTransition } from 'react'; import { useRouter } from 'next/navigation'; -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -56,6 +57,7 @@ const tableColumns = [ { key: 'managerName', label: '담당자', className: 'w-[100px]', sortable: true }, { key: 'status', label: '상태', className: 'text-center w-[100px]', sortable: true }, { key: 'setting', label: '설정', className: 'text-center w-[80px]' }, + { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, ]; // ===== Props 타입 정의 ===== @@ -65,8 +67,7 @@ interface BadDebtCollectionProps { total_amount: number; collecting_amount: number; legal_action_amount: number; - recovered_amount: number; - bad_debt_amount: number; + collection_end_amount: number; } | null; } @@ -132,7 +133,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec totalAmount: initialSummary.total_amount, collectingAmount: initialSummary.collecting_amount, legalActionAmount: initialSummary.legal_action_amount, - recoveredAmount: initialSummary.recovered_amount, + collectionEndAmount: initialSummary.collection_end_amount, }; } @@ -144,11 +145,11 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec const legalActionAmount = data .filter((d) => d.status === 'legalAction') .reduce((sum, d) => sum + d.debtAmount, 0); - const recoveredAmount = data - .filter((d) => d.status === 'recovered') + const collectionEndAmount = data + .filter((d) => d.status === 'collectionEnd') .reduce((sum, d) => sum + d.debtAmount, 0); - return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount }; + return { totalAmount, collectingAmount, legalActionAmount, collectionEndAmount }; }, [data, initialSummary]); // ===== UniversalListPage Config ===== @@ -335,7 +336,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec }, { label: '회수완료', - value: `${formatNumber(statsData.recoveredAmount)}원`, + value: `${formatNumber(statsData.collectionEndAmount)}원`, icon: AlertTriangle, iconColor: 'text-green-500', }, @@ -390,6 +391,27 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec disabled={isPending} /> + {/* 작업 */} + e.stopPropagation()}> +
+ + +
+
), diff --git a/src/components/accounting/BadDebtCollection/types.ts b/src/components/accounting/BadDebtCollection/types.ts index 3d4c83bb..d46b81e1 100644 --- a/src/components/accounting/BadDebtCollection/types.ts +++ b/src/components/accounting/BadDebtCollection/types.ts @@ -1,7 +1,15 @@ // ===== 악성채권 추심관리 타입 정의 ===== // 추심 상태 -export type CollectionStatus = 'collecting' | 'legalAction' | 'recovered' | 'badDebt'; +export type CollectionStatus = 'collecting' | 'legalAction' | 'collectionEnd'; + +// 추심종료 사유 +export type CollectionEndReason = 'recovered' | 'badDebt'; + +export const COLLECTION_END_REASON_OPTIONS: { value: CollectionEndReason; label: string }[] = [ + { value: 'recovered', label: '회수완료' }, + { value: 'badDebt', label: '대손처리' }, +]; // 정렬 옵션 export type SortOption = 'latest' | 'oldest'; @@ -70,6 +78,7 @@ export interface BadDebtRecord { debtAmount: number; // 총 미수금액 badDebtCount: number; // 악성채권 건수 status: CollectionStatus; // 대표 상태 (가장 최근) + collectionEndReason?: CollectionEndReason; // 추심종료 사유 (status === 'collectionEnd'일 때) overdueDays: number; // 최대 연체일수 overdueToggle: boolean; occurrenceDate: string; diff --git a/src/components/accounting/DailyReport/index.tsx b/src/components/accounting/DailyReport/index.tsx index b1bd44cb..555889e0 100644 --- a/src/components/accounting/DailyReport/index.tsx +++ b/src/components/accounting/DailyReport/index.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { format, parseISO } from 'date-fns'; +import { format, parseISO, subMonths, startOfMonth, endOfMonth } from 'date-fns'; import { ko } from 'date-fns/locale'; -import { Download, FileText, Loader2, RefreshCw, Calendar } from 'lucide-react'; +import { Download, FileText, Loader2, Printer, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { @@ -15,18 +15,27 @@ import { TableRow, TableFooter, } from '@/components/ui/table'; -import { DatePicker } from '@/components/ui/date-picker'; +import { DateRangePicker } from '@/components/ui/date-range-picker'; +import { Input } from '@/components/ui/input'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { formatNumber as formatAmount } from '@/lib/utils/amount'; -import { Badge } from '@/components/ui/badge'; import type { NoteReceivableItem, DailyAccountItem } from './types'; -import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types'; import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; +// ===== 빠른 월 선택 버튼 정의 ===== +const QUICK_MONTH_BUTTONS = [ + { label: '이번달', months: 0 }, + { label: '지난달', months: 1 }, + { label: 'D-2월', months: 2 }, + { label: 'D-3월', months: 3 }, + { label: 'D-4월', months: 4 }, + { label: 'D-5월', months: 5 }, +] as const; + // ===== Props 인터페이스 ===== interface DailyReportProps { initialNoteReceivables?: NoteReceivableItem[]; @@ -36,7 +45,9 @@ interface DailyReportProps { export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts = [] }: DailyReportProps) { const { canExport } = usePermission(); // ===== 상태 관리 ===== - const [selectedDate, setSelectedDate] = useState(() => format(new Date(), 'yyyy-MM-dd')); + const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); + const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd')); + const [searchTerm, setSearchTerm] = useState(''); const [noteReceivables, setNoteReceivables] = useState(initialNoteReceivables); const [dailyAccounts, setDailyAccounts] = useState(initialDailyAccounts); const [summary, setSummary] = useState<{ @@ -53,9 +64,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts setIsLoading(true); try { const [noteResult, accountResult, summaryResult] = await Promise.all([ - getNoteReceivables({ date: selectedDate }), - getDailyAccounts({ date: selectedDate }), - getDailyReportSummary({ date: selectedDate }), + getNoteReceivables({ date: startDate }), + getDailyAccounts({ date: startDate }), + getDailyReportSummary({ date: startDate }), ]); if (noteResult.success) { @@ -81,20 +92,20 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts } finally { setIsLoading(false); } - }, [selectedDate]); + }, [startDate]); // ===== 초기 로드 및 날짜 변경시 재로드 ===== const isInitialMount = useRef(true); - const prevDateRef = useRef(selectedDate); + const prevDateRef = useRef(startDate); useEffect(() => { // 초기 마운트 또는 날짜가 실제로 변경된 경우에만 로드 - if (isInitialMount.current || prevDateRef.current !== selectedDate) { + if (isInitialMount.current || prevDateRef.current !== startDate) { isInitialMount.current = false; - prevDateRef.current = selectedDate; + prevDateRef.current = startDate; loadData(); } - }, [selectedDate, loadData]); + }, [startDate, loadData]); // ===== 어음 합계 (API 요약 사용) ===== const noteReceivableTotal = useMemo(() => { @@ -144,9 +155,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts }, [accountTotals]); // ===== 선택된 날짜 정보 ===== - const selectedDateInfo = useMemo(() => { + const startDateInfo = useMemo(() => { try { - const date = parseISO(selectedDate); + const date = parseISO(startDate); return { formatted: format(date, 'yyyy년 M월 d일', { locale: ko }), dayOfWeek: format(date, 'EEEE', { locale: ko }), @@ -154,12 +165,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts } catch { return { formatted: '', dayOfWeek: '' }; } - }, [selectedDate]); + }, [startDate]); // ===== 엑셀 다운로드 (프록시 API 직접 호출) ===== const handleExcelDownload = useCallback(async () => { try { - const url = `/api/proxy/daily-report/export?date=${selectedDate}`; + const url = `/api/proxy/daily-report/export?date=${startDate}`; const response = await fetch(url); if (!response.ok) { @@ -169,7 +180,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts const blob = await response.blob(); const contentDisposition = response.headers.get('Content-Disposition'); - const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${selectedDate}.xlsx`; + const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${startDate}.xlsx`; const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -183,7 +194,36 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts } catch { toast.error('엑셀 다운로드 중 오류가 발생했습니다.'); } - }, [selectedDate]); + }, [startDate]); + + // ===== 빠른 월 선택 ===== + const handleQuickMonth = useCallback((monthsAgo: number) => { + const target = monthsAgo === 0 ? new Date() : subMonths(new Date(), monthsAgo); + setStartDate(format(startOfMonth(target), 'yyyy-MM-dd')); + setEndDate(format(endOfMonth(target), 'yyyy-MM-dd')); + }, []); + + // ===== 인쇄 ===== + const handlePrint = useCallback(() => { + window.print(); + }, []); + + // ===== 검색 필터링 ===== + const filteredNoteReceivables = useMemo(() => { + if (!searchTerm) return noteReceivables; + const term = searchTerm.toLowerCase(); + return noteReceivables.filter(item => + item.content.toLowerCase().includes(term) + ); + }, [noteReceivables, searchTerm]); + + const filteredDailyAccounts = useMemo(() => { + if (!searchTerm) return dailyAccounts; + const term = searchTerm.toLowerCase(); + return dailyAccounts.filter(item => + item.category.toLowerCase().includes(term) + ); + }, [dailyAccounts, searchTerm]); return ( @@ -194,42 +234,57 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts icon={FileText} /> - {/* 헤더 액션 (날짜 선택, 버튼 등) */} + {/* 헤더 액션 (날짜 선택, 빠른 월 선택, 검색, 인쇄) */} - -
-
- - 조회 일자 - -
-
- - {canExport && ( - - )} + ))} +
+ {/* 검색 + 인쇄/엑셀 - 모바일: 세로 배치 */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="검색..." + className="pl-8 h-8 text-sm" + /> +
+
+ + {canExport && ( + + )} +
@@ -237,19 +292,19 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts {/* 어음 및 외상매출채권현황 */} - -
-

어음 및 외상매출채권현황

+ +
+

어음 및 외상매출채권현황

-
-
+
+
- + - 내용 - 현재 잔액 - 발행일 - 만기일 + 내용 + 현재 잔액 + 발행일 + 만기일 @@ -258,32 +313,32 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
- 데이터를 불러오는 중... + 데이터를 불러오는 중...
- ) : noteReceivables.length === 0 ? ( + ) : filteredNoteReceivables.length === 0 ? ( - + 데이터가 없습니다. ) : ( - noteReceivables.map((item) => ( + filteredNoteReceivables.map((item) => ( - {item.content} - {formatAmount(item.currentBalance)} - {item.issueDate} - {item.dueDate} + {item.content} + {formatAmount(item.currentBalance)} + {item.issueDate} + {item.dueDate} )) )}
- {noteReceivables.length > 0 && ( - + {filteredNoteReceivables.length > 0 && ( + - 합계 - {formatAmount(noteReceivableTotal)} + 합계 + {formatAmount(noteReceivableTotal)} @@ -297,82 +352,63 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts {/* 일자별 상세 */} - -
-

- 일자: {selectedDateInfo.formatted} {selectedDateInfo.dayOfWeek} + +
+

+ 일자: {startDateInfo.formatted} {startDateInfo.dayOfWeek}

-
+

- 구분 - 상태 - 전월 이월 - 수입 - 지출 - 잔액 + 구분 + 입금 + 출금 + 잔액 {isLoading ? ( - +
- 데이터를 불러오는 중... + 데이터를 불러오는 중...
- ) : dailyAccounts.length === 0 ? ( + ) : filteredDailyAccounts.length === 0 ? ( - + 데이터가 없습니다. ) : ( <> {/* KRW 계좌들 */} - {dailyAccounts + {filteredDailyAccounts .filter(item => item.currency === 'KRW') .map((item) => ( - {item.category} - - - {MATCH_STATUS_LABELS[item.matchStatus]} - - - {formatAmount(item.carryover)} - {formatAmount(item.income)} - {formatAmount(item.expense)} - {formatAmount(item.balance)} + {item.category} + {formatAmount(item.income)} + {formatAmount(item.expense)} + {formatAmount(item.balance)} ))} )}
- {dailyAccounts.length > 0 && ( + {filteredDailyAccounts.length > 0 && ( - {/* 외화원 (USD) 합계 */} - - 외화원 (USD) 합계 - - ${formatAmount(accountTotals.usd.carryover)} - ${formatAmount(accountTotals.usd.income)} - ${formatAmount(accountTotals.usd.expense)} - ${formatAmount(accountTotals.usd.balance)} - - {/* 현금성 자산 합계 */} + {/* 합계 */} - 현금성 자산 합계 - - {formatAmount(cashAssetTotal.carryover)} - {formatAmount(cashAssetTotal.income)} - {formatAmount(cashAssetTotal.expense)} - {formatAmount(cashAssetTotal.balance)} + 합계 + {formatAmount(cashAssetTotal.income)} + {formatAmount(cashAssetTotal.expense)} + {formatAmount(cashAssetTotal.balance)} )} @@ -381,6 +417,114 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts + + {/* 예금 입출금 내역 */} + + +
+

예금 입출금 내역

+
+
+ {/* 입금 */} +
+
+ 입금 +
+
+
+ + + 입금처/적요 + 금액 + + + + {isLoading ? ( + + + + + + ) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0).length === 0 ? ( + + + 입금 내역이 없습니다. + + + ) : ( + filteredDailyAccounts + .filter(item => item.currency === 'KRW' && item.income > 0) + .map((item) => ( + + +
{item.category}
+
+ {formatAmount(item.income)} +
+ )) + )} +
+ + + 입금 합계 + {formatAmount(cashAssetTotal.income)} + + +
+
+
+ + {/* 출금 */} +
+
+ 출금 +
+
+ + + + 출금처/적요 + 금액 + + + + {isLoading ? ( + + + + + + ) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0).length === 0 ? ( + + + 출금 내역이 없습니다. + + + ) : ( + filteredDailyAccounts + .filter(item => item.currency === 'KRW' && item.expense > 0) + .map((item) => ( + + +
{item.category}
+
+ {formatAmount(item.expense)} +
+ )) + )} +
+ + + 출금 합계 + {formatAmount(cashAssetTotal.expense)} + + +
+
+
+
+ + ); -} \ No newline at end of file +} diff --git a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx index 7966b22e..1c4f8c20 100644 --- a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx +++ b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx @@ -24,8 +24,7 @@ import { purchaseConfig } from './purchaseConfig'; import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail'; import type { ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types'; import { getApprovalById } from '@/components/approval/DocumentCreate/actions'; -import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types'; -import { PURCHASE_TYPE_LABELS } from './types'; +import type { PurchaseRecord, PurchaseItem } from './types'; import { getPurchaseById, createPurchase, @@ -74,7 +73,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd')); const [vendorId, setVendorId] = useState(''); const [vendorName, setVendorName] = useState(''); - const [purchaseType, setPurchaseType] = useState('unset'); + // purchaseType 삭제됨 (기획서 P.109) const [items, setItems] = useState([createEmptyItem()]); const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false); @@ -126,7 +125,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { setPurchaseDate(data.purchaseDate); setVendorId(data.vendorId); setVendorName(data.vendorName); - setPurchaseType(data.purchaseType); setItems(data.items.length > 0 ? data.items : [createEmptyItem()]); setTaxInvoiceReceived(data.taxInvoiceReceived); setSourceDocument(data.sourceDocument); @@ -250,7 +248,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { supplyAmount: totals.supplyAmount, vat: totals.vat, totalAmount: totals.total, - purchaseType, taxInvoiceReceived, }; @@ -275,7 +272,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { } finally { setIsSaving(false); } - }, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId]); + }, [purchaseDate, vendorId, totals, taxInvoiceReceived, isNewMode, purchaseId]); // ===== 삭제 (IntegratedDetailTemplate 호환) ===== const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => { @@ -301,179 +298,101 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { const renderFormContent = () => ( <>
- {/* ===== 기본 정보 섹션 ===== */} + {/* ===== 기본 정보 섹션 (품의서/지출결의서 + 예상비용) ===== */} 기본 정보 - - {/* 품의서/지출결의서인 경우 전용 레이아웃 */} - {sourceDocument ? ( - <> - {/* 문서 타입 및 열람 버튼 */} -
-
- - {sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} - - 연결된 문서가 있습니다 -
+ +
+ {/* 품의서/지출결의서 */} +
+ +
+
- - {/* 품의서/지출결의서용 필드 */} -
- {/* 품의서/지출결의서 제목 */} -
- - -
- - {/* 예상비용 */} -
- - -
- - {/* 매입번호 */} -
- - -
- - {/* 거래처명 */} -
- - -
- - {/* 매입 유형 */} -
- - -
-
- - ) : ( - /* 일반 매입 (품의서/지출결의서 없는 경우) */ -
- {/* 매입번호 */} -
- - -
- - {/* 매입일 */} -
- - -
- - {/* 거래처명 */} -
- - -
- - {/* 매입 유형 */} -
- - -
- )} + + {/* 예상비용 */} +
+ + +
+
+ + + + {/* ===== 매입 정보 섹션 ===== */} + + + 매입 정보 + + +
+ {/* 매입번호 */} +
+ + +
+ + {/* 매입일 */} +
+ + +
+ + {/* 거래처명 */} +
+ + +
+
diff --git a/src/components/accounting/PurchaseManagement/index.tsx b/src/components/accounting/PurchaseManagement/index.tsx index c2665743..e47c54c4 100644 --- a/src/components/accounting/PurchaseManagement/index.tsx +++ b/src/components/accounting/PurchaseManagement/index.tsx @@ -26,8 +26,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; -import { Badge } from '@/components/ui/badge'; -import { getPresetStyle } from '@/lib/utils/status-config'; +// Badge, getPresetStyle removed (매입유형/연결문서 컬럼 삭제) import { Switch } from '@/components/ui/switch'; import { Dialog, @@ -57,9 +56,7 @@ import { MobileCard } from '@/components/organisms/MobileCard'; import type { PurchaseRecord } from './types'; import { SORT_OPTIONS, - PURCHASE_TYPE_LABELS, - PURCHASE_TYPE_FILTER_OPTIONS, - ISSUANCE_FILTER_OPTIONS, + TAX_INVOICE_RECEIVED_FILTER_OPTIONS, ACCOUNT_SUBJECT_SELECTOR_OPTIONS, } from './types'; import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions'; @@ -71,11 +68,9 @@ const tableColumns = [ { key: 'purchaseNo', label: '매입번호', sortable: true }, { key: 'purchaseDate', label: '매입일', sortable: true }, { key: 'vendorName', label: '거래처', sortable: true }, - { key: 'sourceDocument', label: '연결문서', className: 'text-center', sortable: true }, { key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true }, { key: 'vat', label: '부가세', className: 'text-right', sortable: true }, { key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true }, - { key: 'purchaseType', label: '매입유형', className: 'text-center', sortable: true }, { key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' }, ]; @@ -92,8 +87,7 @@ export function PurchaseManagement() { // 통합 필터 상태 (filterConfig 기반) const [filterValues, setFilterValues] = useState>({ vendor: 'all', - purchaseType: 'all', - issuance: 'all', + taxInvoiceReceived: 'all', sort: 'latest', }); @@ -142,9 +136,8 @@ export function PurchaseManagement() { return date.getMonth() === currentMonth && date.getFullYear() === currentYear; }) .reduce((sum, d) => sum + d.totalAmount, 0); - const unsetTypeCount = purchaseData.filter(d => d.purchaseType === 'unset').length; const taxInvoicePendingCount = purchaseData.filter(d => !d.taxInvoiceReceived).length; - return { totalPurchaseAmount, monthlyAmount, unsetTypeCount, taxInvoicePendingCount }; + return { totalPurchaseAmount, monthlyAmount, taxInvoicePendingCount }; }, [purchaseData]); // ===== 거래처 목록 (필터용) ===== @@ -163,17 +156,10 @@ export function PurchaseManagement() { allOptionLabel: '거래처 전체', }, { - key: 'purchaseType', - label: '매입유형', + key: 'taxInvoiceReceived', + label: '세금계산서 수취여부', type: 'single', - options: PURCHASE_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'), - allOptionLabel: '전체', - }, - { - key: 'issuance', - label: '발행여부', - type: 'single', - options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + options: TAX_INVOICE_RECEIVED_FILTER_OPTIONS.filter(o => o.value !== 'all'), allOptionLabel: '전체', }, { @@ -194,8 +180,7 @@ export function PurchaseManagement() { const handleFilterReset = useCallback(() => { setFilterValues({ vendor: 'all', - purchaseType: 'all', - issuance: 'all', + taxInvoiceReceived: 'all', sort: 'latest', }); }, []); @@ -309,18 +294,16 @@ export function PurchaseManagement() { } const vendorVal = fv.vendor as string; - const purchaseTypeVal = fv.purchaseType as string; - const issuanceVal = fv.issuance as string; + const taxInvoiceReceivedVal = fv.taxInvoiceReceived as string; // 거래처 필터 if (vendorVal !== 'all' && item.vendorName !== vendorVal) { return false; } - // 매입유형 필터 - if (purchaseTypeVal !== 'all' && item.purchaseType !== purchaseTypeVal) { + // 세금계산서 수취여부 필터 + if (taxInvoiceReceivedVal === 'received' && !item.taxInvoiceReceived) { return false; } - // 발행여부 필터 - if (issuanceVal === 'taxInvoicePending' && item.taxInvoiceReceived) { + if (taxInvoiceReceivedVal === 'notReceived' && item.taxInvoiceReceived) { return false; } return true; @@ -393,9 +376,8 @@ export function PurchaseManagement() { // Stats 카드 computeStats: (): StatCard[] => [ - { label: '총 매입', value: `${formatNumber(stats.totalPurchaseAmount)}원`, icon: Receipt, iconColor: 'text-blue-500' }, + { label: '총매입', value: `${formatNumber(stats.totalPurchaseAmount)}원`, icon: Receipt, iconColor: 'text-blue-500' }, { label: '당월 매입', value: `${formatNumber(stats.monthlyAmount)}원`, icon: Receipt, iconColor: 'text-green-500' }, - { label: '매입유형 미설정', value: `${stats.unsetTypeCount}건`, icon: Receipt, iconColor: 'text-orange-500' }, { label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-red-500' }, ], @@ -406,13 +388,10 @@ export function PurchaseManagement() { 합계 - - {formatNumber(tableTotals.totalSupplyAmount)} {formatNumber(tableTotals.totalVat)} {formatNumber(tableTotals.totalAmount)} - ), @@ -428,9 +407,7 @@ export function PurchaseManagement() { index: number, globalIndex: number, handlers: SelectionHandlers & RowClickHandlers - ) => { - const isUnsetType = item.purchaseType === 'unset'; - return ( + ) => ( {item.purchaseNo} {item.purchaseDate} {item.vendorName} - - {item.sourceDocument ? ( - - {item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} - - ) : ( - - - )} - {formatNumber(item.supplyAmount)} {formatNumber(item.vat)} {formatNumber(item.totalAmount)} - - - {PURCHASE_TYPE_LABELS[item.purchaseType]} - - e.stopPropagation()}>
- ); - }, + ), // 모바일 카드 렌더링 renderMobileCard: ( @@ -488,14 +447,11 @@ export function PurchaseManagement() { key={item.id} title={item.vendorName} subtitle={item.purchaseNo} - badge={PURCHASE_TYPE_LABELS[item.purchaseType]} - badgeVariant="outline" isSelected={handlers.isSelected} onToggle={handlers.onToggle} onClick={() => handleRowClick(item)} details={[ { label: '매입일', value: item.purchaseDate }, - { label: '연결문서', value: item.sourceDocument ? (item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서') : '-' }, { label: '공급가액', value: `${formatNumber(item.supplyAmount)}원` }, { label: '합계금액', value: `${formatNumber(item.totalAmount)}원` }, ]} diff --git a/src/components/accounting/PurchaseManagement/types.ts b/src/components/accounting/PurchaseManagement/types.ts index 88f2cab8..55e0a6ff 100644 --- a/src/components/accounting/PurchaseManagement/types.ts +++ b/src/components/accounting/PurchaseManagement/types.ts @@ -81,8 +81,8 @@ export interface PurchaseRecord { // 정렬 옵션 export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow'; -// 발행여부 필터 -export type IssuanceFilter = 'all' | 'taxInvoicePending'; +// 세금계산서 수취여부 필터 +export type TaxInvoiceReceivedFilter = 'all' | 'received' | 'notReceived'; // ===== 상수 정의 ===== @@ -154,10 +154,11 @@ export const PURCHASE_TYPE_FILTER_OPTIONS: { value: string; label: string }[] = { value: 'unset', label: '미설정' }, ]; -// 발행여부 필터 옵션 -export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [ +// 세금계산서 수취여부 필터 옵션 +export const TAX_INVOICE_RECEIVED_FILTER_OPTIONS: { value: TaxInvoiceReceivedFilter; label: string }[] = [ { value: 'all', label: '전체' }, - { value: 'taxInvoicePending', label: '세금계산서 미수취' }, + { value: 'received', label: '수취 확인' }, + { value: 'notReceived', label: '수취 미확인' }, ]; // 계정과목명 셀렉터 옵션 (상단 일괄 변경용) diff --git a/src/components/accounting/SalesManagement/SalesDetail.tsx b/src/components/accounting/SalesManagement/SalesDetail.tsx index f4374527..c5fc902a 100644 --- a/src/components/accounting/SalesManagement/SalesDetail.tsx +++ b/src/components/accounting/SalesManagement/SalesDetail.tsx @@ -32,8 +32,7 @@ import { import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable'; import { salesConfig } from './salesConfig'; -import type { SalesRecord, SalesItem, SalesType } from './types'; -import { SALES_TYPE_OPTIONS } from './types'; +import type { SalesRecord, SalesItem } from './types'; import { getSaleById, createSale, updateSale, deleteSale } from './actions'; import { toast } from 'sonner'; import { getClients } from '../VendorManagement/actions'; @@ -78,7 +77,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { const [salesDate, setSalesDate] = useState(format(new Date(), 'yyyy-MM-dd')); const [vendorId, setVendorId] = useState(''); const [vendorName, setVendorName] = useState(''); - const [salesType, setSalesType] = useState('product'); const [items, setItems] = useState([createEmptyItem()]); const [taxInvoiceIssued, setTaxInvoiceIssued] = useState(false); const [transactionStatementIssued, setTransactionStatementIssued] = useState(false); @@ -126,7 +124,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { setSalesDate(data.salesDate); setVendorId(data.vendorId); setVendorName(data.vendorName); - setSalesType(data.salesType); setItems(data.items.length > 0 ? data.items : [createEmptyItem()]); setTaxInvoiceIssued(data.taxInvoiceIssued); setTransactionStatementIssued(data.transactionStatementIssued); @@ -158,7 +155,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { const saleData: Partial = { salesDate, vendorId, - salesType, items, totalSupplyAmount: totals.supplyAmount, totalVat: totals.vat, @@ -189,7 +185,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { } finally { setIsSaving(false); } - }, [salesDate, vendorId, salesType, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]); + }, [salesDate, vendorId, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]); // ===== 삭제 (IntegratedDetailTemplate 호환) ===== const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => { @@ -268,23 +264,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
- - {/* 매출 유형 */} -
- - -
@@ -318,28 +297,42 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { 세금계산서 -
-
- - +
+
+
+ + +
+
+ {taxInvoiceIssued ? ( + + + 발행완료 + + ) : ( + + + 미발행 + + )} +
-
- {taxInvoiceIssued ? ( - - - 발행완료 - - ) : ( - - - 미발행 - - )} +
+
diff --git a/src/components/accounting/SalesManagement/index.tsx b/src/components/accounting/SalesManagement/index.tsx index 8b0b90bf..3dd10606 100644 --- a/src/components/accounting/SalesManagement/index.tsx +++ b/src/components/accounting/SalesManagement/index.tsx @@ -25,7 +25,6 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; -import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Dialog, @@ -55,11 +54,8 @@ import { MobileCard } from '@/components/organisms/MobileCard'; import type { SalesRecord } from './types'; import { SORT_OPTIONS, - SALES_STATUS_LABELS, - SALES_STATUS_COLORS, - SALES_TYPE_LABELS, - SALES_TYPE_FILTER_OPTIONS, - ISSUANCE_FILTER_OPTIONS, + TAX_INVOICE_FILTER_OPTIONS, + TRANSACTION_STATEMENT_FILTER_OPTIONS, ACCOUNT_SUBJECT_SELECTOR_OPTIONS, } from './types'; import { getSales, deleteSale, toggleSaleIssuance } from './actions'; @@ -83,7 +79,6 @@ const tableColumns = [ { key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true }, { key: 'vat', label: '부가세', className: 'text-right', sortable: true }, { key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true }, - { key: 'salesType', label: '매출유형', className: 'text-center', sortable: true }, { key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' }, { key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' }, ]; @@ -113,8 +108,8 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem // 필터 초기값 (filterConfig 기반 - ULP가 내부 state로 관리) const initialFilterValues: Record = { vendor: 'all', - salesType: 'all', - issuance: 'all', + taxInvoice: 'all', + transactionStatement: 'all', sort: 'latest', }; @@ -148,17 +143,17 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem allOptionLabel: '거래처 전체', }, { - key: 'salesType', - label: '매출유형', + key: 'taxInvoice', + label: '세금계산서 발행여부', type: 'single', - options: SALES_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + options: TAX_INVOICE_FILTER_OPTIONS.filter(o => o.value !== 'all'), allOptionLabel: '전체', }, { - key: 'issuance', - label: '발행여부', + key: 'transactionStatement', + label: '거래명세서 발행여부', type: 'single', - options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + options: TRANSACTION_STATEMENT_FILTER_OPTIONS.filter(o => o.value !== 'all'), allOptionLabel: '전체', }, { @@ -322,18 +317,24 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem // 검색은 searchFilter에서 처리하므로 여기서는 필터만 처리 customFilterFn: (items, fv) => { if (!items || items.length === 0) return items; - const issuanceVal = fv.issuance as string; + const taxInvoiceVal = fv.taxInvoice as string; + const transactionStatementVal = fv.transactionStatement as string; let result = applyFilters(items, [ enumFilter('vendorName', fv.vendor as string), - enumFilter('salesType', fv.salesType as string), ]); - // 발행여부 필터 (특수 로직 - enumFilter로 대체 불가) - if (issuanceVal === 'taxInvoicePending') { + // 세금계산서 발행여부 필터 + if (taxInvoiceVal === 'issued') { + result = result.filter(item => item.taxInvoiceIssued); + } else if (taxInvoiceVal === 'notIssued') { result = result.filter(item => !item.taxInvoiceIssued); } - if (issuanceVal === 'transactionStatementPending') { + + // 거래명세서 발행여부 필터 + if (transactionStatementVal === 'issued') { + result = result.filter(item => item.transactionStatementIssued); + } else if (transactionStatementVal === 'notIssued') { result = result.filter(item => !item.transactionStatementIssued); } @@ -411,7 +412,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem {formatNumber(tableTotals.totalAmount)} - ), @@ -443,9 +443,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem {formatNumber(item.totalSupplyAmount)} {formatNumber(item.totalVat)} {formatNumber(item.totalAmount)} - - {SALES_TYPE_LABELS[item.salesType]} - e.stopPropagation()}>
handleRowClick(item)} diff --git a/src/components/accounting/SalesManagement/types.ts b/src/components/accounting/SalesManagement/types.ts index 89109a39..77c1fa41 100644 --- a/src/components/accounting/SalesManagement/types.ts +++ b/src/components/accounting/SalesManagement/types.ts @@ -133,13 +133,22 @@ export const ACCOUNT_SUBJECT_SELECTOR_OPTIONS = [ { value: 'other', label: '기타매출' }, ]; -// ===== 발행여부 필터 ===== -export type IssuanceFilter = 'all' | 'taxInvoicePending' | 'transactionStatementPending'; +// ===== 세금계산서 발행여부 필터 ===== +export type TaxInvoiceFilter = 'all' | 'issued' | 'notIssued'; -export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [ +export const TAX_INVOICE_FILTER_OPTIONS: { value: TaxInvoiceFilter; label: string }[] = [ { value: 'all', label: '전체' }, - { value: 'taxInvoicePending', label: '세금계산서 미발행' }, - { value: 'transactionStatementPending', label: '거래명세서 미발행' }, + { value: 'issued', label: '발행완료' }, + { value: 'notIssued', label: '미발행' }, +]; + +// ===== 거래명세서 발행여부 필터 ===== +export type TransactionStatementFilter = 'all' | 'issued' | 'notIssued'; + +export const TRANSACTION_STATEMENT_FILTER_OPTIONS: { value: TransactionStatementFilter; label: string }[] = [ + { value: 'all', label: '전체' }, + { value: 'issued', label: '발행완료' }, + { value: 'notIssued', label: '미발행' }, ]; // ===== 매출유형 필터 옵션 (스크린샷 기준) ===== diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index a7588083..daf5fcc0 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -35,12 +35,10 @@ import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog'; import { mockData } from './mockData'; import { LazySection } from './LazySection'; import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail } from '@/hooks/useCEODashboard'; -import { useCardManagementModals, type CardManagementCardId } from '@/hooks/useCardManagementModals'; -import type { MonthlyExpenseCardId } from '@/hooks/useCEODashboard'; +import { useCardManagementModals } from '@/hooks/useCardManagementModals'; import { getMonthlyExpenseModalConfig, getCardManagementModalConfig, - getCardManagementModalConfigWithData, getEntertainmentModalConfig, getWelfareModalConfig, getVatModalConfig, @@ -93,19 +91,24 @@ export function CEODashboard() { const data = useMemo(() => ({ ...mockData, // Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback - // TODO: 자금현황 카드 변경 (일일일보/매출채권/매입채무/운영자금) - 새 API 구현 후 교체 + // TODO: 자금현황 카드 변경 (일일일보/미수금/미지급금/당월예상지출) - 새 API 구현 후 교체 dailyReport: mockData.dailyReport, - receivable: apiData.receivable.data ?? mockData.receivable, + // TODO: D1.7 카드 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체 + // cardManagement: 카드/경조사/상품권/접대비 (기존: 카드/가지급금/법인세/종합세) + // entertainment: 주말심야/기피업종/고액결제/증빙미비 (기존: 매출/한도/잔여한도/사용금액) + // welfare: 비과세초과/사적사용/특정인편중/한도초과 (기존: 한도/잔여한도/사용금액) + // receivable: 누적/당월/거래처/Top3 (기존: 누적/당월/거래처현황) + receivable: mockData.receivable, debtCollection: apiData.debtCollection.data ?? mockData.debtCollection, monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense, - cardManagement: apiData.cardManagement.data ?? mockData.cardManagement, + cardManagement: mockData.cardManagement, // Phase 2 섹션들 (API 연동 완료 - 목업 fallback 제거) todayIssue: apiData.statusBoard.data ?? [], todayIssueList: todayIssueData.data?.items ?? [], calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules, vat: vatData.data ?? mockData.vat, - entertainment: entertainmentData.data ?? mockData.entertainment, - welfare: welfareData.data ?? mockData.welfare, + entertainment: mockData.entertainment, + welfare: mockData.welfare, // 신규 섹션 (API 미구현 - mock 데이터) salesStatus: mockData.salesStatus, purchaseStatus: mockData.purchaseStatus, @@ -204,35 +207,28 @@ export function CEODashboard() { }, []); // 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달) - const handleMonthlyExpenseCardClick = useCallback(async (cardId: string) => { - // 1. 먼저 API에서 데이터 fetch 시도 - const apiConfig = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId); - - // 2. API 데이터가 있으면 사용, 없으면 fallback config 사용 - const config = apiConfig ?? getMonthlyExpenseModalConfig(cardId); + // TODO: D1.7 모달 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체 + const handleMonthlyExpenseCardClick = useCallback((cardId: string) => { + const config = getMonthlyExpenseModalConfig(cardId); if (config) { setDetailModalConfig(config); setIsDetailModalOpen(true); } - }, [monthlyExpenseDetailData]); + }, []); // 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체) const handleMonthlyExpenseClick = useCallback(() => { }, []); - // 카드/가지급금 관리 카드 클릭 (개별 카드 클릭 시 상세 모달) - const handleCardManagementCardClick = useCallback(async (cardId: string) => { - // 1. API에서 데이터 fetch (데이터 직접 반환) - const modalData = await cardManagementModals.fetchModalData(cardId as CardManagementCardId); - - // 2. API 데이터로 config 생성 (데이터 없으면 fallback) - const config = getCardManagementModalConfigWithData(cardId, modalData); - + // 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달 + // 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달 + const handleCardManagementCardClick = useCallback((cardId: string) => { + const config = getCardManagementModalConfig('cm2'); if (config) { setDetailModalConfig(config); setIsDetailModalOpen(true); } - }, [cardManagementModals]); + }, []); // 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달) const handleEntertainmentCardClick = useCallback((cardId: string) => { diff --git a/src/components/business/CEODashboard/components.tsx b/src/components/business/CEODashboard/components.tsx index 52c4d1c2..d90d29d5 100644 --- a/src/components/business/CEODashboard/components.tsx +++ b/src/components/business/CEODashboard/components.tsx @@ -37,26 +37,15 @@ export const SECTION_THEME_STYLES: Record { +const formatAmount = (amount: number, showUnit = true): string => { const formatted = new Intl.NumberFormat('ko-KR').format(amount); return showUnit ? formatted + '원' : formatted; }; -/** - * 억 단위 포맷 함수 - */ -export const formatBillion = (amount: number): string => { - const billion = amount / 100000000; - if (billion >= 1) { - return billion.toFixed(1) + '억원'; - } - return formatAmount(amount); -}; - /** * USD 달러 포맷 함수 */ -export const formatUSD = (amount: number): string => { +const formatUSD = (amount: number): string => { return '$ ' + new Intl.NumberFormat('en-US').format(amount); }; diff --git a/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx b/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx index 96d0b139..cccd950f 100644 --- a/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx +++ b/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx @@ -1,10 +1,7 @@ 'use client'; import { useState, useCallback, useEffect } from 'react'; -import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { CurrencyInput } from '@/components/ui/currency-input'; -import { NumberInput } from '@/components/ui/number-input'; import { Dialog, DialogContent, @@ -12,18 +9,6 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; import type { DashboardSettings, @@ -34,21 +19,13 @@ import type { WelfareCalculationType, SectionKey, } from '../types'; -import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types'; - -// 현황판 항목 라벨 (구 오늘의 이슈) -const STATUS_BOARD_LABELS: Record = { - orders: '수주', - debtCollection: '채권 추심', - safetyStock: '안전 재고', - taxReport: '세금 신고', - newVendor: '신규 업체 등록', - annualLeave: '연차', - lateness: '지각', - absence: '결근', - purchase: '발주', - approvalRequest: '결재 요청', -}; +import { DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types'; +import { + SectionRow, + StatusBoardItemsList, + EntertainmentContent, + WelfareContent, +} from './DashboardSettingsSections'; interface DashboardSettingsDialogProps { isOpen: boolean; @@ -65,6 +42,7 @@ export function DashboardSettingsDialog({ }: DashboardSettingsDialogProps) { const [localSettings, setLocalSettings] = useState(settings); const [expandedSections, setExpandedSections] = useState>({ + todayIssueList: false, entertainment: false, welfare: false, statusBoard: false, @@ -192,8 +170,8 @@ export function DashboardSettingsDialog({ // 접대비 설정 변경 const handleEntertainmentChange = useCallback( ( - key: 'enabled' | 'limitType' | 'companyType', - value: boolean | EntertainmentLimitType | CompanyType + key: 'enabled' | 'limitType' | 'companyType' | 'highAmountThreshold', + value: boolean | EntertainmentLimitType | CompanyType | number ) => { setLocalSettings((prev) => ({ ...prev, @@ -248,85 +226,6 @@ export function DashboardSettingsDialog({ onClose(); }, [settings, onClose]); - // 커스텀 스위치 (라이트 테마용) - const ToggleSwitch = ({ - checked, - onCheckedChange, - }: { - checked: boolean; - onCheckedChange: (checked: boolean) => void; - }) => ( - - ); - - // 섹션 행 컴포넌트 (라이트 테마) - const SectionRow = ({ - label, - checked, - onCheckedChange, - hasExpand, - isExpanded, - onToggleExpand, - children, - showGrip, - }: { - label: string; - checked: boolean; - onCheckedChange: (checked: boolean) => void; - hasExpand?: boolean; - isExpanded?: boolean; - onToggleExpand?: () => void; - children?: React.ReactNode; - showGrip?: boolean; - }) => ( - -
-
- {showGrip && ( - - )} - {hasExpand && ( - - - - )} - {label} -
- -
- {children && ( - - {children} - - )} -
- ); - // 섹션 렌더링 함수 const renderSection = (key: SectionKey): React.ReactNode => { switch (key) { @@ -336,8 +235,16 @@ export function DashboardSettingsDialog({ label={SECTION_LABELS.todayIssueList} checked={localSettings.todayIssueList} onCheckedChange={handleTodayIssueListToggle} + hasExpand + isExpanded={expandedSections.todayIssueList} + onToggleExpand={() => toggleSection('todayIssueList')} showGrip - /> + > + + ); case 'dailyReport': @@ -361,26 +268,10 @@ export function DashboardSettingsDialog({ onToggleExpand={() => toggleSection('statusBoard')} showGrip > -
- {(Object.keys(STATUS_BOARD_LABELS) as Array).map( - (itemKey) => ( -
- - {STATUS_BOARD_LABELS[itemKey]} - - - handleStatusBoardItemToggle(itemKey, checked) - } - /> -
- ) - )} -
+ ); @@ -415,211 +306,12 @@ export function DashboardSettingsDialog({ onToggleExpand={() => toggleSection('entertainment')} showGrip > -
-
- 접대비 한도 관리 - -
-
- 기업 구분 - -
- {/* 기업 구분 방법 설명 패널 */} - toggleSection('companyTypeInfo')} - > - - - - - {/* ■ 중소기업 판단 기준표 */} -
-
- - 중소기업 판단 기준표 -
- - - - - - - - - - - - - - - - - - - - - - - - - -
조건기준충족 요건
① 매출액업종별 상이업종별 기준 금액 이하
② 자산총액5,000억원미만
③ 독립성소유·경영대기업 계열 아님
-
- - {/* ① 업종별 매출액 기준 */} -
-
- ① 업종별 매출액 기준 (최근 3개년 평균) -
- - - - - - - - - - - - - - - - - - -
업종 분류기준 매출액
제조업1,500억원 이하
건설업1,000억원 이하
운수업1,000억원 이하
도매업1,000억원 이하
소매업600억원 이하
정보통신업600억원 이하
전문서비스업600억원 이하
숙박·음식점업400억원 이하
기타 서비스업400억원 이하
-
- - {/* ② 자산총액 기준 */} -
-
- ② 자산총액 기준 -
- - - - - - - - - - - - - -
구분기준
5,000억원 미만직전 사업연도 말 자산총액
-
- - {/* ③ 독립성 기준 */} -
-
- ③ 독립성 기준 -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
구분내용판정
독립기업아래 항목에 모두 해당하지 않음충족
기업집단 소속공정거래법상 상호출자제한 기업집단 소속미충족
대기업 지분대기업이 발행주식 30% 이상 보유미충족
관계기업 합산관계기업 포함 시 매출액·자산 기준 초과미충족
-
- - {/* ■ 판정 결과 */} -
-
- - 판정 결과 -
- - - - - - - - - - - - - - - - - - - - -
판정조건접대비 기본한도
중소기업①②③ 모두 충족3,600만원
일반법인①②③ 중 하나라도 미충족1,200만원
-
-
-
-
+ toggleSection('companyTypeInfo')} + /> ); @@ -634,87 +326,10 @@ export function DashboardSettingsDialog({ onToggleExpand={() => toggleSection('welfare')} showGrip > -
-
- 복리후생비 한도 관리 - -
-
- 계산 방식 - -
- {localSettings.welfare.calculationType === 'fixed' ? ( -
- 직원당 정해 금액/월 -
- - handleWelfareChange( - 'fixedAmountPerMonth', - value ?? 0 - ) - } - className="w-28 h-8" - /> -
-
- ) : ( -
- 비율 -
- - handleWelfareChange('ratio', value ?? 0) - } - className="w-20 h-8 text-right" - /> - % -
-
- )} -
- 연간 복리후생비총액 -
- - handleWelfareChange('annualTotal', value ?? 0) - } - className="w-32 h-8" - /> -
-
-
+ ); diff --git a/src/components/business/CEODashboard/dialogs/DashboardSettingsSections.tsx b/src/components/business/CEODashboard/dialogs/DashboardSettingsSections.tsx new file mode 100644 index 00000000..11a3d4e2 --- /dev/null +++ b/src/components/business/CEODashboard/dialogs/DashboardSettingsSections.tsx @@ -0,0 +1,485 @@ +'use client'; + +import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react'; +import { CurrencyInput } from '@/components/ui/currency-input'; +import { NumberInput } from '@/components/ui/number-input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import type { + TodayIssueSettings, + DashboardSettings, + EntertainmentLimitType, + CompanyType, + WelfareLimitType, + WelfareCalculationType, +} from '../types'; + +// ─── 현황판 항목 라벨 ────────────────────────────── +export const STATUS_BOARD_LABELS: Record = { + orders: '수주', + debtCollection: '채권 추심', + safetyStock: '안전 재고', + taxReport: '세금 신고', + newVendor: '신규 업체 등록', + annualLeave: '연차', + vehicle: '차량', + equipment: '장비', + purchase: '발주', + approvalRequest: '결재 요청', + fundStatus: '자금 현황', +}; + +// ─── 커스텀 스위치 ────────────────────────────────── +export function ToggleSwitch({ + checked, + onCheckedChange, +}: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +// ─── 섹션 행 (Collapsible 래퍼) ───────────────────── +export function SectionRow({ + label, + checked, + onCheckedChange, + hasExpand, + isExpanded, + onToggleExpand, + children, + showGrip, +}: { + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + hasExpand?: boolean; + isExpanded?: boolean; + onToggleExpand?: () => void; + children?: React.ReactNode; + showGrip?: boolean; +}) { + return ( + +
+
+ {showGrip && ( + + )} + {hasExpand && ( + + + + )} + {label} +
+ +
+ {children && ( + + {children} + + )} +
+ ); +} + +// ─── 현황판 항목 토글 리스트 ──────────────────────── +export function StatusBoardItemsList({ + items, + onToggle, +}: { + items: TodayIssueSettings; + onToggle: (key: keyof TodayIssueSettings, checked: boolean) => void; +}) { + return ( +
+ {(Object.keys(STATUS_BOARD_LABELS) as Array).map( + (itemKey) => ( +
+ + {STATUS_BOARD_LABELS[itemKey]} + + onToggle(itemKey, checked)} + /> +
+ ) + )} +
+ ); +} + +// ─── 기업 구분 방법 설명 패널 ─────────────────────── +function CompanyTypeInfoPanel({ + isExpanded, + onToggle, +}: { + isExpanded: boolean; + onToggle: () => void; +}) { + return ( + + + + + + {/* ■ 중소기업 판단 기준표 */} +
+
+ + 중소기업 판단 기준표 +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
조건기준충족 요건
① 매출액업종별 상이업종별 기준 금액 이하
② 자산총액5,000억원미만
③ 독립성소유·경영대기업 계열 아님
+
+ + {/* ① 업종별 매출액 기준 */} +
+
+ ① 업종별 매출액 기준 (최근 3개년 평균) +
+ + + + + + + + + + + + + + + + + + +
업종 분류기준 매출액
제조업1,500억원 이하
건설업1,000억원 이하
운수업1,000억원 이하
도매업1,000억원 이하
소매업600억원 이하
정보통신업600억원 이하
전문서비스업600억원 이하
숙박·음식점업400억원 이하
기타 서비스업400억원 이하
+
+ + {/* ② 자산총액 기준 */} +
+
+ ② 자산총액 기준 +
+ + + + + + + + + + + + + +
구분기준
5,000억원 미만직전 사업연도 말 자산총액
+
+ + {/* ③ 독립성 기준 */} +
+
+ ③ 독립성 기준 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구분내용판정
독립기업아래 항목에 모두 해당하지 않음충족
기업집단 소속공정거래법상 상호출자제한 기업집단 소속미충족
대기업 지분대기업이 발행주식 30% 이상 보유미충족
관계기업 합산관계기업 포함 시 매출액·자산 기준 초과미충족
+
+ + {/* ■ 판정 결과 */} +
+
+ + 판정 결과 +
+ + + + + + + + + + + + + + + + + + + + +
판정조건접대비 기본한도
중소기업①②③ 모두 충족3,600만원
일반법인①②③ 중 하나라도 미충족1,200만원
+
+
+
+ ); +} + +// ─── 접대비 설정 콘텐츠 ───────────────────────────── +export function EntertainmentContent({ + entertainment, + onChange, + companyTypeInfoExpanded, + onToggleCompanyTypeInfo, +}: { + entertainment: DashboardSettings['entertainment']; + onChange: ( + key: 'limitType' | 'companyType' | 'highAmountThreshold', + value: EntertainmentLimitType | CompanyType | number, + ) => void; + companyTypeInfoExpanded: boolean; + onToggleCompanyTypeInfo: () => void; +}) { + return ( +
+
+ 접대비 한도 관리 + +
+
+ 기업 구분 + +
+
+ 고액 결제 기준 금액 +
+ onChange('highAmountThreshold', value ?? 0)} + className="w-28 h-8" + /> +
+
+ +
+ ); +} + +// ─── 복리후생비 설정 콘텐츠 ───────────────────────── +export function WelfareContent({ + welfare, + onChange, +}: { + welfare: DashboardSettings['welfare']; + onChange: ( + key: keyof DashboardSettings['welfare'], + value: WelfareLimitType | WelfareCalculationType | number, + ) => void; +}) { + return ( +
+
+ 복리후생비 한도 관리 + +
+
+ 계산 방식 + +
+ {welfare.calculationType === 'fixed' ? ( +
+ 직원당 정해 금액/월 +
+ onChange('fixedAmountPerMonth', value ?? 0)} + className="w-28 h-8" + /> +
+
+ ) : ( +
+ 비율 +
+ onChange('ratio', value ?? 0)} + className="w-20 h-8 text-right" + /> + % +
+
+ )} +
+ 연간 복리후생비 + + ₩ {welfare.annualTotal.toLocaleString()} + +
+
+ 1회 결제 기준 금액 +
+ onChange('singlePaymentThreshold', value ?? 0)} + className="w-32 h-8" + /> +
+
+
+ ); +} diff --git a/src/components/business/CEODashboard/mockData.ts b/src/components/business/CEODashboard/mockData.ts index 8dac19ce..f66e986d 100644 --- a/src/components/business/CEODashboard/mockData.ts +++ b/src/components/business/CEODashboard/mockData.ts @@ -19,9 +19,9 @@ export const mockData: CEODashboardData = { date: '2026년 1월 5일 월요일', cards: [ { id: 'dr1', label: '일일일보', amount: 3050000000, path: '/ko/accounting/daily-report' }, - { id: 'dr2', label: '매출채권 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' }, - { id: 'dr3', label: '매입채무 잔액', amount: 3050000000 }, - { id: 'dr4', label: '운영자금 잔여', amount: 0, displayValue: '6.2개월' }, + { id: 'dr2', label: '미수금 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' }, + { id: 'dr3', label: '미지급금 잔액', amount: 3050000000 }, + { id: 'dr4', label: '당월 예상 지출 합계', amount: 350000000 }, ], checkPoints: [ { @@ -91,10 +91,11 @@ export const mockData: CEODashboardData = { cardManagement: { warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의', cards: [ - { id: 'cm1', label: '카드', amount: 30123000, previousLabel: '미정리 5건 (가지급금 예정)' }, - { id: 'cm2', label: '가지급금', amount: 350000000, previousLabel: '전월 대비 +10.5%' }, - { id: 'cm3', label: '법인세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' }, - { id: 'cm4', label: '대표자 종합세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' }, + { id: 'cm1', label: '카드', amount: 3123000, previousLabel: '미정리 5건' }, + { id: 'cm2', label: '경조사', amount: 3123000, previousLabel: '미증빙 5건' }, + { id: 'cm3', label: '상품권', amount: 3123000, previousLabel: '미증빙 5건' }, + { id: 'cm4', label: '접대비', amount: 3123000, previousLabel: '미증빙 5건' }, + { id: 'cm_total', label: '총 가지급금 합계', amount: 350000000 }, ], checkPoints: [ { @@ -135,10 +136,10 @@ export const mockData: CEODashboardData = { }, entertainment: { cards: [ - { id: 'et1', label: '매출', amount: 30530000000 }, - { id: 'et2', label: '{1사분기} 접대비 총 한도', amount: 40123000 }, - { id: 'et3', label: '{1사분기} 접대비 잔여한도', amount: 30123000 }, - { id: 'et4', label: '{1사분기} 접대비 사용금액', amount: 10000000 }, + { id: 'et1', label: '주말/심야', amount: 3123000, previousLabel: '미증빙 5건' }, + { id: 'et2', label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, previousLabel: '불인정 5건' }, + { id: 'et3', label: '고액 결제', amount: 3123000, previousLabel: '미증빙 5건' }, + { id: 'et4', label: '증빙 미비', amount: 3123000, previousLabel: '미증빙 5건' }, ], checkPoints: [ { @@ -179,10 +180,10 @@ export const mockData: CEODashboardData = { }, welfare: { cards: [ - { id: 'wf1', label: '당해년도 복리후생비 한도', amount: 30123000 }, - { id: 'wf2', label: '{1사분기} 복리후생비 총 한도', amount: 10123000 }, - { id: 'wf3', label: '{1사분기} 복리후생비 잔여한도', amount: 5123000 }, - { id: 'wf4', label: '{1사분기} 복리후생비 사용금액', amount: 5123000 }, + { id: 'wf1', label: '비과세 한도 초과', amount: 3123000, previousLabel: '5건' }, + { id: 'wf2', label: '사적 사용 의심', amount: 3123000, previousLabel: '5건' }, + { id: 'wf3', label: '특정인 편중', amount: 3123000, previousLabel: '5건' }, + { id: 'wf4', label: '항목별 한도 초과', amount: 3123000, previousLabel: '5건' }, ], checkPoints: [ { @@ -219,28 +220,22 @@ export const mockData: CEODashboardData = { id: 'rv2', label: '당월 미수금', amount: 10123000, - subItems: [ - { label: '매출', value: 60123000 }, - { label: '입금', value: 30000000 }, - ], }, { id: 'rv3', - label: '회사명', - amount: 3123000, + label: '미수금 거래처', + amount: 31, + unit: '건', subItems: [ - { label: '매출', value: 6123000 }, - { label: '입금', value: 3000000 }, + { label: '연체', value: '21건' }, + { label: '악성채권', value: '11건' }, ], }, { id: 'rv4', - label: '회사명', - amount: 2123000, - subItems: [ - { label: '매출', value: 6123000 }, - { label: '입금', value: 3000000 }, - ], + label: '미수금 Top 3', + amount: 0, + displayValue: '상세보기', }, ], checkPoints: [ @@ -268,7 +263,7 @@ export const mockData: CEODashboardData = { { id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' }, { id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' }, { id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' }, - { id: 'dc4', label: '회수완료', amount: 280000000, subLabel: '10건' }, + { id: 'dc4', label: '추심종료', amount: 280000000, subLabel: '10건' }, ], checkPoints: [ { diff --git a/src/components/business/CEODashboard/modalConfigs/cardManagementConfigTransformers.ts b/src/components/business/CEODashboard/modalConfigs/cardManagementConfigTransformers.ts index c37e6867..6c7e688c 100644 --- a/src/components/business/CEODashboard/modalConfigs/cardManagementConfigTransformers.ts +++ b/src/components/business/CEODashboard/modalConfigs/cardManagementConfigTransformers.ts @@ -162,16 +162,6 @@ export function transformCm1ModalConfig( ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -196,46 +186,44 @@ export function transformCm2ModalConfig( // 테이블 데이터 매핑 const tableData = (items || []).map((item) => ({ date: item.loan_date, - target: item.user_name, - category: '-', // API에서 별도 필드 없음 + classification: item.status_label || '카드', + category: '-', amount: item.amount, - status: item.status_label || item.status, content: item.description, })); - // 대상 필터 옵션 동적 생성 - const uniqueTargets = [...new Set((items || []).map((item) => item.user_name))]; - const targetFilterOptions = [ + // 분류 필터 옵션 동적 생성 + const uniqueClassifications = [...new Set(tableData.map((item) => item.classification))]; + const classificationFilterOptions = [ { value: 'all', label: '전체' }, - ...uniqueTargets.map((target) => ({ - value: target, - label: target, + ...uniqueClassifications.map((cls) => ({ + value: cls, + label: cls, })), ]; return { title: '가지급금 상세', summaryCards: [ - { label: '가지급금', value: formatKoreanCurrency(summary.total_outstanding) }, - { label: '인정이자 4.6%', value: summary.recognized_interest, unit: '원' }, - { label: '미설정', value: `${summary.pending_count ?? 0}건` }, + { label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) }, + { label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' }, + { label: '미정리/미분류', value: `${summary.pending_count ?? 0}건` }, ], table: { title: '가지급금 관련 내역', columns: [ { key: 'no', label: 'No.', align: 'center' }, - { key: 'date', label: '발생일시', align: 'center' }, - { key: 'target', label: '대상', 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: 'status', label: '상태', align: 'center', highlightValue: '미설정' }, { key: 'content', label: '내용', align: 'left' }, ], data: tableData, filters: [ { - key: 'target', - options: targetFilterOptions, + key: 'classification', + options: classificationFilterOptions, defaultValue: 'all', }, { @@ -247,16 +235,6 @@ export function transformCm2ModalConfig( ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', diff --git a/src/components/business/CEODashboard/modalConfigs/cardManagementConfigs.ts b/src/components/business/CEODashboard/modalConfigs/cardManagementConfigs.ts index 86331fe0..0d9ad10f 100644 --- a/src/components/business/CEODashboard/modalConfigs/cardManagementConfigs.ts +++ b/src/components/business/CEODashboard/modalConfigs/cardManagementConfigs.ts @@ -153,16 +153,6 @@ export function getCardManagementModalConfig(cardId: string): DetailModalConfig ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -170,60 +160,68 @@ export function getCardManagementModalConfig(cardId: string): DetailModalConfig totalColumnKey: 'amount', }, }, + // P52: 가지급금 상세 cm2: { title: '가지급금 상세', + dateFilter: { + enabled: true, + defaultPreset: '당월', + showSearch: true, + }, summaryCards: [ - { label: '가지급금', value: '4.5억원' }, - { label: '인정이자 4.6%', value: 6000000, unit: '원' }, - { label: '미설정', value: '10건' }, + { label: '가지급금 합계', value: '4.5억원' }, + { label: '가지급금 총액', value: 6000000, unit: '원' }, + { label: '건수', value: '10건' }, ], + reviewCards: { + title: '가지급금 검토 필요', + cards: [ + { label: '카드', amount: 3123000, subLabel: '미정리 5건' }, + { label: '경조사', amount: 3123000, subLabel: '미증빙 5건' }, + { label: '상품권', amount: 3123000, subLabel: '미증빙 5건' }, + { label: '접대비', amount: 3123000, subLabel: '미증빙 5건' }, + ], + }, table: { - title: '가지급금 관련 내역', + title: '가지급금 내역', columns: [ { key: 'no', label: 'No.', align: 'center' }, - { key: 'date', label: '발생일시', align: 'center' }, - { key: 'target', label: '대상', 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: 'status', label: '상태', align: 'center', highlightValue: '미설정' }, - { key: 'content', label: '내용', align: 'left' }, + { key: 'response', label: '대응', align: 'left' }, ], data: [ - { date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '미설정', content: '미설정' }, - { date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접비(미정리)', content: '접대비 불인정' }, - { date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미설정', content: '접대비 불인정' }, - { date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미설정', content: '미설정' }, - { date: '2025-12-12 12:12', target: '홍길동', category: '-', amount: 1000000, status: '미설정', content: '미설정' }, - { date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접대비', content: '접대비 불인정' }, - { date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '-', content: '복리후생비, 주말/심야 카드 사용' }, + { date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '미정리' }, + { date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '미증빙' }, + { date: '2025-12-12', classification: '경조사', category: '계좌명', amount: 1000000, response: '미증빙' }, + { date: '2025-12-12', classification: '상품권', category: '계좌명', amount: 1000000, response: '미증빙' }, + { date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '주말 카드 사용' }, + { date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '접대비 불인정' }, + { date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '불인정 가맹점(귀금속)' }, ], filters: [ { - key: 'target', + key: 'classification', options: [ { value: 'all', label: '전체' }, - { value: '홍길동', label: '홍길동' }, - ], - defaultValue: 'all', - }, - { - key: 'category', - options: [ - { value: 'all', label: '전체' }, - { value: '카드명', label: '카드명' }, - { value: '계좌명', label: '계좌명' }, + { value: '카드', label: '카드' }, + { value: '경조사', label: '경조사' }, + { value: '상품권', label: '상품권' }, + { value: '접대비', label: '접대비' }, ], defaultValue: 'all', }, { key: 'sortOrder', options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, + { value: 'all', label: '정렬' }, { value: 'amountDesc', label: '금액 높은순' }, { value: 'amountAsc', label: '금액 낮은순' }, + { value: 'latest', label: '최신순' }, ], - defaultValue: 'latest', + defaultValue: 'all', }, ], showTotal: true, diff --git a/src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts b/src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts index 0ca155c7..39164a5b 100644 --- a/src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts +++ b/src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts @@ -5,18 +5,27 @@ import type { DetailModalConfig } from '../types'; */ const entertainmentDetailConfig: DetailModalConfig = { title: '접대비 상세', + dateFilter: { + enabled: true, + defaultPreset: '당월', + showSearch: true, + }, summaryCards: [ // 첫 번째 줄: 당해년도 - { label: '당해년도 접대비 총한도', value: 3123000, unit: '원' }, + { label: '당해년도 접대비 총 한도', value: 3123000, unit: '원' }, { label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' }, { label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' }, - { label: '당해년도 접대비 사용잔액', value: 0, unit: '원' }, - // 두 번째 줄: 분기별 - { label: '1사분기 접대비 총한도', value: 3123000, unit: '원' }, - { label: '1사분기 접대비 잔여한도', value: 6000000, unit: '원' }, - { label: '1사분기 접대비 사용금액', value: 6000000, unit: '원' }, - { label: '1사분기 접대비 초과금액', value: 6000000, unit: '원' }, + { label: '당해년도 접대비 초과 금액', value: 0, unit: '원' }, ], + reviewCards: { + title: '접대비 검토 필요', + cards: [ + { label: '주말/심야', amount: 3123000, subLabel: '미증빙 5건' }, + { label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, subLabel: '불인정 5건' }, + { label: '고액 결제', amount: 3123000, subLabel: '미증빙 5건' }, + { label: '증빙 미비', amount: 3123000, subLabel: '미증빙 5건' }, + ], + }, barChart: { title: '월별 접대비 사용 추이', data: [ @@ -50,14 +59,14 @@ const entertainmentDetailConfig: DetailModalConfig = { { key: 'useDate', label: '사용일시', align: 'center', format: 'date' }, { key: 'transDate', label: '거래일시', align: 'center', format: 'date' }, { key: 'amount', label: '사용금액', align: 'right', format: 'currency' }, - { key: 'purpose', label: '사용용도', align: 'left' }, + { key: 'content', label: '내용', align: 'left' }, ], data: [ - { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, - { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, - { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, - { cardName: '카드명', user: '홍길동', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, - { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, + { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '심야 카드 사용' }, + { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '미증빙' }, + { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '고액 결제' }, + { cardName: '카드명', user: '김철수', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, content: '불인정 가맹점 (귀금속)' }, + { cardName: '카드명', user: '이영희', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '접대비 불인정' }, ], filters: [ { @@ -71,14 +80,15 @@ const entertainmentDetailConfig: DetailModalConfig = { defaultValue: 'all', }, { - key: 'sortOrder', + key: 'content', options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, + { value: 'all', label: '전체' }, + { value: '주말/심야', label: '주말/심야' }, + { value: '기피업종', label: '기피업종' }, + { value: '고액 결제', label: '고액 결제' }, + { value: '증빙 미비', label: '증빙 미비' }, ], - defaultValue: 'latest', + defaultValue: 'all', }, ], showTotal: true, @@ -91,24 +101,25 @@ const entertainmentDetailConfig: DetailModalConfig = { { title: '접대비 손금한도 계산 - 기본한도', columns: [ - { key: 'type', label: '구분', align: 'left' }, - { key: 'limit', label: '기본한도', align: 'right' }, + { key: 'type', label: '법인 유형', align: 'left' }, + { key: 'annualLimit', label: '연간 기본한도', align: 'right' }, + { key: 'monthlyLimit', label: '월 환산', align: 'right' }, ], data: [ - { type: '일반법인', limit: '3,600만원 (연 1,200만원)' }, - { type: '중소기업', limit: '5,400만원 (연 3,600만원)' }, + { type: '일반법인', annualLimit: '12,000,000원', monthlyLimit: '1,000,000원' }, + { type: '중소기업', annualLimit: '36,000,000원', monthlyLimit: '3,000,000원' }, ], }, { title: '수입금액별 추가한도', columns: [ - { key: 'range', label: '수입금액', align: 'left' }, - { key: 'rate', label: '적용률', align: 'center' }, + { key: 'range', label: '수입금액 구간', align: 'left' }, + { key: 'formula', label: '추가한도 계산식', align: 'left' }, ], data: [ - { range: '100억원 이하', rate: '0.3%' }, - { range: '100억원 초과 ~ 500억원 이하', rate: '0.2%' }, - { range: '500억원 초과', rate: '0.03%' }, + { range: '100억원 이하', formula: '수입금액 × 0.2%' }, + { range: '100억 초과 ~ 500억 이하', formula: '2,000만원 + (수입금액 - 100억) × 0.1%' }, + { range: '500억원 초과', formula: '6,000만원 + (수입금액 - 500억) × 0.03%' }, ], }, ], @@ -116,18 +127,20 @@ const entertainmentDetailConfig: DetailModalConfig = { calculationCards: { title: '접대비 계산', cards: [ - { label: '기본한도', value: 36000000 }, - { label: '추가한도', value: 91170000, operator: '+' }, - { label: '접대비 손금한도', value: 127170000, operator: '=' }, + { label: '중소기업 연간 기본한도', value: 36000000 }, + { label: '당해년도 수입금액별 추가한도', value: 16000000, operator: '+' }, + { label: '당해년도 접대비 총 한도', value: 52000000, operator: '=' }, ], }, // 접대비 현황 (분기별) quarterlyTable: { title: '접대비 현황', rows: [ - { label: '접대비 한도', q1: 31792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 127170000 }, - { label: '접대비 사용', q1: 10000000, q2: 0, q3: 0, q4: 0, total: 10000000 }, - { label: '접대비 잔여', q1: 21792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 117170000 }, + { label: '한도금액', q1: 13000000, q2: 13000000, q3: 13000000, q4: 13000000, total: 52000000 }, + { label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' }, + { label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' }, + { label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' }, + { label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' }, ], }, }; @@ -204,16 +217,6 @@ export function getEntertainmentModalConfig(cardId: string): DetailModalConfig | ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -225,6 +228,11 @@ export function getEntertainmentModalConfig(cardId: string): DetailModalConfig | et_limit: entertainmentDetailConfig, et_remaining: entertainmentDetailConfig, et_used: entertainmentDetailConfig, + // 대시보드 카드 ID (et1~et4) → 접대비 상세 모달 + et1: entertainmentDetailConfig, + et2: entertainmentDetailConfig, + et3: entertainmentDetailConfig, + et4: entertainmentDetailConfig, }; return configs[cardId] || null; diff --git a/src/components/business/CEODashboard/modalConfigs/monthlyExpenseConfigs.ts b/src/components/business/CEODashboard/modalConfigs/monthlyExpenseConfigs.ts index 2921a2ee..170b3ab4 100644 --- a/src/components/business/CEODashboard/modalConfigs/monthlyExpenseConfigs.ts +++ b/src/components/business/CEODashboard/modalConfigs/monthlyExpenseConfigs.ts @@ -1,18 +1,24 @@ import type { DetailModalConfig } from '../types'; /** - * 당월 예상 지출 모달 설정 + * 당월 예상 지출 모달 설정 (D1.7 기획서 P48-51 반영) */ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null { const configs: Record = { + // P48: 매입 상세 me1: { - title: '당월 매입 상세', + title: '매입 상세', + dateFilter: { + enabled: true, + defaultPreset: '당월', + showSearch: true, + }, summaryCards: [ - { label: '당월 매입', value: 3123000, unit: '원' }, - { label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false }, + { label: '매입', value: 3123000, unit: '원' }, + { label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false }, ], barChart: { - title: '월별 매입 추이', + title: '매입 추이', data: [ { name: '1월', value: 45000000 }, { name: '2월', value: 52000000 }, @@ -30,8 +36,8 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig title: '자재 유형별 구매 비율', data: [ { name: '원자재', value: 55000000, percentage: 55, color: '#60A5FA' }, - { name: '부자재', value: 35000000, percentage: 35, color: '#34D399' }, - { name: '포장재', value: 10000000, percentage: 10, color: '#FBBF24' }, + { name: '부자재', value: 35000000, percentage: 35, color: '#FBBF24' }, + { name: '포장재', value: 10000000, percentage: 10, color: '#F87171' }, ], }, table: { @@ -41,36 +47,14 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig { key: 'date', label: '매입일', align: 'center', format: 'date' }, { key: 'vendor', label: '거래처', align: 'left' }, { key: 'amount', label: '매입금액', align: 'right', format: 'currency' }, - { key: 'type', label: '매입유형', align: 'center' }, ], data: [ - { date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' }, - { date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' }, - { date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' }, - { date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' }, - { date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' }, - { date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' }, - { date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' }, - ], - filters: [ - { - key: 'type', - options: [ - { value: 'all', label: '전체' }, - { value: '원재료매입', label: '원재료매입' }, - { value: '부재료매입', label: '부재료매입' }, - { value: '미설정', label: '미설정' }, - ], - defaultValue: 'all', - }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '오래된순' }, - ], - defaultValue: 'latest', - }, + { date: '2025-12-01', vendor: '회사명', amount: 11000000 }, + { date: '2025-12-01', vendor: '회사명', amount: 11000000 }, + { date: '2025-12-01', vendor: '회사명', amount: 11000000 }, + { date: '2025-12-01', vendor: '회사명', amount: 11000000 }, + { date: '2025-12-01', vendor: '회사명', amount: 11000000 }, + { date: '2025-12-01', vendor: '회사명', amount: 11000000 }, ], showTotal: true, totalLabel: '합계', @@ -78,15 +62,21 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig totalColumnKey: 'amount', }, }, + // P49: 카드 상세 me2: { - title: '당월 카드 상세', + title: '카드 상세', + dateFilter: { + enabled: true, + defaultPreset: '당월', + showSearch: true, + }, summaryCards: [ - { label: '당월 카드 사용', value: 6000000, unit: '원' }, - { label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false }, - { label: '이용건', value: '10건' }, + { label: '카드 사용', value: 6000000, unit: '원' }, + { label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false }, + { label: '건수', value: '10건' }, ], barChart: { - title: '월별 카드 사용 추이', + title: '카드 사용 추이', data: [ { name: '1월', value: 4500000 }, { name: '2월', value: 5200000 }, @@ -104,8 +94,8 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig title: '사용자별 카드 사용 비율', data: [ { name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' }, - { name: '김길동', value: 35000000, percentage: 35, color: '#34D399' }, - { name: '이길동', value: 10000000, percentage: 10, color: '#FBBF24' }, + { name: '김영희', value: 35000000, percentage: 35, color: '#FBBF24' }, + { name: '이정현', value: 10000000, percentage: 10, color: '#F87171' }, ], }, table: { @@ -114,30 +104,16 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig { key: 'no', label: 'No.', align: 'center' }, { key: 'cardName', label: '카드명', align: 'left' }, { key: 'user', label: '사용자', align: 'center' }, - { key: 'date', label: '사용일시', align: 'center', format: 'date' }, + { key: 'date', label: '사용일자', align: 'center', format: 'date' }, { key: 'store', label: '가맹점명', align: 'left' }, { key: 'amount', label: '사용금액', align: 'right', format: 'currency' }, - { key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' }, + { key: 'usageType', label: '계정과목', align: 'center', highlightValue: '미설정' }, ], data: [ - { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-08 11:15', store: '가맹점명', amount: 1000000, usageType: '미설정' }, - { cardName: '카드명', user: '김길동', date: '2025-12-07 16:40', store: '가맹점명', amount: 5000000, usageType: '교통비' }, - { cardName: '카드명', user: '이길동', date: '2025-12-06 10:30', store: '가맹점명', amount: 1000000, usageType: '소모품비' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-05 13:25', store: '스타벅스', amount: 45000, usageType: '복리후생비' }, - { cardName: '카드명', user: '김길동', date: '2025-12-04 19:50', store: '주유소', amount: 80000, usageType: '교통비' }, - { cardName: '카드명', user: '이길동', date: '2025-12-03 08:10', store: '편의점', amount: 15000, usageType: '미설정' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-02 12:00', store: '식당', amount: 250000, usageType: '접대비' }, - { cardName: '카드명', user: '김길동', date: '2025-12-01 15:35', store: '문구점', amount: 35000, usageType: '소모품비' }, - { cardName: '카드명', user: '홍길동', date: '2025-11-30 17:20', store: '호텔', amount: 350000, usageType: '미설정' }, - { cardName: '카드명', user: '이길동', date: '2025-11-29 09:00', store: '택시', amount: 25000, usageType: '교통비' }, - { cardName: '카드명', user: '김길동', date: '2025-11-28 14:15', store: '커피숍', amount: 32000, usageType: '복리후생비' }, - { cardName: '카드명', user: '홍길동', date: '2025-11-27 11:45', store: '마트', amount: 180000, usageType: '소모품비' }, - { cardName: '카드명', user: '이길동', date: '2025-11-26 16:30', store: '서점', amount: 45000, usageType: '미설정' }, - { cardName: '카드명', user: '김길동', date: '2025-11-25 10:20', store: '식당', amount: 120000, usageType: '접대비' }, + { cardName: '홍길동', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' }, + { cardName: '홍길동', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' }, + { cardName: '홍길동', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' }, + { cardName: '홍길동', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' }, ], filters: [ { @@ -145,21 +121,11 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig options: [ { value: 'all', label: '전체' }, { value: '홍길동', label: '홍길동' }, - { value: '김길동', label: '김길동' }, - { value: '이길동', label: '이길동' }, + { value: '김영희', label: '김영희' }, + { value: '이정현', label: '이정현' }, ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -167,14 +133,21 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig totalColumnKey: 'amount', }, }, + // P50: 발행어음 상세 me3: { - title: '당월 발행어음 상세', + title: '발행어음 상세', + dateFilter: { + enabled: true, + presets: ['당해년도', '전전월', '전월', '당월', '어제'], + defaultPreset: '당월', + showSearch: true, + }, summaryCards: [ - { label: '당월 발행어음 사용', value: 3123000, unit: '원' }, - { label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false }, + { label: '발행어음', value: 3123000, unit: '원' }, + { label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false }, ], barChart: { - title: '월별 발행어음 추이', + title: '발행어음 추이', data: [ { name: '1월', value: 2000000 }, { name: '2월', value: 2500000 }, @@ -188,15 +161,14 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig xAxisKey: 'name', color: '#60A5FA', }, - horizontalBarChart: { - title: '당월 거래처별 발행어음', + pieChart: { + title: '거래처별 발행어음', data: [ - { name: '거래처1', value: 50000000 }, - { name: '거래처2', value: 35000000 }, - { name: '거래처3', value: 20000000 }, - { name: '거래처4', value: 6000000 }, + { name: '거래처1', value: 50000000, percentage: 45, color: '#60A5FA' }, + { name: '거래처2', value: 35000000, percentage: 32, color: '#FBBF24' }, + { name: '거래처3', value: 20000000, percentage: 18, color: '#F87171' }, + { name: '거래처4', value: 6000000, percentage: 5, color: '#34D399' }, ], - color: '#60A5FA', }, table: { title: '일별 발행어음 내역', @@ -215,7 +187,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' }, { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' }, { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' }, - { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 105000000, status: '보관중' }, ], filters: [ { @@ -238,16 +209,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -255,6 +216,7 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig totalColumnKey: 'amount', }, }, + // P51: 당월 지출 예상 상세 me4: { title: '당월 지출 예상 상세', summaryCards: [ @@ -278,8 +240,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, { paymentDate: '2025-12-12', item: '거래처명 12월분', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, - { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, - { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, { paymentDate: '2025-12-12', item: '적요 내용', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, ], filters: [ @@ -291,14 +251,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '2025/12 계', @@ -314,4 +266,4 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig }; return configs[cardId] || null; -} \ No newline at end of file +} diff --git a/src/components/business/CEODashboard/modalConfigs/vatConfigs.ts b/src/components/business/CEODashboard/modalConfigs/vatConfigs.ts index 72c4b26a..56da1940 100644 --- a/src/components/business/CEODashboard/modalConfigs/vatConfigs.ts +++ b/src/components/business/CEODashboard/modalConfigs/vatConfigs.ts @@ -7,29 +7,36 @@ import type { DetailModalConfig } from '../types'; export function getVatModalConfig(): DetailModalConfig { return { title: '예상 납부세액', - summaryCards: [], - // 세액 산출 내역 테이블 + periodSelect: { + enabled: true, + options: [ + { value: '2026-1-expected', label: '2026년 1기 예정' }, + { value: '2025-2-confirmed', label: '2025년 2기 확정' }, + { value: '2025-2-expected', label: '2025년 2기 예정' }, + { value: '2025-1-confirmed', label: '2025년 1기 확정' }, + ], + defaultValue: '2026-1-expected', + }, + summaryCards: [ + { label: '예상매출', value: '30.5억원' }, + { label: '예상매입', value: '20.5억원' }, + { label: '예상 납부세액', value: '1.1억원' }, + ], + // 부가세 요약 테이블 referenceTable: { - title: '2026년 1사분기 세액 산출 내역', + title: '2026년 1기 예정 부가세 요약', columns: [ - { key: 'category', label: '구분', align: 'center' }, - { key: 'amount', label: '금액', align: 'right' }, - { key: 'note', label: '비고', align: 'left' }, + { key: 'category', label: '구분', align: 'left' }, + { key: 'supplyAmount', label: '공급가액', align: 'right' }, + { key: 'taxAmount', label: '세액', align: 'right' }, ], data: [ - { category: '매출세액', amount: '11,000,000', note: '과세매출 X 10%' }, - { category: '매입세액', amount: '1,000,000', note: '공제대상 매입 X 10%' }, - { category: '경감·공제세액', amount: '0', note: '해당없음' }, - ], - }, - // 예상 납부세액 계산 - calculationCards: { - title: '예상 납부세액 계산', - cards: [ - { label: '매출세액', value: 11000000, unit: '원' }, - { label: '매입세액', value: 1000000, unit: '원', operator: '-' }, - { label: '경감·공제세액', value: 0, unit: '원', operator: '-' }, - { label: '예상 납부세액', value: 10000000, unit: '원', operator: '=' }, + { category: '매출(전자세금계산서)', supplyAmount: '100,000,000', taxAmount: '10,000,000' }, + { category: '매입(전자세금계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' }, + { category: '매입(종이세금계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' }, + { category: '매입(계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' }, + { category: '매입(신용카드)', supplyAmount: '10,000,000', taxAmount: '1,000,000' }, + { category: '납부세액', supplyAmount: '', taxAmount: '6,000,000' }, ], }, // 세금계산서 미발행/미수취 내역 @@ -38,19 +45,17 @@ export function getVatModalConfig(): DetailModalConfig { columns: [ { key: 'no', label: 'No.', align: 'center' }, { key: 'type', label: '구분', align: 'center' }, - { key: 'issueDate', label: '발행일자', align: 'center', format: 'date' }, + { key: 'issueDate', label: '발생일자', align: 'center', format: 'date' }, { key: 'vendor', label: '거래처', align: 'left' }, { key: 'vat', label: '부가세', align: 'right', format: 'currency' }, - { key: 'invoiceStatus', label: '세금계산서 발행', align: 'center' }, + { key: 'invoiceStatus', label: '세금계산서 미발행/미수취', align: 'center' }, ], data: [ - { type: '매출', issueDate: '2025-12-12', vendor: '거래처1', vat: 11000000, invoiceStatus: '미발행' }, - { type: '매입', issueDate: '2025-12-12', vendor: '거래처2', vat: 11000000, invoiceStatus: '미수취' }, - { type: '매출', issueDate: '2025-12-12', vendor: '거래처3', vat: 11000000, invoiceStatus: '미발행' }, - { type: '매입', issueDate: '2025-12-12', vendor: '거래처4', vat: 11000000, invoiceStatus: '미수취' }, - { type: '매출', issueDate: '2025-12-12', vendor: '거래처5', vat: 11000000, invoiceStatus: '미발행' }, - { type: '매입', issueDate: '2025-12-12', vendor: '거래처6', vat: 11000000, invoiceStatus: '미수취' }, - { type: '매출', issueDate: '2025-12-12', vendor: '거래처7', vat: 11000000, invoiceStatus: '미발행' }, + { type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' }, + { type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' }, + { type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' }, + { type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' }, + { type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' }, ], filters: [ { @@ -62,25 +67,6 @@ export function getVatModalConfig(): DetailModalConfig { ], defaultValue: 'all', }, - { - key: 'invoiceStatus', - options: [ - { value: 'all', label: '전체' }, - { value: '미발행', label: '미발행' }, - { value: '미수취', label: '미수취' }, - ], - defaultValue: 'all', - }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -88,4 +74,4 @@ export function getVatModalConfig(): DetailModalConfig { totalColumnKey: 'vat', }, }; -} \ No newline at end of file +} diff --git a/src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts b/src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts index bbb24c0a..8089411f 100644 --- a/src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts +++ b/src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts @@ -45,18 +45,27 @@ export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): Detai return { title: '복리후생비 상세', + dateFilter: { + enabled: true, + defaultPreset: '당월', + showSearch: true, + }, summaryCards: [ // 1행: 당해년도 기준 - { label: '당해년도 복리후생비 계정', value: 3123000, unit: '원' }, - { label: '당해년도 복리후생비 한도', value: 600000, unit: '원' }, - { label: '당해년도 복리후생비 사용', value: 6000000, unit: '원' }, - { label: '당해년도 잔여한도', value: 0, unit: '원' }, - // 2행: 1사분기 기준 - { label: '1사분기 복리후생비 총 한도', value: 3123000, unit: '원' }, - { label: '1사분기 복리후생비 잔여한도', value: 6000000, unit: '원' }, - { label: '1사분기 복리후생비 사용금액', value: 6000000, unit: '원' }, - { label: '1사분기 복리후생비 초과 금액', value: 6000000, unit: '원' }, + { label: '당해년도 복리후생비 총 한도', value: 3123000, unit: '원' }, + { label: '당해년도 복리후생비 잔여한도', value: 6000000, unit: '원' }, + { label: '당해년도 복리후생비 사용금액', value: 6000000, unit: '원' }, + { label: '당해년도 복리후생비 초과 금액', value: 0, unit: '원' }, ], + reviewCards: { + title: '복리후생비 검토 필요', + cards: [ + { label: '비과세 한도 초과', amount: 3123000, subLabel: '5건' }, + { label: '사적 사용 의심', amount: 3123000, subLabel: '5건' }, + { label: '특정인 편중', amount: 3123000, subLabel: '5건' }, + { label: '항목별 한도 초과', amount: 3123000, subLabel: '5건' }, + ], + }, barChart: { title: '월별 복리후생비 사용 추이', data: [ @@ -89,36 +98,34 @@ export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): Detai { key: 'date', label: '사용일자', align: 'center', format: 'date' }, { key: 'store', label: '가맹점명', align: 'left' }, { key: 'amount', label: '사용금액', align: 'right', format: 'currency' }, - { key: 'usageType', label: '사용항목', align: 'center' }, + { key: 'content', label: '내용', align: 'left' }, ], data: [ - { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '식비' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, usageType: '건강검진' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, usageType: '경조사비' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, usageType: '기타' }, - { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, usageType: '식비' }, + { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, content: '비과세 한도 초과' }, + { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, content: '사적 사용 의심' }, + { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, content: '특정인 편중' }, + { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, content: '항목별 한도 초과' }, + { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, content: '비과세 한도 초과' }, ], filters: [ { - key: 'usageType', + key: 'user', options: [ { value: 'all', label: '전체' }, - { value: '식비', label: '식비' }, - { value: '건강검진', label: '건강검진' }, - { value: '경조사비', label: '경조사비' }, - { value: '기타', label: '기타' }, + { value: '홍길동', label: '홍길동' }, ], defaultValue: 'all', }, { - key: 'sortOrder', + key: 'content', options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, + { value: 'all', label: '전체' }, + { value: '비과세 한도 초과', label: '비과세 한도 초과' }, + { value: '사적 사용 의심', label: '사적 사용 의심' }, + { value: '특정인 편중', label: '특정인 편중' }, + { value: '항목별 한도 초과', label: '항목별 한도 초과' }, ], - defaultValue: 'latest', + defaultValue: 'all', }, ], showTotal: true, diff --git a/src/components/business/CEODashboard/modals/DetailModal.tsx b/src/components/business/CEODashboard/modals/DetailModal.tsx index 9c2d54c1..acb80a11 100644 --- a/src/components/business/CEODashboard/modals/DetailModal.tsx +++ b/src/components/business/CEODashboard/modals/DetailModal.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useState, useCallback, useMemo } from 'react'; import { X } from 'lucide-react'; import { Dialog, @@ -8,39 +7,22 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - PieChart, - Pie, - Cell, -} from 'recharts'; import { cn } from '@/lib/utils'; -import type { - DetailModalConfig, - SummaryCardData, - BarChartConfig, - PieChartConfig, - HorizontalBarChartConfig, - TableConfig, - TableFilterConfig, - ComparisonSectionConfig, - ReferenceTableConfig, - CalculationCardsConfig, - QuarterlyTableConfig, -} from '../types'; +import type { DetailModalConfig } from '../types'; +import { + DateFilterSection, + PeriodSelectSection, + SummaryCard, + ReviewCardsSection, + BarChartSection, + PieChartSection, + HorizontalBarChartSection, + ComparisonSection, + CalculationCardsSection, + QuarterlyTableSection, + ReferenceTableSection, + TableSection, +} from './DetailModalSections'; interface DetailModalProps { isOpen: boolean; @@ -48,641 +30,6 @@ interface DetailModalProps { config: DetailModalConfig; } -/** - * 금액 포맷 함수 - */ -const formatCurrency = (value: number): string => { - return new Intl.NumberFormat('ko-KR').format(value); -}; - -/** - * 요약 카드 컴포넌트 - 모바일 반응형 지원 - */ -const SummaryCard = ({ data }: { data: SummaryCardData }) => { - const displayValue = typeof data.value === 'number' - ? formatCurrency(data.value) + (data.unit || '원') - : data.value; - - return ( -
-

{data.label}

-

- {data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''} - {displayValue} -

-
- ); -}; - -/** - * 막대 차트 컴포넌트 - 모바일 반응형 지원 - */ -const BarChartSection = ({ config }: { config: BarChartConfig }) => { - return ( -
-

{config.title}

-
- - - - - value >= 10000 ? `${value / 10000}만` : value} - width={35} - /> - [formatCurrency(value as number) + '원', '']} - contentStyle={{ fontSize: 12 }} - /> - - - -
-
- ); -}; - -/** - * 도넛 차트 컴포넌트 - 모바일 반응형 지원 - */ -const PieChartSection = ({ config }: { config: PieChartConfig }) => { - return ( -
-

{config.title}

- {/* 도넛 차트 - 중앙 정렬, 모바일 크기 조절 */} -
- - >} - cx={50} - cy={50} - innerRadius={28} - outerRadius={45} - paddingAngle={2} - dataKey="value" - > - {config.data.map((entry, index) => ( - - ))} - - -
- {/* 범례 - 세로 배치 (모바일 최적화) */} -
- {config.data.map((item, index) => ( -
-
-
- {item.name} - {item.percentage}% -
- - {formatCurrency(item.value)}원 - -
- ))} -
-
- ); -}; - -/** - * 가로 막대 차트 컴포넌트 - */ -const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfig }) => { - const maxValue = Math.max(...config.data.map(d => d.value)); - - return ( -
-

{config.title}

-
- {config.data.map((item, index) => ( -
-
- {item.name} - - {formatCurrency(item.value)}원 - -
-
-
-
-
- ))} -
-
- ); -}; - -/** - * VS 비교 섹션 컴포넌트 - */ -const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => { - const formatValue = (value: string | number, unit?: string): string => { - if (typeof value === 'number') { - return formatCurrency(value) + (unit || '원'); - } - return value; - }; - - const borderColorClass = { - orange: 'border-orange-400', - blue: 'border-blue-400', - }; - - const titleBgClass = { - orange: 'bg-orange-50', - blue: 'bg-blue-50', - }; - - return ( -
- {/* 왼쪽 박스 */} -
-
- {config.leftBox.title} -
-
- {config.leftBox.items.map((item, index) => ( -
-

{item.label}

-

- {formatValue(item.value, item.unit)} -

-
- ))} -
-
- - {/* VS 영역 */} -
- VS -
-

{config.vsLabel}

-

- {typeof config.vsValue === 'number' - ? formatCurrency(config.vsValue) + '원' - : config.vsValue} -

- {config.vsSubLabel && ( -

{config.vsSubLabel}

- )} - {/* VS 세부 항목 */} - {config.vsBreakdown && config.vsBreakdown.length > 0 && ( -
- {config.vsBreakdown.map((item, index) => ( -
- {item.label} - - {typeof item.value === 'number' - ? formatCurrency(item.value) + (item.unit || '원') - : item.value} - -
- ))} -
- )} -
-
- - {/* 오른쪽 박스 */} -
-
- {config.rightBox.title} -
-
- {config.rightBox.items.map((item, index) => ( -
-

{item.label}

-

- {formatValue(item.value, item.unit)} -

-
- ))} -
-
-
- ); -}; - -/** - * 계산 카드 섹션 컴포넌트 (접대비 계산 등) - */ -const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => { - const isResultCard = (index: number, operator?: string) => { - // '=' 연산자가 있는 카드는 결과 카드로 강조 - return operator === '='; - }; - - return ( -
-
-

{config.title}

- {config.subtitle && ( - {config.subtitle} - )} -
-
- {config.cards.map((card, index) => ( -
- {/* 연산자 표시 (첫 번째 카드 제외) */} - {index > 0 && card.operator && ( - - {card.operator} - - )} - {/* 카드 */} -
-

- {card.label} -

-

- {formatCurrency(card.value)}{card.unit || '원'} -

-
-
- ))} -
-
- ); -}; - -/** - * 분기별 테이블 섹션 컴포넌트 (접대비 현황 등) - 가로 스크롤 지원 - */ -const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => { - const formatValue = (value: number | string | undefined): string => { - if (value === undefined) return '-'; - if (typeof value === 'number') return formatCurrency(value); - return value; - }; - - return ( -
-

{config.title}

-
- - - - - - - - - - - - - {config.rows.map((row, rowIndex) => ( - - - - - - - - - ))} - -
구분1사분기2사분기3사분기4사분기합계
{row.label}{formatValue(row.q1)}{formatValue(row.q2)}{formatValue(row.q3)}{formatValue(row.q4)}{formatValue(row.total)}
-
-
- ); -}; - -/** - * 참조 테이블 컴포넌트 (필터 없는 정보성 테이블) - 가로 스크롤 지원 - */ -const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) => { - const getAlignClass = (align?: string): string => { - switch (align) { - case 'center': - return 'text-center'; - case 'right': - return 'text-right'; - default: - return 'text-left'; - } - }; - - return ( -
-

{config.title}

-
- - - - {config.columns.map((column) => ( - - ))} - - - - {config.data.map((row, rowIndex) => ( - - {config.columns.map((column) => ( - - ))} - - ))} - -
- {column.label} -
- {String(row[column.key] ?? '-')} -
-
-
- ); -}; - -/** - * 테이블 컴포넌트 - */ -const TableSection = ({ config }: { config: TableConfig }) => { - const [filters, setFilters] = useState>(() => { - const initial: Record = {}; - config.filters?.forEach((filter) => { - initial[filter.key] = filter.defaultValue; - }); - return initial; - }); - - const handleFilterChange = useCallback((key: string, value: string) => { - setFilters((prev) => ({ ...prev, [key]: value })); - }, []); - - // 필터링된 데이터 - const filteredData = useMemo(() => { - // 데이터가 없는 경우 빈 배열 반환 - if (!config.data || !Array.isArray(config.data)) { - return []; - } - let result = [...config.data]; - - // 각 필터 적용 (sortOrder는 정렬용이므로 제외) - config.filters?.forEach((filter) => { - if (filter.key === 'sortOrder') return; // 정렬 필터는 값 필터링에서 제외 - const filterValue = filters[filter.key]; - if (filterValue && filterValue !== 'all') { - result = result.filter((row) => row[filter.key] === filterValue); - } - }); - - // 정렬 필터 적용 (sortOrder가 있는 경우) - if (filters['sortOrder']) { - const sortOrder = filters['sortOrder']; - result.sort((a, b) => { - // 금액 정렬 - if (sortOrder === 'amountDesc') { - return (b['amount'] as number) - (a['amount'] as number); - } - if (sortOrder === 'amountAsc') { - return (a['amount'] as number) - (b['amount'] as number); - } - // 날짜 정렬 - const dateA = new Date(a['date'] as string).getTime(); - const dateB = new Date(b['date'] as string).getTime(); - return sortOrder === 'latest' ? dateB - dateA : dateA - dateB; - }); - } - - return result; - }, [config.data, config.filters, filters]); - - // 셀 값 포맷팅 - const formatCellValue = (value: unknown, format?: string): string => { - if (value === null || value === undefined) return '-'; - - switch (format) { - case 'currency': - return typeof value === 'number' ? formatCurrency(value) : String(value); - case 'number': - return typeof value === 'number' ? formatCurrency(value) : String(value); - case 'date': - return String(value); - default: - return String(value); - } - }; - - // 셀 정렬 클래스 - const getAlignClass = (align?: string): string => { - switch (align) { - case 'center': - return 'text-center'; - case 'right': - return 'text-right'; - default: - return 'text-left'; - } - }; - - return ( -
- {/* 테이블 헤더 */} -
-
-

{config.title}

- 총 {filteredData.length}건 -
- - {/* 필터 영역 */} - {config.filters && config.filters.length > 0 && ( -
- {config.filters.map((filter) => ( - - ))} -
- )} -
- - {/* 테이블 - 가로 스크롤 지원 */} -
- - - - {config.columns.map((column) => ( - - ))} - - - - {filteredData.map((row, rowIndex) => ( - - {config.columns.map((column) => { - const cellValue = column.key === 'no' - ? rowIndex + 1 - : formatCellValue(row[column.key], column.format); - const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue; - - // highlightColor 클래스 매핑 - const highlightColorClass = column.highlightColor ? { - red: 'text-red-500', - orange: 'text-orange-500', - blue: 'text-blue-500', - green: 'text-green-500', - }[column.highlightColor] : ''; - - return ( - - ); - })} - - ))} - - {/* 합계 행 */} - {config.showTotal && ( - - {config.columns.map((column, colIndex) => ( - - ))} - - )} - -
- {column.label} -
- {cellValue} -
- {column.key === config.totalColumnKey - ? (typeof config.totalValue === 'number' - ? formatCurrency(config.totalValue) - : config.totalValue) - : (colIndex === 0 ? config.totalLabel || '합계' : '')} -
-
- - {/* 하단 다중 합계 섹션 */} - {config.footerSummary && config.footerSummary.length > 0 && ( -
-
- {config.footerSummary.map((item, index) => ( -
- {item.label} - - {typeof item.value === 'number' - ? formatCurrency(item.value) - : item.value} - -
- ))} -
-
- )} -
- ); -}; - -/** - * 상세 모달 공통 컴포넌트 - */ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) { return ( !open && onClose()} > @@ -702,6 +49,16 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
+ {/* 기간선택기 영역 */} + {config.dateFilter?.enabled && ( + + )} + + {/* 신고기간 셀렉트 영역 */} + {config.periodSelect?.enabled && ( + + )} + {/* 요약 카드 영역 - 모바일: 세로배치 */} {config.summaryCards.length > 0 && (
)} + {/* 검토 필요 카드 영역 */} + {config.reviewCards && ( + + )} + {/* 차트 영역 */} {(config.barChart || config.pieChart || config.horizontalBarChart) && (
diff --git a/src/components/business/CEODashboard/modals/DetailModalSections.tsx b/src/components/business/CEODashboard/modals/DetailModalSections.tsx new file mode 100644 index 00000000..0e8d773f --- /dev/null +++ b/src/components/business/CEODashboard/modals/DetailModalSections.tsx @@ -0,0 +1,712 @@ +'use client'; + +import { useState, useCallback, useMemo } from 'react'; +import { Search } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, +} from 'recharts'; +import { cn } from '@/lib/utils'; +import { formatNumber as formatCurrency } from '@/lib/utils/amount'; +import type { + DateFilterConfig, + PeriodSelectConfig, + SummaryCardData, + BarChartConfig, + PieChartConfig, + HorizontalBarChartConfig, + TableConfig, + ComparisonSectionConfig, + ReferenceTableConfig, + CalculationCardsConfig, + QuarterlyTableConfig, + ReviewCardsConfig, +} from '../types'; + +// ============================================ +// 공통 유틸리티 +// ============================================ +// 필터 섹션 +// ============================================ + +export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => { + const today = new Date(); + const [startDate, setStartDate] = useState(() => { + const d = new Date(today.getFullYear(), today.getMonth(), 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }); + const [endDate, setEndDate] = useState(() => { + const d = new Date(today.getFullYear(), today.getMonth() + 1, 0); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }); + const [searchText, setSearchText] = useState(''); + + return ( +
+ + + setSearchText(e.target.value)} + placeholder="검색" + className="h-8 pl-7 pr-3 text-xs w-[140px]" + /> +
+ ) : undefined + } + /> +
+ ); +}; + +export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => { + const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || ''); + + return ( +
+ 신고기간 + +
+ ); +}; + +// ============================================ +// 카드 섹션 +// ============================================ + +export const SummaryCard = ({ data }: { data: SummaryCardData }) => { + const displayValue = typeof data.value === 'number' + ? formatCurrency(data.value) + (data.unit || '원') + : data.value; + + return ( +
+

{data.label}

+

+ {data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''} + {displayValue} +

+
+ ); +}; + +export const ReviewCardsSection = ({ config }: { config: ReviewCardsConfig }) => { + return ( +
+

{config.title}

+
+ {config.cards.map((card, index) => ( +
+

{card.label}

+

+ {formatCurrency(card.amount)}원 +

+

{card.subLabel}

+
+ ))} +
+
+ ); +}; + +export const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => { + const isResultCard = (_index: number, operator?: string) => { + return operator === '='; + }; + + return ( +
+
+

{config.title}

+ {config.subtitle && ( + {config.subtitle} + )} +
+
+ {config.cards.map((card, index) => ( +
+ {index > 0 && card.operator && ( + + {card.operator} + + )} +
+

+ {card.label} +

+

+ {formatCurrency(card.value)}{card.unit || '원'} +

+
+
+ ))} +
+
+ ); +}; + +// ============================================ +// 차트 섹션 +// ============================================ + +export const BarChartSection = ({ config }: { config: BarChartConfig }) => { + return ( +
+

{config.title}

+
+ + + + + value >= 10000 ? `${value / 10000}만` : value} + width={35} + /> + [formatCurrency(value as number) + '원', '']} + contentStyle={{ fontSize: 12 }} + /> + + + +
+
+ ); +}; + +export const PieChartSection = ({ config }: { config: PieChartConfig }) => { + return ( +
+

{config.title}

+
+ + >} + cx={50} + cy={50} + innerRadius={28} + outerRadius={45} + paddingAngle={2} + dataKey="value" + > + {config.data.map((entry, index) => ( + + ))} + + +
+
+ {config.data.map((item, index) => ( +
+
+
+ {item.name} + {item.percentage}% +
+ + {formatCurrency(item.value)}원 + +
+ ))} +
+
+ ); +}; + +export const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfig }) => { + const maxValue = Math.max(...config.data.map(d => d.value)); + + return ( +
+

{config.title}

+
+ {config.data.map((item, index) => ( +
+
+ {item.name} + + {formatCurrency(item.value)}원 + +
+
+
+
+
+ ))} +
+
+ ); +}; + +// ============================================ +// 비교 섹션 +// ============================================ + +export const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => { + const formatValue = (value: string | number, unit?: string): string => { + if (typeof value === 'number') { + return formatCurrency(value) + (unit || '원'); + } + return value; + }; + + const borderColorClass = { + orange: 'border-orange-400', + blue: 'border-blue-400', + }; + + const titleBgClass = { + orange: 'bg-orange-50', + blue: 'bg-blue-50', + }; + + return ( +
+ {/* 왼쪽 박스 */} +
+
+ {config.leftBox.title} +
+
+ {config.leftBox.items.map((item, index) => ( +
+

{item.label}

+

+ {formatValue(item.value, item.unit)} +

+
+ ))} +
+
+ + {/* VS 영역 */} +
+ VS +
+

{config.vsLabel}

+

+ {typeof config.vsValue === 'number' + ? formatCurrency(config.vsValue) + '원' + : config.vsValue} +

+ {config.vsSubLabel && ( +

{config.vsSubLabel}

+ )} + {config.vsBreakdown && config.vsBreakdown.length > 0 && ( +
+ {config.vsBreakdown.map((item, index) => ( +
+ {item.label} + + {typeof item.value === 'number' + ? formatCurrency(item.value) + (item.unit || '원') + : item.value} + +
+ ))} +
+ )} +
+
+ + {/* 오른쪽 박스 */} +
+
+ {config.rightBox.title} +
+
+ {config.rightBox.items.map((item, index) => ( +
+

{item.label}

+

+ {formatValue(item.value, item.unit)} +

+
+ ))} +
+
+
+ ); +}; + +// ============================================ +// 테이블 섹션 +// ============================================ + +export const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => { + const formatValue = (value: number | string | undefined): string => { + if (value === undefined) return '-'; + if (typeof value === 'number') return formatCurrency(value); + return value; + }; + + return ( +
+

{config.title}

+
+ + + + + + + + + + + + + {config.rows.map((row, rowIndex) => ( + + + + + + + + + ))} + +
구분1사분기2사분기3사분기4사분기합계
{row.label}{formatValue(row.q1)}{formatValue(row.q2)}{formatValue(row.q3)}{formatValue(row.q4)}{formatValue(row.total)}
+
+
+ ); +}; + +export const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) => { + const getAlignClass = (align?: string): string => { + switch (align) { + case 'center': return 'text-center'; + case 'right': return 'text-right'; + default: return 'text-left'; + } + }; + + return ( +
+

{config.title}

+
+ + + + {config.columns.map((column) => ( + + ))} + + + + {config.data.map((row, rowIndex) => ( + + {config.columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+ {String(row[column.key] ?? '-')} +
+
+
+ ); +}; + +export const TableSection = ({ config }: { config: TableConfig }) => { + const [filters, setFilters] = useState>(() => { + const initial: Record = {}; + config.filters?.forEach((filter) => { + initial[filter.key] = filter.defaultValue; + }); + return initial; + }); + + const handleFilterChange = useCallback((key: string, value: string) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }, []); + + const filteredData = useMemo(() => { + if (!config.data || !Array.isArray(config.data)) { + return []; + } + let result = [...config.data]; + + config.filters?.forEach((filter) => { + if (filter.key === 'sortOrder') return; + const filterValue = filters[filter.key]; + if (filterValue && filterValue !== 'all') { + result = result.filter((row) => row[filter.key] === filterValue); + } + }); + + if (filters['sortOrder']) { + const sortOrder = filters['sortOrder']; + result.sort((a, b) => { + if (sortOrder === 'amountDesc') { + return (b['amount'] as number) - (a['amount'] as number); + } + if (sortOrder === 'amountAsc') { + return (a['amount'] as number) - (b['amount'] as number); + } + const dateA = new Date(a['date'] as string).getTime(); + const dateB = new Date(b['date'] as string).getTime(); + return sortOrder === 'latest' ? dateB - dateA : dateA - dateB; + }); + } + + return result; + }, [config.data, config.filters, filters]); + + const formatCellValue = (value: unknown, format?: string): string => { + if (value === null || value === undefined) return '-'; + switch (format) { + case 'currency': + case 'number': + return typeof value === 'number' ? formatCurrency(value) : String(value); + default: + return String(value); + } + }; + + const getAlignClass = (align?: string): string => { + switch (align) { + case 'center': return 'text-center'; + case 'right': return 'text-right'; + default: return 'text-left'; + } + }; + + return ( +
+
+
+

{config.title}

+ 총 {filteredData.length}건 +
+ + {config.filters && config.filters.length > 0 && ( +
+ {config.filters.map((filter) => ( + + ))} +
+ )} +
+ +
+ + + + {config.columns.map((column) => ( + + ))} + + + + {filteredData.map((row, rowIndex) => ( + + {config.columns.map((column) => { + const cellValue = column.key === 'no' + ? rowIndex + 1 + : formatCellValue(row[column.key], column.format); + const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue; + + const highlightColorClass = column.highlightColor ? { + red: 'text-red-500', + orange: 'text-orange-500', + blue: 'text-blue-500', + green: 'text-green-500', + }[column.highlightColor] : ''; + + return ( + + ); + })} + + ))} + + {config.showTotal && ( + + {config.columns.map((column, colIndex) => ( + + ))} + + )} + +
+ {column.label} +
+ {cellValue} +
+ {column.key === config.totalColumnKey + ? (typeof config.totalValue === 'number' + ? formatCurrency(config.totalValue) + : config.totalValue) + : (colIndex === 0 ? config.totalLabel || '합계' : '')} +
+
+ + {config.footerSummary && config.footerSummary.length > 0 && ( +
+
+ {config.footerSummary.map((item, index) => ( +
+ {item.label} + + {typeof item.value === 'number' + ? formatCurrency(item.value) + : item.value} + +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/components/business/CEODashboard/sections/CardManagementSection.tsx b/src/components/business/CEODashboard/sections/CardManagementSection.tsx index d108fc04..487ccd66 100644 --- a/src/components/business/CEODashboard/sections/CardManagementSection.tsx +++ b/src/components/business/CEODashboard/sections/CardManagementSection.tsx @@ -1,13 +1,13 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { CreditCard, Wallet, Receipt, AlertTriangle } from 'lucide-react'; +import { CreditCard, Wallet, Receipt, AlertTriangle, Gift } from 'lucide-react'; import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components'; import type { CardManagementData } from '../types'; // 카드별 아이콘 매핑 -const CARD_ICONS = [CreditCard, Wallet, Receipt, AlertTriangle]; -const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange']; +const CARD_ICONS = [CreditCard, Gift, Receipt, AlertTriangle, Wallet]; +const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange', 'blue']; interface CardManagementSectionProps { data: CardManagementData; @@ -28,8 +28,8 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti return ( } - title="카드/가지급금 관리" - subtitle="카드 및 가지급금 현황" + title="가지급금 현황" + subtitle="가지급금 관리 현황" > {data.warningBanner && (
@@ -38,7 +38,7 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
)} -
+
{data.cards.map((card, idx) => ( = { '세금 신고': 'taxReport', '신규 업체 등록': 'newVendor', '연차': 'annualLeave', - '지각': 'lateness', - '결근': 'absence', + '차량': 'vehicle', + '장비': 'equipment', '발주': 'purchase', '결재 요청': 'approvalRequest', }; @@ -274,6 +275,20 @@ interface EnhancedMonthlyExpenseSectionProps { onCardClick?: (cardId: string) => void; } +// 당월 예상 지출 카드 설정 +const EXPENSE_CARD_CONFIGS: Array<{ + icon: LucideIcon; + iconBg: string; + bgClass: string; + labelClass: string; + defaultLabel: string; + defaultId: string; +}> = [ + { icon: Receipt, iconBg: '#8b5cf6', bgClass: 'bg-purple-50 border-purple-200 dark:bg-purple-900/30 dark:border-purple-800', labelClass: 'text-purple-700 dark:text-purple-300', defaultLabel: '매입', defaultId: 'me1' }, + { icon: CreditCard, iconBg: '#3b82f6', bgClass: 'bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800', labelClass: 'text-blue-700 dark:text-blue-300', defaultLabel: '카드', defaultId: 'me2' }, + { icon: Banknote, iconBg: '#f59e0b', bgClass: 'bg-amber-50 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800', labelClass: 'text-amber-700 dark:text-amber-300', defaultLabel: '발행어음', defaultId: 'me3' }, +]; + export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) { // 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환) const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0); @@ -291,77 +306,35 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon > {/* 카드 그리드 */}
- {/* 카드 1: 매입 */} -
onCardClick?.(data.cards[0]?.id || 'me1')} - > -
-
- + {EXPENSE_CARD_CONFIGS.map((config, idx) => { + const card = data.cards[idx]; + const CardIcon = config.icon; + return ( +
onCardClick?.(card?.id || config.defaultId)} + > +
+
+ +
+ + {card?.label || config.defaultLabel} + +
+
+ {formatKoreanAmount(card?.amount || 0)} +
+ {card?.previousLabel && ( +
+ + {card.previousLabel} +
+ )}
- - {data.cards[0]?.label || '매입'} - -
-
- {formatKoreanAmount(data.cards[0]?.amount || 0)} -
- {data.cards[0]?.previousLabel && ( -
- - {data.cards[0].previousLabel} -
- )} -
- - {/* 카드 2: 카드 */} -
onCardClick?.(data.cards[1]?.id || 'me2')} - > -
-
- -
- - {data.cards[1]?.label || '카드'} - -
-
- {formatKoreanAmount(data.cards[1]?.amount || 0)} -
- {data.cards[1]?.previousLabel && ( -
- - {data.cards[1].previousLabel} -
- )} -
- - {/* 카드 3: 발행어음 */} -
onCardClick?.(data.cards[2]?.id || 'me3')} - > -
-
- -
- - {data.cards[2]?.label || '발행어음'} - -
-
- {formatKoreanAmount(data.cards[2]?.amount || 0)} -
- {data.cards[2]?.previousLabel && ( -
- - {data.cards[2].previousLabel} -
- )} -
+ ); + })} {/* 카드 4: 총 예상 지출 합계 (강조) */}
{ - if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억`; - if (value >= 10000) return `${(value / 10000).toFixed(0)}만`; - return value.toLocaleString(); -}; export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) { const [supplierFilter, setSupplierFilter] = useState([]); - const [sortOrder, setSortOrder] = useState('date-desc'); const filteredItems = data.dailyItems - .filter((item) => supplierFilter.length === 0 || supplierFilter.includes(item.supplier)) - .sort((a, b) => { - if (sortOrder === 'date-desc') return b.date.localeCompare(a.date); - if (sortOrder === 'date-asc') return a.date.localeCompare(b.date); - if (sortOrder === 'amount-desc') return b.amount - a.amount; - return a.amount - b.amount; - }); + .filter((item) => supplierFilter.length === 0 || supplierFilter.includes(item.supplier)); const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))]; @@ -130,7 +112,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) { - + [formatKoreanAmount(Number(value) || 0), '매입']} /> @@ -189,17 +171,6 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) { placeholder="전체 공급처" className="w-full h-8 text-xs" /> -
diff --git a/src/components/business/CEODashboard/sections/SalesStatusSection.tsx b/src/components/business/CEODashboard/sections/SalesStatusSection.tsx index 699eae5b..6290846d 100644 --- a/src/components/business/CEODashboard/sections/SalesStatusSection.tsx +++ b/src/components/business/CEODashboard/sections/SalesStatusSection.tsx @@ -12,14 +12,8 @@ import { DollarSign, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; +import { formatCompactAmount } from '@/lib/utils/amount'; import { BarChart, Bar, @@ -37,24 +31,12 @@ interface SalesStatusSectionProps { data: SalesStatusData; } -const formatAmount = (value: number) => { - if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억`; - if (value >= 10000) return `${(value / 10000).toFixed(0)}만`; - return value.toLocaleString(); -}; export function SalesStatusSection({ data }: SalesStatusSectionProps) { const [clientFilter, setClientFilter] = useState([]); - const [sortOrder, setSortOrder] = useState('date-desc'); const filteredItems = data.dailyItems - .filter((item) => clientFilter.length === 0 || clientFilter.includes(item.client)) - .sort((a, b) => { - if (sortOrder === 'date-desc') return b.date.localeCompare(a.date); - if (sortOrder === 'date-asc') return a.date.localeCompare(b.date); - if (sortOrder === 'amount-desc') return b.amount - a.amount; - return a.amount - b.amount; - }); + .filter((item) => clientFilter.length === 0 || clientFilter.includes(item.client)); const clients = [...new Set(data.dailyItems.map((item) => item.client))]; @@ -143,7 +125,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) { - + [formatKoreanAmount(Number(value) || 0), '매출']} /> @@ -158,7 +140,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) { - + [formatKoreanAmount(Number(value) || 0), '매출']} @@ -187,17 +169,6 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) { placeholder="전체 거래처" className="w-full h-8 text-xs" /> -
diff --git a/src/components/business/CEODashboard/sections/StatusBoardSection.tsx b/src/components/business/CEODashboard/sections/StatusBoardSection.tsx index 44acfb0e..210bc6e4 100644 --- a/src/components/business/CEODashboard/sections/StatusBoardSection.tsx +++ b/src/components/business/CEODashboard/sections/StatusBoardSection.tsx @@ -13,8 +13,8 @@ const LABEL_TO_SETTING_KEY: Record = { '세금 신고': 'taxReport', '신규 업체 등록': 'newVendor', '연차': 'annualLeave', - '지각': 'lateness', - '결근': 'absence', + '차량': 'vehicle', + '장비': 'equipment', '발주': 'purchase', '결재 요청': 'approvalRequest', }; diff --git a/src/components/business/CEODashboard/sections/UnshippedSection.tsx b/src/components/business/CEODashboard/sections/UnshippedSection.tsx index 01591190..67fe9703 100644 --- a/src/components/business/CEODashboard/sections/UnshippedSection.tsx +++ b/src/components/business/CEODashboard/sections/UnshippedSection.tsx @@ -3,13 +3,6 @@ import { useState } from 'react'; import { PackageX } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; import { CollapsibleDashboardCard } from '../components'; import type { UnshippedData } from '../types'; @@ -20,16 +13,11 @@ interface UnshippedSectionProps { export function UnshippedSection({ data }: UnshippedSectionProps) { const [clientFilter, setClientFilter] = useState([]); - const [sortOrder, setSortOrder] = useState('due-asc'); const clients = [...new Set(data.items.map((item) => item.orderClient))]; const filteredItems = data.items - .filter((item) => clientFilter.length === 0 || clientFilter.includes(item.orderClient)) - .sort((a, b) => { - if (sortOrder === 'due-asc') return a.daysLeft - b.daysLeft; - return b.daysLeft - a.daysLeft; - }); + .filter((item) => clientFilter.length === 0 || clientFilter.includes(item.orderClient)); return ( -
diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts index cfffe60e..f0214142 100644 --- a/src/components/business/CEODashboard/types.ts +++ b/src/components/business/CEODashboard/types.ts @@ -396,7 +396,7 @@ export const SECTION_LABELS: Record = { dailyReport: '자금현황', statusBoard: '현황판', monthlyExpense: '당월 예상 지출 내역', - cardManagement: '카드/가지급금 관리', + cardManagement: '가지급금 현황', entertainment: '접대비 현황', welfare: '복리후생비 현황', receivable: '미수금 현황', @@ -422,10 +422,11 @@ export interface TodayIssueSettings { taxReport: boolean; // 세금 신고 newVendor: boolean; // 신규 업체 등록 annualLeave: boolean; // 연차 - lateness: boolean; // 지각 - absence: boolean; // 결근 + vehicle: boolean; // 차량 + equipment: boolean; // 장비 purchase: boolean; // 발주 approvalRequest: boolean; // 결재 요청 + fundStatus: boolean; // 자금 현황 } // 접대비 한도 관리 타입 @@ -445,6 +446,7 @@ export interface EntertainmentSettings { enabled: boolean; limitType: EntertainmentLimitType; companyType: CompanyType; + highAmountThreshold: number; // 고액 결제 기준 금액 } // 복리후생비 설정 @@ -455,6 +457,7 @@ export interface WelfareSettings { fixedAmountPerMonth: number; // 직원당 정해 금액/월 ratio: number; // 연봉 총액 X 비율 (%) annualTotal: number; // 연간 복리후생비총액 + singlePaymentThreshold: number; // 1회 결제 기준 금액 } // 대시보드 전체 설정 @@ -662,10 +665,43 @@ export interface QuarterlyTableConfig { rows: QuarterlyTableRow[]; } +// 검토 필요 카드 아이템 타입 +export interface ReviewCardItem { + label: string; + amount: number; + subLabel: string; // e.g., "미증빙 5건" +} + +// 검토 필요 카드 섹션 설정 타입 +export interface ReviewCardsConfig { + title: string; + cards: ReviewCardItem[]; +} + +// 기간 필터 설정 타입 +export type DateFilterPreset = '당해년도' | '전전월' | '전월' | '당월' | '어제' | '오늘'; + +export interface DateFilterConfig { + enabled: boolean; + presets?: DateFilterPreset[]; // 기간 버튼 목록 (기본: 전체) + defaultPreset?: DateFilterPreset; // 기본 선택 프리셋 + showSearch?: boolean; // 검색 입력창 표시 여부 +} + +// 신고기간 셀렉트 설정 타입 +export interface PeriodSelectConfig { + enabled: boolean; + options: { value: string; label: string }[]; + defaultValue?: string; +} + // 상세 모달 전체 설정 타입 export interface DetailModalConfig { title: string; + dateFilter?: DateFilterConfig; // 기간선택기 + 검색 + periodSelect?: PeriodSelectConfig; // 신고기간 셀렉트 (부가세 등) summaryCards: SummaryCardData[]; + reviewCards?: ReviewCardsConfig; // 검토 필요 카드 섹션 barChart?: BarChartConfig; pieChart?: PieChartConfig; horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용) @@ -691,10 +727,11 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = { taxReport: false, newVendor: false, annualLeave: true, - lateness: true, - absence: false, + vehicle: false, + equipment: false, purchase: false, approvalRequest: false, + fundStatus: true, }, }, dailyReport: true, @@ -704,6 +741,7 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = { enabled: true, limitType: 'annual', companyType: 'medium', + highAmountThreshold: 500000, }, welfare: { enabled: true, @@ -712,6 +750,7 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = { fixedAmountPerMonth: 200000, ratio: 20.5, annualTotal: 20000000, + singlePaymentThreshold: 500000, }, receivable: true, debtCollection: true, @@ -737,10 +776,11 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = { taxReport: false, newVendor: false, annualLeave: true, - lateness: true, - absence: false, + vehicle: false, + equipment: false, purchase: false, approvalRequest: false, + fundStatus: true, }, }, }; \ No newline at end of file diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4282a1ad..6df741aa 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -153,7 +153,9 @@ function MenuItemComponent({ className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${ isFav ? 'opacity-100 text-yellow-500' - : 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500' + : isMobile + ? 'opacity-50 text-muted-foreground active:text-yellow-500' + : 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500' }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > @@ -216,7 +218,9 @@ function MenuItemComponent({ className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${ isFav ? 'opacity-100 text-yellow-500' - : 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500' + : isMobile + ? 'opacity-50 text-muted-foreground active:text-yellow-500' + : 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500' }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > @@ -281,7 +285,9 @@ function MenuItemComponent({ className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${ isFav ? 'opacity-100 text-yellow-500' - : 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500' + : isMobile + ? 'opacity-50 text-muted-foreground active:text-yellow-500' + : 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500' }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > diff --git a/src/hooks/useCEODashboard.ts b/src/hooks/useCEODashboard.ts index b09c9df6..2e48ab67 100644 --- a/src/hooks/useCEODashboard.ts +++ b/src/hooks/useCEODashboard.ts @@ -1,12 +1,18 @@ - /** * CEO Dashboard API 연동 Hook * * 각 섹션별 API 호출 및 데이터 변환 담당 - * 참조 패턴: useClientList.ts + * 제네릭 useDashboardFetch 훅을 활용하여 보일러플레이트 최소화 */ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; + +import { useDashboardFetch } from './useDashboardFetch'; + +import { + fetchLoanDashboard, + fetchTaxSimulation, +} from '@/lib/api/dashboard/endpoints'; import type { DailyReportApiResponse, @@ -22,15 +28,8 @@ import type { WelfareApiResponse, WelfareDetailApiResponse, ExpectedExpenseDashboardDetailApiResponse, - LoanDashboardApiResponse, - TaxSimulationApiResponse, } from '@/lib/api/dashboard/types'; -import { - fetchLoanDashboard, - fetchTaxSimulation, -} from '@/lib/api/dashboard/endpoints'; - import { transformDailyReportResponse, transformReceivableResponse, @@ -45,9 +44,6 @@ import { transformWelfareResponse, transformWelfareDetailResponse, transformExpectedExpenseDetailResponse, - transformPurchaseDetailResponse, - transformCardDetailResponse, - transformBillDetailResponse, } from '@/lib/api/dashboard/transformers'; import type { @@ -66,163 +62,79 @@ import type { } from '@/components/business/CEODashboard/types'; // ============================================ -// 공통 fetch 유틸리티 +// 쿼리 파라미터 빌더 유틸리티 // ============================================ -async function fetchApi(endpoint: string): Promise { - const response = await fetch(`/api/proxy/${endpoint}`); - - if (!response.ok) { - throw new Error(`API 오류: ${response.status}`); +function buildEndpoint( + base: string, + params: Record, +): string { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value != null && value !== '') { + searchParams.append(key, String(value)); + } } - - const result = await response.json(); - - if (!result.success) { - throw new Error(result.message || '데이터 조회 실패'); - } - - return result.data; + const qs = searchParams.toString(); + return qs ? `${base}?${qs}` : base; } // ============================================ -// 1. DailyReport Hook +// CardManagement 전용 fetch 유틸리티 +// ============================================ + +async function fetchCardManagementData(fallbackData?: CardManagementData) { + const [cardApiData, loanResponse, taxResponse] = await Promise.all([ + fetch('/api/proxy/card-transactions/summary').then(async (r) => { + if (!r.ok) throw new Error(`API 오류: ${r.status}`); + const json = await r.json(); + if (!json.success) throw new Error(json.message || '데이터 조회 실패'); + return json.data as CardTransactionApiResponse; + }), + fetchLoanDashboard(), + fetchTaxSimulation(), + ]); + + const loanData = loanResponse.success ? loanResponse.data : null; + const taxData = taxResponse.success ? taxResponse.data : null; + + return transformCardManagementResponse(cardApiData, loanData, taxData, fallbackData); +} + +// ============================================ +// 1~4. 단순 섹션 Hooks (파라미터 없음) // ============================================ export function useDailyReport() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const apiData = await fetchApi('daily-report/summary'); - const transformed = transformDailyReportResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('DailyReport API Error:', err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch( + 'daily-report/summary', + transformDailyReportResponse, + ); } -// ============================================ -// 2. Receivable Hook -// ============================================ - export function useReceivable() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const apiData = await fetchApi('receivables/summary'); - const transformed = transformReceivableResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('Receivable API Error:', err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch( + 'receivables/summary', + transformReceivableResponse, + ); } -// ============================================ -// 3. DebtCollection Hook -// ============================================ - export function useDebtCollection() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const apiData = await fetchApi('bad-debts/summary'); - const transformed = transformDebtCollectionResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('DebtCollection API Error:', err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch( + 'bad-debts/summary', + transformDebtCollectionResponse, + ); } -// ============================================ -// 4. MonthlyExpense Hook -// ============================================ - export function useMonthlyExpense() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const apiData = await fetchApi('expected-expenses/summary'); - const transformed = transformMonthlyExpenseResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('MonthlyExpense API Error:', err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch( + 'expected-expenses/summary', + transformMonthlyExpenseResponse, + ); } // ============================================ -// 5. CardManagement Hook +// 5. CardManagement Hook (커스텀: 3개 API 병렬 호출) // ============================================ export function useCardManagement(fallbackData?: CardManagementData) { @@ -234,24 +146,10 @@ export function useCardManagement(fallbackData?: CardManagementData) { try { setLoading(true); setError(null); - - // 3개 API 병렬 호출: 카드거래, 가지급금, 세금 시뮬레이션 - const [cardApiData, loanResponse, taxResponse] = await Promise.all([ - fetchApi('card-transactions/summary'), - fetchLoanDashboard(), - fetchTaxSimulation(), - ]); - - // LoanDashboard와 TaxSimulation은 ApiResponse wrapper가 있으므로 data 추출 - const loanData = loanResponse.success ? loanResponse.data : null; - const taxData = taxResponse.success ? taxResponse.data : null; - - const transformed = transformCardManagementResponse(cardApiData, loanData, taxData, fallbackData); - setData(transformed); - + const result = await fetchCardManagementData(fallbackData); + setData(result); } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); + setError(err instanceof Error ? err.message : '데이터 로딩 실패'); console.error('CardManagement API Error:', err); } finally { setLoading(false); @@ -270,33 +168,10 @@ export function useCardManagement(fallbackData?: CardManagementData) { // ============================================ export function useStatusBoard() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const apiData = await fetchApi('status-board/summary'); - const transformed = transformStatusBoardResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('StatusBoard API Error:', err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch( + 'status-board/summary', + transformStatusBoardResponse, + ); } // ============================================ @@ -309,33 +184,14 @@ export interface TodayIssueData { } export function useTodayIssue(limit: number = 30) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const apiData = await fetchApi(`today-issues/summary?limit=${limit}`); - const transformed = transformTodayIssueResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('TodayIssue API Error:', err); - } finally { - setLoading(false); - } - }, [limit]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + const endpoint = useMemo( + () => buildEndpoint('today-issues/summary', { limit }), + [limit], + ); + return useDashboardFetch( + endpoint, + transformTodayIssueResponse, + ); } // ============================================ @@ -343,37 +199,15 @@ export function useTodayIssue(limit: number = 30) { // ============================================ export function usePastIssue(date: string | null, limit: number = 30) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - if (!date) return; - - try { - setLoading(true); - setError(null); - - const apiData = await fetchApi( - `today-issues/summary?limit=${limit}&date=${date}` - ); - const transformed = transformTodayIssueResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('PastIssue API Error:', err); - } finally { - setLoading(false); - } - }, [date, limit]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + const endpoint = useMemo( + () => (date ? buildEndpoint('today-issues/summary', { limit, date }) : null), + [date, limit], + ); + return useDashboardFetch( + endpoint, + transformTodayIssueResponse, + { initialLoading: false }, + ); } // ============================================ @@ -386,55 +220,30 @@ export interface CalendarData { } export interface UseCalendarOptions { - /** 조회 시작일 (Y-m-d, 기본: 이번 달 1일) */ startDate?: string; - /** 조회 종료일 (Y-m-d, 기본: 이번 달 말일) */ endDate?: string; - /** 일정 타입 필터 (schedule|order|construction|other|null=전체) */ type?: 'schedule' | 'order' | 'construction' | 'other' | null; - /** 부서 필터 (all|department|personal) */ departmentFilter?: 'all' | 'department' | 'personal'; } export function useCalendar(options: UseCalendarOptions = {}) { const { startDate, endDate, type, departmentFilter = 'all' } = options; - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); + const endpoint = useMemo( + () => + buildEndpoint('calendar/schedules', { + start_date: startDate, + end_date: endDate, + type: type ?? undefined, + department_filter: departmentFilter, + }), + [startDate, endDate, type, departmentFilter], + ); - // 쿼리 파라미터 구성 - const params = new URLSearchParams(); - if (startDate) params.append('start_date', startDate); - if (endDate) params.append('end_date', endDate); - if (type) params.append('type', type); - if (departmentFilter) params.append('department_filter', departmentFilter); - - const queryString = params.toString(); - const endpoint = queryString ? `calendar/schedules?${queryString}` : 'calendar/schedules'; - - const apiData = await fetchApi(endpoint); - const transformed = transformCalendarResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('Calendar API Error:', err); - } finally { - setLoading(false); - } - }, [startDate, endDate, type, departmentFilter]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch( + endpoint, + transformCalendarResponse, + ); } // ============================================ @@ -442,52 +251,25 @@ export function useCalendar(options: UseCalendarOptions = {}) { // ============================================ export interface UseVatOptions { - /** 기간 타입 (quarter: 분기, half: 반기, year: 연간) */ periodType?: 'quarter' | 'half' | 'year'; - /** 연도 (기본: 현재 연도) */ year?: number; - /** 기간 번호 (quarter: 1-4, half: 1-2) */ period?: number; } export function useVat(options: UseVatOptions = {}) { const { periodType = 'quarter', year, period } = options; - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); + const endpoint = useMemo( + () => + buildEndpoint('vat/summary', { + period_type: periodType, + year, + period, + }), + [periodType, year, period], + ); - // 쿼리 파라미터 구성 - const params = new URLSearchParams(); - params.append('period_type', periodType); - if (year) params.append('year', year.toString()); - if (period) params.append('period', period.toString()); - - const queryString = params.toString(); - const endpoint = `vat/summary?${queryString}`; - - const apiData = await fetchApi(endpoint); - const transformed = transformVatResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('Vat API Error:', err); - } finally { - setLoading(false); - } - }, [periodType, year, period]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch(endpoint, transformVatResponse); } // ============================================ @@ -495,55 +277,30 @@ export function useVat(options: UseVatOptions = {}) { // ============================================ export interface UseEntertainmentOptions { - /** 기간 타입 (annual: 연간, quarterly: 분기) */ limitType?: 'annual' | 'quarterly'; - /** 기업 유형 (large: 대기업, medium: 중견기업, small: 중소기업) */ companyType?: 'large' | 'medium' | 'small'; - /** 연도 (기본: 현재 연도) */ year?: number; - /** 분기 번호 (1-4, 기본: 현재 분기) */ quarter?: number; } export function useEntertainment(options: UseEntertainmentOptions = {}) { const { limitType = 'quarterly', companyType = 'medium', year, quarter } = options; - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); + const endpoint = useMemo( + () => + buildEndpoint('entertainment/summary', { + limit_type: limitType, + company_type: companyType, + year, + quarter, + }), + [limitType, companyType, year, quarter], + ); - // 쿼리 파라미터 구성 - const params = new URLSearchParams(); - params.append('limit_type', limitType); - params.append('company_type', companyType); - if (year) params.append('year', year.toString()); - if (quarter) params.append('quarter', quarter.toString()); - - const queryString = params.toString(); - const endpoint = `entertainment/summary?${queryString}`; - - const apiData = await fetchApi(endpoint); - const transformed = transformEntertainmentResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('Entertainment API Error:', err); - } finally { - setLoading(false); - } - }, [limitType, companyType, year, quarter]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch( + endpoint, + transformEntertainmentResponse, + ); } // ============================================ @@ -551,17 +308,11 @@ export function useEntertainment(options: UseEntertainmentOptions = {}) { // ============================================ export interface UseWelfareOptions { - /** 기간 타입 (annual: 연간, quarterly: 분기) */ limitType?: 'annual' | 'quarterly'; - /** 계산 방식 (fixed: 1인당 정액, ratio: 급여 대비 비율) */ calculationType?: 'fixed' | 'ratio'; - /** 1인당 월 정액 (calculation_type=fixed일 때 사용, 기본: 200000) */ fixedAmountPerMonth?: number; - /** 급여 대비 비율 (calculation_type=ratio일 때 사용, 기본: 0.05) */ ratio?: number; - /** 연도 (기본: 현재 연도) */ year?: number; - /** 분기 번호 (1-4, 기본: 현재 분기) */ quarter?: number; } @@ -574,45 +325,24 @@ export function useWelfare(options: UseWelfareOptions = {}) { year, quarter, } = options; - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); + const endpoint = useMemo( + () => + buildEndpoint('welfare/summary', { + limit_type: limitType, + calculation_type: calculationType, + fixed_amount_per_month: fixedAmountPerMonth, + ratio, + year, + quarter, + }), + [limitType, calculationType, fixedAmountPerMonth, ratio, year, quarter], + ); - // 쿼리 파라미터 구성 - const params = new URLSearchParams(); - params.append('limit_type', limitType); - params.append('calculation_type', calculationType); - if (fixedAmountPerMonth) params.append('fixed_amount_per_month', fixedAmountPerMonth.toString()); - if (ratio) params.append('ratio', ratio.toString()); - if (year) params.append('year', year.toString()); - if (quarter) params.append('quarter', quarter.toString()); - - const queryString = params.toString(); - const endpoint = `welfare/summary?${queryString}`; - - const apiData = await fetchApi(endpoint); - const transformed = transformWelfareResponse(apiData); - setData(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('Welfare API Error:', err); - } finally { - setLoading(false); - } - }, [limitType, calculationType, fixedAmountPerMonth, ratio, year, quarter]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, loading, error, refetch: fetchData }; + return useDashboardFetch( + endpoint, + transformWelfareResponse, + ); } // ============================================ @@ -620,22 +350,13 @@ export function useWelfare(options: UseWelfareOptions = {}) { // ============================================ export interface UseWelfareDetailOptions { - /** 계산 방식 (fixed: 1인당 정액, ratio: 급여 대비 비율) */ calculationType?: 'fixed' | 'ratio'; - /** 1인당 월 정액 (calculation_type=fixed일 때 사용, 기본: 200000) */ fixedAmountPerMonth?: number; - /** 급여 대비 비율 (calculation_type=ratio일 때 사용, 기본: 0.05) */ ratio?: number; - /** 연도 (기본: 현재 연도) */ year?: number; - /** 분기 번호 (1-4, 기본: 현재 분기) */ quarter?: number; } -/** - * 복리후생비 상세 데이터 Hook (모달용) - * API에서 상세 데이터를 가져와 DetailModalConfig로 변환 - */ export function useWelfareDetail(options: UseWelfareDetailOptions = {}) { const { calculationType = 'fixed', @@ -644,224 +365,39 @@ export function useWelfareDetail(options: UseWelfareDetailOptions = {}) { year, quarter, } = options; - const [modalConfig, setModalConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); + const endpoint = useMemo( + () => + buildEndpoint('welfare/detail', { + calculation_type: calculationType, + fixed_amount_per_month: fixedAmountPerMonth, + ratio, + year, + quarter, + }), + [calculationType, fixedAmountPerMonth, ratio, year, quarter], + ); - // 쿼리 파라미터 구성 - const params = new URLSearchParams(); - params.append('calculation_type', calculationType); - if (fixedAmountPerMonth) params.append('fixed_amount_per_month', fixedAmountPerMonth.toString()); - if (ratio) params.append('ratio', ratio.toString()); - if (year) params.append('year', year.toString()); - if (quarter) params.append('quarter', quarter.toString()); + const result = useDashboardFetch( + endpoint, + transformWelfareDetailResponse, + { lazy: true }, + ); - const queryString = params.toString(); - const endpoint = `welfare/detail?${queryString}`; - - const apiData = await fetchApi(endpoint); - const transformed = transformWelfareDetailResponse(apiData); - setModalConfig(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('WelfareDetail API Error:', err); - } finally { - setLoading(false); - } - }, [calculationType, fixedAmountPerMonth, ratio, year, quarter]); - - return { modalConfig, loading, error, refetch: fetchData }; + return { + modalConfig: result.data, + loading: result.loading, + error: result.error, + refetch: result.refetch, + }; } // ============================================ -// 13. PurchaseDetail Hook (매입 상세 - me1 모달용) -// ============================================ - -/** - * 매입 상세 데이터 Hook (me1 모달용) - * API에서 상세 데이터를 가져와 DetailModalConfig로 변환 - */ -export function usePurchaseDetail() { - const [modalConfig, setModalConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const response = await fetch('/api/v1/purchases/dashboard-detail'); - if (!response.ok) { - throw new Error(`API 오류: ${response.status}`); - } - const result = await response.json(); - if (!result.success) { - throw new Error(result.message || '데이터 조회 실패'); - } - - const transformed = transformPurchaseDetailResponse(result.data); - setModalConfig(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('PurchaseDetail API Error:', err); - } finally { - setLoading(false); - } - }, []); - - return { modalConfig, loading, error, refetch: fetchData }; -} - -// ============================================ -// 14. CardDetail Hook (카드 상세 - me2 모달용) -// ============================================ - -/** - * 카드 상세 데이터 Hook (me2 모달용) - * API에서 상세 데이터를 가져와 DetailModalConfig로 변환 - */ -export function useCardDetail() { - const [modalConfig, setModalConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const response = await fetch('/api/v1/card-transactions/dashboard'); - if (!response.ok) { - throw new Error(`API 오류: ${response.status}`); - } - const result = await response.json(); - if (!result.success) { - throw new Error(result.message || '데이터 조회 실패'); - } - - const transformed = transformCardDetailResponse(result.data); - setModalConfig(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('CardDetail API Error:', err); - } finally { - setLoading(false); - } - }, []); - - return { modalConfig, loading, error, refetch: fetchData }; -} - -// ============================================ -// 15. BillDetail Hook (발행어음 상세 - me3 모달용) -// ============================================ - -/** - * 발행어음 상세 데이터 Hook (me3 모달용) - * API에서 상세 데이터를 가져와 DetailModalConfig로 변환 - */ -export function useBillDetail() { - const [modalConfig, setModalConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const response = await fetch('/api/v1/bills/dashboard-detail'); - if (!response.ok) { - throw new Error(`API 오류: ${response.status}`); - } - const result = await response.json(); - if (!result.success) { - throw new Error(result.message || '데이터 조회 실패'); - } - - const transformed = transformBillDetailResponse(result.data); - setModalConfig(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('BillDetail API Error:', err); - } finally { - setLoading(false); - } - }, []); - - return { modalConfig, loading, error, refetch: fetchData }; -} - -// ============================================ -// 16. ExpectedExpenseDetail Hook (지출예상 상세 - me4 모달용) -// ============================================ - -/** - * 지출예상 상세 데이터 Hook (me4 모달용) - * API에서 상세 데이터를 가져와 DetailModalConfig로 변환 - */ -export function useExpectedExpenseDetail() { - const [modalConfig, setModalConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const response = await fetch('/api/v1/expected-expenses/dashboard-detail'); - if (!response.ok) { - throw new Error(`API 오류: ${response.status}`); - } - const result = await response.json(); - if (!result.success) { - throw new Error(result.message || '데이터 조회 실패'); - } - - const transformed = transformExpectedExpenseDetailResponse(result.data); - setModalConfig(transformed); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; - setError(errorMessage); - console.error('ExpectedExpenseDetail API Error:', err); - } finally { - setLoading(false); - } - }, []); - - return { modalConfig, loading, error, refetch: fetchData }; -} - -// ============================================ -// 17. MonthlyExpenseDetail Hook (당월 예상 지출 상세 - 통합 모달용) +// 13. MonthlyExpenseDetail Hook (당월 예상 지출 상세 - 통합 모달용) // ============================================ export type MonthlyExpenseCardId = 'me1' | 'me2' | 'me3' | 'me4'; -/** - * 당월 예상 지출 상세 데이터 Hook (통합 모달용) - * cardId에 따라 다른 API를 호출하고 DetailModalConfig로 변환 - * - * @example - * const { modalConfig, loading, error, fetchData } = useMonthlyExpenseDetail(); - * await fetchData('me1'); // 매입 상세 API 호출 - */ export function useMonthlyExpenseDetail() { const [modalConfig, setModalConfig] = useState(null); const [loading, setLoading] = useState(false); @@ -872,48 +408,28 @@ export function useMonthlyExpenseDetail() { setLoading(true); setError(null); - // 모든 카드가 expected-expenses API를 사용하여 데이터 일관성 보장 - // transaction_type: me1=purchase, me2=card, me3=bill, me4=전체 - let endpoint: string; - let transactionType: string | null = null; + const transactionTypeMap: Record = { + me1: 'purchase', + me2: 'card', + me3: 'bill', + me4: null, + }; + const transactionType = transactionTypeMap[cardId]; - switch (cardId) { - case 'me1': - transactionType = 'purchase'; - break; - case 'me2': - transactionType = 'card'; - break; - case 'me3': - transactionType = 'bill'; - break; - case 'me4': - transactionType = null; // 전체 조회 - break; - default: - throw new Error(`Unknown cardId: ${cardId}`); - } - - // 단일 API 엔드포인트 사용 (transaction_type으로 필터링) - endpoint = transactionType + const endpoint = transactionType ? `/api/proxy/expected-expenses/dashboard-detail?transaction_type=${transactionType}` : '/api/proxy/expected-expenses/dashboard-detail'; - const transformer = (data: unknown) => - transformExpectedExpenseDetailResponse(data as ExpectedExpenseDashboardDetailApiResponse, cardId); - const response = await fetch(endpoint); - if (!response.ok) { - throw new Error(`API 오류: ${response.status}`); - } + if (!response.ok) throw new Error(`API 오류: ${response.status}`); const result = await response.json(); - if (!result.success) { - throw new Error(result.message || '데이터 조회 실패'); - } + if (!result.success) throw new Error(result.message || '데이터 조회 실패'); - const transformed = transformer(result.data); + const transformed = transformExpectedExpenseDetailResponse( + result.data as ExpectedExpenseDashboardDetailApiResponse, + cardId, + ); setModalConfig(transformed); - return transformed; } catch (err) { const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; @@ -929,64 +445,29 @@ export function useMonthlyExpenseDetail() { } // ============================================ -// 통합 Dashboard Hook (선택적 사용) +// 통합 Dashboard Hook // ============================================ export interface UseCEODashboardOptions { - /** DailyReport 섹션 활성화 */ dailyReport?: boolean; - /** Receivable 섹션 활성화 */ receivable?: boolean; - /** DebtCollection 섹션 활성화 */ debtCollection?: boolean; - /** MonthlyExpense 섹션 활성화 */ monthlyExpense?: boolean; - /** CardManagement 섹션 활성화 */ cardManagement?: boolean; - /** CardManagement fallback 데이터 (가지급금, 법인세, 종합세 등) */ cardManagementFallback?: CardManagementData; - /** StatusBoard 섹션 활성화 */ statusBoard?: boolean; } export interface CEODashboardState { - dailyReport: { - data: DailyReportData | null; - loading: boolean; - error: string | null; - }; - receivable: { - data: ReceivableData | null; - loading: boolean; - error: string | null; - }; - debtCollection: { - data: DebtCollectionData | null; - loading: boolean; - error: string | null; - }; - monthlyExpense: { - data: MonthlyExpenseData | null; - loading: boolean; - error: string | null; - }; - cardManagement: { - data: CardManagementData | null; - loading: boolean; - error: string | null; - }; - statusBoard: { - data: TodayIssueItem[] | null; - loading: boolean; - error: string | null; - }; + dailyReport: { data: DailyReportData | null; loading: boolean; error: string | null }; + receivable: { data: ReceivableData | null; loading: boolean; error: string | null }; + debtCollection: { data: DebtCollectionData | null; loading: boolean; error: string | null }; + monthlyExpense: { data: MonthlyExpenseData | null; loading: boolean; error: string | null }; + cardManagement: { data: CardManagementData | null; loading: boolean; error: string | null }; + statusBoard: { data: TodayIssueItem[] | null; loading: boolean; error: string | null }; refetchAll: () => void; } -/** - * 통합 CEO Dashboard Hook - * 여러 섹션의 API를 병렬로 호출하여 성능 최적화 - */ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashboardState { const { dailyReport: enableDailyReport = true, @@ -998,175 +479,74 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo statusBoard: enableStatusBoard = true, } = options; - // 각 섹션별 상태 - const [dailyReportData, setDailyReportData] = useState(null); - const [dailyReportLoading, setDailyReportLoading] = useState(enableDailyReport); - const [dailyReportError, setDailyReportError] = useState(null); + // 비활성 섹션은 endpoint를 null로 → useDashboardFetch가 skip + const dr = useDashboardFetch( + enableDailyReport ? 'daily-report/summary' : null, + transformDailyReportResponse, + { initialLoading: enableDailyReport }, + ); + const rv = useDashboardFetch( + enableReceivable ? 'receivables/summary' : null, + transformReceivableResponse, + { initialLoading: enableReceivable }, + ); + const dc = useDashboardFetch( + enableDebtCollection ? 'bad-debts/summary' : null, + transformDebtCollectionResponse, + { initialLoading: enableDebtCollection }, + ); + const me = useDashboardFetch( + enableMonthlyExpense ? 'expected-expenses/summary' : null, + transformMonthlyExpenseResponse, + { initialLoading: enableMonthlyExpense }, + ); + const sb = useDashboardFetch( + enableStatusBoard ? 'status-board/summary' : null, + transformStatusBoardResponse, + { initialLoading: enableStatusBoard }, + ); - const [receivableData, setReceivableData] = useState(null); - const [receivableLoading, setReceivableLoading] = useState(enableReceivable); - const [receivableError, setReceivableError] = useState(null); + // CardManagement: 커스텀 (3개 API 병렬) + const [cmData, setCmData] = useState(null); + const [cmLoading, setCmLoading] = useState(enableCardManagement); + const [cmError, setCmError] = useState(null); - const [debtCollectionData, setDebtCollectionData] = useState(null); - const [debtCollectionLoading, setDebtCollectionLoading] = useState(enableDebtCollection); - const [debtCollectionError, setDebtCollectionError] = useState(null); - - const [monthlyExpenseData, setMonthlyExpenseData] = useState(null); - const [monthlyExpenseLoading, setMonthlyExpenseLoading] = useState(enableMonthlyExpense); - const [monthlyExpenseError, setMonthlyExpenseError] = useState(null); - - const [cardManagementData, setCardManagementData] = useState(null); - const [cardManagementLoading, setCardManagementLoading] = useState(enableCardManagement); - const [cardManagementError, setCardManagementError] = useState(null); - - const [statusBoardData, setStatusBoardData] = useState(null); - const [statusBoardLoading, setStatusBoardLoading] = useState(enableStatusBoard); - const [statusBoardError, setStatusBoardError] = useState(null); - - // 개별 fetch 함수들 - const fetchDailyReport = useCallback(async () => { - if (!enableDailyReport) return; - try { - setDailyReportLoading(true); - setDailyReportError(null); - const apiData = await fetchApi('daily-report/summary'); - setDailyReportData(transformDailyReportResponse(apiData)); - } catch (err) { - setDailyReportError(err instanceof Error ? err.message : '데이터 로딩 실패'); - } finally { - setDailyReportLoading(false); - } - }, [enableDailyReport]); - - const fetchReceivable = useCallback(async () => { - if (!enableReceivable) return; - try { - setReceivableLoading(true); - setReceivableError(null); - const apiData = await fetchApi('receivables/summary'); - setReceivableData(transformReceivableResponse(apiData)); - } catch (err) { - setReceivableError(err instanceof Error ? err.message : '데이터 로딩 실패'); - } finally { - setReceivableLoading(false); - } - }, [enableReceivable]); - - const fetchDebtCollection = useCallback(async () => { - if (!enableDebtCollection) return; - try { - setDebtCollectionLoading(true); - setDebtCollectionError(null); - const apiData = await fetchApi('bad-debts/summary'); - setDebtCollectionData(transformDebtCollectionResponse(apiData)); - } catch (err) { - setDebtCollectionError(err instanceof Error ? err.message : '데이터 로딩 실패'); - } finally { - setDebtCollectionLoading(false); - } - }, [enableDebtCollection]); - - const fetchMonthlyExpense = useCallback(async () => { - if (!enableMonthlyExpense) return; - try { - setMonthlyExpenseLoading(true); - setMonthlyExpenseError(null); - const apiData = await fetchApi('expected-expenses/summary'); - setMonthlyExpenseData(transformMonthlyExpenseResponse(apiData)); - } catch (err) { - setMonthlyExpenseError(err instanceof Error ? err.message : '데이터 로딩 실패'); - } finally { - setMonthlyExpenseLoading(false); - } - }, [enableMonthlyExpense]); - - const fetchCardManagement = useCallback(async () => { + const fetchCM = useCallback(async () => { if (!enableCardManagement) return; try { - setCardManagementLoading(true); - setCardManagementError(null); - - // 3개 API 병렬 호출: 카드거래, 가지급금, 세금 시뮬레이션 - const [cardApiData, loanResponse, taxResponse] = await Promise.all([ - fetchApi('card-transactions/summary'), - fetchLoanDashboard(), - fetchTaxSimulation(), - ]); - - // LoanDashboard와 TaxSimulation은 ApiResponse wrapper가 있으므로 data 추출 - const loanData = loanResponse.success ? loanResponse.data : null; - const taxData = taxResponse.success ? taxResponse.data : null; - - setCardManagementData( - transformCardManagementResponse(cardApiData, loanData, taxData, cardManagementFallback) - ); + setCmLoading(true); + setCmError(null); + const result = await fetchCardManagementData(cardManagementFallback); + setCmData(result); } catch (err) { - setCardManagementError(err instanceof Error ? err.message : '데이터 로딩 실패'); + setCmError(err instanceof Error ? err.message : '데이터 로딩 실패'); + console.error('CardManagement API Error:', err); } finally { - setCardManagementLoading(false); + setCmLoading(false); } }, [enableCardManagement, cardManagementFallback]); - const fetchStatusBoard = useCallback(async () => { - if (!enableStatusBoard) return; - try { - setStatusBoardLoading(true); - setStatusBoardError(null); - const apiData = await fetchApi('status-board/summary'); - setStatusBoardData(transformStatusBoardResponse(apiData)); - } catch (err) { - setStatusBoardError(err instanceof Error ? err.message : '데이터 로딩 실패'); - } finally { - setStatusBoardLoading(false); - } - }, [enableStatusBoard]); - - // 전체 refetch - const refetchAll = useCallback(() => { - fetchDailyReport(); - fetchReceivable(); - fetchDebtCollection(); - fetchMonthlyExpense(); - fetchCardManagement(); - fetchStatusBoard(); - }, [fetchDailyReport, fetchReceivable, fetchDebtCollection, fetchMonthlyExpense, fetchCardManagement, fetchStatusBoard]); - - // 초기 로드 useEffect(() => { - refetchAll(); - }, [refetchAll]); + fetchCM(); + }, [fetchCM]); + + const refetchAll = useCallback(() => { + dr.refetch(); + rv.refetch(); + dc.refetch(); + me.refetch(); + fetchCM(); + sb.refetch(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch]); return { - dailyReport: { - data: dailyReportData, - loading: dailyReportLoading, - error: dailyReportError, - }, - receivable: { - data: receivableData, - loading: receivableLoading, - error: receivableError, - }, - debtCollection: { - data: debtCollectionData, - loading: debtCollectionLoading, - error: debtCollectionError, - }, - monthlyExpense: { - data: monthlyExpenseData, - loading: monthlyExpenseLoading, - error: monthlyExpenseError, - }, - cardManagement: { - data: cardManagementData, - loading: cardManagementLoading, - error: cardManagementError, - }, - statusBoard: { - data: statusBoardData, - loading: statusBoardLoading, - error: statusBoardError, - }, + dailyReport: { data: dr.data, loading: dr.loading, error: dr.error }, + receivable: { data: rv.data, loading: rv.loading, error: rv.error }, + debtCollection: { data: dc.data, loading: dc.loading, error: dc.error }, + monthlyExpense: { data: me.data, loading: me.loading, error: me.error }, + cardManagement: { data: cmData, loading: cmLoading, error: cmError }, + statusBoard: { data: sb.data, loading: sb.loading, error: sb.error }, refetchAll, }; } \ No newline at end of file diff --git a/src/hooks/useDashboardFetch.ts b/src/hooks/useDashboardFetch.ts new file mode 100644 index 00000000..22b71b58 --- /dev/null +++ b/src/hooks/useDashboardFetch.ts @@ -0,0 +1,156 @@ +import { useState, useCallback, useEffect } from 'react'; + +/** + * CEO Dashboard API 호출을 위한 제네릭 훅 + * + * @param endpoint - API 엔드포인트 (예: 'daily-report/summary') + * @param transformer - API 응답을 프론트엔드 데이터로 변환하는 함수 + * @param options - 추가 옵션 + * + * @example + * // 자동 fetch (마운트 시 즉시 호출) + * const { data, loading, error, refetch } = useDashboardFetch( + * 'daily-report/summary', + * transformDailyReportResponse, + * ); + * + * // 수동 fetch (lazy: true → 마운트 시 호출하지 않음) + * const { data, loading, error, refetch } = useDashboardFetch( + * 'welfare/detail', + * transformWelfareDetailResponse, + * { lazy: true }, + * ); + * // 필요할 때 수동 호출 + * await refetch(); + */ +export function useDashboardFetch( + endpoint: string | null, + transformer: (data: TApi) => TResult, + options?: { + /** true이면 마운트 시 자동 호출하지 않음 */ + lazy?: boolean; + /** 초기 로딩 상태 (기본: !lazy) */ + initialLoading?: boolean; + }, +) { + const lazy = options?.lazy ?? false; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(options?.initialLoading ?? !lazy); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + if (!endpoint) return; + try { + setLoading(true); + setError(null); + + const response = await fetch(`/api/proxy/${endpoint}`); + if (!response.ok) { + throw new Error(`API 오류: ${response.status}`); + } + const result = await response.json(); + if (!result.success) { + throw new Error(result.message || '데이터 조회 실패'); + } + + const transformed = transformer(result.data); + setData(transformed); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error(`Dashboard API Error [${endpoint}]:`, err); + } finally { + setLoading(false); + } + }, [endpoint, transformer]); + + useEffect(() => { + if (!lazy && endpoint) { + fetchData(); + } + }, [lazy, endpoint, fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +/** + * 여러 API를 병렬 호출하는 제네릭 훅 + * + * @example + * const { data, loading, error, refetch } = useDashboardMultiFetch( + * [ + * { endpoint: 'card-transactions/summary' }, + * { endpoint: 'loans/dashboard', fetchFn: fetchLoanDashboard }, + * ], + * ([cardData, loanData]) => transformCardManagementResponse(cardData, loanData), + * ); + */ +export function useDashboardMultiFetch( + sources: Array<{ + endpoint: string; + /** 커스텀 fetch 함수 (기본: fetchApi 패턴) */ + fetchFn?: () => Promise; + }>, + transformer: (results: unknown[]) => TResult, + options?: { + lazy?: boolean; + initialLoading?: boolean; + }, +) { + const lazy = options?.lazy ?? false; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(options?.initialLoading ?? !lazy); + const [error, setError] = useState(null); + + // sources를 JSON으로 비교하여 안정적인 의존성 확보 + const sourcesKey = JSON.stringify(sources.map((s) => s.endpoint)); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const results = await Promise.all( + sources.map(async (source) => { + if (source.fetchFn) { + const result = await source.fetchFn(); + // fetchFn이 { success, data } 형태를 반환할 수 있음 + if (result && typeof result === 'object' && 'success' in result) { + const r = result as { success: boolean; data: unknown }; + return r.success ? r.data : null; + } + return result; + } + + const response = await fetch(`/api/proxy/${source.endpoint}`); + if (!response.ok) { + throw new Error(`API 오류: ${response.status}`); + } + const json = await response.json(); + if (!json.success) { + throw new Error(json.message || '데이터 조회 실패'); + } + return json.data; + }), + ); + + const transformed = transformer(results); + setData(transformed); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('Dashboard MultiFetch Error:', err); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sourcesKey, transformer]); + + useEffect(() => { + if (!lazy) { + fetchData(); + } + }, [lazy, fetchData]); + + return { data, loading, error, refetch: fetchData }; +} diff --git a/src/lib/api/dashboard/transformers/expense-detail.ts b/src/lib/api/dashboard/transformers/expense-detail.ts index c9c33afd..d32e9c56 100644 --- a/src/lib/api/dashboard/transformers/expense-detail.ts +++ b/src/lib/api/dashboard/transformers/expense-detail.ts @@ -74,16 +74,6 @@ export function transformPurchaseDetailResponse(api: PurchaseDashboardDetailApiR ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -165,16 +155,6 @@ export function transformCardDetailResponse(api: CardDashboardDetailApiResponse) ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -264,16 +244,6 @@ export function transformBillDetailResponse(api: BillDashboardDetailApiResponse) ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', @@ -398,16 +368,6 @@ export function transformExpectedExpenseDetailResponse( options: vendorOptions, defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', diff --git a/src/lib/api/dashboard/transformers/tax-benefits.ts b/src/lib/api/dashboard/transformers/tax-benefits.ts index cbe76614..85e444ae 100644 --- a/src/lib/api/dashboard/transformers/tax-benefits.ts +++ b/src/lib/api/dashboard/transformers/tax-benefits.ts @@ -161,6 +161,11 @@ export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): D return { title: '복리후생비 상세', + dateFilter: { + enabled: true, + defaultPreset: '당월', + showSearch: true, + }, summaryCards: [ // 1행: 당해년도 기준 { label: '당해년도 복리후생비 계정', value: summary.annual_account, unit: '원' }, @@ -224,16 +229,6 @@ export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): D ], defaultValue: 'all', }, - { - key: 'sortOrder', - options: [ - { value: 'latest', label: '최신순' }, - { value: 'oldest', label: '등록순' }, - { value: 'amountDesc', label: '금액 높은순' }, - { value: 'amountAsc', label: '금액 낮은순' }, - ], - defaultValue: 'latest', - }, ], showTotal: true, totalLabel: '합계', diff --git a/src/lib/utils/amount.ts b/src/lib/utils/amount.ts index 8ff070f0..5bd2d3bb 100644 --- a/src/lib/utils/amount.ts +++ b/src/lib/utils/amount.ts @@ -94,6 +94,19 @@ export function formatAmountManwon(amount: number): string { return `${manwon.toLocaleString("ko-KR")}만원`; } +/** + * 차트 축 레이블용 축약 포맷 + * + * - 1억 이상: "1.5억" + * - 1만 이상: "5320만" + * - 1만 미만: "5,000" + */ +export function formatCompactAmount(value: number): string { + if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억`; + if (value >= 10000) return `${(value / 10000).toFixed(0)}만`; + return value.toLocaleString(); +} + /** * 한국식 금액 축약 포맷 * diff --git a/src/lib/utils/status-config.ts b/src/lib/utils/status-config.ts index b92e09ae..460133a6 100644 --- a/src/lib/utils/status-config.ts +++ b/src/lib/utils/status-config.ts @@ -324,8 +324,7 @@ export const RECEIVING_STATUS_CONFIG = createStatusConfig({ export const BAD_DEBT_COLLECTION_STATUS_CONFIG = createStatusConfig({ collecting: { label: '추심중', style: 'border-orange-300 text-orange-600 bg-orange-50' }, legalAction: { label: '법적조치', style: 'border-red-300 text-red-600 bg-red-50' }, - recovered: { label: '회수완료', style: 'border-green-300 text-green-600 bg-green-50' }, - badDebt: { label: '대손처리', style: 'border-gray-300 text-gray-600 bg-gray-50' }, + collectionEnd: { label: '추심종료', style: 'border-green-300 text-green-600 bg-green-50' }, }, { includeAll: true }); /**