diff --git a/src/components/business/CEODashboard/components.tsx b/src/components/business/CEODashboard/components.tsx
index 52387cdf..356d8065 100644
--- a/src/components/business/CEODashboard/components.tsx
+++ b/src/components/business/CEODashboard/components.tsx
@@ -222,6 +222,7 @@ export const AmountCardItem = ({
trendValue,
trendDirection,
showCountBadge,
+ subLabelAsBadge,
}: {
card: AmountCard;
onClick?: () => void;
@@ -232,6 +233,7 @@ export const AmountCardItem = ({
trendValue?: string;
trendDirection?: 'up' | 'down';
showCountBadge?: boolean;
+ subLabelAsBadge?: boolean;
}) => {
const themeStyle = colorTheme ? SECTION_THEME_STYLES[colorTheme] : null;
@@ -355,7 +357,20 @@ export const AmountCardItem = ({
)}
{card.subLabel && card.subAmount === undefined && !card.previousLabel && (
- {card.subLabel}
+ subLabelAsBadge && themeStyle ? (
+
+ {card.subLabel}
+
+ ) : (
+ {card.subLabel}
+ )
)}
)}
diff --git a/src/components/business/CEODashboard/sections/DebtCollectionSection.tsx b/src/components/business/CEODashboard/sections/DebtCollectionSection.tsx
index fb76251c..2635aaa0 100644
--- a/src/components/business/CEODashboard/sections/DebtCollectionSection.tsx
+++ b/src/components/business/CEODashboard/sections/DebtCollectionSection.tsx
@@ -44,7 +44,7 @@ export function DebtCollectionSection({ data }: DebtCollectionSectionProps) {
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="down"
- showCountBadge={!!card.subLabel}
+ subLabelAsBadge
/>
))}
diff --git a/src/components/business/CEODashboard/sections/EnhancedSections.tsx b/src/components/business/CEODashboard/sections/EnhancedSections.tsx
index 2ca5e0f6..d205343c 100644
--- a/src/components/business/CEODashboard/sections/EnhancedSections.tsx
+++ b/src/components/business/CEODashboard/sections/EnhancedSections.tsx
@@ -335,13 +335,12 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
const iconBgColor = isHighlighted ? 'rgba(255,255,255,0.2)' : style.iconBg;
const labelColor = isHighlighted ? '#ffffff' : style.labelColor;
const countColor = isHighlighted ? '#ffffff' : '#0f172a';
- const subColor = isHighlighted ? '#fecaca' : '#64748b';
return (
handleItemClick(item.path)}
>
{/* 아이콘 + 라벨 */}
@@ -359,9 +358,16 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
{typeof item.count === 'number' ? `${item.count}건` : item.count}
- {/* 부가 정보 */}
+ {/* 부가 정보 (최근 항목 외 N건) - pill 뱃지 스타일 */}
{item.subLabel && (
-
+
{item.subLabel}
)}
diff --git a/src/components/business/CEODashboard/sections/TodayIssueSection.tsx b/src/components/business/CEODashboard/sections/TodayIssueSection.tsx
index 7ed0c77a..cca05e51 100644
--- a/src/components/business/CEODashboard/sections/TodayIssueSection.tsx
+++ b/src/components/business/CEODashboard/sections/TodayIssueSection.tsx
@@ -1,10 +1,12 @@
'use client';
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
@@ -24,8 +26,12 @@ import {
TrendingUp,
TrendingDown,
Info,
+ ChevronLeft,
+ ChevronRight,
+ Loader2,
type LucideIcon,
} from 'lucide-react';
+import { usePastIssue } from '@/hooks/useCEODashboard';
import type { TodayIssueListItem, TodayIssueNotificationType } from '../types';
// 뱃지 스타일 매핑 (notification_type 코드 기반)
@@ -99,24 +105,82 @@ interface TodayIssueSectionProps {
items: TodayIssueListItem[];
}
+// 날짜를 YYYY-MM-DD 형식으로 포맷
+function formatDateParam(date: Date): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+}
+
+// 날짜를 "1월 29일 수요일" 형식으로 표시
+const DAY_NAMES = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
+function formatDateDisplay(date: Date): string {
+ const month = date.getMonth() + 1;
+ const day = date.getDate();
+ const dayName = DAY_NAMES[date.getDay()];
+ return `${month}월 ${day}일 ${dayName}`;
+}
+
+// 어제 날짜 생성
+function getYesterday(): Date {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ d.setDate(d.getDate() - 1);
+ return d;
+}
+
+// 두 날짜가 같은 날인지 비교
+function isSameDay(a: Date, b: Date): boolean {
+ return a.getFullYear() === b.getFullYear()
+ && a.getMonth() === b.getMonth()
+ && a.getDate() === b.getDate();
+}
+
export function TodayIssueSection({ items }: TodayIssueSectionProps) {
const router = useRouter();
const [filter, setFilter] = useState('all');
const [dismissedIds, setDismissedIds] = useState>(new Set());
+ // 탭 & 날짜 상태
+ const [activeTab, setActiveTab] = useState<'today' | 'past'>('today');
+ const [pastDate, setPastDate] = useState(getYesterday);
+
+ // 그리드 높이 유지 (로딩 시 들썩임 방지)
+ const gridRef = useRef(null);
+ const lastHeightRef = useRef(0);
+
+ // 이전 이슈 API 호출 (past 탭일 때만)
+ const pastDateParam = activeTab === 'past' ? formatDateParam(pastDate) : null;
+ const { data: pastIssueData, loading: pastLoading } = usePastIssue(pastDateParam);
+
+ // 현재 탭에 따른 데이터 소스
+ const currentItems = activeTab === 'today' ? items : (pastIssueData?.items ?? []);
+
+ // 콘텐츠가 있을 때 높이 저장, 로딩 중이면 이전 높이 유지
+ useLayoutEffect(() => {
+ if (!gridRef.current) return;
+ if (pastLoading && lastHeightRef.current > 0) {
+ gridRef.current.style.minHeight = `${lastHeightRef.current}px`;
+ } else {
+ lastHeightRef.current = gridRef.current.scrollHeight;
+ gridRef.current.style.minHeight = '';
+ }
+ }, [pastLoading, currentItems.length]);
+
// 확인되지 않은 아이템만 필터링
- const activeItems = items.filter((item) => !dismissedIds.has(item.id));
+ const activeItems = currentItems.filter((item) => !dismissedIds.has(item.id));
// 신규업체 아이템별 랜덤 신용등급 생성 (세션 동안 유지)
const creditRatings = useMemo(() => {
const ratings: Record = {};
- items.forEach((item) => {
+ currentItems.forEach((item) => {
if (item.notificationType === 'new_vendor') {
ratings[item.id] = getRandomCreditRating();
}
});
return ratings;
- }, [items]);
+ }, [currentItems]);
// 항목별 수량 계산 (notification_type 코드 기반)
const itemCounts = useMemo(() => {
@@ -129,6 +193,51 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
return counts;
}, [activeItems]);
+ // 탭 변경 핸들러
+ const handleTabChange = useCallback((value: string) => {
+ setActiveTab(value as 'today' | 'past');
+ setFilter('all');
+ setDismissedIds(new Set());
+ }, []);
+
+ // 날짜 네비게이션
+ const yesterday = useMemo(() => getYesterday(), []);
+ const isNextDisabled = isSameDay(pastDate, yesterday);
+
+ // 날짜 input 직접 선택
+ const handleDateInputChange = useCallback((e: React.ChangeEvent) => {
+ const value = e.target.value; // yyyy-MM-dd
+ if (!value) return;
+ const selected = new Date(value + 'T00:00:00');
+ if (isNaN(selected.getTime())) return;
+ if (selected >= new Date(new Date().setHours(0, 0, 0, 0))) return;
+ setPastDate(selected);
+ setFilter('all');
+ setDismissedIds(new Set());
+ }, []);
+
+ const handlePrevDate = useCallback(() => {
+ setPastDate((prev) => {
+ const next = new Date(prev);
+ next.setDate(next.getDate() - 1);
+ return next;
+ });
+ setFilter('all');
+ setDismissedIds(new Set());
+ }, []);
+
+ const handleNextDate = useCallback(() => {
+ setPastDate((prev) => {
+ const next = new Date(prev);
+ next.setDate(next.getDate() + 1);
+ // 어제까지만 허용
+ if (next > yesterday) return prev;
+ return next;
+ });
+ setFilter('all');
+ setDismissedIds(new Set());
+ }, [yesterday]);
+
// 필터 옵션 (notification_type 코드 기반, 한글 라벨 표시)
const filterOptions = useMemo(() => {
return FILTER_KEYS.map((key) => ({
@@ -172,8 +281,48 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
{/* 헤더 */}
-
-
오늘의 이슈
+
+
오늘의 이슈
+
+ {/* 탭 */}
+
+
+ 오늘
+ 이전 이슈
+
+
+
+ {/* 날짜 네비게이션 (이전 이슈 탭일 때만) */}
+ {activeTab === 'past' && (
+
+
+
+
+
+ )}
+
+ {/* 필터 */}
{/* 리스트 - 반응형 그리드 (4열 → 1열) */}
-
- {filteredItems.length === 0 ? (
+
+ {activeTab === 'past' && pastLoading ? (
+
+
+ 데이터를 불러오는 중...
+
+ ) : filteredItems.length === 0 ? (
- 표시할 이슈가 없습니다.
+ {activeTab === 'past'
+ ? `${formatDateDisplay(pastDate)}에 이슈가 없습니다.`
+ : '표시할 이슈가 없습니다.'}
) : (
filteredItems.map((item) => {
diff --git a/src/hooks/useCEODashboard.ts b/src/hooks/useCEODashboard.ts
index 2b35ae5d..0dc412e5 100644
--- a/src/hooks/useCEODashboard.ts
+++ b/src/hooks/useCEODashboard.ts
@@ -339,6 +339,44 @@ export function useTodayIssue(limit: number = 30) {
return { data, loading, error, refetch: fetchData };
}
+// ============================================
+// 7-1. PastIssue Hook (이전 이슈 - 날짜별 조회)
+// ============================================
+
+export function usePastIssue(date: string | null, limit: number = 30) {
+ const [data, setData] = useState
(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ if (!date) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const apiData = await fetchApi(
+ `today-issues/summary?limit=${limit}&date=${date}`
+ );
+ const transformed = transformTodayIssueResponse(apiData);
+ setData(transformed);
+
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
+ setError(errorMessage);
+ console.error('PastIssue API Error:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [date, limit]);
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ return { data, loading, error, refetch: fetchData };
+}
+
// ============================================
// 8. Calendar Hook
// ============================================
diff --git a/src/lib/api/dashboard/transformers.ts b/src/lib/api/dashboard/transformers.ts
index 10c1a48c..24f418d1 100644
--- a/src/lib/api/dashboard/transformers.ts
+++ b/src/lib/api/dashboard/transformers.ts
@@ -424,6 +424,33 @@ function generateDebtCollectionCheckPoints(api: BadDebtApiResponse): CheckPoint[
return checkPoints;
}
+// TODO: 백엔드 per-card sub_label/count 제공 시 더미값 제거
+// 채권추심 카드별 더미 서브라벨 (회사명 + 건수)
+const DEBT_COLLECTION_FALLBACK_SUB_LABELS: Record = {
+ dc1: { company: '(주)부산화학 외', count: 5 },
+ dc2: { company: '(주)삼성테크 외', count: 3 },
+ dc3: { company: '(주)대한전자 외', count: 2 },
+ dc4: { company: '(주)한국정밀 외', count: 3 },
+};
+
+/**
+ * 채권추심 subLabel 생성 헬퍼
+ * dc1(누적)은 API client_count 사용, 나머지는 더미값
+ */
+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(/ 외$/, '');
+}
+
/**
* BadDebt API 응답 → Frontend 타입 변환
*/
@@ -434,21 +461,25 @@ export function transformDebtCollectionResponse(api: BadDebtApiResponse): DebtCo
id: 'dc1',
label: '누적 악성채권',
amount: api.total_amount,
+ subLabel: buildDebtSubLabel('dc1', api.client_count),
},
{
id: 'dc2',
label: '추심중',
amount: api.collecting_amount,
+ subLabel: buildDebtSubLabel('dc2'),
},
{
id: 'dc3',
label: '법적조치',
amount: api.legal_action_amount,
+ subLabel: buildDebtSubLabel('dc3'),
},
{
id: 'dc4',
label: '회수완료',
amount: api.recovered_amount,
+ subLabel: buildDebtSubLabel('dc4'),
},
],
checkPoints: generateDebtCollectionCheckPoints(api),
@@ -620,6 +651,43 @@ export function transformCardManagementResponse(
// 6. 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 = {
+ orders: '(주)삼성전자 외',
+ bad_debts: '주식회사 부산화학 외',
+ safety_stock: '',
+ tax_deadline: '',
+ new_clients: '대한철강 외',
+ leaves: '',
+ purchases: '(유)한국정밀 외',
+ approvals: '구매 결재 외',
+};
+
+/**
+ * 현황판 subLabel 생성 헬퍼
+ * API 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(/ 외$/, '');
+}
+
/**
* StatusBoard API 응답 → Frontend 타입 변환
* API 응답 형식이 TodayIssueItem과 거의 동일하므로 단순 매핑
@@ -629,6 +697,7 @@ export function transformStatusBoardResponse(api: StatusBoardApiResponse): Today
id: item.id,
label: item.label,
count: item.count,
+ subLabel: buildStatusSubLabel(item),
path: normalizePath(item.path, { addViewMode: true }),
isHighlighted: item.isHighlighted,
}));
@@ -821,8 +890,17 @@ export function transformVatResponse(api: VatApiResponse): VatData {
* 접대비 현황 데이터 변환
*/
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 {
- cards: api.cards.map((card) => ({
+ cards: reordered.map((card) => ({
id: card.id,
label: card.label,
amount: card.amount,
@@ -850,8 +928,17 @@ export function transformEntertainmentResponse(api: EntertainmentApiResponse): E
* 복리후생비 현황 데이터 변환
*/
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 {
- cards: api.cards.map((card) => ({
+ cards: reordered.map((card) => ({
id: card.id,
label: card.label,
amount: card.amount,
diff --git a/src/lib/api/dashboard/types.ts b/src/lib/api/dashboard/types.ts
index c3c03e48..ad3839da 100644
--- a/src/lib/api/dashboard/types.ts
+++ b/src/lib/api/dashboard/types.ts
@@ -70,6 +70,7 @@ export interface BadDebtApiResponse {
legal_action_amount: number; // 법적조치
recovered_amount: number; // 회수완료
bad_debt_amount: number; // 대손처리
+ client_count?: number; // 거래처 수
}
// ============================================
@@ -172,6 +173,7 @@ export interface StatusBoardItemApiResponse {
count: number | string; // 건수 또는 텍스트 (예: "부가세 신고 D-15")
path: string; // 이동 경로
isHighlighted: boolean; // 강조 표시 여부
+ sub_label?: string; // 최근 항목 요약 (예: "대한철강 외 7건")
}
/** GET /api/proxy/status-board/summary 응답 */