refactor: [CEO대시보드] 컴포넌트 분리 및 모달/섹션 리팩토링

- DashboardSettingsSections, DetailModalSections 분리
- 모달 설정(카드/접대비/복리후생/부가세/월비용) 개선
- 섹션 컴포넌트 최적화 (매출/매입/카드/미출고 등)
- mockData, types 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-03-01 12:20:05 +09:00
parent db84d6796b
commit 4e179d2eca
20 changed files with 1645 additions and 1628 deletions

View File

@@ -35,12 +35,10 @@ import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
import { mockData } from './mockData'; import { mockData } from './mockData';
import { LazySection } from './LazySection'; import { LazySection } from './LazySection';
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail } from '@/hooks/useCEODashboard'; import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail } from '@/hooks/useCEODashboard';
import { useCardManagementModals, type CardManagementCardId } from '@/hooks/useCardManagementModals'; import { useCardManagementModals } from '@/hooks/useCardManagementModals';
import type { MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
import { import {
getMonthlyExpenseModalConfig, getMonthlyExpenseModalConfig,
getCardManagementModalConfig, getCardManagementModalConfig,
getCardManagementModalConfigWithData,
getEntertainmentModalConfig, getEntertainmentModalConfig,
getWelfareModalConfig, getWelfareModalConfig,
getVatModalConfig, getVatModalConfig,
@@ -93,19 +91,24 @@ export function CEODashboard() {
const data = useMemo<CEODashboardData>(() => ({ const data = useMemo<CEODashboardData>(() => ({
...mockData, ...mockData,
// Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback // Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback
// TODO: 자금현황 카드 변경 (일일일보/매출채권/매입채무/운영자금) - 새 API 구현 후 교체 // TODO: 자금현황 카드 변경 (일일일보/미수금/미지급금/당월예상지출) - 새 API 구현 후 교체
dailyReport: mockData.dailyReport, dailyReport: mockData.dailyReport,
receivable: apiData.receivable.data ?? mockData.receivable, // TODO: D1.7 카드 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체
// cardManagement: 카드/경조사/상품권/접대비 (기존: 카드/가지급금/법인세/종합세)
// entertainment: 주말심야/기피업종/고액결제/증빙미비 (기존: 매출/한도/잔여한도/사용금액)
// welfare: 비과세초과/사적사용/특정인편중/한도초과 (기존: 한도/잔여한도/사용금액)
// receivable: 누적/당월/거래처/Top3 (기존: 누적/당월/거래처현황)
receivable: mockData.receivable,
debtCollection: apiData.debtCollection.data ?? mockData.debtCollection, debtCollection: apiData.debtCollection.data ?? mockData.debtCollection,
monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense, monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense,
cardManagement: apiData.cardManagement.data ?? mockData.cardManagement, cardManagement: mockData.cardManagement,
// Phase 2 섹션들 (API 연동 완료 - 목업 fallback 제거) // Phase 2 섹션들 (API 연동 완료 - 목업 fallback 제거)
todayIssue: apiData.statusBoard.data ?? [], todayIssue: apiData.statusBoard.data ?? [],
todayIssueList: todayIssueData.data?.items ?? [], todayIssueList: todayIssueData.data?.items ?? [],
calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules, calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules,
vat: vatData.data ?? mockData.vat, vat: vatData.data ?? mockData.vat,
entertainment: entertainmentData.data ?? mockData.entertainment, entertainment: mockData.entertainment,
welfare: welfareData.data ?? mockData.welfare, welfare: mockData.welfare,
// 신규 섹션 (API 미구현 - mock 데이터) // 신규 섹션 (API 미구현 - mock 데이터)
salesStatus: mockData.salesStatus, salesStatus: mockData.salesStatus,
purchaseStatus: mockData.purchaseStatus, purchaseStatus: mockData.purchaseStatus,
@@ -204,35 +207,28 @@ export function CEODashboard() {
}, []); }, []);
// 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달) // 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달)
const handleMonthlyExpenseCardClick = useCallback(async (cardId: string) => { // TODO: D1.7 모달 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체
// 1. 먼저 API에서 데이터 fetch 시도 const handleMonthlyExpenseCardClick = useCallback((cardId: string) => {
const apiConfig = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId); const config = getMonthlyExpenseModalConfig(cardId);
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
const config = apiConfig ?? getMonthlyExpenseModalConfig(cardId);
if (config) { if (config) {
setDetailModalConfig(config); setDetailModalConfig(config);
setIsDetailModalOpen(true); setIsDetailModalOpen(true);
} }
}, [monthlyExpenseDetailData]); }, []);
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체) // 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
const handleMonthlyExpenseClick = useCallback(() => { const handleMonthlyExpenseClick = useCallback(() => {
}, []); }, []);
// 카드/가지급금 관리 카드 클릭 (개별 카드 클릭 시 상세 모달) // 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달
const handleCardManagementCardClick = useCallback(async (cardId: string) => { // 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달
// 1. API에서 데이터 fetch (데이터 직접 반환) const handleCardManagementCardClick = useCallback((cardId: string) => {
const modalData = await cardManagementModals.fetchModalData(cardId as CardManagementCardId); const config = getCardManagementModalConfig('cm2');
// 2. API 데이터로 config 생성 (데이터 없으면 fallback)
const config = getCardManagementModalConfigWithData(cardId, modalData);
if (config) { if (config) {
setDetailModalConfig(config); setDetailModalConfig(config);
setIsDetailModalOpen(true); setIsDetailModalOpen(true);
} }
}, [cardManagementModals]); }, []);
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달) // 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
const handleEntertainmentCardClick = useCallback((cardId: string) => { const handleEntertainmentCardClick = useCallback((cardId: string) => {

View File

@@ -37,26 +37,15 @@ export const SECTION_THEME_STYLES: Record<SectionColorTheme, { bgClass: string;
/** /**
* 금액 포맷 함수 * 금액 포맷 함수
*/ */
export const formatAmount = (amount: number, showUnit = true): string => { const formatAmount = (amount: number, showUnit = true): string => {
const formatted = new Intl.NumberFormat('ko-KR').format(amount); const formatted = new Intl.NumberFormat('ko-KR').format(amount);
return showUnit ? formatted + '원' : formatted; return showUnit ? formatted + '원' : formatted;
}; };
/**
* 억 단위 포맷 함수
*/
export const formatBillion = (amount: number): string => {
const billion = amount / 100000000;
if (billion >= 1) {
return billion.toFixed(1) + '억원';
}
return formatAmount(amount);
};
/** /**
* USD 달러 포맷 함수 * USD 달러 포맷 함수
*/ */
export const formatUSD = (amount: number): string => { const formatUSD = (amount: number): string => {
return '$ ' + new Intl.NumberFormat('en-US').format(amount); return '$ ' + new Intl.NumberFormat('en-US').format(amount);
}; };

View File

@@ -1,10 +1,7 @@
'use client'; 'use client';
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CurrencyInput } from '@/components/ui/currency-input';
import { NumberInput } from '@/components/ui/number-input';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -12,18 +9,6 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { import type {
DashboardSettings, DashboardSettings,
@@ -34,21 +19,13 @@ import type {
WelfareCalculationType, WelfareCalculationType,
SectionKey, SectionKey,
} from '../types'; } from '../types';
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types'; import { DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types';
import {
// 현황판 항목 라벨 (구 오늘의 이슈) SectionRow,
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = { StatusBoardItemsList,
orders: '수주', EntertainmentContent,
debtCollection: '채권 추심', WelfareContent,
safetyStock: '안전 재고', } from './DashboardSettingsSections';
taxReport: '세금 신고',
newVendor: '신규 업체 등록',
annualLeave: '연차',
lateness: '지각',
absence: '결근',
purchase: '발주',
approvalRequest: '결재 요청',
};
interface DashboardSettingsDialogProps { interface DashboardSettingsDialogProps {
isOpen: boolean; isOpen: boolean;
@@ -65,6 +42,7 @@ export function DashboardSettingsDialog({
}: DashboardSettingsDialogProps) { }: DashboardSettingsDialogProps) {
const [localSettings, setLocalSettings] = useState<DashboardSettings>(settings); const [localSettings, setLocalSettings] = useState<DashboardSettings>(settings);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({ const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
todayIssueList: false,
entertainment: false, entertainment: false,
welfare: false, welfare: false,
statusBoard: false, statusBoard: false,
@@ -192,8 +170,8 @@ export function DashboardSettingsDialog({
// 접대비 설정 변경 // 접대비 설정 변경
const handleEntertainmentChange = useCallback( const handleEntertainmentChange = useCallback(
( (
key: 'enabled' | 'limitType' | 'companyType', key: 'enabled' | 'limitType' | 'companyType' | 'highAmountThreshold',
value: boolean | EntertainmentLimitType | CompanyType value: boolean | EntertainmentLimitType | CompanyType | number
) => { ) => {
setLocalSettings((prev) => ({ setLocalSettings((prev) => ({
...prev, ...prev,
@@ -248,85 +226,6 @@ export function DashboardSettingsDialog({
onClose(); onClose();
}, [settings, onClose]); }, [settings, onClose]);
// 커스텀 스위치 (라이트 테마용)
const ToggleSwitch = ({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) => (
<button
type="button"
onClick={() => onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
checked ? 'bg-blue-500' : 'bg-gray-300'
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow-md transition-transform',
checked ? 'translate-x-6' : 'translate-x-1'
)}
/>
</button>
);
// 섹션 행 컴포넌트 (라이트 테마)
const SectionRow = ({
label,
checked,
onCheckedChange,
hasExpand,
isExpanded,
onToggleExpand,
children,
showGrip,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
hasExpand?: boolean;
isExpanded?: boolean;
onToggleExpand?: () => void;
children?: React.ReactNode;
showGrip?: boolean;
}) => (
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
<div
className={cn(
'flex items-center justify-between py-3 px-4 bg-gray-200',
children && isExpanded ? 'rounded-t-lg' : 'rounded-lg'
)}
>
<div className="flex items-center gap-2">
{showGrip && (
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab flex-shrink-0" />
)}
{hasExpand && (
<CollapsibleTrigger asChild>
<button type="button" className="p-1 hover:bg-gray-300 rounded">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-500" />
) : (
<ChevronDown className="h-4 w-4 text-gray-500" />
)}
</button>
</CollapsibleTrigger>
)}
<span className="text-sm font-medium text-gray-800">{label}</span>
</div>
<ToggleSwitch checked={checked} onCheckedChange={onCheckedChange} />
</div>
{children && (
<CollapsibleContent className="px-4 py-3 space-y-3 bg-gray-50 rounded-b-lg">
{children}
</CollapsibleContent>
)}
</Collapsible>
);
// 섹션 렌더링 함수 // 섹션 렌더링 함수
const renderSection = (key: SectionKey): React.ReactNode => { const renderSection = (key: SectionKey): React.ReactNode => {
switch (key) { switch (key) {
@@ -336,8 +235,16 @@ export function DashboardSettingsDialog({
label={SECTION_LABELS.todayIssueList} label={SECTION_LABELS.todayIssueList}
checked={localSettings.todayIssueList} checked={localSettings.todayIssueList}
onCheckedChange={handleTodayIssueListToggle} onCheckedChange={handleTodayIssueListToggle}
hasExpand
isExpanded={expandedSections.todayIssueList}
onToggleExpand={() => toggleSection('todayIssueList')}
showGrip showGrip
/> >
<StatusBoardItemsList
items={localSettings.statusBoard?.items ?? localSettings.todayIssue.items}
onToggle={handleStatusBoardItemToggle}
/>
</SectionRow>
); );
case 'dailyReport': case 'dailyReport':
@@ -361,26 +268,10 @@ export function DashboardSettingsDialog({
onToggleExpand={() => toggleSection('statusBoard')} onToggleExpand={() => toggleSection('statusBoard')}
showGrip showGrip
> >
<div className="space-y-0"> <StatusBoardItemsList
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map( items={localSettings.statusBoard?.items ?? localSettings.todayIssue.items}
(itemKey) => ( onToggle={handleStatusBoardItemToggle}
<div />
key={itemKey}
className="flex items-center justify-between py-2.5 px-2"
>
<span className="text-sm text-gray-600">
{STATUS_BOARD_LABELS[itemKey]}
</span>
<ToggleSwitch
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[itemKey]}
onCheckedChange={(checked) =>
handleStatusBoardItemToggle(itemKey, checked)
}
/>
</div>
)
)}
</div>
</SectionRow> </SectionRow>
); );
@@ -415,211 +306,12 @@ export function DashboardSettingsDialog({
onToggleExpand={() => toggleSection('entertainment')} onToggleExpand={() => toggleSection('entertainment')}
showGrip showGrip
> >
<div className="space-y-3"> <EntertainmentContent
<div className="flex items-center justify-between"> entertainment={localSettings.entertainment}
<span className="text-sm text-gray-600"> </span> onChange={handleEntertainmentChange}
<Select companyTypeInfoExpanded={expandedSections.companyTypeInfo}
value={localSettings.entertainment.limitType} onToggleCompanyTypeInfo={() => toggleSection('companyTypeInfo')}
onValueChange={(value: EntertainmentLimitType) => />
handleEntertainmentChange('limitType', value)
}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="annual"></SelectItem>
<SelectItem value="quarterly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={localSettings.entertainment.companyType}
onValueChange={(value: CompanyType) =>
handleEntertainmentChange('companyType', value)
}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="large"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="small"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 기업 구분 방법 설명 패널 */}
<Collapsible
open={expandedSections.companyTypeInfo}
onOpenChange={() => toggleSection('companyTypeInfo')}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center justify-between w-full py-2 px-3 text-sm text-gray-600 bg-gray-100 rounded hover:bg-gray-200"
>
<span> </span>
{expandedSections.companyTypeInfo ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 p-3 bg-white border border-gray-200 rounded text-xs space-y-4">
{/* ■ 중소기업 판단 기준표 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-800"></span>
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000</td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">·</td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
</tbody>
</table>
</div>
{/* ① 업종별 매출액 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> ( 3 )</span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,500 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">·</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400 </td></tr>
</tbody>
</table>
</div>
{/* ② 자산총액 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000 </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
</tbody>
</table>
</div>
{/* ③ 독립성 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> 30% </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> · </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
</tbody>
</table>
</div>
{/* ■ 판정 결과 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-800"></span>
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">3,600</td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,200</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</SectionRow> </SectionRow>
); );
@@ -634,87 +326,10 @@ export function DashboardSettingsDialog({
onToggleExpand={() => toggleSection('welfare')} onToggleExpand={() => toggleSection('welfare')}
showGrip showGrip
> >
<div className="space-y-3"> <WelfareContent
<div className="flex items-center justify-between"> welfare={localSettings.welfare}
<span className="text-sm text-gray-600"> </span> onChange={handleWelfareChange}
<Select />
value={localSettings.welfare.limitType}
onValueChange={(value: WelfareLimitType) =>
handleWelfareChange('limitType', value)
}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="annual"></SelectItem>
<SelectItem value="quarterly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={localSettings.welfare.calculationType}
onValueChange={(value: WelfareCalculationType) =>
handleWelfareChange('calculationType', value)
}
>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed"> </SelectItem>
<SelectItem value="ratio"> X </SelectItem>
</SelectContent>
</Select>
</div>
{localSettings.welfare.calculationType === 'fixed' ? (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> /</span>
<div className="flex items-center gap-1">
<CurrencyInput
value={localSettings.welfare.fixedAmountPerMonth}
onChange={(value) =>
handleWelfareChange(
'fixedAmountPerMonth',
value ?? 0
)
}
className="w-28 h-8"
/>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"></span>
<div className="flex items-center gap-1">
<NumberInput
step={0.1}
allowDecimal
value={localSettings.welfare.ratio}
onChange={(value) =>
handleWelfareChange('ratio', value ?? 0)
}
className="w-20 h-8 text-right"
/>
<span className="text-sm text-gray-500">%</span>
</div>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<div className="flex items-center gap-1">
<CurrencyInput
value={localSettings.welfare.annualTotal}
onChange={(value) =>
handleWelfareChange('annualTotal', value ?? 0)
}
className="w-32 h-8"
/>
</div>
</div>
</div>
</SectionRow> </SectionRow>
); );

View File

@@ -0,0 +1,485 @@
'use client';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { CurrencyInput } from '@/components/ui/currency-input';
import { NumberInput } from '@/components/ui/number-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type {
TodayIssueSettings,
DashboardSettings,
EntertainmentLimitType,
CompanyType,
WelfareLimitType,
WelfareCalculationType,
} from '../types';
// ─── 현황판 항목 라벨 ──────────────────────────────
export const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
orders: '수주',
debtCollection: '채권 추심',
safetyStock: '안전 재고',
taxReport: '세금 신고',
newVendor: '신규 업체 등록',
annualLeave: '연차',
vehicle: '차량',
equipment: '장비',
purchase: '발주',
approvalRequest: '결재 요청',
fundStatus: '자금 현황',
};
// ─── 커스텀 스위치 ──────────────────────────────────
export function ToggleSwitch({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
checked ? 'bg-blue-500' : 'bg-gray-300'
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow-md transition-transform',
checked ? 'translate-x-6' : 'translate-x-1'
)}
/>
</button>
);
}
// ─── 섹션 행 (Collapsible 래퍼) ─────────────────────
export function SectionRow({
label,
checked,
onCheckedChange,
hasExpand,
isExpanded,
onToggleExpand,
children,
showGrip,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
hasExpand?: boolean;
isExpanded?: boolean;
onToggleExpand?: () => void;
children?: React.ReactNode;
showGrip?: boolean;
}) {
return (
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
<div
className={cn(
'flex items-center justify-between py-3 px-4 bg-gray-200',
children && isExpanded ? 'rounded-t-lg' : 'rounded-lg'
)}
>
<div className="flex items-center gap-2">
{showGrip && (
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab flex-shrink-0" />
)}
{hasExpand && (
<CollapsibleTrigger asChild>
<button type="button" className="p-1 hover:bg-gray-300 rounded">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-500" />
) : (
<ChevronDown className="h-4 w-4 text-gray-500" />
)}
</button>
</CollapsibleTrigger>
)}
<span className="text-sm font-medium text-gray-800">{label}</span>
</div>
<ToggleSwitch checked={checked} onCheckedChange={onCheckedChange} />
</div>
{children && (
<CollapsibleContent className="px-4 py-3 space-y-3 bg-gray-50 rounded-b-lg">
{children}
</CollapsibleContent>
)}
</Collapsible>
);
}
// ─── 현황판 항목 토글 리스트 ────────────────────────
export function StatusBoardItemsList({
items,
onToggle,
}: {
items: TodayIssueSettings;
onToggle: (key: keyof TodayIssueSettings, checked: boolean) => void;
}) {
return (
<div className="space-y-0">
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
(itemKey) => (
<div
key={itemKey}
className="flex items-center justify-between py-2.5 px-2"
>
<span className="text-sm text-gray-600">
{STATUS_BOARD_LABELS[itemKey]}
</span>
<ToggleSwitch
checked={items[itemKey]}
onCheckedChange={(checked) => onToggle(itemKey, checked)}
/>
</div>
)
)}
</div>
);
}
// ─── 기업 구분 방법 설명 패널 ───────────────────────
function CompanyTypeInfoPanel({
isExpanded,
onToggle,
}: {
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<Collapsible open={isExpanded} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center justify-between w-full py-2 px-3 text-sm text-gray-600 bg-gray-100 rounded hover:bg-gray-200"
>
<span> </span>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 p-3 bg-white border border-gray-200 rounded text-xs space-y-4">
{/* ■ 중소기업 판단 기준표 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-800"></span>
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000</td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">·</td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
</tbody>
</table>
</div>
{/* ① 업종별 매출액 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> ( 3 )</span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,500 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">·</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400 </td></tr>
</tbody>
</table>
</div>
{/* ② 자산총액 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000 </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
</tbody>
</table>
</div>
{/* ③ 독립성 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> 30% </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> · </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
</tbody>
</table>
</div>
{/* ■ 판정 결과 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-800"></span>
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">3,600</td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,200</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── 접대비 설정 콘텐츠 ─────────────────────────────
export function EntertainmentContent({
entertainment,
onChange,
companyTypeInfoExpanded,
onToggleCompanyTypeInfo,
}: {
entertainment: DashboardSettings['entertainment'];
onChange: (
key: 'limitType' | 'companyType' | 'highAmountThreshold',
value: EntertainmentLimitType | CompanyType | number,
) => void;
companyTypeInfoExpanded: boolean;
onToggleCompanyTypeInfo: () => void;
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={entertainment.limitType}
onValueChange={(value: EntertainmentLimitType) => onChange('limitType', value)}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="annual"></SelectItem>
<SelectItem value="quarterly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={entertainment.companyType}
onValueChange={(value: CompanyType) => onChange('companyType', value)}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="large"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<div className="flex items-center gap-1">
<CurrencyInput
value={entertainment.highAmountThreshold}
onChange={(value) => onChange('highAmountThreshold', value ?? 0)}
className="w-28 h-8"
/>
</div>
</div>
<CompanyTypeInfoPanel
isExpanded={companyTypeInfoExpanded}
onToggle={onToggleCompanyTypeInfo}
/>
</div>
);
}
// ─── 복리후생비 설정 콘텐츠 ─────────────────────────
export function WelfareContent({
welfare,
onChange,
}: {
welfare: DashboardSettings['welfare'];
onChange: (
key: keyof DashboardSettings['welfare'],
value: WelfareLimitType | WelfareCalculationType | number,
) => void;
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={welfare.limitType}
onValueChange={(value: WelfareLimitType) => onChange('limitType', value)}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="annual"></SelectItem>
<SelectItem value="quarterly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={welfare.calculationType}
onValueChange={(value: WelfareCalculationType) => onChange('calculationType', value)}
>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed"> </SelectItem>
<SelectItem value="ratio"> X </SelectItem>
</SelectContent>
</Select>
</div>
{welfare.calculationType === 'fixed' ? (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> /</span>
<div className="flex items-center gap-1">
<CurrencyInput
value={welfare.fixedAmountPerMonth}
onChange={(value) => onChange('fixedAmountPerMonth', value ?? 0)}
className="w-28 h-8"
/>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"></span>
<div className="flex items-center gap-1">
<NumberInput
step={0.1}
allowDecimal
value={welfare.ratio}
onChange={(value) => onChange('ratio', value ?? 0)}
className="w-20 h-8 text-right"
/>
<span className="text-sm text-gray-500">%</span>
</div>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<span className="text-sm font-medium text-gray-800">
{welfare.annualTotal.toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">1 </span>
<div className="flex items-center gap-1">
<CurrencyInput
value={welfare.singlePaymentThreshold}
onChange={(value) => onChange('singlePaymentThreshold', value ?? 0)}
className="w-32 h-8"
/>
</div>
</div>
</div>
);
}

View File

@@ -19,9 +19,9 @@ export const mockData: CEODashboardData = {
date: '2026년 1월 5일 월요일', date: '2026년 1월 5일 월요일',
cards: [ cards: [
{ id: 'dr1', label: '일일일보', amount: 3050000000, path: '/ko/accounting/daily-report' }, { id: 'dr1', label: '일일일보', amount: 3050000000, path: '/ko/accounting/daily-report' },
{ id: 'dr2', label: '매출채권 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' }, { id: 'dr2', label: '미수금 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' },
{ id: 'dr3', label: '매입채무 잔액', amount: 3050000000 }, { id: 'dr3', label: '미지급금 잔액', amount: 3050000000 },
{ id: 'dr4', label: '운영자금 잔여', amount: 0, displayValue: '6.2개월' }, { id: 'dr4', label: '당월 예상 지출 합계', amount: 350000000 },
], ],
checkPoints: [ checkPoints: [
{ {
@@ -91,10 +91,11 @@ export const mockData: CEODashboardData = {
cardManagement: { cardManagement: {
warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의', warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의',
cards: [ cards: [
{ id: 'cm1', label: '카드', amount: 30123000, previousLabel: '미정리 5건 (가지급금 예정)' }, { id: 'cm1', label: '카드', amount: 3123000, previousLabel: '미정리 5건' },
{ id: 'cm2', label: '가지급금', amount: 350000000, previousLabel: '전월 대비 +10.5%' }, { id: 'cm2', label: '경조사', amount: 3123000, previousLabel: '미증빙 5건' },
{ id: 'cm3', label: '법인세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' }, { id: 'cm3', label: '상품권', amount: 3123000, previousLabel: '미증빙 5건' },
{ id: 'cm4', label: '대표자 종합세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' }, { id: 'cm4', label: '접대비', amount: 3123000, previousLabel: '미증빙 5건' },
{ id: 'cm_total', label: '총 가지급금 합계', amount: 350000000 },
], ],
checkPoints: [ checkPoints: [
{ {
@@ -135,10 +136,10 @@ export const mockData: CEODashboardData = {
}, },
entertainment: { entertainment: {
cards: [ cards: [
{ id: 'et1', label: '매출', amount: 30530000000 }, { id: 'et1', label: '주말/심야', amount: 3123000, previousLabel: '미증빙 5건' },
{ id: 'et2', label: '{1사분기} 접대비 총 한도', amount: 40123000 }, { id: 'et2', label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, previousLabel: '불인정 5건' },
{ id: 'et3', label: '{1사분기} 접대비 잔여한도', amount: 30123000 }, { id: 'et3', label: '고액 결제', amount: 3123000, previousLabel: '미증빙 5건' },
{ id: 'et4', label: '{1사분기} 접대비 사용금액', amount: 10000000 }, { id: 'et4', label: '증빙 미비', amount: 3123000, previousLabel: '미증빙 5건' },
], ],
checkPoints: [ checkPoints: [
{ {
@@ -179,10 +180,10 @@ export const mockData: CEODashboardData = {
}, },
welfare: { welfare: {
cards: [ cards: [
{ id: 'wf1', label: '당해년도 복리후생비 한도', amount: 30123000 }, { id: 'wf1', label: '비과세 한도 초과', amount: 3123000, previousLabel: '5건' },
{ id: 'wf2', label: '{1사분기} 복리후생비 총 한도', amount: 10123000 }, { id: 'wf2', label: '사적 사용 의심', amount: 3123000, previousLabel: '5건' },
{ id: 'wf3', label: '{1사분기} 복리후생비 잔여한도', amount: 5123000 }, { id: 'wf3', label: '특정인 편중', amount: 3123000, previousLabel: '5건' },
{ id: 'wf4', label: '{1사분기} 복리후생비 사용금액', amount: 5123000 }, { id: 'wf4', label: '항목별 한도 초과', amount: 3123000, previousLabel: '5건' },
], ],
checkPoints: [ checkPoints: [
{ {
@@ -219,28 +220,22 @@ export const mockData: CEODashboardData = {
id: 'rv2', id: 'rv2',
label: '당월 미수금', label: '당월 미수금',
amount: 10123000, amount: 10123000,
subItems: [
{ label: '매출', value: 60123000 },
{ label: '입금', value: 30000000 },
],
}, },
{ {
id: 'rv3', id: 'rv3',
label: '회사명', label: '미수금 거래처',
amount: 3123000, amount: 31,
unit: '건',
subItems: [ subItems: [
{ label: '매출', value: 6123000 }, { label: '연체', value: '21건' },
{ label: '입금', value: 3000000 }, { label: '악성채권', value: '11건' },
], ],
}, },
{ {
id: 'rv4', id: 'rv4',
label: '회사명', label: '미수금 Top 3',
amount: 2123000, amount: 0,
subItems: [ displayValue: '상세보기',
{ label: '매출', value: 6123000 },
{ label: '입금', value: 3000000 },
],
}, },
], ],
checkPoints: [ checkPoints: [
@@ -268,7 +263,7 @@ export const mockData: CEODashboardData = {
{ id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' }, { id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' },
{ id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' }, { id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' },
{ id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' }, { id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' },
{ id: 'dc4', label: '회수완료', amount: 280000000, subLabel: '10건' }, { id: 'dc4', label: '추심종료', amount: 280000000, subLabel: '10건' },
], ],
checkPoints: [ checkPoints: [
{ {

View File

@@ -162,16 +162,6 @@ export function transformCm1ModalConfig(
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '합계', totalLabel: '합계',
@@ -196,46 +186,44 @@ export function transformCm2ModalConfig(
// 테이블 데이터 매핑 // 테이블 데이터 매핑
const tableData = (items || []).map((item) => ({ const tableData = (items || []).map((item) => ({
date: item.loan_date, date: item.loan_date,
target: item.user_name, classification: item.status_label || '카드',
category: '-', // API에서 별도 필드 없음 category: '-',
amount: item.amount, amount: item.amount,
status: item.status_label || item.status,
content: item.description, content: item.description,
})); }));
// 대상 필터 옵션 동적 생성 // 분류 필터 옵션 동적 생성
const uniqueTargets = [...new Set((items || []).map((item) => item.user_name))]; const uniqueClassifications = [...new Set(tableData.map((item) => item.classification))];
const targetFilterOptions = [ const classificationFilterOptions = [
{ value: 'all', label: '전체' }, { value: 'all', label: '전체' },
...uniqueTargets.map((target) => ({ ...uniqueClassifications.map((cls) => ({
value: target, value: cls,
label: target, label: cls,
})), })),
]; ];
return { return {
title: '가지급금 상세', title: '가지급금 상세',
summaryCards: [ summaryCards: [
{ label: '가지급금', value: formatKoreanCurrency(summary.total_outstanding) }, { label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) },
{ label: '인정이자 4.6%', value: summary.recognized_interest, unit: '원' }, { label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' },
{ label: '미정', value: `${summary.pending_count ?? 0}` }, { label: '미정리/미분류', value: `${summary.pending_count ?? 0}` },
], ],
table: { table: {
title: '가지급금 관련 내역', title: '가지급금 관련 내역',
columns: [ columns: [
{ key: 'no', label: 'No.', align: 'center' }, { key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '발생일', align: 'center' }, { key: 'date', label: '발생일', align: 'center' },
{ key: 'target', label: '대상', align: 'center' }, { key: 'classification', label: '분류', align: 'center' },
{ key: 'category', label: '구분', align: 'center' }, { key: 'category', label: '구분', align: 'center' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' }, { key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'status', label: '상태', align: 'center', highlightValue: '미설정' },
{ key: 'content', label: '내용', align: 'left' }, { key: 'content', label: '내용', align: 'left' },
], ],
data: tableData, data: tableData,
filters: [ filters: [
{ {
key: 'target', key: 'classification',
options: targetFilterOptions, options: classificationFilterOptions,
defaultValue: 'all', defaultValue: 'all',
}, },
{ {
@@ -247,16 +235,6 @@ export function transformCm2ModalConfig(
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '합계', totalLabel: '합계',

View File

@@ -153,16 +153,6 @@ export function getCardManagementModalConfig(cardId: string): DetailModalConfig
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '합계', totalLabel: '합계',
@@ -170,60 +160,68 @@ export function getCardManagementModalConfig(cardId: string): DetailModalConfig
totalColumnKey: 'amount', totalColumnKey: 'amount',
}, },
}, },
// P52: 가지급금 상세
cm2: { cm2: {
title: '가지급금 상세', title: '가지급금 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [ summaryCards: [
{ label: '가지급금', value: '4.5억원' }, { label: '가지급금 합계', value: '4.5억원' },
{ label: '인정이자 4.6%', value: 6000000, unit: '원' }, { label: '가지급금 총액', value: 6000000, unit: '원' },
{ label: '미설정', value: '10건' }, { label: '건수', value: '10건' },
], ],
reviewCards: {
title: '가지급금 검토 필요',
cards: [
{ label: '카드', amount: 3123000, subLabel: '미정리 5건' },
{ label: '경조사', amount: 3123000, subLabel: '미증빙 5건' },
{ label: '상품권', amount: 3123000, subLabel: '미증빙 5건' },
{ label: '접대비', amount: 3123000, subLabel: '미증빙 5건' },
],
},
table: { table: {
title: '가지급금 관련 내역', title: '가지급금 내역',
columns: [ columns: [
{ key: 'no', label: 'No.', align: 'center' }, { key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '발생일', align: 'center' }, { key: 'date', label: '발생일', align: 'center' },
{ key: 'target', label: '대상', align: 'center' }, { key: 'classification', label: '분류', align: 'center' },
{ key: 'category', label: '구분', align: 'center' }, { key: 'category', label: '구분', align: 'center' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' }, { key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'status', label: '상태', align: 'center', highlightValue: '미설정' }, { key: 'response', label: '대응', align: 'left' },
{ key: 'content', label: '내용', align: 'left' },
], ],
data: [ data: [
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '미설정', content: '미정' }, { date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '미정' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접비(미정리)', content: '접대비 불인정' }, { date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '미증빙' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미설정', content: '접대비 불인정' }, { date: '2025-12-12', classification: '경조사', category: '계좌명', amount: 1000000, response: '미증빙' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미설정', content: '미설정' }, { date: '2025-12-12', classification: '상품권', category: '계좌명', amount: 1000000, response: '미증빙' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '-', amount: 1000000, status: '미설정', content: '미설정' }, { date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '주말 카드 사용' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접대비', content: '접대비 불인정' }, { date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '접대비 불인정' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '-', content: '복리후생비, 주말/심야 카드 사용' }, { date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '불인정 가맹점(귀금속)' },
], ],
filters: [ filters: [
{ {
key: 'target', key: 'classification',
options: [ options: [
{ value: 'all', label: '전체' }, { value: 'all', label: '전체' },
{ value: '홍길동', label: '홍길동' }, { value: '카드', label: '카드' },
], { value: '경조사', label: '경조사' },
defaultValue: 'all', { value: '상품권', label: '상품권' },
}, { value: '접대비', label: '접대비' },
{
key: 'category',
options: [
{ value: 'all', label: '전체' },
{ value: '카드명', label: '카드명' },
{ value: '계좌명', label: '계좌명' },
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{ {
key: 'sortOrder', key: 'sortOrder',
options: [ options: [
{ value: 'latest', label: '최신순' }, { value: 'all', label: '정렬' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' }, { value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' }, { value: 'amountAsc', label: '금액 낮은순' },
{ value: 'latest', label: '최신순' },
], ],
defaultValue: 'latest', defaultValue: 'all',
}, },
], ],
showTotal: true, showTotal: true,

View File

@@ -5,18 +5,27 @@ import type { DetailModalConfig } from '../types';
*/ */
const entertainmentDetailConfig: DetailModalConfig = { const entertainmentDetailConfig: DetailModalConfig = {
title: '접대비 상세', title: '접대비 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [ summaryCards: [
// 첫 번째 줄: 당해년도 // 첫 번째 줄: 당해년도
{ label: '당해년도 접대비 총한도', value: 3123000, unit: '원' }, { label: '당해년도 접대비 총 한도', value: 3123000, unit: '원' },
{ label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' }, { label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' },
{ label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' }, { label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' },
{ label: '당해년도 접대비 사용잔액', value: 0, unit: '원' }, { label: '당해년도 접대비 초과 금액', value: 0, unit: '원' },
// 두 번째 줄: 분기별
{ label: '1사분기 접대비 총한도', value: 3123000, unit: '원' },
{ label: '1사분기 접대비 잔여한도', value: 6000000, unit: '원' },
{ label: '1사분기 접대비 사용금액', value: 6000000, unit: '원' },
{ label: '1사분기 접대비 초과금액', value: 6000000, unit: '원' },
], ],
reviewCards: {
title: '접대비 검토 필요',
cards: [
{ label: '주말/심야', amount: 3123000, subLabel: '미증빙 5건' },
{ label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, subLabel: '불인정 5건' },
{ label: '고액 결제', amount: 3123000, subLabel: '미증빙 5건' },
{ label: '증빙 미비', amount: 3123000, subLabel: '미증빙 5건' },
],
},
barChart: { barChart: {
title: '월별 접대비 사용 추이', title: '월별 접대비 사용 추이',
data: [ data: [
@@ -50,14 +59,14 @@ const entertainmentDetailConfig: DetailModalConfig = {
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' }, { key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
{ key: 'transDate', label: '거래일시', align: 'center', format: 'date' }, { key: 'transDate', label: '거래일시', align: 'center', format: 'date' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' }, { key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'purpose', label: '사용용도', align: 'left' }, { key: 'content', label: '내용', align: 'left' },
], ],
data: [ data: [
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '심야 카드 사용' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '미증빙' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, { cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '고액 결제' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, { cardName: '카드명', user: '김철수', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, content: '불인정 가맹점 (귀금속)' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' }, { cardName: '카드명', user: '이영희', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '접대비 불인정' },
], ],
filters: [ filters: [
{ {
@@ -71,14 +80,15 @@ const entertainmentDetailConfig: DetailModalConfig = {
defaultValue: 'all', defaultValue: 'all',
}, },
{ {
key: 'sortOrder', key: 'content',
options: [ options: [
{ value: 'latest', label: '최신순' }, { value: 'all', label: '전체' },
{ value: 'oldest', label: '등록순' }, { value: '주말/심야', label: '주말/심야' },
{ value: 'amountDesc', label: '금액 높은순' }, { value: '기피업종', label: '기피업종' },
{ value: 'amountAsc', label: '낮은순' }, { value: '고액 결제', label: '결제' },
{ value: '증빙 미비', label: '증빙 미비' },
], ],
defaultValue: 'latest', defaultValue: 'all',
}, },
], ],
showTotal: true, showTotal: true,
@@ -91,24 +101,25 @@ const entertainmentDetailConfig: DetailModalConfig = {
{ {
title: '접대비 손금한도 계산 - 기본한도', title: '접대비 손금한도 계산 - 기본한도',
columns: [ columns: [
{ key: 'type', label: '구분', align: 'left' }, { key: 'type', label: '법인 유형', align: 'left' },
{ key: 'limit', label: '기본한도', align: 'right' }, { key: 'annualLimit', label: '연간 기본한도', align: 'right' },
{ key: 'monthlyLimit', label: '월 환산', align: 'right' },
], ],
data: [ data: [
{ type: '일반법인', limit: '3,600만원 (연 1,200만원)' }, { type: '일반법인', annualLimit: '12,000,000원', monthlyLimit: '1,000,000원' },
{ type: '중소기업', limit: '5,400만원 (연 3,600만원)' }, { type: '중소기업', annualLimit: '36,000,000원', monthlyLimit: '3,000,000원' },
], ],
}, },
{ {
title: '수입금액별 추가한도', title: '수입금액별 추가한도',
columns: [ columns: [
{ key: 'range', label: '수입금액', align: 'left' }, { key: 'range', label: '수입금액 구간', align: 'left' },
{ key: 'rate', label: '적용률', align: 'center' }, { key: 'formula', label: '추가한도 계산식', align: 'left' },
], ],
data: [ data: [
{ range: '100억원 이하', rate: '0.3%' }, { range: '100억원 이하', formula: '수입금액 × 0.2%' },
{ range: '100억 초과 ~ 500억 이하', rate: '0.2%' }, { range: '100억 초과 ~ 500억 이하', formula: '2,000만원 + (수입금액 - 100억) × 0.1%' },
{ range: '500억원 초과', rate: '0.03%' }, { range: '500억원 초과', formula: '6,000만원 + (수입금액 - 500억) × 0.03%' },
], ],
}, },
], ],
@@ -116,18 +127,20 @@ const entertainmentDetailConfig: DetailModalConfig = {
calculationCards: { calculationCards: {
title: '접대비 계산', title: '접대비 계산',
cards: [ cards: [
{ label: '기본한도', value: 36000000 }, { label: '중소기업 연간 기본한도', value: 36000000 },
{ label: '추가한도', value: 91170000, operator: '+' }, { label: '당해년도 수입금액별 추가한도', value: 16000000, operator: '+' },
{ label: '접대비 손금한도', value: 127170000, operator: '=' }, { label: '당해년도 접대비 한도', value: 52000000, operator: '=' },
], ],
}, },
// 접대비 현황 (분기별) // 접대비 현황 (분기별)
quarterlyTable: { quarterlyTable: {
title: '접대비 현황', title: '접대비 현황',
rows: [ rows: [
{ label: '접대비 한도', q1: 31792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 127170000 }, { label: '한도금액', q1: 13000000, q2: 13000000, q3: 13000000, q4: 13000000, total: 52000000 },
{ label: '접대비 사용', q1: 10000000, q2: 0, q3: 0, q4: 0, total: 10000000 }, { label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
{ label: '접대비 잔여', q1: 21792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 117170000 }, { label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
], ],
}, },
}; };
@@ -204,16 +217,6 @@ export function getEntertainmentModalConfig(cardId: string): DetailModalConfig |
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '합계', totalLabel: '합계',
@@ -225,6 +228,11 @@ export function getEntertainmentModalConfig(cardId: string): DetailModalConfig |
et_limit: entertainmentDetailConfig, et_limit: entertainmentDetailConfig,
et_remaining: entertainmentDetailConfig, et_remaining: entertainmentDetailConfig,
et_used: entertainmentDetailConfig, et_used: entertainmentDetailConfig,
// 대시보드 카드 ID (et1~et4) → 접대비 상세 모달
et1: entertainmentDetailConfig,
et2: entertainmentDetailConfig,
et3: entertainmentDetailConfig,
et4: entertainmentDetailConfig,
}; };
return configs[cardId] || null; return configs[cardId] || null;

View File

@@ -1,18 +1,24 @@
import type { DetailModalConfig } from '../types'; import type { DetailModalConfig } from '../types';
/** /**
* 당월 예상 지출 모달 설정 * 당월 예상 지출 모달 설정 (D1.7 기획서 P48-51 반영)
*/ */
export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null { export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null {
const configs: Record<string, DetailModalConfig> = { const configs: Record<string, DetailModalConfig> = {
// P48: 매입 상세
me1: { me1: {
title: '당월 매입 상세', title: '매입 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [ summaryCards: [
{ label: '당월 매입', value: 3123000, unit: '원' }, { label: '매입', value: 3123000, unit: '원' },
{ label: '전 대비', value: '-12.5%', isComparison: true, isPositive: false }, { label: '전 대비', value: '-12.5%', isComparison: true, isPositive: false },
], ],
barChart: { barChart: {
title: '월별 매입 추이', title: '매입 추이',
data: [ data: [
{ name: '1월', value: 45000000 }, { name: '1월', value: 45000000 },
{ name: '2월', value: 52000000 }, { name: '2월', value: 52000000 },
@@ -30,8 +36,8 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
title: '자재 유형별 구매 비율', title: '자재 유형별 구매 비율',
data: [ data: [
{ name: '원자재', value: 55000000, percentage: 55, color: '#60A5FA' }, { name: '원자재', value: 55000000, percentage: 55, color: '#60A5FA' },
{ name: '부자재', value: 35000000, percentage: 35, color: '#34D399' }, { name: '부자재', value: 35000000, percentage: 35, color: '#FBBF24' },
{ name: '포장재', value: 10000000, percentage: 10, color: '#FBBF24' }, { name: '포장재', value: 10000000, percentage: 10, color: '#F87171' },
], ],
}, },
table: { table: {
@@ -41,36 +47,14 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
{ key: 'date', label: '매입일', align: 'center', format: 'date' }, { key: 'date', label: '매입일', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처', align: 'left' }, { key: 'vendor', label: '거래처', align: 'left' },
{ key: 'amount', label: '매입금액', align: 'right', format: 'currency' }, { key: 'amount', label: '매입금액', align: 'right', format: 'currency' },
{ key: 'type', label: '매입유형', align: 'center' },
], ],
data: [ data: [
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' }, { date: '2025-12-01', vendor: '회사명', amount: 11000000 },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' }, { date: '2025-12-01', vendor: '회사명', amount: 11000000 },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' }, { date: '2025-12-01', vendor: '회사명', amount: 11000000 },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' }, { date: '2025-12-01', vendor: '회사명', amount: 11000000 },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' }, { date: '2025-12-01', vendor: '회사명', amount: 11000000 },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' }, { date: '2025-12-01', vendor: '회사명', amount: 11000000 },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
],
filters: [
{
key: 'type',
options: [
{ value: 'all', label: '전체' },
{ value: '원재료매입', label: '원재료매입' },
{ value: '부재료매입', label: '부재료매입' },
{ value: '미설정', label: '미설정' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '합계', totalLabel: '합계',
@@ -78,15 +62,21 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
totalColumnKey: 'amount', totalColumnKey: 'amount',
}, },
}, },
// P49: 카드 상세
me2: { me2: {
title: '당월 카드 상세', title: '카드 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [ summaryCards: [
{ label: '당월 카드 사용', value: 6000000, unit: '원' }, { label: '카드 사용', value: 6000000, unit: '원' },
{ label: '전 대비', value: '-12.5%', isComparison: true, isPositive: false }, { label: '전 대비', value: '-12.5%', isComparison: true, isPositive: false },
{ label: '이용건', value: '10건' }, { label: '건', value: '10건' },
], ],
barChart: { barChart: {
title: '월별 카드 사용 추이', title: '카드 사용 추이',
data: [ data: [
{ name: '1월', value: 4500000 }, { name: '1월', value: 4500000 },
{ name: '2월', value: 5200000 }, { name: '2월', value: 5200000 },
@@ -104,8 +94,8 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
title: '사용자별 카드 사용 비율', title: '사용자별 카드 사용 비율',
data: [ data: [
{ name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' }, { name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' },
{ name: '김길동', value: 35000000, percentage: 35, color: '#34D399' }, { name: '김영희', value: 35000000, percentage: 35, color: '#FBBF24' },
{ name: '이길동', value: 10000000, percentage: 10, color: '#FBBF24' }, { name: '이정현', value: 10000000, percentage: 10, color: '#F87171' },
], ],
}, },
table: { table: {
@@ -114,30 +104,16 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
{ key: 'no', label: 'No.', align: 'center' }, { key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' }, { key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' }, { key: 'user', label: '사용자', align: 'center' },
{ key: 'date', label: '사용일', align: 'center', format: 'date' }, { key: 'date', label: '사용일', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' }, { key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' }, { key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' }, { key: 'usageType', label: '계정과목', align: 'center', highlightValue: '미설정' },
], ],
data: [ data: [
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' }, { cardName: '홍길동', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' }, { cardName: '홍길동', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' }, { cardName: '홍길동', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' }, { cardName: '홍길동', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-08 11:15', store: '가맹점명', amount: 1000000, usageType: '미설정' },
{ cardName: '카드명', user: '김길동', date: '2025-12-07 16:40', store: '가맹점명', amount: 5000000, usageType: '교통비' },
{ cardName: '카드명', user: '이길동', date: '2025-12-06 10:30', store: '가맹점명', amount: 1000000, usageType: '소모품비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-05 13:25', store: '스타벅스', amount: 45000, usageType: '복리후생비' },
{ cardName: '카드명', user: '김길동', date: '2025-12-04 19:50', store: '주유소', amount: 80000, usageType: '교통비' },
{ cardName: '카드명', user: '이길동', date: '2025-12-03 08:10', store: '편의점', amount: 15000, usageType: '미설정' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-02 12:00', store: '식당', amount: 250000, usageType: '접대비' },
{ cardName: '카드명', user: '김길동', date: '2025-12-01 15:35', store: '문구점', amount: 35000, usageType: '소모품비' },
{ cardName: '카드명', user: '홍길동', date: '2025-11-30 17:20', store: '호텔', amount: 350000, usageType: '미설정' },
{ cardName: '카드명', user: '이길동', date: '2025-11-29 09:00', store: '택시', amount: 25000, usageType: '교통비' },
{ cardName: '카드명', user: '김길동', date: '2025-11-28 14:15', store: '커피숍', amount: 32000, usageType: '복리후생비' },
{ cardName: '카드명', user: '홍길동', date: '2025-11-27 11:45', store: '마트', amount: 180000, usageType: '소모품비' },
{ cardName: '카드명', user: '이길동', date: '2025-11-26 16:30', store: '서점', amount: 45000, usageType: '미설정' },
{ cardName: '카드명', user: '김길동', date: '2025-11-25 10:20', store: '식당', amount: 120000, usageType: '접대비' },
], ],
filters: [ filters: [
{ {
@@ -145,21 +121,11 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
options: [ options: [
{ value: 'all', label: '전체' }, { value: 'all', label: '전체' },
{ value: '홍길동', label: '홍길동' }, { value: '홍길동', label: '홍길동' },
{ value: '김길동', label: '김길동' }, { value: '김영희', label: '김영희' },
{ value: '이길동', label: '이길동' }, { value: '이정현', label: '이정현' },
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '합계', totalLabel: '합계',
@@ -167,14 +133,21 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
totalColumnKey: 'amount', totalColumnKey: 'amount',
}, },
}, },
// P50: 발행어음 상세
me3: { me3: {
title: '당월 발행어음 상세', title: '발행어음 상세',
dateFilter: {
enabled: true,
presets: ['당해년도', '전전월', '전월', '당월', '어제'],
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [ summaryCards: [
{ label: '당월 발행어음 사용', value: 3123000, unit: '원' }, { label: '발행어음', value: 3123000, unit: '원' },
{ label: '전 대비', value: '-12.5%', isComparison: true, isPositive: false }, { label: '전 대비', value: '-12.5%', isComparison: true, isPositive: false },
], ],
barChart: { barChart: {
title: '월별 발행어음 추이', title: '발행어음 추이',
data: [ data: [
{ name: '1월', value: 2000000 }, { name: '1월', value: 2000000 },
{ name: '2월', value: 2500000 }, { name: '2월', value: 2500000 },
@@ -188,15 +161,14 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
xAxisKey: 'name', xAxisKey: 'name',
color: '#60A5FA', color: '#60A5FA',
}, },
horizontalBarChart: { pieChart: {
title: '당월 거래처별 발행어음', title: '거래처별 발행어음',
data: [ data: [
{ name: '거래처1', value: 50000000 }, { name: '거래처1', value: 50000000, percentage: 45, color: '#60A5FA' },
{ name: '거래처2', value: 35000000 }, { name: '거래처2', value: 35000000, percentage: 32, color: '#FBBF24' },
{ name: '거래처3', value: 20000000 }, { name: '거래처3', value: 20000000, percentage: 18, color: '#F87171' },
{ name: '거래처4', value: 6000000 }, { name: '거래처4', value: 6000000, percentage: 5, color: '#34D399' },
], ],
color: '#60A5FA',
}, },
table: { table: {
title: '일별 발행어음 내역', title: '일별 발행어음 내역',
@@ -215,7 +187,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' }, { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' }, { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' }, { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 105000000, status: '보관중' },
], ],
filters: [ filters: [
{ {
@@ -238,16 +209,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '합계', totalLabel: '합계',
@@ -255,6 +216,7 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
totalColumnKey: 'amount', totalColumnKey: 'amount',
}, },
}, },
// P51: 당월 지출 예상 상세
me4: { me4: {
title: '당월 지출 예상 상세', title: '당월 지출 예상 상세',
summaryCards: [ summaryCards: [
@@ -278,8 +240,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '거래처명 12월분', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, { paymentDate: '2025-12-12', item: '거래처명 12월분', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '적요 내용', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, { paymentDate: '2025-12-12', item: '적요 내용', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
], ],
filters: [ filters: [
@@ -291,14 +251,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '2025/12 계', totalLabel: '2025/12 계',

View File

@@ -7,29 +7,36 @@ import type { DetailModalConfig } from '../types';
export function getVatModalConfig(): DetailModalConfig { export function getVatModalConfig(): DetailModalConfig {
return { return {
title: '예상 납부세액', title: '예상 납부세액',
summaryCards: [], periodSelect: {
// 세액 산출 내역 테이블 enabled: true,
options: [
{ value: '2026-1-expected', label: '2026년 1기 예정' },
{ value: '2025-2-confirmed', label: '2025년 2기 확정' },
{ value: '2025-2-expected', label: '2025년 2기 예정' },
{ value: '2025-1-confirmed', label: '2025년 1기 확정' },
],
defaultValue: '2026-1-expected',
},
summaryCards: [
{ label: '예상매출', value: '30.5억원' },
{ label: '예상매입', value: '20.5억원' },
{ label: '예상 납부세액', value: '1.1억원' },
],
// 부가세 요약 테이블
referenceTable: { referenceTable: {
title: '2026년 1사분기 세액 산출 내역', title: '2026년 1기 예정 부가세 요약',
columns: [ columns: [
{ key: 'category', label: '구분', align: 'center' }, { key: 'category', label: '구분', align: 'left' },
{ key: 'amount', label: '액', align: 'right' }, { key: 'supplyAmount', label: '공급가액', align: 'right' },
{ key: 'note', label: '비고', align: 'left' }, { key: 'taxAmount', label: '세액', align: 'right' },
], ],
data: [ data: [
{ category: '매출세액', amount: '11,000,000', note: '과세매출 X 10%' }, { category: '매출(전자세금계산서)', supplyAmount: '100,000,000', taxAmount: '10,000,000' },
{ category: '매입세액', amount: '1,000,000', note: '공제대상 매입 X 10%' }, { category: '매입(전자세금계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
{ category: '경감·공제세액', amount: '0', note: '해당없음' }, { category: '매입(종이세금계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
], { category: '매입(계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
}, { category: '매입(신용카드)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
// 예상 납부세액 계산 { category: '납부세액', supplyAmount: '', taxAmount: '6,000,000' },
calculationCards: {
title: '예상 납부세액 계산',
cards: [
{ label: '매출세액', value: 11000000, unit: '원' },
{ label: '매입세액', value: 1000000, unit: '원', operator: '-' },
{ label: '경감·공제세액', value: 0, unit: '원', operator: '-' },
{ label: '예상 납부세액', value: 10000000, unit: '원', operator: '=' },
], ],
}, },
// 세금계산서 미발행/미수취 내역 // 세금계산서 미발행/미수취 내역
@@ -38,19 +45,17 @@ export function getVatModalConfig(): DetailModalConfig {
columns: [ columns: [
{ key: 'no', label: 'No.', align: 'center' }, { key: 'no', label: 'No.', align: 'center' },
{ key: 'type', label: '구분', align: 'center' }, { key: 'type', label: '구분', align: 'center' },
{ key: 'issueDate', label: '발일자', align: 'center', format: 'date' }, { key: 'issueDate', label: '발일자', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처', align: 'left' }, { key: 'vendor', label: '거래처', align: 'left' },
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' }, { key: 'vat', label: '부가세', align: 'right', format: 'currency' },
{ key: 'invoiceStatus', label: '세금계산서 발행', align: 'center' }, { key: 'invoiceStatus', label: '세금계산서 발행/미수취', align: 'center' },
], ],
data: [ data: [
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처1', vat: 11000000, invoiceStatus: '미발행' }, { type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처2', vat: 11000000, invoiceStatus: '미수취' }, { type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처3', vat: 11000000, invoiceStatus: '미발행' }, { type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처4', vat: 11000000, invoiceStatus: '미수취' }, { type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처5', vat: 11000000, invoiceStatus: '미발행' }, { type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처6', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처7', vat: 11000000, invoiceStatus: '미발행' },
], ],
filters: [ filters: [
{ {
@@ -62,25 +67,6 @@ export function getVatModalConfig(): DetailModalConfig {
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{
key: 'invoiceStatus',
options: [
{ value: 'all', label: '전체' },
{ value: '미발행', label: '미발행' },
{ value: '미수취', label: '미수취' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
], ],
showTotal: true, showTotal: true,
totalLabel: '합계', totalLabel: '합계',

View File

@@ -45,18 +45,27 @@ export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): Detai
return { return {
title: '복리후생비 상세', title: '복리후생비 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [ summaryCards: [
// 1행: 당해년도 기준 // 1행: 당해년도 기준
{ label: '당해년도 복리후생비 계정', value: 3123000, unit: '원' }, { label: '당해년도 복리후생비 총 한도', value: 3123000, unit: '원' },
{ label: '당해년도 복리후생비 한도', value: 600000, unit: '원' }, { label: '당해년도 복리후생비 잔여한도', value: 6000000, unit: '원' },
{ label: '당해년도 복리후생비 사용', value: 6000000, unit: '원' }, { label: '당해년도 복리후생비 사용금액', value: 6000000, unit: '원' },
{ label: '당해년도 잔여한도', value: 0, unit: '원' }, { label: '당해년도 복리후생비 초과 금액', value: 0, unit: '원' },
// 2행: 1사분기 기준
{ label: '1사분기 복리후생비 총 한도', value: 3123000, unit: '원' },
{ label: '1사분기 복리후생비 잔여한도', value: 6000000, unit: '원' },
{ label: '1사분기 복리후생비 사용금액', value: 6000000, unit: '원' },
{ label: '1사분기 복리후생비 초과 금액', value: 6000000, unit: '원' },
], ],
reviewCards: {
title: '복리후생비 검토 필요',
cards: [
{ label: '비과세 한도 초과', amount: 3123000, subLabel: '5건' },
{ label: '사적 사용 의심', amount: 3123000, subLabel: '5건' },
{ label: '특정인 편중', amount: 3123000, subLabel: '5건' },
{ label: '항목별 한도 초과', amount: 3123000, subLabel: '5건' },
],
},
barChart: { barChart: {
title: '월별 복리후생비 사용 추이', title: '월별 복리후생비 사용 추이',
data: [ data: [
@@ -89,36 +98,34 @@ export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): Detai
{ key: 'date', label: '사용일자', align: 'center', format: 'date' }, { key: 'date', label: '사용일자', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' }, { key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' }, { key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'usageType', label: '사용항목', align: 'center' }, { key: 'content', label: '내용', align: 'left' },
], ],
data: [ data: [
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '식비' }, { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, content: '비과세 한도 초과' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, usageType: '건강검진' }, { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, content: '사적 사용 의심' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, usageType: '경조사비' }, { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, content: '특정인 편중' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, usageType: '기타' }, { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, content: '항목별 한도 초과' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, usageType: '식비' }, { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, content: '비과세 한도 초과' },
], ],
filters: [ filters: [
{ {
key: 'usageType', key: 'user',
options: [ options: [
{ value: 'all', label: '전체' }, { value: 'all', label: '전체' },
{ value: '식비', label: '식비' }, { value: '홍길동', label: '홍길동' },
{ value: '건강검진', label: '건강검진' },
{ value: '경조사비', label: '경조사비' },
{ value: '기타', label: '기타' },
], ],
defaultValue: 'all', defaultValue: 'all',
}, },
{ {
key: 'sortOrder', key: 'content',
options: [ options: [
{ value: 'latest', label: '최신순' }, { value: 'all', label: '전체' },
{ value: 'oldest', label: '등록순' }, { value: '비과세 한도 초과', label: '비과세 한도 초과' },
{ value: 'amountDesc', label: '금액 높은순' }, { value: '사적 사용 의심', label: '사적 사용 의심' },
{ value: 'amountAsc', label: '금액 낮은순' }, { value: '특정인 편중', label: '특정인 편중' },
{ value: '항목별 한도 초과', label: '항목별 한도 초과' },
], ],
defaultValue: 'latest', defaultValue: 'all',
}, },
], ],
showTotal: true, showTotal: true,

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import { useState, useCallback, useMemo } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -8,39 +7,22 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { import type { DetailModalConfig } from '../types';
DetailModalConfig, import {
SummaryCardData, DateFilterSection,
BarChartConfig, PeriodSelectSection,
PieChartConfig, SummaryCard,
HorizontalBarChartConfig, ReviewCardsSection,
TableConfig, BarChartSection,
TableFilterConfig, PieChartSection,
ComparisonSectionConfig, HorizontalBarChartSection,
ReferenceTableConfig, ComparisonSection,
CalculationCardsConfig, CalculationCardsSection,
QuarterlyTableConfig, QuarterlyTableSection,
} from '../types'; ReferenceTableSection,
TableSection,
} from './DetailModalSections';
interface DetailModalProps { interface DetailModalProps {
isOpen: boolean; isOpen: boolean;
@@ -48,641 +30,6 @@ interface DetailModalProps {
config: DetailModalConfig; config: DetailModalConfig;
} }
/**
* 금액 포맷 함수
*/
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat('ko-KR').format(value);
};
/**
* 요약 카드 컴포넌트 - 모바일 반응형 지원
*/
const SummaryCard = ({ data }: { data: SummaryCardData }) => {
const displayValue = typeof data.value === 'number'
? formatCurrency(data.value) + (data.unit || '원')
: data.value;
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
<p className="text-xs sm:text-sm text-gray-500 mb-1">{data.label}</p>
<p className={cn(
"text-lg sm:text-2xl font-bold break-all",
data.isComparison && (data.isPositive ? "text-blue-600" : "text-red-600")
)}>
{data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''}
{displayValue}
</p>
</div>
);
};
/**
* 막대 차트 컴포넌트 - 모바일 반응형 지원
*/
const BarChartSection = ({ config }: { config: BarChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="h-[150px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={config.data} margin={{ top: 5, right: 5, left: -25, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
<XAxis
dataKey={config.xAxisKey}
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fill: '#6B7280' }}
interval={0}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#6B7280' }}
tickFormatter={(value) => value >= 10000 ? `${value / 10000}` : value}
width={35}
/>
<Tooltip
formatter={(value) => [formatCurrency(value as number) + '원', '']}
contentStyle={{ fontSize: 12 }}
/>
<Bar
dataKey={config.dataKey}
fill={config.color || '#60A5FA'}
radius={[4, 4, 0, 0]}
maxBarSize={30}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
/**
* 도넛 차트 컴포넌트 - 모바일 반응형 지원
*/
const PieChartSection = ({ config }: { config: PieChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 overflow-hidden">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
{/* 도넛 차트 - 중앙 정렬, 모바일 크기 조절 */}
<div className="flex justify-center mb-4">
<PieChart width={100} height={100}>
<Pie
data={config.data as unknown as Array<Record<string, unknown>>}
cx={50}
cy={50}
innerRadius={28}
outerRadius={45}
paddingAngle={2}
dataKey="value"
>
{config.data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
</PieChart>
</div>
{/* 범례 - 세로 배치 (모바일 최적화) */}
<div className="space-y-2">
{config.data.map((item, index) => (
<div key={index} className="flex items-center justify-between text-xs sm:text-sm gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-shrink">
<div
className="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-600 truncate">{item.name}</span>
<span className="text-gray-400 flex-shrink-0">{item.percentage}%</span>
</div>
<span className="font-medium text-gray-900 flex-shrink-0">
{formatCurrency(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
/**
* 가로 막대 차트 컴포넌트
*/
const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfig }) => {
const maxValue = Math.max(...config.data.map(d => d.value));
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="space-y-3">
{config.data.map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">{item.name}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.value)}
</span>
</div>
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(item.value / maxValue) * 100}%`,
backgroundColor: config.color || '#60A5FA',
}}
/>
</div>
</div>
))}
</div>
</div>
);
};
/**
* VS 비교 섹션 컴포넌트
*/
const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
const formatValue = (value: string | number, unit?: string): string => {
if (typeof value === 'number') {
return formatCurrency(value) + (unit || '원');
}
return value;
};
const borderColorClass = {
orange: 'border-orange-400',
blue: 'border-blue-400',
};
const titleBgClass = {
orange: 'bg-orange-50',
blue: 'bg-blue-50',
};
return (
<div className="flex items-stretch gap-4">
{/* 왼쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.leftBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.leftBox.borderColor]
)}>
{config.leftBox.title}
</div>
<div className="p-4 space-y-3">
{config.leftBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
{/* VS 영역 */}
<div className="flex flex-col items-center justify-center px-4">
<span className="text-2xl font-bold text-gray-400 mb-2">VS</span>
<div className="bg-red-50 rounded-lg px-4 py-3 text-center min-w-[180px]">
<p className="text-xs text-gray-600 mb-1">{config.vsLabel}</p>
<p className="text-xl font-bold text-red-500">
{typeof config.vsValue === 'number'
? formatCurrency(config.vsValue) + '원'
: config.vsValue}
</p>
{config.vsSubLabel && (
<p className="text-xs text-gray-500 mt-1">{config.vsSubLabel}</p>
)}
{/* VS 세부 항목 */}
{config.vsBreakdown && config.vsBreakdown.length > 0 && (
<div className="mt-2 pt-2 border-t border-red-200 space-y-1">
{config.vsBreakdown.map((item, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium text-gray-700">
{typeof item.value === 'number'
? formatCurrency(item.value) + (item.unit || '원')
: item.value}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* 오른쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.rightBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.rightBox.borderColor]
)}>
{config.rightBox.title}
</div>
<div className="p-4 space-y-3">
{config.rightBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
</div>
);
};
/**
* 계산 카드 섹션 컴포넌트 (접대비 계산 등)
*/
const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => {
const isResultCard = (index: number, operator?: string) => {
// '=' 연산자가 있는 카드는 결과 카드로 강조
return operator === '=';
};
return (
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<h4 className="font-medium text-gray-800">{config.title}</h4>
{config.subtitle && (
<span className="text-sm text-gray-500">{config.subtitle}</span>
)}
</div>
<div className="flex items-center gap-3">
{config.cards.map((card, index) => (
<div key={index} className="flex items-center gap-3">
{/* 연산자 표시 (첫 번째 카드 제외) */}
{index > 0 && card.operator && (
<span className="text-3xl font-bold text-gray-400">
{card.operator}
</span>
)}
{/* 카드 */}
<div className={cn(
"rounded-lg p-5 min-w-[180px] text-center border",
isResultCard(index, card.operator)
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
)}>
<p className={cn(
"text-sm mb-2",
isResultCard(index, card.operator) ? "text-blue-600" : "text-gray-500"
)}>
{card.label}
</p>
<p className={cn(
"text-2xl font-bold",
isResultCard(index, card.operator) ? "text-blue-700" : "text-gray-900"
)}>
{formatCurrency(card.value)}{card.unit || '원'}
</p>
</div>
</div>
))}
</div>
</div>
);
};
/**
* 분기별 테이블 섹션 컴포넌트 (접대비 현황 등) - 가로 스크롤 지원
*/
const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => {
const formatValue = (value: number | string | undefined): string => {
if (value === undefined) return '-';
if (typeof value === 'number') return formatCurrency(value);
return value;
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-auto">
<table className="w-full min-w-[500px]">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-left"></th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">1</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">2</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">3</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">4</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center"></th>
</tr>
</thead>
<tbody>
{config.rows.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{row.label}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q1)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q2)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q3)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q4)}</td>
<td className="px-4 py-3 text-sm text-gray-900 text-center font-medium">{formatValue(row.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
/**
* 참조 테이블 컴포넌트 (필터 없는 정보성 테이블) - 가로 스크롤 지원
*/
const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) => {
const getAlignClass = (align?: string): string => {
switch (align) {
case 'center':
return 'text-center';
case 'right':
return 'text-right';
default:
return 'text-left';
}
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-auto">
<table className="w-full min-w-[400px]">
<thead>
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align)
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{config.data.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm text-gray-700",
getAlignClass(column.align)
)}
>
{String(row[column.key] ?? '-')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
/**
* 테이블 컴포넌트
*/
const TableSection = ({ config }: { config: TableConfig }) => {
const [filters, setFilters] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
config.filters?.forEach((filter) => {
initial[filter.key] = filter.defaultValue;
});
return initial;
});
const handleFilterChange = useCallback((key: string, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
// 필터링된 데이터
const filteredData = useMemo(() => {
// 데이터가 없는 경우 빈 배열 반환
if (!config.data || !Array.isArray(config.data)) {
return [];
}
let result = [...config.data];
// 각 필터 적용 (sortOrder는 정렬용이므로 제외)
config.filters?.forEach((filter) => {
if (filter.key === 'sortOrder') return; // 정렬 필터는 값 필터링에서 제외
const filterValue = filters[filter.key];
if (filterValue && filterValue !== 'all') {
result = result.filter((row) => row[filter.key] === filterValue);
}
});
// 정렬 필터 적용 (sortOrder가 있는 경우)
if (filters['sortOrder']) {
const sortOrder = filters['sortOrder'];
result.sort((a, b) => {
// 금액 정렬
if (sortOrder === 'amountDesc') {
return (b['amount'] as number) - (a['amount'] as number);
}
if (sortOrder === 'amountAsc') {
return (a['amount'] as number) - (b['amount'] as number);
}
// 날짜 정렬
const dateA = new Date(a['date'] as string).getTime();
const dateB = new Date(b['date'] as string).getTime();
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
});
}
return result;
}, [config.data, config.filters, filters]);
// 셀 값 포맷팅
const formatCellValue = (value: unknown, format?: string): string => {
if (value === null || value === undefined) return '-';
switch (format) {
case 'currency':
return typeof value === 'number' ? formatCurrency(value) : String(value);
case 'number':
return typeof value === 'number' ? formatCurrency(value) : String(value);
case 'date':
return String(value);
default:
return String(value);
}
};
// 셀 정렬 클래스
const getAlignClass = (align?: string): string => {
switch (align) {
case 'center':
return 'text-center';
case 'right':
return 'text-right';
default:
return 'text-left';
}
};
return (
<div className="mt-6">
{/* 테이블 헤더 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-800">{config.title}</h4>
<span className="text-sm text-gray-500"> {filteredData.length}</span>
</div>
{/* 필터 영역 */}
{config.filters && config.filters.length > 0 && (
<div className="flex items-center gap-2">
{config.filters.map((filter) => (
<Select
key={filter.key}
value={filters[filter.key]}
onValueChange={(value) => handleFilterChange(filter.key, value)}
>
<SelectTrigger className="h-8 w-auto min-w-[80px] w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{filter.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
)}
</div>
{/* 테이블 - 가로 스크롤 지원 */}
<div className="border rounded-lg max-h-[400px] overflow-auto">
<table className="w-full min-w-[600px]">
<thead className="sticky top-0 z-10">
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align),
column.width && `w-[${column.width}]`
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => {
const cellValue = column.key === 'no'
? rowIndex + 1
: formatCellValue(row[column.key], column.format);
const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue;
// highlightColor 클래스 매핑
const highlightColorClass = column.highlightColor ? {
red: 'text-red-500',
orange: 'text-orange-500',
blue: 'text-blue-500',
green: 'text-green-500',
}[column.highlightColor] : '';
return (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align),
isHighlighted && "text-orange-500 font-medium",
highlightColorClass
)}
>
{cellValue}
</td>
);
})}
</tr>
))}
{/* 합계 행 */}
{config.showTotal && (
<tr className="border-t-2 border-gray-200 bg-gray-50 font-medium">
{config.columns.map((column, colIndex) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align)
)}
>
{column.key === config.totalColumnKey
? (typeof config.totalValue === 'number'
? formatCurrency(config.totalValue)
: config.totalValue)
: (colIndex === 0 ? config.totalLabel || '합계' : '')}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
{/* 하단 다중 합계 섹션 */}
{config.footerSummary && config.footerSummary.length > 0 && (
<div className="mt-4 border rounded-lg bg-gray-50 p-4">
<div className="space-y-2">
{config.footerSummary.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm text-gray-600">{item.label}</span>
<span className="font-medium text-gray-900">
{typeof item.value === 'number'
? formatCurrency(item.value)
: item.value}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};
/**
* 상세 모달 공통 컴포넌트
*/
export function DetailModal({ isOpen, onClose, config }: DetailModalProps) { export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} > <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} >
@@ -702,6 +49,16 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
</DialogHeader> </DialogHeader>
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6"> <div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* 기간선택기 영역 */}
{config.dateFilter?.enabled && (
<DateFilterSection config={config.dateFilter} />
)}
{/* 신고기간 셀렉트 영역 */}
{config.periodSelect?.enabled && (
<PeriodSelectSection config={config.periodSelect} />
)}
{/* 요약 카드 영역 - 모바일: 세로배치 */} {/* 요약 카드 영역 - 모바일: 세로배치 */}
{config.summaryCards.length > 0 && ( {config.summaryCards.length > 0 && (
<div className={cn( <div className={cn(
@@ -716,6 +73,11 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
</div> </div>
)} )}
{/* 검토 필요 카드 영역 */}
{config.reviewCards && (
<ReviewCardsSection config={config.reviewCards} />
)}
{/* 차트 영역 */} {/* 차트 영역 */}
{(config.barChart || config.pieChart || config.horizontalBarChart) && ( {(config.barChart || config.pieChart || config.horizontalBarChart) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -0,0 +1,712 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { Search } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { cn } from '@/lib/utils';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
import type {
DateFilterConfig,
PeriodSelectConfig,
SummaryCardData,
BarChartConfig,
PieChartConfig,
HorizontalBarChartConfig,
TableConfig,
ComparisonSectionConfig,
ReferenceTableConfig,
CalculationCardsConfig,
QuarterlyTableConfig,
ReviewCardsConfig,
} from '../types';
// ============================================
// 공통 유틸리티
// ============================================
// 필터 섹션
// ============================================
export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
const today = new Date();
const [startDate, setStartDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth(), 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const [endDate, setEndDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const [searchText, setSearchText] = useState('');
return (
<div className="pb-4 border-b">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
config.showSearch !== false ? (
<div className="relative ml-auto">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="검색"
className="h-8 pl-7 pr-3 text-xs w-[140px]"
/>
</div>
) : undefined
}
/>
</div>
);
};
export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => {
const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || '');
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}>
<SelectTrigger className="h-8 w-auto min-w-[200px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
// ============================================
// 카드 섹션
// ============================================
export const SummaryCard = ({ data }: { data: SummaryCardData }) => {
const displayValue = typeof data.value === 'number'
? formatCurrency(data.value) + (data.unit || '원')
: data.value;
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
<p className="text-xs sm:text-sm text-gray-500 mb-1">{data.label}</p>
<p className={cn(
"text-lg sm:text-2xl font-bold break-all",
data.isComparison && (data.isPositive ? "text-blue-600" : "text-red-600")
)}>
{data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''}
{displayValue}
</p>
</div>
);
};
export const ReviewCardsSection = ({ config }: { config: ReviewCardsConfig }) => {
return (
<div>
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{config.cards.map((card, index) => (
<div
key={index}
className="bg-orange-50 border border-orange-200 rounded-lg p-3 sm:p-4"
>
<p className="text-xs sm:text-sm text-orange-700 font-medium mb-1">{card.label}</p>
<p className="text-lg sm:text-xl font-bold text-orange-900">
{formatCurrency(card.amount)}
</p>
<p className="text-xs text-orange-600 mt-1">{card.subLabel}</p>
</div>
))}
</div>
</div>
);
};
export const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => {
const isResultCard = (_index: number, operator?: string) => {
return operator === '=';
};
return (
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<h4 className="font-medium text-gray-800">{config.title}</h4>
{config.subtitle && (
<span className="text-sm text-gray-500">{config.subtitle}</span>
)}
</div>
<div className="flex items-center gap-3">
{config.cards.map((card, index) => (
<div key={index} className="flex items-center gap-3">
{index > 0 && card.operator && (
<span className="text-3xl font-bold text-gray-400">
{card.operator}
</span>
)}
<div className={cn(
"rounded-lg p-5 min-w-[180px] text-center border",
isResultCard(index, card.operator)
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
)}>
<p className={cn(
"text-sm mb-2",
isResultCard(index, card.operator) ? "text-blue-600" : "text-gray-500"
)}>
{card.label}
</p>
<p className={cn(
"text-2xl font-bold",
isResultCard(index, card.operator) ? "text-blue-700" : "text-gray-900"
)}>
{formatCurrency(card.value)}{card.unit || '원'}
</p>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================
// 차트 섹션
// ============================================
export const BarChartSection = ({ config }: { config: BarChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="h-[150px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={config.data} margin={{ top: 5, right: 5, left: -25, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
<XAxis
dataKey={config.xAxisKey}
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fill: '#6B7280' }}
interval={0}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#6B7280' }}
tickFormatter={(value) => value >= 10000 ? `${value / 10000}` : value}
width={35}
/>
<Tooltip
formatter={(value) => [formatCurrency(value as number) + '원', '']}
contentStyle={{ fontSize: 12 }}
/>
<Bar
dataKey={config.dataKey}
fill={config.color || '#60A5FA'}
radius={[4, 4, 0, 0]}
maxBarSize={30}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export const PieChartSection = ({ config }: { config: PieChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 overflow-hidden">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="flex justify-center mb-4">
<PieChart width={100} height={100}>
<Pie
data={config.data as unknown as Array<Record<string, unknown>>}
cx={50}
cy={50}
innerRadius={28}
outerRadius={45}
paddingAngle={2}
dataKey="value"
>
{config.data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
</PieChart>
</div>
<div className="space-y-2">
{config.data.map((item, index) => (
<div key={index} className="flex items-center justify-between text-xs sm:text-sm gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-shrink">
<div
className="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-600 truncate">{item.name}</span>
<span className="text-gray-400 flex-shrink-0">{item.percentage}%</span>
</div>
<span className="font-medium text-gray-900 flex-shrink-0">
{formatCurrency(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
export const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfig }) => {
const maxValue = Math.max(...config.data.map(d => d.value));
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="space-y-3">
{config.data.map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">{item.name}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.value)}
</span>
</div>
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(item.value / maxValue) * 100}%`,
backgroundColor: config.color || '#60A5FA',
}}
/>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================
// 비교 섹션
// ============================================
export const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
const formatValue = (value: string | number, unit?: string): string => {
if (typeof value === 'number') {
return formatCurrency(value) + (unit || '원');
}
return value;
};
const borderColorClass = {
orange: 'border-orange-400',
blue: 'border-blue-400',
};
const titleBgClass = {
orange: 'bg-orange-50',
blue: 'bg-blue-50',
};
return (
<div className="flex items-stretch gap-4">
{/* 왼쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.leftBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.leftBox.borderColor]
)}>
{config.leftBox.title}
</div>
<div className="p-4 space-y-3">
{config.leftBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
{/* VS 영역 */}
<div className="flex flex-col items-center justify-center px-4">
<span className="text-2xl font-bold text-gray-400 mb-2">VS</span>
<div className="bg-red-50 rounded-lg px-4 py-3 text-center min-w-[180px]">
<p className="text-xs text-gray-600 mb-1">{config.vsLabel}</p>
<p className="text-xl font-bold text-red-500">
{typeof config.vsValue === 'number'
? formatCurrency(config.vsValue) + '원'
: config.vsValue}
</p>
{config.vsSubLabel && (
<p className="text-xs text-gray-500 mt-1">{config.vsSubLabel}</p>
)}
{config.vsBreakdown && config.vsBreakdown.length > 0 && (
<div className="mt-2 pt-2 border-t border-red-200 space-y-1">
{config.vsBreakdown.map((item, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium text-gray-700">
{typeof item.value === 'number'
? formatCurrency(item.value) + (item.unit || '원')
: item.value}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* 오른쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.rightBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.rightBox.borderColor]
)}>
{config.rightBox.title}
</div>
<div className="p-4 space-y-3">
{config.rightBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
</div>
);
};
// ============================================
// 테이블 섹션
// ============================================
export const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => {
const formatValue = (value: number | string | undefined): string => {
if (value === undefined) return '-';
if (typeof value === 'number') return formatCurrency(value);
return value;
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-auto">
<table className="w-full min-w-[500px]">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-left"></th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">1</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">2</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">3</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">4</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center"></th>
</tr>
</thead>
<tbody>
{config.rows.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{row.label}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q1)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q2)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q3)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q4)}</td>
<td className="px-4 py-3 text-sm text-gray-900 text-center font-medium">{formatValue(row.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) => {
const getAlignClass = (align?: string): string => {
switch (align) {
case 'center': return 'text-center';
case 'right': return 'text-right';
default: return 'text-left';
}
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-auto">
<table className="w-full min-w-[400px]">
<thead>
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align)
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{config.data.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm text-gray-700",
getAlignClass(column.align)
)}
>
{String(row[column.key] ?? '-')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export const TableSection = ({ config }: { config: TableConfig }) => {
const [filters, setFilters] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
config.filters?.forEach((filter) => {
initial[filter.key] = filter.defaultValue;
});
return initial;
});
const handleFilterChange = useCallback((key: string, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const filteredData = useMemo(() => {
if (!config.data || !Array.isArray(config.data)) {
return [];
}
let result = [...config.data];
config.filters?.forEach((filter) => {
if (filter.key === 'sortOrder') return;
const filterValue = filters[filter.key];
if (filterValue && filterValue !== 'all') {
result = result.filter((row) => row[filter.key] === filterValue);
}
});
if (filters['sortOrder']) {
const sortOrder = filters['sortOrder'];
result.sort((a, b) => {
if (sortOrder === 'amountDesc') {
return (b['amount'] as number) - (a['amount'] as number);
}
if (sortOrder === 'amountAsc') {
return (a['amount'] as number) - (b['amount'] as number);
}
const dateA = new Date(a['date'] as string).getTime();
const dateB = new Date(b['date'] as string).getTime();
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
});
}
return result;
}, [config.data, config.filters, filters]);
const formatCellValue = (value: unknown, format?: string): string => {
if (value === null || value === undefined) return '-';
switch (format) {
case 'currency':
case 'number':
return typeof value === 'number' ? formatCurrency(value) : String(value);
default:
return String(value);
}
};
const getAlignClass = (align?: string): string => {
switch (align) {
case 'center': return 'text-center';
case 'right': return 'text-right';
default: return 'text-left';
}
};
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-800">{config.title}</h4>
<span className="text-sm text-gray-500"> {filteredData.length}</span>
</div>
{config.filters && config.filters.length > 0 && (
<div className="flex items-center gap-2">
{config.filters.map((filter) => (
<Select
key={filter.key}
value={filters[filter.key]}
onValueChange={(value) => handleFilterChange(filter.key, value)}
>
<SelectTrigger className="h-8 w-auto min-w-[80px] w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{filter.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
)}
</div>
<div className="border rounded-lg max-h-[400px] overflow-auto">
<table className="w-full min-w-[600px]">
<thead className="sticky top-0 z-10">
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align),
column.width && `w-[${column.width}]`
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => {
const cellValue = column.key === 'no'
? rowIndex + 1
: formatCellValue(row[column.key], column.format);
const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue;
const highlightColorClass = column.highlightColor ? {
red: 'text-red-500',
orange: 'text-orange-500',
blue: 'text-blue-500',
green: 'text-green-500',
}[column.highlightColor] : '';
return (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align),
isHighlighted && "text-orange-500 font-medium",
highlightColorClass
)}
>
{cellValue}
</td>
);
})}
</tr>
))}
{config.showTotal && (
<tr className="border-t-2 border-gray-200 bg-gray-50 font-medium">
{config.columns.map((column, colIndex) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align)
)}
>
{column.key === config.totalColumnKey
? (typeof config.totalValue === 'number'
? formatCurrency(config.totalValue)
: config.totalValue)
: (colIndex === 0 ? config.totalLabel || '합계' : '')}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
{config.footerSummary && config.footerSummary.length > 0 && (
<div className="mt-4 border rounded-lg bg-gray-50 p-4">
<div className="space-y-2">
{config.footerSummary.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm text-gray-600">{item.label}</span>
<span className="font-medium text-gray-900">
{typeof item.value === 'number'
? formatCurrency(item.value)
: item.value}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -1,13 +1,13 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { CreditCard, Wallet, Receipt, AlertTriangle } from 'lucide-react'; import { CreditCard, Wallet, Receipt, AlertTriangle, Gift } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components'; import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { CardManagementData } from '../types'; import type { CardManagementData } from '../types';
// 카드별 아이콘 매핑 // 카드별 아이콘 매핑
const CARD_ICONS = [CreditCard, Wallet, Receipt, AlertTriangle]; const CARD_ICONS = [CreditCard, Gift, Receipt, AlertTriangle, Wallet];
const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange']; const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange', 'blue'];
interface CardManagementSectionProps { interface CardManagementSectionProps {
data: CardManagementData; data: CardManagementData;
@@ -28,8 +28,8 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
return ( return (
<CollapsibleDashboardCard <CollapsibleDashboardCard
icon={<CreditCard style={{ color: '#ffffff' }} className="h-5 w-5" />} icon={<CreditCard style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="카드/가지급금 관리" title="가지급금 현황"
subtitle="카드 및 가지급금 현황" subtitle="가지급금 관리 현황"
> >
{data.warningBanner && ( {data.warningBanner && (
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2"> <div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2">
@@ -38,7 +38,7 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
</div> </div>
)} )}
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4"> <div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-5 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => ( {data.cards.map((card, idx) => (
<AmountCardItem <AmountCardItem
key={card.id} key={card.id}

View File

@@ -22,6 +22,7 @@ import {
Banknote, Banknote,
CircleDollarSign, CircleDollarSign,
LayoutGrid, LayoutGrid,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -159,8 +160,8 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'세금 신고': 'taxReport', '세금 신고': 'taxReport',
'신규 업체 등록': 'newVendor', '신규 업체 등록': 'newVendor',
'연차': 'annualLeave', '연차': 'annualLeave',
'지각': 'lateness', '차량': 'vehicle',
'결근': 'absence', '장비': 'equipment',
'발주': 'purchase', '발주': 'purchase',
'결재 요청': 'approvalRequest', '결재 요청': 'approvalRequest',
}; };
@@ -274,6 +275,20 @@ interface EnhancedMonthlyExpenseSectionProps {
onCardClick?: (cardId: string) => void; onCardClick?: (cardId: string) => void;
} }
// 당월 예상 지출 카드 설정
const EXPENSE_CARD_CONFIGS: Array<{
icon: LucideIcon;
iconBg: string;
bgClass: string;
labelClass: string;
defaultLabel: string;
defaultId: string;
}> = [
{ icon: Receipt, iconBg: '#8b5cf6', bgClass: 'bg-purple-50 border-purple-200 dark:bg-purple-900/30 dark:border-purple-800', labelClass: 'text-purple-700 dark:text-purple-300', defaultLabel: '매입', defaultId: 'me1' },
{ icon: CreditCard, iconBg: '#3b82f6', bgClass: 'bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800', labelClass: 'text-blue-700 dark:text-blue-300', defaultLabel: '카드', defaultId: 'me2' },
{ icon: Banknote, iconBg: '#f59e0b', bgClass: 'bg-amber-50 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800', labelClass: 'text-amber-700 dark:text-amber-300', defaultLabel: '발행어음', defaultId: 'me3' },
];
export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) { export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) {
// 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환) // 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환)
const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0); const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0);
@@ -291,77 +306,35 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
> >
{/* 카드 그리드 */} {/* 카드 그리드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* 카드 1: 매입 */} {EXPENSE_CARD_CONFIGS.map((config, idx) => {
<div const card = data.cards[idx];
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-purple-50 border-purple-200 dark:bg-purple-900/30 dark:border-purple-800" const CardIcon = config.icon;
onClick={() => onCardClick?.(data.cards[0]?.id || 'me1')} return (
> <div
<div className="flex items-center gap-2 mb-2"> key={config.defaultId}
<div style={{ backgroundColor: '#8b5cf6' }} className="p-1.5 rounded-lg"> className={`rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col ${config.bgClass}`}
<Receipt className="h-4 w-4 text-white" /> onClick={() => onCardClick?.(card?.id || config.defaultId)}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: config.iconBg }} className="p-1.5 rounded-lg">
<CardIcon className="h-4 w-4 text-white" />
</div>
<span className={`text-sm font-medium ${config.labelClass}`}>
{card?.label || config.defaultLabel}
</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(card?.amount || 0)}
</div>
{card?.previousLabel && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{card.previousLabel}
</div>
)}
</div> </div>
<span className="text-sm font-medium text-purple-700 dark:text-purple-300"> );
{data.cards[0]?.label || '매입'} })}
</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[0]?.amount || 0)}
</div>
{data.cards[0]?.previousLabel && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[0].previousLabel}
</div>
)}
</div>
{/* 카드 2: 카드 */}
<div
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800"
onClick={() => onCardClick?.(data.cards[1]?.id || 'me2')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#3b82f6' }} className="p-1.5 rounded-lg">
<CreditCard className="h-4 w-4 text-white" />
</div>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{data.cards[1]?.label || '카드'}
</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[1]?.amount || 0)}
</div>
{data.cards[1]?.previousLabel && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[1].previousLabel}
</div>
)}
</div>
{/* 카드 3: 발행어음 */}
<div
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-amber-50 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800"
onClick={() => onCardClick?.(data.cards[2]?.id || 'me3')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#f59e0b' }} className="p-1.5 rounded-lg">
<Banknote className="h-4 w-4 text-white" />
</div>
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">
{data.cards[2]?.label || '발행어음'}
</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[2]?.amount || 0)}
</div>
{data.cards[2]?.previousLabel && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[2].previousLabel}
</div>
)}
</div>
{/* 카드 4: 총 예상 지출 합계 (강조) */} {/* 카드 4: 총 예상 지출 합계 (강조) */}
<div <div

View File

@@ -10,13 +10,7 @@ import {
AlertCircle, AlertCircle,
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import { formatCompactAmount } from '@/lib/utils/amount';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { import {
BarChart, BarChart,
@@ -39,24 +33,12 @@ interface PurchaseStatusSectionProps {
data: PurchaseStatusData; data: PurchaseStatusData;
} }
const formatAmount = (value: number) => {
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}`;
if (value >= 10000) return `${(value / 10000).toFixed(0)}`;
return value.toLocaleString();
};
export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) { export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
const [supplierFilter, setSupplierFilter] = useState<string[]>([]); const [supplierFilter, setSupplierFilter] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('date-desc');
const filteredItems = data.dailyItems const filteredItems = data.dailyItems
.filter((item) => supplierFilter.length === 0 || supplierFilter.includes(item.supplier)) .filter((item) => supplierFilter.length === 0 || supplierFilter.includes(item.supplier));
.sort((a, b) => {
if (sortOrder === 'date-desc') return b.date.localeCompare(a.date);
if (sortOrder === 'date-asc') return a.date.localeCompare(b.date);
if (sortOrder === 'amount-desc') return b.amount - a.amount;
return a.amount - b.amount;
});
const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))]; const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))];
@@ -130,7 +112,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
<BarChart data={data.monthlyTrend}> <BarChart data={data.monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" /> <CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} /> <XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} /> <YAxis tickFormatter={formatCompactAmount} tick={{ fontSize: 11 }} />
<Tooltip <Tooltip
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매입']} formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매입']}
/> />
@@ -189,17 +171,6 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
placeholder="전체 공급처" placeholder="전체 공급처"
className="w-full h-8 text-xs" className="w-full h-8 text-xs"
/> />
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm min-w-[500px]"> <table className="w-full text-sm min-w-[500px]">

View File

@@ -12,14 +12,8 @@ import {
DollarSign, DollarSign,
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { formatCompactAmount } from '@/lib/utils/amount';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -37,24 +31,12 @@ interface SalesStatusSectionProps {
data: SalesStatusData; data: SalesStatusData;
} }
const formatAmount = (value: number) => {
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}`;
if (value >= 10000) return `${(value / 10000).toFixed(0)}`;
return value.toLocaleString();
};
export function SalesStatusSection({ data }: SalesStatusSectionProps) { export function SalesStatusSection({ data }: SalesStatusSectionProps) {
const [clientFilter, setClientFilter] = useState<string[]>([]); const [clientFilter, setClientFilter] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('date-desc');
const filteredItems = data.dailyItems const filteredItems = data.dailyItems
.filter((item) => clientFilter.length === 0 || clientFilter.includes(item.client)) .filter((item) => clientFilter.length === 0 || clientFilter.includes(item.client));
.sort((a, b) => {
if (sortOrder === 'date-desc') return b.date.localeCompare(a.date);
if (sortOrder === 'date-asc') return a.date.localeCompare(b.date);
if (sortOrder === 'amount-desc') return b.amount - a.amount;
return a.amount - b.amount;
});
const clients = [...new Set(data.dailyItems.map((item) => item.client))]; const clients = [...new Set(data.dailyItems.map((item) => item.client))];
@@ -143,7 +125,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
<BarChart data={data.monthlyTrend}> <BarChart data={data.monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" /> <CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} /> <XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} /> <YAxis tickFormatter={formatCompactAmount} tick={{ fontSize: 11 }} />
<Tooltip <Tooltip
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']} formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']}
/> />
@@ -158,7 +140,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>
<BarChart data={data.clientSales} layout="vertical"> <BarChart data={data.clientSales} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" /> <CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis type="number" tickFormatter={formatAmount} tick={{ fontSize: 11 }} /> <XAxis type="number" tickFormatter={formatCompactAmount} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 12 }} width={80} /> <YAxis type="category" dataKey="name" tick={{ fontSize: 12 }} width={80} />
<Tooltip <Tooltip
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']} formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']}
@@ -187,17 +169,6 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
placeholder="전체 거래처" placeholder="전체 거래처"
className="w-full h-8 text-xs" className="w-full h-8 text-xs"
/> />
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm min-w-[500px]"> <table className="w-full text-sm min-w-[500px]">

View File

@@ -13,8 +13,8 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'세금 신고': 'taxReport', '세금 신고': 'taxReport',
'신규 업체 등록': 'newVendor', '신규 업체 등록': 'newVendor',
'연차': 'annualLeave', '연차': 'annualLeave',
'지각': 'lateness', '차량': 'vehicle',
'결근': 'absence', '장비': 'equipment',
'발주': 'purchase', '발주': 'purchase',
'결재 요청': 'approvalRequest', '결재 요청': 'approvalRequest',
}; };

View File

@@ -3,13 +3,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { PackageX } from 'lucide-react'; import { PackageX } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { CollapsibleDashboardCard } from '../components'; import { CollapsibleDashboardCard } from '../components';
import type { UnshippedData } from '../types'; import type { UnshippedData } from '../types';
@@ -20,16 +13,11 @@ interface UnshippedSectionProps {
export function UnshippedSection({ data }: UnshippedSectionProps) { export function UnshippedSection({ data }: UnshippedSectionProps) {
const [clientFilter, setClientFilter] = useState<string[]>([]); const [clientFilter, setClientFilter] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('due-asc');
const clients = [...new Set(data.items.map((item) => item.orderClient))]; const clients = [...new Set(data.items.map((item) => item.orderClient))];
const filteredItems = data.items const filteredItems = data.items
.filter((item) => clientFilter.length === 0 || clientFilter.includes(item.orderClient)) .filter((item) => clientFilter.length === 0 || clientFilter.includes(item.orderClient));
.sort((a, b) => {
if (sortOrder === 'due-asc') return a.daysLeft - b.daysLeft;
return b.daysLeft - a.daysLeft;
});
return ( return (
<CollapsibleDashboardCard <CollapsibleDashboardCard
@@ -55,15 +43,6 @@ export function UnshippedSection({ data }: UnshippedSectionProps) {
placeholder="전체 거래처" placeholder="전체 거래처"
className="w-full h-8 text-xs" className="w-full h-8 text-xs"
/> />
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due-asc"> </SelectItem>
<SelectItem value="due-desc"> </SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm min-w-[550px]"> <table className="w-full text-sm min-w-[550px]">

View File

@@ -396,7 +396,7 @@ export const SECTION_LABELS: Record<SectionKey, string> = {
dailyReport: '자금현황', dailyReport: '자금현황',
statusBoard: '현황판', statusBoard: '현황판',
monthlyExpense: '당월 예상 지출 내역', monthlyExpense: '당월 예상 지출 내역',
cardManagement: '카드/가지급금 관리', cardManagement: '가지급금 현황',
entertainment: '접대비 현황', entertainment: '접대비 현황',
welfare: '복리후생비 현황', welfare: '복리후생비 현황',
receivable: '미수금 현황', receivable: '미수금 현황',
@@ -422,10 +422,11 @@ export interface TodayIssueSettings {
taxReport: boolean; // 세금 신고 taxReport: boolean; // 세금 신고
newVendor: boolean; // 신규 업체 등록 newVendor: boolean; // 신규 업체 등록
annualLeave: boolean; // 연차 annualLeave: boolean; // 연차
lateness: boolean; // 지각 vehicle: boolean; // 차량
absence: boolean; // 결근 equipment: boolean; // 장비
purchase: boolean; // 발주 purchase: boolean; // 발주
approvalRequest: boolean; // 결재 요청 approvalRequest: boolean; // 결재 요청
fundStatus: boolean; // 자금 현황
} }
// 접대비 한도 관리 타입 // 접대비 한도 관리 타입
@@ -445,6 +446,7 @@ export interface EntertainmentSettings {
enabled: boolean; enabled: boolean;
limitType: EntertainmentLimitType; limitType: EntertainmentLimitType;
companyType: CompanyType; companyType: CompanyType;
highAmountThreshold: number; // 고액 결제 기준 금액
} }
// 복리후생비 설정 // 복리후생비 설정
@@ -455,6 +457,7 @@ export interface WelfareSettings {
fixedAmountPerMonth: number; // 직원당 정해 금액/월 fixedAmountPerMonth: number; // 직원당 정해 금액/월
ratio: number; // 연봉 총액 X 비율 (%) ratio: number; // 연봉 총액 X 비율 (%)
annualTotal: number; // 연간 복리후생비총액 annualTotal: number; // 연간 복리후생비총액
singlePaymentThreshold: number; // 1회 결제 기준 금액
} }
// 대시보드 전체 설정 // 대시보드 전체 설정
@@ -662,10 +665,43 @@ export interface QuarterlyTableConfig {
rows: QuarterlyTableRow[]; rows: QuarterlyTableRow[];
} }
// 검토 필요 카드 아이템 타입
export interface ReviewCardItem {
label: string;
amount: number;
subLabel: string; // e.g., "미증빙 5건"
}
// 검토 필요 카드 섹션 설정 타입
export interface ReviewCardsConfig {
title: string;
cards: ReviewCardItem[];
}
// 기간 필터 설정 타입
export type DateFilterPreset = '당해년도' | '전전월' | '전월' | '당월' | '어제' | '오늘';
export interface DateFilterConfig {
enabled: boolean;
presets?: DateFilterPreset[]; // 기간 버튼 목록 (기본: 전체)
defaultPreset?: DateFilterPreset; // 기본 선택 프리셋
showSearch?: boolean; // 검색 입력창 표시 여부
}
// 신고기간 셀렉트 설정 타입
export interface PeriodSelectConfig {
enabled: boolean;
options: { value: string; label: string }[];
defaultValue?: string;
}
// 상세 모달 전체 설정 타입 // 상세 모달 전체 설정 타입
export interface DetailModalConfig { export interface DetailModalConfig {
title: string; title: string;
dateFilter?: DateFilterConfig; // 기간선택기 + 검색
periodSelect?: PeriodSelectConfig; // 신고기간 셀렉트 (부가세 등)
summaryCards: SummaryCardData[]; summaryCards: SummaryCardData[];
reviewCards?: ReviewCardsConfig; // 검토 필요 카드 섹션
barChart?: BarChartConfig; barChart?: BarChartConfig;
pieChart?: PieChartConfig; pieChart?: PieChartConfig;
horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용) horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용)
@@ -691,10 +727,11 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
taxReport: false, taxReport: false,
newVendor: false, newVendor: false,
annualLeave: true, annualLeave: true,
lateness: true, vehicle: false,
absence: false, equipment: false,
purchase: false, purchase: false,
approvalRequest: false, approvalRequest: false,
fundStatus: true,
}, },
}, },
dailyReport: true, dailyReport: true,
@@ -704,6 +741,7 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
enabled: true, enabled: true,
limitType: 'annual', limitType: 'annual',
companyType: 'medium', companyType: 'medium',
highAmountThreshold: 500000,
}, },
welfare: { welfare: {
enabled: true, enabled: true,
@@ -712,6 +750,7 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
fixedAmountPerMonth: 200000, fixedAmountPerMonth: 200000,
ratio: 20.5, ratio: 20.5,
annualTotal: 20000000, annualTotal: 20000000,
singlePaymentThreshold: 500000,
}, },
receivable: true, receivable: true,
debtCollection: true, debtCollection: true,
@@ -737,10 +776,11 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
taxReport: false, taxReport: false,
newVendor: false, newVendor: false,
annualLeave: true, annualLeave: true,
lateness: true, vehicle: false,
absence: false, equipment: false,
purchase: false, purchase: false,
approvalRequest: false, approvalRequest: false,
fundStatus: true,
}, },
}, },
}; };