feat: CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 개선 및 회계 페이지 확장

- 접대비/복리후생비 섹션: 리스크감지형 구조로 변경
- 매출채권 섹션: transformer/타입 정비
- 캘린더 섹션: ScheduleDetailModal 개선
- 카드관리 모달 transformer 확장
- useCEODashboard 훅 리팩토링 및 정리
- dashboard endpoints/types/transformers (expense, receivable, tax-benefits) 대폭 확장
- 회계 5개 페이지(은행거래, 카드거래, 매출채권, 세금계산서, 거래처원장) 기능 개선
- ApprovalBox 소폭 수정
- CLAUDE.md 업데이트
This commit is contained in:
유병철
2026-03-04 22:19:10 +09:00
parent cde9333652
commit 23fa9c0ea2
27 changed files with 1427 additions and 511 deletions

View File

@@ -326,16 +326,19 @@ const [data, setData] = useState(() => {
--- ---
## Backend API Analysis Policy ## Backend API Policy
**Priority**: 🟡 **Priority**: 🟡
- Backend API 코드는 **분석만**, 직접 수정 안 함 - **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리
- 수정 필요 시 백엔드 요청 문서로 정리: - **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능
- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel)
- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수
- 신규 API가 필요한 경우 요청 문서로 정리:
```markdown ```markdown
## 백엔드 API 수정 요청 ## 백엔드 API 신규 요청
### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX) ### 엔드포인트: [HTTP METHOD /api/v1/path]
### 현재 문제: [설명] ### 목적: [설명]
### 수정 요청: [내용] ### 요청/응답 구조: [내용]
``` ```
--- ---

View File

@@ -51,12 +51,27 @@ import {
getBankAccountOptions, getBankAccountOptions,
getFinancialInstitutions, getFinancialInstitutions,
batchSaveTransactions, batchSaveTransactions,
exportBankTransactionsExcel,
type BankTransactionSummaryData, type BankTransactionSummaryData,
} from './actions'; } from './actions';
import { TransactionFormModal } from './TransactionFormModal'; import { TransactionFormModal } from './TransactionFormModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount'; import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<BankTransaction & Record<string, unknown>>[] = [
{ header: '거래일시', key: 'transactionDate', width: 12 },
{ header: '구분', key: 'type', width: 8,
transform: (v) => v === 'deposit' ? '입금' : '출금' },
{ header: '은행명', key: 'bankName', width: 12 },
{ header: '계좌명', key: 'accountName', width: 15 },
{ header: '적요/내용', key: 'note', width: 20 },
{ header: '입금', key: 'depositAmount', width: 14 },
{ header: '출금', key: 'withdrawalAmount', width: 14 },
{ header: '잔액', key: 'balance', width: 14 },
{ header: '취급점', key: 'branch', width: 12 },
{ header: '상대계좌예금주명', key: 'depositorName', width: 18 },
];
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) ===== // ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
const tableColumns = [ const tableColumns = [
@@ -226,22 +241,45 @@ export function BankTransactionInquiry() {
} }
}, [localChanges, loadData]); }, [localChanges, loadData]);
// 엑셀 다운로드 // 엑셀 다운로드 (프론트 xlsx 생성)
const handleExcelDownload = useCallback(async () => { const handleExcelDownload = useCallback(async () => {
try { try {
const result = await exportBankTransactionsExcel({ toast.info('엑셀 파일 생성 중...');
const allData: BankTransaction[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getBankTransactionList({
startDate, startDate,
endDate, endDate,
accountCategory: accountCategoryFilter, accountCategory: accountCategoryFilter,
financialInstitution: financialInstitutionFilter, financialInstitution: financialInstitutionFilter,
perPage: 100,
page,
}); });
if (result.success && result.data) { if (result.success && result.data.length > 0) {
window.open(result.data.downloadUrl, '_blank'); allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else { } else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({
data: allData as (BankTransaction & Record<string, unknown>)[],
columns: excelColumns,
filename: '계좌입출금내역',
sheetName: '입출금내역',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
} }
} catch { } catch {
toast.error('엑셀 다운로드 중 오류가 발생했습니다.'); toast.error('엑셀 다운로드에 실패했습니다.');
} }
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]); }, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);

View File

@@ -55,6 +55,29 @@ import { JournalEntryModal } from './JournalEntryModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount'; import { formatNumber } from '@/lib/utils/amount';
import { filterByEnum } from '@/lib/utils/search'; import { filterByEnum } from '@/lib/utils/search';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<CardTransaction>[] = [
{ header: '사용일시', key: 'usedAt', width: 18 },
{ header: '카드사', key: 'cardCompany', width: 10 },
{ header: '카드번호', key: 'card', width: 12 },
{ header: '카드명', key: 'cardName', width: 12 },
{ header: '공제', key: 'deductionType', width: 10,
transform: (v) => v === 'deductible' ? '공제' : '불공제' },
{ header: '사업자번호', key: 'businessNumber', width: 15 },
{ header: '가맹점명', key: 'merchantName', width: 15 },
{ header: '증빙/판매자상호', key: 'vendorName', width: 18 },
{ header: '내역', key: 'description', width: 15 },
{ header: '합계금액', key: 'totalAmount', width: 12 },
{ header: '공급가액', key: 'supplyAmount', width: 12 },
{ header: '세액', key: 'taxAmount', width: 10 },
{ header: '계정과목', key: 'accountSubject', width: 12,
transform: (v) => {
const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v);
return found?.label || String(v || '');
}},
];
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) ===== // ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
const tableColumns = [ const tableColumns = [
@@ -269,9 +292,45 @@ export function CardTransactionInquiry() {
setShowJournalEntry(true); setShowJournalEntry(true);
}, []); }, []);
const handleExcelDownload = useCallback(() => { const handleExcelDownload = useCallback(async () => {
toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.'); try {
}, []); toast.info('엑셀 파일 생성 중...');
const allData: CardTransaction[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getCardTransactionList({
startDate,
endDate,
search: searchQuery || undefined,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel<CardTransaction & Record<string, unknown>>({
data: allData as (CardTransaction & Record<string, unknown>)[],
columns: excelColumns as ExcelColumn<CardTransaction & Record<string, unknown>>[],
filename: '카드사용내역',
sheetName: '카드사용내역',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, searchQuery]);
// ===== UniversalListPage Config ===== // ===== UniversalListPage Config =====
const config: UniversalListConfig<CardTransaction> = useMemo( const config: UniversalListConfig<CardTransaction> = useMemo(

View File

@@ -32,9 +32,10 @@ import {
CATEGORY_LABELS, CATEGORY_LABELS,
SORT_OPTIONS, SORT_OPTIONS,
} from './types'; } from './types';
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions'; import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos } from './actions';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { filterByText } from '@/lib/utils/search'; import { filterByText } from '@/lib/utils/search';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
@@ -213,27 +214,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
}); });
}, []); }, []);
// ===== 엑셀 다운로드 핸들러 ===== // ===== 엑셀 다운로드 핸들러 (프론트 xlsx 생성) =====
const handleExcelDownload = useCallback(async () => { const handleExcelDownload = useCallback(async () => {
const result = await exportReceivablesExcel({ try {
year: selectedYear, toast.info('엑셀 파일 생성 중...');
search: searchQuery || undefined, // 데이터가 이미 로드되어 있으므로 sortedData 사용
}); if (sortedData.length === 0) {
toast.warning('다운로드할 데이터가 없습니다.');
if (result.success && result.data) { return;
const url = URL.createObjectURL(result.data);
const a = document.createElement('a');
a.href = url;
a.download = result.filename || '채권현황.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('엑셀 파일이 다운로드되었습니다.');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
} }
}, [selectedYear, searchQuery]); // 동적 월 컬럼 포함 엑셀 컬럼 생성
const columns: ExcelColumn<Record<string, unknown>>[] = [
{ header: '거래처', key: 'vendorName', width: 20 },
{ header: '연체', key: 'isOverdue', width: 8 },
...monthLabels.map((label, idx) => ({
header: label, key: `month_${idx}`, width: 12,
})),
{ header: '합계', key: 'total', width: 14 },
{ header: '메모', key: 'memo', width: 20 },
];
// 미수금 카테고리 기준으로 플랫 데이터 생성
const exportData = sortedData.map(vendor => {
const receivable = vendor.categories.find(c => c.category === 'receivable');
const row: Record<string, unknown> = {
vendorName: vendor.vendorName,
isOverdue: vendor.isOverdue ? '연체' : '',
};
monthLabels.forEach((_, idx) => {
row[`month_${idx}`] = receivable?.amounts.values[idx] || 0;
});
row.total = receivable?.amounts.total || 0;
row.memo = vendor.memo || '';
return row;
});
await downloadExcel({ data: exportData, columns, filename: '미수금현황', sheetName: '미수금현황' });
toast.success('엑셀 다운로드 완료');
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [sortedData, monthLabels]);
// ===== 변경된 연체 항목 확인 ===== // ===== 변경된 연체 항목 확인 =====
const changedOverdueItems = useMemo(() => { const changedOverdueItems = useMemo(() => {

View File

@@ -45,8 +45,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
import { import {
getTaxInvoices, getTaxInvoices,
getTaxInvoiceSummary, getTaxInvoiceSummary,
downloadTaxInvoiceExcel,
} from './actions'; } from './actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
const ManualEntryModal = dynamic( const ManualEntryModal = dynamic(
() => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })), () => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })),
@@ -58,6 +58,10 @@ import type {
TaxInvoiceMgmtRecord, TaxInvoiceMgmtRecord,
InvoiceTab, InvoiceTab,
TaxInvoiceSummary, TaxInvoiceSummary,
TaxType,
ReceiptType,
InvoiceStatus,
InvoiceSource,
} from './types'; } from './types';
import { import {
TAB_OPTIONS, TAB_OPTIONS,
@@ -77,6 +81,26 @@ const QUARTER_BUTTONS = [
{ value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 }, { value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 },
]; ];
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<TaxInvoiceMgmtRecord & Record<string, unknown>>[] = [
{ header: '작성일자', key: 'writeDate', width: 12 },
{ header: '발급일자', key: 'issueDate', width: 12 },
{ header: '거래처', key: 'vendorName', width: 20 },
{ header: '사업자번호', key: 'vendorBusinessNumber', width: 15 },
{ header: '과세형태', key: 'taxType', width: 10,
transform: (v) => TAX_TYPE_LABELS[v as TaxType] || String(v || '') },
{ header: '품목', key: 'itemName', width: 15 },
{ header: '공급가액', key: 'supplyAmount', width: 14 },
{ header: '세액', key: 'taxAmount', width: 14 },
{ header: '합계', key: 'totalAmount', width: 14 },
{ header: '영수청구', key: 'receiptType', width: 10,
transform: (v) => RECEIPT_TYPE_LABELS[v as ReceiptType] || String(v || '') },
{ header: '상태', key: 'status', width: 10,
transform: (v) => INVOICE_STATUS_MAP[v as InvoiceStatus]?.label || String(v || '') },
{ header: '발급형태', key: 'source', width: 10,
transform: (v) => INVOICE_SOURCE_LABELS[v as InvoiceSource] || String(v || '') },
];
// ===== 테이블 컬럼 ===== // ===== 테이블 컬럼 =====
const tableColumns = [ const tableColumns = [
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true }, { key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
@@ -224,19 +248,46 @@ export function TaxInvoiceManagement() {
loadData(); loadData();
}, [loadData]); }, [loadData]);
// ===== 엑셀 다운로드 ===== // ===== 엑셀 다운로드 (프론트 xlsx 생성) =====
const handleExcelDownload = useCallback(async () => { const handleExcelDownload = useCallback(async () => {
const result = await downloadTaxInvoiceExcel({ try {
toast.info('엑셀 파일 생성 중...');
const allData: TaxInvoiceMgmtRecord[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getTaxInvoices({
division: activeTab, division: activeTab,
dateType, dateType,
startDate, startDate,
endDate, endDate,
vendorSearch, vendorSearch,
page,
perPage: 100,
}); });
if (result.success && result.data) { if (result.success && result.data.length > 0) {
window.open(result.data.url, '_blank'); allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else { } else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({
data: allData as (TaxInvoiceMgmtRecord & Record<string, unknown>)[],
columns: excelColumns,
filename: `세금계산서_${activeTab === 'sales' ? '매출' : '매입'}`,
sheetName: activeTab === 'sales' ? '매출' : '매입',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
} }
}, [activeTab, dateType, startDate, endDate, vendorSearch]); }, [activeTab, dateType, startDate, endDate, vendorSearch]);

View File

@@ -26,8 +26,9 @@ import {
type StatCard, type StatCard,
} from '@/components/templates/UniversalListPage'; } from '@/components/templates/UniversalListPage';
import type { VendorLedgerItem, VendorLedgerSummary } from './types'; import type { VendorLedgerItem, VendorLedgerSummary } from './types';
import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions'; import { getVendorLedgerList, getVendorLedgerSummary } from './actions';
import { formatNumber } from '@/lib/utils/amount'; import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
@@ -43,6 +44,16 @@ const tableColumns = [
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true }, { key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true },
]; ];
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<VendorLedgerItem & Record<string, unknown>>[] = [
{ header: '거래처명', key: 'vendorName', width: 20 },
{ header: '이월잔액', key: 'carryoverBalance', width: 14 },
{ header: '매출', key: 'sales', width: 14 },
{ header: '수금', key: 'collection', width: 14 },
{ header: '잔액', key: 'balance', width: 14 },
{ header: '결제일', key: 'paymentDate', width: 12 },
];
// ===== Props ===== // ===== Props =====
interface VendorLedgerProps { interface VendorLedgerProps {
initialData?: VendorLedgerItem[]; initialData?: VendorLedgerItem[];
@@ -144,24 +155,42 @@ export function VendorLedger({
); );
const handleExcelDownload = useCallback(async () => { const handleExcelDownload = useCallback(async () => {
const result = await exportVendorLedgerExcel({ try {
toast.info('엑셀 파일 생성 중...');
const allData: VendorLedgerItem[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getVendorLedgerList({
startDate, startDate,
endDate, endDate,
search: searchQuery || undefined, search: searchQuery || undefined,
perPage: 100,
page,
}); });
if (result.success && result.data.length > 0) {
if (result.success && result.data) { allData.push(...result.data);
const url = URL.createObjectURL(result.data); lastPage = result.pagination?.lastPage ?? 1;
const a = document.createElement('a');
a.href = url;
a.download = result.filename || '거래처원장.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('엑셀 파일이 다운로드되었습니다.');
} else { } else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel<VendorLedgerItem & Record<string, unknown>>({
data: allData as (VendorLedgerItem & Record<string, unknown>)[],
columns: excelColumns,
filename: '거래처원장',
sheetName: '거래처원장',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
} }
}, [startDate, endDate, searchQuery]); }, [startDate, endDate, searchQuery]);

View File

@@ -546,7 +546,7 @@ export function ApprovalBox() {
dateRangeSelector: { dateRangeSelector: {
enabled: true, enabled: true,
showPresets: false, showPresets: true,
startDate, startDate,
endDate, endDate,
onStartDateChange: setStartDate, onStartDateChange: setStartDate,

View File

@@ -34,15 +34,17 @@ import { ScheduleDetailModal, DetailModal } from './modals';
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog'; import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
import { LazySection } from './LazySection'; import { LazySection } from './LazySection';
import { EmptySection } from './components'; import { EmptySection } from './components';
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard'; import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useEntertainmentDetail, useWelfare, useWelfareDetail, useVatDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
import { useCardManagementModals } from '@/hooks/useCardManagementModals'; import { useCardManagementModals } from '@/hooks/useCardManagementModals';
import { import {
getMonthlyExpenseModalConfig, getMonthlyExpenseModalConfig,
getCardManagementModalConfig, getCardManagementModalConfig,
getCardManagementModalConfigWithData,
getEntertainmentModalConfig, getEntertainmentModalConfig,
getWelfareModalConfig, getWelfareModalConfig,
getVatModalConfig, getVatModalConfig,
} from './modalConfigs'; } from './modalConfigs';
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
export function CEODashboard() { export function CEODashboard() {
const router = useRouter(); const router = useRouter();
@@ -138,11 +140,17 @@ export function CEODashboard() {
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>(DEFAULT_DASHBOARD_SETTINGS); const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>(DEFAULT_DASHBOARD_SETTINGS);
// EntertainmentDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
const entertainmentDetailData = useEntertainmentDetail();
// WelfareDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언 // WelfareDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
const welfareDetailData = useWelfareDetail({ const welfareDetailData = useWelfareDetail({
calculationType: dashboardSettings.welfare.calculationType, calculationType: dashboardSettings.welfare.calculationType,
}); });
// VatDetail Hook (부가세 상세 모달용 API)
const vatDetailData = useVatDetail();
// MonthlyExpenseDetail Hook (당월 예상 지출 모달용 상세 API) // MonthlyExpenseDetail Hook (당월 예상 지출 모달용 상세 API)
const monthlyExpenseDetailData = useMonthlyExpenseDetail(); const monthlyExpenseDetailData = useMonthlyExpenseDetail();
@@ -231,9 +239,66 @@ export function CEODashboard() {
} }
}, [monthlyExpenseDetailData]); }, [monthlyExpenseDetailData]);
// 당월 예상 지출 모달 날짜/검색 필터 변경 → 재조회 // 모달 날짜/검색 필터 변경 → 재조회 (당월 예상 지출 + 가지급금 + 접대비 상세)
const handleDateFilterChange = useCallback(async (params: { startDate: string; endDate: string; search: string }) => { const handleDateFilterChange = useCallback(async (params: { startDate: string; endDate: string; search: string }) => {
if (!currentModalCardId) return; if (!currentModalCardId) return;
// cm2: 가지급금 상세 모달 날짜 필터
if (currentModalCardId === 'cm2') {
try {
const modalData = await cardManagementModals.fetchModalData('cm2', {
start_date: params.startDate,
end_date: params.endDate,
});
const config = getCardManagementModalConfigWithData('cm2', modalData);
if (config) {
setDetailModalConfig(config);
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 복리후생비 상세 모달 날짜 필터
if (currentModalCardId === 'welfare_detail') {
try {
const response = await fetch(
`/api/proxy/welfare/detail?calculation_type=${dashboardSettings.welfare.calculationType}&start_date=${params.startDate}&end_date=${params.endDate}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformWelfareDetailResponse(result.data);
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 접대비 상세 모달 날짜 필터
if (currentModalCardId === 'entertainment_detail') {
try {
const response = await fetch(
`/api/proxy/entertainment/detail?company_type=${dashboardSettings.entertainment.companyType}&start_date=${params.startDate}&end_date=${params.endDate}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformEntertainmentDetailResponse(result.data);
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 당월 예상 지출 모달 날짜 필터
const config = await monthlyExpenseDetailData.fetchData( const config = await monthlyExpenseDetailData.fetchData(
currentModalCardId as MonthlyExpenseCardId, currentModalCardId as MonthlyExpenseCardId,
params, params,
@@ -241,7 +306,7 @@ export function CEODashboard() {
if (config) { if (config) {
setDetailModalConfig(config); setDetailModalConfig(config);
} }
}, [currentModalCardId, monthlyExpenseDetailData]); }, [currentModalCardId, monthlyExpenseDetailData, cardManagementModals, dashboardSettings.entertainment, dashboardSettings.welfare]);
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체) // 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
const handleMonthlyExpenseClick = useCallback(() => { const handleMonthlyExpenseClick = useCallback(() => {
@@ -249,41 +314,96 @@ export function CEODashboard() {
// 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달 // 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달
// 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달 // 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달
const handleCardManagementCardClick = useCallback((cardId: string) => { const handleCardManagementCardClick = useCallback(async (cardId: string) => {
const config = getCardManagementModalConfig('cm2'); try {
const modalData = await cardManagementModals.fetchModalData('cm2');
const config = getCardManagementModalConfigWithData('cm2', modalData);
if (config) { if (config) {
setCurrentModalCardId('cm2');
setDetailModalConfig(config); setDetailModalConfig(config);
setIsDetailModalOpen(true); setIsDetailModalOpen(true);
} }
}, []); } catch {
// API 실패 시 fallback mock 데이터 사용
const config = getCardManagementModalConfig('cm2');
if (config) {
setCurrentModalCardId('cm2');
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}
}, [cardManagementModals]);
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달) // 접대비 현황 카드 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
const handleEntertainmentCardClick = useCallback((cardId: string) => { const handleEntertainmentCardClick = useCallback(async (cardId: string) => {
// et_sales 카드는 별도 정적 config 사용 (매출 상세)
if (cardId === 'et_sales') {
const config = getEntertainmentModalConfig(cardId); const config = getEntertainmentModalConfig(cardId);
if (config) { if (config) {
setDetailModalConfig(config); setDetailModalConfig(config);
setIsDetailModalOpen(true); setIsDetailModalOpen(true);
} }
}, []); return;
}
// 리스크 카드 → API에서 상세 데이터 fetch, 반환값 직접 사용
setCurrentModalCardId('entertainment_detail');
const apiConfig = await entertainmentDetailData.refetch();
const config = apiConfig ?? getEntertainmentModalConfig(cardId);
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}, [entertainmentDetailData]);
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달) // 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
// 복리후생비 클릭 - API 데이터로 모달 열기 (fallback: 정적 config) // 복리후생비 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
const handleWelfareCardClick = useCallback(async () => { const handleWelfareCardClick = useCallback(async () => {
// 1. 먼저 API에서 데이터 fetch 시도 const apiConfig = await welfareDetailData.refetch();
await welfareDetailData.refetch(); const config = apiConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType);
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
const config = welfareDetailData.modalConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType);
setDetailModalConfig(config); setDetailModalConfig(config);
setCurrentModalCardId('welfare_detail');
setIsDetailModalOpen(true); setIsDetailModalOpen(true);
}, [welfareDetailData, dashboardSettings.welfare.calculationType]); }, [welfareDetailData, dashboardSettings.welfare.calculationType]);
// 부가세 클릭 (모든 카드가 동일한 상세 모달) // 신고기간 변경 시 API 재호출
const handleVatClick = useCallback(() => { const handlePeriodChange = useCallback(async (periodValue: string) => {
const config = getVatModalConfig(); // periodValue: "2026-quarter-1" → parse
const parts = periodValue.split('-');
if (parts.length < 3) return;
const [year, periodType, period] = parts;
try {
const response = await fetch(
`/api/proxy/vat/detail?period_type=${periodType}&year=${year}&period=${period}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformVatDetailResponse(result.data);
// 새 config에도 onPeriodChange 콜백 주입
if (config.periodSelect) {
config.periodSelect.onPeriodChange = handlePeriodChange;
}
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
}, []);
// 부가세 클릭 (모든 카드가 동일한 상세 모달) - API 데이터로 열기 (fallback: 정적 config)
const handleVatClick = useCallback(async () => {
setCurrentModalCardId('vat_detail');
const apiConfig = await vatDetailData.refetch();
const config = apiConfig ?? getVatModalConfig();
// onPeriodChange 콜백 주입
if (config.periodSelect) {
config.periodSelect.onPeriodChange = handlePeriodChange;
}
setDetailModalConfig(config); setDetailModalConfig(config);
setIsDetailModalOpen(true); setIsDetailModalOpen(true);
}, []); }, [vatDetailData, handlePeriodChange]);
// 캘린더 일정 클릭 (기존 일정 수정) // 캘린더 일정 클릭 (기존 일정 수정)
const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => { const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => {
@@ -303,8 +423,8 @@ export function CEODashboard() {
setSelectedSchedule(null); setSelectedSchedule(null);
}, []); }, []);
// 일정 저장 // 일정 저장 (optimistic update — refetch 없이 로컬 상태만 갱신)
const handleScheduleSave = useCallback((formData: { const handleScheduleSave = useCallback(async (formData: {
title: string; title: string;
department: string; department: string;
startDate: string; startDate: string;
@@ -315,17 +435,114 @@ export function CEODashboard() {
color: string; color: string;
content: string; content: string;
}) => { }) => {
// TODO: API 호출하여 일정 저장 try {
setIsScheduleModalOpen(false); // schedule_ 접두사에서 실제 ID 추출
setSelectedSchedule(null); const rawId = selectedSchedule?.id;
}, []); const numericId = rawId?.startsWith('schedule_') ? rawId.replace('schedule_', '') : null;
// 일정 삭제 const body = {
const handleScheduleDelete = useCallback((id: string) => { title: formData.title,
// TODO: API 호출하여 일정 삭제 description: formData.content,
start_date: formData.startDate,
end_date: formData.endDate,
start_time: formData.isAllDay ? null : (formData.startTime || null),
end_time: formData.isAllDay ? null : (formData.endTime || null),
is_all_day: formData.isAllDay,
color: formData.color || null,
};
const url = numericId
? `/api/proxy/calendar/schedules/${numericId}`
: '/api/proxy/calendar/schedules';
const method = numericId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error('Failed to save schedule');
// API 응답에서 실제 ID 추출 (없으면 임시 ID)
let savedId = numericId;
try {
const result = await response.json();
savedId = result.data?.id?.toString() || numericId || `temp_${Date.now()}`;
} catch {
savedId = numericId || `temp_${Date.now()}`;
}
const updatedSchedule: CalendarScheduleItem = {
id: `schedule_${savedId}`,
title: formData.title,
startDate: formData.startDate,
endDate: formData.endDate,
startTime: formData.isAllDay ? undefined : formData.startTime,
endTime: formData.isAllDay ? undefined : formData.endTime,
isAllDay: formData.isAllDay,
type: 'schedule',
department: formData.department !== 'all' ? formData.department : undefined,
color: formData.color,
};
// Optimistic update: loading 변화 없이 데이터만 갱신 → 캘린더만 리렌더
calendarData.setData((prev) => {
if (!prev) return { items: [updatedSchedule], totalCount: 1 };
if (numericId) {
// 수정: 기존 항목 교체
return {
...prev,
items: prev.items.map((item) =>
item.id === rawId ? updatedSchedule : item
),
};
}
// 신규: 추가
return {
...prev,
items: [...prev.items, updatedSchedule],
totalCount: prev.totalCount + 1,
};
});
} catch {
// 에러 시 서버 데이터로 동기화
calendarData.refetch();
} finally {
setIsScheduleModalOpen(false); setIsScheduleModalOpen(false);
setSelectedSchedule(null); setSelectedSchedule(null);
}, []); }
}, [selectedSchedule, calendarData]);
// 일정 삭제 (optimistic update)
const handleScheduleDelete = useCallback(async (id: string) => {
try {
// schedule_ 접두사에서 실제 ID 추출
const numericId = id.startsWith('schedule_') ? id.replace('schedule_', '') : id;
const response = await fetch(`/api/proxy/calendar/schedules/${numericId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete schedule');
// Optimistic update: 삭제된 항목만 제거 → 캘린더만 리렌더
calendarData.setData((prev) => {
if (!prev) return prev;
return {
...prev,
items: prev.items.filter((item) => item.id !== id),
totalCount: Math.max(0, prev.totalCount - 1),
};
});
} catch {
// 에러 시 서버 데이터로 동기화
calendarData.refetch();
} finally {
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}
}, [calendarData]);
// 섹션 순서 // 섹션 순서
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
@@ -548,13 +765,14 @@ export function CEODashboard() {
{sectionOrder.map(renderDashboardSection)} {sectionOrder.map(renderDashboardSection)}
</div> </div>
{/* 일정 상세 모달 */} {/* 일정 상세 모달 — schedule_ 접두사만 수정/삭제 가능 */}
<ScheduleDetailModal <ScheduleDetailModal
isOpen={isScheduleModalOpen} isOpen={isScheduleModalOpen}
onClose={handleScheduleModalClose} onClose={handleScheduleModalClose}
schedule={selectedSchedule} schedule={selectedSchedule}
onSave={handleScheduleSave} onSave={handleScheduleSave}
onDelete={handleScheduleDelete} onDelete={handleScheduleDelete}
isEditable={!selectedSchedule?.id || selectedSchedule.id === '' || selectedSchedule.id.startsWith('schedule_')}
/> />
{/* 항목 설정 모달 */} {/* 항목 설정 모달 */}

View File

@@ -319,8 +319,8 @@ export const AmountCardItem = ({
<div className="mt-auto space-y-0.5 text-xs text-muted-foreground"> <div className="mt-auto space-y-0.5 text-xs text-muted-foreground">
{card.subItems.map((item, idx) => ( {card.subItems.map((item, idx) => (
<div key={idx} className="flex justify-between gap-2"> <div key={idx} className="flex justify-between gap-2">
<span className="shrink-0">{item.label}</span> <span className="min-w-0 truncate">{item.label}</span>
<span className="text-right">{typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}</span> <span className="shrink-0 text-right">{typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -175,21 +175,39 @@ export function transformCm1ModalConfig(
// cm2: 가지급금 상세 모달 변환기 // cm2: 가지급금 상세 모달 변환기
// ============================================ // ============================================
/** 카테고리 키 → 한글 라벨 매핑
* - category_breakdown 키: 영문 (card, congratulatory, ...)
* - loans[].category: 한글 (카드, 경조사, ...) — 백엔드 category_label accessor
* 양쪽 모두 대응
*/
const CATEGORY_LABELS: Record<string, string> = {
// 영문 키 (category_breakdown용)
card: '카드',
congratulatory: '경조사',
gift_certificate: '상품권',
entertainment: '접대비',
// 한글 값 (loans[].category가 이미 한글인 경우 — 그대로 통과)
'카드': '카드',
'경조사': '경조사',
'상품권': '상품권',
'접대비': '접대비',
};
/** /**
* 가지급금 대시보드 API 응답을 cm2 모달 설정으로 변환 * 가지급금 대시보드 API 응답을 cm2 모달 설정으로 변환
*/ */
export function transformCm2ModalConfig( export function transformCm2ModalConfig(
data: LoanDashboardApiResponse data: LoanDashboardApiResponse
): DetailModalConfig { ): DetailModalConfig {
const { summary, items = [] } = data; const { summary, category_breakdown, loans = [] } = data;
// 테이블 데이터 매핑 // 테이블 데이터 매핑 (백엔드 필드명 기준, 영문 키 → 한글 변환)
const tableData = (items || []).map((item) => ({ const tableData = (loans || []).map((item) => ({
date: item.loan_date, date: item.loan_date,
classification: item.status_label || '카드', classification: CATEGORY_LABELS[item.category] || item.category || '카드',
category: '-', category: item.status_label || '-',
amount: item.amount, amount: item.amount,
content: item.description, response: item.content,
})); }));
// 분류 필터 옵션 동적 생성 // 분류 필터 옵션 동적 생성
@@ -202,22 +220,42 @@ export function transformCm2ModalConfig(
})), })),
]; ];
// reviewCards: category_breakdown에서 4개 카테고리 카드 생성
const reviewCards = category_breakdown
? {
title: '가지급금 검토 필요',
cards: Object.entries(category_breakdown).map(([key, breakdown]) => ({
label: CATEGORY_LABELS[key] || key,
amount: breakdown.outstanding_amount,
subLabel: breakdown.unverified_count > 0
? `미증빙 ${breakdown.unverified_count}`
: `${breakdown.total_count}`,
})),
}
: undefined;
return { return {
title: '가지급금 상세', title: '가지급금 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [ summaryCards: [
{ label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) }, { label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) },
{ label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' }, { label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' },
{ label: '미정리/미분류', value: `${summary.pending_count ?? 0}` }, { label: '건수', value: `${summary.outstanding_count ?? 0}` },
], ],
reviewCards,
table: { table: {
title: '가지급금 관련 내역', title: '가지급금 내역',
columns: [ columns: [
{ key: 'no', label: 'No.', align: 'center' }, { key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '발생일', align: 'center' }, { key: 'date', label: '발생일', align: 'center' },
{ key: 'classification', label: '분류', align: 'center' }, { key: 'classification', label: '분류', align: 'center' },
{ key: 'category', label: '구분', align: 'center' }, { key: 'category', label: '구분', align: 'center' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' }, { key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'content', label: '내용', align: 'left' }, { key: 'response', label: '대응', align: 'left' },
], ],
data: tableData, data: tableData,
filters: [ filters: [
@@ -227,11 +265,12 @@ export function transformCm2ModalConfig(
defaultValue: 'all', defaultValue: 'all',
}, },
{ {
key: 'category', key: 'sortOrder',
options: [ options: [
{ value: 'all', label: '전체' }, { value: 'all', label: '정렬' },
{ value: '카드명', label: '카드명' }, { value: 'amountDesc', label: '금액 높은순' },
{ value: '계좌명', label: '계좌명' }, { value: 'amountAsc', label: '금액 낮은순' },
{ value: 'latest', label: '최신순' },
], ],
defaultValue: 'all', defaultValue: 'all',
}, },

View File

@@ -224,11 +224,15 @@ export function getEntertainmentModalConfig(cardId: string): DetailModalConfig |
totalColumnKey: 'amount', totalColumnKey: 'amount',
}, },
}, },
// et_limit, et_remaining, et_used는 모두 동일한 접대비 상세 모달 // D1.7 리스크감지형 카드 ID → 접대비 상세 모달
et_weekend: entertainmentDetailConfig,
et_prohibited: entertainmentDetailConfig,
et_high_amount: entertainmentDetailConfig,
et_no_receipt: entertainmentDetailConfig,
// 레거시 카드 ID (하위 호환)
et_limit: entertainmentDetailConfig, et_limit: entertainmentDetailConfig,
et_remaining: entertainmentDetailConfig, et_remaining: entertainmentDetailConfig,
et_used: entertainmentDetailConfig, et_used: entertainmentDetailConfig,
// 대시보드 카드 ID (et1~et4) → 접대비 상세 모달
et1: entertainmentDetailConfig, et1: entertainmentDetailConfig,
et2: entertainmentDetailConfig, et2: entertainmentDetailConfig,
et3: entertainmentDetailConfig, et3: entertainmentDetailConfig,

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Search as SearchIcon } from 'lucide-react'; import { Search as SearchIcon } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -58,6 +58,16 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}); });
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const isInitialMount = useRef(true);
// 날짜 변경 시 자동 조회 (다른 페이지와 동일한 UX)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onFilterChange?.({ startDate, endDate, search: searchText });
}, [startDate, endDate]);
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
onFilterChange?.({ startDate, endDate, search: searchText }); onFilterChange?.({ startDate, endDate, search: searchText });
@@ -88,11 +98,6 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt
/> />
</div> </div>
)} )}
{onFilterChange && (
<Button size="sm" onClick={handleSearch} className="h-8 px-3 text-xs">
</Button>
)}
</div> </div>
} }
/> />
@@ -103,10 +108,15 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt
export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => { export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => {
const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || ''); const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || '');
const handleChange = useCallback((value: string) => {
setSelected(value);
config.onPeriodChange?.(value);
}, [config]);
return ( return (
<div className="flex items-center gap-2 pb-4 border-b"> <div className="flex items-center gap-2 pb-4 border-b">
<span className="text-sm text-gray-600 font-medium"></span> <span className="text-sm text-gray-600 font-medium"></span>
<Select value={selected} onValueChange={setSelected}> <Select value={selected} onValueChange={handleChange}>
<SelectTrigger className="h-8 w-auto min-w-[200px] text-xs"> <SelectTrigger className="h-8 w-auto min-w-[200px] text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { TimePicker } from '@/components/ui/time-picker'; import { TimePicker } from '@/components/ui/time-picker';
import { DatePicker } from '@/components/ui/date-picker'; import { DateRangePicker } from '@/components/ui/date-range-picker';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -21,6 +21,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import type { CalendarScheduleItem } from '../types'; import type { CalendarScheduleItem } from '../types';
// 색상 옵션 // 색상 옵션
@@ -59,6 +60,7 @@ interface ScheduleDetailModalProps {
schedule: CalendarScheduleItem | null; schedule: CalendarScheduleItem | null;
onSave: (data: ScheduleFormData) => void; onSave: (data: ScheduleFormData) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
isEditable?: boolean;
} }
export function ScheduleDetailModal({ export function ScheduleDetailModal({
@@ -67,6 +69,7 @@ export function ScheduleDetailModal({
schedule, schedule,
onSave, onSave,
onDelete, onDelete,
isEditable = true,
}: ScheduleDetailModalProps) { }: ScheduleDetailModalProps) {
const isEditMode = schedule && schedule.id !== ''; const isEditMode = schedule && schedule.id !== '';
@@ -128,7 +131,14 @@ export function ScheduleDetailModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}> <Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-4 sm:p-6 max-h-[90vh] overflow-y-auto"> <DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
<DialogHeader className="pb-2"> <DialogHeader className="pb-2">
<DialogTitle className="text-lg font-bold"> </DialogTitle> <DialogTitle className="text-lg font-bold flex items-center gap-2">
{!isEditable && (
<Badge variant="secondary" className="text-xs bg-gray-100 text-gray-600">
</Badge>
)}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
@@ -139,6 +149,7 @@ export function ScheduleDetailModal({
value={formData.title} value={formData.title}
onChange={(e) => handleFieldChange('title', e.target.value)} onChange={(e) => handleFieldChange('title', e.target.value)}
placeholder="제목" placeholder="제목"
disabled={!isEditable}
/> />
</div> </div>
@@ -148,6 +159,7 @@ export function ScheduleDetailModal({
<Select <Select
value={formData.department} value={formData.department}
onValueChange={(value) => handleFieldChange('department', value)} onValueChange={(value) => handleFieldChange('department', value)}
disabled={!isEditable}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="부서명" /> <SelectValue placeholder="부서명" />
@@ -165,23 +177,15 @@ export function ScheduleDetailModal({
{/* 기간 */} {/* 기간 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label> <label className="text-sm font-medium text-gray-700"></label>
<div className="flex flex-col gap-2"> <DateRangePicker
<DatePicker startDate={formData.startDate}
value={formData.startDate} endDate={formData.endDate}
onChange={(value) => handleFieldChange('startDate', value)} onStartDateChange={(date) => handleFieldChange('startDate', date)}
onEndDateChange={(date) => handleFieldChange('endDate', date)}
size="sm" size="sm"
className="w-full" className="w-full"
disabled={!isEditable}
/> />
<div className="flex items-center gap-2">
<span className="text-gray-400 text-xs">~</span>
<DatePicker
value={formData.endDate}
onChange={(value) => handleFieldChange('endDate', value)}
size="sm"
className="w-full"
/>
</div>
</div>
</div> </div>
{/* 시간 */} {/* 시간 */}
@@ -196,6 +200,7 @@ export function ScheduleDetailModal({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleFieldChange('isAllDay', checked === true) handleFieldChange('isAllDay', checked === true)
} }
disabled={!isEditable}
/> />
<label htmlFor="isAllDay" className="text-sm text-gray-600 cursor-pointer"> <label htmlFor="isAllDay" className="text-sm text-gray-600 cursor-pointer">
@@ -236,9 +241,10 @@ export function ScheduleDetailModal({
formData.color === color.value formData.color === color.value
? 'ring-2 ring-offset-2 ring-gray-400' ? 'ring-2 ring-offset-2 ring-gray-400'
: 'hover:scale-110' : 'hover:scale-110'
}`} } ${!isEditable ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => handleFieldChange('color', color.value)} onClick={() => isEditable && handleFieldChange('color', color.value)}
title={color.label} title={color.label}
disabled={!isEditable}
/> />
))} ))}
</div> </div>
@@ -252,11 +258,14 @@ export function ScheduleDetailModal({
onChange={(e) => handleFieldChange('content', e.target.value)} onChange={(e) => handleFieldChange('content', e.target.value)}
placeholder="내용" placeholder="내용"
className="min-h-[100px] resize-none" className="min-h-[100px] resize-none"
disabled={!isEditable}
/> />
</div> </div>
</div> </div>
<DialogFooter className="flex flex-row gap-2 pt-2"> <DialogFooter className="flex flex-row gap-2 pt-2">
{isEditable ? (
<>
{isEditMode && onDelete && ( {isEditMode && onDelete && (
<Button <Button
variant="outline" variant="outline"
@@ -272,6 +281,12 @@ export function ScheduleDetailModal({
> >
{isEditMode ? '수정' : '등록'} {isEditMode ? '수정' : '등록'}
</Button> </Button>
</>
) : (
<Button variant="outline" onClick={handleCancel}>
</Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -44,6 +44,22 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
tax: 'orange', tax: 'orange',
}; };
// 일정 타입별 라벨
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
order: '생산',
construction: '시공',
schedule: '일정',
other: '기타',
};
// 일정 타입별 뱃지 색상
const SCHEDULE_TYPE_BADGE_COLORS: Record<string, string> = {
order: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
construction: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
// 이슈 뱃지별 색상 // 이슈 뱃지별 색상
const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = { const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'수주등록': 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', '수주등록': 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
@@ -453,6 +469,11 @@ export function CalendarSection({
return ( return (
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} /> <span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
{isSelected && evType !== 'holiday' && evType !== 'tax' && evType !== 'issue' && (
<span className={`text-[10px] shrink-0 px-1 rounded ${SCHEDULE_TYPE_BADGE_COLORS[evType] || ''}`}>
{SCHEDULE_TYPE_LABELS[evType] || ''}
</span>
)}
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span> <span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
</div> </div>
); );
@@ -469,8 +490,8 @@ export function CalendarSection({
</div> </div>
</div> </div>
{/* 데스크탑: 기존 캘린더 + 상세 */} {/* 데스크탑: 캘린더 + 상세 (태블릿: 세로배치, 와이드: 가로배치) */}
<div className="hidden lg:grid lg:grid-cols-2 gap-6"> <div className="hidden lg:grid lg:grid-cols-1 xl:grid-cols-2 gap-6">
{/* 캘린더 영역 */} {/* 캘린더 영역 */}
<div> <div>
<ScheduleCalendar <ScheduleCalendar
@@ -554,7 +575,12 @@ export function CalendarSection({
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer" className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onScheduleClick?.(schedule)} onClick={() => onScheduleClick?.(schedule)}
> >
<div className="font-medium text-base text-foreground mb-1">{schedule.title}</div> <div className="flex items-start gap-2 mb-1">
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
</Badge>
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
</div>
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div> <div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
</div> </div>
))} ))}

View File

@@ -1,12 +1,12 @@
'use client'; 'use client';
import { Wine, Utensils, Users, CreditCard } from 'lucide-react'; import { Moon, ShieldAlert, Banknote, FileWarning, Wine } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components'; import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { EntertainmentData } from '../types'; import type { EntertainmentData } from '../types';
// 카드별 아이콘 매핑 // 카드별 아이콘 매핑 (주말/심야, 기피업종, 고액결제, 증빙미비)
const CARD_ICONS = [Wine, Utensils, Users, CreditCard]; const CARD_ICONS = [Moon, ShieldAlert, Banknote, FileWarning];
const CARD_THEMES: SectionColorTheme[] = ['pink', 'purple', 'indigo', 'red']; const CARD_THEMES: SectionColorTheme[] = ['purple', 'red', 'orange', 'pink'];
interface EntertainmentSectionProps { interface EntertainmentSectionProps {
data: EntertainmentData; data: EntertainmentData;

View File

@@ -1,13 +1,13 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Banknote, Clock, AlertTriangle, CircleDollarSign, ChevronRight } from 'lucide-react'; import { Banknote, CircleDollarSign, Building2, TrendingUp, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components'; import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { ReceivableData } from '../types'; import type { ReceivableData } from '../types';
// 카드별 아이콘 매핑 (미수금 합계, 30일 이내, 30~90일, 90일 초과) // 카드별 아이콘 매핑 (누적미수금, 당월미수금, 거래처, Top3)
const CARD_ICONS = [CircleDollarSign, Banknote, Clock, AlertTriangle]; const CARD_ICONS = [CircleDollarSign, Banknote, Building2, TrendingUp];
const CARD_THEMES: SectionColorTheme[] = ['amber', 'green', 'orange', 'red']; const CARD_THEMES: SectionColorTheme[] = ['amber', 'green', 'orange', 'red'];
interface ReceivableSectionProps { interface ReceivableSectionProps {

View File

@@ -1,12 +1,12 @@
'use client'; 'use client';
import { Heart, Gift, Coffee, Smile } from 'lucide-react'; import { Receipt, Moon, UserX, BarChart3, Heart } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components'; import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { WelfareData } from '../types'; import type { WelfareData } from '../types';
// 카드별 아이콘 매핑 // 카드별 아이콘 매핑 (비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과)
const CARD_ICONS = [Heart, Gift, Coffee, Smile]; const CARD_ICONS = [Receipt, Moon, UserX, BarChart3];
const CARD_THEMES: SectionColorTheme[] = ['emerald', 'green', 'cyan', 'blue']; const CARD_THEMES: SectionColorTheme[] = ['red', 'purple', 'orange', 'cyan'];
interface WelfareSectionProps { interface WelfareSectionProps {
data: WelfareData; data: WelfareData;

View File

@@ -693,6 +693,7 @@ export interface PeriodSelectConfig {
enabled: boolean; enabled: boolean;
options: { value: string; label: string }[]; options: { value: string; label: string }[];
defaultValue?: string; defaultValue?: string;
onPeriodChange?: (value: string) => void;
} }
// 상세 모달 전체 설정 타입 // 상세 모달 전체 설정 타입

View File

@@ -18,14 +18,18 @@ import type {
DailyReportApiResponse, DailyReportApiResponse,
ReceivablesApiResponse, ReceivablesApiResponse,
BadDebtApiResponse, BadDebtApiResponse,
ExpectedExpenseApiResponse,
CardTransactionApiResponse, CardTransactionApiResponse,
StatusBoardApiResponse, StatusBoardApiResponse,
TodayIssueApiResponse, TodayIssueApiResponse,
CalendarApiResponse, CalendarApiResponse,
VatApiResponse, VatApiResponse,
VatDetailApiResponse,
EntertainmentApiResponse, EntertainmentApiResponse,
EntertainmentDetailApiResponse,
WelfareApiResponse, WelfareApiResponse,
WelfareDetailApiResponse, WelfareDetailApiResponse,
ExpectedExpenseDashboardDetailApiResponse,
SalesStatusApiResponse, SalesStatusApiResponse,
PurchaseStatusApiResponse, PurchaseStatusApiResponse,
DailyProductionApiResponse, DailyProductionApiResponse,
@@ -38,18 +42,18 @@ import {
transformDailyReportResponse, transformDailyReportResponse,
transformReceivableResponse, transformReceivableResponse,
transformDebtCollectionResponse, transformDebtCollectionResponse,
transformMonthlyExpenseResponse,
transformCardManagementResponse, transformCardManagementResponse,
transformStatusBoardResponse, transformStatusBoardResponse,
transformTodayIssueResponse, transformTodayIssueResponse,
transformCalendarResponse, transformCalendarResponse,
transformVatResponse, transformVatResponse,
transformVatDetailResponse,
transformEntertainmentResponse, transformEntertainmentResponse,
transformEntertainmentDetailResponse,
transformWelfareResponse, transformWelfareResponse,
transformWelfareDetailResponse, transformWelfareDetailResponse,
transformPurchaseRecordsToModal, transformExpectedExpenseDetailResponse,
transformCardTransactionsToModal,
transformBillRecordsToModal,
transformAllExpensesToModal,
transformSalesStatusResponse, transformSalesStatusResponse,
transformPurchaseStatusResponse, transformPurchaseStatusResponse,
transformDailyProductionResponse, transformDailyProductionResponse,
@@ -58,11 +62,6 @@ import {
transformDailyAttendanceResponse, transformDailyAttendanceResponse,
} from '@/lib/api/dashboard/transformers'; } from '@/lib/api/dashboard/transformers';
import { getPurchases } from '@/components/accounting/PurchaseManagement/actions';
import { getCardTransactionList } from '@/components/accounting/CardTransactionInquiry/actions';
import { getBills } from '@/components/accounting/BillManagement/actions';
import { formatAmount } from '@/lib/api/dashboard/transformers/common';
import type { import type {
DailyReportData, DailyReportData,
ReceivableData, ReceivableData,
@@ -124,6 +123,20 @@ async function fetchCardManagementData(fallbackData?: CardManagementData) {
return transformCardManagementResponse(cardApiData, loanData, taxData, fallbackData); return transformCardManagementResponse(cardApiData, loanData, taxData, fallbackData);
} }
// ============================================
// 당월 날짜 범위 유틸리티
// ============================================
function getCurrentMonthEndpoint(base: string): string {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const startDate = `${y}-${String(m).padStart(2, '0')}-01`;
const lastDay = new Date(y, m, 0).getDate();
const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return buildEndpoint(base, { start_date: startDate, end_date: endDate });
}
// ============================================ // ============================================
// 1~4. 단순 섹션 Hooks (파라미터 없음) // 1~4. 단순 섹션 Hooks (파라미터 없음)
// ============================================ // ============================================
@@ -150,65 +163,10 @@ export function useDebtCollection() {
} }
export function useMonthlyExpense() { export function useMonthlyExpense() {
const [data, setData] = useState<MonthlyExpenseData | null>(null); return useDashboardFetch<ExpectedExpenseApiResponse, MonthlyExpenseData>(
const [loading, setLoading] = useState(true); getCurrentMonthEndpoint('expected-expenses/summary'),
const [error, setError] = useState<string | null>(null); transformMonthlyExpenseResponse,
);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// 당월 날짜 범위
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const startDate = `${y}-${String(m).padStart(2, '0')}-01`;
const lastDay = new Date(y, m, 0).getDate();
const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const commonParams = { perPage: 9999, page: 1 };
const [purchaseResult, cardResult, billResult] = await Promise.all([
getPurchases({ ...commonParams, startDate, endDate }),
getCardTransactionList({ ...commonParams, startDate, endDate }),
getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate }),
]);
const purchases = purchaseResult.success ? purchaseResult.data : [];
const cards = cardResult.success ? cardResult.data : [];
const bills = billResult.success ? billResult.data : [];
const purchaseTotal = purchases.reduce((sum, r) => sum + r.totalAmount, 0);
const cardTotal = cards.reduce((sum, r) => sum + r.totalAmount, 0);
const billTotal = bills.reduce((sum, r) => sum + r.amount, 0);
const grandTotal = purchaseTotal + cardTotal + billTotal;
const result: MonthlyExpenseData = {
cards: [
{ id: 'me1', label: '매입', amount: purchaseTotal },
{ id: 'me2', label: '카드', amount: cardTotal },
{ id: 'me3', label: '발행어음', amount: billTotal },
{ id: 'me4', label: '총 예상 지출 합계', amount: grandTotal },
],
checkPoints: grandTotal > 0
? [{ id: 'me-total', type: 'info' as const, message: `이번 달 예상 지출은 ${formatAmount(grandTotal)}입니다.`, highlights: [{ text: formatAmount(grandTotal), color: 'blue' as const }] }]
: [{ id: 'me-total', type: 'info' as const, message: '이번 달 예상 지출이 없습니다.' }],
};
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : '데이터 로딩 실패');
console.error('MonthlyExpense API Error:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
} }
// ============================================ // ============================================
@@ -350,6 +308,27 @@ export function useVat(options: UseVatOptions = {}) {
return useDashboardFetch<VatApiResponse, VatData>(endpoint, transformVatResponse); return useDashboardFetch<VatApiResponse, VatData>(endpoint, transformVatResponse);
} }
// ============================================
// 9-1. VatDetail Hook (부가세 상세 - 모달용)
// ============================================
export function useVatDetail() {
const endpoint = useMemo(() => 'vat/detail', []);
const result = useDashboardFetch<VatDetailApiResponse, DetailModalConfig>(
endpoint,
transformVatDetailResponse,
{ lazy: true },
);
return {
modalConfig: result.data,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
// ============================================ // ============================================
// 10. Entertainment Hook (접대비) // 10. Entertainment Hook (접대비)
// ============================================ // ============================================
@@ -423,6 +402,43 @@ export function useWelfare(options: UseWelfareOptions = {}) {
); );
} }
// ============================================
// 11-1. EntertainmentDetail Hook (접대비 상세 - 모달용)
// ============================================
export interface UseEntertainmentDetailOptions {
companyType?: 'large' | 'medium' | 'small';
year?: number;
quarter?: number;
}
export function useEntertainmentDetail(options: UseEntertainmentDetailOptions = {}) {
const { companyType = 'medium', year, quarter } = options;
const endpoint = useMemo(
() =>
buildEndpoint('entertainment/detail', {
company_type: companyType,
year,
quarter,
}),
[companyType, year, quarter],
);
const result = useDashboardFetch<EntertainmentDetailApiResponse, DetailModalConfig>(
endpoint,
transformEntertainmentDetailResponse,
{ lazy: true },
);
return {
modalConfig: result.data,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
// ============================================ // ============================================
// 12. WelfareDetail Hook (복리후생비 상세 - 모달용) // 12. WelfareDetail Hook (복리후생비 상세 - 모달용)
// ============================================ // ============================================
@@ -547,44 +563,42 @@ export function useMonthlyExpenseDetail() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// cardId → transaction_type 매핑
const CARD_TRANSACTION_TYPE: Record<MonthlyExpenseCardId, string | undefined> = {
me1: 'purchase',
me2: 'card',
me3: 'bill',
me4: undefined, // 전체
};
const fetchData = useCallback(async (cardId: MonthlyExpenseCardId, filterParams?: { startDate?: string; endDate?: string; search?: string }) => { const fetchData = useCallback(async (cardId: MonthlyExpenseCardId, filterParams?: { startDate?: string; endDate?: string; search?: string }) => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
// 당월 기본 날짜 범위 // 대시보드 전용 API 엔드포인트 구성
const now = new Date(); const transactionType = CARD_TRANSACTION_TYPE[cardId];
const startDate = filterParams?.startDate || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; const params: Record<string, string | undefined> = {
const endDate = filterParams?.endDate || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2, '0')}`; transaction_type: transactionType,
const search = filterParams?.search; start_date: filterParams?.startDate,
end_date: filterParams?.endDate,
search: filterParams?.search,
};
const endpoint = buildEndpoint('/api/proxy/expected-expenses/dashboard-detail', params);
// 전체 데이터 가져오기 (perPage 크게 설정) const response = await fetch(endpoint);
const commonParams = { perPage: 9999, page: 1 }; if (!response.ok) {
throw new Error(`API 오류: ${response.status}`);
let transformed: DetailModalConfig;
if (cardId === 'me1') {
const result = await getPurchases({ ...commonParams, startDate, endDate, search });
transformed = transformPurchaseRecordsToModal(result.success ? result.data : []);
} else if (cardId === 'me2') {
const result = await getCardTransactionList({ ...commonParams, startDate, endDate, search });
transformed = transformCardTransactionsToModal(result.success ? result.data : []);
} else if (cardId === 'me3') {
const result = await getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate, search });
transformed = transformBillRecordsToModal(result.success ? result.data : []);
} else {
// me4: 3개 모두 호출 후 합산
const [purchaseResult, cardResult, billResult] = await Promise.all([
getPurchases({ ...commonParams, startDate, endDate, search }),
getCardTransactionList({ ...commonParams, startDate, endDate, search }),
getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate, search }),
]);
transformed = transformAllExpensesToModal(
purchaseResult.success ? purchaseResult.data : [],
cardResult.success ? cardResult.data : [],
billResult.success ? billResult.data : [],
);
} }
const result = await response.json();
if (!result.success) {
throw new Error(result.message || '데이터 조회 실패');
}
const transformed = transformExpectedExpenseDetailResponse(
result.data as ExpectedExpenseDashboardDetailApiResponse,
cardId,
);
setModalConfig(transformed); setModalConfig(transformed);
return transformed; return transformed;
@@ -712,57 +726,12 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
{ initialLoading: enableDailyAttendance }, { initialLoading: enableDailyAttendance },
); );
// MonthlyExpense: 커스텀 (3개 페이지 API 병렬) // MonthlyExpense: 대시보드 전용 API (당월 필터)
const [meData, setMeData] = useState<MonthlyExpenseData | null>(null); const me = useDashboardFetch<ExpectedExpenseApiResponse, MonthlyExpenseData>(
const [meLoading, setMeLoading] = useState(enableMonthlyExpense); enableMonthlyExpense ? getCurrentMonthEndpoint('expected-expenses/summary') : null,
const [meError, setMeError] = useState<string | null>(null); transformMonthlyExpenseResponse,
{ initialLoading: enableMonthlyExpense },
const fetchME = useCallback(async () => { );
if (!enableMonthlyExpense) return;
try {
setMeLoading(true);
setMeError(null);
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const startDate = `${y}-${String(m).padStart(2, '0')}-01`;
const lastDay = new Date(y, m, 0).getDate();
const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const commonParams = { perPage: 9999, page: 1 };
const [purchaseResult, cardResult, billResult] = await Promise.all([
getPurchases({ ...commonParams, startDate, endDate }),
getCardTransactionList({ ...commonParams, startDate, endDate }),
getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate }),
]);
const purchases = purchaseResult.success ? purchaseResult.data : [];
const cards = cardResult.success ? cardResult.data : [];
const bills = billResult.success ? billResult.data : [];
const purchaseTotal = purchases.reduce((sum, r) => sum + r.totalAmount, 0);
const cardTotal = cards.reduce((sum, r) => sum + r.totalAmount, 0);
const billTotal = bills.reduce((sum, r) => sum + r.amount, 0);
const grandTotal = purchaseTotal + cardTotal + billTotal;
setMeData({
cards: [
{ id: 'me1', label: '매입', amount: purchaseTotal },
{ id: 'me2', label: '카드', amount: cardTotal },
{ id: 'me3', label: '발행어음', amount: billTotal },
{ id: 'me4', label: '총 예상 지출 합계', amount: grandTotal },
],
checkPoints: grandTotal > 0
? [{ id: 'me-total', type: 'info' as const, message: `이번 달 예상 지출은 ${formatAmount(grandTotal)}입니다.`, highlights: [{ text: formatAmount(grandTotal), color: 'blue' as const }] }]
: [{ id: 'me-total', type: 'info' as const, message: '이번 달 예상 지출이 없습니다.' }],
});
} catch (err) {
setMeError(err instanceof Error ? err.message : '데이터 로딩 실패');
console.error('MonthlyExpense API Error:', err);
} finally {
setMeLoading(false);
}
}, [enableMonthlyExpense]);
// CardManagement: 커스텀 (3개 API 병렬) // CardManagement: 커스텀 (3개 API 병렬)
const [cmData, setCmData] = useState<CardManagementData | null>(null); const [cmData, setCmData] = useState<CardManagementData | null>(null);
@@ -785,15 +754,14 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
}, [enableCardManagement, cardManagementFallback]); }, [enableCardManagement, cardManagementFallback]);
useEffect(() => { useEffect(() => {
fetchME();
fetchCM(); fetchCM();
}, [fetchME, fetchCM]); }, [fetchCM]);
const refetchAll = useCallback(() => { const refetchAll = useCallback(() => {
dr.refetch(); dr.refetch();
rv.refetch(); rv.refetch();
dc.refetch(); dc.refetch();
fetchME(); me.refetch();
fetchCM(); fetchCM();
sb.refetch(); sb.refetch();
ss.refetch(); ss.refetch();
@@ -803,13 +771,13 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
cs.refetch(); cs.refetch();
da.refetch(); da.refetch();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dr.refetch, rv.refetch, dc.refetch, fetchME, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]); }, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]);
return { return {
dailyReport: { data: dr.data, loading: dr.loading, error: dr.error }, dailyReport: { data: dr.data, loading: dr.loading, error: dr.error },
receivable: { data: rv.data, loading: rv.loading, error: rv.error }, receivable: { data: rv.data, loading: rv.loading, error: rv.error },
debtCollection: { data: dc.data, loading: dc.loading, error: dc.error }, debtCollection: { data: dc.data, loading: dc.loading, error: dc.error },
monthlyExpense: { data: meData, loading: meLoading, error: meError }, monthlyExpense: { data: me.data, loading: me.loading, error: me.error },
cardManagement: { data: cmData, loading: cmLoading, error: cmError }, cardManagement: { data: cmData, loading: cmLoading, error: cmError },
statusBoard: { data: sb.data, loading: sb.loading, error: sb.error }, statusBoard: { data: sb.data, loading: sb.loading, error: sb.error },
salesStatus: { data: ss.data, loading: ss.loading, error: ss.error }, salesStatus: { data: ss.data, loading: ss.loading, error: ss.error },

View File

@@ -52,7 +52,7 @@ export interface UseCardManagementModalsReturn {
/** 에러 메시지 */ /** 에러 메시지 */
error: string | null; error: string | null;
/** 특정 카드의 모달 데이터 조회 - 데이터 직접 반환 */ /** 특정 카드의 모달 데이터 조회 - 데이터 직접 반환 */
fetchModalData: (cardId: CardManagementCardId) => Promise<CardManagementModalData>; fetchModalData: (cardId: CardManagementCardId, params?: { start_date?: string; end_date?: string }) => Promise<CardManagementModalData>;
/** 모든 카드 데이터 조회 */ /** 모든 카드 데이터 조회 */
fetchAllData: () => Promise<void>; fetchAllData: () => Promise<void>;
/** 데이터 초기화 */ /** 데이터 초기화 */
@@ -105,11 +105,15 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
/** /**
* cm2: 가지급금 상세 데이터 조회 * cm2: 가지급금 상세 데이터 조회
* @param params - 날짜 필터 (선택)
* @returns 조회된 데이터 (실패 시 null) * @returns 조회된 데이터 (실패 시 null)
*/ */
const fetchCm2Data = useCallback(async (): Promise<LoanDashboardApiResponse | null> => { const fetchCm2Data = useCallback(async (params?: {
start_date?: string;
end_date?: string;
}): Promise<LoanDashboardApiResponse | null> => {
try { try {
const response = await fetchLoanDashboard(); const response = await fetchLoanDashboard(params);
if (response.success && response.data) { if (response.success && response.data) {
setCm2Data(response.data); setCm2Data(response.data);
return response.data; return response.data;
@@ -148,10 +152,14 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
/** /**
* 특정 카드의 모달 데이터 조회 * 특정 카드의 모달 데이터 조회
* @param params - cm2용 날짜 필터 (선택)
* @returns 조회된 모달 데이터 객체 (카드 ID에 해당하는 데이터만 포함) * @returns 조회된 모달 데이터 객체 (카드 ID에 해당하는 데이터만 포함)
*/ */
const fetchModalData = useCallback( const fetchModalData = useCallback(
async (cardId: CardManagementCardId): Promise<CardManagementModalData> => { async (cardId: CardManagementCardId, params?: {
start_date?: string;
end_date?: string;
}): Promise<CardManagementModalData> => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -163,7 +171,7 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
result.cm1Data = await fetchCm1Data(); result.cm1Data = await fetchCm1Data();
break; break;
case 'cm2': case 'cm2':
result.cm2Data = await fetchCm2Data(); result.cm2Data = await fetchCm2Data(params);
break; break;
case 'cm3': case 'cm3':
case 'cm4': { case 'cm4': {

View File

@@ -38,8 +38,8 @@ export function useDashboardFetch<TApi, TResult>(
const [loading, setLoading] = useState(options?.initialLoading ?? !lazy); const [loading, setLoading] = useState(options?.initialLoading ?? !lazy);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => { const fetchData = useCallback(async (): Promise<TResult | null> => {
if (!endpoint) return; if (!endpoint) return null;
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -55,10 +55,12 @@ export function useDashboardFetch<TApi, TResult>(
const transformed = transformer(result.data); const transformed = transformer(result.data);
setData(transformed); setData(transformed);
return transformed;
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
setError(errorMessage); setError(errorMessage);
console.error(`Dashboard API Error [${endpoint}]:`, err); console.error(`Dashboard API Error [${endpoint}]:`, err);
return null;
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -70,7 +72,7 @@ export function useDashboardFetch<TApi, TResult>(
} }
}, [lazy, endpoint, fetchData]); }, [lazy, endpoint, fetchData]);
return { data, loading, error, refetch: fetchData }; return { data, loading, error, refetch: fetchData, setData };
} }
/** /**

View File

@@ -102,12 +102,17 @@ export async function fetchCardTransactionDashboard(): Promise<ApiResponse<CardD
* 가지급금 대시보드 데이터 조회 * 가지급금 대시보드 데이터 조회
* GET /api/v1/loans/dashboard * GET /api/v1/loans/dashboard
* *
* @returns 가지급금 요약, 월별 추이, 사용자별 분포, 거래 목록 * @param params - 날짜 필터 (선택)
* @returns 가지급금 요약, 카테고리 집계, 거래 목록
*/ */
export async function fetchLoanDashboard(): Promise<ApiResponse<LoanDashboardApiResponse>> { export async function fetchLoanDashboard(params?: {
start_date?: string;
end_date?: string;
}): Promise<ApiResponse<LoanDashboardApiResponse>> {
try { try {
const response = await apiClient.get<ApiResponse<LoanDashboardApiResponse>>( const response = await apiClient.get<ApiResponse<LoanDashboardApiResponse>>(
'/loans/dashboard' '/loans/dashboard',
params ? { params } : undefined
); );
return response; return response;
} catch (error) { } catch (error) {
@@ -126,13 +131,10 @@ export async function fetchLoanDashboard(): Promise<ApiResponse<LoanDashboardApi
data: { data: {
summary: { summary: {
total_outstanding: 0, total_outstanding: 0,
settled_amount: 0,
recognized_interest: 0, recognized_interest: 0,
pending_count: 0, outstanding_count: 0,
}, },
monthly_trend: [], loans: [],
user_distribution: [],
items: [],
}, },
}; };
} }
@@ -144,13 +146,10 @@ export async function fetchLoanDashboard(): Promise<ApiResponse<LoanDashboardApi
data: { data: {
summary: { summary: {
total_outstanding: 0, total_outstanding: 0,
settled_amount: 0,
recognized_interest: 0, recognized_interest: 0,
pending_count: 0, outstanding_count: 0,
}, },
monthly_trend: [], loans: [],
user_distribution: [],
items: [],
}, },
}; };
} }

View File

@@ -10,7 +10,7 @@ export { transformReceivableResponse, transformDebtCollectionResponse } from './
export { transformMonthlyExpenseResponse, transformCardManagementResponse } from './transformers/expense'; export { transformMonthlyExpenseResponse, transformCardManagementResponse } from './transformers/expense';
export { transformStatusBoardResponse, transformTodayIssueResponse } from './transformers/status-issue'; export { transformStatusBoardResponse, transformTodayIssueResponse } from './transformers/status-issue';
export { transformCalendarResponse } from './transformers/calendar'; export { transformCalendarResponse } from './transformers/calendar';
export { transformVatResponse, transformEntertainmentResponse, transformWelfareResponse, transformWelfareDetailResponse } from './transformers/tax-benefits'; export { transformVatResponse, transformVatDetailResponse, transformEntertainmentResponse, transformEntertainmentDetailResponse, transformWelfareResponse, transformWelfareDetailResponse } from './transformers/tax-benefits';
export { transformPurchaseDetailResponse, transformCardDetailResponse, transformBillDetailResponse, transformExpectedExpenseDetailResponse, transformPurchaseRecordsToModal, transformCardTransactionsToModal, transformBillRecordsToModal, transformAllExpensesToModal } from './transformers/expense-detail'; export { transformPurchaseDetailResponse, transformCardDetailResponse, transformBillDetailResponse, transformExpectedExpenseDetailResponse, transformPurchaseRecordsToModal, transformCardTransactionsToModal, transformBillRecordsToModal, transformAllExpensesToModal } from './transformers/expense-detail';
export { transformSalesStatusResponse, transformPurchaseStatusResponse } from './transformers/sales-purchase'; export { transformSalesStatusResponse, transformPurchaseStatusResponse } from './transformers/sales-purchase';
export { transformDailyProductionResponse, transformUnshippedResponse, transformConstructionResponse } from './transformers/production-logistics'; export { transformDailyProductionResponse, transformUnshippedResponse, transformConstructionResponse } from './transformers/production-logistics';

View File

@@ -14,7 +14,7 @@ import type {
CheckPoint, CheckPoint,
CheckPointType, CheckPointType,
} from '@/components/business/CEODashboard/types'; } from '@/components/business/CEODashboard/types';
import { formatAmount, calculateChangeRate } from './common'; import { formatAmount } from './common';
// ============================================ // ============================================
// 월 예상 지출 (MonthlyExpense) // 월 예상 지출 (MonthlyExpense)
@@ -78,49 +78,84 @@ export function transformMonthlyExpenseResponse(api: ExpectedExpenseApiResponse)
} }
// ============================================ // ============================================
// 카드/가지급금 (CardManagement) // 카드/가지급금 (CardManagement) — D1.7 5장 카드 구조
// ============================================ // ============================================
/** /**
* 카드/가지급금 CheckPoints 생성 * 카드/가지급금 CheckPoints 생성 (D1.7)
*
* CP1: 법인카드 → 가지급금 전환 경고
* CP2: 인정이자 발생 현황
* CP3: 접대비 불인정 항목 감지
* CP4: 주말 카드 사용 감지 (향후 확장)
*/ */
function generateCardManagementCheckPoints(api: CardTransactionApiResponse): CheckPoint[] { function generateCardManagementCheckPoints(
loanApi?: LoanDashboardApiResponse | null,
taxApi?: TaxSimulationApiResponse | null,
cardApi?: CardTransactionApiResponse | null,
): CheckPoint[] {
const checkPoints: CheckPoint[] = []; const checkPoints: CheckPoint[] = [];
// 전월 대비 변화 const totalOutstanding = loanApi?.summary?.total_outstanding ?? 0;
const changeRate = calculateChangeRate(api.current_month_total, api.previous_month_total); const interestRate = taxApi?.loan_summary?.interest_rate ?? 4.6;
if (Math.abs(changeRate) > 10) { const recognizedInterest = taxApi?.loan_summary?.recognized_interest ?? 0;
const type: CheckPointType = changeRate > 0 ? 'warning' : 'info';
// CP1: 법인카드 사용 중 가지급금 전환 경고
if (totalOutstanding > 0) {
checkPoints.push({ checkPoints.push({
id: 'cm-change', id: 'cm-cp1',
type, type: 'success' as CheckPointType,
message: `당월 카드 사용액이 전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}% 변동했습니다.`, message: `법인카드 사용${formatAmount(totalOutstanding)}이 가지급금으로 전환되었습니다. 연 ${interestRate}% 인정이자가 발생합니다.`,
highlights: [ highlights: [
{ text: `${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`, color: changeRate > 0 ? 'red' as const : 'green' as const }, { text: formatAmount(totalOutstanding), color: 'red' as const },
{ text: '가지급금', color: 'red' as const },
{ text: `${interestRate}% 인정이자`, color: 'red' as const },
], ],
}); });
} }
// 당월 사용액 // CP2: 인정이자 발생 현황
if (totalOutstanding > 0 && recognizedInterest > 0) {
checkPoints.push({ checkPoints.push({
id: 'cm-current', id: 'cm-cp2',
type: 'info' as CheckPointType, type: 'success' as CheckPointType,
message: `당월 카드 사용 총 ${formatAmount(api.current_month_total)}입니다.`, message: `현재 가지급금 ${formatAmount(totalOutstanding)} × ${interestRate}% = 연간 약 ${formatAmount(recognizedInterest)}의 인정이자가 발생 중입니다.`,
highlights: [ highlights: [
{ text: formatAmount(api.current_month_total), color: 'blue' as const }, { text: `연간 약 ${formatAmount(recognizedInterest)}의 인정이자`, color: 'red' as const },
], ],
}); });
}
// CP3: 접대비 불인정 항목 감지
const entertainmentCount = loanApi?.category_breakdown?.entertainment?.unverified_count ?? 0;
if (entertainmentCount > 0) {
checkPoints.push({
id: 'cm-cp3',
type: 'success' as CheckPointType,
message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.',
highlights: [
{ text: '불인정 항목 결제 감지', color: 'red' as const },
],
});
}
// CP4: 주말 카드 사용 감지 (향후 card-transactions API 확장 시)
// 현재는 cardApi 데이터에서 주말 사용 정보가 없으므로 placeholder
if (cardApi && cardApi.current_month_total > 0) {
// 향후 weekend_amount 필드 추가 시 활성화
}
return checkPoints; return checkPoints;
} }
/** /**
* CardTransaction API 응답 → Frontend 타입 변환 * CardTransaction API 응답 → Frontend 타입 변환
* 4개 카드 구조: * D1.7 5장 카드 구조:
* - cm1: 카드 사용액 (CardTransaction API) * - cm1: 카드 (loans category_breakdown.card)
* - cm2: 가지급금 (LoanDashboard API) * - cm2: 경조사 (loans category_breakdown.congratulatory)
* - cm3: 법인세 예상 가중 (TaxSimulation API - corporate_tax.difference) * - cm3: 상품권 (loans category_breakdown.gift_certificate)
* - cm4: 대표자 종합세 예상 가중 (TaxSimulation API - income_tax.difference) * - cm4: 접대비 (loans category_breakdown.entertainment)
* - cm_total: 총 가지급금 합계 (loans summary.total_outstanding)
*/ */
export function transformCardManagementResponse( export function transformCardManagementResponse(
summaryApi: CardTransactionApiResponse, summaryApi: CardTransactionApiResponse,
@@ -128,50 +163,69 @@ export function transformCardManagementResponse(
taxApi?: TaxSimulationApiResponse | null, taxApi?: TaxSimulationApiResponse | null,
fallbackData?: CardManagementData fallbackData?: CardManagementData
): CardManagementData { ): CardManagementData {
const changeRate = calculateChangeRate(summaryApi.current_month_total, summaryApi.previous_month_total); const breakdown = loanApi?.category_breakdown;
const totalOutstanding = loanApi?.summary?.total_outstanding ?? 0;
// cm2: 가지급금 금액 (LoanDashboard API 또는 fallback) // 카테고리별 금액 추출
const loanAmount = loanApi?.summary?.total_outstanding ?? fallbackData?.cards[1]?.amount ?? 0; const cardAmount = breakdown?.card?.outstanding_amount ?? fallbackData?.cards[0]?.amount ?? 0;
const congratulatoryAmount = breakdown?.congratulatory?.outstanding_amount ?? fallbackData?.cards[1]?.amount ?? 0;
const giftCertificateAmount = breakdown?.gift_certificate?.outstanding_amount ?? fallbackData?.cards[2]?.amount ?? 0;
const entertainmentAmount = breakdown?.entertainment?.outstanding_amount ?? fallbackData?.cards[3]?.amount ?? 0;
// cm3: 법인세 예상 가중 (TaxSimulation API 또는 fallback) // 카테고리별 미증빙/미정리 건수
const corporateTaxDifference = taxApi?.corporate_tax?.difference ?? fallbackData?.cards[2]?.amount ?? 0; const cardUnverified = breakdown?.card?.unverified_count ?? 0;
const congratulatoryUnverified = breakdown?.congratulatory?.unverified_count ?? 0;
const giftCertificateUnverified = breakdown?.gift_certificate?.unverified_count ?? 0;
const entertainmentUnverified = breakdown?.entertainment?.unverified_count ?? 0;
// cm4: 대표자 종합세 예상 가중 (TaxSimulation API 또는 fallback) // 총 합계 (API summary 또는 카테고리 합산)
const incomeTaxDifference = taxApi?.income_tax?.difference ?? fallbackData?.cards[3]?.amount ?? 0; const totalAmount = totalOutstanding > 0
? totalOutstanding
: cardAmount + congratulatoryAmount + giftCertificateAmount + entertainmentAmount;
// 가지급금 경고 배너 표시 여부 결정 (가지급금 잔액 > 0이면 표시) // 가지급금 경고 배너 표시 여부 (가지급금 잔액 > 0이면 표시)
const hasLoanWarning = loanAmount > 0; const hasLoanWarning = totalAmount > 0;
return { return {
// 가지급금 관련 경고 배너 (가지급금 있을 때만 표시) warningBanner: hasLoanWarning
warningBanner: hasLoanWarning ? fallbackData?.warningBanner : undefined, ? (fallbackData?.warningBanner ?? '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의')
: undefined,
cards: [ cards: [
// cm1: 카드 사용액 (CardTransaction API) // cm1: 카드
{ {
id: 'cm1', id: 'cm1',
label: '카드', label: '카드',
amount: summaryApi.current_month_total, amount: cardAmount,
previousLabel: `전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`, previousLabel: cardUnverified > 0 ? `미정리 ${cardUnverified}` : undefined,
}, },
// cm2: 가지급금 (LoanDashboard API) // cm2: 경조사
{ {
id: 'cm2', id: 'cm2',
label: '가지급금', label: '경조사',
amount: loanAmount, amount: congratulatoryAmount,
previousLabel: congratulatoryUnverified > 0 ? `미증빙 ${congratulatoryUnverified}` : undefined,
}, },
// cm3: 법인세 예상 가중 (TaxSimulation API) // cm3: 상품권
{ {
id: 'cm3', id: 'cm3',
label: '법인세 예상 가중', label: '상품권',
amount: corporateTaxDifference, amount: giftCertificateAmount,
previousLabel: giftCertificateUnverified > 0 ? `미증빙 ${giftCertificateUnverified}` : undefined,
}, },
// cm4: 대표자 종합세 예상 가중 (TaxSimulation API) // cm4: 접대비
{ {
id: 'cm4', id: 'cm4',
label: '대표자 종합세 예상 가중', label: '접대비',
amount: incomeTaxDifference, amount: entertainmentAmount,
previousLabel: entertainmentUnverified > 0 ? `미증빙 ${entertainmentUnverified}` : undefined,
},
// cm_total: 총 가지급금 합계
{
id: 'cm_total',
label: '총 가지급금 합계',
amount: totalAmount,
}, },
], ],
checkPoints: generateCardManagementCheckPoints(summaryApi), checkPoints: generateCardManagementCheckPoints(loanApi, taxApi, summaryApi),
}; };
} }

View File

@@ -9,83 +9,48 @@ import type {
CheckPoint, CheckPoint,
CheckPointType, CheckPointType,
} from '@/components/business/CEODashboard/types'; } from '@/components/business/CEODashboard/types';
import { formatAmount, normalizePath } from './common'; import { formatAmount, normalizePath, validateHighlightColor } from './common';
// ============================================ // ============================================
// 미수금 (Receivable) // 미수금 (Receivable) — D1.7 cards + check_points 구조
// ============================================ // ============================================
/**
* 미수금 현황 CheckPoints 생성
*/
function generateReceivableCheckPoints(api: ReceivablesApiResponse): CheckPoint[] {
const checkPoints: CheckPoint[] = [];
// 연체 거래처 경고
if (api.overdue_vendor_count > 0) {
checkPoints.push({
id: 'rv-overdue',
type: 'warning' as CheckPointType,
message: `연체 거래처 ${api.overdue_vendor_count}곳. 회수 조치가 필요합니다.`,
highlights: [
{ text: `연체 거래처 ${api.overdue_vendor_count}`, color: 'red' as const },
],
});
}
// 미수금 현황
if (api.total_receivables > 0) {
checkPoints.push({
id: 'rv-total',
type: 'info' as CheckPointType,
message: `총 미수금 ${formatAmount(api.total_receivables)}입니다.`,
highlights: [
{ text: formatAmount(api.total_receivables), color: 'blue' as const },
],
});
}
return checkPoints;
}
/** /**
* Receivables API 응답 → Frontend 타입 변환 * Receivables API 응답 → Frontend 타입 변환
* 백엔드에서 cards[] + check_points[] 구조로 직접 전달
*/ */
export function transformReceivableResponse(api: ReceivablesApiResponse): ReceivableData { export function transformReceivableResponse(api: ReceivablesApiResponse): ReceivableData {
// 누적 미수금 = 이월 + 매출 - 입금
const cumulativeReceivable = api.total_carry_forward + api.total_sales - api.total_deposits;
return { return {
cards: [ cards: api.cards.map((card) => ({
{ id: card.id,
id: 'rv1', label: card.label,
label: '누적 미수금', amount: card.amount,
amount: cumulativeReceivable, subLabel: card.subLabel,
subItems: [ unit: card.unit,
{ label: '이월', value: api.total_carry_forward }, // sub_items → subItems 매핑
{ label: '매출', value: api.total_sales }, subItems: card.sub_items?.map((item) => ({
{ label: '입금', value: api.total_deposits }, label: item.label,
], value: item.value,
}, })),
{ // top_items → subItems 매핑 (Top 3 거래처)
id: 'rv2', ...(card.top_items && card.top_items.length > 0
label: '당월 미수금', ? {
amount: api.total_receivables, subItems: card.top_items.map((item, idx) => ({
subItems: [ label: `${idx + 1}. ${item.name}`,
{ label: '매출', value: api.total_sales }, value: item.amount,
{ label: '입금', value: api.total_deposits }, })),
], }
}, : {}),
{ })),
id: 'rv3', checkPoints: api.check_points.map((cp) => ({
label: '거래처 현황', id: cp.id,
amount: api.vendor_count, type: cp.type as CheckPointType,
unit: '곳', message: cp.message,
subLabel: `연체 ${api.overdue_vendor_count}`, highlights: cp.highlights?.map((h) => ({
}, text: h.text,
], color: validateHighlightColor(h.color),
checkPoints: generateReceivableCheckPoints(api), })),
//detailButtonLabel: '미수금 상세', })),
detailButtonPath: normalizePath('/accounting/receivables-status'), detailButtonPath: normalizePath('/accounting/receivables-status'),
}; };
} }

View File

@@ -4,7 +4,9 @@
import type { import type {
VatApiResponse, VatApiResponse,
VatDetailApiResponse,
EntertainmentApiResponse, EntertainmentApiResponse,
EntertainmentDetailApiResponse,
WelfareApiResponse, WelfareApiResponse,
WelfareDetailApiResponse, WelfareDetailApiResponse,
} from '../types'; } from '../types';
@@ -47,6 +49,90 @@ export function transformVatResponse(api: VatApiResponse): VatData {
}; };
} }
// ============================================
// 부가세 상세 (VatDetail)
// ============================================
/**
* VatDetail API 응답 → DetailModalConfig 변환
* 부가세 상세 모달 설정 생성
*/
export function transformVatDetailResponse(api: VatDetailApiResponse): DetailModalConfig {
const { period_label, period_options, summary, reference_table, unissued_invoices } = api;
// 참조 테이블 행 구성: direction(invoice_type) 형식 + 납부세액 행
const refRows = reference_table.map(row => ({
category: `${row.direction_label}(${row.invoice_type_label})`,
supplyAmount: formatNumber(row.supply_amount) + '원',
taxAmount: formatNumber(row.tax_amount) + '원',
}));
// 납부세액 행 추가
const paymentLabel = summary.is_refund ? '환급세액' : '납부세액';
refRows.push({
category: paymentLabel,
supplyAmount: '',
taxAmount: formatNumber(summary.estimated_payment) + '원',
});
return {
title: '예상 납부세액',
periodSelect: {
enabled: true,
options: period_options.map(opt => ({ value: opt.value, label: opt.label })),
defaultValue: period_options[0]?.value,
},
summaryCards: [
{ label: '매출 공급가액', value: summary.sales_supply_amount, unit: '원' },
{ label: '매입 공급가액', value: summary.purchases_supply_amount, unit: '원' },
{ label: summary.is_refund ? '예상 환급세액' : '예상 납부세액', value: summary.estimated_payment, unit: '원' },
],
referenceTable: {
title: `${period_label} 부가세 요약`,
columns: [
{ key: 'category', label: '구분', align: 'left' },
{ key: 'supplyAmount', label: '공급가액', align: 'right' },
{ key: 'taxAmount', label: '세액', align: 'right' },
],
data: refRows,
},
table: {
title: '세금계산서 미발행/미수취 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'type', label: '구분', align: 'center' },
{ key: 'issueDate', label: '발생일자', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처', align: 'left' },
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
{ key: 'invoiceStatus', label: '세금계산서 미발행/미수취', align: 'center' },
],
data: unissued_invoices.map((inv, idx) => ({
no: idx + 1,
type: inv.direction_label,
issueDate: inv.issue_date,
vendor: inv.vendor_name,
vat: inv.tax_amount,
invoiceStatus: inv.status,
})),
filters: [
{
key: 'type',
options: [
{ value: 'all', label: '전체' },
{ value: '매출', label: '매출' },
{ value: '매입', label: '매입' },
],
defaultValue: 'all',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: unissued_invoices.reduce((sum, inv) => sum + inv.tax_amount, 0),
totalColumnKey: 'vat',
},
};
}
// ============================================ // ============================================
// 접대비 (Entertainment) // 접대비 (Entertainment)
// ============================================ // ============================================
@@ -56,17 +142,8 @@ export function transformVatResponse(api: VatApiResponse): VatData {
* 접대비 현황 데이터 변환 * 접대비 현황 데이터 변환
*/ */
export function transformEntertainmentResponse(api: EntertainmentApiResponse): EntertainmentData { export function transformEntertainmentResponse(api: EntertainmentApiResponse): EntertainmentData {
// 사용금액(et_used)을 잔여한도(et_remaining) 앞으로 재배치
const reordered = [...api.cards];
const usedIdx = reordered.findIndex((c) => c.id === 'et_used');
const remainIdx = reordered.findIndex((c) => c.id === 'et_remaining');
if (usedIdx > remainIdx && remainIdx >= 0) {
const [used] = reordered.splice(usedIdx, 1);
reordered.splice(remainIdx, 0, used);
}
return { return {
cards: reordered.map((card) => ({ cards: api.cards.map((card) => ({
id: card.id, id: card.id,
label: card.label, label: card.label,
amount: card.amount, amount: card.amount,
@@ -94,17 +171,8 @@ export function transformEntertainmentResponse(api: EntertainmentApiResponse): E
* 복리후생비 현황 데이터 변환 * 복리후생비 현황 데이터 변환
*/ */
export function transformWelfareResponse(api: WelfareApiResponse): WelfareData { export function transformWelfareResponse(api: WelfareApiResponse): WelfareData {
// 사용금액(wf_used)을 잔여한도(wf_remaining) 앞으로 재배치
const reordered = [...api.cards];
const usedIdx = reordered.findIndex((c) => c.id === 'wf_used');
const remainIdx = reordered.findIndex((c) => c.id === 'wf_remaining');
if (usedIdx > remainIdx && remainIdx >= 0) {
const [used] = reordered.splice(usedIdx, 1);
reordered.splice(remainIdx, 0, used);
}
return { return {
cards: reordered.map((card) => ({ cards: api.cards.map((card) => ({
id: card.id, id: card.id,
label: card.label, label: card.label,
amount: card.amount, amount: card.amount,
@@ -131,6 +199,183 @@ export function transformWelfareResponse(api: WelfareApiResponse): WelfareData {
* WelfareDetail API 응답 → DetailModalConfig 변환 * WelfareDetail API 응답 → DetailModalConfig 변환
* 복리후생비 상세 모달 설정 생성 * 복리후생비 상세 모달 설정 생성
*/ */
// ============================================
// 접대비 상세 (EntertainmentDetail)
// ============================================
/**
* EntertainmentDetail API 응답 → DetailModalConfig 변환
* 접대비 상세 모달 설정 생성
*/
export function transformEntertainmentDetailResponse(api: EntertainmentDetailApiResponse): DetailModalConfig {
const { summary, risk_review, monthly_usage, user_distribution, transactions, calculation, quarterly } = api;
// 법인 유형 라벨
const companyTypeLabel = calculation.company_type === 'large' ? '일반법인' : '중소기업';
return {
title: '접대비 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [
{ label: '당해년도 접대비 총 한도', value: summary.annual_limit, unit: '원' },
{ label: '당해년도 접대비 잔여한도', value: summary.annual_remaining, unit: '원' },
{ label: '당해년도 접대비 사용금액', value: summary.annual_used, unit: '원' },
{ label: '당해년도 접대비 초과 금액', value: summary.annual_exceeded, unit: '원' },
],
reviewCards: {
title: '접대비 검토 필요',
cards: risk_review.map(r => ({
label: r.label,
amount: r.amount,
subLabel: r.label === '기피업종'
? (r.count > 0 ? `불인정 ${r.count}` : '0건')
: (r.count > 0 ? `미증빙 ${r.count}` : '0건'),
})),
},
barChart: {
title: '월별 접대비 사용 추이',
data: monthly_usage.map(item => ({
name: item.label,
value: item.amount,
})),
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '사용자별 접대비 사용 비율',
data: user_distribution.map(item => ({
name: item.user_name,
value: item.amount,
percentage: item.percentage,
color: item.color,
})),
},
table: {
title: '월별 접대비 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' },
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'riskType', label: '리스크', align: 'center' },
],
data: transactions.map((tx, idx) => ({
no: idx + 1,
cardName: tx.card_name,
user: tx.user_name,
useDate: tx.expense_date,
store: tx.vendor_name,
amount: tx.amount,
riskType: tx.risk_type,
})),
filters: [
{
key: 'riskType',
options: [
{ value: 'all', label: '전체' },
{ value: '주말/심야', label: '주말/심야' },
{ value: '기피업종', label: '기피업종' },
{ value: '고액 결제', label: '고액 결제' },
{ value: '증빙 미비', label: '증빙 미비' },
{ value: '정상', label: '정상' },
],
defaultValue: 'all',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: transactions.reduce((sum, tx) => sum + tx.amount, 0),
totalColumnKey: 'amount',
},
referenceTables: [
{
title: '접대비 손금한도 계산 - 기본한도',
columns: [
{ key: 'type', label: '법인 유형', align: 'left' },
{ key: 'annualLimit', label: '연간 기본한도', align: 'right' },
{ key: 'monthlyLimit', label: '월 환산', align: 'right' },
],
data: [
{ 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: 'formula', label: '추가한도 계산식', align: 'left' },
],
data: [
{ range: '100억원 이하', formula: '수입금액 × 0.2%' },
{ range: '100억 초과 ~ 500억 이하', formula: '2,000만원 + (수입금액 - 100억) × 0.1%' },
{ range: '500억원 초과', formula: '6,000만원 + (수입금액 - 500억) × 0.03%' },
],
},
],
calculationCards: {
title: '접대비 계산',
cards: [
{ label: `${companyTypeLabel} 연간 기본한도`, value: calculation.base_limit },
{ label: '당해년도 수입금액별 추가한도', value: calculation.revenue_additional, operator: '+' as const },
{ label: '당해년도 접대비 총 한도', value: calculation.annual_limit, operator: '=' as const },
],
},
quarterlyTable: {
title: '접대비 현황',
rows: [
{
label: '한도금액',
q1: quarterly[0]?.limit ?? 0,
q2: quarterly[1]?.limit ?? 0,
q3: quarterly[2]?.limit ?? 0,
q4: quarterly[3]?.limit ?? 0,
total: quarterly.reduce((sum, q) => sum + (q.limit ?? 0), 0),
},
{
label: '이월금액',
q1: quarterly[0]?.carryover ?? 0,
q2: quarterly[1]?.carryover ?? '',
q3: quarterly[2]?.carryover ?? '',
q4: quarterly[3]?.carryover ?? '',
total: '',
},
{
label: '사용금액',
q1: quarterly[0]?.used ?? '',
q2: quarterly[1]?.used ?? '',
q3: quarterly[2]?.used ?? '',
q4: quarterly[3]?.used ?? '',
total: quarterly.reduce((sum, q) => sum + (q.used ?? 0), 0) || '',
},
{
label: '잔여한도',
q1: quarterly[0]?.remaining ?? '',
q2: quarterly[1]?.remaining ?? '',
q3: quarterly[2]?.remaining ?? '',
q4: quarterly[3]?.remaining ?? '',
total: '',
},
{
label: '초과금액',
q1: quarterly[0]?.exceeded ?? '',
q2: quarterly[1]?.exceeded ?? '',
q3: quarterly[2]?.exceeded ?? '',
q4: quarterly[3]?.exceeded ?? '',
total: quarterly.reduce((sum, q) => sum + (q.exceeded ?? 0), 0) || '',
},
],
},
};
}
export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): DetailModalConfig { export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): DetailModalConfig {
const { summary, monthly_usage, category_distribution, transactions, calculation, quarterly } = api; const { summary, monthly_usage, category_distribution, transactions, calculation, quarterly } = api;

View File

@@ -49,18 +49,50 @@ export interface DailyReportApiResponse {
} }
// ============================================ // ============================================
// 2. Receivables API 응답 타입 // 2. Receivables API 응답 타입 (D1.7 cards + check_points 구조)
// ============================================ // ============================================
/** 미수금 카드 서브 아이템 */
export interface ReceivablesCardSubItem {
label: string;
value: number;
}
/** 미수금 Top 거래처 아이템 */
export interface ReceivablesTopItem {
name: string;
amount: number;
}
/** 미수금 금액 카드 아이템 */
export interface ReceivablesAmountCardApiResponse {
id: string;
label: string;
amount: number;
subLabel?: string;
unit?: string;
sub_items?: ReceivablesCardSubItem[];
top_items?: ReceivablesTopItem[];
}
/** 미수금 체크포인트 하이라이트 아이템 */
export interface ReceivablesHighlightItemApiResponse {
text: string;
color: string;
}
/** 미수금 체크포인트 아이템 */
export interface ReceivablesCheckPointApiResponse {
id: string;
type: string;
message: string;
highlights?: ReceivablesHighlightItemApiResponse[];
}
/** GET /api/proxy/receivables/summary 응답 */ /** GET /api/proxy/receivables/summary 응답 */
export interface ReceivablesApiResponse { export interface ReceivablesApiResponse {
total_carry_forward: number; // 이월 미수금 cards: ReceivablesAmountCardApiResponse[];
total_sales: number; // 당월 매출 check_points: ReceivablesCheckPointApiResponse[];
total_deposits: number; // 당월 입금
total_bills: number; // 당월 어음
total_receivables: number; // 미수금 잔액
vendor_count: number; // 거래처 수
overdue_vendor_count: number; // 연체 거래처 수
} }
// ============================================ // ============================================
@@ -280,6 +312,56 @@ export interface VatApiResponse {
check_points: VatCheckPointApiResponse[]; check_points: VatCheckPointApiResponse[];
} }
// ============================================
// 9-1. Vat Detail (부가세 상세) API 응답 타입
// ============================================
/** 부가세 상세 요약 */
export interface VatDetailSummaryApiResponse {
sales_supply_amount: number; // 매출 공급가액
sales_tax_amount: number; // 매출 세액
purchases_supply_amount: number; // 매입 공급가액
purchases_tax_amount: number; // 매입 세액
estimated_payment: number; // 예상 납부세액
is_refund: boolean; // 환급 여부
}
/** 부가세 요약 테이블 행 */
export interface VatReferenceTableRowApiResponse {
direction: string; // sales | purchases
direction_label: string; // 매출 | 매입
invoice_type: string; // tax_invoice | invoice | modified
invoice_type_label: string; // 전자세금계산서 | 계산서 | 수정세금계산서
supply_amount: number; // 공급가액
tax_amount: number; // 세액
}
/** 미발행/미수취 세금계산서 */
export interface VatUnissuedInvoiceApiResponse {
id: number;
direction: string;
direction_label: string; // 매출 | 매입
issue_date: string; // 발생일자
vendor_name: string; // 거래처명
tax_amount: number; // 부가세
status: string; // 미발행 | 미수취
}
/** 신고기간 옵션 */
export interface VatPeriodOptionApiResponse {
value: string; // "2026-quarter-1"
label: string; // "2026년 1기 예정신고"
}
/** GET /api/proxy/vat/detail 응답 */
export interface VatDetailApiResponse {
period_label: string;
period_options: VatPeriodOptionApiResponse[];
summary: VatDetailSummaryApiResponse;
reference_table: VatReferenceTableRowApiResponse[];
unissued_invoices: VatUnissuedInvoiceApiResponse[];
}
// ============================================ // ============================================
// 10. Entertainment (접대비) API 응답 타입 // 10. Entertainment (접대비) API 응답 타입
// ============================================ // ============================================
@@ -346,6 +428,81 @@ export interface WelfareApiResponse {
check_points: WelfareCheckPointApiResponse[]; check_points: WelfareCheckPointApiResponse[];
} }
// ============================================
// 11-1. Entertainment Detail (접대비 상세) API 응답 타입
// ============================================
/** 접대비 상세 요약 */
export interface EntertainmentDetailSummaryApiResponse {
annual_limit: number; // 당해년도 접대비 총 한도
annual_remaining: number; // 당해년도 접대비 잔여한도
annual_used: number; // 당해년도 접대비 사용금액
annual_exceeded: number; // 당해년도 접대비 초과 금액
}
/** 접대비 리스크 검토 카드 */
export interface EntertainmentRiskReviewApiResponse {
label: string; // 주말/심야, 기피업종, 고액 결제, 증빙 미비
amount: number; // 금액
count: number; // 건수
}
/** 접대비 월별 사용 추이 */
export interface EntertainmentMonthlyUsageApiResponse {
month: number; // 1~12
label: string; // "1월"
amount: number; // 사용 금액
}
/** 접대비 사용자별 분포 */
export interface EntertainmentUserDistributionApiResponse {
user_name: string; // 사용자명
amount: number; // 금액
percentage: number; // 비율 (%)
color: string; // 차트 색상
}
/** 접대비 거래 내역 */
export interface EntertainmentTransactionApiResponse {
id: number;
card_name: string; // 카드명
user_name: string; // 사용자명
expense_date: string; // 사용일자
vendor_name: string; // 가맹점명
amount: number; // 사용금액
risk_type: string; // 리스크 유형 (주말/심야, 기피업종, 고액 결제, 증빙 미비, 정상)
}
/** 접대비 손금한도 계산 정보 */
export interface EntertainmentCalculationApiResponse {
company_type: string; // 법인 유형 (large|medium|small)
base_limit: number; // 기본한도
revenue: number; // 수입금액
revenue_additional: number; // 수입금액별 추가한도
annual_limit: number; // 연간 총 한도
}
/** 접대비 분기별 현황 */
export interface EntertainmentQuarterlyStatusApiResponse {
quarter: number; // 분기 (1-4)
limit: number; // 한도금액
carryover: number; // 이월금액
used: number; // 사용금액
remaining: number; // 잔여한도
exceeded: number; // 초과금액
}
/** GET /api/proxy/entertainment/detail 응답 */
export interface EntertainmentDetailApiResponse {
summary: EntertainmentDetailSummaryApiResponse;
risk_review: EntertainmentRiskReviewApiResponse[];
monthly_usage: EntertainmentMonthlyUsageApiResponse[];
user_distribution: EntertainmentUserDistributionApiResponse[];
transactions: EntertainmentTransactionApiResponse[];
calculation: EntertainmentCalculationApiResponse;
quarterly: EntertainmentQuarterlyStatusApiResponse[];
}
// ============================================ // ============================================
// 12. Welfare Detail (복리후생비 상세) API 응답 타입 // 12. Welfare Detail (복리후생비 상세) API 응답 타입
// ============================================ // ============================================
@@ -617,9 +774,8 @@ export interface ExpectedExpenseDashboardDetailApiResponse {
/** 가지급금 대시보드 요약 */ /** 가지급금 대시보드 요약 */
export interface LoanDashboardSummaryApiResponse { export interface LoanDashboardSummaryApiResponse {
total_outstanding: number; // 미정산 잔액 total_outstanding: number; // 미정산 잔액
settled_amount: number; // 정산 완료 금액
recognized_interest: number; // 인정이자 recognized_interest: number; // 인정이자
pending_count: number; // 미정산 건수 outstanding_count: number; // 미정산 건수
} }
/** 가지급금 월별 추이 */ /** 가지급금 월별 추이 */
@@ -644,17 +800,24 @@ export interface LoanItemApiResponse {
user_name: string; // 사용자명 user_name: string; // 사용자명
loan_date: string; // 가지급일 loan_date: string; // 가지급일
amount: number; // 금액 amount: number; // 금액
description: string; // 설명 content: string; // 내용 (백엔드 필드명)
category: string; // 카테고리 라벨 (카드/경조사/상품권/접대비)
status: string; // 상태 코드 status: string; // 상태 코드
status_label: string; // 상태 라벨 status_label?: string; // 상태 라벨 (optional - dashboard()에서 미반환 가능)
}
/** 가지급금 카테고리별 집계 (D1.7) */
export interface LoanCategoryBreakdown {
outstanding_amount: number;
total_count: number;
unverified_count: number;
} }
/** GET /api/v1/loans/dashboard 응답 */ /** GET /api/v1/loans/dashboard 응답 */
export interface LoanDashboardApiResponse { export interface LoanDashboardApiResponse {
summary: LoanDashboardSummaryApiResponse; summary: LoanDashboardSummaryApiResponse;
monthly_trend: LoanMonthlyTrendApiResponse[]; category_breakdown?: Record<string, LoanCategoryBreakdown>;
user_distribution: LoanUserDistributionApiResponse[]; loans: LoanItemApiResponse[];
items: LoanItemApiResponse[];
} }
// ============================================ // ============================================