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

@@ -51,12 +51,27 @@ import {
getBankAccountOptions,
getFinancialInstitutions,
batchSaveTransactions,
exportBankTransactionsExcel,
type BankTransactionSummaryData,
} from './actions';
import { TransactionFormModal } from './TransactionFormModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<BankTransaction & Record<string, unknown>>[] = [
{ header: '거래일시', key: 'transactionDate', width: 12 },
{ header: '구분', key: 'type', width: 8,
transform: (v) => v === 'deposit' ? '입금' : '출금' },
{ header: '은행명', key: 'bankName', width: 12 },
{ header: '계좌명', key: 'accountName', width: 15 },
{ header: '적요/내용', key: 'note', width: 20 },
{ header: '입금', key: 'depositAmount', width: 14 },
{ header: '출금', key: 'withdrawalAmount', width: 14 },
{ header: '잔액', key: 'balance', width: 14 },
{ header: '취급점', key: 'branch', width: 12 },
{ header: '상대계좌예금주명', key: 'depositorName', width: 18 },
];
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
const tableColumns = [
@@ -226,22 +241,45 @@ export function BankTransactionInquiry() {
}
}, [localChanges, loadData]);
// 엑셀 다운로드
// 엑셀 다운로드 (프론트 xlsx 생성)
const handleExcelDownload = useCallback(async () => {
try {
const result = await exportBankTransactionsExcel({
startDate,
endDate,
accountCategory: accountCategoryFilter,
financialInstitution: financialInstitutionFilter,
});
if (result.success && result.data) {
window.open(result.data.downloadUrl, '_blank');
toast.info('엑셀 파일 생성 중...');
const allData: BankTransaction[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getBankTransactionList({
startDate,
endDate,
accountCategory: accountCategoryFilter,
financialInstitution: financialInstitutionFilter,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({
data: allData as (BankTransaction & Record<string, unknown>)[],
columns: excelColumns,
filename: '계좌입출금내역',
sheetName: '입출금내역',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);

View File

@@ -55,6 +55,29 @@ import { JournalEntryModal } from './JournalEntryModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount';
import { filterByEnum } from '@/lib/utils/search';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<CardTransaction>[] = [
{ header: '사용일시', key: 'usedAt', width: 18 },
{ header: '카드사', key: 'cardCompany', width: 10 },
{ header: '카드번호', key: 'card', width: 12 },
{ header: '카드명', key: 'cardName', width: 12 },
{ header: '공제', key: 'deductionType', width: 10,
transform: (v) => v === 'deductible' ? '공제' : '불공제' },
{ header: '사업자번호', key: 'businessNumber', width: 15 },
{ header: '가맹점명', key: 'merchantName', width: 15 },
{ header: '증빙/판매자상호', key: 'vendorName', width: 18 },
{ header: '내역', key: 'description', width: 15 },
{ header: '합계금액', key: 'totalAmount', width: 12 },
{ header: '공급가액', key: 'supplyAmount', width: 12 },
{ header: '세액', key: 'taxAmount', width: 10 },
{ header: '계정과목', key: 'accountSubject', width: 12,
transform: (v) => {
const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v);
return found?.label || String(v || '');
}},
];
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
const tableColumns = [
@@ -269,9 +292,45 @@ export function CardTransactionInquiry() {
setShowJournalEntry(true);
}, []);
const handleExcelDownload = useCallback(() => {
toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.');
}, []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: CardTransaction[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getCardTransactionList({
startDate,
endDate,
search: searchQuery || undefined,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel<CardTransaction & Record<string, unknown>>({
data: allData as (CardTransaction & Record<string, unknown>)[],
columns: excelColumns as ExcelColumn<CardTransaction & Record<string, unknown>>[],
filename: '카드사용내역',
sheetName: '카드사용내역',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, searchQuery]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<CardTransaction> = useMemo(

View File

@@ -32,9 +32,10 @@ import {
CATEGORY_LABELS,
SORT_OPTIONS,
} from './types';
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions';
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos } from './actions';
import { toast } from 'sonner';
import { filterByText } from '@/lib/utils/search';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
@@ -213,27 +214,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
});
}, []);
// ===== 엑셀 다운로드 핸들러 =====
// ===== 엑셀 다운로드 핸들러 (프론트 xlsx 생성) =====
const handleExcelDownload = useCallback(async () => {
const result = await exportReceivablesExcel({
year: selectedYear,
search: searchQuery || undefined,
});
if (result.success && result.data) {
const url = URL.createObjectURL(result.data);
const a = document.createElement('a');
a.href = url;
a.download = result.filename || '채권현황.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('엑셀 파일이 다운로드되었습니다.');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
try {
toast.info('엑셀 파일 생성 중...');
// 데이터가 이미 로드되어 있으므로 sortedData 사용
if (sortedData.length === 0) {
toast.warning('다운로드할 데이터가 없습니다.');
return;
}
// 동적 월 컬럼 포함 엑셀 컬럼 생성
const columns: ExcelColumn<Record<string, unknown>>[] = [
{ header: '거래처', key: 'vendorName', width: 20 },
{ header: '연체', key: 'isOverdue', width: 8 },
...monthLabels.map((label, idx) => ({
header: label, key: `month_${idx}`, width: 12,
})),
{ header: '합계', key: 'total', width: 14 },
{ header: '메모', key: 'memo', width: 20 },
];
// 미수금 카테고리 기준으로 플랫 데이터 생성
const exportData = sortedData.map(vendor => {
const receivable = vendor.categories.find(c => c.category === 'receivable');
const row: Record<string, unknown> = {
vendorName: vendor.vendorName,
isOverdue: vendor.isOverdue ? '연체' : '',
};
monthLabels.forEach((_, idx) => {
row[`month_${idx}`] = receivable?.amounts.values[idx] || 0;
});
row.total = receivable?.amounts.total || 0;
row.memo = vendor.memo || '';
return row;
});
await downloadExcel({ data: exportData, columns, filename: '미수금현황', sheetName: '미수금현황' });
toast.success('엑셀 다운로드 완료');
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [selectedYear, searchQuery]);
}, [sortedData, monthLabels]);
// ===== 변경된 연체 항목 확인 =====
const changedOverdueItems = useMemo(() => {

View File

@@ -45,8 +45,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
import {
getTaxInvoices,
getTaxInvoiceSummary,
downloadTaxInvoiceExcel,
} from './actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
const ManualEntryModal = dynamic(
() => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })),
@@ -58,6 +58,10 @@ import type {
TaxInvoiceMgmtRecord,
InvoiceTab,
TaxInvoiceSummary,
TaxType,
ReceiptType,
InvoiceStatus,
InvoiceSource,
} from './types';
import {
TAB_OPTIONS,
@@ -77,6 +81,26 @@ const QUARTER_BUTTONS = [
{ value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 },
];
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<TaxInvoiceMgmtRecord & Record<string, unknown>>[] = [
{ header: '작성일자', key: 'writeDate', width: 12 },
{ header: '발급일자', key: 'issueDate', width: 12 },
{ header: '거래처', key: 'vendorName', width: 20 },
{ header: '사업자번호', key: 'vendorBusinessNumber', width: 15 },
{ header: '과세형태', key: 'taxType', width: 10,
transform: (v) => TAX_TYPE_LABELS[v as TaxType] || String(v || '') },
{ header: '품목', key: 'itemName', width: 15 },
{ header: '공급가액', key: 'supplyAmount', width: 14 },
{ header: '세액', key: 'taxAmount', width: 14 },
{ header: '합계', key: 'totalAmount', width: 14 },
{ header: '영수청구', key: 'receiptType', width: 10,
transform: (v) => RECEIPT_TYPE_LABELS[v as ReceiptType] || String(v || '') },
{ header: '상태', key: 'status', width: 10,
transform: (v) => INVOICE_STATUS_MAP[v as InvoiceStatus]?.label || String(v || '') },
{ header: '발급형태', key: 'source', width: 10,
transform: (v) => INVOICE_SOURCE_LABELS[v as InvoiceSource] || String(v || '') },
];
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
@@ -224,19 +248,46 @@ export function TaxInvoiceManagement() {
loadData();
}, [loadData]);
// ===== 엑셀 다운로드 =====
// ===== 엑셀 다운로드 (프론트 xlsx 생성) =====
const handleExcelDownload = useCallback(async () => {
const result = await downloadTaxInvoiceExcel({
division: activeTab,
dateType,
startDate,
endDate,
vendorSearch,
});
if (result.success && result.data) {
window.open(result.data.url, '_blank');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
try {
toast.info('엑셀 파일 생성 중...');
const allData: TaxInvoiceMgmtRecord[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getTaxInvoices({
division: activeTab,
dateType,
startDate,
endDate,
vendorSearch,
page,
perPage: 100,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({
data: allData as (TaxInvoiceMgmtRecord & Record<string, unknown>)[],
columns: excelColumns,
filename: `세금계산서_${activeTab === 'sales' ? '매출' : '매입'}`,
sheetName: activeTab === 'sales' ? '매출' : '매입',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [activeTab, dateType, startDate, endDate, vendorSearch]);

View File

@@ -26,8 +26,9 @@ import {
type StatCard,
} from '@/components/templates/UniversalListPage';
import type { VendorLedgerItem, VendorLedgerSummary } from './types';
import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions';
import { getVendorLedgerList, getVendorLedgerSummary } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
@@ -43,6 +44,16 @@ const tableColumns = [
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true },
];
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<VendorLedgerItem & Record<string, unknown>>[] = [
{ header: '거래처명', key: 'vendorName', width: 20 },
{ header: '이월잔액', key: 'carryoverBalance', width: 14 },
{ header: '매출', key: 'sales', width: 14 },
{ header: '수금', key: 'collection', width: 14 },
{ header: '잔액', key: 'balance', width: 14 },
{ header: '결제일', key: 'paymentDate', width: 12 },
];
// ===== Props =====
interface VendorLedgerProps {
initialData?: VendorLedgerItem[];
@@ -144,24 +155,42 @@ export function VendorLedger({
);
const handleExcelDownload = useCallback(async () => {
const result = await exportVendorLedgerExcel({
startDate,
endDate,
search: searchQuery || undefined,
});
try {
toast.info('엑셀 파일 생성 중...');
const allData: VendorLedgerItem[] = [];
let page = 1;
let lastPage = 1;
if (result.success && result.data) {
const url = URL.createObjectURL(result.data);
const a = document.createElement('a');
a.href = url;
a.download = result.filename || '거래처원장.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('엑셀 파일이 다운로드되었습니다.');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
do {
const result = await getVendorLedgerList({
startDate,
endDate,
search: searchQuery || undefined,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel<VendorLedgerItem & Record<string, unknown>>({
data: allData as (VendorLedgerItem & Record<string, unknown>)[],
columns: excelColumns,
filename: '거래처원장',
sheetName: '거래처원장',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, searchQuery]);

View File

@@ -546,7 +546,7 @@ export function ApprovalBox() {
dateRangeSelector: {
enabled: true,
showPresets: false,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,

View File

@@ -34,15 +34,17 @@ import { ScheduleDetailModal, DetailModal } from './modals';
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
import { LazySection } from './LazySection';
import { EmptySection } from './components';
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useEntertainmentDetail, useWelfare, useWelfareDetail, useVatDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
import { useCardManagementModals } from '@/hooks/useCardManagementModals';
import {
getMonthlyExpenseModalConfig,
getCardManagementModalConfig,
getCardManagementModalConfigWithData,
getEntertainmentModalConfig,
getWelfareModalConfig,
getVatModalConfig,
} from './modalConfigs';
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
export function CEODashboard() {
const router = useRouter();
@@ -138,11 +140,17 @@ export function CEODashboard() {
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>(DEFAULT_DASHBOARD_SETTINGS);
// EntertainmentDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
const entertainmentDetailData = useEntertainmentDetail();
// WelfareDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
const welfareDetailData = useWelfareDetail({
calculationType: dashboardSettings.welfare.calculationType,
});
// VatDetail Hook (부가세 상세 모달용 API)
const vatDetailData = useVatDetail();
// MonthlyExpenseDetail Hook (당월 예상 지출 모달용 상세 API)
const monthlyExpenseDetailData = useMonthlyExpenseDetail();
@@ -231,9 +239,66 @@ export function CEODashboard() {
}
}, [monthlyExpenseDetailData]);
// 당월 예상 지출 모달 날짜/검색 필터 변경 → 재조회
// 모달 날짜/검색 필터 변경 → 재조회 (당월 예상 지출 + 가지급금 + 접대비 상세)
const handleDateFilterChange = useCallback(async (params: { startDate: string; endDate: string; search: string }) => {
if (!currentModalCardId) return;
// cm2: 가지급금 상세 모달 날짜 필터
if (currentModalCardId === 'cm2') {
try {
const modalData = await cardManagementModals.fetchModalData('cm2', {
start_date: params.startDate,
end_date: params.endDate,
});
const config = getCardManagementModalConfigWithData('cm2', modalData);
if (config) {
setDetailModalConfig(config);
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 복리후생비 상세 모달 날짜 필터
if (currentModalCardId === 'welfare_detail') {
try {
const response = await fetch(
`/api/proxy/welfare/detail?calculation_type=${dashboardSettings.welfare.calculationType}&start_date=${params.startDate}&end_date=${params.endDate}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformWelfareDetailResponse(result.data);
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 접대비 상세 모달 날짜 필터
if (currentModalCardId === 'entertainment_detail') {
try {
const response = await fetch(
`/api/proxy/entertainment/detail?company_type=${dashboardSettings.entertainment.companyType}&start_date=${params.startDate}&end_date=${params.endDate}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformEntertainmentDetailResponse(result.data);
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 당월 예상 지출 모달 날짜 필터
const config = await monthlyExpenseDetailData.fetchData(
currentModalCardId as MonthlyExpenseCardId,
params,
@@ -241,7 +306,7 @@ export function CEODashboard() {
if (config) {
setDetailModalConfig(config);
}
}, [currentModalCardId, monthlyExpenseDetailData]);
}, [currentModalCardId, monthlyExpenseDetailData, cardManagementModals, dashboardSettings.entertainment, dashboardSettings.welfare]);
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
const handleMonthlyExpenseClick = useCallback(() => {
@@ -249,41 +314,96 @@ export function CEODashboard() {
// 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달
// 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달
const handleCardManagementCardClick = useCallback((cardId: string) => {
const config = getCardManagementModalConfig('cm2');
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
const handleCardManagementCardClick = useCallback(async (cardId: string) => {
try {
const modalData = await cardManagementModals.fetchModalData('cm2');
const config = getCardManagementModalConfigWithData('cm2', modalData);
if (config) {
setCurrentModalCardId('cm2');
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
} catch {
// API 실패 시 fallback mock 데이터 사용
const config = getCardManagementModalConfig('cm2');
if (config) {
setCurrentModalCardId('cm2');
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}
}, []);
}, [cardManagementModals]);
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
const handleEntertainmentCardClick = useCallback((cardId: string) => {
const config = getEntertainmentModalConfig(cardId);
// 접대비 현황 카드 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
const handleEntertainmentCardClick = useCallback(async (cardId: string) => {
// et_sales 카드는 별도 정적 config 사용 (매출 상세)
if (cardId === 'et_sales') {
const config = getEntertainmentModalConfig(cardId);
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
return;
}
// 리스크 카드 → API에서 상세 데이터 fetch, 반환값 직접 사용
setCurrentModalCardId('entertainment_detail');
const apiConfig = await entertainmentDetailData.refetch();
const config = apiConfig ?? getEntertainmentModalConfig(cardId);
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}, []);
}, [entertainmentDetailData]);
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
// 복리후생비 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
const handleWelfareCardClick = useCallback(async () => {
// 1. 먼저 API에서 데이터 fetch 시도
await welfareDetailData.refetch();
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
const config = welfareDetailData.modalConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType);
const apiConfig = await welfareDetailData.refetch();
const config = apiConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType);
setDetailModalConfig(config);
setCurrentModalCardId('welfare_detail');
setIsDetailModalOpen(true);
}, [welfareDetailData, dashboardSettings.welfare.calculationType]);
// 부가세 클릭 (모든 카드가 동일한 상세 모달)
const handleVatClick = useCallback(() => {
const config = getVatModalConfig();
// 신고기간 변경 시 API 재호출
const handlePeriodChange = useCallback(async (periodValue: string) => {
// periodValue: "2026-quarter-1" → parse
const parts = periodValue.split('-');
if (parts.length < 3) return;
const [year, periodType, period] = parts;
try {
const response = await fetch(
`/api/proxy/vat/detail?period_type=${periodType}&year=${year}&period=${period}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformVatDetailResponse(result.data);
// 새 config에도 onPeriodChange 콜백 주입
if (config.periodSelect) {
config.periodSelect.onPeriodChange = handlePeriodChange;
}
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
}, []);
// 부가세 클릭 (모든 카드가 동일한 상세 모달) - API 데이터로 열기 (fallback: 정적 config)
const handleVatClick = useCallback(async () => {
setCurrentModalCardId('vat_detail');
const apiConfig = await vatDetailData.refetch();
const config = apiConfig ?? getVatModalConfig();
// onPeriodChange 콜백 주입
if (config.periodSelect) {
config.periodSelect.onPeriodChange = handlePeriodChange;
}
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}, []);
}, [vatDetailData, handlePeriodChange]);
// 캘린더 일정 클릭 (기존 일정 수정)
const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => {
@@ -303,8 +423,8 @@ export function CEODashboard() {
setSelectedSchedule(null);
}, []);
// 일정 저장
const handleScheduleSave = useCallback((formData: {
// 일정 저장 (optimistic update — refetch 없이 로컬 상태만 갱신)
const handleScheduleSave = useCallback(async (formData: {
title: string;
department: string;
startDate: string;
@@ -315,17 +435,114 @@ export function CEODashboard() {
color: string;
content: string;
}) => {
// TODO: API 호출하여 일정 저장
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}, []);
try {
// schedule_ 접두사에서 실제 ID 추출
const rawId = selectedSchedule?.id;
const numericId = rawId?.startsWith('schedule_') ? rawId.replace('schedule_', '') : null;
// 일정 삭제
const handleScheduleDelete = useCallback((id: string) => {
// TODO: API 호출하여 일정 삭제
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}, []);
const body = {
title: formData.title,
description: formData.content,
start_date: formData.startDate,
end_date: formData.endDate,
start_time: formData.isAllDay ? null : (formData.startTime || null),
end_time: formData.isAllDay ? null : (formData.endTime || null),
is_all_day: formData.isAllDay,
color: formData.color || null,
};
const url = numericId
? `/api/proxy/calendar/schedules/${numericId}`
: '/api/proxy/calendar/schedules';
const method = numericId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error('Failed to save schedule');
// API 응답에서 실제 ID 추출 (없으면 임시 ID)
let savedId = numericId;
try {
const result = await response.json();
savedId = result.data?.id?.toString() || numericId || `temp_${Date.now()}`;
} catch {
savedId = numericId || `temp_${Date.now()}`;
}
const updatedSchedule: CalendarScheduleItem = {
id: `schedule_${savedId}`,
title: formData.title,
startDate: formData.startDate,
endDate: formData.endDate,
startTime: formData.isAllDay ? undefined : formData.startTime,
endTime: formData.isAllDay ? undefined : formData.endTime,
isAllDay: formData.isAllDay,
type: 'schedule',
department: formData.department !== 'all' ? formData.department : undefined,
color: formData.color,
};
// Optimistic update: loading 변화 없이 데이터만 갱신 → 캘린더만 리렌더
calendarData.setData((prev) => {
if (!prev) return { items: [updatedSchedule], totalCount: 1 };
if (numericId) {
// 수정: 기존 항목 교체
return {
...prev,
items: prev.items.map((item) =>
item.id === rawId ? updatedSchedule : item
),
};
}
// 신규: 추가
return {
...prev,
items: [...prev.items, updatedSchedule],
totalCount: prev.totalCount + 1,
};
});
} catch {
// 에러 시 서버 데이터로 동기화
calendarData.refetch();
} finally {
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}
}, [selectedSchedule, calendarData]);
// 일정 삭제 (optimistic update)
const handleScheduleDelete = useCallback(async (id: string) => {
try {
// schedule_ 접두사에서 실제 ID 추출
const numericId = id.startsWith('schedule_') ? id.replace('schedule_', '') : id;
const response = await fetch(`/api/proxy/calendar/schedules/${numericId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete schedule');
// Optimistic update: 삭제된 항목만 제거 → 캘린더만 리렌더
calendarData.setData((prev) => {
if (!prev) return prev;
return {
...prev,
items: prev.items.filter((item) => item.id !== id),
totalCount: Math.max(0, prev.totalCount - 1),
};
});
} catch {
// 에러 시 서버 데이터로 동기화
calendarData.refetch();
} finally {
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}
}, [calendarData]);
// 섹션 순서
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
@@ -548,13 +765,14 @@ export function CEODashboard() {
{sectionOrder.map(renderDashboardSection)}
</div>
{/* 일정 상세 모달 */}
{/* 일정 상세 모달 — schedule_ 접두사만 수정/삭제 가능 */}
<ScheduleDetailModal
isOpen={isScheduleModalOpen}
onClose={handleScheduleModalClose}
schedule={selectedSchedule}
onSave={handleScheduleSave}
onDelete={handleScheduleDelete}
isEditable={!selectedSchedule?.id || selectedSchedule.id === '' || selectedSchedule.id.startsWith('schedule_')}
/>
{/* 항목 설정 모달 */}

View File

@@ -319,8 +319,8 @@ export const AmountCardItem = ({
<div className="mt-auto space-y-0.5 text-xs text-muted-foreground">
{card.subItems.map((item, idx) => (
<div key={idx} className="flex justify-between gap-2">
<span className="shrink-0">{item.label}</span>
<span className="text-right">{typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}</span>
<span className="min-w-0 truncate">{item.label}</span>
<span className="shrink-0 text-right">{typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}</span>
</div>
))}
</div>

View File

@@ -175,21 +175,39 @@ export function transformCm1ModalConfig(
// cm2: 가지급금 상세 모달 변환기
// ============================================
/** 카테고리 키 → 한글 라벨 매핑
* - category_breakdown 키: 영문 (card, congratulatory, ...)
* - loans[].category: 한글 (카드, 경조사, ...) — 백엔드 category_label accessor
* 양쪽 모두 대응
*/
const CATEGORY_LABELS: Record<string, string> = {
// 영문 키 (category_breakdown용)
card: '카드',
congratulatory: '경조사',
gift_certificate: '상품권',
entertainment: '접대비',
// 한글 값 (loans[].category가 이미 한글인 경우 — 그대로 통과)
'카드': '카드',
'경조사': '경조사',
'상품권': '상품권',
'접대비': '접대비',
};
/**
* 가지급금 대시보드 API 응답을 cm2 모달 설정으로 변환
*/
export function transformCm2ModalConfig(
data: LoanDashboardApiResponse
): DetailModalConfig {
const { summary, items = [] } = data;
const { summary, category_breakdown, loans = [] } = data;
// 테이블 데이터 매핑
const tableData = (items || []).map((item) => ({
// 테이블 데이터 매핑 (백엔드 필드명 기준, 영문 키 → 한글 변환)
const tableData = (loans || []).map((item) => ({
date: item.loan_date,
classification: item.status_label || '카드',
category: '-',
classification: CATEGORY_LABELS[item.category] || item.category || '카드',
category: item.status_label || '-',
amount: item.amount,
content: item.description,
response: item.content,
}));
// 분류 필터 옵션 동적 생성
@@ -202,22 +220,42 @@ export function transformCm2ModalConfig(
})),
];
// reviewCards: category_breakdown에서 4개 카테고리 카드 생성
const reviewCards = category_breakdown
? {
title: '가지급금 검토 필요',
cards: Object.entries(category_breakdown).map(([key, breakdown]) => ({
label: CATEGORY_LABELS[key] || key,
amount: breakdown.outstanding_amount,
subLabel: breakdown.unverified_count > 0
? `미증빙 ${breakdown.unverified_count}`
: `${breakdown.total_count}`,
})),
}
: undefined;
return {
title: '가지급금 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [
{ label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) },
{ label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' },
{ label: '미정리/미분류', value: `${summary.pending_count ?? 0}` },
{ label: '건수', value: `${summary.outstanding_count ?? 0}` },
],
reviewCards,
table: {
title: '가지급금 관련 내역',
title: '가지급금 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '발생일', align: 'center' },
{ key: 'classification', label: '분류', align: 'center' },
{ key: 'category', label: '구분', align: 'center' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'content', label: '내용', align: 'left' },
{ key: 'response', label: '대응', align: 'left' },
],
data: tableData,
filters: [
@@ -227,11 +265,12 @@ export function transformCm2ModalConfig(
defaultValue: 'all',
},
{
key: 'category',
key: 'sortOrder',
options: [
{ value: 'all', label: '전체' },
{ value: '카드명', label: '카드명' },
{ value: '계좌명', label: '계좌명' },
{ value: 'all', label: '정렬' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
{ value: 'latest', label: '최신순' },
],
defaultValue: 'all',
},

View File

@@ -224,11 +224,15 @@ export function getEntertainmentModalConfig(cardId: string): DetailModalConfig |
totalColumnKey: 'amount',
},
},
// et_limit, et_remaining, et_used는 모두 동일한 접대비 상세 모달
// D1.7 리스크감지형 카드 ID → 접대비 상세 모달
et_weekend: entertainmentDetailConfig,
et_prohibited: entertainmentDetailConfig,
et_high_amount: entertainmentDetailConfig,
et_no_receipt: entertainmentDetailConfig,
// 레거시 카드 ID (하위 호환)
et_limit: entertainmentDetailConfig,
et_remaining: entertainmentDetailConfig,
et_used: entertainmentDetailConfig,
// 대시보드 카드 ID (et1~et4) → 접대비 상세 모달
et1: entertainmentDetailConfig,
et2: entertainmentDetailConfig,
et3: entertainmentDetailConfig,

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -58,6 +58,16 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const [searchText, setSearchText] = useState('');
const isInitialMount = useRef(true);
// 날짜 변경 시 자동 조회 (다른 페이지와 동일한 UX)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onFilterChange?.({ startDate, endDate, search: searchText });
}, [startDate, endDate]);
const handleSearch = useCallback(() => {
onFilterChange?.({ startDate, endDate, search: searchText });
@@ -88,11 +98,6 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt
/>
</div>
)}
{onFilterChange && (
<Button size="sm" onClick={handleSearch} className="h-8 px-3 text-xs">
</Button>
)}
</div>
}
/>
@@ -103,10 +108,15 @@ export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilt
export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => {
const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || '');
const handleChange = useCallback((value: string) => {
setSelected(value);
config.onPeriodChange?.(value);
}, [config]);
return (
<div className="flex items-center gap-2 pb-4 border-b">
<span className="text-sm text-gray-600 font-medium"></span>
<Select value={selected} onValueChange={setSelected}>
<Select value={selected} onValueChange={handleChange}>
<SelectTrigger className="h-8 w-auto min-w-[200px] text-xs">
<SelectValue />
</SelectTrigger>

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { TimePicker } from '@/components/ui/time-picker';
import { DatePicker } from '@/components/ui/date-picker';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import {
Dialog,
DialogContent,
@@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import type { CalendarScheduleItem } from '../types';
// 색상 옵션
@@ -59,6 +60,7 @@ interface ScheduleDetailModalProps {
schedule: CalendarScheduleItem | null;
onSave: (data: ScheduleFormData) => void;
onDelete?: (id: string) => void;
isEditable?: boolean;
}
export function ScheduleDetailModal({
@@ -67,6 +69,7 @@ export function ScheduleDetailModal({
schedule,
onSave,
onDelete,
isEditable = true,
}: ScheduleDetailModalProps) {
const isEditMode = schedule && schedule.id !== '';
@@ -128,7 +131,14 @@ export function ScheduleDetailModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg font-bold"> </DialogTitle>
<DialogTitle className="text-lg font-bold flex items-center gap-2">
{!isEditable && (
<Badge variant="secondary" className="text-xs bg-gray-100 text-gray-600">
</Badge>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -139,6 +149,7 @@ export function ScheduleDetailModal({
value={formData.title}
onChange={(e) => handleFieldChange('title', e.target.value)}
placeholder="제목"
disabled={!isEditable}
/>
</div>
@@ -148,6 +159,7 @@ export function ScheduleDetailModal({
<Select
value={formData.department}
onValueChange={(value) => handleFieldChange('department', value)}
disabled={!isEditable}
>
<SelectTrigger>
<SelectValue placeholder="부서명" />
@@ -165,23 +177,15 @@ export function ScheduleDetailModal({
{/* 기간 */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label>
<div className="flex flex-col gap-2">
<DatePicker
value={formData.startDate}
onChange={(value) => handleFieldChange('startDate', value)}
size="sm"
className="w-full"
/>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-xs">~</span>
<DatePicker
value={formData.endDate}
onChange={(value) => handleFieldChange('endDate', value)}
size="sm"
className="w-full"
/>
</div>
</div>
<DateRangePicker
startDate={formData.startDate}
endDate={formData.endDate}
onStartDateChange={(date) => handleFieldChange('startDate', date)}
onEndDateChange={(date) => handleFieldChange('endDate', date)}
size="sm"
className="w-full"
disabled={!isEditable}
/>
</div>
{/* 시간 */}
@@ -196,6 +200,7 @@ export function ScheduleDetailModal({
onCheckedChange={(checked) =>
handleFieldChange('isAllDay', checked === true)
}
disabled={!isEditable}
/>
<label htmlFor="isAllDay" className="text-sm text-gray-600 cursor-pointer">
@@ -236,9 +241,10 @@ export function ScheduleDetailModal({
formData.color === color.value
? 'ring-2 ring-offset-2 ring-gray-400'
: 'hover:scale-110'
}`}
onClick={() => handleFieldChange('color', color.value)}
} ${!isEditable ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => isEditable && handleFieldChange('color', color.value)}
title={color.label}
disabled={!isEditable}
/>
))}
</div>
@@ -252,26 +258,35 @@ export function ScheduleDetailModal({
onChange={(e) => handleFieldChange('content', e.target.value)}
placeholder="내용"
className="min-h-[100px] resize-none"
disabled={!isEditable}
/>
</div>
</div>
<DialogFooter className="flex flex-row gap-2 pt-2">
{isEditMode && onDelete && (
<Button
variant="outline"
onClick={handleDelete}
className="bg-gray-800 text-white hover:bg-gray-900"
>
{isEditable ? (
<>
{isEditMode && onDelete && (
<Button
variant="outline"
onClick={handleDelete}
className="bg-gray-800 text-white hover:bg-gray-900"
>
</Button>
)}
<Button
onClick={handleSave}
className="bg-gray-800 text-white hover:bg-gray-900"
>
{isEditMode ? '수정' : '등록'}
</Button>
</>
) : (
<Button variant="outline" onClick={handleCancel}>
</Button>
)}
<Button
onClick={handleSave}
className="bg-gray-800 text-white hover:bg-gray-900"
>
{isEditMode ? '수정' : '등록'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -44,6 +44,22 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
tax: 'orange',
};
// 일정 타입별 라벨
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
order: '생산',
construction: '시공',
schedule: '일정',
other: '기타',
};
// 일정 타입별 뱃지 색상
const SCHEDULE_TYPE_BADGE_COLORS: Record<string, string> = {
order: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
construction: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
// 이슈 뱃지별 색상
const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'수주등록': 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
@@ -453,6 +469,11 @@ export function CalendarSection({
return (
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
{isSelected && evType !== 'holiday' && evType !== 'tax' && evType !== 'issue' && (
<span className={`text-[10px] shrink-0 px-1 rounded ${SCHEDULE_TYPE_BADGE_COLORS[evType] || ''}`}>
{SCHEDULE_TYPE_LABELS[evType] || ''}
</span>
)}
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
</div>
);
@@ -469,8 +490,8 @@ export function CalendarSection({
</div>
</div>
{/* 데스크탑: 기존 캘린더 + 상세 */}
<div className="hidden lg:grid lg:grid-cols-2 gap-6">
{/* 데스크탑: 캘린더 + 상세 (태블릿: 세로배치, 와이드: 가로배치) */}
<div className="hidden lg:grid lg:grid-cols-1 xl:grid-cols-2 gap-6">
{/* 캘린더 영역 */}
<div>
<ScheduleCalendar
@@ -554,7 +575,12 @@ export function CalendarSection({
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onScheduleClick?.(schedule)}
>
<div className="font-medium text-base text-foreground mb-1">{schedule.title}</div>
<div className="flex items-start gap-2 mb-1">
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
</Badge>
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
</div>
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
</div>
))}

View File

@@ -1,12 +1,12 @@
'use client';
import { Wine, Utensils, Users, CreditCard } from 'lucide-react';
import { Moon, ShieldAlert, Banknote, FileWarning, Wine } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { EntertainmentData } from '../types';
// 카드별 아이콘 매핑
const CARD_ICONS = [Wine, Utensils, Users, CreditCard];
const CARD_THEMES: SectionColorTheme[] = ['pink', 'purple', 'indigo', 'red'];
// 카드별 아이콘 매핑 (주말/심야, 기피업종, 고액결제, 증빙미비)
const CARD_ICONS = [Moon, ShieldAlert, Banknote, FileWarning];
const CARD_THEMES: SectionColorTheme[] = ['purple', 'red', 'orange', 'pink'];
interface EntertainmentSectionProps {
data: EntertainmentData;

View File

@@ -1,13 +1,13 @@
'use client';
import { useRouter } from 'next/navigation';
import { Banknote, Clock, AlertTriangle, CircleDollarSign, ChevronRight } from 'lucide-react';
import { Banknote, CircleDollarSign, Building2, TrendingUp, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { ReceivableData } from '../types';
// 카드별 아이콘 매핑 (미수금 합계, 30일 이내, 30~90일, 90일 초과)
const CARD_ICONS = [CircleDollarSign, Banknote, Clock, AlertTriangle];
// 카드별 아이콘 매핑 (누적미수금, 당월미수금, 거래처, Top3)
const CARD_ICONS = [CircleDollarSign, Banknote, Building2, TrendingUp];
const CARD_THEMES: SectionColorTheme[] = ['amber', 'green', 'orange', 'red'];
interface ReceivableSectionProps {

View File

@@ -1,12 +1,12 @@
'use client';
import { Heart, Gift, Coffee, Smile } from 'lucide-react';
import { Receipt, Moon, UserX, BarChart3, Heart } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { WelfareData } from '../types';
// 카드별 아이콘 매핑
const CARD_ICONS = [Heart, Gift, Coffee, Smile];
const CARD_THEMES: SectionColorTheme[] = ['emerald', 'green', 'cyan', 'blue'];
// 카드별 아이콘 매핑 (비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과)
const CARD_ICONS = [Receipt, Moon, UserX, BarChart3];
const CARD_THEMES: SectionColorTheme[] = ['red', 'purple', 'orange', 'cyan'];
interface WelfareSectionProps {
data: WelfareData;

View File

@@ -693,6 +693,7 @@ export interface PeriodSelectConfig {
enabled: boolean;
options: { value: string; label: string }[];
defaultValue?: string;
onPeriodChange?: (value: string) => void;
}
// 상세 모달 전체 설정 타입