Merge branch 'master' of http://114.203.209.83:3000/SamProject/sam-react-prod
This commit is contained in:
@@ -31,6 +31,15 @@ NEXT_PUBLIC_AUTH_MODE=sanctum
|
|||||||
# - 외부 시스템 연동
|
# - 외부 시스템 연동
|
||||||
API_KEY=your-secret-api-key-here
|
API_KEY=your-secret-api-key-here
|
||||||
|
|
||||||
|
# ==============================================
|
||||||
|
# Development Tools
|
||||||
|
# ==============================================
|
||||||
|
# DevToolbar: 개발/테스트용 폼 자동 채우기 도구
|
||||||
|
# - true: 활성화 (화면 하단에 플로팅 툴바 표시)
|
||||||
|
# - false 또는 미설정: 비활성화
|
||||||
|
# 주의: 운영 환경에서는 반드시 false로 설정!
|
||||||
|
NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=false
|
||||||
|
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# Development Notes
|
# Development Notes
|
||||||
# ==============================================
|
# ==============================================
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import AuthenticatedLayout from '@/layouts/AuthenticatedLayout';
|
|||||||
import { RootProvider } from '@/contexts/RootProvider';
|
import { RootProvider } from '@/contexts/RootProvider';
|
||||||
import { ApiErrorProvider } from '@/contexts/ApiErrorContext';
|
import { ApiErrorProvider } from '@/contexts/ApiErrorContext';
|
||||||
import { FCMProvider } from '@/contexts/FCMProvider';
|
import { FCMProvider } from '@/contexts/FCMProvider';
|
||||||
|
import { DevFillProvider, DevToolbar } from '@/components/dev';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected Layout
|
* Protected Layout
|
||||||
@@ -40,7 +41,10 @@ export default function ProtectedLayout({
|
|||||||
<RootProvider>
|
<RootProvider>
|
||||||
<ApiErrorProvider>
|
<ApiErrorProvider>
|
||||||
<FCMProvider>
|
<FCMProvider>
|
||||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
<DevFillProvider>
|
||||||
|
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||||
|
<DevToolbar />
|
||||||
|
</DevFillProvider>
|
||||||
</FCMProvider>
|
</FCMProvider>
|
||||||
</ApiErrorProvider>
|
</ApiErrorProvider>
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { LayoutDashboard, Settings } from 'lucide-react';
|
import { LayoutDashboard, Settings } from 'lucide-react';
|
||||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
@@ -25,6 +25,7 @@ import { DEFAULT_DASHBOARD_SETTINGS } from './types';
|
|||||||
import { ScheduleDetailModal, DetailModal } from './modals';
|
import { ScheduleDetailModal, DetailModal } from './modals';
|
||||||
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
|
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
|
||||||
import { mockData } from './mockData';
|
import { mockData } from './mockData';
|
||||||
|
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare } from '@/hooks/useCEODashboard';
|
||||||
import {
|
import {
|
||||||
getMonthlyExpenseModalConfig,
|
getMonthlyExpenseModalConfig,
|
||||||
getCardManagementModalConfig,
|
getCardManagementModalConfig,
|
||||||
@@ -35,8 +36,61 @@ import {
|
|||||||
|
|
||||||
export function CEODashboard() {
|
export function CEODashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading] = useState(false);
|
|
||||||
const [data] = useState<CEODashboardData>(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<CEODashboardData>(() => ({
|
||||||
|
...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);
|
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||||
|
|||||||
@@ -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 생성
|
// 계산 방식에 따른 조건부 calculationCards 생성
|
||||||
const calculationCards = calculationType === 'fixed'
|
const calculationCards = calculationType === 'fixed'
|
||||||
? {
|
? {
|
||||||
|
|||||||
175
src/components/dev/DevFillContext.tsx
Normal file
175
src/components/dev/DevFillContext.tsx
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
// 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<void>;
|
||||||
|
|
||||||
|
// 등록된 페이지 확인
|
||||||
|
hasRegisteredForm: (pageType: DevFillPageType) => boolean;
|
||||||
|
|
||||||
|
// 플로우 데이터 (이전 단계에서 생성된 ID 저장)
|
||||||
|
flowData: FlowData;
|
||||||
|
setFlowData: (data: Partial<FlowData>) => void;
|
||||||
|
clearFlowData: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플로우 간 전달 데이터
|
||||||
|
interface FlowData {
|
||||||
|
quoteId?: number;
|
||||||
|
orderId?: number;
|
||||||
|
workOrderId?: number;
|
||||||
|
lotNo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DevFillContext = createContext<DevFillContextType | null>(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<DevFillPageType | null>(null);
|
||||||
|
|
||||||
|
// 등록된 폼 채우기 함수들
|
||||||
|
const [fillFunctions, setFillFunctions] = useState<Map<DevFillPageType, FillFormFunction>>(new Map());
|
||||||
|
|
||||||
|
// 플로우 데이터
|
||||||
|
const [flowData, setFlowDataState] = useState<FlowData>({});
|
||||||
|
|
||||||
|
// 툴바 표시 상태 저장
|
||||||
|
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<FlowData>) => {
|
||||||
|
setFlowDataState(prev => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 플로우 데이터 초기화
|
||||||
|
const clearFlowData = useCallback(() => {
|
||||||
|
setFlowDataState({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 비활성화 시 null 반환하지 않고 children만 렌더링
|
||||||
|
// (DevToolbar에서 isEnabled 체크)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DevFillContext.Provider
|
||||||
|
value={{
|
||||||
|
isEnabled,
|
||||||
|
setIsEnabled: () => {}, // 환경변수로 제어하므로 런타임 변경 불가
|
||||||
|
isVisible,
|
||||||
|
setIsVisible: handleSetIsVisible,
|
||||||
|
currentPage,
|
||||||
|
setCurrentPage,
|
||||||
|
registerFillForm,
|
||||||
|
unregisterFillForm,
|
||||||
|
fillForm,
|
||||||
|
hasRegisteredForm,
|
||||||
|
flowData,
|
||||||
|
setFlowData,
|
||||||
|
clearFlowData,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DevFillContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook
|
||||||
|
export function useDevFillContext() {
|
||||||
|
const context = useContext(DevFillContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDevFillContext must be used within a DevFillProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
235
src/components/dev/DevToolbar.tsx
Normal file
235
src/components/dev/DevToolbar.tsx
Normal file
@@ -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<DevFillPageType | null>(null);
|
||||||
|
|
||||||
|
// 비활성화 시 렌더링하지 않음
|
||||||
|
if (!isEnabled) return null;
|
||||||
|
|
||||||
|
// 숨김 상태일 때 작은 버튼만 표시
|
||||||
|
if (!isVisible) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[9999]">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-yellow-100 border-yellow-400 text-yellow-800 hover:bg-yellow-200 shadow-lg"
|
||||||
|
onClick={() => setIsVisible(true)}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-1" />
|
||||||
|
Dev
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 페이지 타입 감지
|
||||||
|
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 (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[9999]">
|
||||||
|
<div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-2xl overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-yellow-100 border-b border-yellow-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="bg-yellow-200 border-yellow-500 text-yellow-800">
|
||||||
|
DEV MODE
|
||||||
|
</Badge>
|
||||||
|
{detectedPage && (
|
||||||
|
<span className="text-sm text-yellow-700">
|
||||||
|
현재: <strong>{detectedPage.label}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasFlowData && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{flowData.quoteId && `견적#${flowData.quoteId}`}
|
||||||
|
{flowData.orderId && ` → 수주#${flowData.orderId}`}
|
||||||
|
{flowData.workOrderId && ` → 작업#${flowData.workOrderId}`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{hasFlowData && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 text-yellow-700 hover:bg-yellow-200"
|
||||||
|
onClick={clearFlowData}
|
||||||
|
title="플로우 초기화"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 text-yellow-700 hover:bg-yellow-200"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 text-yellow-700 hover:bg-yellow-200"
|
||||||
|
onClick={() => setIsVisible(false)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-3">
|
||||||
|
{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 (
|
||||||
|
<div key={step.type} className="flex items-center">
|
||||||
|
{index > 0 && <span className="text-yellow-400 mx-1">→</span>}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
className="opacity-50"
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-1" />
|
||||||
|
{step.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.type} className="flex items-center">
|
||||||
|
{index > 0 && <span className="text-yellow-400 mx-1">→</span>}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isActive ? 'default' : 'outline'}
|
||||||
|
disabled={!isActive || !isRegistered || isCurrentLoading}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? 'bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-600'
|
||||||
|
: 'border-yellow-300 text-yellow-700 hover:bg-yellow-100'
|
||||||
|
}
|
||||||
|
onClick={() => handleFillForm(step.type)}
|
||||||
|
>
|
||||||
|
{isCurrentLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon className="w-4 h-4 mr-1" />
|
||||||
|
)}
|
||||||
|
{step.label}
|
||||||
|
{isActive && ' 채우기'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 안내 메시지 */}
|
||||||
|
{isExpanded && !activePage && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<p className="text-xs text-yellow-600">
|
||||||
|
견적/수주/작업지시/출하 페이지에서 활성화됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DevToolbar;
|
||||||
79
src/components/dev/generators/index.ts
Normal file
79
src/components/dev/generators/index.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 샘플 데이터 생성 공통 유틸리티
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 랜덤 선택
|
||||||
|
export function randomPick<T>(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)}`;
|
||||||
|
}
|
||||||
99
src/components/dev/generators/orderData.ts
Normal file
99
src/components/dev/generators/orderData.ts
Normal file
@@ -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<OrderFormData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateOrderData(options: GenerateOrderDataOptions = {}): Partial<OrderFormData> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
126
src/components/dev/generators/quoteData.ts
Normal file
126
src/components/dev/generators/quoteData.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
77
src/components/dev/generators/shipmentData.ts
Normal file
77
src/components/dev/generators/shipmentData.ts
Normal file
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/components/dev/generators/workOrderData.ts
Normal file
46
src/components/dev/generators/workOrderData.ts
Normal file
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/components/dev/index.ts
Normal file
16
src/components/dev/index.ts
Normal file
@@ -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';
|
||||||
77
src/components/dev/useDevFill.ts
Normal file
77
src/components/dev/useDevFill.ts
Normal file
@@ -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 <form>...</form>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useDevFillContext, type DevFillPageType } from './DevFillContext';
|
||||||
|
|
||||||
|
type FillFormCallback<T = unknown> = (data: T) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DevFill hook
|
||||||
|
*
|
||||||
|
* @param pageType - 현재 페이지 타입
|
||||||
|
* @param onFill - 데이터 채우기 콜백 (DevToolbar에서 호출됨)
|
||||||
|
*/
|
||||||
|
export function useDevFill<T = unknown>(
|
||||||
|
pageType: DevFillPageType,
|
||||||
|
onFill: FillFormCallback<T>
|
||||||
|
) {
|
||||||
|
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;
|
||||||
@@ -61,6 +61,8 @@ import { type QuotationForSelect, type QuotationItem } from "./actions";
|
|||||||
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
|
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
|
||||||
import { formatAmount } from "@/utils/formatAmount";
|
import { formatAmount } from "@/utils/formatAmount";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useDevFill } from "@/components/dev";
|
||||||
|
import { generateOrderDataFull } from "@/components/dev/generators/orderData";
|
||||||
|
|
||||||
// 수주 폼 데이터 타입
|
// 수주 폼 데이터 타입
|
||||||
export interface OrderFormData {
|
export interface OrderFormData {
|
||||||
@@ -218,6 +220,24 @@ export function OrderRegistration({
|
|||||||
}));
|
}));
|
||||||
}, [form.items, form.discountRate]);
|
}, [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) => {
|
const handleQuotationSelect = (quotation: QuotationForSelect) => {
|
||||||
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
|
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import type {
|
|||||||
VehicleTonnageOption,
|
VehicleTonnageOption,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
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 }[] = [
|
const priorityOptions: { value: ShipmentPriority; label: string }[] = [
|
||||||
@@ -115,6 +118,38 @@ export function ShipmentCreate() {
|
|||||||
loadOptions();
|
loadOptions();
|
||||||
}, [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) => {
|
const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -28,9 +28,12 @@ import { AssigneeSelectModal } from './AssigneeSelectModal';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
import { createWorkOrder, getProcessOptions, type ProcessOption } from './actions';
|
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 { workOrderCreateConfig } from './workOrderConfig';
|
||||||
|
|
||||||
|
import { useDevFill } from '@/components/dev';
|
||||||
|
import { generateWorkOrderData } from '@/components/dev/generators/workOrderData';
|
||||||
|
|
||||||
// Validation 에러 타입
|
// Validation 에러 타입
|
||||||
interface ValidationErrors {
|
interface ValidationErrors {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
@@ -113,6 +116,32 @@ export function WorkOrderCreate() {
|
|||||||
loadProcessOptions();
|
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) => {
|
const handleSelectOrder = (order: SalesOrder) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import { getClients } from "../accounting/VendorManagement/actions";
|
|||||||
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||||
import type { Vendor } from "../accounting/VendorManagement";
|
import type { Vendor } from "../accounting/VendorManagement";
|
||||||
import type { BomMaterial, CalculationResults } from "./types";
|
import type { BomMaterial, CalculationResults } from "./types";
|
||||||
|
import { useDevFill } from "@/components/dev";
|
||||||
|
import { generateQuoteData } from "@/components/dev/generators/quoteData";
|
||||||
|
|
||||||
// 견적 항목 타입
|
// 견적 항목 타입
|
||||||
export interface QuoteItem {
|
export interface QuoteItem {
|
||||||
@@ -203,6 +205,20 @@ export function QuoteRegistration({
|
|||||||
// 현장명 자동완성 목록 상태
|
// 현장명 자동완성 목록 상태
|
||||||
const [siteNames, setSiteNames] = useState<string[]>([]);
|
const [siteNames, setSiteNames] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 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로 최적화)
|
// 수량 반영 총합계 계산 (useMemo로 최적화)
|
||||||
const calculatedGrandTotal = useMemo(() => {
|
const calculatedGrandTotal = useMemo(() => {
|
||||||
if (!calculationResults?.items) return 0;
|
if (!calculationResults?.items) return 0;
|
||||||
@@ -1081,32 +1097,32 @@ export function QuoteRegistration({
|
|||||||
const formItem = formData.items[itemResult.index];
|
const formItem = formData.items[itemResult.index];
|
||||||
const product = finishedGoods.find(fg => fg.item_code === formItem?.productName);
|
const product = finishedGoods.find(fg => fg.item_code === formItem?.productName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="border border-green-200 rounded-lg p-4 bg-white">
|
<div key={idx} className="border border-green-200 rounded-lg p-4 bg-white">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="bg-green-100">
|
<Badge variant="outline" className="bg-green-100">
|
||||||
견적 {itemResult.index + 1}
|
견적 {itemResult.index + 1}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{itemResult.result.finished_goods?.name || product?.item_name || formItem?.productName || "-"}
|
{itemResult.result.finished_goods?.name || product?.item_name || formItem?.productName || "-"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
({itemResult.result.finished_goods?.code || formItem?.productName || "-"})
|
({itemResult.result.finished_goods?.code || formItem?.productName || "-"})
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
단가: {(itemResult.result.grand_total || 0).toLocaleString()}원
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="font-semibold text-green-700">
|
||||||
<div className="text-sm text-muted-foreground">
|
합계: {((itemResult.result.grand_total || 0) * (formItem?.quantity || 1)).toLocaleString()}원
|
||||||
단가: {itemResult.result.grand_total.toLocaleString()}원
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
</div>
|
(×{formItem?.quantity || 1})
|
||||||
<div className="font-semibold text-green-700">
|
</span>
|
||||||
합계: {((itemResult.result.grand_total || 0) * (formItem?.quantity || 1)).toLocaleString()}원
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
|
||||||
(×{formItem?.quantity || 1})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* BOM 상세 내역 */}
|
{/* BOM 상세 내역 */}
|
||||||
{itemResult.result.items && itemResult.result.items.length > 0 && (
|
{itemResult.result.items && itemResult.result.items.length > 0 && (
|
||||||
|
|||||||
784
src/hooks/useCEODashboard.ts
Normal file
784
src/hooks/useCEODashboard.ts
Normal file
@@ -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<T>(endpoint: string): Promise<T> {
|
||||||
|
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<DailyReportData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const apiData = await fetchApi<DailyReportApiResponse>('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<ReceivableData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const apiData = await fetchApi<ReceivablesApiResponse>('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<DebtCollectionData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const apiData = await fetchApi<BadDebtApiResponse>('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<MonthlyExpenseData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const apiData = await fetchApi<ExpectedExpenseApiResponse>('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<CardManagementData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const apiData = await fetchApi<CardTransactionApiResponse>('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<TodayIssueItem[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const apiData = await fetchApi<StatusBoardApiResponse>('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<TodayIssueData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const apiData = await fetchApi<TodayIssueApiResponse>(`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<CalendarData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<CalendarApiResponse>(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<VatData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<VatApiResponse>(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<EntertainmentData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<EntertainmentApiResponse>(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<WelfareData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<WelfareApiResponse>(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<DailyReportData | null>(null);
|
||||||
|
const [dailyReportLoading, setDailyReportLoading] = useState(enableDailyReport);
|
||||||
|
const [dailyReportError, setDailyReportError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [receivableData, setReceivableData] = useState<ReceivableData | null>(null);
|
||||||
|
const [receivableLoading, setReceivableLoading] = useState(enableReceivable);
|
||||||
|
const [receivableError, setReceivableError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [debtCollectionData, setDebtCollectionData] = useState<DebtCollectionData | null>(null);
|
||||||
|
const [debtCollectionLoading, setDebtCollectionLoading] = useState(enableDebtCollection);
|
||||||
|
const [debtCollectionError, setDebtCollectionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [monthlyExpenseData, setMonthlyExpenseData] = useState<MonthlyExpenseData | null>(null);
|
||||||
|
const [monthlyExpenseLoading, setMonthlyExpenseLoading] = useState(enableMonthlyExpense);
|
||||||
|
const [monthlyExpenseError, setMonthlyExpenseError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [cardManagementData, setCardManagementData] = useState<CardManagementData | null>(null);
|
||||||
|
const [cardManagementLoading, setCardManagementLoading] = useState(enableCardManagement);
|
||||||
|
const [cardManagementError, setCardManagementError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [statusBoardData, setStatusBoardData] = useState<TodayIssueItem[] | null>(null);
|
||||||
|
const [statusBoardLoading, setStatusBoardLoading] = useState(enableStatusBoard);
|
||||||
|
const [statusBoardError, setStatusBoardError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 개별 fetch 함수들
|
||||||
|
const fetchDailyReport = useCallback(async () => {
|
||||||
|
if (!enableDailyReport) return;
|
||||||
|
try {
|
||||||
|
setDailyReportLoading(true);
|
||||||
|
setDailyReportError(null);
|
||||||
|
const apiData = await fetchApi<DailyReportApiResponse>('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<ReceivablesApiResponse>('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<BadDebtApiResponse>('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<ExpectedExpenseApiResponse>('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<CardTransactionApiResponse>('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<StatusBoardApiResponse>('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,
|
||||||
|
};
|
||||||
|
}
|
||||||
6
src/lib/api/dashboard/index.ts
Normal file
6
src/lib/api/dashboard/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* CEO Dashboard API 모듈 export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export * from './transformers';
|
||||||
642
src/lib/api/dashboard/transformers.ts
Normal file
642
src/lib/api/dashboard/transformers.ts
Normal file
@@ -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),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
286
src/lib/api/dashboard/types.ts
Normal file
286
src/lib/api/dashboard/types.ts
Normal file
@@ -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<string, ExpenseSummaryItem>;
|
||||||
|
by_transaction_type: Record<string, ExpenseSummaryItem>;
|
||||||
|
by_month: Record<string, ExpenseSummaryItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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<T> {
|
||||||
|
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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user