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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -149,6 +149,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
dateField: 'createdAt',
|
||||
},
|
||||
|
||||
// 데이터 변경 콜백 (Stats 계산용)
|
||||
|
||||
@@ -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: '결재함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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: '홍길동',
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
============================================ */
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
{/* 신고기간 셀렉트 영역 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -681,9 +681,9 @@ export function VacationManagement() {
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
// 공통 패턴: dateRangeSelector
|
||||
// 신청현황 탭에서만 날짜 필터 표시 (사용현황/부여현황은 연간 데이터)
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
enabled: mainTab === 'request',
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
|
||||
Reference in New Issue
Block a user