feat: CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 개선 및 회계 페이지 확장
- 접대비/복리후생비 섹션: 리스크감지형 구조로 변경 - 매출채권 섹션: transformer/타입 정비 - 캘린더 섹션: ScheduleDetailModal 개선 - 카드관리 모달 transformer 확장 - useCEODashboard 훅 리팩토링 및 정리 - dashboard endpoints/types/transformers (expense, receivable, tax-benefits) 대폭 확장 - 회계 5개 페이지(은행거래, 카드거래, 매출채권, 세금계산서, 거래처원장) 기능 개선 - ApprovalBox 소폭 수정 - CLAUDE.md 업데이트
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -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]
|
||||||
### 현재 문제: [설명]
|
### 목적: [설명]
|
||||||
### 수정 요청: [내용]
|
### 요청/응답 구조: [내용]
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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('엑셀 파일 생성 중...');
|
||||||
startDate,
|
const allData: BankTransaction[] = [];
|
||||||
endDate,
|
let page = 1;
|
||||||
accountCategory: accountCategoryFilter,
|
let lastPage = 1;
|
||||||
financialInstitution: financialInstitutionFilter,
|
|
||||||
});
|
do {
|
||||||
if (result.success && result.data) {
|
const result = await getBankTransactionList({
|
||||||
window.open(result.data.downloadUrl, '_blank');
|
startDate,
|
||||||
|
endDate,
|
||||||
|
accountCategory: accountCategoryFilter,
|
||||||
|
financialInstitution: financialInstitutionFilter,
|
||||||
|
perPage: 100,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
if (result.success && result.data.length > 0) {
|
||||||
|
allData.push(...result.data);
|
||||||
|
lastPage = result.pagination?.lastPage ?? 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
} while (page <= lastPage);
|
||||||
|
|
||||||
|
if (allData.length > 0) {
|
||||||
|
await downloadExcel({
|
||||||
|
data: allData as (BankTransaction & Record<string, unknown>)[],
|
||||||
|
columns: excelColumns,
|
||||||
|
filename: '계좌입출금내역',
|
||||||
|
sheetName: '입출금내역',
|
||||||
|
});
|
||||||
|
toast.success('엑셀 다운로드 완료');
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
toast.warning('다운로드할 데이터가 없습니다.');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||||
}
|
}
|
||||||
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);
|
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
const columns: ExcelColumn<Record<string, unknown>>[] = [
|
||||||
a.download = result.filename || '채권현황.xlsx';
|
{ header: '거래처', key: 'vendorName', width: 20 },
|
||||||
document.body.appendChild(a);
|
{ header: '연체', key: 'isOverdue', width: 8 },
|
||||||
a.click();
|
...monthLabels.map((label, idx) => ({
|
||||||
document.body.removeChild(a);
|
header: label, key: `month_${idx}`, width: 12,
|
||||||
URL.revokeObjectURL(url);
|
})),
|
||||||
toast.success('엑셀 파일이 다운로드되었습니다.');
|
{ header: '합계', key: 'total', width: 14 },
|
||||||
} else {
|
{ header: '메모', key: 'memo', width: 20 },
|
||||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
];
|
||||||
|
// 미수금 카테고리 기준으로 플랫 데이터 생성
|
||||||
|
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('엑셀 다운로드에 실패했습니다.');
|
||||||
}
|
}
|
||||||
}, [selectedYear, searchQuery]);
|
}, [sortedData, monthLabels]);
|
||||||
|
|
||||||
// ===== 변경된 연체 항목 확인 =====
|
// ===== 변경된 연체 항목 확인 =====
|
||||||
const changedOverdueItems = useMemo(() => {
|
const changedOverdueItems = useMemo(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
division: activeTab,
|
toast.info('엑셀 파일 생성 중...');
|
||||||
dateType,
|
const allData: TaxInvoiceMgmtRecord[] = [];
|
||||||
startDate,
|
let page = 1;
|
||||||
endDate,
|
let lastPage = 1;
|
||||||
vendorSearch,
|
|
||||||
});
|
do {
|
||||||
if (result.success && result.data) {
|
const result = await getTaxInvoices({
|
||||||
window.open(result.data.url, '_blank');
|
division: activeTab,
|
||||||
} else {
|
dateType,
|
||||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
startDate,
|
||||||
|
endDate,
|
||||||
|
vendorSearch,
|
||||||
|
page,
|
||||||
|
perPage: 100,
|
||||||
|
});
|
||||||
|
if (result.success && result.data.length > 0) {
|
||||||
|
allData.push(...result.data);
|
||||||
|
lastPage = result.pagination?.lastPage ?? 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
} while (page <= lastPage);
|
||||||
|
|
||||||
|
if (allData.length > 0) {
|
||||||
|
await downloadExcel({
|
||||||
|
data: allData as (TaxInvoiceMgmtRecord & Record<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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
startDate,
|
toast.info('엑셀 파일 생성 중...');
|
||||||
endDate,
|
const allData: VendorLedgerItem[] = [];
|
||||||
search: searchQuery || undefined,
|
let page = 1;
|
||||||
});
|
let lastPage = 1;
|
||||||
|
|
||||||
if (result.success && result.data) {
|
do {
|
||||||
const url = URL.createObjectURL(result.data);
|
const result = await getVendorLedgerList({
|
||||||
const a = document.createElement('a');
|
startDate,
|
||||||
a.href = url;
|
endDate,
|
||||||
a.download = result.filename || '거래처원장.xlsx';
|
search: searchQuery || undefined,
|
||||||
document.body.appendChild(a);
|
perPage: 100,
|
||||||
a.click();
|
page,
|
||||||
document.body.removeChild(a);
|
});
|
||||||
URL.revokeObjectURL(url);
|
if (result.success && result.data.length > 0) {
|
||||||
toast.success('엑셀 파일이 다운로드되었습니다.');
|
allData.push(...result.data);
|
||||||
} else {
|
lastPage = result.pagination?.lastPage ?? 1;
|
||||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
} else {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
if (config) {
|
const modalData = await cardManagementModals.fetchModalData('cm2');
|
||||||
setDetailModalConfig(config);
|
const config = getCardManagementModalConfigWithData('cm2', modalData);
|
||||||
setIsDetailModalOpen(true);
|
if (config) {
|
||||||
|
setCurrentModalCardId('cm2');
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API 실패 시 fallback mock 데이터 사용
|
||||||
|
const config = getCardManagementModalConfig('cm2');
|
||||||
|
if (config) {
|
||||||
|
setCurrentModalCardId('cm2');
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [cardManagementModals]);
|
||||||
|
|
||||||
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
|
// 접대비 현황 카드 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
|
||||||
const handleEntertainmentCardClick = useCallback((cardId: string) => {
|
const handleEntertainmentCardClick = useCallback(async (cardId: string) => {
|
||||||
const config = getEntertainmentModalConfig(cardId);
|
// et_sales 카드는 별도 정적 config 사용 (매출 상세)
|
||||||
|
if (cardId === 'et_sales') {
|
||||||
|
const config = getEntertainmentModalConfig(cardId);
|
||||||
|
if (config) {
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리스크 카드 → API에서 상세 데이터 fetch, 반환값 직접 사용
|
||||||
|
setCurrentModalCardId('entertainment_detail');
|
||||||
|
const apiConfig = await entertainmentDetailData.refetch();
|
||||||
|
const config = apiConfig ?? getEntertainmentModalConfig(cardId);
|
||||||
if (config) {
|
if (config) {
|
||||||
setDetailModalConfig(config);
|
setDetailModalConfig(config);
|
||||||
setIsDetailModalOpen(true);
|
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,
|
||||||
setIsScheduleModalOpen(false);
|
start_date: formData.startDate,
|
||||||
setSelectedSchedule(null);
|
end_date: formData.endDate,
|
||||||
}, []);
|
start_time: formData.isAllDay ? null : (formData.startTime || null),
|
||||||
|
end_time: formData.isAllDay ? null : (formData.endTime || null),
|
||||||
|
is_all_day: formData.isAllDay,
|
||||||
|
color: formData.color || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = numericId
|
||||||
|
? `/api/proxy/calendar/schedules/${numericId}`
|
||||||
|
: '/api/proxy/calendar/schedules';
|
||||||
|
const method = numericId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to save schedule');
|
||||||
|
|
||||||
|
// API 응답에서 실제 ID 추출 (없으면 임시 ID)
|
||||||
|
let savedId = numericId;
|
||||||
|
try {
|
||||||
|
const result = await response.json();
|
||||||
|
savedId = result.data?.id?.toString() || numericId || `temp_${Date.now()}`;
|
||||||
|
} catch {
|
||||||
|
savedId = numericId || `temp_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSchedule: CalendarScheduleItem = {
|
||||||
|
id: `schedule_${savedId}`,
|
||||||
|
title: formData.title,
|
||||||
|
startDate: formData.startDate,
|
||||||
|
endDate: formData.endDate,
|
||||||
|
startTime: formData.isAllDay ? undefined : formData.startTime,
|
||||||
|
endTime: formData.isAllDay ? undefined : formData.endTime,
|
||||||
|
isAllDay: formData.isAllDay,
|
||||||
|
type: 'schedule',
|
||||||
|
department: formData.department !== 'all' ? formData.department : undefined,
|
||||||
|
color: formData.color,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistic update: loading 변화 없이 데이터만 갱신 → 캘린더만 리렌더
|
||||||
|
calendarData.setData((prev) => {
|
||||||
|
if (!prev) return { items: [updatedSchedule], totalCount: 1 };
|
||||||
|
if (numericId) {
|
||||||
|
// 수정: 기존 항목 교체
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((item) =>
|
||||||
|
item.id === rawId ? updatedSchedule : item
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 신규: 추가
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: [...prev.items, updatedSchedule],
|
||||||
|
totalCount: prev.totalCount + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 에러 시 서버 데이터로 동기화
|
||||||
|
calendarData.refetch();
|
||||||
|
} finally {
|
||||||
|
setIsScheduleModalOpen(false);
|
||||||
|
setSelectedSchedule(null);
|
||||||
|
}
|
||||||
|
}, [selectedSchedule, calendarData]);
|
||||||
|
|
||||||
|
// 일정 삭제 (optimistic update)
|
||||||
|
const handleScheduleDelete = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
// schedule_ 접두사에서 실제 ID 추출
|
||||||
|
const numericId = id.startsWith('schedule_') ? id.replace('schedule_', '') : id;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/proxy/calendar/schedules/${numericId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete schedule');
|
||||||
|
|
||||||
|
// Optimistic update: 삭제된 항목만 제거 → 캘린더만 리렌더
|
||||||
|
calendarData.setData((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.filter((item) => item.id !== id),
|
||||||
|
totalCount: Math.max(0, prev.totalCount - 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 에러 시 서버 데이터로 동기화
|
||||||
|
calendarData.refetch();
|
||||||
|
} finally {
|
||||||
|
setIsScheduleModalOpen(false);
|
||||||
|
setSelectedSchedule(null);
|
||||||
|
}
|
||||||
|
}, [calendarData]);
|
||||||
|
|
||||||
// 섹션 순서
|
// 섹션 순서
|
||||||
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
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_')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 항목 설정 모달 */}
|
{/* 항목 설정 모달 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
size="sm"
|
onEndDateChange={(date) => handleFieldChange('endDate', date)}
|
||||||
className="w-full"
|
size="sm"
|
||||||
/>
|
className="w-full"
|
||||||
<div className="flex items-center gap-2">
|
disabled={!isEditable}
|
||||||
<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,26 +258,35 @@ 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">
|
||||||
{isEditMode && onDelete && (
|
{isEditable ? (
|
||||||
<Button
|
<>
|
||||||
variant="outline"
|
{isEditMode && onDelete && (
|
||||||
onClick={handleDelete}
|
<Button
|
||||||
className="bg-gray-800 text-white hover:bg-gray-900"
|
variant="outline"
|
||||||
>
|
onClick={handleDelete}
|
||||||
삭제
|
className="bg-gray-800 text-white hover:bg-gray-900"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="bg-gray-800 text-white hover:bg-gray-900"
|
||||||
|
>
|
||||||
|
{isEditMode ? '수정' : '등록'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-gray-800 text-white hover:bg-gray-900"
|
|
||||||
>
|
|
||||||
{isEditMode ? '수정' : '등록'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상세 모달 전체 설정 타입
|
// 상세 모달 전체 설정 타입
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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: [],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: 인정이자 발생 현황
|
||||||
checkPoints.push({
|
if (totalOutstanding > 0 && recognizedInterest > 0) {
|
||||||
id: 'cm-current',
|
checkPoints.push({
|
||||||
type: 'info' as CheckPointType,
|
id: 'cm-cp2',
|
||||||
message: `당월 카드 사용 총 ${formatAmount(api.current_month_total)}입니다.`,
|
type: 'success' as CheckPointType,
|
||||||
highlights: [
|
message: `현재 가지급금 ${formatAmount(totalOutstanding)} × ${interestRate}% = 연간 약 ${formatAmount(recognizedInterest)}의 인정이자가 발생 중입니다.`,
|
||||||
{ text: formatAmount(api.current_month_total), color: 'blue' as const },
|
highlights: [
|
||||||
],
|
{ 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user