/** * 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 = { 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 = { 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 = { 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 = { // === 백엔드 실제 값 (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 = { 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; }