feat: CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 개선 및 회계 페이지 확장
- 접대비/복리후생비 섹션: 리스크감지형 구조로 변경 - 매출채권 섹션: transformer/타입 정비 - 캘린더 섹션: ScheduleDetailModal 개선 - 카드관리 모달 transformer 확장 - useCEODashboard 훅 리팩토링 및 정리 - dashboard endpoints/types/transformers (expense, receivable, tax-benefits) 대폭 확장 - 회계 5개 페이지(은행거래, 카드거래, 매출채권, 세금계산서, 거래처원장) 기능 개선 - ApprovalBox 소폭 수정 - CLAUDE.md 업데이트
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -546,7 +546,7 @@ export function ApprovalBox() {
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
|
||||
@@ -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_')}
|
||||
/>
|
||||
|
||||
{/* 항목 설정 모달 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -693,6 +693,7 @@ export interface PeriodSelectConfig {
|
||||
enabled: boolean;
|
||||
options: { value: string; label: string }[];
|
||||
defaultValue?: string;
|
||||
onPeriodChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
// 상세 모달 전체 설정 타입
|
||||
|
||||
Reference in New Issue
Block a user