feat: CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 개선 및 회계 페이지 확장

- 접대비/복리후생비 섹션: 리스크감지형 구조로 변경
- 매출채권 섹션: transformer/타입 정비
- 캘린더 섹션: ScheduleDetailModal 개선
- 카드관리 모달 transformer 확장
- useCEODashboard 훅 리팩토링 및 정리
- dashboard endpoints/types/transformers (expense, receivable, tax-benefits) 대폭 확장
- 회계 5개 페이지(은행거래, 카드거래, 매출채권, 세금계산서, 거래처원장) 기능 개선
- ApprovalBox 소폭 수정
- CLAUDE.md 업데이트
This commit is contained in:
유병철
2026-03-04 22:19:10 +09:00
parent cde9333652
commit 23fa9c0ea2
27 changed files with 1427 additions and 511 deletions

View File

@@ -18,14 +18,18 @@ import type {
DailyReportApiResponse,
ReceivablesApiResponse,
BadDebtApiResponse,
ExpectedExpenseApiResponse,
CardTransactionApiResponse,
StatusBoardApiResponse,
TodayIssueApiResponse,
CalendarApiResponse,
VatApiResponse,
VatDetailApiResponse,
EntertainmentApiResponse,
EntertainmentDetailApiResponse,
WelfareApiResponse,
WelfareDetailApiResponse,
ExpectedExpenseDashboardDetailApiResponse,
SalesStatusApiResponse,
PurchaseStatusApiResponse,
DailyProductionApiResponse,
@@ -38,18 +42,18 @@ import {
transformDailyReportResponse,
transformReceivableResponse,
transformDebtCollectionResponse,
transformMonthlyExpenseResponse,
transformCardManagementResponse,
transformStatusBoardResponse,
transformTodayIssueResponse,
transformCalendarResponse,
transformVatResponse,
transformVatDetailResponse,
transformEntertainmentResponse,
transformEntertainmentDetailResponse,
transformWelfareResponse,
transformWelfareDetailResponse,
transformPurchaseRecordsToModal,
transformCardTransactionsToModal,
transformBillRecordsToModal,
transformAllExpensesToModal,
transformExpectedExpenseDetailResponse,
transformSalesStatusResponse,
transformPurchaseStatusResponse,
transformDailyProductionResponse,
@@ -58,11 +62,6 @@ import {
transformDailyAttendanceResponse,
} from '@/lib/api/dashboard/transformers';
import { getPurchases } from '@/components/accounting/PurchaseManagement/actions';
import { getCardTransactionList } from '@/components/accounting/CardTransactionInquiry/actions';
import { getBills } from '@/components/accounting/BillManagement/actions';
import { formatAmount } from '@/lib/api/dashboard/transformers/common';
import type {
DailyReportData,
ReceivableData,
@@ -124,6 +123,20 @@ async function fetchCardManagementData(fallbackData?: CardManagementData) {
return transformCardManagementResponse(cardApiData, loanData, taxData, fallbackData);
}
// ============================================
// 당월 날짜 범위 유틸리티
// ============================================
function getCurrentMonthEndpoint(base: string): string {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const startDate = `${y}-${String(m).padStart(2, '0')}-01`;
const lastDay = new Date(y, m, 0).getDate();
const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return buildEndpoint(base, { start_date: startDate, end_date: endDate });
}
// ============================================
// 1~4. 단순 섹션 Hooks (파라미터 없음)
// ============================================
@@ -150,65 +163,10 @@ export function useDebtCollection() {
}
export function useMonthlyExpense() {
const [data, setData] = useState<MonthlyExpenseData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// 당월 날짜 범위
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const startDate = `${y}-${String(m).padStart(2, '0')}-01`;
const lastDay = new Date(y, m, 0).getDate();
const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const commonParams = { perPage: 9999, page: 1 };
const [purchaseResult, cardResult, billResult] = await Promise.all([
getPurchases({ ...commonParams, startDate, endDate }),
getCardTransactionList({ ...commonParams, startDate, endDate }),
getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate }),
]);
const purchases = purchaseResult.success ? purchaseResult.data : [];
const cards = cardResult.success ? cardResult.data : [];
const bills = billResult.success ? billResult.data : [];
const purchaseTotal = purchases.reduce((sum, r) => sum + r.totalAmount, 0);
const cardTotal = cards.reduce((sum, r) => sum + r.totalAmount, 0);
const billTotal = bills.reduce((sum, r) => sum + r.amount, 0);
const grandTotal = purchaseTotal + cardTotal + billTotal;
const result: MonthlyExpenseData = {
cards: [
{ id: 'me1', label: '매입', amount: purchaseTotal },
{ id: 'me2', label: '카드', amount: cardTotal },
{ id: 'me3', label: '발행어음', amount: billTotal },
{ id: 'me4', label: '총 예상 지출 합계', amount: grandTotal },
],
checkPoints: grandTotal > 0
? [{ id: 'me-total', type: 'info' as const, message: `이번 달 예상 지출은 ${formatAmount(grandTotal)}입니다.`, highlights: [{ text: formatAmount(grandTotal), color: 'blue' as const }] }]
: [{ id: 'me-total', type: 'info' as const, message: '이번 달 예상 지출이 없습니다.' }],
};
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : '데이터 로딩 실패');
console.error('MonthlyExpense API Error:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
return useDashboardFetch<ExpectedExpenseApiResponse, MonthlyExpenseData>(
getCurrentMonthEndpoint('expected-expenses/summary'),
transformMonthlyExpenseResponse,
);
}
// ============================================
@@ -350,6 +308,27 @@ export function useVat(options: UseVatOptions = {}) {
return useDashboardFetch<VatApiResponse, VatData>(endpoint, transformVatResponse);
}
// ============================================
// 9-1. VatDetail Hook (부가세 상세 - 모달용)
// ============================================
export function useVatDetail() {
const endpoint = useMemo(() => 'vat/detail', []);
const result = useDashboardFetch<VatDetailApiResponse, DetailModalConfig>(
endpoint,
transformVatDetailResponse,
{ lazy: true },
);
return {
modalConfig: result.data,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
// ============================================
// 10. Entertainment Hook (접대비)
// ============================================
@@ -423,6 +402,43 @@ export function useWelfare(options: UseWelfareOptions = {}) {
);
}
// ============================================
// 11-1. EntertainmentDetail Hook (접대비 상세 - 모달용)
// ============================================
export interface UseEntertainmentDetailOptions {
companyType?: 'large' | 'medium' | 'small';
year?: number;
quarter?: number;
}
export function useEntertainmentDetail(options: UseEntertainmentDetailOptions = {}) {
const { companyType = 'medium', year, quarter } = options;
const endpoint = useMemo(
() =>
buildEndpoint('entertainment/detail', {
company_type: companyType,
year,
quarter,
}),
[companyType, year, quarter],
);
const result = useDashboardFetch<EntertainmentDetailApiResponse, DetailModalConfig>(
endpoint,
transformEntertainmentDetailResponse,
{ lazy: true },
);
return {
modalConfig: result.data,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
// ============================================
// 12. WelfareDetail Hook (복리후생비 상세 - 모달용)
// ============================================
@@ -547,44 +563,42 @@ export function useMonthlyExpenseDetail() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// cardId → transaction_type 매핑
const CARD_TRANSACTION_TYPE: Record<MonthlyExpenseCardId, string | undefined> = {
me1: 'purchase',
me2: 'card',
me3: 'bill',
me4: undefined, // 전체
};
const fetchData = useCallback(async (cardId: MonthlyExpenseCardId, filterParams?: { startDate?: string; endDate?: string; search?: string }) => {
try {
setLoading(true);
setError(null);
// 당월 기본 날짜 범위
const now = new Date();
const startDate = filterParams?.startDate || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
const endDate = filterParams?.endDate || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2, '0')}`;
const search = filterParams?.search;
// 대시보드 전용 API 엔드포인트 구성
const transactionType = CARD_TRANSACTION_TYPE[cardId];
const params: Record<string, string | undefined> = {
transaction_type: transactionType,
start_date: filterParams?.startDate,
end_date: filterParams?.endDate,
search: filterParams?.search,
};
const endpoint = buildEndpoint('/api/proxy/expected-expenses/dashboard-detail', params);
// 전체 데이터 가져오기 (perPage 크게 설정)
const commonParams = { perPage: 9999, page: 1 };
let transformed: DetailModalConfig;
if (cardId === 'me1') {
const result = await getPurchases({ ...commonParams, startDate, endDate, search });
transformed = transformPurchaseRecordsToModal(result.success ? result.data : []);
} else if (cardId === 'me2') {
const result = await getCardTransactionList({ ...commonParams, startDate, endDate, search });
transformed = transformCardTransactionsToModal(result.success ? result.data : []);
} else if (cardId === 'me3') {
const result = await getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate, search });
transformed = transformBillRecordsToModal(result.success ? result.data : []);
} else {
// me4: 3개 모두 호출 후 합산
const [purchaseResult, cardResult, billResult] = await Promise.all([
getPurchases({ ...commonParams, startDate, endDate, search }),
getCardTransactionList({ ...commonParams, startDate, endDate, search }),
getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate, search }),
]);
transformed = transformAllExpensesToModal(
purchaseResult.success ? purchaseResult.data : [],
cardResult.success ? cardResult.data : [],
billResult.success ? billResult.data : [],
);
const response = await fetch(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 = transformExpectedExpenseDetailResponse(
result.data as ExpectedExpenseDashboardDetailApiResponse,
cardId,
);
setModalConfig(transformed);
return transformed;
@@ -712,57 +726,12 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
{ initialLoading: enableDailyAttendance },
);
// MonthlyExpense: 커스텀 (3개 페이지 API 병렬)
const [meData, setMeData] = useState<MonthlyExpenseData | null>(null);
const [meLoading, setMeLoading] = useState(enableMonthlyExpense);
const [meError, setMeError] = useState<string | null>(null);
const fetchME = useCallback(async () => {
if (!enableMonthlyExpense) return;
try {
setMeLoading(true);
setMeError(null);
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const startDate = `${y}-${String(m).padStart(2, '0')}-01`;
const lastDay = new Date(y, m, 0).getDate();
const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const commonParams = { perPage: 9999, page: 1 };
const [purchaseResult, cardResult, billResult] = await Promise.all([
getPurchases({ ...commonParams, startDate, endDate }),
getCardTransactionList({ ...commonParams, startDate, endDate }),
getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate }),
]);
const purchases = purchaseResult.success ? purchaseResult.data : [];
const cards = cardResult.success ? cardResult.data : [];
const bills = billResult.success ? billResult.data : [];
const purchaseTotal = purchases.reduce((sum, r) => sum + r.totalAmount, 0);
const cardTotal = cards.reduce((sum, r) => sum + r.totalAmount, 0);
const billTotal = bills.reduce((sum, r) => sum + r.amount, 0);
const grandTotal = purchaseTotal + cardTotal + billTotal;
setMeData({
cards: [
{ id: 'me1', label: '매입', amount: purchaseTotal },
{ id: 'me2', label: '카드', amount: cardTotal },
{ id: 'me3', label: '발행어음', amount: billTotal },
{ id: 'me4', label: '총 예상 지출 합계', amount: grandTotal },
],
checkPoints: grandTotal > 0
? [{ id: 'me-total', type: 'info' as const, message: `이번 달 예상 지출은 ${formatAmount(grandTotal)}입니다.`, highlights: [{ text: formatAmount(grandTotal), color: 'blue' as const }] }]
: [{ id: 'me-total', type: 'info' as const, message: '이번 달 예상 지출이 없습니다.' }],
});
} catch (err) {
setMeError(err instanceof Error ? err.message : '데이터 로딩 실패');
console.error('MonthlyExpense API Error:', err);
} finally {
setMeLoading(false);
}
}, [enableMonthlyExpense]);
// MonthlyExpense: 대시보드 전용 API (당월 필터)
const me = useDashboardFetch<ExpectedExpenseApiResponse, MonthlyExpenseData>(
enableMonthlyExpense ? getCurrentMonthEndpoint('expected-expenses/summary') : null,
transformMonthlyExpenseResponse,
{ initialLoading: enableMonthlyExpense },
);
// CardManagement: 커스텀 (3개 API 병렬)
const [cmData, setCmData] = useState<CardManagementData | null>(null);
@@ -785,15 +754,14 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
}, [enableCardManagement, cardManagementFallback]);
useEffect(() => {
fetchME();
fetchCM();
}, [fetchME, fetchCM]);
}, [fetchCM]);
const refetchAll = useCallback(() => {
dr.refetch();
rv.refetch();
dc.refetch();
fetchME();
me.refetch();
fetchCM();
sb.refetch();
ss.refetch();
@@ -803,13 +771,13 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
cs.refetch();
da.refetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dr.refetch, rv.refetch, dc.refetch, fetchME, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]);
}, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]);
return {
dailyReport: { data: dr.data, loading: dr.loading, error: dr.error },
receivable: { data: rv.data, loading: rv.loading, error: rv.error },
debtCollection: { data: dc.data, loading: dc.loading, error: dc.error },
monthlyExpense: { data: meData, loading: meLoading, error: meError },
monthlyExpense: { data: me.data, loading: me.loading, error: me.error },
cardManagement: { data: cmData, loading: cmLoading, error: cmError },
statusBoard: { data: sb.data, loading: sb.loading, error: sb.error },
salesStatus: { data: ss.data, loading: ss.loading, error: ss.error },

View File

@@ -52,7 +52,7 @@ export interface UseCardManagementModalsReturn {
/** 에러 메시지 */
error: string | null;
/** 특정 카드의 모달 데이터 조회 - 데이터 직접 반환 */
fetchModalData: (cardId: CardManagementCardId) => Promise<CardManagementModalData>;
fetchModalData: (cardId: CardManagementCardId, params?: { start_date?: string; end_date?: string }) => Promise<CardManagementModalData>;
/** 모든 카드 데이터 조회 */
fetchAllData: () => Promise<void>;
/** 데이터 초기화 */
@@ -105,11 +105,15 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
/**
* cm2: 가지급금 상세 데이터 조회
* @param params - 날짜 필터 (선택)
* @returns 조회된 데이터 (실패 시 null)
*/
const fetchCm2Data = useCallback(async (): Promise<LoanDashboardApiResponse | null> => {
const fetchCm2Data = useCallback(async (params?: {
start_date?: string;
end_date?: string;
}): Promise<LoanDashboardApiResponse | null> => {
try {
const response = await fetchLoanDashboard();
const response = await fetchLoanDashboard(params);
if (response.success && response.data) {
setCm2Data(response.data);
return response.data;
@@ -148,10 +152,14 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
/**
* 특정 카드의 모달 데이터 조회
* @param params - cm2용 날짜 필터 (선택)
* @returns 조회된 모달 데이터 객체 (카드 ID에 해당하는 데이터만 포함)
*/
const fetchModalData = useCallback(
async (cardId: CardManagementCardId): Promise<CardManagementModalData> => {
async (cardId: CardManagementCardId, params?: {
start_date?: string;
end_date?: string;
}): Promise<CardManagementModalData> => {
setLoading(true);
setError(null);
@@ -163,7 +171,7 @@ export function useCardManagementModals(): UseCardManagementModalsReturn {
result.cm1Data = await fetchCm1Data();
break;
case 'cm2':
result.cm2Data = await fetchCm2Data();
result.cm2Data = await fetchCm2Data(params);
break;
case 'cm3':
case 'cm4': {

View File

@@ -38,8 +38,8 @@ export function useDashboardFetch<TApi, TResult>(
const [loading, setLoading] = useState(options?.initialLoading ?? !lazy);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!endpoint) return;
const fetchData = useCallback(async (): Promise<TResult | null> => {
if (!endpoint) return null;
try {
setLoading(true);
setError(null);
@@ -55,10 +55,12 @@ export function useDashboardFetch<TApi, TResult>(
const transformed = transformer(result.data);
setData(transformed);
return transformed;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
setError(errorMessage);
console.error(`Dashboard API Error [${endpoint}]:`, err);
return null;
} finally {
setLoading(false);
}
@@ -70,7 +72,7 @@ export function useDashboardFetch<TApi, TResult>(
}
}, [lazy, endpoint, fetchData]);
return { data, loading, error, refetch: fetchData };
return { data, loading, error, refetch: fetchData, setData };
}
/**