diff --git a/.env.example b/.env.example index 72380b1d..d1e65ed6 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,15 @@ NEXT_PUBLIC_AUTH_MODE=sanctum # - 외부 시스템 연동 API_KEY=your-secret-api-key-here +# ============================================== +# Development Tools +# ============================================== +# DevToolbar: 개발/테스트용 폼 자동 채우기 도구 +# - true: 활성화 (화면 하단에 플로팅 툴바 표시) +# - false 또는 미설정: 비활성화 +# 주의: 운영 환경에서는 반드시 false로 설정! +NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=false + # ============================================== # Development Notes # ============================================== diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx index 27efc25e..45753012 100644 --- a/src/app/[locale]/(protected)/layout.tsx +++ b/src/app/[locale]/(protected)/layout.tsx @@ -5,6 +5,7 @@ import AuthenticatedLayout from '@/layouts/AuthenticatedLayout'; import { RootProvider } from '@/contexts/RootProvider'; import { ApiErrorProvider } from '@/contexts/ApiErrorContext'; import { FCMProvider } from '@/contexts/FCMProvider'; +import { DevFillProvider, DevToolbar } from '@/components/dev'; /** * Protected Layout @@ -40,7 +41,10 @@ export default function ProtectedLayout({ - {children} + + {children} + + diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 3336f6d4..c72bd8a3 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { LayoutDashboard, Settings } from 'lucide-react'; import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; @@ -25,6 +25,7 @@ import { DEFAULT_DASHBOARD_SETTINGS } from './types'; import { ScheduleDetailModal, DetailModal } from './modals'; import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog'; import { mockData } from './mockData'; +import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare } from '@/hooks/useCEODashboard'; import { getMonthlyExpenseModalConfig, getCardManagementModalConfig, @@ -35,8 +36,61 @@ import { export function CEODashboard() { const router = useRouter(); - const [isLoading] = useState(false); - const [data] = useState(mockData); + + // API 데이터 Hook (Phase 1 섹션들) + const apiData = useCEODashboard({ + cardManagementFallback: mockData.cardManagement, + }); + + // TodayIssue API Hook (Phase 2) + const todayIssueData = useTodayIssue(30); + + // Calendar API Hook (Phase 2) + const calendarData = useCalendar(); + + // Vat API Hook (Phase 2) + const vatData = useVat(); + + // Entertainment API Hook (Phase 2) + const entertainmentData = useEntertainment(); + + // Welfare API Hook (Phase 2) + const welfareData = useWelfare(); + + // 전체 로딩 상태 (모든 API 호출 중일 때) + const isLoading = useMemo(() => { + return ( + apiData.dailyReport.loading && + apiData.receivable.loading && + apiData.debtCollection.loading && + apiData.monthlyExpense.loading && + apiData.cardManagement.loading && + apiData.statusBoard.loading && + todayIssueData.loading && + calendarData.loading && + vatData.loading && + entertainmentData.loading && + welfareData.loading + ); + }, [apiData, todayIssueData.loading, calendarData.loading, vatData.loading, entertainmentData.loading, welfareData.loading]); + + // API 데이터와 mockData를 병합 (API 우선, 실패 시 fallback) + const data = useMemo(() => ({ + ...mockData, + // Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback + dailyReport: apiData.dailyReport.data ?? mockData.dailyReport, + receivable: apiData.receivable.data ?? mockData.receivable, + debtCollection: apiData.debtCollection.data ?? mockData.debtCollection, + monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense, + cardManagement: apiData.cardManagement.data ?? mockData.cardManagement, + // Phase 2 섹션들 + todayIssue: apiData.statusBoard.data ?? mockData.todayIssue, + todayIssueList: todayIssueData.data?.items ?? mockData.todayIssueList, + calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules, + vat: vatData.data ?? mockData.vat, + entertainment: entertainmentData.data ?? mockData.entertainment, + welfare: welfareData.data ?? mockData.welfare, + }), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data, mockData]); // 일정 상세 모달 상태 const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); diff --git a/src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts b/src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts index 40c26789..00f6ad67 100644 --- a/src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts +++ b/src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts @@ -3,9 +3,9 @@ import type { DetailModalConfig } from '../types'; /** * 복리후생비 현황 모달 설정 * 모든 카드가 동일한 상세 모달 - * @param calculationType - 계산 방식 ('fixed': 직원당 정액 금액/월, 'percentage': 연봉 총액 비율) + * @param calculationType - 계산 방식 ('fixed': 직원당 정액 금액/월, 'ratio': 연봉 총액 비율) */ -export function getWelfareModalConfig(calculationType: 'fixed' | 'percentage'): DetailModalConfig { +export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): DetailModalConfig { // 계산 방식에 따른 조건부 calculationCards 생성 const calculationCards = calculationType === 'fixed' ? { diff --git a/src/components/dev/DevFillContext.tsx b/src/components/dev/DevFillContext.tsx new file mode 100644 index 00000000..a75abb7f --- /dev/null +++ b/src/components/dev/DevFillContext.tsx @@ -0,0 +1,175 @@ +'use client'; + +/** + * DevFill Context + * + * 개발/테스트용 폼 자동 채우기 기능을 위한 Context + * - 각 페이지 컴포넌트에서 폼 채우기 함수를 등록 + * - DevToolbar에서 등록된 함수를 호출 + * + * 사용법: + * 1. 각 폼 컴포넌트에서 useDevFill hook으로 fillForm 함수 등록 + * 2. DevToolbar에서 버튼 클릭 시 해당 함수 호출 + */ + +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; + +// 지원하는 페이지 타입 +export type DevFillPageType = 'quote' | 'order' | 'workOrder' | 'workOrderComplete' | 'shipment'; + +// 폼 채우기 함수 타입 +type FillFormFunction = (data?: unknown) => void | Promise; + +// Context 타입 +interface DevFillContextType { + // 현재 활성화 상태 + isEnabled: boolean; + setIsEnabled: (enabled: boolean) => void; + + // 툴바 표시 상태 + isVisible: boolean; + setIsVisible: (visible: boolean) => void; + + // 현재 페이지 타입 + currentPage: DevFillPageType | null; + setCurrentPage: (page: DevFillPageType | null) => void; + + // 폼 채우기 함수 등록/해제 + registerFillForm: (pageType: DevFillPageType, fillFn: FillFormFunction) => void; + unregisterFillForm: (pageType: DevFillPageType) => void; + + // 폼 채우기 실행 + fillForm: (pageType: DevFillPageType, data?: unknown) => Promise; + + // 등록된 페이지 확인 + hasRegisteredForm: (pageType: DevFillPageType) => boolean; + + // 플로우 데이터 (이전 단계에서 생성된 ID 저장) + flowData: FlowData; + setFlowData: (data: Partial) => void; + clearFlowData: () => void; +} + +// 플로우 간 전달 데이터 +interface FlowData { + quoteId?: number; + orderId?: number; + workOrderId?: number; + lotNo?: string; +} + +const DevFillContext = createContext(null); + +// Provider Props +interface DevFillProviderProps { + children: ReactNode; +} + +export function DevFillProvider({ children }: DevFillProviderProps) { + // 활성화 상태 (환경변수 기반) - 초기값 false로 서버/클라이언트 일치 + const [isEnabled, setIsEnabled] = useState(false); + + // 툴바 표시 상태 (localStorage 기반) - 초기값 false로 서버/클라이언트 일치 + const [isVisible, setIsVisible] = useState(false); + + // 클라이언트 마운트 후 실제 값 설정 (Hydration 불일치 방지) + useEffect(() => { + setIsEnabled(process.env.NEXT_PUBLIC_DEV_TOOLBAR_ENABLED === 'true'); + const stored = localStorage.getItem('devToolbarVisible'); + setIsVisible(stored !== 'false'); + }, []); + + // 현재 페이지 타입 + const [currentPage, setCurrentPage] = useState(null); + + // 등록된 폼 채우기 함수들 + const [fillFunctions, setFillFunctions] = useState>(new Map()); + + // 플로우 데이터 + const [flowData, setFlowDataState] = useState({}); + + // 툴바 표시 상태 저장 + const handleSetIsVisible = useCallback((visible: boolean) => { + setIsVisible(visible); + if (typeof window !== 'undefined') { + localStorage.setItem('devToolbarVisible', String(visible)); + } + }, []); + + // 폼 채우기 함수 등록 + const registerFillForm = useCallback((pageType: DevFillPageType, fillFn: FillFormFunction) => { + setFillFunctions(prev => { + const next = new Map(prev); + next.set(pageType, fillFn); + return next; + }); + }, []); + + // 폼 채우기 함수 해제 + const unregisterFillForm = useCallback((pageType: DevFillPageType) => { + setFillFunctions(prev => { + const next = new Map(prev); + next.delete(pageType); + return next; + }); + }, []); + + // 폼 채우기 실행 + const fillForm = useCallback(async (pageType: DevFillPageType, data?: unknown) => { + const fillFn = fillFunctions.get(pageType); + if (fillFn) { + await fillFn(data); + } else { + console.warn(`[DevFill] No fill function registered for page: ${pageType}`); + } + }, [fillFunctions]); + + // 등록 여부 확인 + const hasRegisteredForm = useCallback((pageType: DevFillPageType) => { + return fillFunctions.has(pageType); + }, [fillFunctions]); + + // 플로우 데이터 설정 + const setFlowData = useCallback((data: Partial) => { + setFlowDataState(prev => ({ ...prev, ...data })); + }, []); + + // 플로우 데이터 초기화 + const clearFlowData = useCallback(() => { + setFlowDataState({}); + }, []); + + // 비활성화 시 null 반환하지 않고 children만 렌더링 + // (DevToolbar에서 isEnabled 체크) + + return ( + {}, // 환경변수로 제어하므로 런타임 변경 불가 + isVisible, + setIsVisible: handleSetIsVisible, + currentPage, + setCurrentPage, + registerFillForm, + unregisterFillForm, + fillForm, + hasRegisteredForm, + flowData, + setFlowData, + clearFlowData, + }} + > + {children} + + ); +} + +// Hook +export function useDevFillContext() { + const context = useContext(DevFillContext); + if (!context) { + throw new Error('useDevFillContext must be used within a DevFillProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/components/dev/DevToolbar.tsx b/src/components/dev/DevToolbar.tsx new file mode 100644 index 00000000..fb30d581 --- /dev/null +++ b/src/components/dev/DevToolbar.tsx @@ -0,0 +1,235 @@ +'use client'; + +/** + * DevToolbar - 개발/테스트용 플로팅 툴바 + * + * 화면 하단에 플로팅으로 표시되며, + * 각 단계(견적→수주→작업지시→완료→출하)의 폼을 자동으로 채울 수 있습니다. + * + * 환경변수로 활성화/비활성화: + * NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=true + */ + +import { useState } from 'react'; +import { usePathname } from 'next/navigation'; +import { + FileText, // 견적 + ClipboardList, // 수주 + Wrench, // 작업지시 + CheckCircle2, // 완료 + Truck, // 출하 + ChevronDown, + ChevronUp, + X, + Loader2, + Play, + RotateCcw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useDevFillContext, type DevFillPageType } from './DevFillContext'; + +// 페이지 경로와 타입 매핑 +const PAGE_PATTERNS: { pattern: RegExp; type: DevFillPageType; label: string }[] = [ + { pattern: /\/quote-management\/new/, type: 'quote', label: '견적' }, + { pattern: /\/quote-management\/\d+\/edit/, type: 'quote', label: '견적' }, + { pattern: /\/order-management-sales\/new/, type: 'order', label: '수주' }, + { pattern: /\/order-management-sales\/\d+\/edit/, type: 'order', label: '수주' }, + { pattern: /\/work-orders\/create/, type: 'workOrder', label: '작업지시' }, + { pattern: /\/work-orders\/\d+\/edit/, type: 'workOrder', label: '작업지시' }, + { pattern: /\/work-orders\/\d+$/, type: 'workOrderComplete', label: '작업완료' }, + { pattern: /\/shipments\/new/, type: 'shipment', label: '출하' }, + { pattern: /\/shipments\/\d+\/edit/, type: 'shipment', label: '출하' }, +]; + +// 플로우 단계 정의 +const FLOW_STEPS: { type: DevFillPageType; label: string; icon: typeof FileText; path: string }[] = [ + { type: 'quote', label: '견적', icon: FileText, path: '/sales/quote-management/new' }, + { type: 'order', label: '수주', icon: ClipboardList, path: '/sales/order-management-sales/new' }, + { type: 'workOrder', label: '작업지시', icon: Wrench, path: '/production/work-orders/create' }, + { type: 'workOrderComplete', label: '완료', icon: CheckCircle2, path: '' }, // 상세 페이지에서 처리 + { type: 'shipment', label: '출하', icon: Truck, path: '/outbound/shipments/new' }, +]; + +export function DevToolbar() { + const pathname = usePathname(); + const { + isEnabled, + isVisible, + setIsVisible, + currentPage, + fillForm, + hasRegisteredForm, + flowData, + clearFlowData, + } = useDevFillContext(); + + const [isExpanded, setIsExpanded] = useState(true); + const [isLoading, setIsLoading] = useState(null); + + // 비활성화 시 렌더링하지 않음 + if (!isEnabled) return null; + + // 숨김 상태일 때 작은 버튼만 표시 + if (!isVisible) { + return ( +
+ +
+ ); + } + + // 현재 페이지 타입 감지 + const detectedPage = PAGE_PATTERNS.find(p => p.pattern.test(pathname)); + const activePage = detectedPage?.type || null; + + // 폼 채우기 실행 + const handleFillForm = async (pageType: DevFillPageType) => { + if (!hasRegisteredForm(pageType)) { + console.warn(`[DevToolbar] Form not registered for: ${pageType}`); + return; + } + + setIsLoading(pageType); + try { + await fillForm(pageType, flowData); + } catch (err) { + console.error('[DevToolbar] Fill form error:', err); + } finally { + setIsLoading(null); + } + }; + + // 플로우 데이터 표시 + const hasFlowData = flowData.quoteId || flowData.orderId || flowData.workOrderId || flowData.lotNo; + + return ( +
+
+ {/* 헤더 */} +
+
+ + DEV MODE + + {detectedPage && ( + + 현재: {detectedPage.label} + + )} + {hasFlowData && ( + + {flowData.quoteId && `견적#${flowData.quoteId}`} + {flowData.orderId && ` → 수주#${flowData.orderId}`} + {flowData.workOrderId && ` → 작업#${flowData.workOrderId}`} + + )} +
+
+ {hasFlowData && ( + + )} + + +
+
+ + {/* 버튼 영역 */} + {isExpanded && ( +
+ {FLOW_STEPS.map((step, index) => { + const Icon = step.icon; + const isActive = activePage === step.type; + const isRegistered = hasRegisteredForm(step.type); + const isCurrentLoading = isLoading === step.type; + + // 완료 버튼은 상세 페이지에서만 활성화 + if (step.type === 'workOrderComplete' && !isActive) { + return ( +
+ {index > 0 && } + +
+ ); + } + + return ( +
+ {index > 0 && } + +
+ ); + })} +
+ )} + + {/* 안내 메시지 */} + {isExpanded && !activePage && ( +
+

+ 견적/수주/작업지시/출하 페이지에서 활성화됩니다 +

+
+ )} +
+
+ ); +} + +export default DevToolbar; \ No newline at end of file diff --git a/src/components/dev/generators/index.ts b/src/components/dev/generators/index.ts new file mode 100644 index 00000000..3c7ba787 --- /dev/null +++ b/src/components/dev/generators/index.ts @@ -0,0 +1,79 @@ +/** + * 샘플 데이터 생성 공통 유틸리티 + */ + +// 랜덤 선택 +export function randomPick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +// 범위 내 랜덤 정수 +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +// 범위 내 랜덤 정수 (100 단위) +export function randomInt100(min: number, max: number): number { + const minH = Math.ceil(min / 100); + const maxH = Math.floor(max / 100); + return randomInt(minH, maxH) * 100; +} + +// 랜덤 전화번호 +export function randomPhone(): string { + const middle = randomInt(1000, 9999); + const last = randomInt(1000, 9999); + return `010-${middle}-${last}`; +} + +// 오늘 기준 N일 후 날짜 +export function dateAfterDays(days: number): string { + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toISOString().split('T')[0]; +} + +// 오늘 날짜 +export function today(): string { + return new Date().toISOString().split('T')[0]; +} + +// 랜덤 층수 +export function randomFloor(): string { + const floors = ['B1', '1F', '2F', '3F', '4F', '5F', '6F', '7F', '8F', '9F', '10F']; + return randomPick(floors); +} + +// 순차 부호 생성 (F001, F002, ...) +let codeCounter = 1; +export function nextCode(): string { + const code = `F${String(codeCounter).padStart(3, '0')}`; + codeCounter++; + return code; +} + +// 부호 카운터 리셋 +export function resetCodeCounter(): void { + codeCounter = 1; +} + +// 랜덤 비고 +export function randomRemark(): string { + const remarks = [ + '특이사항 없음', + '긴급 배송 요청', + '우천 시 배송 연기', + '오전 중 배송 희망', + '현장 담당자 부재 시 경비실 전달', + '설치 시 안전관리자 필요', + '화물용 엘리베이터 사용 가능', + '주차 공간 협소, 사전 연락 필수', + '', + ]; + return randomPick(remarks); +} + +// 랜덤 ID 생성 (임시용) +export function tempId(): string { + return `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} \ No newline at end of file diff --git a/src/components/dev/generators/orderData.ts b/src/components/dev/generators/orderData.ts new file mode 100644 index 00000000..35543e77 --- /dev/null +++ b/src/components/dev/generators/orderData.ts @@ -0,0 +1,99 @@ +/** + * 수주 샘플 데이터 생성기 + * + * 수주는 대부분 견적에서 전환되므로, + * 견적 선택 후 자동 채움되는 필드 외의 추가 정보만 생성 + */ + +import { + randomPick, + randomPhone, + dateAfterDays, + randomRemark, +} from './index'; +import type { OrderFormData } from '@/components/orders/OrderRegistration'; + +// 배송방식 +const DELIVERY_METHODS = ['direct', 'pickup', 'courier']; + +// 운임비용 +const SHIPPING_COSTS = ['free', 'prepaid', 'collect', 'negotiable']; + +/** + * 수주 추가 정보 생성 (견적 전환 후 채울 필드들) + */ +export interface GenerateOrderDataOptions { + // 견적에서 가져온 기본 정보 (이미 채워진 상태) + baseData?: Partial; +} + +export function generateOrderData(options: GenerateOrderDataOptions = {}): Partial { + const { baseData = {} } = options; + + // 견적에서 전환된 경우 이미 채워진 필드들은 그대로 유지 + // 추가로 채워야 할 필드들만 생성 + + return { + ...baseData, + + // 배송 정보 + expectedShipDate: dateAfterDays(14), // 2주 후 + expectedShipDateUndecided: false, + deliveryRequestDate: dateAfterDays(21), // 3주 후 + deliveryRequestDateUndecided: false, + deliveryMethod: randomPick(DELIVERY_METHODS), + shippingCost: randomPick(SHIPPING_COSTS), + + // 수신자 정보 (견적의 담당자 정보와 다를 수 있음) + receiver: baseData.manager || randomPick(['김수신', '이수신', '박수신']), + receiverContact: baseData.contact || randomPhone(), + + // 주소 (테스트용 기본값) + zipCode: '06234', + address: '서울특별시 강남구 테헤란로 123', + addressDetail: '삼성빌딩 10층', + + // 비고 + remarks: randomRemark(), + }; +} + +/** + * 견적 없이 수주 직접 생성 시 사용 + */ +export function generateOrderDataFull(): OrderFormData { + return { + // 기본 정보 + clientId: '', + clientName: '테스트 거래처', + siteName: '테스트 현장', + manager: randomPick(['김담당', '이담당', '박담당']), + contact: randomPhone(), + + // 배송 정보 + expectedShipDate: dateAfterDays(14), + expectedShipDateUndecided: false, + deliveryRequestDate: dateAfterDays(21), + deliveryRequestDateUndecided: false, + deliveryMethod: randomPick(DELIVERY_METHODS), + shippingCost: randomPick(SHIPPING_COSTS), + receiver: randomPick(['김수신', '이수신', '박수신']), + receiverContact: randomPhone(), + + // 주소 + zipCode: '06234', + address: '서울특별시 강남구 테헤란로 123', + addressDetail: '삼성빌딩 10층', + + // 비고 + remarks: randomRemark(), + + // 품목 (빈 배열 - 견적 선택 또는 수동 추가 필요) + items: [], + + // 금액 + subtotal: 0, + discountRate: 0, + totalAmount: 0, + }; +} \ No newline at end of file diff --git a/src/components/dev/generators/quoteData.ts b/src/components/dev/generators/quoteData.ts new file mode 100644 index 00000000..f0b8b32b --- /dev/null +++ b/src/components/dev/generators/quoteData.ts @@ -0,0 +1,126 @@ +/** + * 견적 샘플 데이터 생성기 + */ + +import { + randomPick, + randomInt, + randomInt100, + randomPhone, + dateAfterDays, + today, + randomFloor, + nextCode, + resetCodeCounter, + randomRemark, + tempId, +} from './index'; +import type { QuoteFormData, QuoteItem } from '@/components/quotes/QuoteRegistration'; +import type { Vendor } from '@/components/accounting/VendorManagement'; +import type { FinishedGoods } from '@/components/quotes/actions'; + +// 제품 카테고리 +const PRODUCT_CATEGORIES = ['SCREEN', 'STEEL']; + +// 가이드레일 설치 유형 +const GUIDE_RAIL_TYPES = ['wall', 'floor']; + +// 모터 전원 +const MOTOR_POWERS = ['single', 'three']; + +// 연동제어기 +const CONTROLLERS = ['basic', 'smart', 'premium']; + +// 작성자 목록 (실제로는 로그인 사용자 사용) +const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서연']; + +/** + * 견적 품목 1개 생성 + */ +export function generateQuoteItem( + index: number, + products?: FinishedGoods[] +): QuoteItem { + const category = randomPick(PRODUCT_CATEGORIES); + + // 카테고리에 맞는 제품 필터링 + let productName = ''; + if (products && products.length > 0) { + const categoryProducts = products.filter(p => + p.categoryCode?.toUpperCase() === category || !p.categoryCode + ); + if (categoryProducts.length > 0) { + productName = randomPick(categoryProducts).name; + } + } + + // 제품명이 없으면 기본값 + if (!productName) { + productName = category === 'SCREEN' + ? randomPick(['방화스크린 FSC-1', '방화스크린 FSC-2', '방화스크린 FSC-3']) + : randomPick(['방화철재셔터 FSD-1', '방화철재셔터 FSD-2']); + } + + return { + id: tempId(), + floor: randomFloor(), + code: nextCode(), + productCategory: category, + productName, + openWidth: String(randomInt100(2000, 5000)), + openHeight: String(randomInt100(2000, 5000)), + guideRailType: randomPick(GUIDE_RAIL_TYPES), + motorPower: randomPick(MOTOR_POWERS), + controller: randomPick(CONTROLLERS), + quantity: randomInt(1, 10), + wingSize: '50', + inspectionFee: 50000, + }; +} + +/** + * 견적 폼 데이터 생성 + */ +export interface GenerateQuoteDataOptions { + clients?: Vendor[]; // 거래처 목록 + products?: FinishedGoods[]; // 제품 목록 + itemCount?: number; // 품목 수 (기본: 1~5개 랜덤) +} + +export function generateQuoteData(options: GenerateQuoteDataOptions = {}): QuoteFormData { + const { clients = [], products = [], itemCount } = options; + + // 부호 카운터 리셋 + resetCodeCounter(); + + // 거래처 선택 + let clientId = ''; + let clientName = ''; + if (clients.length > 0) { + const client = randomPick(clients); + clientId = String(client.id); + clientName = client.name; + } + + // 품목 수 결정 + const count = itemCount ?? randomInt(1, 5); + + // 품목 생성 + const items: QuoteItem[] = []; + for (let i = 0; i < count; i++) { + items.push(generateQuoteItem(i, products)); + } + + return { + registrationDate: today(), + writer: randomPick(WRITERS), + clientId, + clientName, + siteName: clientName ? `${clientName} 현장` : '테스트 현장', + manager: randomPick(['김담당', '이담당', '박담당', '최담당']), + contact: randomPhone(), + dueDate: dateAfterDays(7), // 1주일 후 + remarks: randomRemark(), + items, + }; +} \ No newline at end of file diff --git a/src/components/dev/generators/shipmentData.ts b/src/components/dev/generators/shipmentData.ts new file mode 100644 index 00000000..cbcb30c0 --- /dev/null +++ b/src/components/dev/generators/shipmentData.ts @@ -0,0 +1,77 @@ +/** + * 출하 샘플 데이터 생성기 + */ + +import { + randomPick, + today, + randomRemark, +} from './index'; +import type { + ShipmentCreateFormData, + ShipmentPriority, + DeliveryMethod, + LotOption, + LogisticsOption, + VehicleTonnageOption, +} from '@/components/outbound/ShipmentManagement/types'; + +// 우선순위 +const PRIORITIES: ShipmentPriority[] = ['urgent', 'normal', 'low']; + +// 배송방식 +const DELIVERY_METHODS: DeliveryMethod[] = ['pickup', 'direct', 'logistics']; + +/** + * 출하 폼 데이터 생성 + */ +export interface GenerateShipmentDataOptions { + lotOptions?: LotOption[]; // 로트 목록 + logisticsOptions?: LogisticsOption[]; // 물류사 목록 + tonnageOptions?: VehicleTonnageOption[]; // 차량 톤수 목록 + lotNo?: string; // 지정 로트번호 (플로우에서 전달) +} + +export function generateShipmentData( + options: GenerateShipmentDataOptions = {} +): ShipmentCreateFormData { + const { + lotOptions = [], + logisticsOptions = [], + tonnageOptions = [], + lotNo, + } = options; + + // 로트 선택 + let selectedLotNo = lotNo || ''; + if (!selectedLotNo && lotOptions.length > 0) { + selectedLotNo = randomPick(lotOptions).lotNo; + } + + // 배송방식 + const deliveryMethod = randomPick(DELIVERY_METHODS); + + // 물류사 (물류사 배송일 때만) + let logisticsCompany = ''; + if (deliveryMethod === 'logistics' && logisticsOptions.length > 0) { + logisticsCompany = randomPick(logisticsOptions).name; + } + + // 차량 톤수 + let vehicleTonnage = ''; + if (tonnageOptions.length > 0) { + vehicleTonnage = randomPick(tonnageOptions).value; + } + + return { + lotNo: selectedLotNo, + scheduledDate: today(), + priority: randomPick(PRIORITIES), + deliveryMethod, + logisticsCompany, + vehicleTonnage, + loadingTime: '', + loadingManager: '', + remarks: randomRemark(), + }; +} \ No newline at end of file diff --git a/src/components/dev/generators/workOrderData.ts b/src/components/dev/generators/workOrderData.ts new file mode 100644 index 00000000..d1468981 --- /dev/null +++ b/src/components/dev/generators/workOrderData.ts @@ -0,0 +1,46 @@ +/** + * 작업지시 샘플 데이터 생성기 + * + * 작업지시는 수주 선택 후 자동 채움되는 필드 외의 추가 정보만 생성 + */ + +import { + randomPick, + randomInt, + dateAfterDays, + randomRemark, +} from './index'; +import type { ProcessOption } from '@/components/production/WorkOrders/actions'; + +/** + * 작업지시 추가 정보 생성 + */ +export interface GenerateWorkOrderDataOptions { + processOptions?: ProcessOption[]; // 공정 목록 +} + +export interface WorkOrderFormDataPartial { + processId: number | null; + shipmentDate: string; + priority: number; + note: string; +} + +export function generateWorkOrderData( + options: GenerateWorkOrderDataOptions = {} +): WorkOrderFormDataPartial { + const { processOptions = [] } = options; + + // 공정 선택 (있으면 랜덤 선택, 없으면 null) + let processId: number | null = null; + if (processOptions.length > 0) { + processId = randomPick(processOptions).id; + } + + return { + processId, + shipmentDate: dateAfterDays(randomInt(7, 21)), // 1~3주 후 + priority: randomPick([3, 5, 7]), // 높음(3), 보통(5), 낮음(7) + note: randomRemark(), + }; +} \ No newline at end of file diff --git a/src/components/dev/index.ts b/src/components/dev/index.ts new file mode 100644 index 00000000..cb0e1f6f --- /dev/null +++ b/src/components/dev/index.ts @@ -0,0 +1,16 @@ +/** + * Dev 컴포넌트 모듈 + * + * 개발/테스트용 도구 모음 + * 환경변수 NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=true 로 활성화 + */ + +export { DevFillProvider, useDevFillContext, type DevFillPageType } from './DevFillContext'; +export { useDevFill } from './useDevFill'; +export { DevToolbar } from './DevToolbar'; + +// Generators +export { generateQuoteData, generateQuoteItem } from './generators/quoteData'; +export { generateOrderData, generateOrderDataFull } from './generators/orderData'; +export { generateWorkOrderData } from './generators/workOrderData'; +export { generateShipmentData } from './generators/shipmentData'; \ No newline at end of file diff --git a/src/components/dev/useDevFill.ts b/src/components/dev/useDevFill.ts new file mode 100644 index 00000000..1e235191 --- /dev/null +++ b/src/components/dev/useDevFill.ts @@ -0,0 +1,77 @@ +'use client'; + +/** + * useDevFill Hook + * + * 각 폼 컴포넌트에서 DevFill 기능을 사용하기 위한 hook + * + * 사용법: + * ```tsx + * function MyFormComponent() { + * const [formData, setFormData] = useState(initialData); + * + * useDevFill('quote', (data) => { + * setFormData(data as QuoteFormData); + * }); + * + * return
...
; + * } + * ``` + */ + +import { useEffect, useCallback } from 'react'; +import { useDevFillContext, type DevFillPageType } from './DevFillContext'; + +type FillFormCallback = (data: T) => void | Promise; + +/** + * DevFill hook + * + * @param pageType - 현재 페이지 타입 + * @param onFill - 데이터 채우기 콜백 (DevToolbar에서 호출됨) + */ +export function useDevFill( + pageType: DevFillPageType, + onFill: FillFormCallback +) { + const { + isEnabled, + setCurrentPage, + registerFillForm, + unregisterFillForm, + flowData, + } = useDevFillContext(); + + // 안정적인 콜백 참조 + const stableFillCallback = useCallback( + (data?: unknown) => { + return onFill(data as T); + }, + [onFill] + ); + + // 컴포넌트 마운트 시 등록, 언마운트 시 해제 + useEffect(() => { + if (!isEnabled) return; + + // 현재 페이지 설정 + setCurrentPage(pageType); + + // 폼 채우기 함수 등록 + registerFillForm(pageType, stableFillCallback); + + return () => { + // 클린업: 현재 페이지 해제 및 함수 등록 해제 + setCurrentPage(null); + unregisterFillForm(pageType); + }; + }, [isEnabled, pageType, setCurrentPage, registerFillForm, unregisterFillForm, stableFillCallback]); + + // 플로우 데이터 반환 (이전 단계에서 생성된 ID 등) + return { + isEnabled, + flowData, + }; +} + +export default useDevFill; \ No newline at end of file diff --git a/src/components/orders/OrderRegistration.tsx b/src/components/orders/OrderRegistration.tsx index c6fa4e85..f30b801d 100644 --- a/src/components/orders/OrderRegistration.tsx +++ b/src/components/orders/OrderRegistration.tsx @@ -61,6 +61,8 @@ import { type QuotationForSelect, type QuotationItem } from "./actions"; import { ItemAddDialog, OrderItem } from "./ItemAddDialog"; import { formatAmount } from "@/utils/formatAmount"; import { cn } from "@/lib/utils"; +import { useDevFill } from "@/components/dev"; +import { generateOrderDataFull } from "@/components/dev/generators/orderData"; // 수주 폼 데이터 타입 export interface OrderFormData { @@ -218,6 +220,24 @@ export function OrderRegistration({ })); }, [form.items, form.discountRate]); + // DevToolbar 자동 채우기 + useDevFill( + 'order', + useCallback(() => { + const sampleData = generateOrderDataFull(); + + // 거래처 목록에서 실제 데이터 사용 + if (clients.length > 0) { + const randomClient = clients[Math.floor(Math.random() * clients.length)]; + sampleData.clientId = randomClient.id; + sampleData.clientName = randomClient.name; + } + + setForm(sampleData); + toast.success('[Dev] 수주 폼이 자동으로 채워졌습니다.'); + }, [clients]) + ); + // 견적 선택 핸들러 const handleQuotationSelect = (quotation: QuotationForSelect) => { // 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가) diff --git a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx index 08972674..66046086 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx @@ -37,6 +37,9 @@ import type { VehicleTonnageOption, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +import { toast } from 'sonner'; +import { useDevFill } from '@/components/dev'; +import { generateShipmentData } from '@/components/dev/generators/shipmentData'; // 고정 옵션 (클라이언트에서 관리) const priorityOptions: { value: ShipmentPriority; label: string }[] = [ @@ -115,6 +118,38 @@ export function ShipmentCreate() { loadOptions(); }, [loadOptions]); + // DevToolbar 자동 채우기 + useDevFill( + 'shipment', + useCallback(() => { + // lotOptions를 generateShipmentData에 전달하기 위해 변환 + const lotOptionsForGenerator = lotOptions.map(o => ({ + lotNo: o.value, + customerName: o.customerName, + siteName: o.siteName, + })); + + const logisticsOptionsForGenerator = logisticsOptions.map(o => ({ + id: o.value, + name: o.label, + })); + + const tonnageOptionsForGenerator = vehicleTonnageOptions.map(o => ({ + value: o.value, + label: o.label, + })); + + const sampleData = generateShipmentData({ + lotOptions: lotOptionsForGenerator, + logisticsOptions: logisticsOptionsForGenerator, + tonnageOptions: tonnageOptionsForGenerator, + }); + + setFormData(sampleData); + toast.success('[Dev] 출하 폼이 자동으로 채워졌습니다.'); + }, [lotOptions, logisticsOptions, vehicleTonnageOptions]) + ); + // 폼 입력 핸들러 const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => { setFormData(prev => ({ ...prev, [field]: value })); diff --git a/src/components/production/WorkOrders/WorkOrderCreate.tsx b/src/components/production/WorkOrders/WorkOrderCreate.tsx index 21cced1f..43500941 100644 --- a/src/components/production/WorkOrders/WorkOrderCreate.tsx +++ b/src/components/production/WorkOrders/WorkOrderCreate.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { X, Edit2, FileText } from 'lucide-react'; +import { ArrowLeft, FileText, X, Edit2, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -28,9 +28,12 @@ import { AssigneeSelectModal } from './AssigneeSelectModal'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { createWorkOrder, getProcessOptions, type ProcessOption } from './actions'; -import { type SalesOrder } from './types'; +import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types'; import { workOrderCreateConfig } from './workOrderConfig'; +import { useDevFill } from '@/components/dev'; +import { generateWorkOrderData } from '@/components/dev/generators/workOrderData'; + // Validation 에러 타입 interface ValidationErrors { [key: string]: string; @@ -113,6 +116,32 @@ export function WorkOrderCreate() { loadProcessOptions(); }, []); + // DevToolbar 자동 채우기 + useDevFill( + 'workOrder', + useCallback(() => { + const sampleData = generateWorkOrderData({ processOptions }); + + // 수동 등록 모드로 변경 + setMode('manual'); + + // 폼 데이터 채우기 + setFormData(prev => ({ + ...prev, + client: '테스트 거래처', + projectName: '테스트 현장', + orderNo: '', + itemCount: 0, + processId: sampleData.processId, + shipmentDate: sampleData.shipmentDate, + priority: sampleData.priority, + note: sampleData.note, + })); + + toast.success('[Dev] 작업지시 폼이 자동으로 채워졌습니다.'); + }, [processOptions]) + ); + // 수주 선택 핸들러 const handleSelectOrder = (order: SalesOrder) => { setFormData({ diff --git a/src/components/quotes/QuoteRegistration.tsx b/src/components/quotes/QuoteRegistration.tsx index c5eef204..b521e2ed 100644 --- a/src/components/quotes/QuoteRegistration.tsx +++ b/src/components/quotes/QuoteRegistration.tsx @@ -58,6 +58,8 @@ import { getClients } from "../accounting/VendorManagement/actions"; import { isNextRedirectError } from "@/lib/utils/redirect-error"; import type { Vendor } from "../accounting/VendorManagement"; import type { BomMaterial, CalculationResults } from "./types"; +import { useDevFill } from "@/components/dev"; +import { generateQuoteData } from "@/components/dev/generators/quoteData"; // 견적 항목 타입 export interface QuoteItem { @@ -203,6 +205,20 @@ export function QuoteRegistration({ // 현장명 자동완성 목록 상태 const [siteNames, setSiteNames] = useState([]); + // DevToolbar용 폼 자동 채우기 등록 + useDevFill( + 'quote', + useCallback(() => { + // 실제 로드된 데이터를 기반으로 샘플 데이터 생성 + const sampleData = generateQuoteData({ + clients: clients.map(c => ({ id: c.id, name: c.vendorName })), + products: finishedGoods.map(p => ({ code: p.item_code, name: p.item_name, category: p.category })), + }); + setFormData(sampleData); + toast.success('[Dev] 견적 폼이 자동으로 채워졌습니다.'); + }, [clients, finishedGoods]) + ); + // 수량 반영 총합계 계산 (useMemo로 최적화) const calculatedGrandTotal = useMemo(() => { if (!calculationResults?.items) return 0; @@ -1081,32 +1097,32 @@ export function QuoteRegistration({ const formItem = formData.items[itemResult.index]; const product = finishedGoods.find(fg => fg.item_code === formItem?.productName); - return ( -
-
-
- - 견적 {itemResult.index + 1} - - - {itemResult.result.finished_goods?.name || product?.item_name || formItem?.productName || "-"} - - - ({itemResult.result.finished_goods?.code || formItem?.productName || "-"}) - + return ( +
+
+
+ + 견적 {itemResult.index + 1} + + + {itemResult.result.finished_goods?.name || product?.item_name || formItem?.productName || "-"} + + + ({itemResult.result.finished_goods?.code || formItem?.productName || "-"}) + +
+
+
+ 단가: {(itemResult.result.grand_total || 0).toLocaleString()}원
-
-
- 단가: {itemResult.result.grand_total.toLocaleString()}원 -
-
- 합계: {((itemResult.result.grand_total || 0) * (formItem?.quantity || 1)).toLocaleString()}원 - - (×{formItem?.quantity || 1}) - -
+
+ 합계: {((itemResult.result.grand_total || 0) * (formItem?.quantity || 1)).toLocaleString()}원 + + (×{formItem?.quantity || 1}) +
+
{/* BOM 상세 내역 */} {itemResult.result.items && itemResult.result.items.length > 0 && ( diff --git a/src/hooks/useCEODashboard.ts b/src/hooks/useCEODashboard.ts new file mode 100644 index 00000000..55377f16 --- /dev/null +++ b/src/hooks/useCEODashboard.ts @@ -0,0 +1,784 @@ +'use client'; + +/** + * CEO Dashboard API 연동 Hook + * + * 각 섹션별 API 호출 및 데이터 변환 담당 + * 참조 패턴: useClientList.ts + */ + +import { useState, useCallback, useEffect } from 'react'; + +import type { + DailyReportApiResponse, + ReceivablesApiResponse, + BadDebtApiResponse, + ExpectedExpenseApiResponse, + CardTransactionApiResponse, + StatusBoardApiResponse, + TodayIssueApiResponse, + CalendarApiResponse, + VatApiResponse, + EntertainmentApiResponse, + WelfareApiResponse, +} from '@/lib/api/dashboard/types'; + +import { + transformDailyReportResponse, + transformReceivableResponse, + transformDebtCollectionResponse, + transformMonthlyExpenseResponse, + transformCardManagementResponse, + transformStatusBoardResponse, + transformTodayIssueResponse, + transformCalendarResponse, + transformVatResponse, + transformEntertainmentResponse, + transformWelfareResponse, +} from '@/lib/api/dashboard/transformers'; + +import type { + DailyReportData, + ReceivableData, + DebtCollectionData, + MonthlyExpenseData, + CardManagementData, + TodayIssueItem, + TodayIssueListItem, + CalendarScheduleItem, + VatData, + EntertainmentData, + WelfareData, +} from '@/components/business/CEODashboard/types'; + +// ============================================ +// 공통 fetch 유틸리티 +// ============================================ + +async function fetchApi(endpoint: string): Promise { + const response = await fetch(`/api/proxy/${endpoint}`); + + if (!response.ok) { + throw new Error(`API 오류: ${response.status}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || '데이터 조회 실패'); + } + + return result.data; +} + +// ============================================ +// 1. DailyReport Hook +// ============================================ + +export function useDailyReport() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const apiData = await fetchApi('daily-report/summary'); + const transformed = transformDailyReportResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('DailyReport API Error:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 2. Receivable Hook +// ============================================ + +export function useReceivable() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const apiData = await fetchApi('receivables/summary'); + const transformed = transformReceivableResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('Receivable API Error:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 3. DebtCollection Hook +// ============================================ + +export function useDebtCollection() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const apiData = await fetchApi('bad-debts/summary'); + const transformed = transformDebtCollectionResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('DebtCollection API Error:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 4. MonthlyExpense Hook +// ============================================ + +export function useMonthlyExpense() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const apiData = await fetchApi('expected-expenses/summary'); + const transformed = transformMonthlyExpenseResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('MonthlyExpense API Error:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 5. CardManagement Hook +// ============================================ + +export function useCardManagement(fallbackData?: CardManagementData) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const apiData = await fetchApi('card-transactions/summary'); + const transformed = transformCardManagementResponse(apiData, fallbackData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('CardManagement API Error:', err); + } finally { + setLoading(false); + } + }, [fallbackData]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 6. StatusBoard Hook +// ============================================ + +export function useStatusBoard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const apiData = await fetchApi('status-board/summary'); + const transformed = transformStatusBoardResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('StatusBoard API Error:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 7. TodayIssue Hook +// ============================================ + +export interface TodayIssueData { + items: TodayIssueListItem[]; + totalCount: number; +} + +export function useTodayIssue(limit: number = 30) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const apiData = await fetchApi(`today-issues/summary?limit=${limit}`); + const transformed = transformTodayIssueResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('TodayIssue API Error:', err); + } finally { + setLoading(false); + } + }, [limit]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 8. Calendar Hook +// ============================================ + +export interface CalendarData { + items: CalendarScheduleItem[]; + totalCount: number; +} + +export interface UseCalendarOptions { + /** 조회 시작일 (Y-m-d, 기본: 이번 달 1일) */ + startDate?: string; + /** 조회 종료일 (Y-m-d, 기본: 이번 달 말일) */ + endDate?: string; + /** 일정 타입 필터 (schedule|order|construction|other|null=전체) */ + type?: 'schedule' | 'order' | 'construction' | 'other' | null; + /** 부서 필터 (all|department|personal) */ + departmentFilter?: 'all' | 'department' | 'personal'; +} + +export function useCalendar(options: UseCalendarOptions = {}) { + const { startDate, endDate, type, departmentFilter = 'all' } = options; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + // 쿼리 파라미터 구성 + const params = new URLSearchParams(); + if (startDate) params.append('start_date', startDate); + if (endDate) params.append('end_date', endDate); + if (type) params.append('type', type); + if (departmentFilter) params.append('department_filter', departmentFilter); + + const queryString = params.toString(); + const endpoint = queryString ? `calendar/schedules?${queryString}` : 'calendar/schedules'; + + const apiData = await fetchApi(endpoint); + const transformed = transformCalendarResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('Calendar API Error:', err); + } finally { + setLoading(false); + } + }, [startDate, endDate, type, departmentFilter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 9. Vat Hook +// ============================================ + +export interface UseVatOptions { + /** 기간 타입 (quarter: 분기, half: 반기, year: 연간) */ + periodType?: 'quarter' | 'half' | 'year'; + /** 연도 (기본: 현재 연도) */ + year?: number; + /** 기간 번호 (quarter: 1-4, half: 1-2) */ + period?: number; +} + +export function useVat(options: UseVatOptions = {}) { + const { periodType = 'quarter', year, period } = options; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + // 쿼리 파라미터 구성 + const params = new URLSearchParams(); + params.append('period_type', periodType); + if (year) params.append('year', year.toString()); + if (period) params.append('period', period.toString()); + + const queryString = params.toString(); + const endpoint = `vat/summary?${queryString}`; + + const apiData = await fetchApi(endpoint); + const transformed = transformVatResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('Vat API Error:', err); + } finally { + setLoading(false); + } + }, [periodType, year, period]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 10. Entertainment Hook (접대비) +// ============================================ + +export interface UseEntertainmentOptions { + /** 기간 타입 (annual: 연간, quarterly: 분기) */ + limitType?: 'annual' | 'quarterly'; + /** 기업 유형 (large: 대기업, medium: 중견기업, small: 중소기업) */ + companyType?: 'large' | 'medium' | 'small'; + /** 연도 (기본: 현재 연도) */ + year?: number; + /** 분기 번호 (1-4, 기본: 현재 분기) */ + quarter?: number; +} + +export function useEntertainment(options: UseEntertainmentOptions = {}) { + const { limitType = 'quarterly', companyType = 'medium', year, quarter } = options; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + // 쿼리 파라미터 구성 + const params = new URLSearchParams(); + params.append('limit_type', limitType); + params.append('company_type', companyType); + if (year) params.append('year', year.toString()); + if (quarter) params.append('quarter', quarter.toString()); + + const queryString = params.toString(); + const endpoint = `entertainment/summary?${queryString}`; + + const apiData = await fetchApi(endpoint); + const transformed = transformEntertainmentResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('Entertainment API Error:', err); + } finally { + setLoading(false); + } + }, [limitType, companyType, year, quarter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 11. Welfare Hook (복리후생비) +// ============================================ + +export interface UseWelfareOptions { + /** 기간 타입 (annual: 연간, quarterly: 분기) */ + limitType?: 'annual' | 'quarterly'; + /** 계산 방식 (fixed: 1인당 정액, ratio: 급여 대비 비율) */ + calculationType?: 'fixed' | 'ratio'; + /** 1인당 월 정액 (calculation_type=fixed일 때 사용, 기본: 200000) */ + fixedAmountPerMonth?: number; + /** 급여 대비 비율 (calculation_type=ratio일 때 사용, 기본: 0.05) */ + ratio?: number; + /** 연도 (기본: 현재 연도) */ + year?: number; + /** 분기 번호 (1-4, 기본: 현재 분기) */ + quarter?: number; +} + +export function useWelfare(options: UseWelfareOptions = {}) { + const { + limitType = 'quarterly', + calculationType = 'fixed', + fixedAmountPerMonth, + ratio, + year, + quarter, + } = options; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + // 쿼리 파라미터 구성 + const params = new URLSearchParams(); + params.append('limit_type', limitType); + params.append('calculation_type', calculationType); + if (fixedAmountPerMonth) params.append('fixed_amount_per_month', fixedAmountPerMonth.toString()); + if (ratio) params.append('ratio', ratio.toString()); + if (year) params.append('year', year.toString()); + if (quarter) params.append('quarter', quarter.toString()); + + const queryString = params.toString(); + const endpoint = `welfare/summary?${queryString}`; + + const apiData = await fetchApi(endpoint); + const transformed = transformWelfareResponse(apiData); + setData(transformed); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패'; + setError(errorMessage); + console.error('Welfare API Error:', err); + } finally { + setLoading(false); + } + }, [limitType, calculationType, fixedAmountPerMonth, ratio, year, quarter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; +} + +// ============================================ +// 통합 Dashboard Hook (선택적 사용) +// ============================================ + +export interface UseCEODashboardOptions { + /** DailyReport 섹션 활성화 */ + dailyReport?: boolean; + /** Receivable 섹션 활성화 */ + receivable?: boolean; + /** DebtCollection 섹션 활성화 */ + debtCollection?: boolean; + /** MonthlyExpense 섹션 활성화 */ + monthlyExpense?: boolean; + /** CardManagement 섹션 활성화 */ + cardManagement?: boolean; + /** CardManagement fallback 데이터 */ + cardManagementFallback?: CardManagementData; + /** StatusBoard 섹션 활성화 */ + statusBoard?: boolean; +} + +export interface CEODashboardState { + dailyReport: { + data: DailyReportData | null; + loading: boolean; + error: string | null; + }; + receivable: { + data: ReceivableData | null; + loading: boolean; + error: string | null; + }; + debtCollection: { + data: DebtCollectionData | null; + loading: boolean; + error: string | null; + }; + monthlyExpense: { + data: MonthlyExpenseData | null; + loading: boolean; + error: string | null; + }; + cardManagement: { + data: CardManagementData | null; + loading: boolean; + error: string | null; + }; + statusBoard: { + data: TodayIssueItem[] | null; + loading: boolean; + error: string | null; + }; + refetchAll: () => void; +} + +/** + * 통합 CEO Dashboard Hook + * 여러 섹션의 API를 병렬로 호출하여 성능 최적화 + */ +export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashboardState { + const { + dailyReport: enableDailyReport = true, + receivable: enableReceivable = true, + debtCollection: enableDebtCollection = true, + monthlyExpense: enableMonthlyExpense = true, + cardManagement: enableCardManagement = true, + cardManagementFallback, + statusBoard: enableStatusBoard = true, + } = options; + + // 각 섹션별 상태 + const [dailyReportData, setDailyReportData] = useState(null); + const [dailyReportLoading, setDailyReportLoading] = useState(enableDailyReport); + const [dailyReportError, setDailyReportError] = useState(null); + + const [receivableData, setReceivableData] = useState(null); + const [receivableLoading, setReceivableLoading] = useState(enableReceivable); + const [receivableError, setReceivableError] = useState(null); + + const [debtCollectionData, setDebtCollectionData] = useState(null); + const [debtCollectionLoading, setDebtCollectionLoading] = useState(enableDebtCollection); + const [debtCollectionError, setDebtCollectionError] = useState(null); + + const [monthlyExpenseData, setMonthlyExpenseData] = useState(null); + const [monthlyExpenseLoading, setMonthlyExpenseLoading] = useState(enableMonthlyExpense); + const [monthlyExpenseError, setMonthlyExpenseError] = useState(null); + + const [cardManagementData, setCardManagementData] = useState(null); + const [cardManagementLoading, setCardManagementLoading] = useState(enableCardManagement); + const [cardManagementError, setCardManagementError] = useState(null); + + const [statusBoardData, setStatusBoardData] = useState(null); + const [statusBoardLoading, setStatusBoardLoading] = useState(enableStatusBoard); + const [statusBoardError, setStatusBoardError] = useState(null); + + // 개별 fetch 함수들 + const fetchDailyReport = useCallback(async () => { + if (!enableDailyReport) return; + try { + setDailyReportLoading(true); + setDailyReportError(null); + const apiData = await fetchApi('daily-report/summary'); + setDailyReportData(transformDailyReportResponse(apiData)); + } catch (err) { + setDailyReportError(err instanceof Error ? err.message : '데이터 로딩 실패'); + } finally { + setDailyReportLoading(false); + } + }, [enableDailyReport]); + + const fetchReceivable = useCallback(async () => { + if (!enableReceivable) return; + try { + setReceivableLoading(true); + setReceivableError(null); + const apiData = await fetchApi('receivables/summary'); + setReceivableData(transformReceivableResponse(apiData)); + } catch (err) { + setReceivableError(err instanceof Error ? err.message : '데이터 로딩 실패'); + } finally { + setReceivableLoading(false); + } + }, [enableReceivable]); + + const fetchDebtCollection = useCallback(async () => { + if (!enableDebtCollection) return; + try { + setDebtCollectionLoading(true); + setDebtCollectionError(null); + const apiData = await fetchApi('bad-debts/summary'); + setDebtCollectionData(transformDebtCollectionResponse(apiData)); + } catch (err) { + setDebtCollectionError(err instanceof Error ? err.message : '데이터 로딩 실패'); + } finally { + setDebtCollectionLoading(false); + } + }, [enableDebtCollection]); + + const fetchMonthlyExpense = useCallback(async () => { + if (!enableMonthlyExpense) return; + try { + setMonthlyExpenseLoading(true); + setMonthlyExpenseError(null); + const apiData = await fetchApi('expected-expenses/summary'); + setMonthlyExpenseData(transformMonthlyExpenseResponse(apiData)); + } catch (err) { + setMonthlyExpenseError(err instanceof Error ? err.message : '데이터 로딩 실패'); + } finally { + setMonthlyExpenseLoading(false); + } + }, [enableMonthlyExpense]); + + const fetchCardManagement = useCallback(async () => { + if (!enableCardManagement) return; + try { + setCardManagementLoading(true); + setCardManagementError(null); + const apiData = await fetchApi('card-transactions/summary'); + setCardManagementData(transformCardManagementResponse(apiData, cardManagementFallback)); + } catch (err) { + setCardManagementError(err instanceof Error ? err.message : '데이터 로딩 실패'); + } finally { + setCardManagementLoading(false); + } + }, [enableCardManagement, cardManagementFallback]); + + const fetchStatusBoard = useCallback(async () => { + if (!enableStatusBoard) return; + try { + setStatusBoardLoading(true); + setStatusBoardError(null); + const apiData = await fetchApi('status-board/summary'); + setStatusBoardData(transformStatusBoardResponse(apiData)); + } catch (err) { + setStatusBoardError(err instanceof Error ? err.message : '데이터 로딩 실패'); + } finally { + setStatusBoardLoading(false); + } + }, [enableStatusBoard]); + + // 전체 refetch + const refetchAll = useCallback(() => { + fetchDailyReport(); + fetchReceivable(); + fetchDebtCollection(); + fetchMonthlyExpense(); + fetchCardManagement(); + fetchStatusBoard(); + }, [fetchDailyReport, fetchReceivable, fetchDebtCollection, fetchMonthlyExpense, fetchCardManagement, fetchStatusBoard]); + + // 초기 로드 + useEffect(() => { + refetchAll(); + }, [refetchAll]); + + return { + dailyReport: { + data: dailyReportData, + loading: dailyReportLoading, + error: dailyReportError, + }, + receivable: { + data: receivableData, + loading: receivableLoading, + error: receivableError, + }, + debtCollection: { + data: debtCollectionData, + loading: debtCollectionLoading, + error: debtCollectionError, + }, + monthlyExpense: { + data: monthlyExpenseData, + loading: monthlyExpenseLoading, + error: monthlyExpenseError, + }, + cardManagement: { + data: cardManagementData, + loading: cardManagementLoading, + error: cardManagementError, + }, + statusBoard: { + data: statusBoardData, + loading: statusBoardLoading, + error: statusBoardError, + }, + refetchAll, + }; +} \ No newline at end of file diff --git a/src/lib/api/dashboard/index.ts b/src/lib/api/dashboard/index.ts new file mode 100644 index 00000000..c831c140 --- /dev/null +++ b/src/lib/api/dashboard/index.ts @@ -0,0 +1,6 @@ +/** + * CEO Dashboard API 모듈 export + */ + +export * from './types'; +export * from './transformers'; \ No newline at end of file diff --git a/src/lib/api/dashboard/transformers.ts b/src/lib/api/dashboard/transformers.ts new file mode 100644 index 00000000..f8bc53db --- /dev/null +++ b/src/lib/api/dashboard/transformers.ts @@ -0,0 +1,642 @@ +/** + * CEO Dashboard API 응답 → Frontend 타입 변환 함수 + * + * 참조: docs/plans/AI_리포트_키워드_색상체계_가이드_v1.4.md + */ + +import type { + DailyReportApiResponse, + ReceivablesApiResponse, + BadDebtApiResponse, + ExpectedExpenseApiResponse, + CardTransactionApiResponse, + StatusBoardApiResponse, + TodayIssueApiResponse, + CalendarApiResponse, + VatApiResponse, + EntertainmentApiResponse, + WelfareApiResponse, +} from './types'; + +import type { + DailyReportData, + ReceivableData, + DebtCollectionData, + MonthlyExpenseData, + CardManagementData, + TodayIssueItem, + TodayIssueListItem, + TodayIssueListBadgeType, + CalendarScheduleItem, + CheckPoint, + CheckPointType, + VatData, + EntertainmentData, + WelfareData, + HighlightColor, +} from '@/components/business/CEODashboard/types'; + +// ============================================ +// 헬퍼 함수 +// ============================================ + +/** + * 금액 포맷팅 + * @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 `${Math.round(amount / 10000).toLocaleString()}만원`; + } + return `${amount.toLocaleString()}원`; +} + +/** + * 날짜 포맷팅 (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 변환 +// ============================================ + +/** + * 일일 일보 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; + checkPoints.push({ + id: 'dr-cash-asset', + type: 'info' as CheckPointType, + message: `총 현금성 자산이 ${formatAmount(cashAsset)}입니다.`, + highlights: [ + { text: formatAmount(cashAsset), color: 'blue' as const }, + ], + }); + + return checkPoints; +} + +/** + * DailyReport API 응답 → Frontend 타입 변환 + */ +export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData { + return { + date: formatDate(api.date, api.day_of_week), + cards: [ + { + id: 'dr1', + label: '현금성 자산 합계', + amount: api.cash_asset_total, + }, + { + id: 'dr2', + label: '외국환(USD) 합계', + amount: api.foreign_currency_total, + currency: 'USD', + }, + { + id: 'dr3', + label: '입금 합계', + amount: api.krw_totals.income, + }, + { + id: 'dr4', + label: '출금 합계', + amount: api.krw_totals.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: '/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; +} + +/** + * BadDebt API 응답 → Frontend 타입 변환 + */ +export function transformDebtCollectionResponse(api: BadDebtApiResponse): DebtCollectionData { + return { + cards: [ + { + id: 'dc1', + label: '누적 악성채권', + amount: api.total_amount, + }, + { + id: 'dc2', + label: '추심중', + amount: api.collecting_amount, + }, + { + id: 'dc3', + label: '법적조치', + amount: api.legal_action_amount, + }, + { + id: 'dc4', + label: '회수완료', + amount: api.recovered_amount, + }, + ], + checkPoints: generateDebtCollectionCheckPoints(api), + detailButtonPath: '/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 타입 변환 + * 주의: 가지급금, 법인세 예상 가중 등은 별도 API 필요 (현재 목업 유지) + */ +export function transformCardManagementResponse( + api: CardTransactionApiResponse, + fallbackData?: CardManagementData +): CardManagementData { + const changeRate = calculateChangeRate(api.current_month_total, api.previous_month_total); + + return { + // 가지급금 관련 경고는 API 데이터가 없으므로 fallback 사용 + warningBanner: fallbackData?.warningBanner, + cards: [ + { + id: 'cm1', + label: '카드', + amount: api.current_month_total, + previousLabel: `전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`, + }, + // 아래 항목들은 API에서 제공하지 않으므로 fallback 사용 + fallbackData?.cards[1] ?? { + id: 'cm2', + label: '가지급금', + amount: 0, + }, + fallbackData?.cards[2] ?? { + id: 'cm3', + label: '법인세 예상 가중', + amount: 0, + }, + fallbackData?.cards[3] ?? { + id: 'cm4', + label: '대표자 종합세 예상 가중', + amount: 0, + }, + ], + checkPoints: generateCardManagementCheckPoints(api), + }; +} + +// ============================================ +// 6. StatusBoard 변환 +// ============================================ + +/** + * 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, + path: item.path, + isHighlighted: item.isHighlighted, + })); +} + +// ============================================ +// 7. TodayIssue 변환 +// ============================================ + +/** 유효한 뱃지 타입 목록 */ +const VALID_BADGE_TYPES: TodayIssueListBadgeType[] = [ + '수주 성공', + '주식 이슈', + '직정 제고', + '지출예상내역서', + '세금 신고', + '결재 요청', + '기타', +]; + +/** + * API 뱃지 문자열 → Frontend 뱃지 타입 변환 + * 유효하지 않은 뱃지는 '기타'로 폴백 + */ +function validateBadgeType(badge: string): TodayIssueListBadgeType { + if (VALID_BADGE_TYPES.includes(badge as TodayIssueListBadgeType)) { + return badge as TodayIssueListBadgeType; + } + return '기타'; +} + +/** + * TodayIssue API 응답 → Frontend 타입 변환 + * 오늘의 이슈 리스트 데이터 변환 + */ +export function transformTodayIssueResponse(api: TodayIssueApiResponse): { + items: TodayIssueListItem[]; + totalCount: number; +} { + return { + items: api.items.map((item) => ({ + id: item.id, + badge: validateBadgeType(item.badge), + content: item.content, + time: item.time, + date: item.date, + needsApproval: item.needsApproval ?? false, + path: item.path, + })), + 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, + endTime: item.endTime, + isAllDay: item.isAllDay, + type: item.type, + department: item.department, + personName: item.personName, + color: item.color, + })), + 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 { + 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), + })), + })), + }; +} + +// ============================================ +// 11. Welfare 변환 +// ============================================ + +/** + * Welfare API 응답 → Frontend 타입 변환 + * 복리후생비 현황 데이터 변환 + */ +export function transformWelfareResponse(api: WelfareApiResponse): WelfareData { + 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), + })), + })), + }; +} \ No newline at end of file diff --git a/src/lib/api/dashboard/types.ts b/src/lib/api/dashboard/types.ts new file mode 100644 index 00000000..2cddb9c1 --- /dev/null +++ b/src/lib/api/dashboard/types.ts @@ -0,0 +1,286 @@ +/** + * CEO Dashboard API 응답 타입 정의 + * + * Laravel API 응답과 Frontend 타입 간의 매핑을 위한 타입들 + */ + +// ============================================ +// 1. DailyReport API 응답 타입 +// ============================================ + +/** KRW/USD 통화별 합계 */ +export interface CurrencyTotals { + carryover: number; // 전월 이월 + income: number; // 수입 (입금) + expense: number; // 지출 (출금) + balance: number; // 잔액 +} + +/** GET /api/proxy/daily-report/summary 응답 */ +export interface DailyReportApiResponse { + date: string; // "2026-01-20" + day_of_week: string; // "월요일" + note_receivable_total: number; // 수취채권 합계 + foreign_currency_total: number; // 외화 합계 (USD) + cash_asset_total: number; // 현금성 자산 합계 + krw_totals: CurrencyTotals; // 원화 합계 + usd_totals: CurrencyTotals; // 달러 합계 +} + +// ============================================ +// 2. Receivables API 응답 타입 +// ============================================ + +/** GET /api/proxy/receivables/summary 응답 */ +export interface ReceivablesApiResponse { + total_carry_forward: number; // 이월 미수금 + total_sales: number; // 당월 매출 + total_deposits: number; // 당월 입금 + total_bills: number; // 당월 어음 + total_receivables: number; // 미수금 잔액 + vendor_count: number; // 거래처 수 + overdue_vendor_count: number; // 연체 거래처 수 +} + +// ============================================ +// 3. BadDebt (채권추심) API 응답 타입 +// ============================================ + +/** GET /api/proxy/bad-debts/summary 응답 */ +export interface BadDebtApiResponse { + total_amount: number; // 총 악성채권 + collecting_amount: number; // 추심중 + legal_action_amount: number; // 법적조치 + recovered_amount: number; // 회수완료 + bad_debt_amount: number; // 대손처리 +} + +// ============================================ +// 4. ExpectedExpense (당월 예상 지출) API 응답 타입 +// ============================================ + +/** 상태/유형별 집계 아이템 */ +export interface ExpenseSummaryItem { + total: number; + count: number; +} + +/** GET /api/proxy/expected-expenses/summary 응답 */ +export interface ExpectedExpenseApiResponse { + total_amount: number; + total_count: number; + by_payment_status: Record; + by_transaction_type: Record; + by_month: Record; +} + +// ============================================ +// 5. CardTransaction (카드/가지급금) API 응답 타입 +// ============================================ + +/** GET /api/proxy/card-transactions/summary 응답 */ +export interface CardTransactionApiResponse { + previous_month_total: number; // 전월 카드 사용액 + current_month_total: number; // 당월 카드 사용액 + total_count: number; // 총 건수 + total_amount: number; // 총 금액 +} + +// ============================================ +// 공통 API 응답 Wrapper +// ============================================ + +/** 표준 API 응답 래퍼 */ +export interface ApiResponse { + success: boolean; + message: string; + data: T; +} + +// ============================================ +// Dashboard Hook 상태 타입 +// ============================================ + +/** 섹션별 로딩 상태 */ +export interface DashboardLoadingState { + dailyReport: boolean; + receivable: boolean; + debtCollection: boolean; + monthlyExpense: boolean; + cardManagement: boolean; +} + +/** 섹션별 에러 상태 */ +export interface DashboardErrorState { + dailyReport: string | null; + receivable: string | null; + debtCollection: string | null; + monthlyExpense: string | null; + cardManagement: string | null; +} + +// ============================================ +// 6. StatusBoard (현황판) API 응답 타입 +// ============================================ + +/** 현황판 카드 아이템 */ +export interface StatusBoardItemApiResponse { + id: string; // 카드 ID (orders, bad_debts, etc.) + label: string; // 카드 라벨 + count: number | string; // 건수 또는 텍스트 (예: "부가세 신고 D-15") + path: string; // 이동 경로 + isHighlighted: boolean; // 강조 표시 여부 +} + +/** GET /api/proxy/status-board/summary 응답 */ +export interface StatusBoardApiResponse { + items: StatusBoardItemApiResponse[]; +} + +// ============================================ +// 7. TodayIssue (오늘의 이슈 리스트) API 응답 타입 +// ============================================ + +/** 오늘의 이슈 아이템 */ +export interface TodayIssueItemApiResponse { + id: string; // 항목 고유 ID + badge: string; // 이슈 카테고리 뱃지 + content: string; // 이슈 내용 + time: string; // 상대 시간 (예: "10분 전") + date?: string; // 날짜 (ISO 형식) + needsApproval?: boolean; // 승인/반려 버튼 표시 여부 + path?: string; // 클릭 시 이동할 경로 +} + +/** GET /api/proxy/today-issues/summary 응답 */ +export interface TodayIssueApiResponse { + items: TodayIssueItemApiResponse[]; + total_count: number; // 전체 이슈 건수 +} + +// ============================================ +// 8. Calendar (캘린더) API 응답 타입 +// ============================================ + +/** 캘린더 일정 타입 */ +export type CalendarScheduleType = 'schedule' | 'order' | 'construction' | 'other'; + +/** 캘린더 일정 아이템 */ +export interface CalendarScheduleItemApiResponse { + id: string; // 일정 ID (타입_ID 형식, 예: "wo_123") + title: string; // 일정 제목 + startDate: string; // 시작일 (Y-m-d) + endDate: string; // 종료일 (Y-m-d) + startTime: string | null; // 시작 시간 (HH:mm) 또는 null + endTime: string | null; // 종료 시간 (HH:mm) 또는 null + isAllDay: boolean; // 종일 여부 + type: CalendarScheduleType; // 일정 타입 + department: string | null; // 부서명 + personName: string | null; // 담당자명 + color: string | null; // 일정 색상 +} + +/** GET /api/proxy/calendar/schedules 응답 */ +export interface CalendarApiResponse { + items: CalendarScheduleItemApiResponse[]; + total_count: number; // 총 일정 수 +} + +// ============================================ +// 9. Vat (부가세) API 응답 타입 +// ============================================ + +/** 부가세 금액 카드 아이템 */ +export interface VatAmountCardApiResponse { + id: string; // 카드 ID (vat_sales_tax, vat_purchases_tax, etc.) + label: string; // 카드 라벨 + amount: number; // 금액 또는 건수 + subLabel?: string; // 부가 라벨 (예: "환급") + unit?: string; // 단위 (예: "건") +} + +/** 부가세 체크포인트 하이라이트 아이템 */ +export interface VatHighlightItemApiResponse { + text: string; // 하이라이트 텍스트 + color: string; // 색상 (red, blue, green 등) +} + +/** 부가세 체크포인트 아이템 */ +export interface VatCheckPointApiResponse { + id: string; // 체크포인트 ID + type: string; // 타입 (success, warning, error) + message: string; // 메시지 + highlights?: VatHighlightItemApiResponse[]; // 하이라이트 아이템 목록 +} + +/** GET /api/proxy/vat/summary 응답 */ +export interface VatApiResponse { + cards: VatAmountCardApiResponse[]; + check_points: VatCheckPointApiResponse[]; +} + +// ============================================ +// 10. Entertainment (접대비) API 응답 타입 +// ============================================ + +/** 접대비 금액 카드 아이템 */ +export interface EntertainmentAmountCardApiResponse { + id: string; // 카드 ID (et_sales, et_limit, etc.) + label: string; // 카드 라벨 + amount: number; // 금액 + subLabel?: string; // 부가 라벨 + unit?: string; // 단위 +} + +/** 접대비 체크포인트 하이라이트 아이템 */ +export interface EntertainmentHighlightItemApiResponse { + text: string; // 하이라이트 텍스트 + color: string; // 색상 (red, green, orange 등) +} + +/** 접대비 체크포인트 아이템 */ +export interface EntertainmentCheckPointApiResponse { + id: string; // 체크포인트 ID + type: string; // 타입 (success, warning, error) + message: string; // 메시지 + highlights?: EntertainmentHighlightItemApiResponse[]; // 하이라이트 아이템 목록 +} + +/** GET /api/proxy/entertainment/summary 응답 */ +export interface EntertainmentApiResponse { + cards: EntertainmentAmountCardApiResponse[]; + check_points: EntertainmentCheckPointApiResponse[]; +} + +// ============================================ +// 11. Welfare (복리후생비) API 응답 타입 +// ============================================ + +/** 복리후생비 금액 카드 아이템 */ +export interface WelfareAmountCardApiResponse { + id: string; // 카드 ID (wf_annual_limit, wf_period_limit, etc.) + label: string; // 카드 라벨 + amount: number; // 금액 + subLabel?: string; // 부가 라벨 + unit?: string; // 단위 +} + +/** 복리후생비 체크포인트 하이라이트 아이템 */ +export interface WelfareHighlightItemApiResponse { + text: string; // 하이라이트 텍스트 + color: string; // 색상 (red, green, orange 등) +} + +/** 복리후생비 체크포인트 아이템 */ +export interface WelfareCheckPointApiResponse { + id: string; // 체크포인트 ID + type: string; // 타입 (success, warning, error) + message: string; // 메시지 + highlights?: WelfareHighlightItemApiResponse[]; // 하이라이트 아이템 목록 +} + +/** GET /api/proxy/welfare/summary 응답 */ +export interface WelfareApiResponse { + cards: WelfareAmountCardApiResponse[]; + check_points: WelfareCheckPointApiResponse[]; +} \ No newline at end of file