feat: [공통] Sidebar, 대시보드 훅, 유틸 개선
- Sidebar 레이아웃 개선 - useCEODashboard 최적화, useDashboardFetch 훅 신규 - amount, status-config 유틸 개선 - dashboard transformers 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,8 @@ function MenuItemComponent({
|
|||||||
className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${
|
className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${
|
||||||
isFav
|
isFav
|
||||||
? 'opacity-100 text-yellow-500'
|
? 'opacity-100 text-yellow-500'
|
||||||
|
: isMobile
|
||||||
|
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
||||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||||
}`}
|
}`}
|
||||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||||
@@ -216,6 +218,8 @@ function MenuItemComponent({
|
|||||||
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
||||||
isFav
|
isFav
|
||||||
? 'opacity-100 text-yellow-500'
|
? 'opacity-100 text-yellow-500'
|
||||||
|
: isMobile
|
||||||
|
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
||||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||||
}`}
|
}`}
|
||||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||||
@@ -281,6 +285,8 @@ function MenuItemComponent({
|
|||||||
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
||||||
isFav
|
isFav
|
||||||
? 'opacity-100 text-yellow-500'
|
? 'opacity-100 text-yellow-500'
|
||||||
|
: isMobile
|
||||||
|
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
||||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||||
}`}
|
}`}
|
||||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
156
src/hooks/useDashboardFetch.ts
Normal file
156
src/hooks/useDashboardFetch.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CEO Dashboard API 호출을 위한 제네릭 훅
|
||||||
|
*
|
||||||
|
* @param endpoint - API 엔드포인트 (예: 'daily-report/summary')
|
||||||
|
* @param transformer - API 응답을 프론트엔드 데이터로 변환하는 함수
|
||||||
|
* @param options - 추가 옵션
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 자동 fetch (마운트 시 즉시 호출)
|
||||||
|
* const { data, loading, error, refetch } = useDashboardFetch(
|
||||||
|
* 'daily-report/summary',
|
||||||
|
* transformDailyReportResponse,
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // 수동 fetch (lazy: true → 마운트 시 호출하지 않음)
|
||||||
|
* const { data, loading, error, refetch } = useDashboardFetch(
|
||||||
|
* 'welfare/detail',
|
||||||
|
* transformWelfareDetailResponse,
|
||||||
|
* { lazy: true },
|
||||||
|
* );
|
||||||
|
* // 필요할 때 수동 호출
|
||||||
|
* await refetch();
|
||||||
|
*/
|
||||||
|
export function useDashboardFetch<TApi, TResult>(
|
||||||
|
endpoint: string | null,
|
||||||
|
transformer: (data: TApi) => TResult,
|
||||||
|
options?: {
|
||||||
|
/** true이면 마운트 시 자동 호출하지 않음 */
|
||||||
|
lazy?: boolean;
|
||||||
|
/** 초기 로딩 상태 (기본: !lazy) */
|
||||||
|
initialLoading?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const lazy = options?.lazy ?? false;
|
||||||
|
const [data, setData] = useState<TResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(options?.initialLoading ?? !lazy);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!endpoint) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/proxy/${endpoint}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API 오류: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '데이터 조회 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformed = transformer(result.data);
|
||||||
|
setData(transformed);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error(`Dashboard API Error [${endpoint}]:`, err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [endpoint, transformer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lazy && endpoint) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [lazy, endpoint, fetchData]);
|
||||||
|
|
||||||
|
return { data, loading, error, refetch: fetchData };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 API를 병렬 호출하는 제네릭 훅
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, loading, error, refetch } = useDashboardMultiFetch(
|
||||||
|
* [
|
||||||
|
* { endpoint: 'card-transactions/summary' },
|
||||||
|
* { endpoint: 'loans/dashboard', fetchFn: fetchLoanDashboard },
|
||||||
|
* ],
|
||||||
|
* ([cardData, loanData]) => transformCardManagementResponse(cardData, loanData),
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
export function useDashboardMultiFetch<TResult>(
|
||||||
|
sources: Array<{
|
||||||
|
endpoint: string;
|
||||||
|
/** 커스텀 fetch 함수 (기본: fetchApi 패턴) */
|
||||||
|
fetchFn?: () => Promise<unknown>;
|
||||||
|
}>,
|
||||||
|
transformer: (results: unknown[]) => TResult,
|
||||||
|
options?: {
|
||||||
|
lazy?: boolean;
|
||||||
|
initialLoading?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const lazy = options?.lazy ?? false;
|
||||||
|
const [data, setData] = useState<TResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(options?.initialLoading ?? !lazy);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// sources를 JSON으로 비교하여 안정적인 의존성 확보
|
||||||
|
const sourcesKey = JSON.stringify(sources.map((s) => s.endpoint));
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
sources.map(async (source) => {
|
||||||
|
if (source.fetchFn) {
|
||||||
|
const result = await source.fetchFn();
|
||||||
|
// fetchFn이 { success, data } 형태를 반환할 수 있음
|
||||||
|
if (result && typeof result === 'object' && 'success' in result) {
|
||||||
|
const r = result as { success: boolean; data: unknown };
|
||||||
|
return r.success ? r.data : null;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/proxy/${source.endpoint}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API 오류: ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (!json.success) {
|
||||||
|
throw new Error(json.message || '데이터 조회 실패');
|
||||||
|
}
|
||||||
|
return json.data;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const transformed = transformer(results);
|
||||||
|
setData(transformed);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Dashboard MultiFetch Error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sourcesKey, transformer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lazy) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [lazy, fetchData]);
|
||||||
|
|
||||||
|
return { data, loading, error, refetch: fetchData };
|
||||||
|
}
|
||||||
@@ -74,16 +74,6 @@ export function transformPurchaseDetailResponse(api: PurchaseDashboardDetailApiR
|
|||||||
],
|
],
|
||||||
defaultValue: 'all',
|
defaultValue: 'all',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'sortOrder',
|
|
||||||
options: [
|
|
||||||
{ value: 'latest', label: '최신순' },
|
|
||||||
{ value: 'oldest', label: '등록순' },
|
|
||||||
{ value: 'amountDesc', label: '금액 높은순' },
|
|
||||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
|
||||||
],
|
|
||||||
defaultValue: 'latest',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
showTotal: true,
|
showTotal: true,
|
||||||
totalLabel: '합계',
|
totalLabel: '합계',
|
||||||
@@ -165,16 +155,6 @@ export function transformCardDetailResponse(api: CardDashboardDetailApiResponse)
|
|||||||
],
|
],
|
||||||
defaultValue: 'all',
|
defaultValue: 'all',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'sortOrder',
|
|
||||||
options: [
|
|
||||||
{ value: 'latest', label: '최신순' },
|
|
||||||
{ value: 'oldest', label: '등록순' },
|
|
||||||
{ value: 'amountDesc', label: '금액 높은순' },
|
|
||||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
|
||||||
],
|
|
||||||
defaultValue: 'latest',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
showTotal: true,
|
showTotal: true,
|
||||||
totalLabel: '합계',
|
totalLabel: '합계',
|
||||||
@@ -264,16 +244,6 @@ export function transformBillDetailResponse(api: BillDashboardDetailApiResponse)
|
|||||||
],
|
],
|
||||||
defaultValue: 'all',
|
defaultValue: 'all',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'sortOrder',
|
|
||||||
options: [
|
|
||||||
{ value: 'latest', label: '최신순' },
|
|
||||||
{ value: 'oldest', label: '등록순' },
|
|
||||||
{ value: 'amountDesc', label: '금액 높은순' },
|
|
||||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
|
||||||
],
|
|
||||||
defaultValue: 'latest',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
showTotal: true,
|
showTotal: true,
|
||||||
totalLabel: '합계',
|
totalLabel: '합계',
|
||||||
@@ -398,16 +368,6 @@ export function transformExpectedExpenseDetailResponse(
|
|||||||
options: vendorOptions,
|
options: vendorOptions,
|
||||||
defaultValue: 'all',
|
defaultValue: 'all',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'sortOrder',
|
|
||||||
options: [
|
|
||||||
{ value: 'latest', label: '최신순' },
|
|
||||||
{ value: 'oldest', label: '등록순' },
|
|
||||||
{ value: 'amountDesc', label: '금액 높은순' },
|
|
||||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
|
||||||
],
|
|
||||||
defaultValue: 'latest',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
showTotal: true,
|
showTotal: true,
|
||||||
totalLabel: '합계',
|
totalLabel: '합계',
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): D
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: '복리후생비 상세',
|
title: '복리후생비 상세',
|
||||||
|
dateFilter: {
|
||||||
|
enabled: true,
|
||||||
|
defaultPreset: '당월',
|
||||||
|
showSearch: true,
|
||||||
|
},
|
||||||
summaryCards: [
|
summaryCards: [
|
||||||
// 1행: 당해년도 기준
|
// 1행: 당해년도 기준
|
||||||
{ label: '당해년도 복리후생비 계정', value: summary.annual_account, unit: '원' },
|
{ label: '당해년도 복리후생비 계정', value: summary.annual_account, unit: '원' },
|
||||||
@@ -224,16 +229,6 @@ export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): D
|
|||||||
],
|
],
|
||||||
defaultValue: 'all',
|
defaultValue: 'all',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'sortOrder',
|
|
||||||
options: [
|
|
||||||
{ value: 'latest', label: '최신순' },
|
|
||||||
{ value: 'oldest', label: '등록순' },
|
|
||||||
{ value: 'amountDesc', label: '금액 높은순' },
|
|
||||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
|
||||||
],
|
|
||||||
defaultValue: 'latest',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
showTotal: true,
|
showTotal: true,
|
||||||
totalLabel: '합계',
|
totalLabel: '합계',
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ export function formatAmountManwon(amount: number): string {
|
|||||||
return `${manwon.toLocaleString("ko-KR")}만원`;
|
return `${manwon.toLocaleString("ko-KR")}만원`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차트 축 레이블용 축약 포맷
|
||||||
|
*
|
||||||
|
* - 1억 이상: "1.5억"
|
||||||
|
* - 1만 이상: "5320만"
|
||||||
|
* - 1만 미만: "5,000"
|
||||||
|
*/
|
||||||
|
export function formatCompactAmount(value: number): string {
|
||||||
|
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억`;
|
||||||
|
if (value >= 10000) return `${(value / 10000).toFixed(0)}만`;
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 한국식 금액 축약 포맷
|
* 한국식 금액 축약 포맷
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -324,8 +324,7 @@ export const RECEIVING_STATUS_CONFIG = createStatusConfig({
|
|||||||
export const BAD_DEBT_COLLECTION_STATUS_CONFIG = createStatusConfig({
|
export const BAD_DEBT_COLLECTION_STATUS_CONFIG = createStatusConfig({
|
||||||
collecting: { label: '추심중', style: 'border-orange-300 text-orange-600 bg-orange-50' },
|
collecting: { label: '추심중', style: 'border-orange-300 text-orange-600 bg-orange-50' },
|
||||||
legalAction: { label: '법적조치', style: 'border-red-300 text-red-600 bg-red-50' },
|
legalAction: { label: '법적조치', style: 'border-red-300 text-red-600 bg-red-50' },
|
||||||
recovered: { label: '회수완료', style: 'border-green-300 text-green-600 bg-green-50' },
|
collectionEnd: { label: '추심종료', style: 'border-green-300 text-green-600 bg-green-50' },
|
||||||
badDebt: { label: '대손처리', style: 'border-gray-300 text-gray-600 bg-gray-50' },
|
|
||||||
}, { includeAll: true });
|
}, { includeAll: true });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user