Files
sam-react-prod/src/lib/api/dashboard/transformers.ts

1578 lines
51 KiB
TypeScript
Raw Normal View History

/**
* CEO Dashboard API Frontend
*
* 참조: docs/plans/AI_리포트_키워드_색상체계_가이드_v1.4.md
*/
import type {
DailyReportApiResponse,
ReceivablesApiResponse,
BadDebtApiResponse,
ExpectedExpenseApiResponse,
CardTransactionApiResponse,
StatusBoardApiResponse,
TodayIssueApiResponse,
CalendarApiResponse,
VatApiResponse,
EntertainmentApiResponse,
WelfareApiResponse,
WelfareDetailApiResponse,
PurchaseDashboardDetailApiResponse,
CardDashboardDetailApiResponse,
BillDashboardDetailApiResponse,
ExpectedExpenseDashboardDetailApiResponse,
LoanDashboardApiResponse,
TaxSimulationApiResponse,
} from './types';
import type {
DailyReportData,
ReceivableData,
DebtCollectionData,
MonthlyExpenseData,
CardManagementData,
TodayIssueItem,
TodayIssueListItem,
TodayIssueListBadgeType,
TodayIssueNotificationType,
CalendarScheduleItem,
CheckPoint,
CheckPointType,
VatData,
EntertainmentData,
WelfareData,
HighlightColor,
} from '@/components/business/CEODashboard/types';
import { formatNumber } from '@/lib/utils/amount';
// ============================================
// 헬퍼 함수
// ============================================
/**
*
* - /ko prefix ( )
* - ?mode=view ()
* @example normalizePath('/accounting/deposits/73') '/ko/accounting/deposits/73?mode=view'
*/
function normalizePath(path: string | undefined, options?: { addViewMode?: boolean }): string {
if (!path) return '';
let normalizedPath = path;
// /ko prefix 추가 (없는 경우)
if (!normalizedPath.startsWith('/ko')) {
normalizedPath = `/ko${normalizedPath}`;
}
// 상세 페이지에 ?mode=view 추가 (숫자 ID가 있고 mode 파라미터가 없는 경우)
if (options?.addViewMode) {
const hasIdPattern = /\/\d+($|\?)/.test(normalizedPath);
const hasMode = /[?&]mode=/.test(normalizedPath);
const hasModal = /[?&]openModal=/.test(normalizedPath);
// ID가 있고, mode 파라미터가 없고, openModal도 없는 경우에만 ?mode=view 추가
if (hasIdPattern && !hasMode && !hasModal) {
normalizedPath = normalizedPath.includes('?')
? `${normalizedPath}&mode=view`
: `${normalizedPath}?mode=view`;
}
}
return normalizedPath;
}
/**
*
* @example formatAmount(3050000000) "30.5억원"
*/
function formatAmount(amount: number): string {
const absAmount = Math.abs(amount);
if (absAmount >= 100000000) {
return `${(amount / 100000000).toFixed(1)}억원`;
} else if (absAmount >= 10000) {
return `${formatNumber(Math.round(amount / 10000))}만원`;
}
return `${formatNumber(amount)}`;
}
/**
* (API )
* @example formatDate("2026-01-20", "월요일") "2026년 1월 20일 월요일"
*/
function formatDate(dateStr: string, dayOfWeek: string): string {
const date = new Date(dateStr);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${dayOfWeek}`;
}
/**
*
*/
function calculateChangeRate(current: number, previous: number): number {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
}
// ============================================
// 1. DailyReport 변환
// ============================================
/**
*
* 참조: AI - 2.3
*/
function getStabilityColor(stability: string): 'red' | 'green' | 'blue' {
switch (stability) {
case 'stable':
return 'blue'; // 6개월 이상 - 안정적
case 'caution':
return 'green'; // 3-6개월 - 주의 (주황 대신 green 사용, 기존 타입 호환)
case 'warning':
return 'red'; // 3개월 미만 - 경고
default:
return 'blue';
}
}
/**
*
* - 음수: 현금성
* - 0~3개월: 자금
* - 3~6개월: 자금
* - 6 이상: 안정적
*/
function getStabilityMessage(months: number | null, stability: string, cashAsset: number): string {
if (months === null) {
return '월 운영비 데이터가 없어 안정성을 판단할 수 없습니다.';
}
// 현금성 자산이 음수인 경우 (적자 상태)
if (cashAsset < 0 || months < 0) {
return '현금성 자산이 부족한 상태입니다. 긴급 자금 확보가 필요합니다.';
}
// 운영 가능 기간이 거의 없는 경우 (1개월 미만)
if (months < 1) {
return '운영 자금이 거의 소진된 상태입니다. 즉시 자금 확보가 필요합니다.';
}
const monthsText = `${months}개월분`;
switch (stability) {
case 'stable':
return `월 운영비용 대비 ${monthsText}이 확보되어 안정적입니다.`;
case 'caution':
return `월 운영비용 대비 ${monthsText}이 확보되어 있습니다. 자금 관리가 필요합니다.`;
case 'warning':
return `월 운영비용 대비 ${monthsText}만 확보되어 자금 부족 우려가 있습니다.`;
default:
return `월 운영비용 대비 ${monthsText}이 확보되어 있습니다.`;
}
}
/**
* CheckPoints
* 참조: AI - 2
*/
function generateDailyReportCheckPoints(api: DailyReportApiResponse): CheckPoint[] {
const checkPoints: CheckPoint[] = [];
// 출금 정보
const withdrawal = api.krw_totals.expense;
if (withdrawal > 0) {
checkPoints.push({
id: 'dr-withdrawal',
type: 'info' as CheckPointType,
message: `어제 ${formatAmount(withdrawal)} 출금했습니다.`,
highlights: [
{ text: formatAmount(withdrawal), color: 'red' as const },
],
});
}
// 입금 정보
const deposit = api.krw_totals.income;
if (deposit > 0) {
checkPoints.push({
id: 'dr-deposit',
type: 'success' as CheckPointType,
message: `어제 ${formatAmount(deposit)}이 입금되었습니다.`,
highlights: [
{ text: formatAmount(deposit), color: 'green' as const },
{ text: '입금', color: 'green' as const },
],
});
}
// 현금성 자산 + 운영자금 안정성 현황
const cashAsset = api.cash_asset_total;
const operatingMonths = api.operating_months;
const operatingStability = api.operating_stability;
const stabilityColor = getStabilityColor(operatingStability);
const stabilityMessage = getStabilityMessage(operatingMonths, operatingStability, cashAsset);
// 하이라이트 생성 (음수/적자 상태일 때는 "X개월분" 대신 다른 메시지)
const isDeficit = cashAsset < 0 || (operatingMonths !== null && operatingMonths < 0);
const isAlmostEmpty = operatingMonths !== null && operatingMonths >= 0 && operatingMonths < 1;
const highlights: Array<{ text: string; color: 'red' | 'green' | 'blue' }> = [];
if (isDeficit) {
highlights.push({ text: '긴급 자금 확보 필요', color: 'red' });
} else if (isAlmostEmpty) {
highlights.push({ text: '즉시 자금 확보 필요', color: 'red' });
} else if (operatingMonths !== null && operatingMonths >= 1) {
highlights.push({ text: `${operatingMonths}개월분`, color: stabilityColor });
if (operatingStability === 'stable') {
highlights.push({ text: '안정적', color: 'blue' });
} else if (operatingStability === 'warning') {
highlights.push({ text: '자금 부족 우려', color: 'red' });
}
}
checkPoints.push({
id: 'dr-cash-asset',
type: isDeficit || isAlmostEmpty ? 'warning' as CheckPointType : 'info' as CheckPointType,
message: `총 현금성 자산이 ${formatAmount(cashAsset)}입니다. ${stabilityMessage}`,
highlights,
});
return checkPoints;
}
/**
* changeRate/changeDirection
*/
function toChangeFields(rate?: number): { changeRate?: string; changeDirection?: 'up' | 'down' } {
if (rate === undefined || rate === null) return {};
const direction = rate >= 0 ? 'up' as const : 'down' as const;
const sign = rate >= 0 ? '+' : '';
return {
changeRate: `${sign}${rate.toFixed(1)}%`,
changeDirection: direction,
};
}
/**
* DailyReport API Frontend
*/
export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData {
const change = api.daily_change;
// TODO: 백엔드 daily_change 필드 제공 시 더미값 제거
const FALLBACK_CHANGES = {
cash_asset: { changeRate: '+5.2%', changeDirection: 'up' as const },
foreign_currency: { changeRate: '+2.1%', changeDirection: 'up' as const },
income: { changeRate: '+12.0%', changeDirection: 'up' as const },
expense: { changeRate: '-8.0%', changeDirection: 'down' as const },
};
return {
date: formatDate(api.date, api.day_of_week),
cards: [
{
id: 'dr1',
label: '현금성 자산 합계',
amount: api.cash_asset_total,
...(change?.cash_asset_change_rate !== undefined
? toChangeFields(change.cash_asset_change_rate)
: FALLBACK_CHANGES.cash_asset),
},
{
id: 'dr2',
label: '외국환(USD) 합계',
amount: api.foreign_currency_total,
currency: 'USD',
...(change?.foreign_currency_change_rate !== undefined
? toChangeFields(change.foreign_currency_change_rate)
: FALLBACK_CHANGES.foreign_currency),
},
{
id: 'dr3',
label: '입금 합계',
amount: api.krw_totals.income,
...(change?.income_change_rate !== undefined
? toChangeFields(change.income_change_rate)
: FALLBACK_CHANGES.income),
},
{
id: 'dr4',
label: '출금 합계',
amount: api.krw_totals.expense,
...(change?.expense_change_rate !== undefined
? toChangeFields(change.expense_change_rate)
: FALLBACK_CHANGES.expense),
},
],
checkPoints: generateDailyReportCheckPoints(api),
};
}
// ============================================
// 2. Receivable 변환
// ============================================
/**
* 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
*/
export function transformReceivableResponse(api: ReceivablesApiResponse): ReceivableData {
// 누적 미수금 = 이월 + 매출 - 입금
const cumulativeReceivable = api.total_carry_forward + api.total_sales - api.total_deposits;
return {
cards: [
{
id: 'rv1',
label: '누적 미수금',
amount: cumulativeReceivable,
subItems: [
{ label: '이월', value: api.total_carry_forward },
{ label: '매출', value: api.total_sales },
{ label: '입금', value: api.total_deposits },
],
},
{
id: 'rv2',
label: '당월 미수금',
amount: api.total_receivables,
subItems: [
{ label: '매출', value: api.total_sales },
{ label: '입금', value: api.total_deposits },
],
},
{
id: 'rv3',
label: '거래처 현황',
amount: api.vendor_count,
unit: '곳',
subLabel: `연체 ${api.overdue_vendor_count}`,
},
],
checkPoints: generateReceivableCheckPoints(api),
//detailButtonLabel: '미수금 상세',
detailButtonPath: normalizePath('/accounting/receivables-status'),
};
}
// ============================================
// 3. DebtCollection 변환
// ============================================
/**
* CheckPoints
*/
function generateDebtCollectionCheckPoints(api: BadDebtApiResponse): CheckPoint[] {
const checkPoints: CheckPoint[] = [];
// 법적조치 진행 중
if (api.legal_action_amount > 0) {
checkPoints.push({
id: 'dc-legal',
type: 'warning' as CheckPointType,
message: `법적조치 진행 중 ${formatAmount(api.legal_action_amount)}입니다.`,
highlights: [
{ text: formatAmount(api.legal_action_amount), color: 'red' as const },
],
});
}
// 회수 완료
if (api.recovered_amount > 0) {
checkPoints.push({
id: 'dc-recovered',
type: 'success' as CheckPointType,
message: `${formatAmount(api.recovered_amount)}을 회수 완료했습니다.`,
highlights: [
{ text: formatAmount(api.recovered_amount), color: 'green' as const },
{ text: '회수 완료', color: 'green' as const },
],
});
}
return checkPoints;
}
// TODO: 백엔드 per-card sub_label/count 제공 시 더미값 제거
// 채권추심 카드별 더미 서브라벨 (회사명 + 건수)
const DEBT_COLLECTION_FALLBACK_SUB_LABELS: Record<string, { company: string; count: number }> = {
dc1: { company: '(주)부산화학 외', count: 5 },
dc2: { company: '(주)삼성테크 외', count: 3 },
dc3: { company: '(주)대한전자 외', count: 2 },
dc4: { company: '(주)한국정밀 외', count: 3 },
};
/**
* subLabel
* dc1() API client_count ,
*/
function buildDebtSubLabel(cardId: string, clientCount?: number): string | undefined {
const fallback = DEBT_COLLECTION_FALLBACK_SUB_LABELS[cardId];
if (!fallback) return undefined;
const count = cardId === 'dc1' && clientCount !== undefined ? clientCount : fallback.count;
if (count <= 0) return undefined;
const remaining = count - 1;
if (remaining > 0) {
return `${fallback.company} ${remaining}`;
}
return fallback.company.replace(/ 외$/, '');
}
/**
* BadDebt API Frontend
*/
export function transformDebtCollectionResponse(api: BadDebtApiResponse): DebtCollectionData {
return {
cards: [
{
id: 'dc1',
label: '누적 악성채권',
amount: api.total_amount,
subLabel: buildDebtSubLabel('dc1', api.client_count),
},
{
id: 'dc2',
label: '추심중',
amount: api.collecting_amount,
subLabel: buildDebtSubLabel('dc2'),
},
{
id: 'dc3',
label: '법적조치',
amount: api.legal_action_amount,
subLabel: buildDebtSubLabel('dc3'),
},
{
id: 'dc4',
label: '회수완료',
amount: api.recovered_amount,
subLabel: buildDebtSubLabel('dc4'),
},
],
checkPoints: generateDebtCollectionCheckPoints(api),
detailButtonPath: normalizePath('/accounting/bad-debt-collection'),
};
}
// ============================================
// 4. MonthlyExpense 변환
// ============================================
/**
* CheckPoints
*/
function generateMonthlyExpenseCheckPoints(api: ExpectedExpenseApiResponse): CheckPoint[] {
const checkPoints: CheckPoint[] = [];
// 총 예상 지출
checkPoints.push({
id: 'me-total',
type: 'info' as CheckPointType,
message: `이번 달 예상 지출은 ${formatAmount(api.total_amount)}입니다.`,
highlights: [
{ text: formatAmount(api.total_amount), color: 'blue' as const },
],
});
return checkPoints;
}
/**
* ExpectedExpense API Frontend
* 주의: 실제 API는 (// )
* by_transaction_type에서
*/
export function transformMonthlyExpenseResponse(api: ExpectedExpenseApiResponse): MonthlyExpenseData {
// transaction_type별 금액 추출
const purchaseTotal = api.by_transaction_type['purchase']?.total ?? 0;
const cardTotal = api.by_transaction_type['card']?.total ?? 0;
const billTotal = api.by_transaction_type['bill']?.total ?? 0;
return {
cards: [
{
id: 'me1',
label: '매입',
amount: purchaseTotal,
},
{
id: 'me2',
label: '카드',
amount: cardTotal,
},
{
id: 'me3',
label: '발행어음',
amount: billTotal,
},
{
id: 'me4',
label: '총 예상 지출 합계',
amount: api.total_amount,
},
],
checkPoints: generateMonthlyExpenseCheckPoints(api),
};
}
// ============================================
// 5. CardManagement 변환
// ============================================
/**
* / CheckPoints
*/
function generateCardManagementCheckPoints(api: CardTransactionApiResponse): CheckPoint[] {
const checkPoints: CheckPoint[] = [];
// 전월 대비 변화
const changeRate = calculateChangeRate(api.current_month_total, api.previous_month_total);
if (Math.abs(changeRate) > 10) {
const type: CheckPointType = changeRate > 0 ? 'warning' : 'info';
checkPoints.push({
id: 'cm-change',
type,
message: `당월 카드 사용액이 전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}% 변동했습니다.`,
highlights: [
{ text: `${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`, color: changeRate > 0 ? 'red' as const : 'green' as const },
],
});
}
// 당월 사용액
checkPoints.push({
id: 'cm-current',
type: 'info' as CheckPointType,
message: `당월 카드 사용 총 ${formatAmount(api.current_month_total)}입니다.`,
highlights: [
{ text: formatAmount(api.current_month_total), color: 'blue' as const },
],
});
return checkPoints;
}
/**
* CardTransaction API Frontend
* 4 :
* - cm1: 카드 (CardTransaction API)
* - cm2: 가지급금 (LoanDashboard API)
* - cm3: 법인세 (TaxSimulation API - corporate_tax.difference)
* - cm4: 대표자 (TaxSimulation API - income_tax.difference)
*/
export function transformCardManagementResponse(
summaryApi: CardTransactionApiResponse,
loanApi?: LoanDashboardApiResponse | null,
taxApi?: TaxSimulationApiResponse | null,
fallbackData?: CardManagementData
): CardManagementData {
const changeRate = calculateChangeRate(summaryApi.current_month_total, summaryApi.previous_month_total);
// cm2: 가지급금 금액 (LoanDashboard API 또는 fallback)
const loanAmount = loanApi?.summary?.total_outstanding ?? fallbackData?.cards[1]?.amount ?? 0;
// cm3: 법인세 예상 가중 (TaxSimulation API 또는 fallback)
const corporateTaxDifference = taxApi?.corporate_tax?.difference ?? fallbackData?.cards[2]?.amount ?? 0;
// cm4: 대표자 종합세 예상 가중 (TaxSimulation API 또는 fallback)
const incomeTaxDifference = taxApi?.income_tax?.difference ?? fallbackData?.cards[3]?.amount ?? 0;
// 가지급금 경고 배너 표시 여부 결정 (가지급금 잔액 > 0이면 표시)
const hasLoanWarning = loanAmount > 0;
return {
// 가지급금 관련 경고 배너 (가지급금 있을 때만 표시)
warningBanner: hasLoanWarning ? fallbackData?.warningBanner : undefined,
cards: [
// cm1: 카드 사용액 (CardTransaction API)
{
id: 'cm1',
label: '카드',
amount: summaryApi.current_month_total,
previousLabel: `전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`,
},
// cm2: 가지급금 (LoanDashboard API)
{
id: 'cm2',
label: '가지급금',
amount: loanAmount,
},
// cm3: 법인세 예상 가중 (TaxSimulation API)
{
id: 'cm3',
label: '법인세 예상 가중',
amount: corporateTaxDifference,
},
// cm4: 대표자 종합세 예상 가중 (TaxSimulation API)
{
id: 'cm4',
label: '대표자 종합세 예상 가중',
amount: incomeTaxDifference,
},
],
checkPoints: generateCardManagementCheckPoints(summaryApi),
};
}
// ============================================
// 6. StatusBoard 변환
// ============================================
// TODO: 백엔드 sub_label 필드 제공 시 더미값 제거
// API id 기준: orders, bad_debts, safety_stock, tax_deadline, new_clients, leaves, purchases, approvals
const STATUS_BOARD_FALLBACK_SUB_LABELS: Record<string, string> = {
orders: '(주)삼성전자 외',
bad_debts: '주식회사 부산화학 외',
safety_stock: '',
tax_deadline: '',
new_clients: '대한철강 외',
leaves: '',
purchases: '(유)한국정밀 외',
approvals: '구매 결재 외',
};
/**
* subLabel
* API sub_label이 , +
*/
function buildStatusSubLabel(item: { id: string; count: number | string; sub_label?: string }): string | undefined {
// API에서 sub_label 제공 시 우선 사용
if (item.sub_label) return item.sub_label;
// 건수가 0이거나 문자열이면 subLabel 불필요
const count = typeof item.count === 'number' ? item.count : parseInt(String(item.count), 10);
if (isNaN(count) || count <= 0) return undefined;
const fallback = STATUS_BOARD_FALLBACK_SUB_LABELS[item.id];
if (!fallback) return undefined;
// "대한철강 외" + 나머지 건수
const remaining = count - 1;
if (remaining > 0) {
return `${fallback} ${remaining}`;
}
// 1건이면 "외" 제거하고 이름만
return fallback.replace(/ 외$/, '');
}
/**
* StatusBoard API Frontend
* API TodayIssueItem과
*/
export function transformStatusBoardResponse(api: StatusBoardApiResponse): TodayIssueItem[] {
return api.items.map((item) => ({
id: item.id,
label: item.label,
count: item.count,
subLabel: buildStatusSubLabel(item),
path: normalizePath(item.path, { addViewMode: true }),
isHighlighted: item.isHighlighted,
}));
}
// ============================================
// 7. TodayIssue 변환
// ============================================
/** 유효한 notification_type 목록 (API TodayIssue 모델과 동기화) */
const VALID_NOTIFICATION_TYPES: TodayIssueNotificationType[] = [
'sales_order',
'bad_debt',
'safety_stock',
'expected_expense',
'vat_report',
'approval_request',
'new_vendor',
'deposit',
'withdrawal',
'other',
];
/** notification_type badge
* TodayIssue.php BADGE !
*/
const NOTIFICATION_TYPE_TO_BADGE: Record<TodayIssueNotificationType, TodayIssueListBadgeType> = {
sales_order: '수주등록',
bad_debt: '추심이슈',
safety_stock: '안전재고',
expected_expense: '지출 승인대기',
vat_report: '세금 신고',
approval_request: '결재 요청',
new_vendor: '신규거래처',
deposit: '입금',
withdrawal: '출금',
other: '기타',
};
/** badge notification_type (fallback)
* TodayIssue.php BADGE !
*/
const BADGE_TO_NOTIFICATION_TYPE: Record<string, TodayIssueNotificationType> = {
// === 백엔드 실제 값 (TodayIssue.php 상수) ===
'수주등록': 'sales_order',
'추심이슈': 'bad_debt',
'안전재고': 'safety_stock',
'지출 승인대기': 'expected_expense',
'세금 신고': 'vat_report',
'결재 요청': 'approval_request',
'신규거래처': 'new_vendor',
'신규업체': 'new_vendor', // 변형
'입금': 'deposit',
'출금': 'withdrawal',
// === 혹시 모를 변형 (안전장치) ===
'수주 등록': 'sales_order',
'추심 이슈': 'bad_debt',
'안전 재고': 'safety_stock',
'지출승인대기': 'expected_expense',
'세금신고': 'vat_report',
'결재요청': 'approval_request',
};
/**
* API notification_type Frontend notificationType
* notification_type이 badge에서
*/
function validateNotificationType(notificationType: string | null, badge?: string): TodayIssueNotificationType {
// 1. notification_type이 유효하면 그대로 사용
if (notificationType && VALID_NOTIFICATION_TYPES.includes(notificationType as TodayIssueNotificationType)) {
return notificationType as TodayIssueNotificationType;
}
// 2. notification_type이 없으면 badge에서 추론
if (badge && BADGE_TO_NOTIFICATION_TYPE[badge]) {
return BADGE_TO_NOTIFICATION_TYPE[badge];
}
return 'other';
}
/**
* TodayIssue API Frontend
*
* notification_type
*/
export function transformTodayIssueResponse(api: TodayIssueApiResponse): {
items: TodayIssueListItem[];
totalCount: number;
} {
return {
items: api.items.map((item) => {
// notification_type이 없으면 badge에서 추론
const notificationType = validateNotificationType(item.notification_type, item.badge);
// badge는 API 응답 그대로 사용하되, 없으면 notification_type에서 변환
const badge = item.badge || NOTIFICATION_TYPE_TO_BADGE[notificationType];
return {
id: item.id,
badge: badge as TodayIssueListBadgeType,
notificationType,
content: item.content,
time: item.time,
date: item.date,
needsApproval: item.needsApproval ?? false,
path: normalizePath(item.path, { addViewMode: true }),
};
}),
totalCount: api.total_count,
};
}
// ============================================
// 8. Calendar 변환
// ============================================
/**
* Calendar API Frontend
* API CalendarScheduleItem과
*/
export function transformCalendarResponse(api: CalendarApiResponse): {
items: CalendarScheduleItem[];
totalCount: number;
} {
return {
items: api.items.map((item) => ({
id: item.id,
title: item.title,
startDate: item.startDate,
endDate: item.endDate,
startTime: item.startTime ?? undefined,
endTime: item.endTime ?? undefined,
isAllDay: item.isAllDay,
type: item.type,
department: item.department ?? undefined,
personName: item.personName ?? undefined,
color: item.color ?? undefined,
})),
totalCount: api.total_count,
};
}
// ============================================
// 9. Vat 변환
// ============================================
/** 유효한 하이라이트 색상 목록 */
const VALID_HIGHLIGHT_COLORS: HighlightColor[] = ['red', 'green', 'blue'];
/**
* API Frontend
* 'blue'
*/
function validateHighlightColor(color: string): HighlightColor {
if (VALID_HIGHLIGHT_COLORS.includes(color as HighlightColor)) {
return color as HighlightColor;
}
return 'blue';
}
/**
* Vat API Frontend
*
*/
export function transformVatResponse(api: VatApiResponse): VatData {
return {
cards: api.cards.map((card) => ({
id: card.id,
label: card.label,
amount: card.amount,
subLabel: card.subLabel,
unit: card.unit,
})),
checkPoints: api.check_points.map((cp) => ({
id: cp.id,
type: cp.type as CheckPointType,
message: cp.message,
highlights: cp.highlights?.map((h) => ({
text: h.text,
color: validateHighlightColor(h.color),
})),
})),
};
}
// ============================================
// 10. Entertainment 변환
// ============================================
/**
* Entertainment API Frontend
*
*/
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 {
cards: reordered.map((card) => ({
id: card.id,
label: card.label,
amount: card.amount,
subLabel: card.subLabel,
unit: card.unit,
})),
checkPoints: api.check_points.map((cp) => ({
id: cp.id,
type: cp.type as CheckPointType,
message: cp.message,
highlights: cp.highlights?.map((h) => ({
text: h.text,
color: validateHighlightColor(h.color),
})),
})),
};
}
// ============================================
// 11. Welfare 변환
// ============================================
/**
* Welfare API Frontend
*
*/
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 {
cards: reordered.map((card) => ({
id: card.id,
label: card.label,
amount: card.amount,
subLabel: card.subLabel,
unit: card.unit,
})),
checkPoints: api.check_points.map((cp) => ({
id: cp.id,
type: cp.type as CheckPointType,
message: cp.message,
highlights: cp.highlights?.map((h) => ({
text: h.text,
color: validateHighlightColor(h.color),
})),
})),
};
}
// ============================================
// 12. Welfare Detail 변환
// ============================================
import type { DetailModalConfig } from '@/components/business/CEODashboard/types';
/**
* WelfareDetail API DetailModalConfig
*
*/
export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): DetailModalConfig {
const { summary, monthly_usage, category_distribution, transactions, calculation, quarterly } = api;
// 계산 방식에 따른 calculationCards 생성
const calculationCards = calculation.type === 'fixed'
? {
title: '복리후생비 계산',
subtitle: `직원당 정액 금액/월 ${formatNumber(calculation.fixed_amount_per_month ?? 200000)}`,
cards: [
{ label: '직원 수', value: calculation.employee_count, unit: '명' },
{ label: '연간 직원당 월급 금액', value: calculation.annual_amount_per_employee ?? 0, unit: '원', operator: '×' as const },
{ label: '당해년도 복리후생비 총 한도', value: calculation.annual_limit, unit: '원', operator: '=' as const },
],
}
: {
title: '복리후생비 계산',
subtitle: `연봉 총액 기준 비율 ${((calculation.ratio ?? 0.05) * 100).toFixed(1)}%`,
cards: [
{ label: '연봉 총액', value: calculation.total_salary ?? 0, unit: '원' },
{ label: '비율', value: (calculation.ratio ?? 0.05) * 100, unit: '%', operator: '×' as const },
{ label: '당해년도 복리후생비 총 한도', value: calculation.annual_limit, unit: '원', operator: '=' as const },
],
};
// 분기 라벨 가져오기 (현재 분기 기준)
const currentQuarter = quarterly.find(q => q.used !== null)?.quarter ?? 1;
const quarterLabel = `${currentQuarter}사분기`;
return {
title: '복리후생비 상세',
summaryCards: [
// 1행: 당해년도 기준
{ label: '당해년도 복리후생비 계정', value: summary.annual_account, unit: '원' },
{ label: '당해년도 복리후생비 한도', value: summary.annual_limit, unit: '원' },
{ label: '당해년도 복리후생비 사용', value: summary.annual_used, unit: '원' },
{ label: '당해년도 잔여한도', value: summary.annual_remaining, unit: '원' },
// 2행: 분기 기준
{ label: `${quarterLabel} 복리후생비 총 한도`, value: summary.quarterly_limit, unit: '원' },
{ label: `${quarterLabel} 복리후생비 잔여한도`, value: summary.quarterly_remaining, unit: '원' },
{ label: `${quarterLabel} 복리후생비 사용금액`, value: summary.quarterly_used, unit: '원' },
{ label: `${quarterLabel} 복리후생비 초과 금액`, value: summary.quarterly_exceeded, unit: '원' },
],
barChart: {
title: '월별 복리후생비 사용 추이',
data: monthly_usage.map(item => ({
name: item.label,
value: item.amount,
})),
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '항목별 사용 비율',
data: category_distribution.map(item => ({
name: item.category_label,
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: 'date', label: '사용일자', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'usageType', label: '사용항목', align: 'center' },
],
data: transactions.map((tx, idx) => ({
no: idx + 1,
cardName: tx.card_name,
user: tx.user_name,
date: tx.transaction_date,
store: tx.merchant_name,
amount: tx.amount,
usageType: tx.usage_type_label,
})),
filters: [
{
key: 'usageType',
options: [
{ value: 'all', label: '전체' },
{ value: '식비', label: '식비' },
{ value: '건강검진', label: '건강검진' },
{ value: '경조사비', label: '경조사비' },
{ value: '기타', label: '기타' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: transactions.reduce((sum, tx) => sum + tx.amount, 0),
totalColumnKey: 'amount',
},
calculationCards,
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) || '',
},
],
},
};
}
// ============================================
// 13. Purchase Dashboard Detail 변환 (me1)
// ============================================
/**
* Purchase Dashboard Detail API DetailModalConfig
* (me1)
*/
export function transformPurchaseDetailResponse(api: PurchaseDashboardDetailApiResponse): DetailModalConfig {
const { summary, monthly_trend, by_type, items } = api;
const changeRateText = summary.change_rate >= 0
? `+${summary.change_rate.toFixed(1)}%`
: `${summary.change_rate.toFixed(1)}%`;
return {
title: '매입 상세',
summaryCards: [
{ label: '당월 매입액', value: summary.current_month_amount, unit: '원' },
{ label: '전월 대비', value: changeRateText },
],
barChart: {
title: '월별 매입 추이',
data: monthly_trend.map(item => ({
name: item.label,
value: item.amount,
})),
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '유형별 매입 비율',
data: by_type.map(item => ({
name: item.type_label,
value: item.amount,
percentage: 0, // API에서 계산하거나 프론트에서 계산
color: item.color,
})),
},
table: {
title: '매입 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '매입일자', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처명', align: 'left' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'type', label: '유형', align: 'center' },
],
data: items.map((item, idx) => ({
no: idx + 1,
date: item.purchase_date,
vendor: item.vendor_name,
amount: item.amount,
type: item.type_label,
})),
filters: [
{
key: 'type',
options: [
{ value: 'all', label: '전체' },
...by_type.map(t => ({ value: t.type, label: t.type_label })),
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: items.reduce((sum, item) => sum + item.amount, 0),
totalColumnKey: 'amount',
},
};
}
// ============================================
// 14. Card Dashboard Detail 변환 (me2)
// ============================================
/**
* Card Dashboard Detail API DetailModalConfig
* (me2)
*/
export function transformCardDetailResponse(api: CardDashboardDetailApiResponse): DetailModalConfig {
const { summary, monthly_trend, by_user, items } = api;
const changeRate = summary.previous_month_total > 0
? ((summary.current_month_total - summary.previous_month_total) / summary.previous_month_total * 100)
: 0;
const changeRateText = changeRate >= 0
? `+${changeRate.toFixed(1)}%`
: `${changeRate.toFixed(1)}%`;
return {
title: '카드 사용 상세',
summaryCards: [
{ label: '당월 사용액', value: summary.current_month_total, unit: '원' },
{ label: '전월 대비', value: changeRateText },
{ label: '당월 건수', value: summary.current_month_count, unit: '건' },
],
barChart: {
title: '월별 카드 사용 추이',
data: monthly_trend.map(item => ({
name: item.label,
value: item.amount,
})),
dataKey: 'value',
xAxisKey: 'name',
color: '#34D399',
},
pieChart: {
title: '사용자별 사용 비율',
data: by_user.map(item => ({
name: item.user_name,
value: item.amount,
percentage: 0,
color: item.color,
})),
},
table: {
title: '카드 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' },
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'usageType', label: '사용항목', align: 'center' },
],
data: items.map((item, idx) => ({
no: idx + 1,
cardName: item.card_name,
user: item.user_name,
date: item.transaction_date,
store: item.merchant_name,
amount: item.amount,
usageType: item.usage_type,
})),
filters: [
{
key: 'user',
options: [
{ value: 'all', label: '전체' },
...by_user.map(u => ({ value: u.user_name, label: u.user_name })),
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: items.reduce((sum, item) => sum + item.amount, 0),
totalColumnKey: 'amount',
},
};
}
// ============================================
// 15. Bill Dashboard Detail 변환 (me3)
// ============================================
/**
* Bill Dashboard Detail API DetailModalConfig
* (me3)
*/
export function transformBillDetailResponse(api: BillDashboardDetailApiResponse): DetailModalConfig {
const { summary, monthly_trend, by_vendor, items } = api;
const changeRateText = summary.change_rate >= 0
? `+${summary.change_rate.toFixed(1)}%`
: `${summary.change_rate.toFixed(1)}%`;
// 거래처별 가로 막대 차트 데이터
const horizontalBarData = by_vendor.map(item => ({
name: item.vendor_name,
value: item.amount,
}));
return {
title: '발행어음 상세',
summaryCards: [
{ label: '당월 발행어음', value: summary.current_month_amount, unit: '원' },
{ label: '전월 대비', value: changeRateText },
],
barChart: {
title: '월별 발행어음 추이',
data: monthly_trend.map(item => ({
name: item.label,
value: item.amount,
})),
dataKey: 'value',
xAxisKey: 'name',
color: '#F59E0B',
},
horizontalBarChart: {
title: '거래처별 발행어음',
data: horizontalBarData,
dataKey: 'value',
yAxisKey: 'name',
color: '#8B5CF6',
},
table: {
title: '발행어음 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'vendor', label: '거래처명', align: 'left' },
{ key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
{ key: 'dueDate', label: '만기일', align: 'center', format: 'date' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'status', label: '상태', align: 'center' },
],
data: items.map((item, idx) => ({
no: idx + 1,
vendor: item.vendor_name,
issueDate: item.issue_date,
dueDate: item.due_date,
amount: item.amount,
status: item.status_label,
})),
filters: [
{
key: 'vendor',
options: [
{ value: 'all', label: '전체' },
...by_vendor.map(v => ({ value: v.vendor_name, label: v.vendor_name })),
],
defaultValue: 'all',
},
{
key: 'status',
options: [
{ value: 'all', label: '전체' },
{ value: 'pending', label: '대기중' },
{ value: 'paid', label: '결제완료' },
{ value: 'overdue', label: '연체' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: items.reduce((sum, item) => sum + item.amount, 0),
totalColumnKey: 'amount',
},
};
}
// ============================================
// 16. Expected Expense Dashboard Detail 변환 (me1~me4 통합)
// ============================================
// cardId별 제목 및 차트 설정 매핑
const EXPENSE_CARD_CONFIG: Record<string, {
title: string;
tableTitle: string;
summaryLabel: string;
barChartTitle: string;
pieChartTitle?: string;
horizontalBarChartTitle?: string;
hasBarChart: boolean;
hasPieChart: boolean;
hasHorizontalBarChart: boolean;
}> = {
me1: {
title: '당월 매입 상세',
tableTitle: '일별 매입 내역',
summaryLabel: '당월 매입',
barChartTitle: '월별 매입 추이',
pieChartTitle: '거래처별 매입 비율',
hasBarChart: true,
hasPieChart: true,
hasHorizontalBarChart: false,
},
me2: {
title: '당월 카드 상세',
tableTitle: '일별 카드 사용 내역',
summaryLabel: '당월 카드 사용',
barChartTitle: '월별 카드 사용 추이',
pieChartTitle: '거래처별 카드 사용 비율',
hasBarChart: true,
hasPieChart: true,
hasHorizontalBarChart: false,
},
me3: {
title: '당월 발행어음 상세',
tableTitle: '일별 발행어음 내역',
summaryLabel: '당월 발행어음 사용',
barChartTitle: '월별 발행어음 추이',
horizontalBarChartTitle: '당월 거래처별 발행어음',
hasBarChart: true,
hasPieChart: false,
hasHorizontalBarChart: true,
},
me4: {
title: '당월 지출 예상 상세',
tableTitle: '당월 지출 승인 내역서',
summaryLabel: '당월 지출 예상',
barChartTitle: '',
hasBarChart: false,
hasPieChart: false,
hasHorizontalBarChart: false,
},
};
/**
* ExpectedExpense Dashboard Detail API DetailModalConfig
* (me1: 매입, me2: 카드, me3: 발행어음, me4: 전체)
*
* @param api API
* @param cardId ID (me1~me4), me4
*/
export function transformExpectedExpenseDetailResponse(
api: ExpectedExpenseDashboardDetailApiResponse,
cardId: string = 'me4'
): DetailModalConfig {
const { summary, monthly_trend, vendor_distribution, items, footer_summary } = api;
const changeRateText = summary.change_rate >= 0
? `+${summary.change_rate.toFixed(1)}%`
: `${summary.change_rate.toFixed(1)}%`;
// cardId별 설정 가져오기 (기본값: me4)
const config = EXPENSE_CARD_CONFIG[cardId] || EXPENSE_CARD_CONFIG.me4;
// 거래처 필터 옵션 생성
const vendorOptions = [{ value: 'all', label: '전체' }];
const uniqueVendors = [...new Set(items.map(item => item.vendor_name).filter(Boolean))];
uniqueVendors.forEach(vendor => {
vendorOptions.push({ value: vendor, label: vendor });
});
// 결과 객체 생성
const result: DetailModalConfig = {
title: config.title,
summaryCards: [
{ label: config.summaryLabel, value: summary.total_amount, unit: '원' },
{ label: '전월 대비', value: changeRateText },
{ label: '지출 후 예상 잔액', value: summary.remaining_balance, unit: '원' },
],
table: {
title: config.tableTitle,
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'paymentDate', label: '결제예정일', align: 'center', format: 'date' },
{ key: 'item', label: '항목', align: 'left' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'vendor', label: '거래처', align: 'left' },
{ key: 'account', label: '계정과목', align: 'center' },
],
data: items.map((item, idx) => ({
no: idx + 1,
paymentDate: item.payment_date,
item: item.item_name,
amount: item.amount,
vendor: item.vendor_name,
account: item.account_title,
})),
filters: [
{
key: 'vendor',
options: vendorOptions,
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: footer_summary.total_amount,
totalColumnKey: 'amount',
footerSummary: [
{ label: `${footer_summary.item_count}`, value: footer_summary.total_amount },
],
},
};
// barChart 추가 (me1, me2, me3)
if (config.hasBarChart && monthly_trend && monthly_trend.length > 0) {
result.barChart = {
title: config.barChartTitle,
data: monthly_trend.map(item => ({
name: item.label,
value: item.amount,
})),
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
};
}
// pieChart 추가 (me1, me2)
if (config.hasPieChart && vendor_distribution && vendor_distribution.length > 0) {
result.pieChart = {
title: config.pieChartTitle || '분포',
data: vendor_distribution.map(item => ({
name: item.name,
value: item.value,
percentage: item.percentage,
color: item.color,
})),
};
}
// horizontalBarChart 추가 (me3)
if (config.hasHorizontalBarChart && vendor_distribution && vendor_distribution.length > 0) {
result.horizontalBarChart = {
title: config.horizontalBarChartTitle || '거래처별 분포',
data: vendor_distribution.map(item => ({
name: item.name,
value: item.value,
})),
color: '#60A5FA',
};
}
return result;
}