feat: [CEO 대시보드] API 연동 + 섹션 확장 + SummaryNavBar
- 접대비/복리후생비/매출채권/캘린더 섹션 API 연동 - SummaryNavBar 추가 + mockData/modalConfigs 대규모 리팩토링 - Dashboard transformers 도메인별 분리 - 상세 모달 ScheduleDetailModal 추가
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
import { useState, useCallback, useEffect, useMemo, type RefCallback } 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 { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -32,24 +32,27 @@ import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailM
|
|||||||
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } from './types';
|
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } 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 { LazySection } from './LazySection';
|
import { LazySection } from './LazySection';
|
||||||
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail } from '@/hooks/useCEODashboard';
|
import { EmptySection } from './components';
|
||||||
|
import { SummaryNavBar } from './SummaryNavBar';
|
||||||
|
import { useSectionSummary } from './useSectionSummary';
|
||||||
|
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useEntertainmentDetail, useWelfare, useWelfareDetail, useVatDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
|
||||||
import { useCardManagementModals } from '@/hooks/useCardManagementModals';
|
import { useCardManagementModals } from '@/hooks/useCardManagementModals';
|
||||||
import {
|
import { getCardManagementModalConfigWithData } from './modalConfigs';
|
||||||
getMonthlyExpenseModalConfig,
|
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
|
||||||
getCardManagementModalConfig,
|
import { toast } from 'sonner';
|
||||||
getEntertainmentModalConfig,
|
|
||||||
getWelfareModalConfig,
|
|
||||||
getVatModalConfig,
|
|
||||||
} from './modalConfigs';
|
|
||||||
|
|
||||||
export function CEODashboard() {
|
export function CEODashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// API 데이터 Hook (Phase 1 섹션들)
|
// API 데이터 Hook
|
||||||
const apiData = useCEODashboard({
|
const apiData = useCEODashboard({
|
||||||
cardManagementFallback: mockData.cardManagement,
|
salesStatus: true,
|
||||||
|
purchaseStatus: true,
|
||||||
|
dailyProduction: true,
|
||||||
|
unshipped: true,
|
||||||
|
construction: true,
|
||||||
|
dailyAttendance: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// TodayIssue API Hook (Phase 2)
|
// TodayIssue API Hook (Phase 2)
|
||||||
@@ -79,6 +82,12 @@ export function CEODashboard() {
|
|||||||
apiData.monthlyExpense.loading ||
|
apiData.monthlyExpense.loading ||
|
||||||
apiData.cardManagement.loading ||
|
apiData.cardManagement.loading ||
|
||||||
apiData.statusBoard.loading ||
|
apiData.statusBoard.loading ||
|
||||||
|
apiData.salesStatus.loading ||
|
||||||
|
apiData.purchaseStatus.loading ||
|
||||||
|
apiData.dailyProduction.loading ||
|
||||||
|
apiData.unshipped.loading ||
|
||||||
|
apiData.construction.loading ||
|
||||||
|
apiData.dailyAttendance.loading ||
|
||||||
todayIssueData.loading ||
|
todayIssueData.loading ||
|
||||||
calendarData.loading ||
|
calendarData.loading ||
|
||||||
vatData.loading ||
|
vatData.loading ||
|
||||||
@@ -87,35 +96,37 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
}, [apiData, 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)
|
// API 데이터만으로 구성 (mock 제거 — API 미응답 시 undefined → 빈 상태 UI)
|
||||||
const data = useMemo<CEODashboardData>(() => ({
|
const data = useMemo<CEODashboardData>(() => ({
|
||||||
...mockData,
|
|
||||||
// Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback
|
|
||||||
// TODO: 자금현황 카드 변경 (일일일보/미수금/미지급금/당월예상지출) - 새 API 구현 후 교체
|
|
||||||
dailyReport: mockData.dailyReport,
|
|
||||||
// TODO: D1.7 카드 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체
|
|
||||||
// cardManagement: 카드/경조사/상품권/접대비 (기존: 카드/가지급금/법인세/종합세)
|
|
||||||
// entertainment: 주말심야/기피업종/고액결제/증빙미비 (기존: 매출/한도/잔여한도/사용금액)
|
|
||||||
// welfare: 비과세초과/사적사용/특정인편중/한도초과 (기존: 한도/잔여한도/사용금액)
|
|
||||||
// receivable: 누적/당월/거래처/Top3 (기존: 누적/당월/거래처현황)
|
|
||||||
receivable: mockData.receivable,
|
|
||||||
debtCollection: apiData.debtCollection.data ?? mockData.debtCollection,
|
|
||||||
monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense,
|
|
||||||
cardManagement: mockData.cardManagement,
|
|
||||||
// Phase 2 섹션들 (API 연동 완료 - 목업 fallback 제거)
|
|
||||||
todayIssue: apiData.statusBoard.data ?? [],
|
todayIssue: apiData.statusBoard.data ?? [],
|
||||||
todayIssueList: todayIssueData.data?.items ?? [],
|
todayIssueList: todayIssueData.data?.items ?? [],
|
||||||
calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules,
|
dailyReport: apiData.dailyReport.data ?? undefined,
|
||||||
vat: vatData.data ?? mockData.vat,
|
monthlyExpense: apiData.monthlyExpense.data ?? undefined,
|
||||||
entertainment: mockData.entertainment,
|
cardManagement: apiData.cardManagement.data ?? undefined,
|
||||||
welfare: mockData.welfare,
|
entertainment: entertainmentData.data ?? undefined,
|
||||||
// 신규 섹션 (API 미구현 - mock 데이터)
|
welfare: welfareData.data ?? undefined,
|
||||||
salesStatus: mockData.salesStatus,
|
receivable: apiData.receivable.data ?? undefined,
|
||||||
purchaseStatus: mockData.purchaseStatus,
|
debtCollection: apiData.debtCollection.data ?? undefined,
|
||||||
dailyProduction: mockData.dailyProduction,
|
vat: vatData.data ?? undefined,
|
||||||
unshipped: mockData.unshipped,
|
calendarSchedules: calendarData.data?.items ?? undefined,
|
||||||
dailyAttendance: mockData.dailyAttendance,
|
salesStatus: apiData.salesStatus.data ?? {
|
||||||
}), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data, mockData]);
|
cumulativeSales: 0, achievementRate: 0, yoyChange: 0, monthlySales: 0,
|
||||||
|
monthlyTrend: [], clientSales: [], dailyItems: [], dailyTotal: 0,
|
||||||
|
},
|
||||||
|
purchaseStatus: apiData.purchaseStatus.data ?? {
|
||||||
|
cumulativePurchase: 0, unpaidAmount: 0, yoyChange: 0,
|
||||||
|
monthlyTrend: [], materialRatio: [], dailyItems: [], dailyTotal: 0,
|
||||||
|
},
|
||||||
|
dailyProduction: apiData.dailyProduction.data ?? {
|
||||||
|
date: '', processes: [],
|
||||||
|
shipment: { expectedAmount: 0, expectedCount: 0, actualAmount: 0, actualCount: 0 },
|
||||||
|
},
|
||||||
|
unshipped: apiData.unshipped.data ?? { items: [] },
|
||||||
|
constructionData: apiData.construction.data ?? { thisMonth: 0, completed: 0, items: [] },
|
||||||
|
dailyAttendance: apiData.dailyAttendance.data ?? {
|
||||||
|
present: 0, onLeave: 0, late: 0, absent: 0, employees: [],
|
||||||
|
},
|
||||||
|
}), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data]);
|
||||||
|
|
||||||
// 일정 상세 모달 상태
|
// 일정 상세 모달 상태
|
||||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||||
@@ -125,17 +136,24 @@ export function CEODashboard() {
|
|||||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||||
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>(DEFAULT_DASHBOARD_SETTINGS);
|
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>(DEFAULT_DASHBOARD_SETTINGS);
|
||||||
|
|
||||||
|
// EntertainmentDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
|
||||||
|
const entertainmentDetailData = useEntertainmentDetail();
|
||||||
|
|
||||||
// WelfareDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
|
// WelfareDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
|
||||||
const welfareDetailData = useWelfareDetail({
|
const welfareDetailData = useWelfareDetail({
|
||||||
calculationType: dashboardSettings.welfare.calculationType,
|
calculationType: dashboardSettings.welfare.calculationType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// VatDetail Hook (부가세 상세 모달용 API)
|
||||||
|
const vatDetailData = useVatDetail();
|
||||||
|
|
||||||
// MonthlyExpenseDetail Hook (당월 예상 지출 모달용 상세 API)
|
// MonthlyExpenseDetail Hook (당월 예상 지출 모달용 상세 API)
|
||||||
const monthlyExpenseDetailData = useMonthlyExpenseDetail();
|
const monthlyExpenseDetailData = useMonthlyExpenseDetail();
|
||||||
|
|
||||||
// 상세 모달 상태
|
// 상세 모달 상태
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
const [detailModalConfig, setDetailModalConfig] = useState<DetailModalConfig | null>(null);
|
const [detailModalConfig, setDetailModalConfig] = useState<DetailModalConfig | null>(null);
|
||||||
|
const [currentModalCardId, setCurrentModalCardId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 클라이언트에서만 localStorage에서 설정 불러오기 (hydration 에러 방지)
|
// 클라이언트에서만 localStorage에서 설정 불러오기 (hydration 에러 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -204,17 +222,87 @@ export function CEODashboard() {
|
|||||||
const handleDetailModalClose = useCallback(() => {
|
const handleDetailModalClose = useCallback(() => {
|
||||||
setIsDetailModalOpen(false);
|
setIsDetailModalOpen(false);
|
||||||
setDetailModalConfig(null);
|
setDetailModalConfig(null);
|
||||||
|
setCurrentModalCardId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달)
|
// 당월 예상 지출 카드 클릭 - API 데이터로 모달 열기
|
||||||
// TODO: D1.7 모달 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체
|
const handleMonthlyExpenseCardClick = useCallback(async (cardId: string) => {
|
||||||
const handleMonthlyExpenseCardClick = useCallback((cardId: string) => {
|
const config = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId);
|
||||||
const config = getMonthlyExpenseModalConfig(cardId);
|
|
||||||
if (config) {
|
if (config) {
|
||||||
|
setCurrentModalCardId(cardId);
|
||||||
setDetailModalConfig(config);
|
setDetailModalConfig(config);
|
||||||
setIsDetailModalOpen(true);
|
setIsDetailModalOpen(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [monthlyExpenseDetailData]);
|
||||||
|
|
||||||
|
// 모달 날짜/검색 필터 변경 → 재조회 (당월 예상 지출 + 가지급금 + 접대비 상세)
|
||||||
|
const handleDateFilterChange = useCallback(async (params: { startDate: string; endDate: string; search: string }) => {
|
||||||
|
if (!currentModalCardId) return;
|
||||||
|
|
||||||
|
// cm2: 가지급금 상세 모달 날짜 필터
|
||||||
|
if (currentModalCardId === 'cm2') {
|
||||||
|
try {
|
||||||
|
const modalData = await cardManagementModals.fetchModalData('cm2', {
|
||||||
|
start_date: params.startDate,
|
||||||
|
end_date: params.endDate,
|
||||||
|
});
|
||||||
|
const config = getCardManagementModalConfigWithData('cm2', modalData);
|
||||||
|
if (config) {
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 실패 시 기존 config 유지
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 복리후생비 상세 모달 날짜 필터
|
||||||
|
if (currentModalCardId === 'welfare_detail') {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/proxy/welfare/detail?calculation_type=${dashboardSettings.welfare.calculationType}&start_date=${params.startDate}&end_date=${params.endDate}`,
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
const config = transformWelfareDetailResponse(result.data);
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 실패 시 기존 config 유지
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 접대비 상세 모달 날짜 필터
|
||||||
|
if (currentModalCardId === 'entertainment_detail') {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/proxy/entertainment/detail?company_type=${dashboardSettings.entertainment.companyType}&start_date=${params.startDate}&end_date=${params.endDate}`,
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
const config = transformEntertainmentDetailResponse(result.data);
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 실패 시 기존 config 유지
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 당월 예상 지출 모달 날짜 필터
|
||||||
|
const config = await monthlyExpenseDetailData.fetchData(
|
||||||
|
currentModalCardId as MonthlyExpenseCardId,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
if (config) {
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
}
|
||||||
|
}, [currentModalCardId, monthlyExpenseDetailData, cardManagementModals, dashboardSettings.entertainment, dashboardSettings.welfare]);
|
||||||
|
|
||||||
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
|
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
|
||||||
const handleMonthlyExpenseClick = useCallback(() => {
|
const handleMonthlyExpenseClick = useCallback(() => {
|
||||||
@@ -222,42 +310,87 @@ export function CEODashboard() {
|
|||||||
|
|
||||||
// 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달
|
// 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달
|
||||||
// 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달
|
// 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달
|
||||||
const handleCardManagementCardClick = useCallback((cardId: string) => {
|
const handleCardManagementCardClick = useCallback(async (cardId: string) => {
|
||||||
const config = getCardManagementModalConfig('cm2');
|
try {
|
||||||
if (config) {
|
const modalData = await cardManagementModals.fetchModalData('cm2');
|
||||||
setDetailModalConfig(config);
|
const config = getCardManagementModalConfigWithData('cm2', modalData);
|
||||||
setIsDetailModalOpen(true);
|
if (config) {
|
||||||
|
setCurrentModalCardId('cm2');
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
} else {
|
||||||
|
toast.error('데이터를 불러올 수 없습니다');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('데이터를 불러올 수 없습니다');
|
||||||
}
|
}
|
||||||
}, []);
|
}, [cardManagementModals]);
|
||||||
|
|
||||||
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
|
// 접대비 현황 카드 클릭 - API 데이터로 모달 열기
|
||||||
const handleEntertainmentCardClick = useCallback((cardId: string) => {
|
const handleEntertainmentCardClick = useCallback(async (cardId: string) => {
|
||||||
const config = getEntertainmentModalConfig(cardId);
|
setCurrentModalCardId('entertainment_detail');
|
||||||
if (config) {
|
const apiConfig = await entertainmentDetailData.refetch();
|
||||||
setDetailModalConfig(config);
|
if (apiConfig) {
|
||||||
|
setDetailModalConfig(apiConfig);
|
||||||
setIsDetailModalOpen(true);
|
setIsDetailModalOpen(true);
|
||||||
|
} else {
|
||||||
|
toast.error('데이터를 불러올 수 없습니다');
|
||||||
}
|
}
|
||||||
}, []);
|
}, [entertainmentDetailData]);
|
||||||
|
|
||||||
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
|
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
|
||||||
// 복리후생비 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
|
|
||||||
const handleWelfareCardClick = useCallback(async () => {
|
const handleWelfareCardClick = useCallback(async () => {
|
||||||
// 1. 먼저 API에서 데이터 fetch 시도
|
const apiConfig = await welfareDetailData.refetch();
|
||||||
await welfareDetailData.refetch();
|
if (apiConfig) {
|
||||||
|
setDetailModalConfig(apiConfig);
|
||||||
|
setCurrentModalCardId('welfare_detail');
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
} else {
|
||||||
|
toast.error('데이터를 불러올 수 없습니다');
|
||||||
|
}
|
||||||
|
}, [welfareDetailData]);
|
||||||
|
|
||||||
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
|
// 신고기간 변경 시 API 재호출
|
||||||
const config = welfareDetailData.modalConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType);
|
const handlePeriodChange = useCallback(async (periodValue: string) => {
|
||||||
setDetailModalConfig(config);
|
// periodValue: "2026-quarter-1" → parse
|
||||||
setIsDetailModalOpen(true);
|
const parts = periodValue.split('-');
|
||||||
}, [welfareDetailData, dashboardSettings.welfare.calculationType]);
|
if (parts.length < 3) return;
|
||||||
|
const [year, periodType, period] = parts;
|
||||||
// 부가세 클릭 (모든 카드가 동일한 상세 모달)
|
try {
|
||||||
const handleVatClick = useCallback(() => {
|
const response = await fetch(
|
||||||
const config = getVatModalConfig();
|
`/api/proxy/vat/detail?period_type=${periodType}&year=${year}&period=${period}`,
|
||||||
setDetailModalConfig(config);
|
);
|
||||||
setIsDetailModalOpen(true);
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
const config = transformVatDetailResponse(result.data);
|
||||||
|
// 새 config에도 onPeriodChange 콜백 주입
|
||||||
|
if (config.periodSelect) {
|
||||||
|
config.periodSelect.onPeriodChange = handlePeriodChange;
|
||||||
|
}
|
||||||
|
setDetailModalConfig(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 실패 시 기존 config 유지
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 부가세 클릭 (모든 카드가 동일한 상세 모달) - API 데이터로 열기
|
||||||
|
const handleVatClick = useCallback(async () => {
|
||||||
|
setCurrentModalCardId('vat_detail');
|
||||||
|
const apiConfig = await vatDetailData.refetch();
|
||||||
|
if (apiConfig) {
|
||||||
|
if (apiConfig.periodSelect) {
|
||||||
|
apiConfig.periodSelect.onPeriodChange = handlePeriodChange;
|
||||||
|
}
|
||||||
|
setDetailModalConfig(apiConfig);
|
||||||
|
setIsDetailModalOpen(true);
|
||||||
|
} else {
|
||||||
|
toast.error('데이터를 불러올 수 없습니다');
|
||||||
|
}
|
||||||
|
}, [vatDetailData, handlePeriodChange]);
|
||||||
|
|
||||||
// 캘린더 일정 클릭 (기존 일정 수정)
|
// 캘린더 일정 클릭 (기존 일정 수정)
|
||||||
const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => {
|
const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => {
|
||||||
setSelectedSchedule(schedule);
|
setSelectedSchedule(schedule);
|
||||||
@@ -276,8 +409,8 @@ export function CEODashboard() {
|
|||||||
setSelectedSchedule(null);
|
setSelectedSchedule(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 일정 저장
|
// 일정 저장 (optimistic update — refetch 없이 로컬 상태만 갱신)
|
||||||
const handleScheduleSave = useCallback((formData: {
|
const handleScheduleSave = useCallback(async (formData: {
|
||||||
title: string;
|
title: string;
|
||||||
department: string;
|
department: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@@ -288,21 +421,138 @@ export function CEODashboard() {
|
|||||||
color: string;
|
color: string;
|
||||||
content: string;
|
content: string;
|
||||||
}) => {
|
}) => {
|
||||||
// TODO: API 호출하여 일정 저장
|
try {
|
||||||
setIsScheduleModalOpen(false);
|
// schedule_ 접두사에서 실제 ID 추출
|
||||||
setSelectedSchedule(null);
|
const rawId = selectedSchedule?.id;
|
||||||
}, []);
|
const numericId = rawId?.startsWith('schedule_') ? rawId.replace('schedule_', '') : null;
|
||||||
|
|
||||||
// 일정 삭제
|
const body = {
|
||||||
const handleScheduleDelete = useCallback((id: string) => {
|
title: formData.title,
|
||||||
// TODO: API 호출하여 일정 삭제
|
description: formData.content,
|
||||||
setIsScheduleModalOpen(false);
|
start_date: formData.startDate,
|
||||||
setSelectedSchedule(null);
|
end_date: formData.endDate,
|
||||||
}, []);
|
start_time: formData.isAllDay ? null : (formData.startTime || null),
|
||||||
|
end_time: formData.isAllDay ? null : (formData.endTime || null),
|
||||||
|
is_all_day: formData.isAllDay,
|
||||||
|
color: formData.color || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = numericId
|
||||||
|
? `/api/proxy/calendar/schedules/${numericId}`
|
||||||
|
: '/api/proxy/calendar/schedules';
|
||||||
|
const method = numericId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to save schedule');
|
||||||
|
|
||||||
|
// API 응답에서 실제 ID 추출 (없으면 임시 ID)
|
||||||
|
let savedId = numericId;
|
||||||
|
try {
|
||||||
|
const result = await response.json();
|
||||||
|
savedId = result.data?.id?.toString() || numericId || `temp_${Date.now()}`;
|
||||||
|
} catch {
|
||||||
|
savedId = numericId || `temp_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSchedule: CalendarScheduleItem = {
|
||||||
|
id: `schedule_${savedId}`,
|
||||||
|
title: formData.title,
|
||||||
|
startDate: formData.startDate,
|
||||||
|
endDate: formData.endDate,
|
||||||
|
startTime: formData.isAllDay ? undefined : formData.startTime,
|
||||||
|
endTime: formData.isAllDay ? undefined : formData.endTime,
|
||||||
|
isAllDay: formData.isAllDay,
|
||||||
|
type: 'schedule',
|
||||||
|
department: formData.department !== 'all' ? formData.department : undefined,
|
||||||
|
color: formData.color,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistic update: loading 변화 없이 데이터만 갱신 → 캘린더만 리렌더
|
||||||
|
calendarData.setData((prev) => {
|
||||||
|
if (!prev) return { items: [updatedSchedule], totalCount: 1 };
|
||||||
|
if (numericId) {
|
||||||
|
// 수정: 기존 항목 교체
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((item) =>
|
||||||
|
item.id === rawId ? updatedSchedule : item
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 신규: 추가
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: [...prev.items, updatedSchedule],
|
||||||
|
totalCount: prev.totalCount + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 에러 시 서버 데이터로 동기화
|
||||||
|
calendarData.refetch();
|
||||||
|
} finally {
|
||||||
|
setIsScheduleModalOpen(false);
|
||||||
|
setSelectedSchedule(null);
|
||||||
|
}
|
||||||
|
}, [selectedSchedule, calendarData]);
|
||||||
|
|
||||||
|
// 일정 삭제 (optimistic update)
|
||||||
|
const handleScheduleDelete = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
// schedule_ 접두사에서 실제 ID 추출
|
||||||
|
const numericId = id.startsWith('schedule_') ? id.replace('schedule_', '') : id;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/proxy/calendar/schedules/${numericId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete schedule');
|
||||||
|
|
||||||
|
// Optimistic update: 삭제된 항목만 제거 → 캘린더만 리렌더
|
||||||
|
calendarData.setData((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.filter((item) => item.id !== id),
|
||||||
|
totalCount: Math.max(0, prev.totalCount - 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 에러 시 서버 데이터로 동기화
|
||||||
|
calendarData.refetch();
|
||||||
|
} finally {
|
||||||
|
setIsScheduleModalOpen(false);
|
||||||
|
setSelectedSchedule(null);
|
||||||
|
}
|
||||||
|
}, [calendarData]);
|
||||||
|
|
||||||
// 섹션 순서
|
// 섹션 순서
|
||||||
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
||||||
|
|
||||||
|
// 요약 네비게이션 바 훅
|
||||||
|
const { summaries, activeSectionKey, sectionRefs, scrollToSection } = useSectionSummary({
|
||||||
|
data,
|
||||||
|
sectionOrder,
|
||||||
|
dashboardSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 섹션 ref 수집 콜백
|
||||||
|
const setSectionRef = useCallback(
|
||||||
|
(key: SectionKey): RefCallback<HTMLDivElement> =>
|
||||||
|
(el) => {
|
||||||
|
if (el) {
|
||||||
|
sectionRefs.current.set(key, el);
|
||||||
|
} else {
|
||||||
|
sectionRefs.current.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sectionRefs],
|
||||||
|
);
|
||||||
|
|
||||||
// 섹션 렌더링 함수
|
// 섹션 렌더링 함수
|
||||||
const renderDashboardSection = (key: SectionKey): React.ReactNode => {
|
const renderDashboardSection = (key: SectionKey): React.ReactNode => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
@@ -315,12 +565,13 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'dailyReport':
|
case 'dailyReport':
|
||||||
if (!dashboardSettings.dailyReport) return null;
|
if (!dashboardSettings.dailyReport || !data.dailyReport) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key}>
|
<LazySection key={key}>
|
||||||
<EnhancedDailyReportSection
|
<EnhancedDailyReportSection
|
||||||
data={data.dailyReport}
|
data={data.dailyReport}
|
||||||
onClick={handleDailyReportClick}
|
onClick={handleDailyReportClick}
|
||||||
|
onExpenseDetailClick={() => handleMonthlyExpenseCardClick('me4')}
|
||||||
/>
|
/>
|
||||||
</LazySection>
|
</LazySection>
|
||||||
);
|
);
|
||||||
@@ -337,7 +588,7 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'monthlyExpense':
|
case 'monthlyExpense':
|
||||||
if (!dashboardSettings.monthlyExpense) return null;
|
if (!dashboardSettings.monthlyExpense || !data.monthlyExpense) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key}>
|
<LazySection key={key}>
|
||||||
<EnhancedMonthlyExpenseSection
|
<EnhancedMonthlyExpenseSection
|
||||||
@@ -348,7 +599,7 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'cardManagement':
|
case 'cardManagement':
|
||||||
if (!dashboardSettings.cardManagement) return null;
|
if (!dashboardSettings.cardManagement || !data.cardManagement) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key}>
|
<LazySection key={key}>
|
||||||
<CardManagementSection
|
<CardManagementSection
|
||||||
@@ -359,7 +610,7 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'entertainment':
|
case 'entertainment':
|
||||||
if (!dashboardSettings.entertainment.enabled) return null;
|
if (!dashboardSettings.entertainment.enabled || !data.entertainment) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key}>
|
<LazySection key={key}>
|
||||||
<EntertainmentSection
|
<EntertainmentSection
|
||||||
@@ -370,7 +621,7 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'welfare':
|
case 'welfare':
|
||||||
if (!dashboardSettings.welfare.enabled) return null;
|
if (!dashboardSettings.welfare.enabled || !data.welfare) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key}>
|
<LazySection key={key}>
|
||||||
<WelfareSection
|
<WelfareSection
|
||||||
@@ -381,7 +632,7 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'receivable':
|
case 'receivable':
|
||||||
if (!dashboardSettings.receivable) return null;
|
if (!dashboardSettings.receivable || !data.receivable) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key}>
|
<LazySection key={key}>
|
||||||
<ReceivableSection data={data.receivable} />
|
<ReceivableSection data={data.receivable} />
|
||||||
@@ -389,7 +640,7 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'debtCollection':
|
case 'debtCollection':
|
||||||
if (!dashboardSettings.debtCollection) return null;
|
if (!dashboardSettings.debtCollection || !data.debtCollection) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key}>
|
<LazySection key={key}>
|
||||||
<DebtCollectionSection data={data.debtCollection} />
|
<DebtCollectionSection data={data.debtCollection} />
|
||||||
@@ -397,7 +648,7 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'vat':
|
case 'vat':
|
||||||
if (!dashboardSettings.vat) return null;
|
if (!dashboardSettings.vat || !data.vat) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key}>
|
<LazySection key={key}>
|
||||||
<VatSection data={data.vat} onClick={handleVatClick} />
|
<VatSection data={data.vat} onClick={handleVatClick} />
|
||||||
@@ -405,7 +656,7 @@ export function CEODashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'calendar':
|
case 'calendar':
|
||||||
if (!dashboardSettings.calendar) return null;
|
if (!dashboardSettings.calendar || !data.calendarSchedules) return null;
|
||||||
return (
|
return (
|
||||||
<LazySection key={key} minHeight={500}>
|
<LazySection key={key} minHeight={500}>
|
||||||
<CalendarSection
|
<CalendarSection
|
||||||
@@ -516,17 +767,32 @@ export function CEODashboard() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SummaryNavBar
|
||||||
|
summaries={summaries}
|
||||||
|
activeSectionKey={activeSectionKey}
|
||||||
|
onChipClick={scrollToSection}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{sectionOrder.map(renderDashboardSection)}
|
{sectionOrder.map((key) => {
|
||||||
|
const node = renderDashboardSection(key);
|
||||||
|
if (!node) return null;
|
||||||
|
return (
|
||||||
|
<div key={key} ref={setSectionRef(key)} data-section-key={key}>
|
||||||
|
{node}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 일정 상세 모달 */}
|
{/* 일정 상세 모달 — schedule_ 접두사만 수정/삭제 가능 */}
|
||||||
<ScheduleDetailModal
|
<ScheduleDetailModal
|
||||||
isOpen={isScheduleModalOpen}
|
isOpen={isScheduleModalOpen}
|
||||||
onClose={handleScheduleModalClose}
|
onClose={handleScheduleModalClose}
|
||||||
schedule={selectedSchedule}
|
schedule={selectedSchedule}
|
||||||
onSave={handleScheduleSave}
|
onSave={handleScheduleSave}
|
||||||
onDelete={handleScheduleDelete}
|
onDelete={handleScheduleDelete}
|
||||||
|
isEditable={!selectedSchedule?.id || selectedSchedule.id === '' || selectedSchedule.id.startsWith('schedule_')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 항목 설정 모달 */}
|
{/* 항목 설정 모달 */}
|
||||||
@@ -543,6 +809,7 @@ export function CEODashboard() {
|
|||||||
isOpen={isDetailModalOpen}
|
isOpen={isDetailModalOpen}
|
||||||
onClose={handleDetailModalClose}
|
onClose={handleDetailModalClose}
|
||||||
config={detailModalConfig}
|
config={detailModalConfig}
|
||||||
|
onDateFilterChange={handleDateFilterChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
255
src/components/business/CEODashboard/SummaryNavBar.tsx
Normal file
255
src/components/business/CEODashboard/SummaryNavBar.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { SectionSummary, SummaryStatus } from './useSectionSummary';
|
||||||
|
import type { SectionKey } from './types';
|
||||||
|
|
||||||
|
/** 상태별 점(dot) 색상 */
|
||||||
|
const STATUS_DOT: Record<SummaryStatus, string> = {
|
||||||
|
normal: 'bg-green-500',
|
||||||
|
warning: 'bg-yellow-500',
|
||||||
|
danger: 'bg-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 상태별 칩 배경색 (비활성) */
|
||||||
|
const STATUS_BG: Record<SummaryStatus, string> = {
|
||||||
|
normal: 'bg-background border-border',
|
||||||
|
warning: 'bg-yellow-50 border-yellow-300 dark:bg-yellow-950/30 dark:border-yellow-700',
|
||||||
|
danger: 'bg-red-50 border-red-300 dark:bg-red-950/30 dark:border-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 상태별 칩 배경색 (활성) */
|
||||||
|
const STATUS_BG_ACTIVE: Record<SummaryStatus, string> = {
|
||||||
|
normal: 'bg-accent border-primary/40',
|
||||||
|
warning: 'bg-yellow-100 border-yellow-400 dark:bg-yellow-900/40 dark:border-yellow-600',
|
||||||
|
danger: 'bg-red-100 border-red-400 dark:bg-red-900/40 dark:border-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SummaryChipProps {
|
||||||
|
summary: SectionSummary;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryChip({ summary, isActive, onClick }: SummaryChipProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center gap-2 px-4 py-2.5 rounded-full text-sm font-medium whitespace-nowrap',
|
||||||
|
'border transition-all duration-200 shrink-0',
|
||||||
|
'hover:brightness-95 active:scale-[0.97]',
|
||||||
|
isActive
|
||||||
|
? cn(STATUS_BG_ACTIVE[summary.status], 'text-foreground shadow-sm')
|
||||||
|
: cn(STATUS_BG[summary.status], 'text-muted-foreground'),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 상태 점 (확대) */}
|
||||||
|
<span className={cn('w-2.5 h-2.5 rounded-full shrink-0', STATUS_DOT[summary.status])} />
|
||||||
|
{/* 라벨 */}
|
||||||
|
<span className="truncate max-w-[6rem]">{summary.label}</span>
|
||||||
|
{/* 값 */}
|
||||||
|
<span className={cn(
|
||||||
|
'font-bold',
|
||||||
|
summary.status === 'danger' && 'text-red-600 dark:text-red-400',
|
||||||
|
summary.status === 'warning' && 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
)}>
|
||||||
|
{summary.value}
|
||||||
|
</span>
|
||||||
|
{/* 활성 하단 바 */}
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute bottom-0 left-3 right-3 h-[3px] rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADER_BOTTOM = 100; // 헤더 하단 고정 위치 (px)
|
||||||
|
const BAR_HEIGHT = 56; // 요약바 높이 (px) — 고령 친화 확대
|
||||||
|
const SCROLL_STEP = 200; // 화살표 버튼 클릭 시 스크롤 이동량 (px)
|
||||||
|
|
||||||
|
interface SummaryNavBarProps {
|
||||||
|
summaries: SectionSummary[];
|
||||||
|
activeSectionKey: SectionKey | null;
|
||||||
|
onChipClick: (key: SectionKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryNavBar({ summaries, activeSectionKey, onChipClick }: SummaryNavBarProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isFixed, setIsFixed] = useState(false);
|
||||||
|
const [barRect, setBarRect] = useState({ left: 0, width: 0 });
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||||
|
|
||||||
|
// 스크롤 가능 여부 체크
|
||||||
|
const updateScrollButtons = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setCanScrollLeft(el.scrollLeft > 4);
|
||||||
|
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// sentinel 위치 감시: sentinel이 헤더 뒤로 지나가면 fixed 모드
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!sentinelRef.current) return;
|
||||||
|
const rect = sentinelRef.current.getBoundingClientRect();
|
||||||
|
const shouldFix = rect.top < HEADER_BOTTOM;
|
||||||
|
setIsFixed(shouldFix);
|
||||||
|
|
||||||
|
if (shouldFix) {
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
if (main) {
|
||||||
|
const mainRect = main.getBoundingClientRect();
|
||||||
|
const mainStyle = getComputedStyle(main);
|
||||||
|
const pl = parseFloat(mainStyle.paddingLeft) || 0;
|
||||||
|
const pr = parseFloat(mainStyle.paddingRight) || 0;
|
||||||
|
setBarRect({
|
||||||
|
left: mainRect.left + pl,
|
||||||
|
width: mainRect.width - pl - pr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', handleScroll, { passive: true });
|
||||||
|
handleScroll();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 칩 영역 스크롤 상태 감시
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
updateScrollButtons();
|
||||||
|
el.addEventListener('scroll', updateScrollButtons, { passive: true });
|
||||||
|
const ro = new ResizeObserver(updateScrollButtons);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', updateScrollButtons);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [updateScrollButtons, summaries]);
|
||||||
|
|
||||||
|
// 활성 칩 자동 스크롤 into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSectionKey || !scrollRef.current) return;
|
||||||
|
const chipEl = scrollRef.current.querySelector(`[data-chip-key="${activeSectionKey}"]`) as HTMLElement | null;
|
||||||
|
if (!chipEl) return;
|
||||||
|
|
||||||
|
const container = scrollRef.current;
|
||||||
|
const chipLeft = chipEl.offsetLeft;
|
||||||
|
const chipWidth = chipEl.offsetWidth;
|
||||||
|
const containerWidth = container.offsetWidth;
|
||||||
|
const scrollLeft = container.scrollLeft;
|
||||||
|
|
||||||
|
if (chipLeft < scrollLeft + 50 || chipLeft + chipWidth > scrollLeft + containerWidth - 50) {
|
||||||
|
container.scrollTo({
|
||||||
|
left: chipLeft - containerWidth / 2 + chipWidth / 2,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeSectionKey]);
|
||||||
|
|
||||||
|
const handleChipClick = useCallback(
|
||||||
|
(key: SectionKey) => {
|
||||||
|
onChipClick(key);
|
||||||
|
},
|
||||||
|
[onChipClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 화살표 버튼 핸들러
|
||||||
|
const scrollBy = useCallback((direction: 'left' | 'right') => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({
|
||||||
|
left: direction === 'left' ? -SCROLL_STEP : SCROLL_STEP,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (summaries.length === 0) return null;
|
||||||
|
|
||||||
|
const arrowBtnClass = cn(
|
||||||
|
'flex items-center justify-center w-8 h-8 rounded-full shrink-0',
|
||||||
|
'bg-muted/80 hover:bg-muted text-foreground',
|
||||||
|
'border border-border shadow-sm',
|
||||||
|
'transition-opacity duration-150',
|
||||||
|
);
|
||||||
|
|
||||||
|
const barContent = (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* 좌측 화살표 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="이전 항목"
|
||||||
|
className={cn(arrowBtnClass, !canScrollLeft && 'opacity-0 pointer-events-none')}
|
||||||
|
onClick={() => scrollBy('left')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 칩 목록 */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex items-center gap-2 overflow-x-auto flex-1"
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
>
|
||||||
|
{summaries.map((s) => (
|
||||||
|
<div key={s.key} data-chip-key={s.key}>
|
||||||
|
<SummaryChip
|
||||||
|
summary={s}
|
||||||
|
isActive={activeSectionKey === s.key}
|
||||||
|
onClick={() => handleChipClick(s.key)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 화살표 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="다음 항목"
|
||||||
|
className={cn(arrowBtnClass, !canScrollRight && 'opacity-0 pointer-events-none')}
|
||||||
|
onClick={() => scrollBy('right')}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* sentinel: 이 div가 헤더 뒤로 사라지면 fixed 모드 활성화 */}
|
||||||
|
<div ref={sentinelRef} className="h-0 w-full" />
|
||||||
|
|
||||||
|
{/* fixed일 때 레이아웃 공간 유지용 spacer */}
|
||||||
|
{isFixed && <div style={{ height: BAR_HEIGHT }} />}
|
||||||
|
|
||||||
|
{/* 실제 바 */}
|
||||||
|
<div
|
||||||
|
className="z-40 py-2.5 backdrop-blur-md bg-background/90 border-b border-border/50"
|
||||||
|
style={
|
||||||
|
isFixed
|
||||||
|
? {
|
||||||
|
position: 'fixed',
|
||||||
|
top: HEADER_BOTTOM,
|
||||||
|
left: barRect.left,
|
||||||
|
width: barRect.width,
|
||||||
|
}
|
||||||
|
: { position: 'relative' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{barContent}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -319,8 +319,8 @@ export const AmountCardItem = ({
|
|||||||
<div className="mt-auto space-y-0.5 text-xs text-muted-foreground">
|
<div className="mt-auto space-y-0.5 text-xs text-muted-foreground">
|
||||||
{card.subItems.map((item, idx) => (
|
{card.subItems.map((item, idx) => (
|
||||||
<div key={idx} className="flex justify-between gap-2">
|
<div key={idx} className="flex justify-between gap-2">
|
||||||
<span className="shrink-0">{item.label}</span>
|
<span className="min-w-0 truncate">{item.label}</span>
|
||||||
<span className="text-right">{typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}</span>
|
<span className="shrink-0 text-right">{typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -479,4 +479,19 @@ export function CollapsibleDashboardCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터가 없거나 API 미연동 섹션에 표시하는 빈 상태 컴포넌트
|
||||||
|
*/
|
||||||
|
export function EmptySection({ title, message = '데이터를 불러올 수 없습니다' }: { title: string; message?: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Info className="mb-3 h-8 w-8 opacity-40" />
|
||||||
|
<p className="text-sm font-medium">{title}</p>
|
||||||
|
<p className="mt-1 text-xs">{message}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ export const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
|
|||||||
annualLeave: '연차',
|
annualLeave: '연차',
|
||||||
vehicle: '차량',
|
vehicle: '차량',
|
||||||
equipment: '장비',
|
equipment: '장비',
|
||||||
purchase: '발주',
|
purchase: '발주', // [2026-03-03] 비활성화 — 설정 모달에서 숨김 처리 (STATUS_BOARD_HIDDEN_SETTINGS)
|
||||||
approvalRequest: '결재 요청',
|
approvalRequest: '결재 요청',
|
||||||
fundStatus: '자금 현황',
|
fundStatus: '자금 현황',
|
||||||
};
|
};
|
||||||
@@ -123,6 +123,13 @@ export function SectionRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [2026-03-03] 설정 모달에서 숨길 항목
|
||||||
|
// - purchase: 백엔드 path 오류 + 데이터 정합성 이슈 (API-SPEC N4 참조)
|
||||||
|
// - vehicle, equipment, fundStatus: 백엔드 API에서 미제공 (StatusBoard 응답에 없음)
|
||||||
|
const STATUS_BOARD_HIDDEN_SETTINGS = new Set<keyof TodayIssueSettings>([
|
||||||
|
'purchase', 'vehicle', 'equipment', 'fundStatus',
|
||||||
|
]);
|
||||||
|
|
||||||
// ─── 현황판 항목 토글 리스트 ────────────────────────
|
// ─── 현황판 항목 토글 리스트 ────────────────────────
|
||||||
export function StatusBoardItemsList({
|
export function StatusBoardItemsList({
|
||||||
items,
|
items,
|
||||||
@@ -133,8 +140,9 @@ export function StatusBoardItemsList({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
|
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>)
|
||||||
(itemKey) => (
|
.filter((itemKey) => !STATUS_BOARD_HIDDEN_SETTINGS.has(itemKey))
|
||||||
|
.map((itemKey) => (
|
||||||
<div
|
<div
|
||||||
key={itemKey}
|
key={itemKey}
|
||||||
className="flex items-center justify-between py-2.5 px-2"
|
className="flex items-center justify-between py-2.5 px-2"
|
||||||
|
|||||||
@@ -1,519 +1,10 @@
|
|||||||
import type {
|
import type { CEODashboardData } from './types';
|
||||||
CEODashboardData,
|
|
||||||
SalesStatusData,
|
|
||||||
PurchaseStatusData,
|
|
||||||
DailyProductionData,
|
|
||||||
UnshippedData,
|
|
||||||
DailyAttendanceData,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CEO 대시보드 목데이터
|
* Mock 데이터 제거 완료 — 빈 기본값만 유지
|
||||||
* TODO: API 연동 시 이 파일을 API 호출로 대체
|
* dev 페이지에서 import하므로 파일 유지
|
||||||
*/
|
*/
|
||||||
export const mockData: CEODashboardData = {
|
export const mockData: CEODashboardData = {
|
||||||
// TodayIssue: API 연동 완료 - 목업 데이터 제거됨
|
|
||||||
todayIssue: [],
|
todayIssue: [],
|
||||||
todayIssueList: [],
|
todayIssueList: [],
|
||||||
dailyReport: {
|
};
|
||||||
date: '2026년 1월 5일 월요일',
|
|
||||||
cards: [
|
|
||||||
{ id: 'dr1', label: '일일일보', amount: 3050000000, path: '/ko/accounting/daily-report' },
|
|
||||||
{ id: 'dr2', label: '미수금 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' },
|
|
||||||
{ id: 'dr3', label: '미지급금 잔액', amount: 3050000000 },
|
|
||||||
{ id: 'dr4', label: '당월 예상 지출 합계', amount: 350000000 },
|
|
||||||
],
|
|
||||||
checkPoints: [
|
|
||||||
{
|
|
||||||
id: 'dr-cp1',
|
|
||||||
type: 'success',
|
|
||||||
message: '어제 3.5억원 출금했습니다. 최근 7일 평균 대비 2배 이상으로 점검이 필요합니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '3.5억원 출금', color: 'red' },
|
|
||||||
{ text: '점검이 필요', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'dr-cp2',
|
|
||||||
type: 'success',
|
|
||||||
message: '어제 10.2억원이 입금되었습니다. 대한건설 선수금 입금이 주요 원인입니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '10.2억원', color: 'green' },
|
|
||||||
{ text: '입금', color: 'green' },
|
|
||||||
{ text: '대한건설 선수금 입금', color: 'green' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'dr-cp3',
|
|
||||||
type: 'success',
|
|
||||||
message: '총 현금성 자산이 300.2억원입니다. 월 운영비용 대비 18개월분이 확보되어 안정적입니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '18개월분', color: 'blue' },
|
|
||||||
{ text: '안정적', color: 'blue' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
monthlyExpense: {
|
|
||||||
cards: [
|
|
||||||
{ id: 'me1', label: '매입', amount: 3050000000, previousLabel: '전월 대비 +10.5%' },
|
|
||||||
{ id: 'me2', label: '카드', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
|
|
||||||
{ id: 'me3', label: '발행어음', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
|
|
||||||
{ id: 'me4', label: '총 예상 지출 합계', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
|
|
||||||
],
|
|
||||||
checkPoints: [
|
|
||||||
{
|
|
||||||
id: 'me-cp1',
|
|
||||||
type: 'success',
|
|
||||||
message: '이번 달 예상 지출이 전월 대비 15% 증가했습니다. 매입 비용 증가가 주요 원인입니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '전월 대비 15% 증가', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'me-cp2',
|
|
||||||
type: 'success',
|
|
||||||
message: '이번 달 예상 지출이 예산을 12% 초과했습니다. 비용 항목별 점검이 필요합니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '예산을 12% 초과', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'me-cp3',
|
|
||||||
type: 'success',
|
|
||||||
message: '이번 달 예상 지출이 전월 대비 8% 감소했습니다. {계정과목명} 비용이 줄었습니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '전월 대비 8% 감소', color: 'green' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
cardManagement: {
|
|
||||||
warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의',
|
|
||||||
cards: [
|
|
||||||
{ id: 'cm1', label: '카드', amount: 3123000, previousLabel: '미정리 5건' },
|
|
||||||
{ id: 'cm2', label: '경조사', amount: 3123000, previousLabel: '미증빙 5건' },
|
|
||||||
{ id: 'cm3', label: '상품권', amount: 3123000, previousLabel: '미증빙 5건' },
|
|
||||||
{ id: 'cm4', label: '접대비', amount: 3123000, previousLabel: '미증빙 5건' },
|
|
||||||
{ id: 'cm_total', label: '총 가지급금 합계', amount: 350000000 },
|
|
||||||
],
|
|
||||||
checkPoints: [
|
|
||||||
{
|
|
||||||
id: 'cm-cp1',
|
|
||||||
type: 'success',
|
|
||||||
message: '법인카드 사용 총 850만원이 가지급금으로 전환되었습니다. 연 4.6% 인정이자가 발생합니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '850만원', color: 'red' },
|
|
||||||
{ text: '가지급금', color: 'red' },
|
|
||||||
{ text: '연 4.6% 인정이자', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cm-cp2',
|
|
||||||
type: 'success',
|
|
||||||
message: '현재 가지급금 3.5원 × 4.6% = 연 약 1,400만원의 인정이자가 발생 중입니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '연 약 1,400만원의 인정이자', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cm-cp3',
|
|
||||||
type: 'success',
|
|
||||||
message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '불인정 항목 결제 감지', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cm-cp4',
|
|
||||||
type: 'success',
|
|
||||||
message: '주말 카드 사용 100만원 결제 감지. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '주말 카드 사용 100만원 결제 감지', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
entertainment: {
|
|
||||||
cards: [
|
|
||||||
{ id: 'et1', label: '주말/심야', amount: 3123000, previousLabel: '미증빙 5건' },
|
|
||||||
{ id: 'et2', label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, previousLabel: '불인정 5건' },
|
|
||||||
{ id: 'et3', label: '고액 결제', amount: 3123000, previousLabel: '미증빙 5건' },
|
|
||||||
{ id: 'et4', label: '증빙 미비', amount: 3123000, previousLabel: '미증빙 5건' },
|
|
||||||
],
|
|
||||||
checkPoints: [
|
|
||||||
{
|
|
||||||
id: 'et-cp1',
|
|
||||||
type: 'success',
|
|
||||||
message: '{1사분기} 접대비 사용 1,000만원 / 한도 4,012만원 (75%). 여유 있게 운영 중입니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '1,000만원', color: 'green' },
|
|
||||||
{ text: '4,012만원 (75%)', color: 'green' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'et-cp2',
|
|
||||||
type: 'success',
|
|
||||||
message: '접대비 한도 85% 도달. 잔여 한도 600만원입니다. 사용 계획을 점검해 주세요.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '잔여 한도 600만원', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'et-cp3',
|
|
||||||
type: 'error',
|
|
||||||
message: '접대비 한도 초과 320만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '320만원 발생', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'et-cp4',
|
|
||||||
type: 'error',
|
|
||||||
message: '접대비 사용 중 3건(45만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '3건(45만원)', color: 'red' },
|
|
||||||
{ text: '거래처 정보가 누락', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
welfare: {
|
|
||||||
cards: [
|
|
||||||
{ id: 'wf1', label: '비과세 한도 초과', amount: 3123000, previousLabel: '5건' },
|
|
||||||
{ id: 'wf2', label: '사적 사용 의심', amount: 3123000, previousLabel: '5건' },
|
|
||||||
{ id: 'wf3', label: '특정인 편중', amount: 3123000, previousLabel: '5건' },
|
|
||||||
{ id: 'wf4', label: '항목별 한도 초과', amount: 3123000, previousLabel: '5건' },
|
|
||||||
],
|
|
||||||
checkPoints: [
|
|
||||||
{
|
|
||||||
id: 'wf-cp1',
|
|
||||||
type: 'success',
|
|
||||||
message: '1인당 월 복리후생비 20만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '1인당 월 복리후생비 20만원', color: 'green' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'wf-cp2',
|
|
||||||
type: 'error',
|
|
||||||
message: '식대가 월 25만원으로 비과세 한도(20만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '식대가 월 25만원으로', color: 'red' },
|
|
||||||
{ text: '초과', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
receivable: {
|
|
||||||
cards: [
|
|
||||||
{
|
|
||||||
id: 'rv1',
|
|
||||||
label: '누적 미수금',
|
|
||||||
amount: 30123000,
|
|
||||||
subItems: [
|
|
||||||
{ label: '매출', value: 60123000 },
|
|
||||||
{ label: '입금', value: 30000000 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rv2',
|
|
||||||
label: '당월 미수금',
|
|
||||||
amount: 10123000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rv3',
|
|
||||||
label: '미수금 거래처',
|
|
||||||
amount: 31,
|
|
||||||
unit: '건',
|
|
||||||
subItems: [
|
|
||||||
{ label: '연체', value: '21건' },
|
|
||||||
{ label: '악성채권', value: '11건' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rv4',
|
|
||||||
label: '미수금 Top 3',
|
|
||||||
amount: 0,
|
|
||||||
displayValue: '상세보기',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
checkPoints: [
|
|
||||||
{
|
|
||||||
id: 'rv-cp1',
|
|
||||||
type: 'success',
|
|
||||||
message: '90일 이상 장기 미수금 3건(2,500만원) 발생. 회수 조치가 필요합니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '90일 이상 장기 미수금 3건(2,500만원) 발생', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rv-cp2',
|
|
||||||
type: 'success',
|
|
||||||
message: '(주)대한전자 미수금 1,500만원으로 전체의 35%를 차지합니다. 리스크 분산이 필요합니다.',
|
|
||||||
highlights: [
|
|
||||||
{ text: '(주)대한전자 미수금 1,500만원으로 전체의 35%를', color: 'red' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
detailButtonPath: '/ko/accounting/receivables-status',
|
|
||||||
},
|
|
||||||
debtCollection: {
|
|
||||||
cards: [
|
|
||||||
{ id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' },
|
|
||||||
{ id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' },
|
|
||||||
{ id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' },
|
|
||||||
{ id: 'dc4', label: '추심종료', amount: 280000000, subLabel: '10건' },
|
|
||||||
],
|
|
||||||
checkPoints: [
|
|
||||||
{
|
|
||||||
id: 'dc-cp1',
|
|
||||||
type: 'success',
|
|
||||||
message: '(주)대한전자 건 지급명령 신청 완료. 법원 결정까지 약 2주 소요 예정입니다.',
|
|
||||||
highlights: [{ text: '(주)대한전자 건 지급명령 신청 완료.', color: 'red' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'dc-cp2',
|
|
||||||
type: 'success',
|
|
||||||
message: '(주)삼성테크 건 회수 불가 판정. 대손 처리 검토가 필요합니다.',
|
|
||||||
highlights: [{ text: '(주)삼성테크 건 회수 불가 판정.', color: 'red' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
detailButtonPath: '/ko/accounting/bad-debt-collection',
|
|
||||||
},
|
|
||||||
vat: {
|
|
||||||
cards: [
|
|
||||||
{ id: 'vat1', label: '매출세액', amount: 3050000000 },
|
|
||||||
{ id: 'vat2', label: '매입세액', amount: 2050000000 },
|
|
||||||
{ id: 'vat3', label: '예상 납부세액', amount: 110000000 },
|
|
||||||
{ id: 'vat4', label: '세금계산서 미발행', amount: 3, unit: '건' },
|
|
||||||
],
|
|
||||||
checkPoints: [
|
|
||||||
{
|
|
||||||
id: 'vat-cp1',
|
|
||||||
type: 'success',
|
|
||||||
message: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다. 설비투자에 따른 매입세액 증가가 주요 원인입니다.',
|
|
||||||
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다.', color: 'red' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'vat-cp2',
|
|
||||||
type: 'success',
|
|
||||||
message: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다. 전기 대비 12.9% 증가했으며, 이는 매출 증가에 따른 정상적인 증가로 판단됩니다.',
|
|
||||||
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다.', color: 'red' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// ===== 신규 섹션 Mock 데이터 =====
|
|
||||||
salesStatus: {
|
|
||||||
cumulativeSales: 312300000,
|
|
||||||
achievementRate: 94.5,
|
|
||||||
yoyChange: 12.5,
|
|
||||||
monthlySales: 312300000,
|
|
||||||
monthlyTrend: [
|
|
||||||
{ month: '8월', amount: 250000000 },
|
|
||||||
{ month: '9월', amount: 280000000 },
|
|
||||||
{ month: '10월', amount: 310000000 },
|
|
||||||
{ month: '11월', amount: 290000000 },
|
|
||||||
{ month: '12월', amount: 320000000 },
|
|
||||||
{ month: '1월', amount: 300000000 },
|
|
||||||
{ month: '2월', amount: 312300000 },
|
|
||||||
],
|
|
||||||
clientSales: [
|
|
||||||
{ name: '대한건설', amount: 95000000 },
|
|
||||||
{ name: '삼성테크', amount: 78000000 },
|
|
||||||
{ name: '현대산업', amount: 62000000 },
|
|
||||||
{ name: 'LG전자', amount: 45000000 },
|
|
||||||
{ name: '기타', amount: 32300000 },
|
|
||||||
],
|
|
||||||
dailyItems: [
|
|
||||||
{ date: '2026-02-01', client: '대한건설', item: '스크린 외', amount: 25000000, status: '입금완료' },
|
|
||||||
{ date: '2026-02-03', client: '삼성테크', item: '슬루 외', amount: 18000000, status: '미입금' },
|
|
||||||
{ date: '2026-02-05', client: '현대산업', item: '절곡 외', amount: 32000000, status: '입금완료' },
|
|
||||||
{ date: '2026-02-07', client: 'LG전자', item: '스크린', amount: 15000000, status: '부분입금' },
|
|
||||||
{ date: '2026-02-10', client: '대한건설', item: '슬루', amount: 28000000, status: '입금완료' },
|
|
||||||
{ date: '2026-02-12', client: '삼성테크', item: '절곡', amount: 22000000, status: '미입금' },
|
|
||||||
{ date: '2026-02-15', client: '현대산업', item: '스크린 외', amount: 35000000, status: '입금완료' },
|
|
||||||
],
|
|
||||||
dailyTotal: 312300000,
|
|
||||||
},
|
|
||||||
purchaseStatus: {
|
|
||||||
cumulativePurchase: 312300000,
|
|
||||||
unpaidAmount: 312300000,
|
|
||||||
yoyChange: -12.5,
|
|
||||||
monthlyTrend: [
|
|
||||||
{ month: '8월', amount: 180000000 },
|
|
||||||
{ month: '9월', amount: 200000000 },
|
|
||||||
{ month: '10월', amount: 220000000 },
|
|
||||||
{ month: '11월', amount: 195000000 },
|
|
||||||
{ month: '12월', amount: 230000000 },
|
|
||||||
{ month: '1월', amount: 210000000 },
|
|
||||||
{ month: '2월', amount: 312300000 },
|
|
||||||
],
|
|
||||||
materialRatio: [
|
|
||||||
{ name: '원자재', value: 55, percentage: 55, color: '#3b82f6' },
|
|
||||||
{ name: '부자재', value: 35, percentage: 35, color: '#10b981' },
|
|
||||||
{ name: '소모품', value: 10, percentage: 10, color: '#f59e0b' },
|
|
||||||
],
|
|
||||||
dailyItems: [
|
|
||||||
{ date: '2026-02-01', supplier: '한국철강', item: '철판 외', amount: 45000000, status: '결제완료' },
|
|
||||||
{ date: '2026-02-03', supplier: '삼성소재', item: '알루미늄', amount: 28000000, status: '미결제' },
|
|
||||||
{ date: '2026-02-05', supplier: '현대자재', item: '볼트/너트', amount: 12000000, status: '결제완료' },
|
|
||||||
{ date: '2026-02-08', supplier: 'LG화학', item: '도료 외', amount: 18000000, status: '부분결제' },
|
|
||||||
{ date: '2026-02-10', supplier: '한국철강', item: '스테인리스', amount: 52000000, status: '미결제' },
|
|
||||||
{ date: '2026-02-13', supplier: '삼성소재', item: '구리판', amount: 35000000, status: '결제완료' },
|
|
||||||
],
|
|
||||||
dailyTotal: 312300000,
|
|
||||||
},
|
|
||||||
dailyProduction: {
|
|
||||||
date: '2026년 2월 23일 월요일',
|
|
||||||
processes: [
|
|
||||||
{
|
|
||||||
processName: '스크린',
|
|
||||||
totalWork: 10,
|
|
||||||
todo: 10,
|
|
||||||
inProgress: 10,
|
|
||||||
completed: 10,
|
|
||||||
urgent: 3,
|
|
||||||
subLine: 2,
|
|
||||||
regular: 5,
|
|
||||||
workerCount: 8,
|
|
||||||
workItems: [
|
|
||||||
{ id: 'sp1', orderNo: 'SO-2026-001', client: '대한건설', product: '스크린 A형', quantity: 50, status: '진행중' },
|
|
||||||
{ id: 'sp2', orderNo: 'SO-2026-002', client: '삼성테크', product: '스크린 B형', quantity: 30, status: '진행중' },
|
|
||||||
{ id: 'sp3', orderNo: 'SO-2026-003', client: '현대산업', product: '스크린 C형', quantity: 20, status: '대기' },
|
|
||||||
{ id: 'sp4', orderNo: 'SO-2026-004', client: 'LG전자', product: '스크린 D형', quantity: 40, status: '대기' },
|
|
||||||
{ id: 'sp5', orderNo: 'SO-2026-005', client: '대한건설', product: '스크린 E형', quantity: 25, status: '완료' },
|
|
||||||
],
|
|
||||||
workers: [
|
|
||||||
{ name: '김철수', assigned: 5, completed: 3, rate: 60 },
|
|
||||||
{ name: '이영희', assigned: 4, completed: 4, rate: 100 },
|
|
||||||
{ name: '박민수', assigned: 3, completed: 2, rate: 67 },
|
|
||||||
{ name: '정수진', assigned: 3, completed: 1, rate: 33 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
processName: '슬랫',
|
|
||||||
totalWork: 10,
|
|
||||||
todo: 10,
|
|
||||||
inProgress: 10,
|
|
||||||
completed: 10,
|
|
||||||
urgent: 2,
|
|
||||||
subLine: 3,
|
|
||||||
regular: 5,
|
|
||||||
workerCount: 6,
|
|
||||||
workItems: [
|
|
||||||
{ id: 'sl1', orderNo: 'SO-2026-010', client: '대한건설', product: '슬루 A형', quantity: 40, status: '진행중' },
|
|
||||||
{ id: 'sl2', orderNo: 'SO-2026-011', client: '삼성테크', product: '슬루 B형', quantity: 25, status: '진행중' },
|
|
||||||
{ id: 'sl3', orderNo: 'SO-2026-012', client: '현대산업', product: '슬루 C형', quantity: 35, status: '대기' },
|
|
||||||
],
|
|
||||||
workers: [
|
|
||||||
{ name: '최동훈', assigned: 4, completed: 3, rate: 75 },
|
|
||||||
{ name: '강미영', assigned: 3, completed: 2, rate: 67 },
|
|
||||||
{ name: '윤상호', assigned: 3, completed: 3, rate: 100 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
processName: '절곡',
|
|
||||||
totalWork: 10,
|
|
||||||
todo: 10,
|
|
||||||
inProgress: 10,
|
|
||||||
completed: 10,
|
|
||||||
urgent: 1,
|
|
||||||
subLine: 2,
|
|
||||||
regular: 7,
|
|
||||||
workerCount: 5,
|
|
||||||
workItems: [
|
|
||||||
{ id: 'jg1', orderNo: 'SO-2026-020', client: '현대산업', product: '절곡 A형', quantity: 60, status: '진행중' },
|
|
||||||
{ id: 'jg2', orderNo: 'SO-2026-021', client: 'LG전자', product: '절곡 B형', quantity: 45, status: '대기' },
|
|
||||||
{ id: 'jg3', orderNo: 'SO-2026-022', client: '대한건설', product: '절곡 C형', quantity: 30, status: '완료' },
|
|
||||||
],
|
|
||||||
workers: [
|
|
||||||
{ name: '한지원', assigned: 4, completed: 4, rate: 100 },
|
|
||||||
{ name: '서준혁', assigned: 3, completed: 2, rate: 67 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
shipment: {
|
|
||||||
expectedAmount: 150000000,
|
|
||||||
expectedCount: 12,
|
|
||||||
actualAmount: 120000000,
|
|
||||||
actualCount: 9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unshipped: {
|
|
||||||
items: [
|
|
||||||
{ id: 'us1', portNo: 'P-2026-001', siteName: '강남 현장', orderClient: '대한건설', dueDate: '2026-02-25', daysLeft: 2 },
|
|
||||||
{ id: 'us2', portNo: 'P-2026-002', siteName: '서초 현장', orderClient: '삼성테크', dueDate: '2026-02-26', daysLeft: 3 },
|
|
||||||
{ id: 'us3', portNo: 'P-2026-003', siteName: '판교 현장', orderClient: '현대산업', dueDate: '2026-02-27', daysLeft: 4 },
|
|
||||||
{ id: 'us4', portNo: 'P-2026-004', siteName: '송도 현장', orderClient: 'LG전자', dueDate: '2026-02-28', daysLeft: 5 },
|
|
||||||
{ id: 'us5', portNo: 'P-2026-005', siteName: '마포 현장', orderClient: '대한건설', dueDate: '2026-03-01', daysLeft: 6 },
|
|
||||||
{ id: 'us6', portNo: 'P-2026-006', siteName: '영등포 현장', orderClient: '삼성테크', dueDate: '2026-03-03', daysLeft: 8 },
|
|
||||||
{ id: 'us7', portNo: 'P-2026-007', siteName: '용산 현장', orderClient: '현대산업', dueDate: '2026-03-05', daysLeft: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
constructionData: {
|
|
||||||
thisMonth: 15,
|
|
||||||
completed: 15,
|
|
||||||
items: [
|
|
||||||
{ id: 'cs1', siteName: '강남 현장', client: '대한건설', startDate: '2026-02-01', endDate: '2026-02-28', progress: 85, status: '진행중' },
|
|
||||||
{ id: 'cs2', siteName: '서초 현장', client: '삼성테크', startDate: '2026-02-05', endDate: '2026-03-05', progress: 60, status: '진행중' },
|
|
||||||
{ id: 'cs3', siteName: '판교 현장', client: '현대산업', startDate: '2026-02-10', endDate: '2026-03-10', progress: 40, status: '진행중' },
|
|
||||||
{ id: 'cs4', siteName: '송도 현장', client: 'LG전자', startDate: '2026-03-01', endDate: '2026-03-30', progress: 0, status: '예정' },
|
|
||||||
{ id: 'cs5', siteName: '마포 현장', client: '대한건설', startDate: '2026-01-15', endDate: '2026-02-15', progress: 100, status: '완료' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
dailyAttendance: {
|
|
||||||
present: 10,
|
|
||||||
onLeave: 10,
|
|
||||||
late: 10,
|
|
||||||
absent: 10,
|
|
||||||
employees: [
|
|
||||||
{ id: 'att1', department: '생산부', position: '과장', name: '김철수', status: '출근' },
|
|
||||||
{ id: 'att2', department: '영업부', position: '대리', name: '이영희', status: '출근' },
|
|
||||||
{ id: 'att3', department: '관리부', position: '사원', name: '박민수', status: '휴가' },
|
|
||||||
{ id: 'att4', department: '생산부', position: '부장', name: '정수진', status: '지각' },
|
|
||||||
{ id: 'att5', department: '영업부', position: '과장', name: '최동훈', status: '출근' },
|
|
||||||
{ id: 'att6', department: '관리부', position: '대리', name: '강미영', status: '결근' },
|
|
||||||
{ id: 'att7', department: '생산부', position: '사원', name: '윤상호', status: '출근' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
calendarSchedules: [
|
|
||||||
{
|
|
||||||
id: 'sch1',
|
|
||||||
title: '제목',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-04',
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '12:00',
|
|
||||||
type: 'schedule',
|
|
||||||
department: '부서명',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sch2',
|
|
||||||
title: '제목',
|
|
||||||
startDate: '2026-01-06',
|
|
||||||
endDate: '2026-01-06',
|
|
||||||
type: 'schedule',
|
|
||||||
personName: '홍길동',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sch3',
|
|
||||||
title: '제목',
|
|
||||||
startDate: '2026-01-06',
|
|
||||||
endDate: '2026-01-06',
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '12:00',
|
|
||||||
type: 'order',
|
|
||||||
department: '부서명',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sch4',
|
|
||||||
title: '제목',
|
|
||||||
startDate: '2026-01-06',
|
|
||||||
endDate: '2026-01-06',
|
|
||||||
startTime: '12:35',
|
|
||||||
type: 'construction',
|
|
||||||
personName: '홍길동',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -175,21 +175,39 @@ export function transformCm1ModalConfig(
|
|||||||
// cm2: 가지급금 상세 모달 변환기
|
// cm2: 가지급금 상세 모달 변환기
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
/** 카테고리 키 → 한글 라벨 매핑
|
||||||
|
* - category_breakdown 키: 영문 (card, congratulatory, ...)
|
||||||
|
* - loans[].category: 한글 (카드, 경조사, ...) — 백엔드 category_label accessor
|
||||||
|
* 양쪽 모두 대응
|
||||||
|
*/
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
// 영문 키 (category_breakdown용)
|
||||||
|
card: '카드',
|
||||||
|
congratulatory: '경조사',
|
||||||
|
gift_certificate: '상품권',
|
||||||
|
entertainment: '접대비',
|
||||||
|
// 한글 값 (loans[].category가 이미 한글인 경우 — 그대로 통과)
|
||||||
|
'카드': '카드',
|
||||||
|
'경조사': '경조사',
|
||||||
|
'상품권': '상품권',
|
||||||
|
'접대비': '접대비',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가지급금 대시보드 API 응답을 cm2 모달 설정으로 변환
|
* 가지급금 대시보드 API 응답을 cm2 모달 설정으로 변환
|
||||||
*/
|
*/
|
||||||
export function transformCm2ModalConfig(
|
export function transformCm2ModalConfig(
|
||||||
data: LoanDashboardApiResponse
|
data: LoanDashboardApiResponse
|
||||||
): DetailModalConfig {
|
): DetailModalConfig {
|
||||||
const { summary, items = [] } = data;
|
const { summary, category_breakdown, loans = [] } = data;
|
||||||
|
|
||||||
// 테이블 데이터 매핑
|
// 테이블 데이터 매핑 (백엔드 필드명 기준, 영문 키 → 한글 변환)
|
||||||
const tableData = (items || []).map((item) => ({
|
const tableData = (loans || []).map((item) => ({
|
||||||
date: item.loan_date,
|
date: item.loan_date,
|
||||||
classification: item.status_label || '카드',
|
classification: CATEGORY_LABELS[item.category] || item.category || '카드',
|
||||||
category: '-',
|
category: item.status_label || '-',
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
content: item.description,
|
response: item.content,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 분류 필터 옵션 동적 생성
|
// 분류 필터 옵션 동적 생성
|
||||||
@@ -202,22 +220,42 @@ export function transformCm2ModalConfig(
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// reviewCards: category_breakdown에서 4개 카테고리 카드 생성
|
||||||
|
const reviewCards = category_breakdown
|
||||||
|
? {
|
||||||
|
title: '가지급금 검토 필요',
|
||||||
|
cards: Object.entries(category_breakdown).map(([key, breakdown]) => ({
|
||||||
|
label: CATEGORY_LABELS[key] || key,
|
||||||
|
amount: breakdown.outstanding_amount,
|
||||||
|
subLabel: breakdown.unverified_count > 0
|
||||||
|
? `미증빙 ${breakdown.unverified_count}건`
|
||||||
|
: `${breakdown.total_count}건`,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: '가지급금 상세',
|
title: '가지급금 상세',
|
||||||
|
dateFilter: {
|
||||||
|
enabled: true,
|
||||||
|
defaultPreset: '당월',
|
||||||
|
showSearch: true,
|
||||||
|
},
|
||||||
summaryCards: [
|
summaryCards: [
|
||||||
{ label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) },
|
{ label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) },
|
||||||
{ label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' },
|
{ label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' },
|
||||||
{ label: '미정리/미분류', value: `${summary.pending_count ?? 0}건` },
|
{ label: '건수', value: `${summary.outstanding_count ?? 0}건` },
|
||||||
],
|
],
|
||||||
|
reviewCards,
|
||||||
table: {
|
table: {
|
||||||
title: '가지급금 관련 내역',
|
title: '가지급금 내역',
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
{ key: 'date', label: '발생일', align: 'center' },
|
{ key: 'date', label: '발생일', align: 'center' },
|
||||||
{ key: 'classification', label: '분류', align: 'center' },
|
{ key: 'classification', label: '분류', align: 'center' },
|
||||||
{ key: 'category', label: '구분', align: 'center' },
|
{ key: 'category', label: '구분', align: 'center' },
|
||||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||||
{ key: 'content', label: '내용', align: 'left' },
|
{ key: 'response', label: '대응', align: 'left' },
|
||||||
],
|
],
|
||||||
data: tableData,
|
data: tableData,
|
||||||
filters: [
|
filters: [
|
||||||
@@ -227,11 +265,12 @@ export function transformCm2ModalConfig(
|
|||||||
defaultValue: 'all',
|
defaultValue: 'all',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'category',
|
key: 'sortOrder',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'all', label: '전체' },
|
{ value: 'all', label: '정렬' },
|
||||||
{ value: '카드명', label: '카드명' },
|
{ value: 'amountDesc', label: '금액 높은순' },
|
||||||
{ value: '계좌명', label: '계좌명' },
|
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
],
|
],
|
||||||
defaultValue: 'all',
|
defaultValue: 'all',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface CardManagementModalData {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* API 데이터를 사용하여 모달 설정을 동적으로 생성
|
* API 데이터를 사용하여 모달 설정을 동적으로 생성
|
||||||
* 데이터가 없는 경우 fallback 설정 사용
|
* 데이터가 없는 경우 null 반환 (mock fallback 제거)
|
||||||
*/
|
*/
|
||||||
export function getCardManagementModalConfigWithData(
|
export function getCardManagementModalConfigWithData(
|
||||||
cardId: string,
|
cardId: string,
|
||||||
@@ -40,297 +40,26 @@ export function getCardManagementModalConfigWithData(
|
|||||||
): DetailModalConfig | null {
|
): DetailModalConfig | null {
|
||||||
switch (cardId) {
|
switch (cardId) {
|
||||||
case 'cm1':
|
case 'cm1':
|
||||||
if (data.cm1Data) {
|
return data.cm1Data ? transformCm1ModalConfig(data.cm1Data) : null;
|
||||||
return transformCm1ModalConfig(data.cm1Data);
|
|
||||||
}
|
|
||||||
return getCardManagementModalConfig(cardId);
|
|
||||||
|
|
||||||
case 'cm2':
|
case 'cm2':
|
||||||
if (data.cm2Data) {
|
return data.cm2Data ? transformCm2ModalConfig(data.cm2Data) : null;
|
||||||
return transformCm2ModalConfig(data.cm2Data);
|
|
||||||
}
|
|
||||||
return getCardManagementModalConfig(cardId);
|
|
||||||
|
|
||||||
case 'cm3':
|
case 'cm3':
|
||||||
if (data.cm3Data) {
|
return data.cm3Data ? transformCm3ModalConfig(data.cm3Data) : null;
|
||||||
return transformCm3ModalConfig(data.cm3Data);
|
|
||||||
}
|
|
||||||
return getCardManagementModalConfig(cardId);
|
|
||||||
|
|
||||||
case 'cm4':
|
case 'cm4':
|
||||||
if (data.cm4Data) {
|
return data.cm4Data ? transformCm4ModalConfig(data.cm4Data) : null;
|
||||||
return transformCm4ModalConfig(data.cm4Data);
|
|
||||||
}
|
|
||||||
return getCardManagementModalConfig(cardId);
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Fallback 모달 설정 (API 데이터 없을 때 사용)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback: 정적 목업 데이터 기반 모달 설정
|
* Fallback 모달 설정 (mock 제거 완료 — null 반환)
|
||||||
* API 데이터가 없을 때 사용
|
* API 데이터가 없을 때 모달을 열지 않음
|
||||||
*/
|
*/
|
||||||
export function getCardManagementModalConfig(cardId: string): DetailModalConfig | null {
|
export function getCardManagementModalConfig(_cardId: string): DetailModalConfig | null {
|
||||||
const configs: Record<string, DetailModalConfig> = {
|
return null;
|
||||||
cm1: {
|
|
||||||
title: '카드 사용 상세',
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '당월 카드 사용', value: 30123000, unit: '원' },
|
|
||||||
{ label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true },
|
|
||||||
{ label: '미정리 건수', value: '5건' },
|
|
||||||
],
|
|
||||||
barChart: {
|
|
||||||
title: '월별 카드 사용 추이',
|
|
||||||
data: [
|
|
||||||
{ name: '7월', value: 28000000 },
|
|
||||||
{ name: '8월', value: 32000000 },
|
|
||||||
{ name: '9월', value: 27000000 },
|
|
||||||
{ name: '10월', value: 35000000 },
|
|
||||||
{ name: '11월', value: 29000000 },
|
|
||||||
{ name: '12월', value: 30123000 },
|
|
||||||
],
|
|
||||||
dataKey: 'value',
|
|
||||||
xAxisKey: 'name',
|
|
||||||
color: '#60A5FA',
|
|
||||||
},
|
|
||||||
pieChart: {
|
|
||||||
title: '사용자별 카드 사용 비율',
|
|
||||||
data: [
|
|
||||||
{ name: '대표이사', value: 15000000, percentage: 50, color: '#60A5FA' },
|
|
||||||
{ name: '경영지원팀', value: 9000000, percentage: 30, color: '#34D399' },
|
|
||||||
{ name: '영업팀', value: 6123000, percentage: 20, color: '#FBBF24' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
title: '카드 사용 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
|
||||||
{ key: 'user', label: '사용자', align: 'center' },
|
|
||||||
{ key: 'date', label: '사용일시', align: 'center', format: 'date' },
|
|
||||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
|
||||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-05 18:30', store: '스타벅스 강남점', amount: 45000, usageType: '복리후생비' },
|
|
||||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-04 12:15', store: '한식당', amount: 350000, usageType: '접대비' },
|
|
||||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2026-01-03 14:20', store: '오피스디포', amount: 125000, usageType: '소모품비' },
|
|
||||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-02 19:45', store: '골프장', amount: 850000, usageType: '미설정' },
|
|
||||||
{ cardName: '법인카드3', user: '영업팀', date: '2026-01-02 11:30', store: 'GS칼텍스', amount: 80000, usageType: '교통비' },
|
|
||||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2026-01-01 16:00', store: '이마트', amount: 230000, usageType: '미설정' },
|
|
||||||
{ cardName: '법인카드1', user: '대표이사', date: '2025-12-30 20:30', store: '백화점', amount: 1500000, usageType: '미설정' },
|
|
||||||
{ cardName: '법인카드3', user: '영업팀', date: '2025-12-29 09:15', store: '커피빈', amount: 32000, usageType: '복리후생비' },
|
|
||||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2025-12-28 13:45', store: '문구점', amount: 55000, usageType: '소모품비' },
|
|
||||||
{ cardName: '법인카드1', user: '대표이사', date: '2025-12-27 21:00', store: '호텔', amount: 450000, usageType: '미설정' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'user',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '대표이사', label: '대표이사' },
|
|
||||||
{ value: '경영지원팀', label: '경영지원팀' },
|
|
||||||
{ value: '영업팀', label: '영업팀' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'usageType',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '미설정', label: '미설정' },
|
|
||||||
{ value: '복리후생비', label: '복리후생비' },
|
|
||||||
{ value: '접대비', label: '접대비' },
|
|
||||||
{ value: '소모품비', label: '소모품비' },
|
|
||||||
{ value: '교통비', label: '교통비' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 30123000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// P52: 가지급금 상세
|
|
||||||
cm2: {
|
|
||||||
title: '가지급금 상세',
|
|
||||||
dateFilter: {
|
|
||||||
enabled: true,
|
|
||||||
defaultPreset: '당월',
|
|
||||||
showSearch: true,
|
|
||||||
},
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '가지급금 합계', value: '4.5억원' },
|
|
||||||
{ label: '가지급금 총액', value: 6000000, unit: '원' },
|
|
||||||
{ label: '건수', value: '10건' },
|
|
||||||
],
|
|
||||||
reviewCards: {
|
|
||||||
title: '가지급금 검토 필요',
|
|
||||||
cards: [
|
|
||||||
{ label: '카드', amount: 3123000, subLabel: '미정리 5건' },
|
|
||||||
{ label: '경조사', amount: 3123000, subLabel: '미증빙 5건' },
|
|
||||||
{ label: '상품권', amount: 3123000, subLabel: '미증빙 5건' },
|
|
||||||
{ label: '접대비', amount: 3123000, subLabel: '미증빙 5건' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
title: '가지급금 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'date', label: '발생일', align: 'center' },
|
|
||||||
{ key: 'classification', label: '분류', align: 'center' },
|
|
||||||
{ key: 'category', label: '구분', align: 'center' },
|
|
||||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'response', label: '대응', align: 'left' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '미정리' },
|
|
||||||
{ date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '미증빙' },
|
|
||||||
{ date: '2025-12-12', classification: '경조사', category: '계좌명', amount: 1000000, response: '미증빙' },
|
|
||||||
{ date: '2025-12-12', classification: '상품권', category: '계좌명', amount: 1000000, response: '미증빙' },
|
|
||||||
{ date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '주말 카드 사용' },
|
|
||||||
{ date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '접대비 불인정' },
|
|
||||||
{ date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '불인정 가맹점(귀금속)' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'classification',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '카드', label: '카드' },
|
|
||||||
{ value: '경조사', label: '경조사' },
|
|
||||||
{ value: '상품권', label: '상품권' },
|
|
||||||
{ value: '접대비', label: '접대비' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sortOrder',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '정렬' },
|
|
||||||
{ value: 'amountDesc', label: '금액 높은순' },
|
|
||||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
|
||||||
{ value: 'latest', label: '최신순' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 111000000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cm3: {
|
|
||||||
title: '법인세 예상 가중 상세',
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '법인세 예상 증가', value: 3123000, unit: '원' },
|
|
||||||
{ label: '인정 이자', value: 6000000, unit: '원' },
|
|
||||||
{ label: '가지급금', value: '4.5억원' },
|
|
||||||
{ label: '인정이자', value: 6000000, unit: '원' },
|
|
||||||
],
|
|
||||||
comparisonSection: {
|
|
||||||
leftBox: {
|
|
||||||
title: '없을때 법인세',
|
|
||||||
items: [
|
|
||||||
{ label: '과세표준', value: '3억원' },
|
|
||||||
{ label: '법인세', value: 50970000, unit: '원' },
|
|
||||||
],
|
|
||||||
borderColor: 'orange',
|
|
||||||
},
|
|
||||||
rightBox: {
|
|
||||||
title: '있을때 법인세',
|
|
||||||
items: [
|
|
||||||
{ label: '과세표준', value: '3.06억원' },
|
|
||||||
{ label: '법인세', value: 54093000, unit: '원' },
|
|
||||||
],
|
|
||||||
borderColor: 'blue',
|
|
||||||
},
|
|
||||||
vsLabel: '법인세 예상 증가',
|
|
||||||
vsValue: 3123000,
|
|
||||||
vsSubLabel: '법인 세율 -12.5%',
|
|
||||||
},
|
|
||||||
referenceTable: {
|
|
||||||
title: '법인세 과세표준 (2024년 기준)',
|
|
||||||
columns: [
|
|
||||||
{ key: 'bracket', label: '과세표준', align: 'left' },
|
|
||||||
{ key: 'rate', label: '세율', align: 'center' },
|
|
||||||
{ key: 'formula', label: '계산식', align: 'left' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ bracket: '2억원 이하', rate: '9%', formula: '과세표준 × 9%' },
|
|
||||||
{ bracket: '2억원 초과 ~ 200억원 이하', rate: '19%', formula: '1,800만원 + (2억원 초과분 × 19%)' },
|
|
||||||
{ bracket: '200억원 초과 ~ 3,000억원 이하', rate: '21%', formula: '37.62억원 + (200억원 초과분 × 21%)' },
|
|
||||||
{ bracket: '3,000억원 초과', rate: '24%', formula: '625.62억원 + (3,000억원 초과분 × 24%)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cm4: {
|
|
||||||
title: '대표자 종합소득세 예상 가중 상세',
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '대표자 종합세 예상 가중', value: 3123000, unit: '원' },
|
|
||||||
{ label: '추가 세금', value: '+12.5%', isComparison: true, isPositive: false },
|
|
||||||
{ label: '가지급금', value: '4.5억원' },
|
|
||||||
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
|
|
||||||
],
|
|
||||||
comparisonSection: {
|
|
||||||
leftBox: {
|
|
||||||
title: '가지급금 인정이자가 반영된 종합소득세',
|
|
||||||
items: [
|
|
||||||
{ label: '현재 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
|
|
||||||
{ label: '현재 적용 세율', value: '19%' },
|
|
||||||
{ label: '현재 예상 세액', value: 10000000, unit: '원' },
|
|
||||||
],
|
|
||||||
borderColor: 'orange',
|
|
||||||
},
|
|
||||||
rightBox: {
|
|
||||||
title: '가지급금 인정이자가 정리된 종합소득세',
|
|
||||||
items: [
|
|
||||||
{ label: '가지급금 정리 시 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
|
|
||||||
{ label: '가지급금 정리 시 적용 세율', value: '19%' },
|
|
||||||
{ label: '가지급금 정리 시 예상 세액', value: 10000000, unit: '원' },
|
|
||||||
],
|
|
||||||
borderColor: 'blue',
|
|
||||||
},
|
|
||||||
vsLabel: '종합소득세 예상 절감',
|
|
||||||
vsValue: 3123000,
|
|
||||||
vsSubLabel: '감소 세금 -12.5%',
|
|
||||||
vsBreakdown: [
|
|
||||||
{ label: '종합소득세', value: -2000000, unit: '원' },
|
|
||||||
{ label: '지방소득세', value: -200000, unit: '원' },
|
|
||||||
{ label: '4대 보험', value: -1000000, unit: '원' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
referenceTable: {
|
|
||||||
title: '종합소득세 과세표준 (2024년 기준)',
|
|
||||||
columns: [
|
|
||||||
{ key: 'bracket', label: '과세표준', align: 'left' },
|
|
||||||
{ key: 'rate', label: '세율', align: 'center' },
|
|
||||||
{ key: 'deduction', label: '누진공제', align: 'right' },
|
|
||||||
{ key: 'formula', label: '계산식', align: 'left' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ bracket: '1,400만원 이하', rate: '6%', deduction: '-', formula: '과세표준 × 6%' },
|
|
||||||
{ bracket: '1,400만원 초과 ~ 5,000만원 이하', rate: '15%', deduction: '126만원', formula: '과세표준 × 15% - 126만원' },
|
|
||||||
{ bracket: '5,000만원 초과 ~ 8,800만원 이하', rate: '24%', deduction: '576만원', formula: '과세표준 × 24% - 576만원' },
|
|
||||||
{ bracket: '8,800만원 초과 ~ 1.5억원 이하', rate: '35%', deduction: '1,544만원', formula: '과세표준 × 35% - 1,544만원' },
|
|
||||||
{ bracket: '1.5억원 초과 ~ 3억원 이하', rate: '38%', deduction: '1,994만원', formula: '과세표준 × 38% - 1,994만원' },
|
|
||||||
{ bracket: '3억원 초과 ~ 5억원 이하', rate: '40%', deduction: '2,594만원', formula: '과세표준 × 40% - 2,594만원' },
|
|
||||||
{ bracket: '5억원 초과 ~ 10억원 이하', rate: '42%', deduction: '3,594만원', formula: '과세표준 × 42% - 3,594만원' },
|
|
||||||
{ bracket: '10억원 초과', rate: '45%', deduction: '6,594만원', formula: '과세표준 × 45% - 6,594만원' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return configs[cardId] || null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,239 +1,10 @@
|
|||||||
import type { DetailModalConfig } from '../types';
|
import type { DetailModalConfig } from '../types';
|
||||||
|
|
||||||
/**
|
|
||||||
* 접대비 상세 공통 모달 config (et2, et3, et4 공통)
|
|
||||||
*/
|
|
||||||
const entertainmentDetailConfig: DetailModalConfig = {
|
|
||||||
title: '접대비 상세',
|
|
||||||
dateFilter: {
|
|
||||||
enabled: true,
|
|
||||||
defaultPreset: '당월',
|
|
||||||
showSearch: true,
|
|
||||||
},
|
|
||||||
summaryCards: [
|
|
||||||
// 첫 번째 줄: 당해년도
|
|
||||||
{ label: '당해년도 접대비 총 한도', value: 3123000, unit: '원' },
|
|
||||||
{ label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' },
|
|
||||||
{ label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' },
|
|
||||||
{ label: '당해년도 접대비 초과 금액', value: 0, unit: '원' },
|
|
||||||
],
|
|
||||||
reviewCards: {
|
|
||||||
title: '접대비 검토 필요',
|
|
||||||
cards: [
|
|
||||||
{ label: '주말/심야', amount: 3123000, subLabel: '미증빙 5건' },
|
|
||||||
{ label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, subLabel: '불인정 5건' },
|
|
||||||
{ label: '고액 결제', amount: 3123000, subLabel: '미증빙 5건' },
|
|
||||||
{ label: '증빙 미비', amount: 3123000, subLabel: '미증빙 5건' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
barChart: {
|
|
||||||
title: '월별 접대비 사용 추이',
|
|
||||||
data: [
|
|
||||||
{ name: '1월', value: 3500000 },
|
|
||||||
{ name: '2월', value: 4200000 },
|
|
||||||
{ name: '3월', value: 2300000 },
|
|
||||||
{ name: '4월', value: 3800000 },
|
|
||||||
{ name: '5월', value: 4500000 },
|
|
||||||
{ name: '6월', value: 3200000 },
|
|
||||||
{ name: '7월', value: 2800000 },
|
|
||||||
],
|
|
||||||
dataKey: 'value',
|
|
||||||
xAxisKey: 'name',
|
|
||||||
color: '#60A5FA',
|
|
||||||
},
|
|
||||||
pieChart: {
|
|
||||||
title: '사용자별 접대비 사용 비율',
|
|
||||||
data: [
|
|
||||||
{ name: '홍길동', value: 15000000, percentage: 53, color: '#60A5FA' },
|
|
||||||
{ name: '김철수', value: 10000000, percentage: 31, color: '#34D399' },
|
|
||||||
{ name: '이영희', value: 10000000, percentage: 10, color: '#FBBF24' },
|
|
||||||
{ name: '기타', value: 2000000, percentage: 6, color: '#F87171' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
title: '월별 접대비 사용 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
|
||||||
{ key: 'user', label: '사용자', align: 'center' },
|
|
||||||
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
|
|
||||||
{ key: 'transDate', label: '거래일시', align: 'center', format: 'date' },
|
|
||||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'content', label: '내용', align: 'left' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '심야 카드 사용' },
|
|
||||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '미증빙' },
|
|
||||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '고액 결제' },
|
|
||||||
{ cardName: '카드명', user: '김철수', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, content: '불인정 가맹점 (귀금속)' },
|
|
||||||
{ cardName: '카드명', user: '이영희', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '접대비 불인정' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'user',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '홍길동', label: '홍길동' },
|
|
||||||
{ value: '김철수', label: '김철수' },
|
|
||||||
{ value: '이영희', label: '이영희' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'content',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '주말/심야', label: '주말/심야' },
|
|
||||||
{ value: '기피업종', label: '기피업종' },
|
|
||||||
{ value: '고액 결제', label: '고액 결제' },
|
|
||||||
{ value: '증빙 미비', label: '증빙 미비' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 11000000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
},
|
|
||||||
// 접대비 손금한도 계산 - 기본한도 / 수입금액별 추가한도
|
|
||||||
referenceTables: [
|
|
||||||
{
|
|
||||||
title: '접대비 손금한도 계산 - 기본한도',
|
|
||||||
columns: [
|
|
||||||
{ key: 'type', label: '법인 유형', align: 'left' },
|
|
||||||
{ key: 'annualLimit', label: '연간 기본한도', align: 'right' },
|
|
||||||
{ key: 'monthlyLimit', label: '월 환산', align: 'right' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ type: '일반법인', annualLimit: '12,000,000원', monthlyLimit: '1,000,000원' },
|
|
||||||
{ type: '중소기업', annualLimit: '36,000,000원', monthlyLimit: '3,000,000원' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '수입금액별 추가한도',
|
|
||||||
columns: [
|
|
||||||
{ key: 'range', label: '수입금액 구간', align: 'left' },
|
|
||||||
{ key: 'formula', label: '추가한도 계산식', align: 'left' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ range: '100억원 이하', formula: '수입금액 × 0.2%' },
|
|
||||||
{ range: '100억 초과 ~ 500억 이하', formula: '2,000만원 + (수입금액 - 100억) × 0.1%' },
|
|
||||||
{ range: '500억원 초과', formula: '6,000만원 + (수입금액 - 500억) × 0.03%' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// 접대비 계산
|
|
||||||
calculationCards: {
|
|
||||||
title: '접대비 계산',
|
|
||||||
cards: [
|
|
||||||
{ label: '중소기업 연간 기본한도', value: 36000000 },
|
|
||||||
{ label: '당해년도 수입금액별 추가한도', value: 16000000, operator: '+' },
|
|
||||||
{ label: '당해년도 접대비 총 한도', value: 52000000, operator: '=' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// 접대비 현황 (분기별)
|
|
||||||
quarterlyTable: {
|
|
||||||
title: '접대비 현황',
|
|
||||||
rows: [
|
|
||||||
{ label: '한도금액', q1: 13000000, q2: 13000000, q3: 13000000, q4: 13000000, total: 52000000 },
|
|
||||||
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
|
|
||||||
{ label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
|
|
||||||
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
|
|
||||||
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 접대비 현황 모달 설정
|
* 접대비 현황 모달 설정
|
||||||
* et_sales: 당해 매출 상세
|
* API 연동 완료 — useEntertainmentDetail hook이 실제 데이터 반환
|
||||||
* et_limit, et_remaining, et_used: 접대비 상세 (공통)
|
* 이 함수는 하위 호환용으로 유지하되 null 반환
|
||||||
*/
|
*/
|
||||||
export function getEntertainmentModalConfig(cardId: string): DetailModalConfig | null {
|
export function getEntertainmentModalConfig(_cardId: string): DetailModalConfig | null {
|
||||||
const configs: Record<string, DetailModalConfig> = {
|
return null;
|
||||||
et_sales: {
|
|
||||||
title: '당해 매출 상세',
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '당해년도 매출', value: 600000000, unit: '원' },
|
|
||||||
{ label: '전년 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
|
||||||
{ label: '당월 매출', value: 6000000, unit: '원' },
|
|
||||||
],
|
|
||||||
barChart: {
|
|
||||||
title: '월별 매출 추이',
|
|
||||||
data: [
|
|
||||||
{ name: '1월', value: 85000000 },
|
|
||||||
{ name: '2월', value: 92000000 },
|
|
||||||
{ name: '3월', value: 78000000 },
|
|
||||||
{ name: '4월', value: 95000000 },
|
|
||||||
{ name: '5월', value: 88000000 },
|
|
||||||
{ name: '6월', value: 102000000 },
|
|
||||||
{ name: '7월', value: 60000000 },
|
|
||||||
],
|
|
||||||
dataKey: 'value',
|
|
||||||
xAxisKey: 'name',
|
|
||||||
color: '#60A5FA',
|
|
||||||
},
|
|
||||||
horizontalBarChart: {
|
|
||||||
title: '당해년도 거래처별 매출',
|
|
||||||
data: [
|
|
||||||
{ name: '(주)세우', value: 120000000 },
|
|
||||||
{ name: '대한건설', value: 95000000 },
|
|
||||||
{ name: '삼성테크', value: 78000000 },
|
|
||||||
{ name: '현대상사', value: 65000000 },
|
|
||||||
{ name: '기타', value: 42000000 },
|
|
||||||
],
|
|
||||||
color: '#60A5FA',
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
title: '일별 매출 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'date', label: '매출일', align: 'center', format: 'date' },
|
|
||||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
|
||||||
{ key: 'amount', label: '매출금액', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'type', label: '매출유형', align: 'center', highlightValue: '미설정' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
|
|
||||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부품 매출' },
|
|
||||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '공사 매출' },
|
|
||||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
|
||||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
|
||||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
|
||||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '상품 매출', label: '상품 매출' },
|
|
||||||
{ value: '부품 매출', label: '부품 매출' },
|
|
||||||
{ value: '공사 매출', label: '공사 매출' },
|
|
||||||
{ value: '임대 수익', label: '임대 수익' },
|
|
||||||
{ value: '기타 매출', label: '기타 매출' },
|
|
||||||
{ value: '미설정', label: '미설정' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 111000000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// et_limit, et_remaining, et_used는 모두 동일한 접대비 상세 모달
|
|
||||||
et_limit: entertainmentDetailConfig,
|
|
||||||
et_remaining: entertainmentDetailConfig,
|
|
||||||
et_used: entertainmentDetailConfig,
|
|
||||||
// 대시보드 카드 ID (et1~et4) → 접대비 상세 모달
|
|
||||||
et1: entertainmentDetailConfig,
|
|
||||||
et2: entertainmentDetailConfig,
|
|
||||||
et3: entertainmentDetailConfig,
|
|
||||||
et4: entertainmentDetailConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
return configs[cardId] || null;
|
|
||||||
}
|
}
|
||||||
@@ -1,269 +1,10 @@
|
|||||||
import type { DetailModalConfig } from '../types';
|
import type { DetailModalConfig } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 당월 예상 지출 모달 설정 (D1.7 기획서 P48-51 반영)
|
* 당월 예상 지출 모달 설정
|
||||||
|
* API 연동 완료 — useMonthlyExpenseDetail hook이 실제 데이터 반환
|
||||||
|
* 이 함수는 하위 호환용으로 유지하되 null 반환
|
||||||
*/
|
*/
|
||||||
export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null {
|
export function getMonthlyExpenseModalConfig(_cardId: string): DetailModalConfig | null {
|
||||||
const configs: Record<string, DetailModalConfig> = {
|
return null;
|
||||||
// P48: 매입 상세
|
|
||||||
me1: {
|
|
||||||
title: '매입 상세',
|
|
||||||
dateFilter: {
|
|
||||||
enabled: true,
|
|
||||||
defaultPreset: '당월',
|
|
||||||
showSearch: true,
|
|
||||||
},
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '매입', value: 3123000, unit: '원' },
|
|
||||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
|
||||||
],
|
|
||||||
barChart: {
|
|
||||||
title: '매입 추이',
|
|
||||||
data: [
|
|
||||||
{ name: '1월', value: 45000000 },
|
|
||||||
{ name: '2월', value: 52000000 },
|
|
||||||
{ name: '3월', value: 48000000 },
|
|
||||||
{ name: '4월', value: 61000000 },
|
|
||||||
{ name: '5월', value: 55000000 },
|
|
||||||
{ name: '6월', value: 58000000 },
|
|
||||||
{ name: '7월', value: 50000000 },
|
|
||||||
],
|
|
||||||
dataKey: 'value',
|
|
||||||
xAxisKey: 'name',
|
|
||||||
color: '#60A5FA',
|
|
||||||
},
|
|
||||||
pieChart: {
|
|
||||||
title: '자재 유형별 구매 비율',
|
|
||||||
data: [
|
|
||||||
{ name: '원자재', value: 55000000, percentage: 55, color: '#60A5FA' },
|
|
||||||
{ name: '부자재', value: 35000000, percentage: 35, color: '#FBBF24' },
|
|
||||||
{ name: '포장재', value: 10000000, percentage: 10, color: '#F87171' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
title: '일별 매입 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'date', label: '매입일', align: 'center', format: 'date' },
|
|
||||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
|
||||||
{ key: 'amount', label: '매입금액', align: 'right', format: 'currency' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
|
||||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
|
||||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
|
||||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
|
||||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
|
||||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 111000000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// P49: 카드 상세
|
|
||||||
me2: {
|
|
||||||
title: '카드 상세',
|
|
||||||
dateFilter: {
|
|
||||||
enabled: true,
|
|
||||||
defaultPreset: '당월',
|
|
||||||
showSearch: true,
|
|
||||||
},
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '카드 사용', value: 6000000, unit: '원' },
|
|
||||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
|
||||||
{ label: '건수', value: '10건' },
|
|
||||||
],
|
|
||||||
barChart: {
|
|
||||||
title: '카드 사용 추이',
|
|
||||||
data: [
|
|
||||||
{ name: '1월', value: 4500000 },
|
|
||||||
{ name: '2월', value: 5200000 },
|
|
||||||
{ name: '3월', value: 4800000 },
|
|
||||||
{ name: '4월', value: 6100000 },
|
|
||||||
{ name: '5월', value: 5500000 },
|
|
||||||
{ name: '6월', value: 5800000 },
|
|
||||||
{ name: '7월', value: 6000000 },
|
|
||||||
],
|
|
||||||
dataKey: 'value',
|
|
||||||
xAxisKey: 'name',
|
|
||||||
color: '#60A5FA',
|
|
||||||
},
|
|
||||||
pieChart: {
|
|
||||||
title: '사용자별 카드 사용 비율',
|
|
||||||
data: [
|
|
||||||
{ name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' },
|
|
||||||
{ name: '김영희', value: 35000000, percentage: 35, color: '#FBBF24' },
|
|
||||||
{ name: '이정현', value: 10000000, percentage: 10, color: '#F87171' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
title: '일별 카드 사용 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
|
||||||
{ key: 'user', label: '사용자', align: 'center' },
|
|
||||||
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
|
||||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
|
||||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'usageType', label: '계정과목', align: 'center', highlightValue: '미설정' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' },
|
|
||||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' },
|
|
||||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
|
||||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'user',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '홍길동', label: '홍길동' },
|
|
||||||
{ value: '김영희', label: '김영희' },
|
|
||||||
{ value: '이정현', label: '이정현' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 11000000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// P50: 발행어음 상세
|
|
||||||
me3: {
|
|
||||||
title: '발행어음 상세',
|
|
||||||
dateFilter: {
|
|
||||||
enabled: true,
|
|
||||||
presets: ['당해년도', '전전월', '전월', '당월', '어제'],
|
|
||||||
defaultPreset: '당월',
|
|
||||||
showSearch: true,
|
|
||||||
},
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '발행어음', value: 3123000, unit: '원' },
|
|
||||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
|
||||||
],
|
|
||||||
barChart: {
|
|
||||||
title: '발행어음 추이',
|
|
||||||
data: [
|
|
||||||
{ name: '1월', value: 2000000 },
|
|
||||||
{ name: '2월', value: 2500000 },
|
|
||||||
{ name: '3월', value: 2200000 },
|
|
||||||
{ name: '4월', value: 2800000 },
|
|
||||||
{ name: '5월', value: 2600000 },
|
|
||||||
{ name: '6월', value: 3000000 },
|
|
||||||
{ name: '7월', value: 3123000 },
|
|
||||||
],
|
|
||||||
dataKey: 'value',
|
|
||||||
xAxisKey: 'name',
|
|
||||||
color: '#60A5FA',
|
|
||||||
},
|
|
||||||
pieChart: {
|
|
||||||
title: '거래처별 발행어음',
|
|
||||||
data: [
|
|
||||||
{ name: '거래처1', value: 50000000, percentage: 45, color: '#60A5FA' },
|
|
||||||
{ name: '거래처2', value: 35000000, percentage: 32, color: '#FBBF24' },
|
|
||||||
{ name: '거래처3', value: 20000000, percentage: 18, color: '#F87171' },
|
|
||||||
{ name: '거래처4', value: 6000000, percentage: 5, color: '#34D399' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
title: '일별 발행어음 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
|
||||||
{ key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
|
|
||||||
{ key: 'dueDate', label: '만기일', align: 'center', format: 'date' },
|
|
||||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'status', label: '상태', align: 'center' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
|
||||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
|
||||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
|
||||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
|
||||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
|
||||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'vendor',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '회사명', label: '회사명' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '보관중', label: '보관중' },
|
|
||||||
{ value: '만기임박', label: '만기임박' },
|
|
||||||
{ value: '만기경과', label: '만기경과' },
|
|
||||||
{ value: '결제완료', label: '결제완료' },
|
|
||||||
{ value: '부도', label: '부도' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 111000000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// P51: 당월 지출 예상 상세
|
|
||||||
me4: {
|
|
||||||
title: '당월 지출 예상 상세',
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '당월 지출 예상', value: 6000000, unit: '원' },
|
|
||||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
|
||||||
{ label: '총 계좌 잔액', value: 10000000, unit: '원' },
|
|
||||||
],
|
|
||||||
table: {
|
|
||||||
title: '당월 지출 승인 내역서',
|
|
||||||
columns: [
|
|
||||||
{ key: 'paymentDate', label: '예상 지급일', align: 'center' },
|
|
||||||
{ key: 'item', label: '항목', align: 'left' },
|
|
||||||
{ key: 'amount', label: '지출금액', align: 'right', format: 'currency', highlightColor: 'red' },
|
|
||||||
{ key: 'vendor', label: '거래처', align: 'center' },
|
|
||||||
{ key: 'account', label: '계좌', align: 'center' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
|
||||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
|
||||||
{ paymentDate: '2025-12-12', item: '(발행 어음) 123123123', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
|
||||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
|
||||||
{ paymentDate: '2025-12-12', item: '거래처명 12월분', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
|
||||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
|
||||||
{ paymentDate: '2025-12-12', item: '적요 내용', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'vendor',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '회사명', label: '회사명' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '2025/12 계',
|
|
||||||
totalValue: 6000000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
footerSummary: [
|
|
||||||
{ label: '지출 합계', value: 6000000 },
|
|
||||||
{ label: '계좌 잔액', value: 10000000 },
|
|
||||||
{ label: '최종 차액', value: 4000000 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return configs[cardId] || null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,76 +2,9 @@ import type { DetailModalConfig } from '../types';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 부가세 모달 설정
|
* 부가세 모달 설정
|
||||||
* 모든 카드가 동일한 상세 모달
|
* API 연동 완료 — useVatDetail hook이 실제 데이터 반환
|
||||||
|
* 이 함수는 하위 호환용으로 유지하되 null 반환
|
||||||
*/
|
*/
|
||||||
export function getVatModalConfig(): DetailModalConfig {
|
export function getVatModalConfig(): DetailModalConfig | null {
|
||||||
return {
|
return null;
|
||||||
title: '예상 납부세액',
|
|
||||||
periodSelect: {
|
|
||||||
enabled: true,
|
|
||||||
options: [
|
|
||||||
{ value: '2026-1-expected', label: '2026년 1기 예정' },
|
|
||||||
{ value: '2025-2-confirmed', label: '2025년 2기 확정' },
|
|
||||||
{ value: '2025-2-expected', label: '2025년 2기 예정' },
|
|
||||||
{ value: '2025-1-confirmed', label: '2025년 1기 확정' },
|
|
||||||
],
|
|
||||||
defaultValue: '2026-1-expected',
|
|
||||||
},
|
|
||||||
summaryCards: [
|
|
||||||
{ label: '예상매출', value: '30.5억원' },
|
|
||||||
{ label: '예상매입', value: '20.5억원' },
|
|
||||||
{ label: '예상 납부세액', value: '1.1억원' },
|
|
||||||
],
|
|
||||||
// 부가세 요약 테이블
|
|
||||||
referenceTable: {
|
|
||||||
title: '2026년 1기 예정 부가세 요약',
|
|
||||||
columns: [
|
|
||||||
{ key: 'category', label: '구분', align: 'left' },
|
|
||||||
{ key: 'supplyAmount', label: '공급가액', align: 'right' },
|
|
||||||
{ key: 'taxAmount', label: '세액', align: 'right' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ category: '매출(전자세금계산서)', supplyAmount: '100,000,000', taxAmount: '10,000,000' },
|
|
||||||
{ category: '매입(전자세금계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
|
|
||||||
{ category: '매입(종이세금계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
|
|
||||||
{ category: '매입(계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
|
|
||||||
{ category: '매입(신용카드)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
|
|
||||||
{ category: '납부세액', supplyAmount: '', taxAmount: '6,000,000' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// 세금계산서 미발행/미수취 내역
|
|
||||||
table: {
|
|
||||||
title: '세금계산서 미발행/미수취 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'type', label: '구분', align: 'center' },
|
|
||||||
{ key: 'issueDate', label: '발생일자', align: 'center', format: 'date' },
|
|
||||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
|
||||||
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'invoiceStatus', label: '세금계산서 미발행/미수취', align: 'center' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
|
||||||
{ type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' },
|
|
||||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
|
||||||
{ type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' },
|
|
||||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '매출', label: '매출' },
|
|
||||||
{ value: '매입', label: '매입' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 111000000,
|
|
||||||
totalColumnKey: 'vat',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,149 +2,9 @@ import type { DetailModalConfig } from '../types';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 복리후생비 현황 모달 설정
|
* 복리후생비 현황 모달 설정
|
||||||
*
|
* API 연동 완료 — useWelfareDetail hook이 실제 데이터 반환
|
||||||
* @deprecated 정적 목업 데이터 - API 연동 후에는 useWelfareDetail hook 사용 권장
|
* 이 함수는 하위 호환용으로 유지하되 null 반환
|
||||||
*
|
|
||||||
* API 연동 방법:
|
|
||||||
* 1. useWelfareDetail hook 호출하여 modalConfig 가져오기
|
|
||||||
* 2. API 호출 실패 시 이 fallback config 사용
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const { modalConfig, loading, error, refetch } = useWelfareDetail({
|
|
||||||
* calculationType: 'fixed',
|
|
||||||
* year: 2026,
|
|
||||||
* quarter: 1,
|
|
||||||
* });
|
|
||||||
* const config = modalConfig ?? getWelfareModalConfig('fixed'); // fallback
|
|
||||||
*
|
|
||||||
* @param calculationType - 계산 방식 ('fixed': 직원당 정액 금액/월, 'ratio': 연봉 총액 비율)
|
|
||||||
*/
|
*/
|
||||||
export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): DetailModalConfig {
|
export function getWelfareModalConfig(_calculationType: 'fixed' | 'ratio'): DetailModalConfig | null {
|
||||||
// 계산 방식에 따른 조건부 calculationCards 생성
|
return null;
|
||||||
const calculationCards = calculationType === 'fixed'
|
|
||||||
? {
|
|
||||||
// 직원당 정액 금액/월 방식
|
|
||||||
title: '복리후생비 계산',
|
|
||||||
subtitle: '직원당 정액 금액/월 200,000원',
|
|
||||||
cards: [
|
|
||||||
{ label: '직원 수', value: 20, unit: '명' },
|
|
||||||
{ label: '연간 직원당 월급 금액', value: 2400000, unit: '원', operator: '×' as const },
|
|
||||||
{ label: '당해년도 복리후생비 총 한도', value: 48000000, unit: '원', operator: '=' as const },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
// 연봉 총액 비율 방식
|
|
||||||
title: '복리후생비 계산',
|
|
||||||
subtitle: '연봉 총액 기준 비율 20.5%',
|
|
||||||
cards: [
|
|
||||||
{ label: '연봉 총액', value: 1000000000, unit: '원' },
|
|
||||||
{ label: '비율', value: 20.5, unit: '%', operator: '×' as const },
|
|
||||||
{ label: '당해년도 복리후생비 총 한도', value: 205000000, unit: '원', operator: '=' as const },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: '복리후생비 상세',
|
|
||||||
dateFilter: {
|
|
||||||
enabled: true,
|
|
||||||
defaultPreset: '당월',
|
|
||||||
showSearch: true,
|
|
||||||
},
|
|
||||||
summaryCards: [
|
|
||||||
// 1행: 당해년도 기준
|
|
||||||
{ label: '당해년도 복리후생비 총 한도', value: 3123000, unit: '원' },
|
|
||||||
{ label: '당해년도 복리후생비 잔여한도', value: 6000000, unit: '원' },
|
|
||||||
{ label: '당해년도 복리후생비 사용금액', value: 6000000, unit: '원' },
|
|
||||||
{ label: '당해년도 복리후생비 초과 금액', value: 0, unit: '원' },
|
|
||||||
],
|
|
||||||
reviewCards: {
|
|
||||||
title: '복리후생비 검토 필요',
|
|
||||||
cards: [
|
|
||||||
{ label: '비과세 한도 초과', amount: 3123000, subLabel: '5건' },
|
|
||||||
{ label: '사적 사용 의심', amount: 3123000, subLabel: '5건' },
|
|
||||||
{ label: '특정인 편중', amount: 3123000, subLabel: '5건' },
|
|
||||||
{ label: '항목별 한도 초과', amount: 3123000, subLabel: '5건' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
barChart: {
|
|
||||||
title: '월별 복리후생비 사용 추이',
|
|
||||||
data: [
|
|
||||||
{ name: '1월', value: 1500000 },
|
|
||||||
{ name: '2월', value: 1800000 },
|
|
||||||
{ name: '3월', value: 2200000 },
|
|
||||||
{ name: '4월', value: 1900000 },
|
|
||||||
{ name: '5월', value: 2100000 },
|
|
||||||
{ name: '6월', value: 1700000 },
|
|
||||||
],
|
|
||||||
dataKey: 'value',
|
|
||||||
xAxisKey: 'name',
|
|
||||||
color: '#60A5FA',
|
|
||||||
},
|
|
||||||
pieChart: {
|
|
||||||
title: '항목별 사용 비율',
|
|
||||||
data: [
|
|
||||||
{ name: '식비', value: 55000000, percentage: 55, color: '#FBBF24' },
|
|
||||||
{ name: '건강검진', value: 25000000, percentage: 5, color: '#60A5FA' },
|
|
||||||
{ name: '경조사비', value: 10000000, percentage: 10, color: '#F87171' },
|
|
||||||
{ name: '기타', value: 10000000, percentage: 30, color: '#34D399' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
title: '일별 복리후생비 사용 내역',
|
|
||||||
columns: [
|
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
|
||||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
|
||||||
{ key: 'user', label: '사용자', align: 'center' },
|
|
||||||
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
|
||||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
|
||||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'content', label: '내용', align: 'left' },
|
|
||||||
],
|
|
||||||
data: [
|
|
||||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, content: '비과세 한도 초과' },
|
|
||||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, content: '사적 사용 의심' },
|
|
||||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, content: '특정인 편중' },
|
|
||||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, content: '항목별 한도 초과' },
|
|
||||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, content: '비과세 한도 초과' },
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
key: 'user',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '홍길동', label: '홍길동' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'content',
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
{ value: '비과세 한도 초과', label: '비과세 한도 초과' },
|
|
||||||
{ value: '사적 사용 의심', label: '사적 사용 의심' },
|
|
||||||
{ value: '특정인 편중', label: '특정인 편중' },
|
|
||||||
{ value: '항목별 한도 초과', label: '항목별 한도 초과' },
|
|
||||||
],
|
|
||||||
defaultValue: 'all',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showTotal: true,
|
|
||||||
totalLabel: '합계',
|
|
||||||
totalValue: 11000000,
|
|
||||||
totalColumnKey: 'amount',
|
|
||||||
},
|
|
||||||
// 복리후생비 계산 (조건부 - calculationType에 따라)
|
|
||||||
calculationCards,
|
|
||||||
// 복리후생비 현황 (분기별 테이블)
|
|
||||||
quarterlyTable: {
|
|
||||||
title: '복리후생비 현황',
|
|
||||||
rows: [
|
|
||||||
{ label: '한도금액', q1: 12000000, q2: 12000000, q3: 12000000, q4: 12000000, total: 48000000 },
|
|
||||||
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
|
|
||||||
{ label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
|
|
||||||
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
|
|
||||||
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -28,9 +28,10 @@ interface DetailModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
config: DetailModalConfig;
|
config: DetailModalConfig;
|
||||||
|
onDateFilterChange?: (params: { startDate: string; endDate: string; search: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
export function DetailModal({ isOpen, onClose, config, onDateFilterChange }: DetailModalProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} >
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} >
|
||||||
<DialogContent className="!w-[95vw] sm:!w-[90vw] md:!w-[85vw] !max-w-[1600px] max-h-[90vh] overflow-auto p-0">
|
<DialogContent className="!w-[95vw] sm:!w-[90vw] md:!w-[85vw] !max-w-[1600px] max-h-[90vh] overflow-auto p-0">
|
||||||
@@ -51,7 +52,7 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
|||||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||||
{/* 기간선택기 영역 */}
|
{/* 기간선택기 영역 */}
|
||||||
{config.dateFilter?.enabled && (
|
{config.dateFilter?.enabled && (
|
||||||
<DateFilterSection config={config.dateFilter} />
|
<DateFilterSection config={config.dateFilter} onFilterChange={onDateFilterChange} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 신고기간 셀렉트 영역 */}
|
{/* 신고기간 셀렉트 영역 */}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
import { Search } from 'lucide-react';
|
import { Search as SearchIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -46,7 +47,7 @@ import type {
|
|||||||
// 필터 섹션
|
// 필터 섹션
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
|
export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilterConfig; onFilterChange?: (params: { startDate: string; endDate: string; search: string }) => void }) => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [startDate, setStartDate] = useState(() => {
|
const [startDate, setStartDate] = useState(() => {
|
||||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
@@ -57,6 +58,24 @@ export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
|
|||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
});
|
});
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
// 날짜 변경 시 자동 조회 (다른 페이지와 동일한 UX)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFilterChange?.({ startDate, endDate, search: searchText });
|
||||||
|
}, [startDate, endDate]);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
onFilterChange?.({ startDate, endDate, search: searchText });
|
||||||
|
}, [startDate, endDate, searchText, onFilterChange]);
|
||||||
|
|
||||||
|
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleSearch();
|
||||||
|
}, [handleSearch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-4 border-b">
|
<div className="pb-4 border-b">
|
||||||
@@ -66,17 +85,20 @@ export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
|
|||||||
onStartDateChange={setStartDate}
|
onStartDateChange={setStartDate}
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
extraActions={
|
extraActions={
|
||||||
config.showSearch !== false ? (
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<div className="relative ml-auto">
|
{config.showSearch !== false && (
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
|
<div className="relative">
|
||||||
<Input
|
<SearchIcon className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
|
||||||
value={searchText}
|
<Input
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
value={searchText}
|
||||||
placeholder="검색"
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
className="h-8 pl-7 pr-3 text-xs w-[140px]"
|
onKeyDown={handleSearchKeyDown}
|
||||||
/>
|
placeholder="검색"
|
||||||
</div>
|
className="h-8 pl-7 pr-3 text-xs w-[140px]"
|
||||||
) : undefined
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,10 +108,15 @@ export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
|
|||||||
export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => {
|
export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => {
|
||||||
const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || '');
|
const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || '');
|
||||||
|
|
||||||
|
const handleChange = useCallback((value: string) => {
|
||||||
|
setSelected(value);
|
||||||
|
config.onPeriodChange?.(value);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 pb-4 border-b">
|
<div className="flex items-center gap-2 pb-4 border-b">
|
||||||
<span className="text-sm text-gray-600 font-medium">신고기간</span>
|
<span className="text-sm text-gray-600 font-medium">신고기간</span>
|
||||||
<Select value={selected} onValueChange={setSelected}>
|
<Select value={selected} onValueChange={handleChange}>
|
||||||
<SelectTrigger className="h-8 w-auto min-w-[200px] text-xs">
|
<SelectTrigger className="h-8 w-auto min-w-[200px] text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { TimePicker } from '@/components/ui/time-picker';
|
import { TimePicker } from '@/components/ui/time-picker';
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { CalendarScheduleItem } from '../types';
|
import type { CalendarScheduleItem } from '../types';
|
||||||
|
|
||||||
// 색상 옵션
|
// 색상 옵션
|
||||||
@@ -59,6 +60,7 @@ interface ScheduleDetailModalProps {
|
|||||||
schedule: CalendarScheduleItem | null;
|
schedule: CalendarScheduleItem | null;
|
||||||
onSave: (data: ScheduleFormData) => void;
|
onSave: (data: ScheduleFormData) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
isEditable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleDetailModal({
|
export function ScheduleDetailModal({
|
||||||
@@ -67,6 +69,7 @@ export function ScheduleDetailModal({
|
|||||||
schedule,
|
schedule,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
isEditable = true,
|
||||||
}: ScheduleDetailModalProps) {
|
}: ScheduleDetailModalProps) {
|
||||||
const isEditMode = schedule && schedule.id !== '';
|
const isEditMode = schedule && schedule.id !== '';
|
||||||
|
|
||||||
@@ -128,7 +131,14 @@ export function ScheduleDetailModal({
|
|||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||||
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
|
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader className="pb-2">
|
<DialogHeader className="pb-2">
|
||||||
<DialogTitle className="text-lg font-bold">일정 상세</DialogTitle>
|
<DialogTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
|
일정 상세
|
||||||
|
{!isEditable && (
|
||||||
|
<Badge variant="secondary" className="text-xs bg-gray-100 text-gray-600">
|
||||||
|
읽기전용
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
@@ -139,6 +149,7 @@ export function ScheduleDetailModal({
|
|||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => handleFieldChange('title', e.target.value)}
|
onChange={(e) => handleFieldChange('title', e.target.value)}
|
||||||
placeholder="제목"
|
placeholder="제목"
|
||||||
|
disabled={!isEditable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,6 +159,7 @@ export function ScheduleDetailModal({
|
|||||||
<Select
|
<Select
|
||||||
value={formData.department}
|
value={formData.department}
|
||||||
onValueChange={(value) => handleFieldChange('department', value)}
|
onValueChange={(value) => handleFieldChange('department', value)}
|
||||||
|
disabled={!isEditable}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="부서명" />
|
<SelectValue placeholder="부서명" />
|
||||||
@@ -165,23 +177,15 @@ export function ScheduleDetailModal({
|
|||||||
{/* 기간 */}
|
{/* 기간 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-gray-700">기간</label>
|
<label className="text-sm font-medium text-gray-700">기간</label>
|
||||||
<div className="flex flex-col gap-2">
|
<DateRangePicker
|
||||||
<DatePicker
|
startDate={formData.startDate}
|
||||||
value={formData.startDate}
|
endDate={formData.endDate}
|
||||||
onChange={(value) => handleFieldChange('startDate', value)}
|
onStartDateChange={(date) => handleFieldChange('startDate', date)}
|
||||||
size="sm"
|
onEndDateChange={(date) => handleFieldChange('endDate', date)}
|
||||||
className="w-full"
|
size="sm"
|
||||||
/>
|
className="w-full"
|
||||||
<div className="flex items-center gap-2">
|
disabled={!isEditable}
|
||||||
<span className="text-gray-400 text-xs">~</span>
|
/>
|
||||||
<DatePicker
|
|
||||||
value={formData.endDate}
|
|
||||||
onChange={(value) => handleFieldChange('endDate', value)}
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 시간 */}
|
{/* 시간 */}
|
||||||
@@ -196,6 +200,7 @@ export function ScheduleDetailModal({
|
|||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
handleFieldChange('isAllDay', checked === true)
|
handleFieldChange('isAllDay', checked === true)
|
||||||
}
|
}
|
||||||
|
disabled={!isEditable}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="isAllDay" className="text-sm text-gray-600 cursor-pointer">
|
<label htmlFor="isAllDay" className="text-sm text-gray-600 cursor-pointer">
|
||||||
종일
|
종일
|
||||||
@@ -236,9 +241,10 @@ export function ScheduleDetailModal({
|
|||||||
formData.color === color.value
|
formData.color === color.value
|
||||||
? 'ring-2 ring-offset-2 ring-gray-400'
|
? 'ring-2 ring-offset-2 ring-gray-400'
|
||||||
: 'hover:scale-110'
|
: 'hover:scale-110'
|
||||||
}`}
|
} ${!isEditable ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
onClick={() => handleFieldChange('color', color.value)}
|
onClick={() => isEditable && handleFieldChange('color', color.value)}
|
||||||
title={color.label}
|
title={color.label}
|
||||||
|
disabled={!isEditable}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -252,26 +258,35 @@ export function ScheduleDetailModal({
|
|||||||
onChange={(e) => handleFieldChange('content', e.target.value)}
|
onChange={(e) => handleFieldChange('content', e.target.value)}
|
||||||
placeholder="내용"
|
placeholder="내용"
|
||||||
className="min-h-[100px] resize-none"
|
className="min-h-[100px] resize-none"
|
||||||
|
disabled={!isEditable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex flex-row gap-2 pt-2">
|
<DialogFooter className="flex flex-row gap-2 pt-2">
|
||||||
{isEditMode && onDelete && (
|
{isEditable ? (
|
||||||
<Button
|
<>
|
||||||
variant="outline"
|
{isEditMode && onDelete && (
|
||||||
onClick={handleDelete}
|
<Button
|
||||||
className="bg-gray-800 text-white hover:bg-gray-900"
|
variant="outline"
|
||||||
>
|
onClick={handleDelete}
|
||||||
삭제
|
className="bg-gray-800 text-white hover:bg-gray-900"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="bg-gray-800 text-white hover:bg-gray-900"
|
||||||
|
>
|
||||||
|
{isEditMode ? '수정' : '등록'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-gray-800 text-white hover:bg-gray-900"
|
|
||||||
>
|
|
||||||
{isEditMode ? '수정' : '등록'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
|
|||||||
tax: 'orange',
|
tax: 'orange',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 일정 타입별 라벨
|
||||||
|
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
order: '생산',
|
||||||
|
construction: '시공',
|
||||||
|
schedule: '일정',
|
||||||
|
other: '기타',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 일정 타입별 뱃지 색상
|
||||||
|
const SCHEDULE_TYPE_BADGE_COLORS: Record<string, string> = {
|
||||||
|
order: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
construction: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
|
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
other: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
// 이슈 뱃지별 색상
|
// 이슈 뱃지별 색상
|
||||||
const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
|
const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
|
||||||
'수주등록': 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
'수주등록': 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
@@ -453,6 +469,11 @@ export function CalendarSection({
|
|||||||
return (
|
return (
|
||||||
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
|
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
|
||||||
|
{isSelected && evType !== 'holiday' && evType !== 'tax' && evType !== 'issue' && (
|
||||||
|
<span className={`text-[10px] shrink-0 px-1 rounded ${SCHEDULE_TYPE_BADGE_COLORS[evType] || ''}`}>
|
||||||
|
{SCHEDULE_TYPE_LABELS[evType] || ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
|
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -469,8 +490,8 @@ export function CalendarSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데스크탑: 기존 캘린더 + 상세 */}
|
{/* 데스크탑: 캘린더 + 상세 (태블릿: 세로배치, 와이드: 가로배치) */}
|
||||||
<div className="hidden lg:grid lg:grid-cols-2 gap-6">
|
<div className="hidden lg:grid lg:grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
{/* 캘린더 영역 */}
|
{/* 캘린더 영역 */}
|
||||||
<div>
|
<div>
|
||||||
<ScheduleCalendar
|
<ScheduleCalendar
|
||||||
@@ -554,7 +575,12 @@ export function CalendarSection({
|
|||||||
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
|
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
|
||||||
onClick={() => onScheduleClick?.(schedule)}
|
onClick={() => onScheduleClick?.(schedule)}
|
||||||
>
|
>
|
||||||
<div className="font-medium text-base text-foreground mb-1">{schedule.title}</div>
|
<div className="flex items-start gap-2 mb-1">
|
||||||
|
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
|
||||||
|
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
|
||||||
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
|
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { CreditCard, Wallet, Receipt, AlertTriangle, Gift } from 'lucide-react';
|
import { CreditCard, Wallet, Receipt, AlertTriangle, Gift, CheckCircle2, ShieldAlert } from 'lucide-react';
|
||||||
|
import { formatKoreanAmount } from '@/lib/utils/amount';
|
||||||
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
||||||
import type { CardManagementData } from '../types';
|
import type { CardManagementData } from '../types';
|
||||||
|
|
||||||
@@ -14,9 +16,33 @@ interface CardManagementSectionProps {
|
|||||||
onCardClick?: (cardId: string) => void;
|
onCardClick?: (cardId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** subLabel에서 "미정리 N건", "미증빙 N건" 등의 건수를 파싱 */
|
||||||
|
function parseIssueCount(subLabel?: string): number {
|
||||||
|
if (!subLabel) return 0;
|
||||||
|
const match = subLabel.match(/(\d+)\s*건/);
|
||||||
|
return match ? parseInt(match[1], 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function CardManagementSection({ data, onCardClick }: CardManagementSectionProps) {
|
export function CardManagementSection({ data, onCardClick }: CardManagementSectionProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 카드별 미정리/미증빙 건수 집계
|
||||||
|
const issueStats = useMemo(() => {
|
||||||
|
let totalCount = 0;
|
||||||
|
let totalAmount = 0;
|
||||||
|
const issueCards: string[] = [];
|
||||||
|
|
||||||
|
for (const card of data.cards) {
|
||||||
|
const count = parseIssueCount(card.subLabel);
|
||||||
|
if (count > 0 || card.isHighlighted) {
|
||||||
|
totalCount += count;
|
||||||
|
totalAmount += card.subAmount ?? 0;
|
||||||
|
issueCards.push(card.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { totalCount, totalAmount, issueCards, hasIssues: totalCount > 0 };
|
||||||
|
}, [data.cards]);
|
||||||
|
|
||||||
const handleClick = (cardId: string) => {
|
const handleClick = (cardId: string) => {
|
||||||
if (onCardClick) {
|
if (onCardClick) {
|
||||||
onCardClick(cardId);
|
onCardClick(cardId);
|
||||||
@@ -31,9 +57,46 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
|
|||||||
title="가지급금 현황"
|
title="가지급금 현황"
|
||||||
subtitle="가지급금 관리 현황"
|
subtitle="가지급금 관리 현황"
|
||||||
>
|
>
|
||||||
{data.warningBanner && (
|
{/* 상태 배너: 미정리 있으면 빨간 펄스, 정상이면 초록 */}
|
||||||
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2">
|
{issueStats.hasIssues ? (
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<div className="relative overflow-hidden rounded-lg mb-4">
|
||||||
|
{/* 펄스 배경 */}
|
||||||
|
<div className="absolute inset-0 bg-red-500 animate-pulse opacity-20 rounded-lg" />
|
||||||
|
<div className="relative bg-red-50 dark:bg-red-900/40 border border-red-300 dark:border-red-700 px-4 py-3 rounded-lg flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center shrink-0">
|
||||||
|
<ShieldAlert className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-red-700 dark:text-red-300">
|
||||||
|
미정리 {issueStats.totalCount}건
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-red-600/80 dark:text-red-400/80">
|
||||||
|
{issueStats.issueCards.join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{issueStats.totalAmount > 0 && (
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-lg font-bold text-red-700 dark:text-red-300">
|
||||||
|
{formatKoreanAmount(issueStats.totalAmount)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-red-500/70 dark:text-red-400/60">미정리 총액</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-2.5 rounded-lg mb-4 flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-700 dark:text-green-300">미정리 건 없음 — 가지급금 정상 관리 중</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기존 warningBanner 호환 (issueStats과 별도 메시지가 있는 경우) */}
|
||||||
|
{data.warningBanner && issueStats.hasIssues && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs font-medium px-3 py-2 rounded-lg mb-4 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
{data.warningBanner}
|
{data.warningBanner}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); router.push('/ko/hr/attendance'); }}
|
onClick={(e) => { e.stopPropagation(); router.push('/hr/attendance-management'); }}
|
||||||
className="text-white hover:bg-white/10 gap-1 text-xs"
|
className="text-white hover:bg-white/10 gap-1 text-xs"
|
||||||
>
|
>
|
||||||
근태관리
|
근태관리
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* 공정 탭 */}
|
{/* 공정 탭 */}
|
||||||
|
{data.processes.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Factory className="h-10 w-10 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">오늘 등록된 작업 지시가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="mb-4">
|
<TabsList className="mb-4">
|
||||||
{data.processes.map((process) => (
|
{data.processes.map((process) => (
|
||||||
@@ -240,6 +246,7 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
</CollapsibleDashboardCard>
|
</CollapsibleDashboardCard>
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const formatUSD = (amount: number): string => {
|
|||||||
interface EnhancedDailyReportSectionProps {
|
interface EnhancedDailyReportSectionProps {
|
||||||
data: DailyReportData;
|
data: DailyReportData;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onExpenseDetailClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARD_STYLES = [
|
const CARD_STYLES = [
|
||||||
@@ -54,10 +55,18 @@ const CARD_STYLES = [
|
|||||||
{ bgClass: 'bg-orange-50 dark:bg-orange-900/30', borderClass: 'border-orange-200 dark:border-orange-800', iconBg: '#f97316', labelClass: 'text-orange-700 dark:text-orange-300', Icon: Clock },
|
{ bgClass: 'bg-orange-50 dark:bg-orange-900/30', borderClass: 'border-orange-200 dark:border-orange-800', iconBg: '#f97316', labelClass: 'text-orange-700 dark:text-orange-300', Icon: Clock },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyReportSectionProps) {
|
export function EnhancedDailyReportSection({ data, onClick, onExpenseDetailClick }: EnhancedDailyReportSectionProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleCardClick = (card: DailyReportData['cards'][number]) => {
|
const handleCardClick = (card: DailyReportData['cards'][number]) => {
|
||||||
|
// dr3 (미지급금 잔액): 클릭 동작 없음
|
||||||
|
if (card.id === 'dr3') return;
|
||||||
|
// dr4 (당월 예상 지출 합계): 상세 팝업 표시
|
||||||
|
if (card.id === 'dr4') {
|
||||||
|
onExpenseDetailClick?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// dr1, dr2: path로 페이지 이동
|
||||||
if (card.path) {
|
if (card.path) {
|
||||||
router.push(card.path);
|
router.push(card.path);
|
||||||
} else if (onClick) {
|
} else if (onClick) {
|
||||||
@@ -86,7 +95,7 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
className={`rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border min-h-[110px] flex flex-col ${style.bgClass} ${style.borderClass}`}
|
className={`rounded-xl p-4 transition-all border min-h-[110px] flex flex-col ${style.bgClass} ${style.borderClass} ${card.id === 'dr3' ? 'cursor-default' : 'cursor-pointer hover:shadow-lg'}`}
|
||||||
onClick={() => handleCardClick(card)}
|
onClick={() => handleCardClick(card)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
@@ -97,25 +106,21 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
|
|||||||
{card.label}
|
{card.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
<span className="text-2xl font-bold text-foreground">
|
{card.displayValue
|
||||||
{card.displayValue
|
? card.displayValue
|
||||||
? card.displayValue
|
: card.currency === 'USD'
|
||||||
: card.currency === 'USD'
|
? formatUSD(card.amount)
|
||||||
? formatUSD(card.amount)
|
: formatKoreanAmount(card.amount)}
|
||||||
: formatKoreanAmount(card.amount)}
|
|
||||||
</span>
|
|
||||||
{card.changeRate && (
|
|
||||||
<span
|
|
||||||
className={`flex items-center text-xs font-medium mb-1 ${card.changeDirection === 'up' ? 'text-red-500 dark:text-red-400' : 'text-blue-500 dark:text-blue-400'}`}
|
|
||||||
>
|
|
||||||
{card.changeDirection === 'up'
|
|
||||||
? <ArrowUpRight className="h-3 w-3" />
|
|
||||||
: <ArrowDownRight className="h-3 w-3" />}
|
|
||||||
{card.changeRate}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* 기획서 D1.7 기준: 자금현황 카드에 전일 대비 미표시 — 추후 필요 시 복원
|
||||||
|
{card.changeRate && (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
전일 대비 {card.changeRate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -191,6 +196,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleItemClick = (path: string) => {
|
const handleItemClick = (path: string) => {
|
||||||
|
if (!path) return;
|
||||||
router.push(path);
|
router.push(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,7 +231,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md min-h-[130px] flex flex-col ${isHighlighted ? 'bg-red-500 border-red-500 dark:bg-red-600 dark:border-red-600' : `${style.bgClass} ${style.borderClass}`}`}
|
className={`relative p-4 rounded-xl border transition-all min-h-[130px] flex flex-col ${item.path ? 'cursor-pointer hover:scale-[1.02] hover:shadow-md' : 'cursor-default'} ${isHighlighted ? 'bg-red-500 border-red-500 dark:bg-red-600 dark:border-red-600' : `${style.bgClass} ${style.borderClass}`}`}
|
||||||
onClick={() => handleItemClick(item.path)}
|
onClick={() => handleItemClick(item.path)}
|
||||||
>
|
>
|
||||||
{/* 아이콘 + 라벨 */}
|
{/* 아이콘 + 라벨 */}
|
||||||
@@ -290,19 +296,15 @@ const EXPENSE_CARD_CONFIGS: Array<{
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) {
|
export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) {
|
||||||
// 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환)
|
// 총 예상 지출: cards[3]이 API total_amount (advance 등 미표시 항목 포함)
|
||||||
const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0);
|
const totalAmount = Number(data.cards[3]?.amount) || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleDashboardCard
|
<CollapsibleDashboardCard
|
||||||
icon={<Receipt className="h-5 w-5 text-white" />}
|
icon={<Receipt className="h-5 w-5 text-white" />}
|
||||||
title="당월 예상 지출 내역"
|
title="당월 예상 지출 내역"
|
||||||
subtitle="이달 예상 지출 정보"
|
subtitle="이달 예상 지출 정보"
|
||||||
rightElement={
|
rightElement={undefined}
|
||||||
<Badge className="bg-orange-500 text-white border-none hover:opacity-90">
|
|
||||||
전월 대비 +15%
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* 카드 그리드 */}
|
{/* 카드 그리드 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
@@ -354,7 +356,7 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-white/20 text-white">
|
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-white/20 text-white">
|
||||||
<TrendingUp className="h-3 w-3" />
|
<TrendingUp className="h-3 w-3" />
|
||||||
전월 대비 +10.5%
|
{data.cards[3]?.previousLabel || '전월 대비 0.0%'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Wine, Utensils, Users, CreditCard } from 'lucide-react';
|
import { Moon, ShieldAlert, Banknote, FileWarning, Wine } from 'lucide-react';
|
||||||
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
||||||
import type { EntertainmentData } from '../types';
|
import type { EntertainmentData } from '../types';
|
||||||
|
|
||||||
// 카드별 아이콘 매핑
|
// 카드별 아이콘 매핑 (주말/심야, 기피업종, 고액결제, 증빙미비)
|
||||||
const CARD_ICONS = [Wine, Utensils, Users, CreditCard];
|
const CARD_ICONS = [Moon, ShieldAlert, Banknote, FileWarning];
|
||||||
const CARD_THEMES: SectionColorTheme[] = ['pink', 'purple', 'indigo', 'red'];
|
const CARD_THEMES: SectionColorTheme[] = ['purple', 'red', 'orange', 'pink'];
|
||||||
|
|
||||||
interface EntertainmentSectionProps {
|
interface EntertainmentSectionProps {
|
||||||
data: EntertainmentData;
|
data: EntertainmentData;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Banknote, Clock, AlertTriangle, CircleDollarSign, ChevronRight } from 'lucide-react';
|
import { Banknote, CircleDollarSign, Building2, TrendingUp, ChevronRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
||||||
import type { ReceivableData } from '../types';
|
import type { ReceivableData } from '../types';
|
||||||
|
|
||||||
// 카드별 아이콘 매핑 (미수금 합계, 30일 이내, 30~90일, 90일 초과)
|
// 카드별 아이콘 매핑 (누적미수금, 당월미수금, 거래처, Top3)
|
||||||
const CARD_ICONS = [CircleDollarSign, Banknote, Clock, AlertTriangle];
|
const CARD_ICONS = [CircleDollarSign, Banknote, Building2, TrendingUp];
|
||||||
const CARD_THEMES: SectionColorTheme[] = ['amber', 'green', 'orange', 'red'];
|
const CARD_THEMES: SectionColorTheme[] = ['amber', 'green', 'orange', 'red'];
|
||||||
|
|
||||||
interface ReceivableSectionProps {
|
interface ReceivableSectionProps {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
|
|||||||
'연차': 'annualLeave',
|
'연차': 'annualLeave',
|
||||||
'차량': 'vehicle',
|
'차량': 'vehicle',
|
||||||
'장비': 'equipment',
|
'장비': 'equipment',
|
||||||
'발주': 'purchase',
|
// '발주': 'purchase', // [2026-03-03] 비활성화 — transformer에서 필터링됨 (N4 참조)
|
||||||
'결재 요청': 'approvalRequest',
|
'결재 요청': 'approvalRequest',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleItemClick = (path: string) => {
|
const handleItemClick = (path: string) => {
|
||||||
|
if (!path) return;
|
||||||
router.push(path);
|
router.push(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Heart, Gift, Coffee, Smile } from 'lucide-react';
|
import { Receipt, Moon, UserX, BarChart3, Heart } from 'lucide-react';
|
||||||
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
||||||
import type { WelfareData } from '../types';
|
import type { WelfareData } from '../types';
|
||||||
|
|
||||||
// 카드별 아이콘 매핑
|
// 카드별 아이콘 매핑 (비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과)
|
||||||
const CARD_ICONS = [Heart, Gift, Coffee, Smile];
|
const CARD_ICONS = [Receipt, Moon, UserX, BarChart3];
|
||||||
const CARD_THEMES: SectionColorTheme[] = ['emerald', 'green', 'cyan', 'blue'];
|
const CARD_THEMES: SectionColorTheme[] = ['red', 'purple', 'orange', 'cyan'];
|
||||||
|
|
||||||
interface WelfareSectionProps {
|
interface WelfareSectionProps {
|
||||||
data: WelfareData;
|
data: WelfareData;
|
||||||
|
|||||||
@@ -326,19 +326,19 @@ export interface DailyAttendanceData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CEO Dashboard 전체 데이터
|
// CEO Dashboard 전체 데이터
|
||||||
|
// 모든 필드 optional: mock 제거 후 API 미구현 섹션은 undefined
|
||||||
export interface CEODashboardData {
|
export interface CEODashboardData {
|
||||||
todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈)
|
todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈)
|
||||||
todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태)
|
todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태)
|
||||||
dailyReport: DailyReportData;
|
dailyReport?: DailyReportData;
|
||||||
monthlyExpense: MonthlyExpenseData;
|
monthlyExpense?: MonthlyExpenseData;
|
||||||
cardManagement: CardManagementData;
|
cardManagement?: CardManagementData;
|
||||||
entertainment: EntertainmentData;
|
entertainment?: EntertainmentData;
|
||||||
welfare: WelfareData;
|
welfare?: WelfareData;
|
||||||
receivable: ReceivableData;
|
receivable?: ReceivableData;
|
||||||
debtCollection: DebtCollectionData;
|
debtCollection?: DebtCollectionData;
|
||||||
vat: VatData;
|
vat?: VatData;
|
||||||
calendarSchedules: CalendarScheduleItem[];
|
calendarSchedules?: CalendarScheduleItem[];
|
||||||
// 신규 섹션 (API 미구현 - mock 데이터)
|
|
||||||
salesStatus?: SalesStatusData;
|
salesStatus?: SalesStatusData;
|
||||||
purchaseStatus?: PurchaseStatusData;
|
purchaseStatus?: PurchaseStatusData;
|
||||||
dailyProduction?: DailyProductionData;
|
dailyProduction?: DailyProductionData;
|
||||||
@@ -693,6 +693,7 @@ export interface PeriodSelectConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
options: { value: string; label: string }[];
|
options: { value: string; label: string }[];
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
|
onPeriodChange?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상세 모달 전체 설정 타입
|
// 상세 모달 전체 설정 타입
|
||||||
|
|||||||
293
src/components/business/CEODashboard/useSectionSummary.ts
Normal file
293
src/components/business/CEODashboard/useSectionSummary.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import type { CEODashboardData, DashboardSettings, SectionKey } from './types';
|
||||||
|
import { SECTION_LABELS } from './types';
|
||||||
|
|
||||||
|
export type SummaryStatus = 'normal' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export interface SectionSummary {
|
||||||
|
key: SectionKey;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
status: SummaryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 숫자를 간략하게 포맷 (억/만) — 칩 표시용 (간결 + 반올림) */
|
||||||
|
function formatCompact(n: number): string {
|
||||||
|
if (n === 0) return '0원';
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
const sign = n < 0 ? '-' : '';
|
||||||
|
if (abs >= 100_000_000) {
|
||||||
|
const v = Math.round(abs / 100_000_000 * 10) / 10;
|
||||||
|
return `${sign}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}억`;
|
||||||
|
}
|
||||||
|
if (abs >= 10_000) {
|
||||||
|
const v = Math.round(abs / 10_000);
|
||||||
|
return `${sign}${v.toLocaleString()}만`;
|
||||||
|
}
|
||||||
|
if (abs > 0) return `${sign}${abs.toLocaleString()}원`;
|
||||||
|
return '0원';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카드 배열에서 합계 카드(마지막) 금액 추출 — "합계" 라벨이 있으면 그것, 없으면 첫 번째 */
|
||||||
|
function getTotalCardAmount(cards?: { label: string; amount: number }[]): number {
|
||||||
|
if (!cards?.length) return 0;
|
||||||
|
const totalCard = cards.find((c) => c.label.includes('합계'));
|
||||||
|
return totalCard ? totalCard.amount : cards[0].amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 체크포인트 배열에서 가장 심각한 상태 추출 */
|
||||||
|
function checkPointStatus(
|
||||||
|
checkPoints?: { type: string }[],
|
||||||
|
): SummaryStatus {
|
||||||
|
if (!checkPoints?.length) return 'normal';
|
||||||
|
if (checkPoints.some((c) => c.type === 'error')) return 'danger';
|
||||||
|
if (checkPoints.some((c) => c.type === 'warning')) return 'warning';
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 섹션 활성화 여부 확인 */
|
||||||
|
function isSectionEnabled(key: SectionKey, settings: DashboardSettings): boolean {
|
||||||
|
switch (key) {
|
||||||
|
case 'todayIssueList': return !!settings.todayIssueList;
|
||||||
|
case 'dailyReport': return !!settings.dailyReport;
|
||||||
|
case 'statusBoard': return !!(settings.statusBoard?.enabled ?? settings.todayIssue.enabled);
|
||||||
|
case 'monthlyExpense': return !!settings.monthlyExpense;
|
||||||
|
case 'cardManagement': return !!settings.cardManagement;
|
||||||
|
case 'entertainment': return !!settings.entertainment.enabled;
|
||||||
|
case 'welfare': return !!settings.welfare.enabled;
|
||||||
|
case 'receivable': return !!settings.receivable;
|
||||||
|
case 'debtCollection': return !!settings.debtCollection;
|
||||||
|
case 'vat': return !!settings.vat;
|
||||||
|
case 'calendar': return !!settings.calendar;
|
||||||
|
case 'salesStatus': return !!(settings.salesStatus ?? true);
|
||||||
|
case 'purchaseStatus': return !!(settings.purchaseStatus ?? true);
|
||||||
|
case 'production': return !!(settings.production ?? true);
|
||||||
|
case 'shipment': return !!(settings.shipment ?? true);
|
||||||
|
case 'unshipped': return !!(settings.unshipped ?? true);
|
||||||
|
case 'construction': return !!(settings.construction ?? true);
|
||||||
|
case 'attendance': return !!(settings.attendance ?? true);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 섹션별 요약값 + 상태 추출 */
|
||||||
|
function extractSummary(
|
||||||
|
key: SectionKey,
|
||||||
|
data: CEODashboardData,
|
||||||
|
): { value: string; status: SummaryStatus } {
|
||||||
|
switch (key) {
|
||||||
|
case 'todayIssueList': {
|
||||||
|
const count = data.todayIssueList?.length ?? 0;
|
||||||
|
return { value: `${count}건`, status: count > 0 ? 'warning' : 'normal' };
|
||||||
|
}
|
||||||
|
case 'dailyReport': {
|
||||||
|
const firstCard = data.dailyReport?.cards?.[0];
|
||||||
|
return {
|
||||||
|
value: firstCard ? formatCompact(firstCard.amount) : '-',
|
||||||
|
status: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'statusBoard': {
|
||||||
|
const count = data.todayIssue?.length ?? 0;
|
||||||
|
const hasHighlight = data.todayIssue?.some((i) => i.isHighlighted);
|
||||||
|
return {
|
||||||
|
value: `${count}항목`,
|
||||||
|
status: hasHighlight ? 'danger' : 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'monthlyExpense': {
|
||||||
|
const total = getTotalCardAmount(data.monthlyExpense?.cards);
|
||||||
|
return {
|
||||||
|
value: formatCompact(total),
|
||||||
|
status: checkPointStatus(data.monthlyExpense?.checkPoints),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'cardManagement': {
|
||||||
|
const total = getTotalCardAmount(data.cardManagement?.cards);
|
||||||
|
const hasHighlight = data.cardManagement?.cards?.some((c) => c.isHighlighted);
|
||||||
|
const hasWarning = !!data.cardManagement?.warningBanner;
|
||||||
|
return {
|
||||||
|
value: formatCompact(total),
|
||||||
|
status: hasHighlight ? 'danger' : hasWarning ? 'warning' : 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'entertainment': {
|
||||||
|
const total = data.entertainment?.cards?.reduce((s, c) => s + c.amount, 0) ?? 0;
|
||||||
|
return {
|
||||||
|
value: formatCompact(total),
|
||||||
|
status: checkPointStatus(data.entertainment?.checkPoints),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'welfare': {
|
||||||
|
const total = data.welfare?.cards?.reduce((s, c) => s + c.amount, 0) ?? 0;
|
||||||
|
return {
|
||||||
|
value: formatCompact(total),
|
||||||
|
status: checkPointStatus(data.welfare?.checkPoints),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'receivable': {
|
||||||
|
// 누적 미수금 = 첫 번째 카드
|
||||||
|
const first = data.receivable?.cards?.[0];
|
||||||
|
return {
|
||||||
|
value: first ? formatCompact(first.amount) : '-',
|
||||||
|
status: checkPointStatus(data.receivable?.checkPoints),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'debtCollection': {
|
||||||
|
const first = data.debtCollection?.cards?.[0];
|
||||||
|
return {
|
||||||
|
value: first ? formatCompact(first.amount) : '-',
|
||||||
|
status: checkPointStatus(data.debtCollection?.checkPoints),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'vat': {
|
||||||
|
const first = data.vat?.cards?.[0];
|
||||||
|
return {
|
||||||
|
value: first ? formatCompact(first.amount) : '-',
|
||||||
|
status: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'calendar': {
|
||||||
|
const count = data.calendarSchedules?.length ?? 0;
|
||||||
|
return { value: `${count}일정`, status: 'normal' };
|
||||||
|
}
|
||||||
|
case 'salesStatus': {
|
||||||
|
return {
|
||||||
|
value: formatCompact(data.salesStatus?.cumulativeSales ?? 0),
|
||||||
|
status: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'purchaseStatus': {
|
||||||
|
return {
|
||||||
|
value: formatCompact(data.purchaseStatus?.cumulativePurchase ?? 0),
|
||||||
|
status: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'production': {
|
||||||
|
const count = data.dailyProduction?.processes?.length ?? 0;
|
||||||
|
return { value: `${count}공정`, status: 'normal' };
|
||||||
|
}
|
||||||
|
case 'shipment': {
|
||||||
|
const count = data.dailyProduction?.shipment?.actualCount ?? 0;
|
||||||
|
return { value: `${count}건`, status: 'normal' };
|
||||||
|
}
|
||||||
|
case 'unshipped': {
|
||||||
|
const count = data.unshipped?.items?.length ?? 0;
|
||||||
|
return {
|
||||||
|
value: `${count}건`,
|
||||||
|
status: count > 0 ? 'danger' : 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'construction': {
|
||||||
|
return {
|
||||||
|
value: `${data.constructionData?.thisMonth ?? 0}건`,
|
||||||
|
status: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'attendance': {
|
||||||
|
return {
|
||||||
|
value: `${data.dailyAttendance?.present ?? 0}명`,
|
||||||
|
status: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { value: '-', status: 'normal' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSectionSummaryParams {
|
||||||
|
data: CEODashboardData;
|
||||||
|
sectionOrder: SectionKey[];
|
||||||
|
dashboardSettings: DashboardSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSectionSummaryReturn {
|
||||||
|
summaries: SectionSummary[];
|
||||||
|
activeSectionKey: SectionKey | null;
|
||||||
|
sectionRefs: React.MutableRefObject<Map<SectionKey, HTMLElement>>;
|
||||||
|
scrollToSection: (key: SectionKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSectionSummary({
|
||||||
|
data,
|
||||||
|
sectionOrder,
|
||||||
|
dashboardSettings,
|
||||||
|
}: UseSectionSummaryParams): UseSectionSummaryReturn {
|
||||||
|
const sectionRefs = useRef<Map<SectionKey, HTMLElement>>(new Map());
|
||||||
|
const [activeSectionKey, setActiveSectionKey] = useState<SectionKey | null>(null);
|
||||||
|
// 칩 클릭으로 선택된 키 — 해당 섹션이 화면에 보이는 한 유지
|
||||||
|
const pinnedKey = useRef<SectionKey | null>(null);
|
||||||
|
|
||||||
|
// 활성화된 섹션만 필터
|
||||||
|
const enabledSections = useMemo(
|
||||||
|
() => sectionOrder.filter((key) => isSectionEnabled(key, dashboardSettings)),
|
||||||
|
[sectionOrder, dashboardSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 요약 데이터 계산
|
||||||
|
const summaries = useMemo<SectionSummary[]>(
|
||||||
|
() =>
|
||||||
|
enabledSections.map((key) => {
|
||||||
|
const { value, status } = extractSummary(key, data);
|
||||||
|
return { key, label: SECTION_LABELS[key], value, status };
|
||||||
|
}),
|
||||||
|
[enabledSections, data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스크롤 기반 현재 섹션 감지
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
// pin이 걸려 있으면 스크롤 감지 무시 (칩 클릭 후 programmatic scroll 중)
|
||||||
|
if (pinnedKey.current) return;
|
||||||
|
|
||||||
|
const headerBottom = 156; // 헤더(~100px) + 요약바(~56px)
|
||||||
|
let bestKey: SectionKey | null = null;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
|
||||||
|
for (const [key, el] of sectionRefs.current.entries()) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const distance = Math.abs(rect.top - headerBottom);
|
||||||
|
if (rect.top < window.innerHeight * 0.6 && rect.bottom > headerBottom) {
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
bestKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestKey) {
|
||||||
|
setActiveSectionKey(bestKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자가 직접 스크롤(마우스 휠/터치)하면 pin 해제
|
||||||
|
const handleUserScroll = () => { pinnedKey.current = null; };
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('wheel', handleUserScroll, { passive: true });
|
||||||
|
window.addEventListener('touchstart', handleUserScroll, { passive: true });
|
||||||
|
handleScroll(); // 초기 호출
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('wheel', handleUserScroll);
|
||||||
|
window.removeEventListener('touchstart', handleUserScroll);
|
||||||
|
};
|
||||||
|
}, [enabledSections, summaries]);
|
||||||
|
|
||||||
|
// 칩 클릭 → 즉시 활성 표시 + 섹션으로 스크롤
|
||||||
|
const scrollToSection = useCallback((key: SectionKey) => {
|
||||||
|
setActiveSectionKey(key);
|
||||||
|
pinnedKey.current = key; // 해당 섹션이 화면에 보이는 한 유지
|
||||||
|
|
||||||
|
const el = sectionRefs.current.get(key);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const offset = window.scrollY + elRect.top - 160; // 헤더(~100) + 요약바(~56) + 여유
|
||||||
|
|
||||||
|
window.scrollTo({ top: offset, behavior: 'smooth' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { summaries, activeSectionKey, sectionRefs, scrollToSection };
|
||||||
|
}
|
||||||
@@ -9,11 +9,6 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
|
|||||||
|
|
||||||
import { useDashboardFetch } from './useDashboardFetch';
|
import { useDashboardFetch } from './useDashboardFetch';
|
||||||
|
|
||||||
import {
|
|
||||||
fetchLoanDashboard,
|
|
||||||
fetchTaxSimulation,
|
|
||||||
} from '@/lib/api/dashboard/endpoints';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DailyReportApiResponse,
|
DailyReportApiResponse,
|
||||||
ReceivablesApiResponse,
|
ReceivablesApiResponse,
|
||||||
@@ -24,10 +19,20 @@ import type {
|
|||||||
TodayIssueApiResponse,
|
TodayIssueApiResponse,
|
||||||
CalendarApiResponse,
|
CalendarApiResponse,
|
||||||
VatApiResponse,
|
VatApiResponse,
|
||||||
|
VatDetailApiResponse,
|
||||||
EntertainmentApiResponse,
|
EntertainmentApiResponse,
|
||||||
|
EntertainmentDetailApiResponse,
|
||||||
WelfareApiResponse,
|
WelfareApiResponse,
|
||||||
WelfareDetailApiResponse,
|
WelfareDetailApiResponse,
|
||||||
ExpectedExpenseDashboardDetailApiResponse,
|
ExpectedExpenseDashboardDetailApiResponse,
|
||||||
|
SalesStatusApiResponse,
|
||||||
|
PurchaseStatusApiResponse,
|
||||||
|
DailyProductionApiResponse,
|
||||||
|
UnshippedApiResponse,
|
||||||
|
ConstructionApiResponse,
|
||||||
|
DailyAttendanceApiResponse,
|
||||||
|
LoanDashboardApiResponse,
|
||||||
|
TaxSimulationApiResponse,
|
||||||
} from '@/lib/api/dashboard/types';
|
} from '@/lib/api/dashboard/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -40,10 +45,18 @@ import {
|
|||||||
transformTodayIssueResponse,
|
transformTodayIssueResponse,
|
||||||
transformCalendarResponse,
|
transformCalendarResponse,
|
||||||
transformVatResponse,
|
transformVatResponse,
|
||||||
|
transformVatDetailResponse,
|
||||||
transformEntertainmentResponse,
|
transformEntertainmentResponse,
|
||||||
|
transformEntertainmentDetailResponse,
|
||||||
transformWelfareResponse,
|
transformWelfareResponse,
|
||||||
transformWelfareDetailResponse,
|
transformWelfareDetailResponse,
|
||||||
transformExpectedExpenseDetailResponse,
|
transformExpectedExpenseDetailResponse,
|
||||||
|
transformSalesStatusResponse,
|
||||||
|
transformPurchaseStatusResponse,
|
||||||
|
transformDailyProductionResponse,
|
||||||
|
transformUnshippedResponse,
|
||||||
|
transformConstructionResponse,
|
||||||
|
transformDailyAttendanceResponse,
|
||||||
} from '@/lib/api/dashboard/transformers';
|
} from '@/lib/api/dashboard/transformers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -59,6 +72,12 @@ import type {
|
|||||||
EntertainmentData,
|
EntertainmentData,
|
||||||
WelfareData,
|
WelfareData,
|
||||||
DetailModalConfig,
|
DetailModalConfig,
|
||||||
|
SalesStatusData,
|
||||||
|
PurchaseStatusData,
|
||||||
|
DailyProductionData,
|
||||||
|
UnshippedData,
|
||||||
|
ConstructionData,
|
||||||
|
DailyAttendanceData,
|
||||||
} from '@/components/business/CEODashboard/types';
|
} from '@/components/business/CEODashboard/types';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -83,7 +102,19 @@ function buildEndpoint(
|
|||||||
// CardManagement 전용 fetch 유틸리티
|
// CardManagement 전용 fetch 유틸리티
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
async function fetchProxyJson<T>(path: string): Promise<{ success: boolean; data: T | null }> {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/proxy${path}`);
|
||||||
|
if (!r.ok) return { success: false, data: null };
|
||||||
|
const json = await r.json();
|
||||||
|
return { success: !!json.success, data: json.data ?? null };
|
||||||
|
} catch {
|
||||||
|
return { success: false, data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCardManagementData(fallbackData?: CardManagementData) {
|
async function fetchCardManagementData(fallbackData?: CardManagementData) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
const [cardApiData, loanResponse, taxResponse] = await Promise.all([
|
const [cardApiData, loanResponse, taxResponse] = await Promise.all([
|
||||||
fetch('/api/proxy/card-transactions/summary').then(async (r) => {
|
fetch('/api/proxy/card-transactions/summary').then(async (r) => {
|
||||||
if (!r.ok) throw new Error(`API 오류: ${r.status}`);
|
if (!r.ok) throw new Error(`API 오류: ${r.status}`);
|
||||||
@@ -91,8 +122,8 @@ async function fetchCardManagementData(fallbackData?: CardManagementData) {
|
|||||||
if (!json.success) throw new Error(json.message || '데이터 조회 실패');
|
if (!json.success) throw new Error(json.message || '데이터 조회 실패');
|
||||||
return json.data as CardTransactionApiResponse;
|
return json.data as CardTransactionApiResponse;
|
||||||
}),
|
}),
|
||||||
fetchLoanDashboard(),
|
fetchProxyJson<LoanDashboardApiResponse>('/loans/dashboard'),
|
||||||
fetchTaxSimulation(),
|
fetchProxyJson<TaxSimulationApiResponse>(`/loans/tax-simulation?year=${currentYear}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const loanData = loanResponse.success ? loanResponse.data : null;
|
const loanData = loanResponse.success ? loanResponse.data : null;
|
||||||
@@ -101,6 +132,20 @@ async function fetchCardManagementData(fallbackData?: CardManagementData) {
|
|||||||
return transformCardManagementResponse(cardApiData, loanData, taxData, fallbackData);
|
return transformCardManagementResponse(cardApiData, loanData, taxData, fallbackData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 당월 날짜 범위 유틸리티
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function getCurrentMonthEndpoint(base: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = now.getMonth() + 1;
|
||||||
|
const startDate = `${y}-${String(m).padStart(2, '0')}-01`;
|
||||||
|
const lastDay = new Date(y, m, 0).getDate();
|
||||||
|
const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||||
|
return buildEndpoint(base, { start_date: startDate, end_date: endDate });
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 1~4. 단순 섹션 Hooks (파라미터 없음)
|
// 1~4. 단순 섹션 Hooks (파라미터 없음)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -128,7 +173,7 @@ export function useDebtCollection() {
|
|||||||
|
|
||||||
export function useMonthlyExpense() {
|
export function useMonthlyExpense() {
|
||||||
return useDashboardFetch<ExpectedExpenseApiResponse, MonthlyExpenseData>(
|
return useDashboardFetch<ExpectedExpenseApiResponse, MonthlyExpenseData>(
|
||||||
'expected-expenses/summary',
|
getCurrentMonthEndpoint('expected-expenses/summary'),
|
||||||
transformMonthlyExpenseResponse,
|
transformMonthlyExpenseResponse,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,6 +317,27 @@ export function useVat(options: UseVatOptions = {}) {
|
|||||||
return useDashboardFetch<VatApiResponse, VatData>(endpoint, transformVatResponse);
|
return useDashboardFetch<VatApiResponse, VatData>(endpoint, transformVatResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 9-1. VatDetail Hook (부가세 상세 - 모달용)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function useVatDetail() {
|
||||||
|
const endpoint = useMemo(() => 'vat/detail', []);
|
||||||
|
|
||||||
|
const result = useDashboardFetch<VatDetailApiResponse, DetailModalConfig>(
|
||||||
|
endpoint,
|
||||||
|
transformVatDetailResponse,
|
||||||
|
{ lazy: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modalConfig: result.data,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 10. Entertainment Hook (접대비)
|
// 10. Entertainment Hook (접대비)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -345,6 +411,43 @@ export function useWelfare(options: UseWelfareOptions = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 11-1. EntertainmentDetail Hook (접대비 상세 - 모달용)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface UseEntertainmentDetailOptions {
|
||||||
|
companyType?: 'large' | 'medium' | 'small';
|
||||||
|
year?: number;
|
||||||
|
quarter?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEntertainmentDetail(options: UseEntertainmentDetailOptions = {}) {
|
||||||
|
const { companyType = 'medium', year, quarter } = options;
|
||||||
|
|
||||||
|
const endpoint = useMemo(
|
||||||
|
() =>
|
||||||
|
buildEndpoint('entertainment/detail', {
|
||||||
|
company_type: companyType,
|
||||||
|
year,
|
||||||
|
quarter,
|
||||||
|
}),
|
||||||
|
[companyType, year, quarter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = useDashboardFetch<EntertainmentDetailApiResponse, DetailModalConfig>(
|
||||||
|
endpoint,
|
||||||
|
transformEntertainmentDetailResponse,
|
||||||
|
{ lazy: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modalConfig: result.data,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 12. WelfareDetail Hook (복리후생비 상세 - 모달용)
|
// 12. WelfareDetail Hook (복리후생비 상세 - 모달용)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -393,7 +496,73 @@ export function useWelfareDetail(options: UseWelfareDetailOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 13. MonthlyExpenseDetail Hook (당월 예상 지출 상세 - 통합 모달용)
|
// 13. SalesStatus Hook (매출 현황)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function useSalesStatus() {
|
||||||
|
return useDashboardFetch<SalesStatusApiResponse, SalesStatusData>(
|
||||||
|
'dashboard/sales/summary',
|
||||||
|
transformSalesStatusResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 14. PurchaseStatus Hook (매입 현황)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function usePurchaseStatus() {
|
||||||
|
return useDashboardFetch<PurchaseStatusApiResponse, PurchaseStatusData>(
|
||||||
|
'dashboard/purchases/summary',
|
||||||
|
transformPurchaseStatusResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 15. DailyProduction Hook (생산 현황)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function useDailyProduction() {
|
||||||
|
return useDashboardFetch<DailyProductionApiResponse, DailyProductionData>(
|
||||||
|
'dashboard/production/summary',
|
||||||
|
transformDailyProductionResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 16. Unshipped Hook (미출고 내역)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function useUnshipped() {
|
||||||
|
return useDashboardFetch<UnshippedApiResponse, UnshippedData>(
|
||||||
|
'dashboard/unshipped/summary',
|
||||||
|
transformUnshippedResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 17. Construction Hook (시공 현황)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function useConstruction() {
|
||||||
|
return useDashboardFetch<ConstructionApiResponse, ConstructionData>(
|
||||||
|
'dashboard/construction/summary',
|
||||||
|
transformConstructionResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 18. DailyAttendance Hook (근태 현황)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function useDailyAttendance() {
|
||||||
|
return useDashboardFetch<DailyAttendanceApiResponse, DailyAttendanceData>(
|
||||||
|
'dashboard/attendance/summary',
|
||||||
|
transformDailyAttendanceResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 19. MonthlyExpenseDetail Hook (당월 예상 지출 상세 - 통합 모달용)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export type MonthlyExpenseCardId = 'me1' | 'me2' | 'me3' | 'me4';
|
export type MonthlyExpenseCardId = 'me1' | 'me2' | 'me3' | 'me4';
|
||||||
@@ -403,32 +572,43 @@ export function useMonthlyExpenseDetail() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchData = useCallback(async (cardId: MonthlyExpenseCardId) => {
|
// cardId → transaction_type 매핑
|
||||||
|
const CARD_TRANSACTION_TYPE: Record<MonthlyExpenseCardId, string | undefined> = {
|
||||||
|
me1: 'purchase',
|
||||||
|
me2: 'card',
|
||||||
|
me3: 'bill',
|
||||||
|
me4: undefined, // 전체
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (cardId: MonthlyExpenseCardId, filterParams?: { startDate?: string; endDate?: string; search?: string }) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const transactionTypeMap: Record<MonthlyExpenseCardId, string | null> = {
|
// 대시보드 전용 API 엔드포인트 구성
|
||||||
me1: 'purchase',
|
const transactionType = CARD_TRANSACTION_TYPE[cardId];
|
||||||
me2: 'card',
|
const params: Record<string, string | undefined> = {
|
||||||
me3: 'bill',
|
transaction_type: transactionType,
|
||||||
me4: null,
|
start_date: filterParams?.startDate,
|
||||||
|
end_date: filterParams?.endDate,
|
||||||
|
search: filterParams?.search,
|
||||||
};
|
};
|
||||||
const transactionType = transactionTypeMap[cardId];
|
const endpoint = buildEndpoint('/api/proxy/expected-expenses/dashboard-detail', params);
|
||||||
|
|
||||||
const endpoint = transactionType
|
|
||||||
? `/api/proxy/expected-expenses/dashboard-detail?transaction_type=${transactionType}`
|
|
||||||
: '/api/proxy/expected-expenses/dashboard-detail';
|
|
||||||
|
|
||||||
const response = await fetch(endpoint);
|
const response = await fetch(endpoint);
|
||||||
if (!response.ok) throw new Error(`API 오류: ${response.status}`);
|
if (!response.ok) {
|
||||||
|
throw new Error(`API 오류: ${response.status}`);
|
||||||
|
}
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!result.success) throw new Error(result.message || '데이터 조회 실패');
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '데이터 조회 실패');
|
||||||
|
}
|
||||||
|
|
||||||
const transformed = transformExpectedExpenseDetailResponse(
|
const transformed = transformExpectedExpenseDetailResponse(
|
||||||
result.data as ExpectedExpenseDashboardDetailApiResponse,
|
result.data as ExpectedExpenseDashboardDetailApiResponse,
|
||||||
cardId,
|
cardId,
|
||||||
);
|
);
|
||||||
|
|
||||||
setModalConfig(transformed);
|
setModalConfig(transformed);
|
||||||
return transformed;
|
return transformed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -456,15 +636,33 @@ export interface UseCEODashboardOptions {
|
|||||||
cardManagement?: boolean;
|
cardManagement?: boolean;
|
||||||
cardManagementFallback?: CardManagementData;
|
cardManagementFallback?: CardManagementData;
|
||||||
statusBoard?: boolean;
|
statusBoard?: boolean;
|
||||||
|
salesStatus?: boolean;
|
||||||
|
purchaseStatus?: boolean;
|
||||||
|
dailyProduction?: boolean;
|
||||||
|
unshipped?: boolean;
|
||||||
|
construction?: boolean;
|
||||||
|
dailyAttendance?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CEODashboardState {
|
export interface CEODashboardState {
|
||||||
dailyReport: { data: DailyReportData | null; loading: boolean; error: string | null };
|
dailyReport: SectionState<DailyReportData>;
|
||||||
receivable: { data: ReceivableData | null; loading: boolean; error: string | null };
|
receivable: SectionState<ReceivableData>;
|
||||||
debtCollection: { data: DebtCollectionData | null; loading: boolean; error: string | null };
|
debtCollection: SectionState<DebtCollectionData>;
|
||||||
monthlyExpense: { data: MonthlyExpenseData | null; loading: boolean; error: string | null };
|
monthlyExpense: SectionState<MonthlyExpenseData>;
|
||||||
cardManagement: { data: CardManagementData | null; loading: boolean; error: string | null };
|
cardManagement: SectionState<CardManagementData>;
|
||||||
statusBoard: { data: TodayIssueItem[] | null; loading: boolean; error: string | null };
|
statusBoard: SectionState<TodayIssueItem[]>;
|
||||||
|
salesStatus: SectionState<SalesStatusData>;
|
||||||
|
purchaseStatus: SectionState<PurchaseStatusData>;
|
||||||
|
dailyProduction: SectionState<DailyProductionData>;
|
||||||
|
unshipped: SectionState<UnshippedData>;
|
||||||
|
construction: SectionState<ConstructionData>;
|
||||||
|
dailyAttendance: SectionState<DailyAttendanceData>;
|
||||||
refetchAll: () => void;
|
refetchAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +675,12 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
|
|||||||
cardManagement: enableCardManagement = true,
|
cardManagement: enableCardManagement = true,
|
||||||
cardManagementFallback,
|
cardManagementFallback,
|
||||||
statusBoard: enableStatusBoard = true,
|
statusBoard: enableStatusBoard = true,
|
||||||
|
salesStatus: enableSalesStatus = true,
|
||||||
|
purchaseStatus: enablePurchaseStatus = true,
|
||||||
|
dailyProduction: enableDailyProduction = true,
|
||||||
|
unshipped: enableUnshipped = true,
|
||||||
|
construction: enableConstruction = true,
|
||||||
|
dailyAttendance: enableDailyAttendance = true,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// 비활성 섹션은 endpoint를 null로 → useDashboardFetch가 skip
|
// 비활성 섹션은 endpoint를 null로 → useDashboardFetch가 skip
|
||||||
@@ -495,16 +699,48 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
|
|||||||
transformDebtCollectionResponse,
|
transformDebtCollectionResponse,
|
||||||
{ initialLoading: enableDebtCollection },
|
{ initialLoading: enableDebtCollection },
|
||||||
);
|
);
|
||||||
const me = useDashboardFetch<ExpectedExpenseApiResponse, MonthlyExpenseData>(
|
|
||||||
enableMonthlyExpense ? 'expected-expenses/summary' : null,
|
|
||||||
transformMonthlyExpenseResponse,
|
|
||||||
{ initialLoading: enableMonthlyExpense },
|
|
||||||
);
|
|
||||||
const sb = useDashboardFetch<StatusBoardApiResponse, TodayIssueItem[]>(
|
const sb = useDashboardFetch<StatusBoardApiResponse, TodayIssueItem[]>(
|
||||||
enableStatusBoard ? 'status-board/summary' : null,
|
enableStatusBoard ? 'status-board/summary' : null,
|
||||||
transformStatusBoardResponse,
|
transformStatusBoardResponse,
|
||||||
{ initialLoading: enableStatusBoard },
|
{ initialLoading: enableStatusBoard },
|
||||||
);
|
);
|
||||||
|
const ss = useDashboardFetch<SalesStatusApiResponse, SalesStatusData>(
|
||||||
|
enableSalesStatus ? 'dashboard/sales/summary' : null,
|
||||||
|
transformSalesStatusResponse,
|
||||||
|
{ initialLoading: enableSalesStatus },
|
||||||
|
);
|
||||||
|
const ps = useDashboardFetch<PurchaseStatusApiResponse, PurchaseStatusData>(
|
||||||
|
enablePurchaseStatus ? 'dashboard/purchases/summary' : null,
|
||||||
|
transformPurchaseStatusResponse,
|
||||||
|
{ initialLoading: enablePurchaseStatus },
|
||||||
|
);
|
||||||
|
const dp = useDashboardFetch<DailyProductionApiResponse, DailyProductionData>(
|
||||||
|
enableDailyProduction ? 'dashboard/production/summary' : null,
|
||||||
|
transformDailyProductionResponse,
|
||||||
|
{ initialLoading: enableDailyProduction },
|
||||||
|
);
|
||||||
|
const us = useDashboardFetch<UnshippedApiResponse, UnshippedData>(
|
||||||
|
enableUnshipped ? 'dashboard/unshipped/summary' : null,
|
||||||
|
transformUnshippedResponse,
|
||||||
|
{ initialLoading: enableUnshipped },
|
||||||
|
);
|
||||||
|
const cs = useDashboardFetch<ConstructionApiResponse, ConstructionData>(
|
||||||
|
enableConstruction ? 'dashboard/construction/summary' : null,
|
||||||
|
transformConstructionResponse,
|
||||||
|
{ initialLoading: enableConstruction },
|
||||||
|
);
|
||||||
|
const da = useDashboardFetch<DailyAttendanceApiResponse, DailyAttendanceData>(
|
||||||
|
enableDailyAttendance ? 'dashboard/attendance/summary' : null,
|
||||||
|
transformDailyAttendanceResponse,
|
||||||
|
{ initialLoading: enableDailyAttendance },
|
||||||
|
);
|
||||||
|
|
||||||
|
// MonthlyExpense: 대시보드 전용 API (당월 필터)
|
||||||
|
const me = useDashboardFetch<ExpectedExpenseApiResponse, MonthlyExpenseData>(
|
||||||
|
enableMonthlyExpense ? getCurrentMonthEndpoint('expected-expenses/summary') : null,
|
||||||
|
transformMonthlyExpenseResponse,
|
||||||
|
{ initialLoading: enableMonthlyExpense },
|
||||||
|
);
|
||||||
|
|
||||||
// CardManagement: 커스텀 (3개 API 병렬)
|
// CardManagement: 커스텀 (3개 API 병렬)
|
||||||
const [cmData, setCmData] = useState<CardManagementData | null>(null);
|
const [cmData, setCmData] = useState<CardManagementData | null>(null);
|
||||||
@@ -537,8 +773,14 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
|
|||||||
me.refetch();
|
me.refetch();
|
||||||
fetchCM();
|
fetchCM();
|
||||||
sb.refetch();
|
sb.refetch();
|
||||||
|
ss.refetch();
|
||||||
|
ps.refetch();
|
||||||
|
dp.refetch();
|
||||||
|
us.refetch();
|
||||||
|
cs.refetch();
|
||||||
|
da.refetch();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch]);
|
}, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dailyReport: { data: dr.data, loading: dr.loading, error: dr.error },
|
dailyReport: { data: dr.data, loading: dr.loading, error: dr.error },
|
||||||
@@ -547,6 +789,12 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
|
|||||||
monthlyExpense: { data: me.data, loading: me.loading, error: me.error },
|
monthlyExpense: { data: me.data, loading: me.loading, error: me.error },
|
||||||
cardManagement: { data: cmData, loading: cmLoading, error: cmError },
|
cardManagement: { data: cmData, loading: cmLoading, error: cmError },
|
||||||
statusBoard: { data: sb.data, loading: sb.loading, error: sb.error },
|
statusBoard: { data: sb.data, loading: sb.loading, error: sb.error },
|
||||||
|
salesStatus: { data: ss.data, loading: ss.loading, error: ss.error },
|
||||||
|
purchaseStatus: { data: ps.data, loading: ps.loading, error: ps.error },
|
||||||
|
dailyProduction: { data: dp.data, loading: dp.loading, error: dp.error },
|
||||||
|
unshipped: { data: us.data, loading: us.loading, error: us.error },
|
||||||
|
construction: { data: cs.data, loading: cs.loading, error: cs.error },
|
||||||
|
dailyAttendance: { data: da.data, loading: da.loading, error: da.error },
|
||||||
refetchAll,
|
refetchAll,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export interface UseCardManagementModalsReturn {
|
|||||||
/** 에러 메시지 */
|
/** 에러 메시지 */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** 특정 카드의 모달 데이터 조회 - 데이터 직접 반환 */
|
/** 특정 카드의 모달 데이터 조회 - 데이터 직접 반환 */
|
||||||
fetchModalData: (cardId: CardManagementCardId) => Promise<CardManagementModalData>;
|
fetchModalData: (cardId: CardManagementCardId, params?: { start_date?: string; end_date?: string }) => Promise<CardManagementModalData>;
|
||||||
/** 모든 카드 데이터 조회 */
|
/** 모든 카드 데이터 조회 */
|
||||||
fetchAllData: () => Promise<void>;
|
fetchAllData: () => Promise<void>;
|
||||||
/** 데이터 초기화 */
|
/** 데이터 초기화 */
|
||||||
@@ -105,11 +105,15 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* cm2: 가지급금 상세 데이터 조회
|
* cm2: 가지급금 상세 데이터 조회
|
||||||
|
* @param params - 날짜 필터 (선택)
|
||||||
* @returns 조회된 데이터 (실패 시 null)
|
* @returns 조회된 데이터 (실패 시 null)
|
||||||
*/
|
*/
|
||||||
const fetchCm2Data = useCallback(async (): Promise<LoanDashboardApiResponse | null> => {
|
const fetchCm2Data = useCallback(async (params?: {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}): Promise<LoanDashboardApiResponse | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetchLoanDashboard();
|
const response = await fetchLoanDashboard(params);
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setCm2Data(response.data);
|
setCm2Data(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -148,10 +152,14 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 카드의 모달 데이터 조회
|
* 특정 카드의 모달 데이터 조회
|
||||||
|
* @param params - cm2용 날짜 필터 (선택)
|
||||||
* @returns 조회된 모달 데이터 객체 (카드 ID에 해당하는 데이터만 포함)
|
* @returns 조회된 모달 데이터 객체 (카드 ID에 해당하는 데이터만 포함)
|
||||||
*/
|
*/
|
||||||
const fetchModalData = useCallback(
|
const fetchModalData = useCallback(
|
||||||
async (cardId: CardManagementCardId): Promise<CardManagementModalData> => {
|
async (cardId: CardManagementCardId, params?: {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}): Promise<CardManagementModalData> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -163,7 +171,7 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
|
|||||||
result.cm1Data = await fetchCm1Data();
|
result.cm1Data = await fetchCm1Data();
|
||||||
break;
|
break;
|
||||||
case 'cm2':
|
case 'cm2':
|
||||||
result.cm2Data = await fetchCm2Data();
|
result.cm2Data = await fetchCm2Data(params);
|
||||||
break;
|
break;
|
||||||
case 'cm3':
|
case 'cm3':
|
||||||
case 'cm4': {
|
case 'cm4': {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function useDashboardFetch<TApi, TResult>(
|
|||||||
const [loading, setLoading] = useState(options?.initialLoading ?? !lazy);
|
const [loading, setLoading] = useState(options?.initialLoading ?? !lazy);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async (): Promise<TResult | null> => {
|
||||||
if (!endpoint) return;
|
if (!endpoint) return null;
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -55,10 +55,12 @@ export function useDashboardFetch<TApi, TResult>(
|
|||||||
|
|
||||||
const transformed = transformer(result.data);
|
const transformed = transformer(result.data);
|
||||||
setData(transformed);
|
setData(transformed);
|
||||||
|
return transformed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
|
const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error(`Dashboard API Error [${endpoint}]:`, err);
|
console.error(`Dashboard API Error [${endpoint}]:`, err);
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,7 @@ export function useDashboardFetch<TApi, TResult>(
|
|||||||
}
|
}
|
||||||
}, [lazy, endpoint, fetchData]);
|
}, [lazy, endpoint, fetchData]);
|
||||||
|
|
||||||
return { data, loading, error, refetch: fetchData };
|
return { data, loading, error, refetch: fetchData, setData };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -102,12 +102,17 @@ export async function fetchCardTransactionDashboard(): Promise<ApiResponse<CardD
|
|||||||
* 가지급금 대시보드 데이터 조회
|
* 가지급금 대시보드 데이터 조회
|
||||||
* GET /api/v1/loans/dashboard
|
* GET /api/v1/loans/dashboard
|
||||||
*
|
*
|
||||||
* @returns 가지급금 요약, 월별 추이, 사용자별 분포, 거래 목록
|
* @param params - 날짜 필터 (선택)
|
||||||
|
* @returns 가지급금 요약, 카테고리 집계, 거래 목록
|
||||||
*/
|
*/
|
||||||
export async function fetchLoanDashboard(): Promise<ApiResponse<LoanDashboardApiResponse>> {
|
export async function fetchLoanDashboard(params?: {
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}): Promise<ApiResponse<LoanDashboardApiResponse>> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<ApiResponse<LoanDashboardApiResponse>>(
|
const response = await apiClient.get<ApiResponse<LoanDashboardApiResponse>>(
|
||||||
'/loans/dashboard'
|
'/loans/dashboard',
|
||||||
|
params ? { params } : undefined
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -126,13 +131,10 @@ export async function fetchLoanDashboard(): Promise<ApiResponse<LoanDashboardApi
|
|||||||
data: {
|
data: {
|
||||||
summary: {
|
summary: {
|
||||||
total_outstanding: 0,
|
total_outstanding: 0,
|
||||||
settled_amount: 0,
|
|
||||||
recognized_interest: 0,
|
recognized_interest: 0,
|
||||||
pending_count: 0,
|
outstanding_count: 0,
|
||||||
},
|
},
|
||||||
monthly_trend: [],
|
loans: [],
|
||||||
user_distribution: [],
|
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -144,13 +146,10 @@ export async function fetchLoanDashboard(): Promise<ApiResponse<LoanDashboardApi
|
|||||||
data: {
|
data: {
|
||||||
summary: {
|
summary: {
|
||||||
total_outstanding: 0,
|
total_outstanding: 0,
|
||||||
settled_amount: 0,
|
|
||||||
recognized_interest: 0,
|
recognized_interest: 0,
|
||||||
pending_count: 0,
|
outstanding_count: 0,
|
||||||
},
|
},
|
||||||
monthly_trend: [],
|
loans: [],
|
||||||
user_distribution: [],
|
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ export { transformReceivableResponse, transformDebtCollectionResponse } from './
|
|||||||
export { transformMonthlyExpenseResponse, transformCardManagementResponse } from './transformers/expense';
|
export { transformMonthlyExpenseResponse, transformCardManagementResponse } from './transformers/expense';
|
||||||
export { transformStatusBoardResponse, transformTodayIssueResponse } from './transformers/status-issue';
|
export { transformStatusBoardResponse, transformTodayIssueResponse } from './transformers/status-issue';
|
||||||
export { transformCalendarResponse } from './transformers/calendar';
|
export { transformCalendarResponse } from './transformers/calendar';
|
||||||
export { transformVatResponse, transformEntertainmentResponse, transformWelfareResponse, transformWelfareDetailResponse } from './transformers/tax-benefits';
|
export { transformVatResponse, transformVatDetailResponse, transformEntertainmentResponse, transformEntertainmentDetailResponse, transformWelfareResponse, transformWelfareDetailResponse } from './transformers/tax-benefits';
|
||||||
export { transformPurchaseDetailResponse, transformCardDetailResponse, transformBillDetailResponse, transformExpectedExpenseDetailResponse } from './transformers/expense-detail';
|
export { transformPurchaseDetailResponse, transformCardDetailResponse, transformBillDetailResponse, transformExpectedExpenseDetailResponse, transformPurchaseRecordsToModal, transformCardTransactionsToModal, transformBillRecordsToModal, transformAllExpensesToModal } from './transformers/expense-detail';
|
||||||
|
export { transformSalesStatusResponse, transformPurchaseStatusResponse } from './transformers/sales-purchase';
|
||||||
|
export { transformDailyProductionResponse, transformUnshippedResponse, transformConstructionResponse } from './transformers/production-logistics';
|
||||||
|
export { transformDailyAttendanceResponse } from './transformers/hr';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
CheckPoint,
|
CheckPoint,
|
||||||
CheckPointType,
|
CheckPointType,
|
||||||
} from '@/components/business/CEODashboard/types';
|
} from '@/components/business/CEODashboard/types';
|
||||||
import { formatAmount, formatDate, toChangeFields } from './common';
|
import { formatAmount, formatDate } from './common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 운영자금 안정성에 따른 색상 반환
|
* 운영자금 안정성에 따른 색상 반환
|
||||||
@@ -137,51 +137,32 @@ function generateDailyReportCheckPoints(api: DailyReportApiResponse): CheckPoint
|
|||||||
* DailyReport API 응답 → Frontend 타입 변환
|
* DailyReport API 응답 → Frontend 타입 변환
|
||||||
*/
|
*/
|
||||||
export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData {
|
export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData {
|
||||||
const change = api.daily_change;
|
|
||||||
|
|
||||||
// TODO: 백엔드 daily_change 필드 제공 시 더미값 제거
|
|
||||||
const FALLBACK_CHANGES = {
|
|
||||||
cash_asset: { changeRate: '+5.2%', changeDirection: 'up' as const },
|
|
||||||
foreign_currency: { changeRate: '+2.1%', changeDirection: 'up' as const },
|
|
||||||
income: { changeRate: '+12.0%', changeDirection: 'up' as const },
|
|
||||||
expense: { changeRate: '-8.0%', changeDirection: 'down' as const },
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: formatDate(api.date, api.day_of_week),
|
date: formatDate(api.date, api.day_of_week),
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
id: 'dr1',
|
id: 'dr1',
|
||||||
label: '현금성 자산 합계',
|
label: '일일일보',
|
||||||
amount: api.cash_asset_total,
|
amount: api.cash_asset_total,
|
||||||
...(change?.cash_asset_change_rate !== undefined
|
path: '/ko/accounting/daily-report',
|
||||||
? toChangeFields(change.cash_asset_change_rate)
|
|
||||||
: FALLBACK_CHANGES.cash_asset),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dr2',
|
id: 'dr2',
|
||||||
label: '외국환(USD) 합계',
|
label: '미수금 잔액',
|
||||||
amount: api.foreign_currency_total,
|
amount: api.receivable_balance ?? 0,
|
||||||
currency: 'USD',
|
path: '/ko/accounting/receivables-status',
|
||||||
...(change?.foreign_currency_change_rate !== undefined
|
|
||||||
? toChangeFields(change.foreign_currency_change_rate)
|
|
||||||
: FALLBACK_CHANGES.foreign_currency),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dr3',
|
id: 'dr3',
|
||||||
label: '입금 합계',
|
label: '미지급금 잔액',
|
||||||
amount: api.krw_totals.income,
|
amount: api.payable_balance ?? 0,
|
||||||
...(change?.income_change_rate !== undefined
|
// 클릭 이동 없음
|
||||||
? toChangeFields(change.income_change_rate)
|
|
||||||
: FALLBACK_CHANGES.income),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dr4',
|
id: 'dr4',
|
||||||
label: '출금 합계',
|
label: '당월 예상 지출 합계',
|
||||||
amount: api.krw_totals.expense,
|
amount: api.monthly_expense_total ?? 0,
|
||||||
...(change?.expense_change_rate !== undefined
|
// 클릭 시 당월 예상 지출 상세 팝업 (UI에서 처리)
|
||||||
? toChangeFields(change.expense_change_rate)
|
|
||||||
: FALLBACK_CHANGES.expense),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkPoints: generateDailyReportCheckPoints(api),
|
checkPoints: generateDailyReportCheckPoints(api),
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import type {
|
|||||||
BillDashboardDetailApiResponse,
|
BillDashboardDetailApiResponse,
|
||||||
ExpectedExpenseDashboardDetailApiResponse,
|
ExpectedExpenseDashboardDetailApiResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { DetailModalConfig } from '@/components/business/CEODashboard/types';
|
import type { DateFilterConfig, DetailModalConfig } from '@/components/business/CEODashboard/types';
|
||||||
|
import type { PurchaseRecord } from '@/components/accounting/PurchaseManagement/types';
|
||||||
|
import type { CardTransaction } from '@/components/accounting/CardTransactionInquiry/types';
|
||||||
|
import type { BillRecord } from '@/components/accounting/BillManagement/types';
|
||||||
|
import { PURCHASE_TYPE_LABELS } from '@/components/accounting/PurchaseManagement/types';
|
||||||
|
import { getBillStatusLabel } from '@/components/accounting/BillManagement/types';
|
||||||
|
|
||||||
|
// 차트 색상 팔레트
|
||||||
|
const CHART_COLORS = ['#60A5FA', '#34D399', '#F59E0B', '#F87171', '#A78BFA', '#94A3B8'];
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Purchase Dashboard Detail 변환 (me1)
|
// Purchase Dashboard Detail 변환 (me1)
|
||||||
@@ -268,6 +276,7 @@ const EXPENSE_CARD_CONFIG: Record<string, {
|
|||||||
hasBarChart: boolean;
|
hasBarChart: boolean;
|
||||||
hasPieChart: boolean;
|
hasPieChart: boolean;
|
||||||
hasHorizontalBarChart: boolean;
|
hasHorizontalBarChart: boolean;
|
||||||
|
dateFilter?: DateFilterConfig;
|
||||||
}> = {
|
}> = {
|
||||||
me1: {
|
me1: {
|
||||||
title: '당월 매입 상세',
|
title: '당월 매입 상세',
|
||||||
@@ -278,6 +287,7 @@ const EXPENSE_CARD_CONFIG: Record<string, {
|
|||||||
hasBarChart: true,
|
hasBarChart: true,
|
||||||
hasPieChart: true,
|
hasPieChart: true,
|
||||||
hasHorizontalBarChart: false,
|
hasHorizontalBarChart: false,
|
||||||
|
dateFilter: { enabled: true, defaultPreset: '당월', showSearch: true },
|
||||||
},
|
},
|
||||||
me2: {
|
me2: {
|
||||||
title: '당월 카드 상세',
|
title: '당월 카드 상세',
|
||||||
@@ -288,6 +298,7 @@ const EXPENSE_CARD_CONFIG: Record<string, {
|
|||||||
hasBarChart: true,
|
hasBarChart: true,
|
||||||
hasPieChart: true,
|
hasPieChart: true,
|
||||||
hasHorizontalBarChart: false,
|
hasHorizontalBarChart: false,
|
||||||
|
dateFilter: { enabled: true, defaultPreset: '당월', showSearch: true },
|
||||||
},
|
},
|
||||||
me3: {
|
me3: {
|
||||||
title: '당월 발행어음 상세',
|
title: '당월 발행어음 상세',
|
||||||
@@ -298,6 +309,7 @@ const EXPENSE_CARD_CONFIG: Record<string, {
|
|||||||
hasBarChart: true,
|
hasBarChart: true,
|
||||||
hasPieChart: false,
|
hasPieChart: false,
|
||||||
hasHorizontalBarChart: true,
|
hasHorizontalBarChart: true,
|
||||||
|
dateFilter: { enabled: true, presets: ['당해년도', '전전월', '전월', '당월', '어제'], defaultPreset: '당월', showSearch: true },
|
||||||
},
|
},
|
||||||
me4: {
|
me4: {
|
||||||
title: '당월 지출 예상 상세',
|
title: '당월 지출 예상 상세',
|
||||||
@@ -339,6 +351,7 @@ export function transformExpectedExpenseDetailResponse(
|
|||||||
// 결과 객체 생성
|
// 결과 객체 생성
|
||||||
const result: DetailModalConfig = {
|
const result: DetailModalConfig = {
|
||||||
title: config.title,
|
title: config.title,
|
||||||
|
...(config.dateFilter && { dateFilter: config.dateFilter }),
|
||||||
summaryCards: [
|
summaryCards: [
|
||||||
{ label: config.summaryLabel, value: summary.total_amount, unit: '원' },
|
{ label: config.summaryLabel, value: summary.total_amount, unit: '원' },
|
||||||
{ label: '전월 대비', value: changeRateText },
|
{ label: '전월 대비', value: changeRateText },
|
||||||
@@ -420,3 +433,294 @@ export function transformExpectedExpenseDetailResponse(
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 기존 페이지 서버 액션 응답 → DetailModalConfig 변환
|
||||||
|
// (dashboard-detail API 대신 실제 페이지 API 사용)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 거래처별 그룹핑 → 상위 5개 + 기타 */
|
||||||
|
function groupByVendor(
|
||||||
|
records: { vendorName: string; amount: number }[],
|
||||||
|
limit: number = 5,
|
||||||
|
): { name: string; value: number; percentage: number; color: string }[] {
|
||||||
|
const vendorMap = new Map<string, number>();
|
||||||
|
let total = 0;
|
||||||
|
for (const r of records) {
|
||||||
|
const name = r.vendorName || '미지정';
|
||||||
|
vendorMap.set(name, (vendorMap.get(name) || 0) + r.amount);
|
||||||
|
total += r.amount;
|
||||||
|
}
|
||||||
|
const sorted = [...vendorMap.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const top = sorted.slice(0, limit);
|
||||||
|
const otherTotal = sorted.slice(limit).reduce((sum, [, v]) => sum + v, 0);
|
||||||
|
if (otherTotal > 0) top.push(['기타', otherTotal]);
|
||||||
|
|
||||||
|
return top.map(([name, value], idx) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
percentage: total > 0 ? Math.round((value / total) * 1000) / 10 : 0,
|
||||||
|
color: CHART_COLORS[idx % CHART_COLORS.length],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PurchaseRecord[] → DetailModalConfig (me1 매입)
|
||||||
|
*/
|
||||||
|
export function transformPurchaseRecordsToModal(
|
||||||
|
records: PurchaseRecord[],
|
||||||
|
dateFilter?: DateFilterConfig,
|
||||||
|
): DetailModalConfig {
|
||||||
|
const totalAmount = records.reduce((sum, r) => sum + r.totalAmount, 0);
|
||||||
|
const totalSupply = records.reduce((sum, r) => sum + r.supplyAmount, 0);
|
||||||
|
const totalVat = records.reduce((sum, r) => sum + r.vat, 0);
|
||||||
|
|
||||||
|
const vendorData = groupByVendor(
|
||||||
|
records.map(r => ({ vendorName: r.vendorName, amount: r.totalAmount })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '당월 매입 상세',
|
||||||
|
dateFilter: dateFilter ?? { enabled: true, defaultPreset: '당월', showSearch: true },
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '총 매입액', value: totalAmount, unit: '원' },
|
||||||
|
{ label: '공급가액', value: totalSupply, unit: '원' },
|
||||||
|
{ label: '부가세', value: totalVat, unit: '원' },
|
||||||
|
{ label: '건수', value: records.length, unit: '건' },
|
||||||
|
],
|
||||||
|
pieChart: vendorData.length > 0 ? {
|
||||||
|
title: '거래처별 매입 비율',
|
||||||
|
data: vendorData,
|
||||||
|
} : undefined,
|
||||||
|
table: {
|
||||||
|
title: '매입 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'purchaseDate', label: '매입일자', align: 'center', format: 'date' },
|
||||||
|
{ key: 'vendorName', label: '거래처명', align: 'left' },
|
||||||
|
{ key: 'supplyAmount', label: '공급가액', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'totalAmount', label: '합계', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'purchaseType', label: '유형', align: 'center' },
|
||||||
|
],
|
||||||
|
data: records.map((r, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
purchaseDate: r.purchaseDate,
|
||||||
|
vendorName: r.vendorName,
|
||||||
|
supplyAmount: r.supplyAmount,
|
||||||
|
vat: r.vat,
|
||||||
|
totalAmount: r.totalAmount,
|
||||||
|
purchaseType: PURCHASE_TYPE_LABELS[r.purchaseType] || r.purchaseType,
|
||||||
|
})),
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: totalAmount,
|
||||||
|
totalColumnKey: 'totalAmount',
|
||||||
|
footerSummary: [
|
||||||
|
{ label: `총 ${records.length}건`, value: totalAmount, format: 'currency' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CardTransaction[] → DetailModalConfig (me2 카드)
|
||||||
|
*/
|
||||||
|
export function transformCardTransactionsToModal(
|
||||||
|
records: CardTransaction[],
|
||||||
|
dateFilter?: DateFilterConfig,
|
||||||
|
): DetailModalConfig {
|
||||||
|
const totalAmount = records.reduce((sum, r) => sum + r.totalAmount, 0);
|
||||||
|
|
||||||
|
const vendorData = groupByVendor(
|
||||||
|
records.map(r => ({ vendorName: r.merchantName || r.vendorName, amount: r.totalAmount })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '당월 카드 상세',
|
||||||
|
dateFilter: dateFilter ?? { enabled: true, defaultPreset: '당월', showSearch: true },
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '총 사용액', value: totalAmount, unit: '원' },
|
||||||
|
{ label: '건수', value: records.length, unit: '건' },
|
||||||
|
],
|
||||||
|
pieChart: vendorData.length > 0 ? {
|
||||||
|
title: '가맹점별 카드 사용 비율',
|
||||||
|
data: vendorData,
|
||||||
|
} : undefined,
|
||||||
|
table: {
|
||||||
|
title: '카드 사용 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'usedAt', label: '사용일자', align: 'center', format: 'date' },
|
||||||
|
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||||
|
{ key: 'user', label: '사용자', align: 'center' },
|
||||||
|
{ key: 'merchantName', label: '가맹점명', align: 'left' },
|
||||||
|
{ key: 'totalAmount', label: '사용금액', align: 'right', format: 'currency' },
|
||||||
|
],
|
||||||
|
data: records.map((r, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
usedAt: r.usedAt,
|
||||||
|
cardName: r.cardName,
|
||||||
|
user: r.user,
|
||||||
|
merchantName: r.merchantName,
|
||||||
|
totalAmount: r.totalAmount,
|
||||||
|
})),
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: totalAmount,
|
||||||
|
totalColumnKey: 'totalAmount',
|
||||||
|
footerSummary: [
|
||||||
|
{ label: `총 ${records.length}건`, value: totalAmount, format: 'currency' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BillRecord[] → DetailModalConfig (me3 발행어음)
|
||||||
|
*/
|
||||||
|
export function transformBillRecordsToModal(
|
||||||
|
records: BillRecord[],
|
||||||
|
dateFilter?: DateFilterConfig,
|
||||||
|
): DetailModalConfig {
|
||||||
|
const totalAmount = records.reduce((sum, r) => sum + r.amount, 0);
|
||||||
|
|
||||||
|
const vendorBarData = groupByVendor(
|
||||||
|
records.map(r => ({ vendorName: r.vendorName, amount: r.amount })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '당월 발행어음 상세',
|
||||||
|
dateFilter: dateFilter ?? {
|
||||||
|
enabled: true,
|
||||||
|
presets: ['당해년도', '전전월', '전월', '당월', '어제'],
|
||||||
|
defaultPreset: '당월',
|
||||||
|
showSearch: true,
|
||||||
|
},
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '총 발행어음', value: totalAmount, unit: '원' },
|
||||||
|
{ label: '건수', value: records.length, unit: '건' },
|
||||||
|
],
|
||||||
|
horizontalBarChart: vendorBarData.length > 0 ? {
|
||||||
|
title: '거래처별 발행어음',
|
||||||
|
data: vendorBarData.map(d => ({ name: d.name, value: d.value })),
|
||||||
|
dataKey: 'value',
|
||||||
|
yAxisKey: 'name',
|
||||||
|
color: '#8B5CF6',
|
||||||
|
} : undefined,
|
||||||
|
table: {
|
||||||
|
title: '발행어음 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'billNumber', label: '어음번호', align: 'center' },
|
||||||
|
{ key: 'vendorName', label: '거래처명', align: 'left' },
|
||||||
|
{ key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
|
||||||
|
{ key: 'maturityDate', label: '만기일', align: 'center', format: 'date' },
|
||||||
|
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'status', label: '상태', align: 'center' },
|
||||||
|
],
|
||||||
|
data: records.map((r, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
billNumber: r.billNumber,
|
||||||
|
vendorName: r.vendorName,
|
||||||
|
issueDate: r.issueDate,
|
||||||
|
maturityDate: r.maturityDate,
|
||||||
|
amount: r.amount,
|
||||||
|
status: getBillStatusLabel(r.billType, r.status),
|
||||||
|
})),
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: totalAmount,
|
||||||
|
totalColumnKey: 'amount',
|
||||||
|
footerSummary: [
|
||||||
|
{ label: `총 ${records.length}건`, value: totalAmount, format: 'currency' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3개 합산 → DetailModalConfig (me4 총 예상 지출)
|
||||||
|
*/
|
||||||
|
export function transformAllExpensesToModal(
|
||||||
|
purchases: PurchaseRecord[],
|
||||||
|
cards: CardTransaction[],
|
||||||
|
bills: BillRecord[],
|
||||||
|
): DetailModalConfig {
|
||||||
|
const purchaseTotal = purchases.reduce((sum, r) => sum + r.totalAmount, 0);
|
||||||
|
const cardTotal = cards.reduce((sum, r) => sum + r.totalAmount, 0);
|
||||||
|
const billTotal = bills.reduce((sum, r) => sum + r.amount, 0);
|
||||||
|
const grandTotal = purchaseTotal + cardTotal + billTotal;
|
||||||
|
const totalCount = purchases.length + cards.length + bills.length;
|
||||||
|
|
||||||
|
// 3개 소스를 하나의 테이블로 합침
|
||||||
|
type UnifiedRow = { date: string; type: string; vendorName: string; amount: number };
|
||||||
|
const allRows: UnifiedRow[] = [
|
||||||
|
...purchases.map(r => ({
|
||||||
|
date: r.purchaseDate,
|
||||||
|
type: '매입',
|
||||||
|
vendorName: r.vendorName,
|
||||||
|
amount: r.totalAmount,
|
||||||
|
})),
|
||||||
|
...cards.map(r => ({
|
||||||
|
date: r.usedAt?.split(' ')[0] || r.usedAt, // 'YYYY-MM-DD HH:mm' → 'YYYY-MM-DD'
|
||||||
|
type: '카드',
|
||||||
|
vendorName: r.merchantName || r.vendorName,
|
||||||
|
amount: r.totalAmount,
|
||||||
|
})),
|
||||||
|
...bills.map(r => ({
|
||||||
|
date: r.issueDate,
|
||||||
|
type: '어음',
|
||||||
|
vendorName: r.vendorName,
|
||||||
|
amount: r.amount,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
// 날짜순 정렬
|
||||||
|
allRows.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '당월 지출 예상 상세',
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '총 지출 예상액', value: grandTotal, unit: '원' },
|
||||||
|
{ label: '매입', value: purchaseTotal, unit: '원' },
|
||||||
|
{ label: '카드', value: cardTotal, unit: '원' },
|
||||||
|
{ label: '어음', value: billTotal, unit: '원' },
|
||||||
|
],
|
||||||
|
table: {
|
||||||
|
title: '당월 지출 승인 내역서',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'date', label: '일자', align: 'center', format: 'date' },
|
||||||
|
{ key: 'type', label: '유형', align: 'center' },
|
||||||
|
{ key: 'vendorName', label: '거래처명', align: 'left' },
|
||||||
|
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||||
|
],
|
||||||
|
data: allRows.map((r, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
date: r.date,
|
||||||
|
type: r.type,
|
||||||
|
vendorName: r.vendorName,
|
||||||
|
amount: r.amount,
|
||||||
|
})),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: '매입', label: '매입' },
|
||||||
|
{ value: '카드', label: '카드' },
|
||||||
|
{ value: '어음', label: '어음' },
|
||||||
|
],
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: grandTotal,
|
||||||
|
totalColumnKey: 'amount',
|
||||||
|
footerSummary: [
|
||||||
|
{ label: `총 ${totalCount}건`, value: grandTotal, format: 'currency' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
CheckPoint,
|
CheckPoint,
|
||||||
CheckPointType,
|
CheckPointType,
|
||||||
} from '@/components/business/CEODashboard/types';
|
} from '@/components/business/CEODashboard/types';
|
||||||
import { formatAmount, calculateChangeRate } from './common';
|
import { formatAmount } from './common';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 월 예상 지출 (MonthlyExpense)
|
// 월 예상 지출 (MonthlyExpense)
|
||||||
@@ -78,49 +78,84 @@ export function transformMonthlyExpenseResponse(api: ExpectedExpenseApiResponse)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 카드/가지급금 (CardManagement)
|
// 카드/가지급금 (CardManagement) — D1.7 5장 카드 구조
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카드/가지급금 CheckPoints 생성
|
* 카드/가지급금 CheckPoints 생성 (D1.7)
|
||||||
|
*
|
||||||
|
* CP1: 법인카드 → 가지급금 전환 경고
|
||||||
|
* CP2: 인정이자 발생 현황
|
||||||
|
* CP3: 접대비 불인정 항목 감지
|
||||||
|
* CP4: 주말 카드 사용 감지 (향후 확장)
|
||||||
*/
|
*/
|
||||||
function generateCardManagementCheckPoints(api: CardTransactionApiResponse): CheckPoint[] {
|
function generateCardManagementCheckPoints(
|
||||||
|
loanApi?: LoanDashboardApiResponse | null,
|
||||||
|
taxApi?: TaxSimulationApiResponse | null,
|
||||||
|
cardApi?: CardTransactionApiResponse | null,
|
||||||
|
): CheckPoint[] {
|
||||||
const checkPoints: CheckPoint[] = [];
|
const checkPoints: CheckPoint[] = [];
|
||||||
|
|
||||||
// 전월 대비 변화
|
const totalOutstanding = loanApi?.summary?.total_outstanding ?? 0;
|
||||||
const changeRate = calculateChangeRate(api.current_month_total, api.previous_month_total);
|
const interestRate = taxApi?.loan_summary?.interest_rate ?? 4.6;
|
||||||
if (Math.abs(changeRate) > 10) {
|
const recognizedInterest = taxApi?.loan_summary?.recognized_interest ?? 0;
|
||||||
const type: CheckPointType = changeRate > 0 ? 'warning' : 'info';
|
|
||||||
|
// CP1: 법인카드 사용 중 가지급금 전환 경고
|
||||||
|
if (totalOutstanding > 0) {
|
||||||
checkPoints.push({
|
checkPoints.push({
|
||||||
id: 'cm-change',
|
id: 'cm-cp1',
|
||||||
type,
|
type: 'success' as CheckPointType,
|
||||||
message: `당월 카드 사용액이 전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}% 변동했습니다.`,
|
message: `법인카드 사용 중 ${formatAmount(totalOutstanding)}이 가지급금으로 전환되었습니다. 연 ${interestRate}% 인정이자가 발생합니다.`,
|
||||||
highlights: [
|
highlights: [
|
||||||
{ text: `${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`, color: changeRate > 0 ? 'red' as const : 'green' as const },
|
{ text: formatAmount(totalOutstanding), color: 'red' as const },
|
||||||
|
{ text: '가지급금', color: 'red' as const },
|
||||||
|
{ text: `연 ${interestRate}% 인정이자`, color: 'red' as const },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 당월 사용액
|
// CP2: 인정이자 발생 현황
|
||||||
checkPoints.push({
|
if (totalOutstanding > 0 && recognizedInterest > 0) {
|
||||||
id: 'cm-current',
|
checkPoints.push({
|
||||||
type: 'info' as CheckPointType,
|
id: 'cm-cp2',
|
||||||
message: `당월 카드 사용 총 ${formatAmount(api.current_month_total)}입니다.`,
|
type: 'success' as CheckPointType,
|
||||||
highlights: [
|
message: `현재 가지급금 ${formatAmount(totalOutstanding)} × ${interestRate}% = 연간 약 ${formatAmount(recognizedInterest)}의 인정이자가 발생 중입니다.`,
|
||||||
{ text: formatAmount(api.current_month_total), color: 'blue' as const },
|
highlights: [
|
||||||
],
|
{ text: `연간 약 ${formatAmount(recognizedInterest)}의 인정이자`, color: 'red' as const },
|
||||||
});
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CP3: 접대비 불인정 항목 감지
|
||||||
|
const entertainmentCount = loanApi?.category_breakdown?.entertainment?.unverified_count ?? 0;
|
||||||
|
if (entertainmentCount > 0) {
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'cm-cp3',
|
||||||
|
type: 'success' as CheckPointType,
|
||||||
|
message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.',
|
||||||
|
highlights: [
|
||||||
|
{ text: '불인정 항목 결제 감지', color: 'red' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CP4: 주말 카드 사용 감지 (향후 card-transactions API 확장 시)
|
||||||
|
// 현재는 cardApi 데이터에서 주말 사용 정보가 없으므로 placeholder
|
||||||
|
if (cardApi && cardApi.current_month_total > 0) {
|
||||||
|
// 향후 weekend_amount 필드 추가 시 활성화
|
||||||
|
}
|
||||||
|
|
||||||
return checkPoints;
|
return checkPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CardTransaction API 응답 → Frontend 타입 변환
|
* CardTransaction API 응답 → Frontend 타입 변환
|
||||||
* 4개 카드 구조:
|
* D1.7 5장 카드 구조:
|
||||||
* - cm1: 카드 사용액 (CardTransaction API)
|
* - cm1: 카드 (loans category_breakdown.card)
|
||||||
* - cm2: 가지급금 (LoanDashboard API)
|
* - cm2: 경조사 (loans category_breakdown.congratulatory)
|
||||||
* - cm3: 법인세 예상 가중 (TaxSimulation API - corporate_tax.difference)
|
* - cm3: 상품권 (loans category_breakdown.gift_certificate)
|
||||||
* - cm4: 대표자 종합세 예상 가중 (TaxSimulation API - income_tax.difference)
|
* - cm4: 접대비 (loans category_breakdown.entertainment)
|
||||||
|
* - cm_total: 총 가지급금 합계 (loans summary.total_outstanding)
|
||||||
*/
|
*/
|
||||||
export function transformCardManagementResponse(
|
export function transformCardManagementResponse(
|
||||||
summaryApi: CardTransactionApiResponse,
|
summaryApi: CardTransactionApiResponse,
|
||||||
@@ -128,50 +163,77 @@ export function transformCardManagementResponse(
|
|||||||
taxApi?: TaxSimulationApiResponse | null,
|
taxApi?: TaxSimulationApiResponse | null,
|
||||||
fallbackData?: CardManagementData
|
fallbackData?: CardManagementData
|
||||||
): CardManagementData {
|
): CardManagementData {
|
||||||
const changeRate = calculateChangeRate(summaryApi.current_month_total, summaryApi.previous_month_total);
|
const breakdown = loanApi?.category_breakdown;
|
||||||
|
const totalOutstanding = loanApi?.summary?.total_outstanding ?? 0;
|
||||||
|
|
||||||
// cm2: 가지급금 금액 (LoanDashboard API 또는 fallback)
|
// 카테고리별 금액 추출
|
||||||
const loanAmount = loanApi?.summary?.total_outstanding ?? fallbackData?.cards[1]?.amount ?? 0;
|
const cardAmount = breakdown?.card?.outstanding_amount ?? fallbackData?.cards[0]?.amount ?? 0;
|
||||||
|
const congratulatoryAmount = breakdown?.congratulatory?.outstanding_amount ?? fallbackData?.cards[1]?.amount ?? 0;
|
||||||
|
const giftCertificateAmount = breakdown?.gift_certificate?.outstanding_amount ?? fallbackData?.cards[2]?.amount ?? 0;
|
||||||
|
const entertainmentAmount = breakdown?.entertainment?.outstanding_amount ?? fallbackData?.cards[3]?.amount ?? 0;
|
||||||
|
|
||||||
// cm3: 법인세 예상 가중 (TaxSimulation API 또는 fallback)
|
// 카테고리별 미증빙/미정리 건수
|
||||||
const corporateTaxDifference = taxApi?.corporate_tax?.difference ?? fallbackData?.cards[2]?.amount ?? 0;
|
const cardUnverified = breakdown?.card?.unverified_count ?? 0;
|
||||||
|
const congratulatoryUnverified = breakdown?.congratulatory?.unverified_count ?? 0;
|
||||||
|
const giftCertificateUnverified = breakdown?.gift_certificate?.unverified_count ?? 0;
|
||||||
|
const entertainmentUnverified = breakdown?.entertainment?.unverified_count ?? 0;
|
||||||
|
|
||||||
// cm4: 대표자 종합세 예상 가중 (TaxSimulation API 또는 fallback)
|
// 총 합계 (API summary 또는 카테고리 합산)
|
||||||
const incomeTaxDifference = taxApi?.income_tax?.difference ?? fallbackData?.cards[3]?.amount ?? 0;
|
const totalAmount = totalOutstanding > 0
|
||||||
|
? totalOutstanding
|
||||||
|
: cardAmount + congratulatoryAmount + giftCertificateAmount + entertainmentAmount;
|
||||||
|
|
||||||
// 가지급금 경고 배너 표시 여부 결정 (가지급금 잔액 > 0이면 표시)
|
// 가지급금 경고 배너 표시 여부 (가지급금 잔액 > 0이면 표시)
|
||||||
const hasLoanWarning = loanAmount > 0;
|
const hasLoanWarning = totalAmount > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 가지급금 관련 경고 배너 (가지급금 있을 때만 표시)
|
warningBanner: hasLoanWarning
|
||||||
warningBanner: hasLoanWarning ? fallbackData?.warningBanner : undefined,
|
? (fallbackData?.warningBanner ?? '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의')
|
||||||
|
: undefined,
|
||||||
cards: [
|
cards: [
|
||||||
// cm1: 카드 사용액 (CardTransaction API)
|
// cm1: 카드
|
||||||
{
|
{
|
||||||
id: 'cm1',
|
id: 'cm1',
|
||||||
label: '카드',
|
label: '카드',
|
||||||
amount: summaryApi.current_month_total,
|
amount: cardAmount,
|
||||||
previousLabel: `전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`,
|
subLabel: cardUnverified > 0 ? `미정리 ${cardUnverified}건` : undefined,
|
||||||
|
subAmount: cardUnverified > 0 ? cardAmount : undefined,
|
||||||
|
isHighlighted: cardUnverified > 0,
|
||||||
},
|
},
|
||||||
// cm2: 가지급금 (LoanDashboard API)
|
// cm2: 경조사
|
||||||
{
|
{
|
||||||
id: 'cm2',
|
id: 'cm2',
|
||||||
label: '가지급금',
|
label: '경조사',
|
||||||
amount: loanAmount,
|
amount: congratulatoryAmount,
|
||||||
|
subLabel: congratulatoryUnverified > 0 ? `미증빙 ${congratulatoryUnverified}건` : undefined,
|
||||||
|
subAmount: congratulatoryUnverified > 0 ? congratulatoryAmount : undefined,
|
||||||
|
isHighlighted: congratulatoryUnverified > 0,
|
||||||
},
|
},
|
||||||
// cm3: 법인세 예상 가중 (TaxSimulation API)
|
// cm3: 상품권
|
||||||
{
|
{
|
||||||
id: 'cm3',
|
id: 'cm3',
|
||||||
label: '법인세 예상 가중',
|
label: '상품권',
|
||||||
amount: corporateTaxDifference,
|
amount: giftCertificateAmount,
|
||||||
|
subLabel: giftCertificateUnverified > 0 ? `미증빙 ${giftCertificateUnverified}건` : undefined,
|
||||||
|
subAmount: giftCertificateUnverified > 0 ? giftCertificateAmount : undefined,
|
||||||
|
isHighlighted: giftCertificateUnverified > 0,
|
||||||
},
|
},
|
||||||
// cm4: 대표자 종합세 예상 가중 (TaxSimulation API)
|
// cm4: 접대비
|
||||||
{
|
{
|
||||||
id: 'cm4',
|
id: 'cm4',
|
||||||
label: '대표자 종합세 예상 가중',
|
label: '접대비',
|
||||||
amount: incomeTaxDifference,
|
amount: entertainmentAmount,
|
||||||
|
subLabel: entertainmentUnverified > 0 ? `미증빙 ${entertainmentUnverified}건` : undefined,
|
||||||
|
subAmount: entertainmentUnverified > 0 ? entertainmentAmount : undefined,
|
||||||
|
isHighlighted: entertainmentUnverified > 0,
|
||||||
|
},
|
||||||
|
// cm_total: 총 가지급금 합계
|
||||||
|
{
|
||||||
|
id: 'cm_total',
|
||||||
|
label: '총 가지급금 합계',
|
||||||
|
amount: totalAmount,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkPoints: generateCardManagementCheckPoints(summaryApi),
|
checkPoints: generateCardManagementCheckPoints(loanApi, taxApi, summaryApi),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/lib/api/dashboard/transformers/hr.ts
Normal file
32
src/lib/api/dashboard/transformers/hr.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 근태 현황 (HR/Attendance) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DailyAttendanceApiResponse } from '../types';
|
||||||
|
import type { DailyAttendanceData } from '@/components/business/CEODashboard/types';
|
||||||
|
|
||||||
|
const ATTENDANCE_STATUS_MAP: Record<string, '출근' | '휴가' | '지각' | '결근'> = {
|
||||||
|
present: '출근',
|
||||||
|
on_leave: '휴가',
|
||||||
|
late: '지각',
|
||||||
|
absent: '결근',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DailyAttendance API 응답 → Frontend DailyAttendanceData 변환
|
||||||
|
*/
|
||||||
|
export function transformDailyAttendanceResponse(api: DailyAttendanceApiResponse): DailyAttendanceData {
|
||||||
|
return {
|
||||||
|
present: api.present,
|
||||||
|
onLeave: api.on_leave,
|
||||||
|
late: api.late,
|
||||||
|
absent: api.absent,
|
||||||
|
employees: api.employees.map((emp) => ({
|
||||||
|
id: emp.id,
|
||||||
|
department: emp.department,
|
||||||
|
position: emp.position,
|
||||||
|
name: emp.name,
|
||||||
|
status: ATTENDANCE_STATUS_MAP[emp.status] ?? '출근',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
105
src/lib/api/dashboard/transformers/production-logistics.ts
Normal file
105
src/lib/api/dashboard/transformers/production-logistics.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 생산/물류 현황 (Production/Logistics) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DailyProductionApiResponse,
|
||||||
|
UnshippedApiResponse,
|
||||||
|
ConstructionApiResponse,
|
||||||
|
} from '../types';
|
||||||
|
import type {
|
||||||
|
DailyProductionData,
|
||||||
|
UnshippedData,
|
||||||
|
ConstructionData,
|
||||||
|
} from '@/components/business/CEODashboard/types';
|
||||||
|
|
||||||
|
const WORK_STATUS_MAP: Record<string, '진행중' | '대기' | '완료'> = {
|
||||||
|
in_progress: '진행중',
|
||||||
|
pending: '대기',
|
||||||
|
completed: '완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONSTRUCTION_STATUS_MAP: Record<string, '진행중' | '예정' | '완료'> = {
|
||||||
|
in_progress: '진행중',
|
||||||
|
scheduled: '예정',
|
||||||
|
completed: '완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DailyProduction API 응답 → Frontend DailyProductionData 변환
|
||||||
|
*/
|
||||||
|
export function transformDailyProductionResponse(api: DailyProductionApiResponse): DailyProductionData {
|
||||||
|
const dateObj = new Date(api.date);
|
||||||
|
const dayNames = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
|
||||||
|
const formattedDate = `${dateObj.getFullYear()}년 ${dateObj.getMonth() + 1}월 ${dateObj.getDate()}일 ${api.day_of_week || dayNames[dateObj.getDay()]}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: formattedDate,
|
||||||
|
processes: api.processes.map((proc) => ({
|
||||||
|
processName: proc.process_name,
|
||||||
|
totalWork: proc.total_work,
|
||||||
|
todo: proc.todo,
|
||||||
|
inProgress: proc.in_progress,
|
||||||
|
completed: proc.completed,
|
||||||
|
urgent: proc.urgent,
|
||||||
|
subLine: proc.sub_line,
|
||||||
|
regular: proc.regular,
|
||||||
|
workerCount: proc.worker_count,
|
||||||
|
workItems: proc.work_items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
orderNo: item.order_no,
|
||||||
|
client: item.client,
|
||||||
|
product: item.product,
|
||||||
|
quantity: item.quantity,
|
||||||
|
status: WORK_STATUS_MAP[item.status] ?? '대기',
|
||||||
|
})),
|
||||||
|
workers: proc.workers.map((w) => ({
|
||||||
|
name: w.name,
|
||||||
|
assigned: w.assigned,
|
||||||
|
completed: w.completed,
|
||||||
|
rate: w.rate,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
shipment: {
|
||||||
|
expectedAmount: api.shipment.expected_amount,
|
||||||
|
expectedCount: api.shipment.expected_count,
|
||||||
|
actualAmount: api.shipment.actual_amount,
|
||||||
|
actualCount: api.shipment.actual_count,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unshipped API 응답 → Frontend UnshippedData 변환
|
||||||
|
*/
|
||||||
|
export function transformUnshippedResponse(api: UnshippedApiResponse): UnshippedData {
|
||||||
|
return {
|
||||||
|
items: api.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
portNo: item.port_no,
|
||||||
|
siteName: item.site_name,
|
||||||
|
orderClient: item.order_client,
|
||||||
|
dueDate: item.due_date,
|
||||||
|
daysLeft: item.days_left,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construction API 응답 → Frontend ConstructionData 변환
|
||||||
|
*/
|
||||||
|
export function transformConstructionResponse(api: ConstructionApiResponse): ConstructionData {
|
||||||
|
return {
|
||||||
|
thisMonth: api.this_month,
|
||||||
|
completed: api.completed,
|
||||||
|
items: api.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
siteName: item.site_name,
|
||||||
|
client: item.client,
|
||||||
|
startDate: item.start_date,
|
||||||
|
endDate: item.end_date,
|
||||||
|
progress: item.progress,
|
||||||
|
status: CONSTRUCTION_STATUS_MAP[item.status] ?? '진행중',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,83 +9,48 @@ import type {
|
|||||||
CheckPoint,
|
CheckPoint,
|
||||||
CheckPointType,
|
CheckPointType,
|
||||||
} from '@/components/business/CEODashboard/types';
|
} from '@/components/business/CEODashboard/types';
|
||||||
import { formatAmount, normalizePath } from './common';
|
import { formatAmount, normalizePath, validateHighlightColor } from './common';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 미수금 (Receivable)
|
// 미수금 (Receivable) — D1.7 cards + check_points 구조
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
|
||||||
* 미수금 현황 CheckPoints 생성
|
|
||||||
*/
|
|
||||||
function generateReceivableCheckPoints(api: ReceivablesApiResponse): CheckPoint[] {
|
|
||||||
const checkPoints: CheckPoint[] = [];
|
|
||||||
|
|
||||||
// 연체 거래처 경고
|
|
||||||
if (api.overdue_vendor_count > 0) {
|
|
||||||
checkPoints.push({
|
|
||||||
id: 'rv-overdue',
|
|
||||||
type: 'warning' as CheckPointType,
|
|
||||||
message: `연체 거래처 ${api.overdue_vendor_count}곳. 회수 조치가 필요합니다.`,
|
|
||||||
highlights: [
|
|
||||||
{ text: `연체 거래처 ${api.overdue_vendor_count}곳`, color: 'red' as const },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 미수금 현황
|
|
||||||
if (api.total_receivables > 0) {
|
|
||||||
checkPoints.push({
|
|
||||||
id: 'rv-total',
|
|
||||||
type: 'info' as CheckPointType,
|
|
||||||
message: `총 미수금 ${formatAmount(api.total_receivables)}입니다.`,
|
|
||||||
highlights: [
|
|
||||||
{ text: formatAmount(api.total_receivables), color: 'blue' as const },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Receivables API 응답 → Frontend 타입 변환
|
* Receivables API 응답 → Frontend 타입 변환
|
||||||
|
* 백엔드에서 cards[] + check_points[] 구조로 직접 전달
|
||||||
*/
|
*/
|
||||||
export function transformReceivableResponse(api: ReceivablesApiResponse): ReceivableData {
|
export function transformReceivableResponse(api: ReceivablesApiResponse): ReceivableData {
|
||||||
// 누적 미수금 = 이월 + 매출 - 입금
|
|
||||||
const cumulativeReceivable = api.total_carry_forward + api.total_sales - api.total_deposits;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cards: [
|
cards: api.cards.map((card) => ({
|
||||||
{
|
id: card.id,
|
||||||
id: 'rv1',
|
label: card.label,
|
||||||
label: '누적 미수금',
|
amount: card.amount,
|
||||||
amount: cumulativeReceivable,
|
subLabel: card.subLabel,
|
||||||
subItems: [
|
unit: card.unit,
|
||||||
{ label: '이월', value: api.total_carry_forward },
|
// sub_items → subItems 매핑
|
||||||
{ label: '매출', value: api.total_sales },
|
subItems: card.sub_items?.map((item) => ({
|
||||||
{ label: '입금', value: api.total_deposits },
|
label: item.label,
|
||||||
],
|
value: item.value,
|
||||||
},
|
})),
|
||||||
{
|
// top_items → subItems 매핑 (Top 3 거래처)
|
||||||
id: 'rv2',
|
...(card.top_items && card.top_items.length > 0
|
||||||
label: '당월 미수금',
|
? {
|
||||||
amount: api.total_receivables,
|
subItems: card.top_items.map((item, idx) => ({
|
||||||
subItems: [
|
label: `${idx + 1}. ${item.name}`,
|
||||||
{ label: '매출', value: api.total_sales },
|
value: item.amount,
|
||||||
{ label: '입금', value: api.total_deposits },
|
})),
|
||||||
],
|
}
|
||||||
},
|
: {}),
|
||||||
{
|
})),
|
||||||
id: 'rv3',
|
checkPoints: api.check_points.map((cp) => ({
|
||||||
label: '거래처 현황',
|
id: cp.id,
|
||||||
amount: api.vendor_count,
|
type: cp.type as CheckPointType,
|
||||||
unit: '곳',
|
message: cp.message,
|
||||||
subLabel: `연체 ${api.overdue_vendor_count}곳`,
|
highlights: cp.highlights?.map((h) => ({
|
||||||
},
|
text: h.text,
|
||||||
],
|
color: validateHighlightColor(h.color),
|
||||||
checkPoints: generateReceivableCheckPoints(api),
|
})),
|
||||||
//detailButtonLabel: '미수금 상세',
|
})),
|
||||||
detailButtonPath: normalizePath('/accounting/receivables-status'),
|
detailButtonPath: normalizePath('/accounting/receivables-status'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/lib/api/dashboard/transformers/sales-purchase.ts
Normal file
75
src/lib/api/dashboard/transformers/sales-purchase.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 매출/매입 현황 (Sales/Purchase) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SalesStatusApiResponse, PurchaseStatusApiResponse } from '../types';
|
||||||
|
import type { SalesStatusData, PurchaseStatusData } from '@/components/business/CEODashboard/types';
|
||||||
|
|
||||||
|
const STATUS_MAP_SALES: Record<string, '입금완료' | '미입금' | '부분입금'> = {
|
||||||
|
deposited: '입금완료',
|
||||||
|
unpaid: '미입금',
|
||||||
|
partial: '부분입금',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_MAP_PURCHASE: Record<string, '결제완료' | '미결제' | '부분결제'> = {
|
||||||
|
paid: '결제완료',
|
||||||
|
unpaid: '미결제',
|
||||||
|
partial: '부분결제',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sales Summary API 응답 → Frontend SalesStatusData 변환
|
||||||
|
*/
|
||||||
|
export function transformSalesStatusResponse(api: SalesStatusApiResponse): SalesStatusData {
|
||||||
|
return {
|
||||||
|
cumulativeSales: api.cumulative_sales,
|
||||||
|
achievementRate: api.achievement_rate,
|
||||||
|
yoyChange: api.yoy_change,
|
||||||
|
monthlySales: api.monthly_sales,
|
||||||
|
monthlyTrend: api.monthly_trend.map((item) => ({
|
||||||
|
month: item.label,
|
||||||
|
amount: item.amount,
|
||||||
|
})),
|
||||||
|
clientSales: api.client_sales.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
amount: item.amount,
|
||||||
|
})),
|
||||||
|
dailyItems: api.daily_items.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
client: item.client,
|
||||||
|
item: item.item,
|
||||||
|
amount: item.amount,
|
||||||
|
status: STATUS_MAP_SALES[item.status] ?? '미입금',
|
||||||
|
})),
|
||||||
|
dailyTotal: api.daily_total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purchase Summary API 응답 → Frontend PurchaseStatusData 변환
|
||||||
|
*/
|
||||||
|
export function transformPurchaseStatusResponse(api: PurchaseStatusApiResponse): PurchaseStatusData {
|
||||||
|
return {
|
||||||
|
cumulativePurchase: api.cumulative_purchase,
|
||||||
|
unpaidAmount: api.unpaid_amount,
|
||||||
|
yoyChange: api.yoy_change,
|
||||||
|
monthlyTrend: api.monthly_trend.map((item) => ({
|
||||||
|
month: item.label,
|
||||||
|
amount: item.amount,
|
||||||
|
})),
|
||||||
|
materialRatio: api.material_ratio.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
value: item.value,
|
||||||
|
percentage: item.percentage,
|
||||||
|
color: item.color,
|
||||||
|
})),
|
||||||
|
dailyItems: api.daily_items.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
supplier: item.supplier,
|
||||||
|
item: item.item,
|
||||||
|
amount: item.amount,
|
||||||
|
status: STATUS_MAP_PURCHASE[item.status] ?? '미결제',
|
||||||
|
})),
|
||||||
|
dailyTotal: api.daily_total,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const STATUS_BOARD_FALLBACK_SUB_LABELS: Record<string, string> = {
|
|||||||
tax_deadline: '',
|
tax_deadline: '',
|
||||||
new_clients: '대한철강 외',
|
new_clients: '대한철강 외',
|
||||||
leaves: '',
|
leaves: '',
|
||||||
purchases: '(유)한국정밀 외',
|
// purchases: '(유)한국정밀 외', // [2026-03-03] 비활성화 — 백엔드 path 오류 + 데이터 정합성 이슈 (N4 참조)
|
||||||
approvals: '구매 결재 외',
|
approvals: '구매 결재 외',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,19 +52,42 @@ function buildStatusSubLabel(item: { id: string; count: number | string; sub_lab
|
|||||||
return fallback.replace(/ 외$/, '');
|
return fallback.replace(/ 외$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프론트 path 오버라이드: 백엔드 path가 잘못되거나 링크 불필요한 항목
|
||||||
|
* - 값이 빈 문자열: 클릭 비활성화 (일정 표시 등 링크 불필요)
|
||||||
|
* - 값이 경로 문자열: 백엔드 path 대신 사용
|
||||||
|
*/
|
||||||
|
const STATUS_BOARD_PATH_OVERRIDE: Record<string, string> = {
|
||||||
|
tax_deadline: '/accounting/tax-invoices', // 백엔드 /accounting/tax → 실제 페이지
|
||||||
|
// purchases: '/accounting/purchase', // [2026-03-03] 비활성화 — purchases 항목 자체를 숨김 (N4 참조)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [2026-03-03] 비활성화 항목: 백엔드 이슈 해결 전까지 현황판에서 숨김
|
||||||
|
* - purchases: path 오류(건설경로 하드코딩) + 데이터 정합성 미확인 (API-SPEC N4 참조)
|
||||||
|
*/
|
||||||
|
const STATUS_BOARD_HIDDEN_ITEMS = new Set(['purchases']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StatusBoard API 응답 → Frontend 타입 변환
|
* StatusBoard API 응답 → Frontend 타입 변환
|
||||||
* API 응답 형식이 TodayIssueItem과 거의 동일하므로 단순 매핑
|
* API 응답 형식이 TodayIssueItem과 거의 동일하므로 단순 매핑
|
||||||
*/
|
*/
|
||||||
export function transformStatusBoardResponse(api: StatusBoardApiResponse): TodayIssueItem[] {
|
export function transformStatusBoardResponse(api: StatusBoardApiResponse): TodayIssueItem[] {
|
||||||
return api.items.map((item) => ({
|
return api.items.filter((item) => !STATUS_BOARD_HIDDEN_ITEMS.has(item.id)).map((item) => {
|
||||||
id: item.id,
|
const overridePath = STATUS_BOARD_PATH_OVERRIDE[item.id];
|
||||||
label: item.label,
|
const path = overridePath !== undefined
|
||||||
count: item.count,
|
? overridePath
|
||||||
subLabel: buildStatusSubLabel(item),
|
: normalizePath(item.path, { addViewMode: true });
|
||||||
path: normalizePath(item.path, { addViewMode: true }),
|
|
||||||
isHighlighted: item.isHighlighted,
|
return {
|
||||||
}));
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
count: item.count,
|
||||||
|
subLabel: buildStatusSubLabel(item),
|
||||||
|
path,
|
||||||
|
isHighlighted: item.isHighlighted,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
VatApiResponse,
|
VatApiResponse,
|
||||||
|
VatDetailApiResponse,
|
||||||
EntertainmentApiResponse,
|
EntertainmentApiResponse,
|
||||||
|
EntertainmentDetailApiResponse,
|
||||||
WelfareApiResponse,
|
WelfareApiResponse,
|
||||||
WelfareDetailApiResponse,
|
WelfareDetailApiResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -47,6 +49,90 @@ export function transformVatResponse(api: VatApiResponse): VatData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 부가세 상세 (VatDetail)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VatDetail API 응답 → DetailModalConfig 변환
|
||||||
|
* 부가세 상세 모달 설정 생성
|
||||||
|
*/
|
||||||
|
export function transformVatDetailResponse(api: VatDetailApiResponse): DetailModalConfig {
|
||||||
|
const { period_label, period_options, summary, reference_table, unissued_invoices } = api;
|
||||||
|
|
||||||
|
// 참조 테이블 행 구성: direction(invoice_type) 형식 + 납부세액 행
|
||||||
|
const refRows = reference_table.map(row => ({
|
||||||
|
category: `${row.direction_label}(${row.invoice_type_label})`,
|
||||||
|
supplyAmount: formatNumber(row.supply_amount) + '원',
|
||||||
|
taxAmount: formatNumber(row.tax_amount) + '원',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 납부세액 행 추가
|
||||||
|
const paymentLabel = summary.is_refund ? '환급세액' : '납부세액';
|
||||||
|
refRows.push({
|
||||||
|
category: paymentLabel,
|
||||||
|
supplyAmount: '',
|
||||||
|
taxAmount: formatNumber(summary.estimated_payment) + '원',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '예상 납부세액',
|
||||||
|
periodSelect: {
|
||||||
|
enabled: true,
|
||||||
|
options: period_options.map(opt => ({ value: opt.value, label: opt.label })),
|
||||||
|
defaultValue: period_options[0]?.value,
|
||||||
|
},
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '매출 공급가액', value: summary.sales_supply_amount, unit: '원' },
|
||||||
|
{ label: '매입 공급가액', value: summary.purchases_supply_amount, unit: '원' },
|
||||||
|
{ label: summary.is_refund ? '예상 환급세액' : '예상 납부세액', value: summary.estimated_payment, unit: '원' },
|
||||||
|
],
|
||||||
|
referenceTable: {
|
||||||
|
title: `${period_label} 부가세 요약`,
|
||||||
|
columns: [
|
||||||
|
{ key: 'category', label: '구분', align: 'left' },
|
||||||
|
{ key: 'supplyAmount', label: '공급가액', align: 'right' },
|
||||||
|
{ key: 'taxAmount', label: '세액', align: 'right' },
|
||||||
|
],
|
||||||
|
data: refRows,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
title: '세금계산서 미발행/미수취 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'type', label: '구분', align: 'center' },
|
||||||
|
{ key: 'issueDate', label: '발생일자', align: 'center', format: 'date' },
|
||||||
|
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||||
|
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'invoiceStatus', label: '세금계산서 미발행/미수취', align: 'center' },
|
||||||
|
],
|
||||||
|
data: unissued_invoices.map((inv, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
type: inv.direction_label,
|
||||||
|
issueDate: inv.issue_date,
|
||||||
|
vendor: inv.vendor_name,
|
||||||
|
vat: inv.tax_amount,
|
||||||
|
invoiceStatus: inv.status,
|
||||||
|
})),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: '매출', label: '매출' },
|
||||||
|
{ value: '매입', label: '매입' },
|
||||||
|
],
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: unissued_invoices.reduce((sum, inv) => sum + inv.tax_amount, 0),
|
||||||
|
totalColumnKey: 'vat',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 접대비 (Entertainment)
|
// 접대비 (Entertainment)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -56,17 +142,8 @@ export function transformVatResponse(api: VatApiResponse): VatData {
|
|||||||
* 접대비 현황 데이터 변환
|
* 접대비 현황 데이터 변환
|
||||||
*/
|
*/
|
||||||
export function transformEntertainmentResponse(api: EntertainmentApiResponse): EntertainmentData {
|
export function transformEntertainmentResponse(api: EntertainmentApiResponse): EntertainmentData {
|
||||||
// 사용금액(et_used)을 잔여한도(et_remaining) 앞으로 재배치
|
|
||||||
const reordered = [...api.cards];
|
|
||||||
const usedIdx = reordered.findIndex((c) => c.id === 'et_used');
|
|
||||||
const remainIdx = reordered.findIndex((c) => c.id === 'et_remaining');
|
|
||||||
if (usedIdx > remainIdx && remainIdx >= 0) {
|
|
||||||
const [used] = reordered.splice(usedIdx, 1);
|
|
||||||
reordered.splice(remainIdx, 0, used);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cards: reordered.map((card) => ({
|
cards: api.cards.map((card) => ({
|
||||||
id: card.id,
|
id: card.id,
|
||||||
label: card.label,
|
label: card.label,
|
||||||
amount: card.amount,
|
amount: card.amount,
|
||||||
@@ -94,17 +171,8 @@ export function transformEntertainmentResponse(api: EntertainmentApiResponse): E
|
|||||||
* 복리후생비 현황 데이터 변환
|
* 복리후생비 현황 데이터 변환
|
||||||
*/
|
*/
|
||||||
export function transformWelfareResponse(api: WelfareApiResponse): WelfareData {
|
export function transformWelfareResponse(api: WelfareApiResponse): WelfareData {
|
||||||
// 사용금액(wf_used)을 잔여한도(wf_remaining) 앞으로 재배치
|
|
||||||
const reordered = [...api.cards];
|
|
||||||
const usedIdx = reordered.findIndex((c) => c.id === 'wf_used');
|
|
||||||
const remainIdx = reordered.findIndex((c) => c.id === 'wf_remaining');
|
|
||||||
if (usedIdx > remainIdx && remainIdx >= 0) {
|
|
||||||
const [used] = reordered.splice(usedIdx, 1);
|
|
||||||
reordered.splice(remainIdx, 0, used);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cards: reordered.map((card) => ({
|
cards: api.cards.map((card) => ({
|
||||||
id: card.id,
|
id: card.id,
|
||||||
label: card.label,
|
label: card.label,
|
||||||
amount: card.amount,
|
amount: card.amount,
|
||||||
@@ -131,6 +199,183 @@ export function transformWelfareResponse(api: WelfareApiResponse): WelfareData {
|
|||||||
* WelfareDetail API 응답 → DetailModalConfig 변환
|
* WelfareDetail API 응답 → DetailModalConfig 변환
|
||||||
* 복리후생비 상세 모달 설정 생성
|
* 복리후생비 상세 모달 설정 생성
|
||||||
*/
|
*/
|
||||||
|
// ============================================
|
||||||
|
// 접대비 상세 (EntertainmentDetail)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EntertainmentDetail API 응답 → DetailModalConfig 변환
|
||||||
|
* 접대비 상세 모달 설정 생성
|
||||||
|
*/
|
||||||
|
export function transformEntertainmentDetailResponse(api: EntertainmentDetailApiResponse): DetailModalConfig {
|
||||||
|
const { summary, risk_review, monthly_usage, user_distribution, transactions, calculation, quarterly } = api;
|
||||||
|
|
||||||
|
// 법인 유형 라벨
|
||||||
|
const companyTypeLabel = calculation.company_type === 'large' ? '일반법인' : '중소기업';
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '접대비 상세',
|
||||||
|
dateFilter: {
|
||||||
|
enabled: true,
|
||||||
|
defaultPreset: '당월',
|
||||||
|
showSearch: true,
|
||||||
|
},
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '당해년도 접대비 총 한도', value: summary.annual_limit, unit: '원' },
|
||||||
|
{ label: '당해년도 접대비 잔여한도', value: summary.annual_remaining, unit: '원' },
|
||||||
|
{ label: '당해년도 접대비 사용금액', value: summary.annual_used, unit: '원' },
|
||||||
|
{ label: '당해년도 접대비 초과 금액', value: summary.annual_exceeded, unit: '원' },
|
||||||
|
],
|
||||||
|
reviewCards: {
|
||||||
|
title: '접대비 검토 필요',
|
||||||
|
cards: risk_review.map(r => ({
|
||||||
|
label: r.label,
|
||||||
|
amount: r.amount,
|
||||||
|
subLabel: r.label === '기피업종'
|
||||||
|
? (r.count > 0 ? `불인정 ${r.count}건` : '0건')
|
||||||
|
: (r.count > 0 ? `미증빙 ${r.count}건` : '0건'),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
barChart: {
|
||||||
|
title: '월별 접대비 사용 추이',
|
||||||
|
data: monthly_usage.map(item => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.amount,
|
||||||
|
})),
|
||||||
|
dataKey: 'value',
|
||||||
|
xAxisKey: 'name',
|
||||||
|
color: '#60A5FA',
|
||||||
|
},
|
||||||
|
pieChart: {
|
||||||
|
title: '사용자별 접대비 사용 비율',
|
||||||
|
data: user_distribution.map(item => ({
|
||||||
|
name: item.user_name,
|
||||||
|
value: item.amount,
|
||||||
|
percentage: item.percentage,
|
||||||
|
color: item.color,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
title: '월별 접대비 사용 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||||
|
{ key: 'user', label: '사용자', align: 'center' },
|
||||||
|
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
|
||||||
|
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||||
|
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'riskType', label: '리스크', align: 'center' },
|
||||||
|
],
|
||||||
|
data: transactions.map((tx, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
cardName: tx.card_name,
|
||||||
|
user: tx.user_name,
|
||||||
|
useDate: tx.expense_date,
|
||||||
|
store: tx.vendor_name,
|
||||||
|
amount: tx.amount,
|
||||||
|
riskType: tx.risk_type,
|
||||||
|
})),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'riskType',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: '주말/심야', label: '주말/심야' },
|
||||||
|
{ value: '기피업종', label: '기피업종' },
|
||||||
|
{ value: '고액 결제', label: '고액 결제' },
|
||||||
|
{ value: '증빙 미비', label: '증빙 미비' },
|
||||||
|
{ value: '정상', label: '정상' },
|
||||||
|
],
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: transactions.reduce((sum, tx) => sum + tx.amount, 0),
|
||||||
|
totalColumnKey: 'amount',
|
||||||
|
},
|
||||||
|
referenceTables: [
|
||||||
|
{
|
||||||
|
title: '접대비 손금한도 계산 - 기본한도',
|
||||||
|
columns: [
|
||||||
|
{ key: 'type', label: '법인 유형', align: 'left' },
|
||||||
|
{ key: 'annualLimit', label: '연간 기본한도', align: 'right' },
|
||||||
|
{ key: 'monthlyLimit', label: '월 환산', align: 'right' },
|
||||||
|
],
|
||||||
|
data: [
|
||||||
|
{ type: '일반법인', annualLimit: '12,000,000원', monthlyLimit: '1,000,000원' },
|
||||||
|
{ type: '중소기업', annualLimit: '36,000,000원', monthlyLimit: '3,000,000원' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '수입금액별 추가한도',
|
||||||
|
columns: [
|
||||||
|
{ key: 'range', label: '수입금액 구간', align: 'left' },
|
||||||
|
{ key: 'formula', label: '추가한도 계산식', align: 'left' },
|
||||||
|
],
|
||||||
|
data: [
|
||||||
|
{ range: '100억원 이하', formula: '수입금액 × 0.2%' },
|
||||||
|
{ range: '100억 초과 ~ 500억 이하', formula: '2,000만원 + (수입금액 - 100억) × 0.1%' },
|
||||||
|
{ range: '500억원 초과', formula: '6,000만원 + (수입금액 - 500억) × 0.03%' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
calculationCards: {
|
||||||
|
title: '접대비 계산',
|
||||||
|
cards: [
|
||||||
|
{ label: `${companyTypeLabel} 연간 기본한도`, value: calculation.base_limit },
|
||||||
|
{ label: '당해년도 수입금액별 추가한도', value: calculation.revenue_additional, operator: '+' as const },
|
||||||
|
{ label: '당해년도 접대비 총 한도', value: calculation.annual_limit, operator: '=' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
quarterlyTable: {
|
||||||
|
title: '접대비 현황',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
label: '한도금액',
|
||||||
|
q1: quarterly[0]?.limit ?? 0,
|
||||||
|
q2: quarterly[1]?.limit ?? 0,
|
||||||
|
q3: quarterly[2]?.limit ?? 0,
|
||||||
|
q4: quarterly[3]?.limit ?? 0,
|
||||||
|
total: quarterly.reduce((sum, q) => sum + (q.limit ?? 0), 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '이월금액',
|
||||||
|
q1: quarterly[0]?.carryover ?? 0,
|
||||||
|
q2: quarterly[1]?.carryover ?? '',
|
||||||
|
q3: quarterly[2]?.carryover ?? '',
|
||||||
|
q4: quarterly[3]?.carryover ?? '',
|
||||||
|
total: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '사용금액',
|
||||||
|
q1: quarterly[0]?.used ?? '',
|
||||||
|
q2: quarterly[1]?.used ?? '',
|
||||||
|
q3: quarterly[2]?.used ?? '',
|
||||||
|
q4: quarterly[3]?.used ?? '',
|
||||||
|
total: quarterly.reduce((sum, q) => sum + (q.used ?? 0), 0) || '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '잔여한도',
|
||||||
|
q1: quarterly[0]?.remaining ?? '',
|
||||||
|
q2: quarterly[1]?.remaining ?? '',
|
||||||
|
q3: quarterly[2]?.remaining ?? '',
|
||||||
|
q4: quarterly[3]?.remaining ?? '',
|
||||||
|
total: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '초과금액',
|
||||||
|
q1: quarterly[0]?.exceeded ?? '',
|
||||||
|
q2: quarterly[1]?.exceeded ?? '',
|
||||||
|
q3: quarterly[2]?.exceeded ?? '',
|
||||||
|
q4: quarterly[3]?.exceeded ?? '',
|
||||||
|
total: quarterly.reduce((sum, q) => sum + (q.exceeded ?? 0), 0) || '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): DetailModalConfig {
|
export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): DetailModalConfig {
|
||||||
const { summary, monthly_usage, category_distribution, transactions, calculation, quarterly } = api;
|
const { summary, monthly_usage, category_distribution, transactions, calculation, quarterly } = api;
|
||||||
|
|
||||||
|
|||||||
@@ -40,23 +40,59 @@ export interface DailyReportApiResponse {
|
|||||||
monthly_operating_expense: number; // 월 운영비 (직전 3개월 평균)
|
monthly_operating_expense: number; // 월 운영비 (직전 3개월 평균)
|
||||||
operating_months: number | null; // 운영 가능 개월 수
|
operating_months: number | null; // 운영 가능 개월 수
|
||||||
operating_stability: OperatingStability; // 안정성 상태
|
operating_stability: OperatingStability; // 안정성 상태
|
||||||
// 어제 대비 변동률 (optional - 백엔드에서 제공 시)
|
// 기획서 D1.7 자금현황 카드용 필드
|
||||||
|
receivable_balance: number; // 미수금 잔액
|
||||||
|
payable_balance: number; // 미지급금 잔액
|
||||||
|
monthly_expense_total: number; // 당월 예상 지출 합계
|
||||||
|
// 어제 대비 변동률 (optional - 백엔드에서 제공 시, 현재 주석 처리)
|
||||||
daily_change?: DailyChangeRate;
|
daily_change?: DailyChangeRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 2. Receivables API 응답 타입
|
// 2. Receivables API 응답 타입 (D1.7 cards + check_points 구조)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
/** 미수금 카드 서브 아이템 */
|
||||||
|
export interface ReceivablesCardSubItem {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미수금 Top 거래처 아이템 */
|
||||||
|
export interface ReceivablesTopItem {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미수금 금액 카드 아이템 */
|
||||||
|
export interface ReceivablesAmountCardApiResponse {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
subLabel?: string;
|
||||||
|
unit?: string;
|
||||||
|
sub_items?: ReceivablesCardSubItem[];
|
||||||
|
top_items?: ReceivablesTopItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미수금 체크포인트 하이라이트 아이템 */
|
||||||
|
export interface ReceivablesHighlightItemApiResponse {
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미수금 체크포인트 아이템 */
|
||||||
|
export interface ReceivablesCheckPointApiResponse {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
highlights?: ReceivablesHighlightItemApiResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/proxy/receivables/summary 응답 */
|
/** GET /api/proxy/receivables/summary 응답 */
|
||||||
export interface ReceivablesApiResponse {
|
export interface ReceivablesApiResponse {
|
||||||
total_carry_forward: number; // 이월 미수금
|
cards: ReceivablesAmountCardApiResponse[];
|
||||||
total_sales: number; // 당월 매출
|
check_points: ReceivablesCheckPointApiResponse[];
|
||||||
total_deposits: number; // 당월 입금
|
|
||||||
total_bills: number; // 당월 어음
|
|
||||||
total_receivables: number; // 미수금 잔액
|
|
||||||
vendor_count: number; // 거래처 수
|
|
||||||
overdue_vendor_count: number; // 연체 거래처 수
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -276,6 +312,56 @@ export interface VatApiResponse {
|
|||||||
check_points: VatCheckPointApiResponse[];
|
check_points: VatCheckPointApiResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 9-1. Vat Detail (부가세 상세) API 응답 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 부가세 상세 요약 */
|
||||||
|
export interface VatDetailSummaryApiResponse {
|
||||||
|
sales_supply_amount: number; // 매출 공급가액
|
||||||
|
sales_tax_amount: number; // 매출 세액
|
||||||
|
purchases_supply_amount: number; // 매입 공급가액
|
||||||
|
purchases_tax_amount: number; // 매입 세액
|
||||||
|
estimated_payment: number; // 예상 납부세액
|
||||||
|
is_refund: boolean; // 환급 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 부가세 요약 테이블 행 */
|
||||||
|
export interface VatReferenceTableRowApiResponse {
|
||||||
|
direction: string; // sales | purchases
|
||||||
|
direction_label: string; // 매출 | 매입
|
||||||
|
invoice_type: string; // tax_invoice | invoice | modified
|
||||||
|
invoice_type_label: string; // 전자세금계산서 | 계산서 | 수정세금계산서
|
||||||
|
supply_amount: number; // 공급가액
|
||||||
|
tax_amount: number; // 세액
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미발행/미수취 세금계산서 */
|
||||||
|
export interface VatUnissuedInvoiceApiResponse {
|
||||||
|
id: number;
|
||||||
|
direction: string;
|
||||||
|
direction_label: string; // 매출 | 매입
|
||||||
|
issue_date: string; // 발생일자
|
||||||
|
vendor_name: string; // 거래처명
|
||||||
|
tax_amount: number; // 부가세
|
||||||
|
status: string; // 미발행 | 미수취
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 신고기간 옵션 */
|
||||||
|
export interface VatPeriodOptionApiResponse {
|
||||||
|
value: string; // "2026-quarter-1"
|
||||||
|
label: string; // "2026년 1기 예정신고"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/proxy/vat/detail 응답 */
|
||||||
|
export interface VatDetailApiResponse {
|
||||||
|
period_label: string;
|
||||||
|
period_options: VatPeriodOptionApiResponse[];
|
||||||
|
summary: VatDetailSummaryApiResponse;
|
||||||
|
reference_table: VatReferenceTableRowApiResponse[];
|
||||||
|
unissued_invoices: VatUnissuedInvoiceApiResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 10. Entertainment (접대비) API 응답 타입
|
// 10. Entertainment (접대비) API 응답 타입
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -342,6 +428,81 @@ export interface WelfareApiResponse {
|
|||||||
check_points: WelfareCheckPointApiResponse[];
|
check_points: WelfareCheckPointApiResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 11-1. Entertainment Detail (접대비 상세) API 응답 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 접대비 상세 요약 */
|
||||||
|
export interface EntertainmentDetailSummaryApiResponse {
|
||||||
|
annual_limit: number; // 당해년도 접대비 총 한도
|
||||||
|
annual_remaining: number; // 당해년도 접대비 잔여한도
|
||||||
|
annual_used: number; // 당해년도 접대비 사용금액
|
||||||
|
annual_exceeded: number; // 당해년도 접대비 초과 금액
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 접대비 리스크 검토 카드 */
|
||||||
|
export interface EntertainmentRiskReviewApiResponse {
|
||||||
|
label: string; // 주말/심야, 기피업종, 고액 결제, 증빙 미비
|
||||||
|
amount: number; // 금액
|
||||||
|
count: number; // 건수
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 접대비 월별 사용 추이 */
|
||||||
|
export interface EntertainmentMonthlyUsageApiResponse {
|
||||||
|
month: number; // 1~12
|
||||||
|
label: string; // "1월"
|
||||||
|
amount: number; // 사용 금액
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 접대비 사용자별 분포 */
|
||||||
|
export interface EntertainmentUserDistributionApiResponse {
|
||||||
|
user_name: string; // 사용자명
|
||||||
|
amount: number; // 금액
|
||||||
|
percentage: number; // 비율 (%)
|
||||||
|
color: string; // 차트 색상
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 접대비 거래 내역 */
|
||||||
|
export interface EntertainmentTransactionApiResponse {
|
||||||
|
id: number;
|
||||||
|
card_name: string; // 카드명
|
||||||
|
user_name: string; // 사용자명
|
||||||
|
expense_date: string; // 사용일자
|
||||||
|
vendor_name: string; // 가맹점명
|
||||||
|
amount: number; // 사용금액
|
||||||
|
risk_type: string; // 리스크 유형 (주말/심야, 기피업종, 고액 결제, 증빙 미비, 정상)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 접대비 손금한도 계산 정보 */
|
||||||
|
export interface EntertainmentCalculationApiResponse {
|
||||||
|
company_type: string; // 법인 유형 (large|medium|small)
|
||||||
|
base_limit: number; // 기본한도
|
||||||
|
revenue: number; // 수입금액
|
||||||
|
revenue_additional: number; // 수입금액별 추가한도
|
||||||
|
annual_limit: number; // 연간 총 한도
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 접대비 분기별 현황 */
|
||||||
|
export interface EntertainmentQuarterlyStatusApiResponse {
|
||||||
|
quarter: number; // 분기 (1-4)
|
||||||
|
limit: number; // 한도금액
|
||||||
|
carryover: number; // 이월금액
|
||||||
|
used: number; // 사용금액
|
||||||
|
remaining: number; // 잔여한도
|
||||||
|
exceeded: number; // 초과금액
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/proxy/entertainment/detail 응답 */
|
||||||
|
export interface EntertainmentDetailApiResponse {
|
||||||
|
summary: EntertainmentDetailSummaryApiResponse;
|
||||||
|
risk_review: EntertainmentRiskReviewApiResponse[];
|
||||||
|
monthly_usage: EntertainmentMonthlyUsageApiResponse[];
|
||||||
|
user_distribution: EntertainmentUserDistributionApiResponse[];
|
||||||
|
transactions: EntertainmentTransactionApiResponse[];
|
||||||
|
calculation: EntertainmentCalculationApiResponse;
|
||||||
|
quarterly: EntertainmentQuarterlyStatusApiResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 12. Welfare Detail (복리후생비 상세) API 응답 타입
|
// 12. Welfare Detail (복리후생비 상세) API 응답 타입
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -613,9 +774,8 @@ export interface ExpectedExpenseDashboardDetailApiResponse {
|
|||||||
/** 가지급금 대시보드 요약 */
|
/** 가지급금 대시보드 요약 */
|
||||||
export interface LoanDashboardSummaryApiResponse {
|
export interface LoanDashboardSummaryApiResponse {
|
||||||
total_outstanding: number; // 미정산 잔액
|
total_outstanding: number; // 미정산 잔액
|
||||||
settled_amount: number; // 정산 완료 금액
|
|
||||||
recognized_interest: number; // 인정이자
|
recognized_interest: number; // 인정이자
|
||||||
pending_count: number; // 미정산 건수
|
outstanding_count: number; // 미정산 건수
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 가지급금 월별 추이 */
|
/** 가지급금 월별 추이 */
|
||||||
@@ -640,17 +800,24 @@ export interface LoanItemApiResponse {
|
|||||||
user_name: string; // 사용자명
|
user_name: string; // 사용자명
|
||||||
loan_date: string; // 가지급일
|
loan_date: string; // 가지급일
|
||||||
amount: number; // 금액
|
amount: number; // 금액
|
||||||
description: string; // 설명
|
content: string; // 내용 (백엔드 필드명)
|
||||||
|
category: string; // 카테고리 라벨 (카드/경조사/상품권/접대비)
|
||||||
status: string; // 상태 코드
|
status: string; // 상태 코드
|
||||||
status_label: string; // 상태 라벨
|
status_label?: string; // 상태 라벨 (optional - dashboard()에서 미반환 가능)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 가지급금 카테고리별 집계 (D1.7) */
|
||||||
|
export interface LoanCategoryBreakdown {
|
||||||
|
outstanding_amount: number;
|
||||||
|
total_count: number;
|
||||||
|
unverified_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/v1/loans/dashboard 응답 */
|
/** GET /api/v1/loans/dashboard 응답 */
|
||||||
export interface LoanDashboardApiResponse {
|
export interface LoanDashboardApiResponse {
|
||||||
summary: LoanDashboardSummaryApiResponse;
|
summary: LoanDashboardSummaryApiResponse;
|
||||||
monthly_trend: LoanMonthlyTrendApiResponse[];
|
category_breakdown?: Record<string, LoanCategoryBreakdown>;
|
||||||
user_distribution: LoanUserDistributionApiResponse[];
|
loans: LoanItemApiResponse[];
|
||||||
items: LoanItemApiResponse[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -706,4 +873,198 @@ export interface TaxSimulationApiResponse {
|
|||||||
loan_summary: TaxSimulationLoanSummaryApiResponse; // 가지급금 요약
|
loan_summary: TaxSimulationLoanSummaryApiResponse; // 가지급금 요약
|
||||||
corporate_tax: CorporateTaxComparisonApiResponse; // 법인세 비교
|
corporate_tax: CorporateTaxComparisonApiResponse; // 법인세 비교
|
||||||
income_tax: IncomeTaxComparisonApiResponse; // 소득세 비교
|
income_tax: IncomeTaxComparisonApiResponse; // 소득세 비교
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 19. SalesStatus (매출 현황) API 응답 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 매출 월별 추이 */
|
||||||
|
export interface SalesMonthlyTrendApiResponse {
|
||||||
|
month: string; // "2026-08"
|
||||||
|
label: string; // "8월"
|
||||||
|
amount: number; // 금액
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 거래처별 매출 */
|
||||||
|
export interface SalesClientApiResponse {
|
||||||
|
name: string; // 거래처명
|
||||||
|
amount: number; // 금액
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 일별 매출 아이템 */
|
||||||
|
export interface SalesDailyItemApiResponse {
|
||||||
|
date: string; // "2026-02-01"
|
||||||
|
client: string; // 거래처명
|
||||||
|
item: string; // 품목명
|
||||||
|
amount: number; // 금액
|
||||||
|
status: 'deposited' | 'unpaid' | 'partial'; // 입금상태
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/dashboard/sales/summary 응답 */
|
||||||
|
export interface SalesStatusApiResponse {
|
||||||
|
cumulative_sales: number; // 누적 매출
|
||||||
|
achievement_rate: number; // 달성률 (%)
|
||||||
|
yoy_change: number; // 전년 동월 대비 변화율 (%)
|
||||||
|
monthly_sales: number; // 당월 매출
|
||||||
|
monthly_trend: SalesMonthlyTrendApiResponse[];
|
||||||
|
client_sales: SalesClientApiResponse[];
|
||||||
|
daily_items: SalesDailyItemApiResponse[];
|
||||||
|
daily_total: number; // 일별 합계
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 20. PurchaseStatus (매입 현황) API 응답 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 매입 월별 추이 */
|
||||||
|
export interface PurchaseMonthlyTrendDashboardApiResponse {
|
||||||
|
month: string; // "2026-08"
|
||||||
|
label: string; // "8월"
|
||||||
|
amount: number; // 금액
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 자재 구성 비율 */
|
||||||
|
export interface PurchaseMaterialRatioApiResponse {
|
||||||
|
name: string; // "원자재", "부자재", "소모품"
|
||||||
|
value: number; // 금액
|
||||||
|
percentage: number; // 비율 (%)
|
||||||
|
color: string; // 차트 색상
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 일별 매입 아이템 */
|
||||||
|
export interface PurchaseDailyItemApiResponse {
|
||||||
|
date: string; // "2026-02-01"
|
||||||
|
supplier: string; // 거래처명
|
||||||
|
item: string; // 품목명
|
||||||
|
amount: number; // 금액
|
||||||
|
status: 'paid' | 'unpaid' | 'partial'; // 결제상태
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/dashboard/purchases/summary 응답 */
|
||||||
|
export interface PurchaseStatusApiResponse {
|
||||||
|
cumulative_purchase: number; // 누적 매입
|
||||||
|
unpaid_amount: number; // 미결제 금액
|
||||||
|
yoy_change: number; // 전년 동월 대비 변화율 (%)
|
||||||
|
monthly_trend: PurchaseMonthlyTrendDashboardApiResponse[];
|
||||||
|
material_ratio: PurchaseMaterialRatioApiResponse[];
|
||||||
|
daily_items: PurchaseDailyItemApiResponse[];
|
||||||
|
daily_total: number; // 일별 합계
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 21. DailyProduction (생산 현황) API 응답 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 공정별 작업 아이템 */
|
||||||
|
export interface ProductionWorkItemApiResponse {
|
||||||
|
id: string;
|
||||||
|
order_no: string; // 수주번호
|
||||||
|
client: string; // 거래처명
|
||||||
|
product: string; // 제품명
|
||||||
|
quantity: number; // 수량
|
||||||
|
status: 'in_progress' | 'pending' | 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공정별 작업자 */
|
||||||
|
export interface ProductionWorkerApiResponse {
|
||||||
|
name: string;
|
||||||
|
assigned: number; // 배정 건수
|
||||||
|
completed: number; // 완료 건수
|
||||||
|
rate: number; // 완료율 (%)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공정별 데이터 */
|
||||||
|
export interface ProductionProcessApiResponse {
|
||||||
|
process_name: string; // "스크린", "슬랫", "절곡"
|
||||||
|
total_work: number;
|
||||||
|
todo: number;
|
||||||
|
in_progress: number;
|
||||||
|
completed: number;
|
||||||
|
urgent: number; // 긴급 건수
|
||||||
|
sub_line: number;
|
||||||
|
regular: number;
|
||||||
|
worker_count: number;
|
||||||
|
work_items: ProductionWorkItemApiResponse[];
|
||||||
|
workers: ProductionWorkerApiResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 출고 현황 */
|
||||||
|
export interface ShipmentApiResponse {
|
||||||
|
expected_amount: number;
|
||||||
|
expected_count: number;
|
||||||
|
actual_amount: number;
|
||||||
|
actual_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/dashboard/production/summary 응답 */
|
||||||
|
export interface DailyProductionApiResponse {
|
||||||
|
date: string; // "2026-02-23"
|
||||||
|
day_of_week: string; // "월요일"
|
||||||
|
processes: ProductionProcessApiResponse[];
|
||||||
|
shipment: ShipmentApiResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 22. Unshipped (미출고 내역) API 응답 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 미출고 아이템 */
|
||||||
|
export interface UnshippedItemApiResponse {
|
||||||
|
id: string;
|
||||||
|
port_no: string; // 로트번호
|
||||||
|
site_name: string; // 현장명
|
||||||
|
order_client: string; // 수주처
|
||||||
|
due_date: string; // 납기일
|
||||||
|
days_left: number; // 잔여일수
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/dashboard/unshipped/summary 응답 */
|
||||||
|
export interface UnshippedApiResponse {
|
||||||
|
items: UnshippedItemApiResponse[];
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 23. Construction (시공 현황) API 응답 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 시공 아이템 */
|
||||||
|
export interface ConstructionItemApiResponse {
|
||||||
|
id: string;
|
||||||
|
site_name: string; // 현장명
|
||||||
|
client: string; // 거래처명
|
||||||
|
start_date: string; // 시작일
|
||||||
|
end_date: string; // 종료일
|
||||||
|
progress: number; // 진행률 (%)
|
||||||
|
status: 'in_progress' | 'scheduled' | 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/dashboard/construction/summary 응답 */
|
||||||
|
export interface ConstructionApiResponse {
|
||||||
|
this_month: number; // 이번 달 시공 건수
|
||||||
|
completed: number; // 완료 건수
|
||||||
|
items: ConstructionItemApiResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 24. DailyAttendance (근태 현황) API 응답 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 직원 근태 아이템 */
|
||||||
|
export interface AttendanceEmployeeApiResponse {
|
||||||
|
id: string;
|
||||||
|
department: string; // 부서명
|
||||||
|
position: string; // 직급
|
||||||
|
name: string; // 이름
|
||||||
|
status: 'present' | 'on_leave' | 'late' | 'absent';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/dashboard/attendance/summary 응답 */
|
||||||
|
export interface DailyAttendanceApiResponse {
|
||||||
|
present: number; // 출근
|
||||||
|
on_leave: number; // 휴가
|
||||||
|
late: number; // 지각
|
||||||
|
absent: number; // 결근
|
||||||
|
employees: AttendanceEmployeeApiResponse[];
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user