From 9f7f55aeff22aafea42b73ba7cd05882ed7310fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 30 Jan 2026 15:23:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20CEO=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=EB=8A=98=EC=9D=98=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=ED=83=AD=20=EB=B0=8F=20=EC=9D=B4=EC=A0=84=20=EC=9D=B4=EC=8A=88?= =?UTF-8?q?=20=EB=82=A0=EC=A7=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오늘의 이슈 섹션에 "오늘" / "이전 이슈" 탭 추가 - 이전 이슈 탭에서 날짜 네비게이션(< >, date input) 지원 - usePastIssue 훅 추가 (date 파라미터로 과거 이슈 API 호출) - 탭/날짜 전환 시 필터 및 상태 자동 리셋 - 로딩 중 그리드 높이 유지로 UI 들썩임 방지 Co-Authored-By: Claude Opus 4.5 --- .../business/CEODashboard/components.tsx | 17 +- .../sections/DebtCollectionSection.tsx | 2 +- .../sections/EnhancedSections.tsx | 14 +- .../sections/TodayIssueSection.tsx | 174 +++++++++++++++++- src/hooks/useCEODashboard.ts | 38 ++++ src/lib/api/dashboard/transformers.ts | 91 ++++++++- src/lib/api/dashboard/types.ts | 2 + 7 files changed, 321 insertions(+), 17 deletions(-) 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' && ( +
+ + + +
+ )} + + {/* 필터 */}