feat: CEO 대시보드·결재·레이아웃·HR 개선

- CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 API 연동
- dashboard-invalidation 유틸 추가
- 결재 문서작성/결재함 검사성적서 렌더링 개선
- HeaderFavoritesBar/Sidebar 레이아웃 수정
- 근태관리/휴가관리 뷰 보강
- LoginPage 개선
- 대시보드 transformer 수정 (receivable, status-issue)
This commit is contained in:
2026-03-10 11:35:57 +09:00
parent dcaca59685
commit 2f00eac0f0
17 changed files with 234 additions and 85 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission';
import { format } from 'date-fns';
@@ -50,7 +51,7 @@ import { getClients } from '@/components/accounting/VendorManagement/actions';
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
const getInitialBasicInfo = (): BasicInfo => ({
drafter: '홍길동',
drafter: '', // 클라이언트에서 currentUser로 설정
draftDate: '', // 클라이언트에서 설정
documentNo: '',
documentType: 'proposal',
@@ -118,14 +119,22 @@ export function DocumentCreate() {
const today = format(new Date(), 'yyyy-MM-dd');
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
setBasicInfo(prev => ({ ...prev, draftDate: prev.draftDate || now }));
// localStorage 'user' 키에서 사용자 이름 가져오기 (로그인 시 저장됨)
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
const userName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name || '';
setBasicInfo(prev => ({
...prev,
drafter: prev.drafter || userName,
draftDate: prev.draftDate || now,
}));
setProposalData(prev => ({ ...prev, vendorPaymentDate: prev.vendorPaymentDate || today }));
setExpenseReportData(prev => ({
...prev,
requestDate: prev.requestDate || today,
paymentDate: prev.paymentDate || today,
}));
}, []);
}, [currentUser?.name]);
// 미리보기 모달 상태
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
@@ -172,6 +181,7 @@ export function DocumentCreate() {
setBasicInfo(prev => ({
...prev,
...mockData.basicInfo,
drafter: currentUserName || prev.drafter,
draftDate: prev.draftDate || mockData.basicInfo.draftDate,
documentType: (mockData.basicInfo.documentType || prev.documentType) as BasicInfo['documentType'],
}));
@@ -343,6 +353,7 @@ export function DocumentCreate() {
try {
const result = await deleteApproval(parseInt(documentId));
if (result.success) {
invalidateDashboard('approval');
toast.success('문서가 삭제되었습니다.');
router.back();
} else {
@@ -375,6 +386,7 @@ export function DocumentCreate() {
if (isEditMode && documentId) {
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('수정 및 상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -386,6 +398,7 @@ export function DocumentCreate() {
// 새 문서: 생성 후 상신
const result = await createAndSubmitApproval(formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -411,6 +424,7 @@ export function DocumentCreate() {
if (isEditMode && documentId) {
const result = await updateApproval(parseInt(documentId), formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('저장 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -421,6 +435,7 @@ export function DocumentCreate() {
// 새 문서: 임시저장
const result = await createApproval(formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('임시저장 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import {
@@ -175,6 +176,7 @@ export function DraftBox() {
try {
const result = await submitDrafts(ids);
if (result.success) {
invalidateDashboard('approval');
toast.success(`${ids.length}건의 문서를 상신했습니다.`);
loadData();
loadSummary();
@@ -200,6 +202,7 @@ export function DraftBox() {
try {
const result = await deleteDrafts(ids);
if (result.success) {
invalidateDashboard('approval');
toast.success(`${ids.length}건의 문서를 삭제했습니다.`);
loadData();
loadSummary();
@@ -222,6 +225,7 @@ export function DraftBox() {
try {
const result = await deleteDraft(id);
if (result.success) {
invalidateDashboard('approval');
toast.success('문서를 삭제했습니다.');
loadData();
loadSummary();
@@ -298,6 +302,7 @@ export function DraftBox() {
try {
const result = await submitDraft(selectedDocument.id);
if (result.success) {
invalidateDashboard('approval');
toast.success('문서를 상신했습니다.');
setIsModalOpen(false);
setSelectedDocument(null);

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -268,7 +269,11 @@ export function LoginPage() {
/>
<span className="text-sm text-muted-foreground">{t('rememberMe')}</span>
</label>
<button type="button" className="text-sm text-primary hover:underline">
<button
type="button"
className="text-sm text-primary hover:underline"
onClick={() => toast.info('비밀번호 초기화는 시스템 관리자에게 요청해 주세요.')}
>
{t('forgotPassword')}
</button>
</div>

View File

@@ -41,6 +41,7 @@ import { useCardManagementModals } from '@/hooks/useCardManagementModals';
import { getCardManagementModalConfigWithData } from './modalConfigs';
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
import { toast } from 'sonner';
import { consumeStaleSections, DASHBOARD_INVALIDATE_EVENT, type DashboardSectionKey } from '@/lib/dashboard-invalidation';
export function CEODashboard() {
const router = useRouter();
@@ -70,6 +71,27 @@ export function CEODashboard() {
// Welfare API Hook (Phase 2)
const welfareData = useWelfare();
// 대시보드 targeted refetch: CUD 후 stale 섹션만 갱신
useEffect(() => {
const refetchSection = (key: string) => {
if (key === 'entertainment') entertainmentData.refetch();
else if (key === 'welfare') welfareData.refetch();
else apiData.refetchMap[key as DashboardSectionKey]?.();
};
const stale = consumeStaleSections();
if (stale.length > 0) {
for (const key of stale) refetchSection(key);
}
const handler = (e: Event) => {
const sections = (e as CustomEvent).detail?.sections as string[] | undefined;
if (sections) {
for (const key of sections) refetchSection(key);
}
};
window.addEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
return () => window.removeEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
}, [apiData.refetchMap, entertainmentData.refetch, welfareData.refetch]);
// Card Management Modal API Hook (Phase 3)
const cardManagementModals = useCardManagementModals();

View File

@@ -47,12 +47,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
<CollapsibleDashboardCard
icon={<ShoppingCart className="h-5 w-5 text-white" />}
title="매입 현황"
subtitle="당월 매입 실적"
rightElement={
<Badge className="bg-amber-500 text-white border-none hover:opacity-90">
</Badge>
}
subtitle="매입 실적"
>
{/* 통계카드 3개 - 가로 배치 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
@@ -158,8 +153,8 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
{/* 당월 매입 내역 (별도 카드) */}
<CollapsibleDashboardCard
icon={<ShoppingCart className="h-5 w-5 text-white" />}
title="당월 매입 내역"
subtitle="당월 매입 거래 상세"
title="최근 매입 내역"
subtitle="매입 거래 상세"
bodyClassName="p-0"
>
<div className="p-3 bg-muted/50 border-b border-border space-y-2">

View File

@@ -725,13 +725,13 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
orders: true,
debtCollection: true,
safetyStock: true,
taxReport: false,
newVendor: false,
taxReport: true,
newVendor: true,
annualLeave: true,
vehicle: false,
equipment: false,
purchase: false,
approvalRequest: false,
approvalRequest: true,
fundStatus: true,
},
},
@@ -774,13 +774,13 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
orders: true,
debtCollection: true,
safetyStock: true,
taxReport: false,
newVendor: false,
taxReport: true,
newVendor: true,
annualLeave: true,
vehicle: false,
equipment: false,
purchase: false,
approvalRequest: false,
approvalRequest: true,
fundStatus: true,
},
},

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { getTodayString, formatDate } from '@/lib/utils/date';
@@ -243,6 +244,7 @@ export default function ConstructionDetailClient({ id, mode }: ConstructionDetai
try {
const result = await updateConstructionManagementDetail(id, formData);
if (result.success) {
invalidateDashboard('construction');
toast.success('저장되었습니다.');
return { success: true };
} else {
@@ -265,6 +267,7 @@ export default function ConstructionDetailClient({ id, mode }: ConstructionDetai
try {
const result = await completeConstruction(id);
if (result.success) {
invalidateDashboard('construction');
toast.success('시공이 완료되었습니다.');
router.push('/ko/construction/project/construction-management');
} else {

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import {
Clock,
@@ -310,6 +311,7 @@ export function AttendanceManagement() {
if (attendanceDialogMode === 'create') {
const result = await createAttendance(data);
if (result.success && result.data) {
invalidateDashboard('attendance');
setAttendanceRecords(prev => [result.data!, ...prev]);
} else {
console.error('Create failed:', result.error);
@@ -317,6 +319,7 @@ export function AttendanceManagement() {
} else if (selectedAttendance) {
const result = await updateAttendance(selectedAttendance.id, data);
if (result.success && result.data) {
invalidateDashboard('attendance');
setAttendanceRecords(prev =>
prev.map(r => r.id === selectedAttendance.id ? result.data! : r)
);

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { format } from 'date-fns';
import { useDateRange } from '@/hooks';
import {
@@ -312,6 +313,7 @@ export function VacationManagement() {
const ids = Array.from(selectedItems).map((id) => parseInt(id, 10));
const result = await approveLeavesMany(ids);
if (result.success) {
invalidateDashboard('leave');
await fetchLeaveRequests();
await fetchUsageData(); // 휴가 사용현황도 갱신
} else {
@@ -340,6 +342,7 @@ export function VacationManagement() {
const ids = Array.from(selectedItems).map((id) => parseInt(id, 10));
const result = await rejectLeavesMany(ids, '관리자에 의해 반려됨');
if (result.success) {
invalidateDashboard('leave');
await fetchLeaveRequests();
} else {
console.error('[VacationManagement] 반려 실패:', result.error);
@@ -750,6 +753,7 @@ export function VacationManagement() {
reason: data.reason,
});
if (result.success) {
invalidateDashboard('leave');
await fetchGrantData();
await fetchUsageData();
} else {
@@ -780,6 +784,7 @@ export function VacationManagement() {
days: data.vacationDays,
});
if (result.success) {
invalidateDashboard('leave');
await fetchLeaveRequests();
await fetchUsageData();
} else {

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bookmark, MoreHorizontal } from 'lucide-react';
import { Pin, MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
@@ -51,7 +51,7 @@ function StarDropdown({
className={`p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${className ?? 'w-10 h-10'}`}
title="즐겨찾기"
>
<Bookmark className="h-4 w-4 fill-white" />
<Pin className="h-4 w-4 fill-white" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">

View File

@@ -1,4 +1,4 @@
import { Bookmark, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
import { Pin, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
import type { MenuItem } from '@/stores/menuStore';
import { useEffect, useRef, useCallback } from 'react';
import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore';
@@ -159,7 +159,7 @@ function MenuItemComponent({
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Bookmark className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
<Pin className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>
@@ -224,7 +224,7 @@ function MenuItemComponent({
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Bookmark className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
<Pin className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>
@@ -291,7 +291,7 @@ function MenuItemComponent({
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Bookmark className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
<Pin className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>

View File

@@ -59,6 +59,8 @@ import {
transformDailyAttendanceResponse,
} from '@/lib/api/dashboard/transformers';
import type { DashboardSectionKey } from '@/lib/dashboard-invalidation';
import type {
DailyReportData,
ReceivableData,
@@ -664,6 +666,7 @@ export interface CEODashboardState {
construction: SectionState<ConstructionData>;
dailyAttendance: SectionState<DailyAttendanceData>;
refetchAll: () => void;
refetchMap: Partial<Record<DashboardSectionKey, () => void>>;
}
export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashboardState {
@@ -782,6 +785,22 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]);
// 섹션별 refetch 함수 맵 (targeted invalidation용)
const refetchMap = useMemo<Partial<Record<DashboardSectionKey, () => void>>>(() => ({
dailyReport: dr.refetch,
receivable: rv.refetch,
debtCollection: dc.refetch,
monthlyExpense: me.refetch,
cardManagement: fetchCM,
statusBoard: sb.refetch,
salesStatus: ss.refetch,
purchaseStatus: ps.refetch,
dailyProduction: dp.refetch,
unshipped: us.refetch,
construction: cs.refetch,
dailyAttendance: da.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 {
dailyReport: { data: dr.data, loading: dr.loading, error: dr.error },
receivable: { data: rv.data, loading: rv.loading, error: rv.error },
@@ -796,5 +815,6 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
construction: { data: cs.data, loading: cs.loading, error: cs.error },
dailyAttendance: { data: da.data, loading: da.loading, error: da.error },
refetchAll,
refetchMap,
};
}

View File

@@ -93,31 +93,11 @@ function generateDebtCollectionCheckPoints(api: BadDebtApiResponse): CheckPoint[
return checkPoints;
}
// TODO: 백엔드 per-card sub_label/count 제공 시 더미값 제거
// 채권추심 카드별 더미 서브라벨 (회사명 + 건수)
const DEBT_COLLECTION_FALLBACK_SUB_LABELS: Record<string, { company: string; count: number }> = {
dc1: { company: '(주)부산화학 외', count: 5 },
dc2: { company: '(주)삼성테크 외', count: 3 },
dc3: { company: '(주)대한전자 외', count: 2 },
dc4: { company: '(주)한국정밀 외', count: 3 },
};
/**
* 채권추심 subLabel 생성 헬퍼
* dc1(누적)은 API client_count 사용, 나머지는 더미값
* 채권추심 subLabel: 백엔드 sub_labels 필드 직접 사용
*/
function buildDebtSubLabel(cardId: string, clientCount?: number): string | undefined {
const fallback = DEBT_COLLECTION_FALLBACK_SUB_LABELS[cardId];
if (!fallback) return undefined;
const count = cardId === 'dc1' && clientCount !== undefined ? clientCount : fallback.count;
if (count <= 0) return undefined;
const remaining = count - 1;
if (remaining > 0) {
return `${fallback.company} ${remaining}`;
}
return fallback.company.replace(/ 외$/, '');
function buildDebtSubLabel(cardId: string, subLabels?: Record<string, string | null>): string | undefined {
return subLabels?.[cardId] || undefined;
}
/**
@@ -130,25 +110,25 @@ export function transformDebtCollectionResponse(api: BadDebtApiResponse): DebtCo
id: 'dc1',
label: '누적 악성채권',
amount: api.total_amount,
subLabel: buildDebtSubLabel('dc1', api.client_count),
subLabel: buildDebtSubLabel('dc1', api.sub_labels),
},
{
id: 'dc2',
label: '추심중',
amount: api.collecting_amount,
subLabel: buildDebtSubLabel('dc2'),
subLabel: buildDebtSubLabel('dc2', api.sub_labels),
},
{
id: 'dc3',
label: '법적조치',
amount: api.legal_action_amount,
subLabel: buildDebtSubLabel('dc3'),
subLabel: buildDebtSubLabel('dc3', api.sub_labels),
},
{
id: 'dc4',
label: '회수완료',
amount: api.recovered_amount,
subLabel: buildDebtSubLabel('dc4'),
subLabel: buildDebtSubLabel('dc4', api.sub_labels),
},
],
checkPoints: generateDebtCollectionCheckPoints(api),

View File

@@ -14,42 +14,26 @@ import { normalizePath } from './common';
// ============================================
// 현황판 (StatusBoard)
// ============================================
// TODO: 백엔드 sub_label 필드 제공 시 더미값 제거
// API id 기준: orders, bad_debts, safety_stock, tax_deadline, new_clients, leaves, purchases, approvals
const STATUS_BOARD_FALLBACK_SUB_LABELS: Record<string, string> = {
orders: '(주)삼성전자 외',
bad_debts: '주식회사 부산화학 외',
safety_stock: '',
tax_deadline: '',
new_clients: '대한철강 외',
leaves: '',
// purchases: '(유)한국정밀 외', // [2026-03-03] 비활성화 — 백엔드 path 오류 + 데이터 정합성 이슈 (N4 참조)
approvals: '구매 결재 외',
};
//
// [대시보드 vs 원본 페이지 쿼리 조건 차이 — 건수 불일치는 버그 아님]
//
// | 항목 | 대시보드 조건 | 원본 페이지 |
// |----------------|---------------------------------------------------|------------------------------------------|
// | 수주 현황 | 오늘 날짜 + status=confirmed만 | /sales/order-management-sales (전체 기간) |
// | 채권 추심 | status=collecting + is_active=true만 | /accounting/bad-debt-collection (전체) |
// | 안전 재고 | safety_stock>0 && stock_qty<safety_stock (날짜무관) | /material/stock-status (날짜 필터 적용) |
// | 세금 신고 | 가장 가까운 tax 일정 D-day | /accounting/tax-invoices |
// | 신규 업체 | 최근 7일 이내 등록된 거래처만 | /accounting/vendors (전체 목록) |
// | 연차 현황 | 오늘 기준 approved 휴가만 | /hr/vacation-management (전체 기간) |
// | 발주 현황 | status=draft(임시저장)만 | /construction/order (전체 상태) |
// | 결재 요청 | 현재 로그인 사용자의 pending 결재만 | /approval/inbox (필터 조건 다름) |
//
/**
* 현황판 subLabel 생성 헬퍼
* API sub_label이 있으면 사용, 없으면 더미값 + 건수로 조합
* 현황판 subLabel: 백엔드 sub_label 필드 직접 사용
*/
function buildStatusSubLabel(item: { id: string; count: number | string; sub_label?: string }): string | undefined {
// API에서 sub_label 제공 시 우선 사용
if (item.sub_label) return item.sub_label;
// 건수가 0이거나 문자열이면 subLabel 불필요
const count = typeof item.count === 'number' ? item.count : parseInt(String(item.count), 10);
if (isNaN(count) || count <= 0) return undefined;
const fallback = STATUS_BOARD_FALLBACK_SUB_LABELS[item.id];
if (!fallback) return undefined;
// "대한철강 외" + 나머지 건수
const remaining = count - 1;
if (remaining > 0) {
return `${fallback} ${remaining}`;
}
// 1건이면 "외" 제거하고 이름만
return fallback.replace(/ 외$/, '');
function buildStatusSubLabel(item: { sub_label?: string }): string | undefined {
return item.sub_label || undefined;
}
/**

View File

@@ -107,6 +107,7 @@ export interface BadDebtApiResponse {
recovered_amount: number; // 회수완료
bad_debt_amount: number; // 대손처리
client_count?: number; // 거래처 수
sub_labels?: Record<string, string | null>; // 카드별 거래처 sub_label (dc1~dc4)
}
// ============================================

View File

@@ -42,7 +42,7 @@ function extractArray<T>(data: PaginatedOrArray<T>): T[] {
export async function fetchVendorOptions(): Promise<ActionResult<SelectOption[]>> {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const result = await executeServerAction({
url: `${API_URL}/api/v1/clients?per_page=100`,
url: `${API_URL}/api/v1/clients?size=1000`,
transform: (data: PaginatedOrArray<ClientApiItem>) => {
const clients = extractArray(data);
return clients.map(c => ({ id: String(c.id), name: c.name }));

View File

@@ -0,0 +1,111 @@
/**
* CEO 대시보드 targeted refetch 시스템
*
* CUD 발생 시 sessionStorage + CustomEvent로 대시보드 섹션별 갱신 트리거
*/
// 대시보드 섹션 키 (useCEODashboard의 refetchMap과 1:1 매핑)
export type DashboardSectionKey =
| 'dailyReport'
| 'receivable'
| 'debtCollection'
| 'monthlyExpense'
| 'cardManagement'
| 'statusBoard'
| 'salesStatus'
| 'purchaseStatus'
| 'dailyProduction'
| 'unshipped'
| 'construction'
| 'dailyAttendance'
| 'entertainment'
| 'welfare';
// CUD 도메인 → 영향받는 대시보드 섹션 매핑
type DomainKey =
| 'deposit'
| 'withdrawal'
| 'sales'
| 'purchase'
| 'badDebt'
| 'expectedExpense'
| 'bill'
| 'giftCertificate'
| 'journalEntry'
| 'order'
| 'stock'
| 'schedule'
| 'client'
| 'leave'
| 'approval'
| 'attendance'
| 'production'
| 'shipment'
| 'construction';
const DOMAIN_SECTION_MAP: Record<DomainKey, DashboardSectionKey[]> = {
deposit: ['dailyReport', 'receivable'],
withdrawal: ['dailyReport', 'monthlyExpense'],
sales: ['dailyReport', 'salesStatus', 'receivable'],
purchase: ['dailyReport', 'purchaseStatus', 'monthlyExpense'],
badDebt: ['debtCollection', 'receivable'],
expectedExpense: ['monthlyExpense'],
bill: ['dailyReport', 'receivable'],
giftCertificate: ['entertainment', 'cardManagement'],
journalEntry: ['entertainment', 'welfare', 'monthlyExpense'],
order: ['statusBoard', 'salesStatus'],
stock: ['statusBoard'],
schedule: ['statusBoard'],
client: ['statusBoard'],
leave: ['statusBoard', 'dailyAttendance'],
approval: ['statusBoard'],
attendance: ['statusBoard', 'dailyAttendance'],
production: ['statusBoard', 'dailyProduction'],
shipment: ['statusBoard', 'unshipped'],
construction: ['statusBoard', 'construction'],
};
const STORAGE_KEY = 'dashboard:stale-sections';
const EVENT_NAME = 'dashboard:invalidate';
/**
* CUD 성공 후 호출 — 해당 도메인이 영향 주는 대시보드 섹션을 stale 처리
*/
export function invalidateDashboard(domain: DomainKey): void {
const sections = DOMAIN_SECTION_MAP[domain];
if (!sections || sections.length === 0) return;
// 1. sessionStorage에 stale 섹션 저장 (navigation 사이 유지)
try {
const existing = sessionStorage.getItem(STORAGE_KEY);
const current: string[] = existing ? JSON.parse(existing) : [];
const merged = Array.from(new Set([...current, ...sections]));
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
} catch {
// sessionStorage 접근 불가 시 무시
}
// 2. CustomEvent 발행 (대시보드가 마운트 중이면 즉시 처리)
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(EVENT_NAME, { detail: { sections } }),
);
}
}
/**
* 대시보드 마운트 시 호출 — stale 섹션 읽고 클리어
*/
export function consumeStaleSections(): DashboardSectionKey[] {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return [];
sessionStorage.removeItem(STORAGE_KEY);
return JSON.parse(raw) as DashboardSectionKey[];
} catch {
return [];
}
}
/** CustomEvent 이름 (리스너 등록용) */
export const DASHBOARD_INVALIDATE_EVENT = EVENT_NAME;