feat: CEO 대시보드 API 연동 강화 및 회계/결재/HR 개선

- CEO 대시보드: 예상비용, 현황이슈, 일별매출/매입 등 모달 API 연동 확대
- dashboard transformers 리팩토링 (hr, sales-purchase, production-logistics 분리)
- useCEODashboard 훅 대폭 확장 (모달 데이터 fetching 로직)
- DailyReport: USD 섹션 추가 및 레이아웃 개선
- VendorManagement/ApprovalBox: 소폭 개선
- VacationManagement: 소폭 수정
- component-registry previews 업데이트
- claudedocs: 대시보드 API 스펙, 분석 문서 추가
This commit is contained in:
유병철
2026-03-03 22:18:48 +09:00
parent 7bb8699403
commit cde9333652
30 changed files with 2852 additions and 269 deletions

View File

@@ -208,6 +208,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
window.print();
}, []);
// ===== USD 금액 포맷 =====
const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []);
// ===== 검색 필터링 =====
const filteredNoteReceivables = useMemo(() => {
if (!searchTerm) return noteReceivables;
@@ -225,6 +228,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
);
}, [dailyAccounts, searchTerm]);
// ===== USD 데이터 존재 여부 =====
const hasUsdAccounts = useMemo(() =>
filteredDailyAccounts.some(item => item.currency === 'USD'),
[filteredDailyAccounts]
);
return (
<PageLayout>
{/* 헤더 */}
@@ -290,67 +299,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
</CardContent>
</Card>
{/* 어음 및 외상매출채권현황 */}
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-between mb-3 md:mb-4">
<h3 className="text-base md:text-lg font-semibold"> </h3>
</div>
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
<div className="min-w-[480px] md:min-w-[550px]">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"> </TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500 text-sm"> ...</span>
</div>
</TableCell>
</TableRow>
) : filteredNoteReceivables.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
filteredNoteReceivables.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
</TableRow>
))
)}
</TableBody>
{filteredNoteReceivables.length > 0 && (
<TableFooter className="sticky bottom-0 z-10 bg-background">
<TableRow className="bg-muted/50 font-medium">
<TableCell className="font-bold text-xs md:text-sm"></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableFooter>
)}
</Table>
</div>
</div>
</CardContent>
</Card>
{/* 일자별 상세 */}
{/* 일자별 입출금 합계 */}
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-between mb-3 md:mb-4">
@@ -358,10 +307,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
</h3>
</div>
<div className="rounded-md border overflow-x-auto">
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
<div className="min-w-[420px] md:min-w-[650px]">
<Table>
<TableHeader>
<table className="w-full caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"></TableHead>
@@ -398,6 +347,35 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.balance)}</TableCell>
</TableRow>
))}
{/* KRW 소계 */}
{hasUsdAccounts && (
<TableRow className="bg-muted/30 font-medium">
<TableCell className="text-xs md:text-sm font-semibold">(KRW) </TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.balance)}</TableCell>
</TableRow>
)}
{/* USD 계좌들 */}
{hasUsdAccounts && filteredDailyAccounts
.filter(item => item.currency === 'USD')
.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.balance)}</TableCell>
</TableRow>
))}
{/* USD 소계 */}
{hasUsdAccounts && (
<TableRow className="bg-muted/30 font-medium">
<TableCell className="text-xs md:text-sm font-semibold">(USD) </TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.balance)}</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
@@ -412,7 +390,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
</TableRow>
</TableFooter>
)}
</Table>
</table>
</div>
</div>
</CardContent>
@@ -424,11 +402,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
<div className="flex items-center justify-center mb-3 md:mb-4 py-1.5 md:py-2 bg-gray-100 rounded-md">
<h3 className="text-base md:text-lg font-semibold"> </h3>
</div>
{/* KRW 입출금 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
{/* 입금 */}
{/* KRW 입금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-blue-50 rounded-t-md border border-b-0">
<span className="font-semibold text-blue-700 text-sm md:text-base"></span>
<span className="font-semibold text-blue-700 text-sm md:text-base"> (KRW)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
@@ -474,10 +453,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
</div>
</div>
{/* 출금 */}
{/* KRW 출금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-red-50 rounded-t-md border border-b-0">
<span className="font-semibold text-red-700 text-sm md:text-base"></span>
<span className="font-semibold text-red-700 text-sm md:text-base"> (KRW)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
@@ -523,6 +502,162 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
</div>
</div>
</div>
{/* USD 입출금 — USD 데이터가 있을 때만 표시 */}
{hasUsdAccounts && (
<>
<div className="flex items-center justify-center mt-4 mb-3 py-1.5 md:py-2 bg-emerald-50 rounded-md">
<h3 className="text-base md:text-lg font-semibold text-emerald-800">(USD) </h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
{/* USD 입금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-emerald-50 rounded-t-md border border-b-0">
<span className="font-semibold text-emerald-700 text-sm md:text-base"> (USD)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.income > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
USD .
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'USD' && item.income > 0)
.map((item) => (
<TableRow key={`usd-deposit-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-emerald-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.income)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
{/* USD 출금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-orange-50 rounded-t-md border border-b-0">
<span className="font-semibold text-orange-700 text-sm md:text-base"> (USD)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.expense > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
USD .
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'USD' && item.expense > 0)
.map((item) => (
<TableRow key={`usd-withdrawal-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-orange-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.expense)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* 어음 및 외상매출채권현황 */}
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-between mb-3 md:mb-4">
<h3 className="text-base md:text-lg font-semibold"> </h3>
</div>
<div className="rounded-md border overflow-x-auto max-h-[25vh] md:max-h-[30vh] overflow-y-auto">
<div className="min-w-[480px] md:min-w-[550px]">
<table className="w-full caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"> </TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500 text-sm"> ...</span>
</div>
</TableCell>
</TableRow>
) : filteredNoteReceivables.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
filteredNoteReceivables.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
</TableRow>
))
)}
</TableBody>
{filteredNoteReceivables.length > 0 && (
<TableFooter className="sticky bottom-0 z-10 bg-background">
<TableRow className="bg-muted/50 font-medium">
<TableCell className="font-bold text-xs md:text-sm"></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableFooter>
)}
</table>
</div>
</div>
</CardContent>
</Card>
</PageLayout>

