Files
sam-react-prod/src/components/business/CEODashboard/CEODashboard.tsx
유병철 8f4a7ee842 refactor(WEB): CEO 대시보드 대규모 개선 및 문서/권한/스토어 리팩토링
- CEO 대시보드: 섹션별 API 연동 강화 (매출/매입/생산 실데이터 표시)
- DashboardSettingsDialog 드래그 정렬 및 설정 UX 개선
- dashboard transformers 모듈 분리 (파일 분할)
- DocumentTable/DocumentWrapper 공통 문서 컴포넌트 추출
- LineItemsTable organisms 컴포넌트 추가
- PurchaseOrderDocument/InspectionRequestDocument 문서 컴포넌트 리팩토링
- PermissionContext → permissionStore(Zustand) 전환
- useUIStore, stores/utils/userStorage 추가
- favoritesStore/useTableColumnStore 사용자별 저장 지원
- DepositDetail/WithdrawalDetail 삭제 (통합)
- PurchaseDetail/SalesDetail 간소화
- amount.ts/formatters.ts 유틸 확장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:59:25 +09:00

554 lines
19 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { LayoutDashboard, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CEODashboardSkeleton } from './skeletons';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
import {
TodayIssueSection,
EnhancedStatusBoardSection,
EnhancedDailyReportSection,
EnhancedMonthlyExpenseSection,
CardManagementSection,
EntertainmentSection,
WelfareSection,
ReceivableSection,
DebtCollectionSection,
VatSection,
CalendarSection,
SalesStatusSection,
PurchaseStatusSection,
DailyProductionSection,
ShipmentSection,
UnshippedSection,
ConstructionSection,
DailyAttendanceSection,
} from './sections';
import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig, SectionKey } from './types';
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } from './types';
import { ScheduleDetailModal, DetailModal } from './modals';
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
import { mockData } from './mockData';
import { LazySection } from './LazySection';
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail } from '@/hooks/useCEODashboard';
import { useCardManagementModals, type CardManagementCardId } from '@/hooks/useCardManagementModals';
import type { MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
import {
getMonthlyExpenseModalConfig,
getCardManagementModalConfig,
getCardManagementModalConfigWithData,
getEntertainmentModalConfig,
getWelfareModalConfig,
getVatModalConfig,
} from './modalConfigs';
export function CEODashboard() {
const router = useRouter();
// API 데이터 Hook (Phase 1 섹션들)
const apiData = useCEODashboard({
cardManagementFallback: mockData.cardManagement,
});
// TodayIssue API Hook (Phase 2)
const todayIssueData = useTodayIssue(30);
// Calendar API Hook (Phase 2)
const calendarData = useCalendar();
// Vat API Hook (Phase 2)
const vatData = useVat();
// Entertainment API Hook (Phase 2)
const entertainmentData = useEntertainment();
// Welfare API Hook (Phase 2)
const welfareData = useWelfare();
// Card Management Modal API Hook (Phase 3)
const cardManagementModals = useCardManagementModals();
// 전체 로딩 상태 (하나라도 로딩 중이면 스켈레톤 표시)
const isLoading = useMemo(() => {
return (
apiData.dailyReport.loading ||
apiData.receivable.loading ||
apiData.debtCollection.loading ||
apiData.monthlyExpense.loading ||
apiData.cardManagement.loading ||
apiData.statusBoard.loading ||
todayIssueData.loading ||
calendarData.loading ||
vatData.loading ||
entertainmentData.loading ||
welfareData.loading
);
}, [apiData, todayIssueData.loading, calendarData.loading, vatData.loading, entertainmentData.loading, welfareData.loading]);
// API 데이터와 mockData를 병합 (API 우선, 실패 시 fallback)
const data = useMemo<CEODashboardData>(() => ({
...mockData,
// Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback
// TODO: 자금현황 카드 변경 (일일일보/매출채권/매입채무/운영자금) - 새 API 구현 후 교체
dailyReport: mockData.dailyReport,
receivable: apiData.receivable.data ?? mockData.receivable,
debtCollection: apiData.debtCollection.data ?? mockData.debtCollection,
monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense,
cardManagement: apiData.cardManagement.data ?? mockData.cardManagement,
// Phase 2 섹션들 (API 연동 완료 - 목업 fallback 제거)
todayIssue: apiData.statusBoard.data ?? [],
todayIssueList: todayIssueData.data?.items ?? [],
calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules,
vat: vatData.data ?? mockData.vat,
entertainment: entertainmentData.data ?? mockData.entertainment,
welfare: welfareData.data ?? mockData.welfare,
// 신규 섹션 (API 미구현 - mock 데이터)
salesStatus: mockData.salesStatus,
purchaseStatus: mockData.purchaseStatus,
dailyProduction: mockData.dailyProduction,
unshipped: mockData.unshipped,
dailyAttendance: mockData.dailyAttendance,
}), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data, mockData]);
// 일정 상세 모달 상태
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<CalendarScheduleItem | null>(null);
// 항목 설정 모달 상태
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>(DEFAULT_DASHBOARD_SETTINGS);
// WelfareDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
const welfareDetailData = useWelfareDetail({
calculationType: dashboardSettings.welfare.calculationType,
});
// MonthlyExpenseDetail Hook (당월 예상 지출 모달용 상세 API)
const monthlyExpenseDetailData = useMonthlyExpenseDetail();
// 상세 모달 상태
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [detailModalConfig, setDetailModalConfig] = useState<DetailModalConfig | null>(null);
// 클라이언트에서만 localStorage에서 설정 불러오기 (hydration 에러 방지)
useEffect(() => {
const saved = localStorage.getItem('ceo-dashboard-settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
// 구버전 설정 마이그레이션: object → boolean 변환
if (typeof parsed.receivable === 'object' && parsed.receivable !== null) {
parsed.receivable = parsed.receivable.enabled ?? true;
}
if (typeof parsed.salesStatus === 'object' && parsed.salesStatus !== null) {
parsed.salesStatus = parsed.salesStatus.enabled ?? true;
}
if (typeof parsed.purchaseStatus === 'object' && parsed.purchaseStatus !== null) {
parsed.purchaseStatus = parsed.purchaseStatus.enabled ?? true;
}
// sectionOrder 마이그레이션
if (!parsed.sectionOrder) {
parsed.sectionOrder = DEFAULT_SECTION_ORDER;
} else {
const currentOrder: SectionKey[] = parsed.sectionOrder;
// 새 섹션이 추가됐으면 끝에 추가
for (const key of DEFAULT_SECTION_ORDER) {
if (!currentOrder.includes(key)) {
currentOrder.push(key);
}
}
// 삭제된 섹션 필터링
parsed.sectionOrder = currentOrder.filter((key: SectionKey) =>
DEFAULT_SECTION_ORDER.includes(key)
);
}
setDashboardSettings(parsed);
} catch {
// 파싱 실패 시 기본값 유지
}
}
}, []);
// 항목 설정 클릭
const handleSettingClick = useCallback(() => {
setIsSettingsModalOpen(true);
}, []);
// 항목 설정 저장
const handleSettingsSave = useCallback((settings: DashboardSettings) => {
setDashboardSettings(settings);
// localStorage에 설정 저장
if (typeof window !== 'undefined') {
localStorage.setItem('ceo-dashboard-settings', JSON.stringify(settings));
}
}, []);
// 항목 설정 모달 닫기
const handleSettingsModalClose = useCallback(() => {
setIsSettingsModalOpen(false);
}, []);
// 일일 일보 클릭 → 일일 일보 페이지로 이동
const handleDailyReportClick = useCallback(() => {
router.push('/ko/accounting/daily-report');
}, [router]);
// 상세 모달 닫기
const handleDetailModalClose = useCallback(() => {
setIsDetailModalOpen(false);
setDetailModalConfig(null);
}, []);
// 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달)
const handleMonthlyExpenseCardClick = useCallback(async (cardId: string) => {
// 1. 먼저 API에서 데이터 fetch 시도
const apiConfig = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId);
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
const config = apiConfig ?? getMonthlyExpenseModalConfig(cardId);
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}, [monthlyExpenseDetailData]);
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
const handleMonthlyExpenseClick = useCallback(() => {
}, []);
// 카드/가지급금 관리 카드 클릭 (개별 카드 클릭 시 상세 모달)
const handleCardManagementCardClick = useCallback(async (cardId: string) => {
// 1. API에서 데이터 fetch (데이터 직접 반환)
const modalData = await cardManagementModals.fetchModalData(cardId as CardManagementCardId);
// 2. API 데이터로 config 생성 (데이터 없으면 fallback)
const config = getCardManagementModalConfigWithData(cardId, modalData);
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}, [cardManagementModals]);
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
const handleEntertainmentCardClick = useCallback((cardId: string) => {
const config = getEntertainmentModalConfig(cardId);
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}, []);
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
// 복리후생비 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
const handleWelfareCardClick = useCallback(async () => {
// 1. 먼저 API에서 데이터 fetch 시도
await welfareDetailData.refetch();
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
const config = welfareDetailData.modalConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType);
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}, [welfareDetailData, dashboardSettings.welfare.calculationType]);
// 부가세 클릭 (모든 카드가 동일한 상세 모달)
const handleVatClick = useCallback(() => {
const config = getVatModalConfig();
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}, []);
// 캘린더 일정 클릭 (기존 일정 수정)
const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => {
setSelectedSchedule(schedule);
setIsScheduleModalOpen(true);
}, []);
// 캘린더 일정 등록 (새 일정)
const handleScheduleEdit = useCallback((schedule: CalendarScheduleItem) => {
setSelectedSchedule(schedule);
setIsScheduleModalOpen(true);
}, []);
// 일정 모달 닫기
const handleScheduleModalClose = useCallback(() => {
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}, []);
// 일정 저장
const handleScheduleSave = useCallback((formData: {
title: string;
department: string;
startDate: string;
endDate: string;
isAllDay: boolean;
startTime: string;
endTime: string;
color: string;
content: string;
}) => {
// TODO: API 호출하여 일정 저장
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}, []);
// 일정 삭제
const handleScheduleDelete = useCallback((id: string) => {
// TODO: API 호출하여 일정 삭제
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}, []);
// 섹션 순서
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
// 섹션 렌더링 함수
const renderDashboardSection = (key: SectionKey): React.ReactNode => {
switch (key) {
case 'todayIssueList':
if (!dashboardSettings.todayIssueList) return null;
return (
<LazySection key={key}>
<TodayIssueSection items={data.todayIssueList} />
</LazySection>
);
case 'dailyReport':
if (!dashboardSettings.dailyReport) return null;
return (
<LazySection key={key}>
<EnhancedDailyReportSection
data={data.dailyReport}
onClick={handleDailyReportClick}
/>
</LazySection>
);
case 'statusBoard':
if (!(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled)) return null;
return (
<LazySection key={key}>
<EnhancedStatusBoardSection
items={data.todayIssue}
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
/>
</LazySection>
);
case 'monthlyExpense':
if (!dashboardSettings.monthlyExpense) return null;
return (
<LazySection key={key}>
<EnhancedMonthlyExpenseSection
data={data.monthlyExpense}
onCardClick={handleMonthlyExpenseCardClick}
/>
</LazySection>
);
case 'cardManagement':
if (!dashboardSettings.cardManagement) return null;
return (
<LazySection key={key}>
<CardManagementSection
data={data.cardManagement}
onCardClick={handleCardManagementCardClick}
/>
</LazySection>
);
case 'entertainment':
if (!dashboardSettings.entertainment.enabled) return null;
return (
<LazySection key={key}>
<EntertainmentSection
data={data.entertainment}
onCardClick={handleEntertainmentCardClick}
/>
</LazySection>
);
case 'welfare':
if (!dashboardSettings.welfare.enabled) return null;
return (
<LazySection key={key}>
<WelfareSection
data={data.welfare}
onCardClick={handleWelfareCardClick}
/>
</LazySection>
);
case 'receivable':
if (!dashboardSettings.receivable) return null;
return (
<LazySection key={key}>
<ReceivableSection data={data.receivable} />
</LazySection>
);
case 'debtCollection':
if (!dashboardSettings.debtCollection) return null;
return (
<LazySection key={key}>
<DebtCollectionSection data={data.debtCollection} />
</LazySection>
);
case 'vat':
if (!dashboardSettings.vat) return null;
return (
<LazySection key={key}>
<VatSection data={data.vat} onClick={handleVatClick} />
</LazySection>
);
case 'calendar':
if (!dashboardSettings.calendar) return null;
return (
<LazySection key={key} minHeight={500}>
<CalendarSection
schedules={data.calendarSchedules}
issues={data.todayIssueList}
onScheduleClick={handleScheduleClick}
onScheduleEdit={handleScheduleEdit}
/>
</LazySection>
);
case 'salesStatus':
if (!(dashboardSettings.salesStatus ?? true) || !data.salesStatus) return null;
return (
<LazySection key={key} minHeight={600}>
<SalesStatusSection data={data.salesStatus} />
</LazySection>
);
case 'purchaseStatus':
if (!(dashboardSettings.purchaseStatus ?? true) || !data.purchaseStatus) return null;
return (
<LazySection key={key} minHeight={600}>
<PurchaseStatusSection data={data.purchaseStatus} />
</LazySection>
);
case 'production':
if (!(dashboardSettings.production ?? true) || !data.dailyProduction) return null;
return (
<LazySection key={key} minHeight={400}>
<DailyProductionSection
data={data.dailyProduction}
showShipment={false}
/>
</LazySection>
);
case 'shipment':
if (!(dashboardSettings.shipment ?? true) || !data.dailyProduction) return null;
return (
<LazySection key={key}>
<ShipmentSection data={data.dailyProduction} />
</LazySection>
);
case 'unshipped':
if (!(dashboardSettings.unshipped ?? true) || !data.unshipped) return null;
return (
<LazySection key={key}>
<UnshippedSection data={data.unshipped} />
</LazySection>
);
case 'construction':
if (!(dashboardSettings.construction ?? true) || !data.constructionData) return null;
return (
<LazySection key={key}>
<ConstructionSection data={data.constructionData} />
</LazySection>
);
case 'attendance':
if (!(dashboardSettings.attendance ?? true) || !data.dailyAttendance) return null;
return (
<LazySection key={key}>
<DailyAttendanceSection data={data.dailyAttendance} />
</LazySection>
);
default:
return null;
}
};
if (isLoading) {
return (
<PageLayout>
<PageHeader
title="대시보드"
description="전체 현황을 조회합니다."
icon={LayoutDashboard}
/>
<CEODashboardSkeleton />
</PageLayout>
);
}
return (
<PageLayout>
<PageHeader
title="대시보드"
description="전체 현황을 조회합니다."
icon={LayoutDashboard}
actions={
<>
<Button
variant="outline"
size="sm"
onClick={handleSettingClick}
className="gap-2"
>
<Settings className="h-4 w-4" />
</Button>
<DashboardSwitcher />
</>
}
/>
<div className="space-y-6">
{sectionOrder.map(renderDashboardSection)}
</div>
{/* 일정 상세 모달 */}
<ScheduleDetailModal
isOpen={isScheduleModalOpen}
onClose={handleScheduleModalClose}
schedule={selectedSchedule}
onSave={handleScheduleSave}
onDelete={handleScheduleDelete}
/>
{/* 항목 설정 모달 */}
<DashboardSettingsDialog
isOpen={isSettingsModalOpen}
onClose={handleSettingsModalClose}
settings={dashboardSettings}
onSave={handleSettingsSave}
/>
{/* 상세 모달 */}
{detailModalConfig && (
<DetailModal
isOpen={isDetailModalOpen}
onClose={handleDetailModalClose}
config={detailModalConfig}
/>
)}
</PageLayout>
);
}