feat(WEB): CEO 대시보드 오늘의 이슈 탭 및 이전 이슈 날짜 조회 기능 추가

- 오늘의 이슈 섹션에 "오늘" / "이전 이슈" 탭 추가
- 이전 이슈 탭에서 날짜 네비게이션(< >, date input) 지원
- usePastIssue 훅 추가 (date 파라미터로 과거 이슈 API 호출)
- 탭/날짜 전환 시 필터 및 상태 자동 리셋
- 로딩 중 그리드 높이 유지로 UI 들썩임 방지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-30 15:23:35 +09:00
parent 103a2b9f03
commit 9f7f55aeff
7 changed files with 321 additions and 17 deletions

View File

@@ -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 = ({
</span>
)}
{card.subLabel && card.subAmount === undefined && !card.previousLabel && (
<span>{card.subLabel}</span>
subLabelAsBadge && themeStyle ? (
<span
className="text-xs font-medium px-2 py-0.5 rounded-full border truncate w-fit max-w-full"
style={{
backgroundColor: `${themeStyle.iconBg}15`,
color: themeStyle.labelColor,
borderColor: `${themeStyle.iconBg}30`,
}}
>
{card.subLabel}
</span>
) : (
<span>{card.subLabel}</span>
)
)}
</div>
)}

View File

@@ -44,7 +44,7 @@ export function DebtCollectionSection({ data }: DebtCollectionSectionProps) {
showTrend={!!card.previousLabel}
trendValue={card.previousLabel}
trendDirection="down"
showCountBadge={!!card.subLabel}
subLabelAsBadge
/>
))}
</div>

View File

@@ -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 (
<div
key={item.id}
style={{ backgroundColor: bgColor, borderColor: borderColor }}
className="relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md h-[110px] flex flex-col"
className="relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md h-[130px] flex flex-col"
onClick={() => handleItemClick(item.path)}
>
{/* 아이콘 + 라벨 */}
@@ -359,9 +358,16 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
{typeof item.count === 'number' ? `${item.count}` : item.count}
</div>
{/* 부가 정보 */}
{/* 부가 정보 (최근 항목 외 N건) - pill 뱃지 스타일 */}
{item.subLabel && (
<span style={{ color: subColor }} className="text-xs mt-auto">
<span
style={{
backgroundColor: isHighlighted ? 'rgba(255,255,255,0.2)' : `${style.iconBg}15`,
color: isHighlighted ? '#ffffff' : style.labelColor,
borderColor: isHighlighted ? 'rgba(255,255,255,0.3)' : `${style.iconBg}30`,
}}
className="text-xs font-medium mt-auto px-2 py-0.5 rounded-full border truncate w-fit max-w-full"
>
{item.subLabel}
</span>
)}

View File

@@ -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<string>('all');
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
// 탭 & 날짜 상태
const [activeTab, setActiveTab] = useState<'today' | 'past'>('today');
const [pastDate, setPastDate] = useState<Date>(getYesterday);
// 그리드 높이 유지 (로딩 시 들썩임 방지)
const gridRef = useRef<HTMLDivElement>(null);
const lastHeightRef = useRef<number>(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<string, CreditRating> = {};
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<HTMLInputElement>) => {
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) {
<Card>
<CardContent className="p-6">
{/* 헤더 */}
<div className="flex items-center mb-4">
<h2 className="text-lg font-semibold text-gray-900"> </h2>
<div className="flex items-center gap-3 mb-4 flex-wrap">
<h2 className="text-lg font-semibold text-gray-900 shrink-0"> </h2>
{/* 탭 */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="shrink-0">
<TabsList className="h-8">
<TabsTrigger value="today" className="text-xs px-3 h-7"></TabsTrigger>
<TabsTrigger value="past" className="text-xs px-3 h-7"> </TabsTrigger>
</TabsList>
</Tabs>
{/* 날짜 네비게이션 (이전 이슈 탭일 때만) */}
{activeTab === 'past' && (
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handlePrevDate}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Input
type="date"
value={formatDateParam(pastDate)}
max={formatDateParam(yesterday)}
onChange={handleDateInputChange}
className="w-[160px] h-8 text-sm text-center"
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleNextDate}
disabled={isNextDisabled}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
{/* 필터 */}
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-44 h-9 ml-auto">
<SelectValue placeholder="전체" />
@@ -194,10 +343,17 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
</div>
{/* 리스트 - 반응형 그리드 (4열 → 1열) */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 max-h-[400px] overflow-y-auto pr-1">
{filteredItems.length === 0 ? (
<div ref={gridRef} className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 max-h-[400px] overflow-y-auto pr-1">
{activeTab === 'past' && pastLoading ? (
<div className="col-span-full flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400 mr-2" />
<span className="text-sm text-gray-500"> ...</span>
</div>
) : filteredItems.length === 0 ? (
<div className="col-span-full text-center py-8 text-gray-500">
.
{activeTab === 'past'
? `${formatDateDisplay(pastDate)}에 이슈가 없습니다.`
: '표시할 이슈가 없습니다.'}
</div>
) : (
filteredItems.map((item) => {

View File

@@ -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<TodayIssueData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!date) return;
try {
setLoading(true);
setError(null);
const apiData = await fetchApi<TodayIssueApiResponse>(
`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
// ============================================

View File

@@ -424,6 +424,33 @@ 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 사용, 나머지는 더미값
*/
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<string, string> = {
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,

View File

@@ -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 응답 */