View File

@@ -129,6 +129,11 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
enumFilter('creditRating', creditRatingFilter),
enumFilter('transactionGrade', transactionGradeFilter),
enumFilter('badDebtStatus', badDebtFilter),
(items: Vendor[]) => items.filter((item) => {
if (!item.createdAt) return true;
const created = item.createdAt.slice(0, 10);
return created >= startDate && created <= endDate;
}),
]);
// 정렬
@@ -154,7 +159,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
}
return result;
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]);
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption, startDate, endDate]);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;

View File

@@ -131,6 +131,8 @@ export async function getClients(params?: {
size?: number;
q?: string;
only_active?: boolean;
start_date?: string;
end_date?: string;
}): Promise<{ success: boolean; data: Vendor[]; total: number; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/clients', {
@@ -138,6 +140,8 @@ export async function getClients(params?: {
size: params?.size,
q: params?.q,
only_active: params?.only_active,
start_date: params?.start_date,
end_date: params?.end_date,
}),
transform: (data: PaginatedResponse<ClientApiData>) => ({
items: data.data.map(transformApiToFrontend),

View File

@@ -149,6 +149,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
dateField: 'createdAt',
},
// 데이터 변경 콜백 (Stats 계산용)

View File

@@ -113,6 +113,7 @@ function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
export async function getInbox(params?: {
page?: number; per_page?: number; search?: string; status?: string;
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
start_date?: string; end_date?: string;
}): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number; __authError?: boolean }> {
const result = await executeServerAction<PaginatedApiResponse<InboxApiData>>({
url: buildApiUrl('/api/v1/approvals/inbox', {
@@ -123,6 +124,8 @@ export async function getInbox(params?: {
approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined,
sort_by: params?.sort_by,
sort_dir: params?.sort_dir,
start_date: params?.start_date,
end_date: params?.end_date,
}),
errorMessage: '결재함 목록 조회에 실패했습니다.',
});

View File

@@ -158,6 +158,8 @@ export function ApprovalBox() {
search: searchQuery || undefined,
status: activeTab !== 'all' ? activeTab : undefined,
approval_type: filterOption !== 'all' ? filterOption : undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
...sortConfig,
});
@@ -172,7 +174,7 @@ export function ApprovalBox() {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]);
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
// ===== 초기 로드 =====
useEffect(() => {

View File

@@ -32,9 +32,9 @@ import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailM
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 { EmptySection } from './components';
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
import { useCardManagementModals } from '@/hooks/useCardManagementModals';
import {
getMonthlyExpenseModalConfig,
@@ -47,9 +47,14 @@ import {
export function CEODashboard() {
const router = useRouter();
// API 데이터 Hook (Phase 1 섹션들)
// API 데이터 Hook (신규 6개는 백엔드 API 구현 전까지 비활성)
const apiData = useCEODashboard({
cardManagementFallback: mockData.cardManagement,
salesStatus: false,
purchaseStatus: false,
dailyProduction: false,
unshipped: false,
construction: false,
dailyAttendance: false,
});
// TodayIssue API Hook (Phase 2)
@@ -79,6 +84,12 @@ export function CEODashboard() {
apiData.monthlyExpense.loading ||
apiData.cardManagement.loading ||
apiData.statusBoard.loading ||
apiData.salesStatus.loading ||
apiData.purchaseStatus.loading ||
apiData.dailyProduction.loading ||
apiData.unshipped.loading ||
apiData.construction.loading ||
apiData.dailyAttendance.loading ||
todayIssueData.loading ||
calendarData.loading ||
vatData.loading ||
@@ -87,35 +98,37 @@ export function CEODashboard() {
);
}, [apiData, todayIssueData.loading, calendarData.loading, vatData.loading, entertainmentData.loading, welfareData.loading]);
// API 데이터와 mockData를 병합 (API 우선, 실패 시 fallback)
// API 데이터만으로 구성 (mock 제거 — API 미응답 시 undefined → 빈 상태 UI)
const data = useMemo<CEODashboardData>(() => ({
...mockData,
// Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback
// TODO: 자금현황 카드 변경 (일일일보/미수금/미지급금/당월예상지출) - 새 API 구현 후 교체
dailyReport: mockData.dailyReport,
// TODO: D1.7 카드 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체
// cardManagement: 카드/경조사/상품권/접대비 (기존: 카드/가지급금/법인세/종합세)
// entertainment: 주말심야/기피업종/고액결제/증빙미비 (기존: 매출/한도/잔여한도/사용금액)
// welfare: 비과세초과/사적사용/특정인편중/한도초과 (기존: 한도/잔여한도/사용금액)
// receivable: 누적/당월/거래처/Top3 (기존: 누적/당월/거래처현황)
receivable: mockData.receivable,
debtCollection: apiData.debtCollection.data ?? mockData.debtCollection,
monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense,
cardManagement: 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: mockData.entertainment,
welfare: 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]);
dailyReport: apiData.dailyReport.data ?? undefined,
monthlyExpense: apiData.monthlyExpense.data ?? undefined,
cardManagement: apiData.cardManagement.data ?? undefined,
entertainment: entertainmentData.data ?? undefined,
welfare: welfareData.data ?? undefined,
receivable: apiData.receivable.data ?? undefined,
debtCollection: apiData.debtCollection.data ?? undefined,
vat: vatData.data ?? undefined,
calendarSchedules: calendarData.data?.items ?? undefined,
salesStatus: apiData.salesStatus.data ?? {
cumulativeSales: 0, achievementRate: 0, yoyChange: 0, monthlySales: 0,
monthlyTrend: [], clientSales: [], dailyItems: [], dailyTotal: 0,
},
purchaseStatus: apiData.purchaseStatus.data ?? {
cumulativePurchase: 0, unpaidAmount: 0, yoyChange: 0,
monthlyTrend: [], materialRatio: [], dailyItems: [], dailyTotal: 0,
},
dailyProduction: apiData.dailyProduction.data ?? {
date: '', processes: [],
shipment: { expectedAmount: 0, expectedCount: 0, actualAmount: 0, actualCount: 0 },
},
unshipped: apiData.unshipped.data ?? { items: [] },
constructionData: apiData.construction.data ?? { thisMonth: 0, completed: 0, items: [] },
dailyAttendance: apiData.dailyAttendance.data ?? {
present: 0, onLeave: 0, late: 0, absent: 0, employees: [],
},
}), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data]);
// 일정 상세 모달 상태
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
@@ -136,6 +149,7 @@ export function CEODashboard() {
// 상세 모달 상태
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [detailModalConfig, setDetailModalConfig] = useState<DetailModalConfig | null>(null);
const [currentModalCardId, setCurrentModalCardId] = useState<string | null>(null);
// 클라이언트에서만 localStorage에서 설정 불러오기 (hydration 에러 방지)
useEffect(() => {
@@ -204,17 +218,30 @@ export function CEODashboard() {
const handleDetailModalClose = useCallback(() => {
setIsDetailModalOpen(false);
setDetailModalConfig(null);
setCurrentModalCardId(null);
}, []);
// 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달)
// TODO: D1.7 모달 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체
const handleMonthlyExpenseCardClick = useCallback((cardId: string) => {
const config = getMonthlyExpenseModalConfig(cardId);
// 당월 예상 지출 카드 클릭 - API 데이터로 모달 열기
const handleMonthlyExpenseCardClick = useCallback(async (cardId: string) => {
const config = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId);
if (config) {
setCurrentModalCardId(cardId);
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}, []);
}, [monthlyExpenseDetailData]);
// 당월 예상 지출 모달 날짜/검색 필터 변경 → 재조회
const handleDateFilterChange = useCallback(async (params: { startDate: string; endDate: string; search: string }) => {
if (!currentModalCardId) return;
const config = await monthlyExpenseDetailData.fetchData(
currentModalCardId as MonthlyExpenseCardId,
params,
);
if (config) {
setDetailModalConfig(config);
}
}, [currentModalCardId, monthlyExpenseDetailData]);
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
const handleMonthlyExpenseClick = useCallback(() => {
@@ -315,12 +342,13 @@ export function CEODashboard() {
);
case 'dailyReport':
if (!dashboardSettings.dailyReport) return null;
if (!dashboardSettings.dailyReport || !data.dailyReport) return null;
return (
<LazySection key={key}>
<EnhancedDailyReportSection
data={data.dailyReport}
onClick={handleDailyReportClick}
onExpenseDetailClick={() => handleMonthlyExpenseCardClick('me4')}
/>
</LazySection>
);
@@ -337,7 +365,7 @@ export function CEODashboard() {
);
case 'monthlyExpense':
if (!dashboardSettings.monthlyExpense) return null;
if (!dashboardSettings.monthlyExpense || !data.monthlyExpense) return null;
return (
<LazySection key={key}>
<EnhancedMonthlyExpenseSection
@@ -348,7 +376,7 @@ export function CEODashboard() {
);
case 'cardManagement':
if (!dashboardSettings.cardManagement) return null;
if (!dashboardSettings.cardManagement || !data.cardManagement) return null;
return (
<LazySection key={key}>
<CardManagementSection
@@ -359,7 +387,7 @@ export function CEODashboard() {
);
case 'entertainment':
if (!dashboardSettings.entertainment.enabled) return null;
if (!dashboardSettings.entertainment.enabled || !data.entertainment) return null;
return (
<LazySection key={key}>
<EntertainmentSection
@@ -370,7 +398,7 @@ export function CEODashboard() {
);
case 'welfare':
if (!dashboardSettings.welfare.enabled) return null;
if (!dashboardSettings.welfare.enabled || !data.welfare) return null;
return (
<LazySection key={key}>
<WelfareSection
@@ -381,7 +409,7 @@ export function CEODashboard() {
);
case 'receivable':
if (!dashboardSettings.receivable) return null;
if (!dashboardSettings.receivable || !data.receivable) return null;
return (
<LazySection key={key}>
<ReceivableSection data={data.receivable} />
@@ -389,7 +417,7 @@ export function CEODashboard() {
);
case 'debtCollection':
if (!dashboardSettings.debtCollection) return null;
if (!dashboardSettings.debtCollection || !data.debtCollection) return null;
return (
<LazySection key={key}>
<DebtCollectionSection data={data.debtCollection} />
@@ -397,7 +425,7 @@ export function CEODashboard() {
);
case 'vat':
if (!dashboardSettings.vat) return null;
if (!dashboardSettings.vat || !data.vat) return null;
return (
<LazySection key={key}>
<VatSection data={data.vat} onClick={handleVatClick} />
@@ -405,7 +433,7 @@ export function CEODashboard() {
);
case 'calendar':
if (!dashboardSettings.calendar) return null;
if (!dashboardSettings.calendar || !data.calendarSchedules) return null;
return (
<LazySection key={key} minHeight={500}>
<CalendarSection
@@ -543,6 +571,7 @@ export function CEODashboard() {
isOpen={isDetailModalOpen}
onClose={handleDetailModalClose}
config={detailModalConfig}
onDateFilterChange={handleDateFilterChange}
/>
)}
</PageLayout>

View File

@@ -479,4 +479,19 @@ export function CollapsibleDashboardCard({
)}
</div>
);
}
/**
* 데이터가 없거나 API 미연동 섹션에 표시하는 빈 상태 컴포넌트
*/
export function EmptySection({ title, message = '데이터를 불러올 수 없습니다' }: { title: string; message?: string }) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Info className="mb-3 h-8 w-8 opacity-40" />
<p className="text-sm font-medium">{title}</p>
<p className="mt-1 text-xs">{message}</p>
</CardContent>
</Card>
);
}

View File

@@ -35,7 +35,7 @@ export const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
annualLeave: '연차',
vehicle: '차량',
equipment: '장비',
purchase: '발주',
purchase: '발주', // [2026-03-03] 비활성화 — 설정 모달에서 숨김 처리 (STATUS_BOARD_HIDDEN_SETTINGS)
approvalRequest: '결재 요청',
fundStatus: '자금 현황',
};
@@ -123,6 +123,13 @@ export function SectionRow({
);
}
// [2026-03-03] 설정 모달에서 숨길 항목
// - purchase: 백엔드 path 오류 + 데이터 정합성 이슈 (API-SPEC N4 참조)
// - vehicle, equipment, fundStatus: 백엔드 API에서 미제공 (StatusBoard 응답에 없음)
const STATUS_BOARD_HIDDEN_SETTINGS = new Set<keyof TodayIssueSettings>([
'purchase', 'vehicle', 'equipment', 'fundStatus',
]);
// ─── 현황판 항목 토글 리스트 ────────────────────────
export function StatusBoardItemsList({
items,
@@ -133,8 +140,9 @@ export function StatusBoardItemsList({
}) {
return (
<div className="space-y-0">
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
(itemKey) => (
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>)
.filter((itemKey) => !STATUS_BOARD_HIDDEN_SETTINGS.has(itemKey))
.map((itemKey) => (
<div
key={itemKey}
className="flex items-center justify-between py-2.5 px-2"

View File

@@ -1,5 +1,22 @@
import type { CEODashboardData } from './types';
/* ============================================
* 전체 Mock 데이터 주석 처리
* API 연동 완료 후 이 파일 삭제 예정
* 기존 mock 데이터는 git history에서 확인 가능
* ============================================ */
// 빈 기본값 (타입 안전성 유지 — 필수 필드만)
export const mockData: CEODashboardData = {
todayIssue: [],
todayIssueList: [],
};
/* ============================================
* 아래는 주석 처리된 기존 Mock 데이터
* ============================================
import type {
CEODashboardData,
SalesStatusData,
PurchaseStatusData,
DailyProductionData,
@@ -7,12 +24,7 @@ import type {
DailyAttendanceData,
} from './types';
/**
* CEO 대시보드 목데이터
* TODO: API 연동 시 이 파일을 API 호출로 대체
*/
export const mockData: CEODashboardData = {
// TodayIssue: API 연동 완료 - 목업 데이터 제거됨
const _originalMockData: CEODashboardData = {
todayIssue: [],
todayIssueList: [],
dailyReport: {
@@ -516,4 +528,6 @@ export const mockData: CEODashboardData = {
personName: '홍길동',
},
],
};
};
============================================ */

View File

@@ -28,9 +28,10 @@ interface DetailModalProps {
isOpen: boolean;
onClose: () => void;
config: DetailModalConfig;
onDateFilterChange?: (params: { startDate: string; endDate: string; search: string }) => void;
}
export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
export function DetailModal({ isOpen, onClose, config, onDateFilterChange }: DetailModalProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} >
<DialogContent className="!w-[95vw] sm:!w-[90vw] md:!w-[85vw] !max-w-[1600px] max-h-[90vh] overflow-auto p-0">
@@ -51,7 +52,7 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* 기간선택기 영역 */}
{config.dateFilter?.enabled && (
<DateFilterSection config={config.dateFilter} />
<DateFilterSection config={config.dateFilter} onFilterChange={onDateFilterChange} />
)}
{/* 신고기간 셀렉트 영역 */}

View File

@@ -1,7 +1,8 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { Search } from 'lucide-react';
import { Search as SearchIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -46,7 +47,7 @@ import type {
// 필터 섹션
// ============================================
export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilterConfig; onFilterChange?: (params: { startDate: string; endDate: string; search: string }) => void }) => {
const today = new Date();
const [startDate, setStartDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth(), 1);
@@ -58,6 +59,14 @@ export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
});
const [searchText, setSearchText] = useState('');
const handleSearch = useCallback(() => {
onFilterChange?.({ startDate, endDate, search: searchText });
}, [startDate, endDate, searchText, onFilterChange]);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSearch();
}, [handleSearch]);
return (
<div className="pb-4 border-b">
<DateRangeSelector
@@ -66,17 +75,25 @@ export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
config.showSearch !== false ? (
<div className="relative ml-auto">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="검색"
className="h-8 pl-7 pr-3 text-xs w-[140px]"
/>
</div>
) : undefined
<div className="flex items-center gap-2 ml-auto">
{config.showSearch !== false && (
<div className="relative">
<SearchIcon className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="검색"
className="h-8 pl-7 pr-3 text-xs w-[140px]"
/>
</div>
)}
{onFilterChange && (
<Button size="sm" onClick={handleSearch} className="h-8 px-3 text-xs">
</Button>
)}
</div>
}
/>
</div>

View File

@@ -45,6 +45,7 @@ const formatUSD = (amount: number): string => {
interface EnhancedDailyReportSectionProps {
data: DailyReportData;
onClick?: () => void;
onExpenseDetailClick?: () => void;
}
const CARD_STYLES = [
@@ -54,10 +55,18 @@ const CARD_STYLES = [
{ bgClass: 'bg-orange-50 dark:bg-orange-900/30', borderClass: 'border-orange-200 dark:border-orange-800', iconBg: '#f97316', labelClass: 'text-orange-700 dark:text-orange-300', Icon: Clock },
];
export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyReportSectionProps) {
export function EnhancedDailyReportSection({ data, onClick, onExpenseDetailClick }: EnhancedDailyReportSectionProps) {
const router = useRouter();
const handleCardClick = (card: DailyReportData['cards'][number]) => {
// dr3 (미지급금 잔액): 클릭 동작 없음
if (card.id === 'dr3') return;
// dr4 (당월 예상 지출 합계): 상세 팝업 표시
if (card.id === 'dr4') {
onExpenseDetailClick?.();
return;
}
// dr1, dr2: path로 페이지 이동
if (card.path) {
router.push(card.path);
} else if (onClick) {
@@ -86,7 +95,7 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
return (
<div
key={card.id}
className={`rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border min-h-[110px] flex flex-col ${style.bgClass} ${style.borderClass}`}
className={`rounded-xl p-4 transition-all border min-h-[110px] flex flex-col ${style.bgClass} ${style.borderClass} ${card.id === 'dr3' ? 'cursor-default' : 'cursor-pointer hover:shadow-lg'}`}
onClick={() => handleCardClick(card)}
>
<div className="flex items-center gap-2 mb-3">
@@ -97,25 +106,21 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
{card.label}
</span>
</div>
<div className="flex items-end gap-2">
<span className="text-2xl font-bold text-foreground">
{card.displayValue
? card.displayValue
: card.currency === 'USD'
? formatUSD(card.amount)
: formatKoreanAmount(card.amount)}
</span>
{card.changeRate && (
<span
className={`flex items-center text-xs font-medium mb-1 ${card.changeDirection === 'up' ? 'text-red-500 dark:text-red-400' : 'text-blue-500 dark:text-blue-400'}`}
>
{card.changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{card.changeRate}
</span>
)}
<div className="text-2xl font-bold text-foreground">
{card.displayValue
? card.displayValue
: card.currency === 'USD'
? formatUSD(card.amount)
: formatKoreanAmount(card.amount)}
</div>
{/* 기획서 D1.7 기준: 자금현황 카드에 전일 대비 미표시 — 추후 필요 시 복원
{card.changeRate && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
전일 대비 {card.changeRate}
</div>
)}
*/}
</div>
);
})}
@@ -191,6 +196,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
const router = useRouter();
const handleItemClick = (path: string) => {
if (!path) return;
router.push(path);
};
@@ -225,7 +231,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
return (
<div
key={item.id}
className={`relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md min-h-[130px] flex flex-col ${isHighlighted ? 'bg-red-500 border-red-500 dark:bg-red-600 dark:border-red-600' : `${style.bgClass} ${style.borderClass}`}`}
className={`relative p-4 rounded-xl border transition-all min-h-[130px] flex flex-col ${item.path ? 'cursor-pointer hover:scale-[1.02] hover:shadow-md' : 'cursor-default'} ${isHighlighted ? 'bg-red-500 border-red-500 dark:bg-red-600 dark:border-red-600' : `${style.bgClass} ${style.borderClass}`}`}
onClick={() => handleItemClick(item.path)}
>
{/* 아이콘 + 라벨 */}
@@ -290,19 +296,15 @@ const EXPENSE_CARD_CONFIGS: Array<{
];
export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) {
// 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환)
const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0);
// 총 예상 지출: cards[3]이 API total_amount (advance 등 미표시 항목 포함)
const totalAmount = Number(data.cards[3]?.amount) || 0;
return (
<CollapsibleDashboardCard
icon={<Receipt className="h-5 w-5 text-white" />}
title="당월 예상 지출 내역"
subtitle="이달 예상 지출 정보"
rightElement={
<Badge className="bg-orange-500 text-white border-none hover:opacity-90">
+15%
</Badge>
}
rightElement={undefined}
>
{/* 카드 그리드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
@@ -354,7 +356,7 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
</div>
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-white/20 text-white">
<TrendingUp className="h-3 w-3" />
+10.5%
{data.cards[3]?.previousLabel || '전월 대비 0.0%'}
</div>
</div>
</div>

View File

@@ -15,7 +15,7 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'연차': 'annualLeave',
'차량': 'vehicle',
'장비': 'equipment',
'발주': 'purchase',
// '발주': 'purchase', // [2026-03-03] 비활성화 — transformer에서 필터링됨 (N4 참조)
'결재 요청': 'approvalRequest',
};
@@ -28,6 +28,7 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr
const router = useRouter();
const handleItemClick = (path: string) => {
if (!path) return;
router.push(path);
};

View File

@@ -326,19 +326,19 @@ export interface DailyAttendanceData {
}
// CEO Dashboard 전체 데이터
// 모든 필드 optional: mock 제거 후 API 미구현 섹션은 undefined
export interface CEODashboardData {
todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈)
todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태)
dailyReport: DailyReportData;
monthlyExpense: MonthlyExpenseData;
cardManagement: CardManagementData;
entertainment: EntertainmentData;
welfare: WelfareData;
receivable: ReceivableData;
debtCollection: DebtCollectionData;
vat: VatData;
calendarSchedules: CalendarScheduleItem[];
// 신규 섹션 (API 미구현 - mock 데이터)
dailyReport?: DailyReportData;
monthlyExpense?: MonthlyExpenseData;
cardManagement?: CardManagementData;
entertainment?: EntertainmentData;
welfare?: WelfareData;
receivable?: ReceivableData;
debtCollection?: DebtCollectionData;
vat?: VatData;
calendarSchedules?: CalendarScheduleItem[];
salesStatus?: SalesStatusData;
purchaseStatus?: PurchaseStatusData;
dailyProduction?: DailyProductionData;

View File

@@ -681,9 +681,9 @@ export function VacationManagement() {
columns: tableColumns,
// 공통 패턴: dateRangeSelector
// 신청현황 탭에서만 날짜 필터 표시 (사용현황/부여현황은 연간 데이터)
dateRangeSelector: {
enabled: true,
enabled: mainTab === 'request',
startDate,
endDate,
onStartDateChange: setStartDate,