feat: CEO 대시보드 리팩토링 및 회계 관리 개선
- CEO 대시보드: 컴포넌트 분리(DashboardSettingsSections, DetailModalSections), 모달/섹션 개선, useCEODashboard 최적화 - 회계: 부실채권/매출/매입/일일보고 UI 및 타입 개선 - 공통: Sidebar, useDashboardFetch 훅 추가, amount/status-config 유틸 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,7 @@ export default function BadDebtCollectionPage() {
|
||||
return (
|
||||
<BadDebtCollection
|
||||
initialData={data}
|
||||
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; recovered_amount: number; bad_debt_amount: number; } | null}
|
||||
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; collection_end_amount: number; } | null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,8 +39,10 @@ import type {
|
||||
} from './types';
|
||||
import {
|
||||
STATUS_SELECT_OPTIONS,
|
||||
COLLECTION_END_REASON_OPTIONS,
|
||||
VENDOR_TYPE_LABELS,
|
||||
} from './types';
|
||||
import type { CollectionEndReason } from './types';
|
||||
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
@@ -87,6 +89,7 @@ const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'>
|
||||
assignedManagerId: null,
|
||||
assignedManager: null,
|
||||
settingToggle: true,
|
||||
collectionEndReason: undefined,
|
||||
badDebtCount: 0,
|
||||
badDebts: [],
|
||||
files: [],
|
||||
@@ -778,22 +781,47 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(val) => handleChange('status', val as CollectionStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_SELECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(val) => {
|
||||
handleChange('status', val as CollectionStatus);
|
||||
if (val !== 'collectionEnd') {
|
||||
handleChange('collectionEndReason', null);
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_SELECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.status === 'collectionEnd' && (
|
||||
<Select
|
||||
value={formData.collectionEndReason || ''}
|
||||
onValueChange={(val) => handleChange('collectionEndReason', val as CollectionEndReason)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="종료사유 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLLECTION_END_REASON_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 연체일수 */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -72,8 +72,9 @@ function mapApiStatusToFrontend(apiStatus: string): CollectionStatus {
|
||||
switch (apiStatus) {
|
||||
case 'collecting': return 'collecting';
|
||||
case 'legal_action': return 'legalAction';
|
||||
case 'recovered': return 'recovered';
|
||||
case 'bad_debt': return 'badDebt';
|
||||
case 'recovered':
|
||||
case 'bad_debt':
|
||||
case 'collection_end': return 'collectionEnd';
|
||||
default: return 'collecting';
|
||||
}
|
||||
}
|
||||
@@ -82,8 +83,7 @@ function mapFrontendStatusToApi(status: CollectionStatus): string {
|
||||
switch (status) {
|
||||
case 'collecting': return 'collecting';
|
||||
case 'legalAction': return 'legal_action';
|
||||
case 'recovered': return 'recovered';
|
||||
case 'badDebt': return 'bad_debt';
|
||||
case 'collectionEnd': return 'collection_end';
|
||||
default: return 'collecting';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
|
||||
|
||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -56,6 +57,7 @@ const tableColumns = [
|
||||
{ key: 'managerName', label: '담당자', className: 'w-[100px]', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
];
|
||||
|
||||
// ===== Props 타입 정의 =====
|
||||
@@ -65,8 +67,7 @@ interface BadDebtCollectionProps {
|
||||
total_amount: number;
|
||||
collecting_amount: number;
|
||||
legal_action_amount: number;
|
||||
recovered_amount: number;
|
||||
bad_debt_amount: number;
|
||||
collection_end_amount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
totalAmount: initialSummary.total_amount,
|
||||
collectingAmount: initialSummary.collecting_amount,
|
||||
legalActionAmount: initialSummary.legal_action_amount,
|
||||
recoveredAmount: initialSummary.recovered_amount,
|
||||
collectionEndAmount: initialSummary.collection_end_amount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,11 +145,11 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
const legalActionAmount = data
|
||||
.filter((d) => d.status === 'legalAction')
|
||||
.reduce((sum, d) => sum + d.debtAmount, 0);
|
||||
const recoveredAmount = data
|
||||
.filter((d) => d.status === 'recovered')
|
||||
const collectionEndAmount = data
|
||||
.filter((d) => d.status === 'collectionEnd')
|
||||
.reduce((sum, d) => sum + d.debtAmount, 0);
|
||||
|
||||
return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount };
|
||||
return { totalAmount, collectingAmount, legalActionAmount, collectionEndAmount };
|
||||
}, [data, initialSummary]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
@@ -335,7 +336,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
},
|
||||
{
|
||||
label: '회수완료',
|
||||
value: `${formatNumber(statsData.recoveredAmount)}원`,
|
||||
value: `${formatNumber(statsData.collectionEndAmount)}원`,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
@@ -390,6 +391,27 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
disabled={isPending}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 작업 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700"
|
||||
onClick={() => handlers.onDelete?.(item)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// ===== 악성채권 추심관리 타입 정의 =====
|
||||
|
||||
// 추심 상태
|
||||
export type CollectionStatus = 'collecting' | 'legalAction' | 'recovered' | 'badDebt';
|
||||
export type CollectionStatus = 'collecting' | 'legalAction' | 'collectionEnd';
|
||||
|
||||
// 추심종료 사유
|
||||
export type CollectionEndReason = 'recovered' | 'badDebt';
|
||||
|
||||
export const COLLECTION_END_REASON_OPTIONS: { value: CollectionEndReason; label: string }[] = [
|
||||
{ value: 'recovered', label: '회수완료' },
|
||||
{ value: 'badDebt', label: '대손처리' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest';
|
||||
@@ -70,6 +78,7 @@ export interface BadDebtRecord {
|
||||
debtAmount: number; // 총 미수금액
|
||||
badDebtCount: number; // 악성채권 건수
|
||||
status: CollectionStatus; // 대표 상태 (가장 최근)
|
||||
collectionEndReason?: CollectionEndReason; // 추심종료 사유 (status === 'collectionEnd'일 때)
|
||||
overdueDays: number; // 최대 연체일수
|
||||
overdueToggle: boolean;
|
||||
occurrenceDate: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { format, parseISO, subMonths, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { Download, FileText, Loader2, RefreshCw, Calendar } from 'lucide-react';
|
||||
import { Download, FileText, Loader2, Printer, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -15,18 +15,27 @@ import {
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from '@/components/ui/table';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { NoteReceivableItem, DailyAccountItem } from './types';
|
||||
import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
// ===== 빠른 월 선택 버튼 정의 =====
|
||||
const QUICK_MONTH_BUTTONS = [
|
||||
{ label: '이번달', months: 0 },
|
||||
{ label: '지난달', months: 1 },
|
||||
{ label: 'D-2월', months: 2 },
|
||||
{ label: 'D-3월', months: 3 },
|
||||
{ label: 'D-4월', months: 4 },
|
||||
{ label: 'D-5월', months: 5 },
|
||||
] as const;
|
||||
|
||||
// ===== Props 인터페이스 =====
|
||||
interface DailyReportProps {
|
||||
initialNoteReceivables?: NoteReceivableItem[];
|
||||
@@ -36,7 +45,9 @@ interface DailyReportProps {
|
||||
export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts = [] }: DailyReportProps) {
|
||||
const { canExport } = usePermission();
|
||||
// ===== 상태 관리 =====
|
||||
const [selectedDate, setSelectedDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
|
||||
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [noteReceivables, setNoteReceivables] = useState<NoteReceivableItem[]>(initialNoteReceivables);
|
||||
const [dailyAccounts, setDailyAccounts] = useState<DailyAccountItem[]>(initialDailyAccounts);
|
||||
const [summary, setSummary] = useState<{
|
||||
@@ -53,9 +64,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [noteResult, accountResult, summaryResult] = await Promise.all([
|
||||
getNoteReceivables({ date: selectedDate }),
|
||||
getDailyAccounts({ date: selectedDate }),
|
||||
getDailyReportSummary({ date: selectedDate }),
|
||||
getNoteReceivables({ date: startDate }),
|
||||
getDailyAccounts({ date: startDate }),
|
||||
getDailyReportSummary({ date: startDate }),
|
||||
]);
|
||||
|
||||
if (noteResult.success) {
|
||||
@@ -81,20 +92,20 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 초기 로드 및 날짜 변경시 재로드 =====
|
||||
const isInitialMount = useRef(true);
|
||||
const prevDateRef = useRef(selectedDate);
|
||||
const prevDateRef = useRef(startDate);
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 마운트 또는 날짜가 실제로 변경된 경우에만 로드
|
||||
if (isInitialMount.current || prevDateRef.current !== selectedDate) {
|
||||
if (isInitialMount.current || prevDateRef.current !== startDate) {
|
||||
isInitialMount.current = false;
|
||||
prevDateRef.current = selectedDate;
|
||||
prevDateRef.current = startDate;
|
||||
loadData();
|
||||
}
|
||||
}, [selectedDate, loadData]);
|
||||
}, [startDate, loadData]);
|
||||
|
||||
// ===== 어음 합계 (API 요약 사용) =====
|
||||
const noteReceivableTotal = useMemo(() => {
|
||||
@@ -144,9 +155,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
}, [accountTotals]);
|
||||
|
||||
// ===== 선택된 날짜 정보 =====
|
||||
const selectedDateInfo = useMemo(() => {
|
||||
const startDateInfo = useMemo(() => {
|
||||
try {
|
||||
const date = parseISO(selectedDate);
|
||||
const date = parseISO(startDate);
|
||||
return {
|
||||
formatted: format(date, 'yyyy년 M월 d일', { locale: ko }),
|
||||
dayOfWeek: format(date, 'EEEE', { locale: ko }),
|
||||
@@ -154,12 +165,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} catch {
|
||||
return { formatted: '', dayOfWeek: '' };
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 엑셀 다운로드 (프록시 API 직접 호출) =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
const url = `/api/proxy/daily-report/export?date=${selectedDate}`;
|
||||
const url = `/api/proxy/daily-report/export?date=${startDate}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -169,7 +180,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${selectedDate}.xlsx`;
|
||||
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${startDate}.xlsx`;
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -183,7 +194,36 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 빠른 월 선택 =====
|
||||
const handleQuickMonth = useCallback((monthsAgo: number) => {
|
||||
const target = monthsAgo === 0 ? new Date() : subMonths(new Date(), monthsAgo);
|
||||
setStartDate(format(startOfMonth(target), 'yyyy-MM-dd'));
|
||||
setEndDate(format(endOfMonth(target), 'yyyy-MM-dd'));
|
||||
}, []);
|
||||
|
||||
// ===== 인쇄 =====
|
||||
const handlePrint = useCallback(() => {
|
||||
window.print();
|
||||
}, []);
|
||||
|
||||
// ===== 검색 필터링 =====
|
||||
const filteredNoteReceivables = useMemo(() => {
|
||||
if (!searchTerm) return noteReceivables;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return noteReceivables.filter(item =>
|
||||
item.content.toLowerCase().includes(term)
|
||||
);
|
||||
}, [noteReceivables, searchTerm]);
|
||||
|
||||
const filteredDailyAccounts = useMemo(() => {
|
||||
if (!searchTerm) return dailyAccounts;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return dailyAccounts.filter(item =>
|
||||
item.category.toLowerCase().includes(term)
|
||||
);
|
||||
}, [dailyAccounts, searchTerm]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -194,42 +234,57 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
{/* 헤더 액션 (날짜 선택, 버튼 등) */}
|
||||
{/* 헤더 액션 (날짜 선택, 빠른 월 선택, 검색, 인쇄) */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Calendar className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-700 shrink-0">조회 일자</span>
|
||||
<DatePicker
|
||||
value={selectedDate}
|
||||
onChange={setSelectedDate}
|
||||
className="w-auto min-w-[140px]"
|
||||
size="sm"
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={isLoading}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
새로고침
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-8 px-2 text-xs">
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
<CardContent className="p-3 md:p-4">
|
||||
<div className="flex flex-col gap-2 md:gap-3">
|
||||
{/* DateRange */}
|
||||
<DateRangePicker
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
size="sm"
|
||||
className="w-full md:w-auto md:min-w-[280px]"
|
||||
displayFormat="yyyy-MM-dd"
|
||||
/>
|
||||
{/* 빠른 월 선택 버튼 - 모바일: 가로 스크롤 */}
|
||||
<div className="flex items-center gap-1.5 md:gap-2 overflow-x-auto pb-1 -mb-1">
|
||||
{QUICK_MONTH_BUTTONS.map((btn) => (
|
||||
<Button
|
||||
key={btn.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 md:h-8 px-2 md:px-2.5 text-xs shrink-0"
|
||||
onClick={() => handleQuickMonth(btn.months)}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{/* 검색 + 인쇄/엑셀 - 모바일: 세로 배치 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-[300px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint} className="h-7 md:h-8 px-2 md:px-3 text-xs">
|
||||
<Printer className="mr-1 h-3.5 w-3.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-7 md:h-8 px-2 md:px-3 text-xs">
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -237,19 +292,19 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
|
||||
{/* 어음 및 외상매출채권현황 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="min-w-[550px]">
|
||||
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
|
||||
<div className="min-w-[480px] md:min-w-[550px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold max-w-[200px]">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">만기일</TableHead>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">만기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -258,32 +313,32 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500">데이터를 불러오는 중...</span>
|
||||
<span className="text-gray-500 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : noteReceivables.length === 0 ? (
|
||||
) : filteredNoteReceivables.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
noteReceivables.map((item) => (
|
||||
filteredNoteReceivables.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[200px] truncate">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">{item.dueDate}</TableCell>
|
||||
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{noteReceivables.length > 0 && (
|
||||
<TableFooter>
|
||||
{filteredNoteReceivables.length > 0 && (
|
||||
<TableFooter className="sticky bottom-0 z-10 bg-background">
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell className="font-bold text-xs md:text-sm">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
@@ -297,82 +352,63 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
|
||||
{/* 일자별 상세 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
일자: {selectedDateInfo.formatted} {selectedDateInfo.dayOfWeek}
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold">
|
||||
일자: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="min-w-[650px]">
|
||||
<div className="min-w-[420px] md:min-w-[650px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold max-w-[180px]">구분</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">상태</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">전월 이월</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">수입</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">지출</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">잔액</TableHead>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">구분</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">입금</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">출금</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">잔액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500">데이터를 불러오는 중...</span>
|
||||
<span className="text-gray-500 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : dailyAccounts.length === 0 ? (
|
||||
) : filteredDailyAccounts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{/* KRW 계좌들 */}
|
||||
{dailyAccounts
|
||||
{filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW')
|
||||
.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[180px] truncate">{item.category}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">
|
||||
<Badge className={MATCH_STATUS_COLORS[item.matchStatus]}>
|
||||
{MATCH_STATUS_LABELS[item.matchStatus]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.carryover)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.balance)}</TableCell>
|
||||
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.balance)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
{dailyAccounts.length > 0 && (
|
||||
{filteredDailyAccounts.length > 0 && (
|
||||
<TableFooter>
|
||||
{/* 외화원 (USD) 합계 */}
|
||||
<TableRow className="bg-blue-50/50">
|
||||
<TableCell className="font-semibold whitespace-nowrap">외화원 (USD) 합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.carryover)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.balance)}</TableCell>
|
||||
</TableRow>
|
||||
{/* 현금성 자산 합계 */}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold whitespace-nowrap">현금성 자산 합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.carryover)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.balance)}</TableCell>
|
||||
<TableCell className="font-bold whitespace-nowrap text-xs md:text-sm">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.balance)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
@@ -381,6 +417,114 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 예금 입출금 내역 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-center mb-3 md:mb-4 py-1.5 md:py-2 bg-gray-100 rounded-md">
|
||||
<h3 className="text-base md:text-lg font-semibold">예금 입출금 내역</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
{/* 입금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-blue-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-blue-700 text-sm md:text-base">입금</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">입금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
입금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW' && item.income > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`deposit-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-blue-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">입금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-red-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-red-700 text-sm md:text-base">출금</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">출금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
출금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW' && item.expense > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`withdrawal-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-red-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">출금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ import { purchaseConfig } from './purchaseConfig';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
|
||||
import { PURCHASE_TYPE_LABELS } from './types';
|
||||
import type { PurchaseRecord, PurchaseItem } from './types';
|
||||
import {
|
||||
getPurchaseById,
|
||||
createPurchase,
|
||||
@@ -74,7 +73,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||
const [vendorId, setVendorId] = useState('');
|
||||
const [vendorName, setVendorName] = useState('');
|
||||
const [purchaseType, setPurchaseType] = useState<PurchaseType>('unset');
|
||||
// purchaseType 삭제됨 (기획서 P.109)
|
||||
const [items, setItems] = useState<PurchaseItem[]>([createEmptyItem()]);
|
||||
const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false);
|
||||
|
||||
@@ -126,7 +125,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
setPurchaseDate(data.purchaseDate);
|
||||
setVendorId(data.vendorId);
|
||||
setVendorName(data.vendorName);
|
||||
setPurchaseType(data.purchaseType);
|
||||
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
|
||||
setTaxInvoiceReceived(data.taxInvoiceReceived);
|
||||
setSourceDocument(data.sourceDocument);
|
||||
@@ -250,7 +248,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
supplyAmount: totals.supplyAmount,
|
||||
vat: totals.vat,
|
||||
totalAmount: totals.total,
|
||||
purchaseType,
|
||||
taxInvoiceReceived,
|
||||
};
|
||||
|
||||
@@ -275,7 +272,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId]);
|
||||
}, [purchaseDate, vendorId, totals, taxInvoiceReceived, isNewMode, purchaseId]);
|
||||
|
||||
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -301,179 +298,101 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* ===== 기본 정보 섹션 ===== */}
|
||||
{/* ===== 기본 정보 섹션 (품의서/지출결의서 + 예상비용) ===== */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 품의서/지출결의서인 경우 전용 레이아웃 */}
|
||||
{sourceDocument ? (
|
||||
<>
|
||||
{/* 문서 타입 및 열람 버튼 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={getPresetStyle('orange')}>
|
||||
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">연결된 문서가 있습니다</span>
|
||||
</div>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 품의서/지출결의서 */}
|
||||
<div className="space-y-2">
|
||||
<Label>품의서</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={sourceDocument ? `${sourceDocument.documentNo} ${sourceDocument.title}` : ''}
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="연결된 품의서 없음"
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:ml-auto border-orange-300 text-orange-700 hover:bg-orange-100 w-full sm:w-auto"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenDocument}
|
||||
disabled={!sourceDocument}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
문서 열람
|
||||
열람
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 품의서/지출결의서용 필드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 품의서/지출결의서 제목 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} 제목</Label>
|
||||
<Input
|
||||
value={sourceDocument.title}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 예상비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label>예상비용</Label>
|
||||
<Input
|
||||
value={`${formatAmount(sourceDocument.expectedCost)}원`}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 매입번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입번호</Label>
|
||||
<Input
|
||||
value={purchaseNo}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Select
|
||||
value={vendorId}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="거래처 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 매입 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입 유형</Label>
|
||||
<Select
|
||||
value={purchaseType}
|
||||
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="매입 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 일반 매입 (품의서/지출결의서 없는 경우) */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 매입번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입번호</Label>
|
||||
<Input
|
||||
value={purchaseNo}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 매입일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입일</Label>
|
||||
<DatePicker
|
||||
value={purchaseDate}
|
||||
onChange={setPurchaseDate}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Select
|
||||
value={vendorId}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="거래처 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 매입 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입 유형</Label>
|
||||
<Select
|
||||
value={purchaseType}
|
||||
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="매입 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 예상비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label>예상비용</Label>
|
||||
<Input
|
||||
value={sourceDocument ? `${formatAmount(sourceDocument.expectedCost)}원` : ''}
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="-"
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 매입 정보 섹션 ===== */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">매입 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* 매입번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입번호</Label>
|
||||
<Input
|
||||
value={purchaseNo}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 매입일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입일</Label>
|
||||
<DatePicker
|
||||
value={purchaseDate}
|
||||
onChange={setPurchaseDate}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Select
|
||||
value={vendorId}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="거래처 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getPresetStyle } from '@/lib/utils/status-config';
|
||||
// Badge, getPresetStyle removed (매입유형/연결문서 컬럼 삭제)
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -57,9 +56,7 @@ import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import type { PurchaseRecord } from './types';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
PURCHASE_TYPE_LABELS,
|
||||
PURCHASE_TYPE_FILTER_OPTIONS,
|
||||
ISSUANCE_FILTER_OPTIONS,
|
||||
TAX_INVOICE_RECEIVED_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
|
||||
} from './types';
|
||||
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
|
||||
@@ -71,11 +68,9 @@ const tableColumns = [
|
||||
{ key: 'purchaseNo', label: '매입번호', sortable: true },
|
||||
{ key: 'purchaseDate', label: '매입일', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'sourceDocument', label: '연결문서', className: 'text-center', sortable: true },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
|
||||
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
|
||||
{ key: 'purchaseType', label: '매입유형', className: 'text-center', sortable: true },
|
||||
{ key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' },
|
||||
];
|
||||
|
||||
@@ -92,8 +87,7 @@ export function PurchaseManagement() {
|
||||
// 통합 필터 상태 (filterConfig 기반)
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
vendor: 'all',
|
||||
purchaseType: 'all',
|
||||
issuance: 'all',
|
||||
taxInvoiceReceived: 'all',
|
||||
sort: 'latest',
|
||||
});
|
||||
|
||||
@@ -142,9 +136,8 @@ export function PurchaseManagement() {
|
||||
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
|
||||
})
|
||||
.reduce((sum, d) => sum + d.totalAmount, 0);
|
||||
const unsetTypeCount = purchaseData.filter(d => d.purchaseType === 'unset').length;
|
||||
const taxInvoicePendingCount = purchaseData.filter(d => !d.taxInvoiceReceived).length;
|
||||
return { totalPurchaseAmount, monthlyAmount, unsetTypeCount, taxInvoicePendingCount };
|
||||
return { totalPurchaseAmount, monthlyAmount, taxInvoicePendingCount };
|
||||
}, [purchaseData]);
|
||||
|
||||
// ===== 거래처 목록 (필터용) =====
|
||||
@@ -163,17 +156,10 @@ export function PurchaseManagement() {
|
||||
allOptionLabel: '거래처 전체',
|
||||
},
|
||||
{
|
||||
key: 'purchaseType',
|
||||
label: '매입유형',
|
||||
key: 'taxInvoiceReceived',
|
||||
label: '세금계산서 수취여부',
|
||||
type: 'single',
|
||||
options: PURCHASE_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'issuance',
|
||||
label: '발행여부',
|
||||
type: 'single',
|
||||
options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
options: TAX_INVOICE_RECEIVED_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
@@ -194,8 +180,7 @@ export function PurchaseManagement() {
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setFilterValues({
|
||||
vendor: 'all',
|
||||
purchaseType: 'all',
|
||||
issuance: 'all',
|
||||
taxInvoiceReceived: 'all',
|
||||
sort: 'latest',
|
||||
});
|
||||
}, []);
|
||||
@@ -309,18 +294,16 @@ export function PurchaseManagement() {
|
||||
}
|
||||
|
||||
const vendorVal = fv.vendor as string;
|
||||
const purchaseTypeVal = fv.purchaseType as string;
|
||||
const issuanceVal = fv.issuance as string;
|
||||
const taxInvoiceReceivedVal = fv.taxInvoiceReceived as string;
|
||||
// 거래처 필터
|
||||
if (vendorVal !== 'all' && item.vendorName !== vendorVal) {
|
||||
return false;
|
||||
}
|
||||
// 매입유형 필터
|
||||
if (purchaseTypeVal !== 'all' && item.purchaseType !== purchaseTypeVal) {
|
||||
// 세금계산서 수취여부 필터
|
||||
if (taxInvoiceReceivedVal === 'received' && !item.taxInvoiceReceived) {
|
||||
return false;
|
||||
}
|
||||
// 발행여부 필터
|
||||
if (issuanceVal === 'taxInvoicePending' && item.taxInvoiceReceived) {
|
||||
if (taxInvoiceReceivedVal === 'notReceived' && item.taxInvoiceReceived) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -393,9 +376,8 @@ export function PurchaseManagement() {
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{ label: '총 매입', value: `${formatNumber(stats.totalPurchaseAmount)}원`, icon: Receipt, iconColor: 'text-blue-500' },
|
||||
{ label: '총매입', value: `${formatNumber(stats.totalPurchaseAmount)}원`, icon: Receipt, iconColor: 'text-blue-500' },
|
||||
{ label: '당월 매입', value: `${formatNumber(stats.monthlyAmount)}원`, icon: Receipt, iconColor: 'text-green-500' },
|
||||
{ label: '매입유형 미설정', value: `${stats.unsetTypeCount}건`, icon: Receipt, iconColor: 'text-orange-500' },
|
||||
{ label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-red-500' },
|
||||
],
|
||||
|
||||
@@ -406,13 +388,10 @@ export function PurchaseManagement() {
|
||||
<TableCell className="text-center"></TableCell>
|
||||
<TableCell className="font-bold">합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalSupplyAmount)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalVat)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalAmount)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
@@ -428,9 +407,7 @@ export function PurchaseManagement() {
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PurchaseRecord>
|
||||
) => {
|
||||
const isUnsetType = item.purchaseType === 'unset';
|
||||
return (
|
||||
) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
@@ -443,26 +420,9 @@ export function PurchaseManagement() {
|
||||
<TableCell className="text-sm font-medium">{item.purchaseNo}</TableCell>
|
||||
<TableCell>{item.purchaseDate}</TableCell>
|
||||
<TableCell>{item.vendorName}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.sourceDocument ? (
|
||||
<Badge variant="outline" className={`text-xs ${getPresetStyle('info')}`}>
|
||||
{item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.supplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.vat)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatNumber(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={isUnsetType ? 'border-red-500 text-red-500 bg-red-50' : ''}
|
||||
>
|
||||
{PURCHASE_TYPE_LABELS[item.purchaseType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Switch
|
||||
@@ -474,8 +434,7 @@ export function PurchaseManagement() {
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
@@ -488,14 +447,11 @@ export function PurchaseManagement() {
|
||||
key={item.id}
|
||||
title={item.vendorName}
|
||||
subtitle={item.purchaseNo}
|
||||
badge={PURCHASE_TYPE_LABELS[item.purchaseType]}
|
||||
badgeVariant="outline"
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '매입일', value: item.purchaseDate },
|
||||
{ label: '연결문서', value: item.sourceDocument ? (item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서') : '-' },
|
||||
{ label: '공급가액', value: `${formatNumber(item.supplyAmount)}원` },
|
||||
{ label: '합계금액', value: `${formatNumber(item.totalAmount)}원` },
|
||||
]}
|
||||
|
||||
@@ -81,8 +81,8 @@ export interface PurchaseRecord {
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
|
||||
|
||||
// 발행여부 필터
|
||||
export type IssuanceFilter = 'all' | 'taxInvoicePending';
|
||||
// 세금계산서 수취여부 필터
|
||||
export type TaxInvoiceReceivedFilter = 'all' | 'received' | 'notReceived';
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
@@ -154,10 +154,11 @@ export const PURCHASE_TYPE_FILTER_OPTIONS: { value: string; label: string }[] =
|
||||
{ value: 'unset', label: '미설정' },
|
||||
];
|
||||
|
||||
// 발행여부 필터 옵션
|
||||
export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [
|
||||
// 세금계산서 수취여부 필터 옵션
|
||||
export const TAX_INVOICE_RECEIVED_FILTER_OPTIONS: { value: TaxInvoiceReceivedFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'taxInvoicePending', label: '세금계산서 미수취' },
|
||||
{ value: 'received', label: '수취 확인' },
|
||||
{ value: 'notReceived', label: '수취 미확인' },
|
||||
];
|
||||
|
||||
// 계정과목명 셀렉터 옵션 (상단 일괄 변경용)
|
||||
|
||||
@@ -32,8 +32,7 @@ import {
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
||||
import { salesConfig } from './salesConfig';
|
||||
import type { SalesRecord, SalesItem, SalesType } from './types';
|
||||
import { SALES_TYPE_OPTIONS } from './types';
|
||||
import type { SalesRecord, SalesItem } from './types';
|
||||
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { getClients } from '../VendorManagement/actions';
|
||||
@@ -78,7 +77,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
const [salesDate, setSalesDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||
const [vendorId, setVendorId] = useState('');
|
||||
const [vendorName, setVendorName] = useState('');
|
||||
const [salesType, setSalesType] = useState<SalesType>('product');
|
||||
const [items, setItems] = useState<SalesItem[]>([createEmptyItem()]);
|
||||
const [taxInvoiceIssued, setTaxInvoiceIssued] = useState(false);
|
||||
const [transactionStatementIssued, setTransactionStatementIssued] = useState(false);
|
||||
@@ -126,7 +124,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
setSalesDate(data.salesDate);
|
||||
setVendorId(data.vendorId);
|
||||
setVendorName(data.vendorName);
|
||||
setSalesType(data.salesType);
|
||||
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
|
||||
setTaxInvoiceIssued(data.taxInvoiceIssued);
|
||||
setTransactionStatementIssued(data.transactionStatementIssued);
|
||||
@@ -158,7 +155,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
const saleData: Partial<SalesRecord> = {
|
||||
salesDate,
|
||||
vendorId,
|
||||
salesType,
|
||||
items,
|
||||
totalSupplyAmount: totals.supplyAmount,
|
||||
totalVat: totals.vat,
|
||||
@@ -189,7 +185,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [salesDate, vendorId, salesType, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
|
||||
}, [salesDate, vendorId, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
|
||||
|
||||
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -268,23 +264,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 매출 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salesType">매출 유형</Label>
|
||||
<Select value={salesType} onValueChange={(v) => setSalesType(v as SalesType)} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SALES_TYPE_OPTIONS.filter(o => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -318,28 +297,42 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
<CardTitle className="text-lg">세금계산서</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="taxInvoice">세금계산서 발행</Label>
|
||||
<Switch
|
||||
id="taxInvoice"
|
||||
checked={taxInvoiceIssued}
|
||||
onCheckedChange={setTaxInvoiceIssued}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="taxInvoice">세금계산서 발행</Label>
|
||||
<Switch
|
||||
id="taxInvoice"
|
||||
checked={taxInvoiceIssued}
|
||||
onCheckedChange={setTaxInvoiceIssued}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{taxInvoiceIssued ? (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
발행완료
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
미발행
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{taxInvoiceIssued ? (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
발행완료
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
미발행
|
||||
</span>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||
onClick={() => {
|
||||
toast.info('세금계산서 발행 기능 준비 중입니다.');
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
세금계산서 발행하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -55,11 +54,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import type { SalesRecord } from './types';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SALES_STATUS_LABELS,
|
||||
SALES_STATUS_COLORS,
|
||||
SALES_TYPE_LABELS,
|
||||
SALES_TYPE_FILTER_OPTIONS,
|
||||
ISSUANCE_FILTER_OPTIONS,
|
||||
TAX_INVOICE_FILTER_OPTIONS,
|
||||
TRANSACTION_STATEMENT_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
|
||||
} from './types';
|
||||
import { getSales, deleteSale, toggleSaleIssuance } from './actions';
|
||||
@@ -83,7 +79,6 @@ const tableColumns = [
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
|
||||
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
|
||||
{ key: 'salesType', label: '매출유형', className: 'text-center', sortable: true },
|
||||
{ key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' },
|
||||
{ key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' },
|
||||
];
|
||||
@@ -113,8 +108,8 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
// 필터 초기값 (filterConfig 기반 - ULP가 내부 state로 관리)
|
||||
const initialFilterValues: Record<string, string | string[]> = {
|
||||
vendor: 'all',
|
||||
salesType: 'all',
|
||||
issuance: 'all',
|
||||
taxInvoice: 'all',
|
||||
transactionStatement: 'all',
|
||||
sort: 'latest',
|
||||
};
|
||||
|
||||
@@ -148,17 +143,17 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
allOptionLabel: '거래처 전체',
|
||||
},
|
||||
{
|
||||
key: 'salesType',
|
||||
label: '매출유형',
|
||||
key: 'taxInvoice',
|
||||
label: '세금계산서 발행여부',
|
||||
type: 'single',
|
||||
options: SALES_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
options: TAX_INVOICE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'issuance',
|
||||
label: '발행여부',
|
||||
key: 'transactionStatement',
|
||||
label: '거래명세서 발행여부',
|
||||
type: 'single',
|
||||
options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
options: TRANSACTION_STATEMENT_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
@@ -322,18 +317,24 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
// 검색은 searchFilter에서 처리하므로 여기서는 필터만 처리
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
const issuanceVal = fv.issuance as string;
|
||||
const taxInvoiceVal = fv.taxInvoice as string;
|
||||
const transactionStatementVal = fv.transactionStatement as string;
|
||||
|
||||
let result = applyFilters(items, [
|
||||
enumFilter('vendorName', fv.vendor as string),
|
||||
enumFilter('salesType', fv.salesType as string),
|
||||
]);
|
||||
|
||||
// 발행여부 필터 (특수 로직 - enumFilter로 대체 불가)
|
||||
if (issuanceVal === 'taxInvoicePending') {
|
||||
// 세금계산서 발행여부 필터
|
||||
if (taxInvoiceVal === 'issued') {
|
||||
result = result.filter(item => item.taxInvoiceIssued);
|
||||
} else if (taxInvoiceVal === 'notIssued') {
|
||||
result = result.filter(item => !item.taxInvoiceIssued);
|
||||
}
|
||||
if (issuanceVal === 'transactionStatementPending') {
|
||||
|
||||
// 거래명세서 발행여부 필터
|
||||
if (transactionStatementVal === 'issued') {
|
||||
result = result.filter(item => item.transactionStatementIssued);
|
||||
} else if (transactionStatementVal === 'notIssued') {
|
||||
result = result.filter(item => !item.transactionStatementIssued);
|
||||
}
|
||||
|
||||
@@ -411,7 +412,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalAmount)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
@@ -443,9 +443,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
<TableCell className="text-right">{formatNumber(item.totalSupplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.totalVat)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatNumber(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{SALES_TYPE_LABELS[item.salesType]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Switch
|
||||
@@ -480,8 +477,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
key={item.id}
|
||||
title={item.vendorName}
|
||||
subtitle={item.salesNo}
|
||||
badge={SALES_TYPE_LABELS[item.salesType]}
|
||||
badgeVariant="outline"
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
|
||||
@@ -133,13 +133,22 @@ export const ACCOUNT_SUBJECT_SELECTOR_OPTIONS = [
|
||||
{ value: 'other', label: '기타매출' },
|
||||
];
|
||||
|
||||
// ===== 발행여부 필터 =====
|
||||
export type IssuanceFilter = 'all' | 'taxInvoicePending' | 'transactionStatementPending';
|
||||
// ===== 세금계산서 발행여부 필터 =====
|
||||
export type TaxInvoiceFilter = 'all' | 'issued' | 'notIssued';
|
||||
|
||||
export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [
|
||||
export const TAX_INVOICE_FILTER_OPTIONS: { value: TaxInvoiceFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'taxInvoicePending', label: '세금계산서 미발행' },
|
||||
{ value: 'transactionStatementPending', label: '거래명세서 미발행' },
|
||||
{ value: 'issued', label: '발행완료' },
|
||||
{ value: 'notIssued', label: '미발행' },
|
||||
];
|
||||
|
||||
// ===== 거래명세서 발행여부 필터 =====
|
||||
export type TransactionStatementFilter = 'all' | 'issued' | 'notIssued';
|
||||
|
||||
export const TRANSACTION_STATEMENT_FILTER_OPTIONS: { value: TransactionStatementFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'issued', label: '발행완료' },
|
||||
{ value: 'notIssued', label: '미발행' },
|
||||
];
|
||||
|
||||
// ===== 매출유형 필터 옵션 (스크린샷 기준) =====
|
||||
|
||||
@@ -35,12 +35,10 @@ import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
|
||||
import { mockData } from './mockData';
|
||||
import { LazySection } from './LazySection';
|
||||
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail } from '@/hooks/useCEODashboard';
|
||||
import { useCardManagementModals, type CardManagementCardId } from '@/hooks/useCardManagementModals';
|
||||
import type { MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
|
||||
import { useCardManagementModals } from '@/hooks/useCardManagementModals';
|
||||
import {
|
||||
getMonthlyExpenseModalConfig,
|
||||
getCardManagementModalConfig,
|
||||
getCardManagementModalConfigWithData,
|
||||
getEntertainmentModalConfig,
|
||||
getWelfareModalConfig,
|
||||
getVatModalConfig,
|
||||
@@ -93,19 +91,24 @@ export function CEODashboard() {
|
||||
const data = useMemo<CEODashboardData>(() => ({
|
||||
...mockData,
|
||||
// Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback
|
||||
// TODO: 자금현황 카드 변경 (일일일보/매출채권/매입채무/운영자금) - 새 API 구현 후 교체
|
||||
// TODO: 자금현황 카드 변경 (일일일보/미수금/미지급금/당월예상지출) - 새 API 구현 후 교체
|
||||
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,
|
||||
monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense,
|
||||
cardManagement: apiData.cardManagement.data ?? mockData.cardManagement,
|
||||
cardManagement: mockData.cardManagement,
|
||||
// Phase 2 섹션들 (API 연동 완료 - 목업 fallback 제거)
|
||||
todayIssue: apiData.statusBoard.data ?? [],
|
||||
todayIssueList: todayIssueData.data?.items ?? [],
|
||||
calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules,
|
||||
vat: vatData.data ?? mockData.vat,
|
||||
entertainment: entertainmentData.data ?? mockData.entertainment,
|
||||
welfare: welfareData.data ?? mockData.welfare,
|
||||
entertainment: mockData.entertainment,
|
||||
welfare: mockData.welfare,
|
||||
// 신규 섹션 (API 미구현 - mock 데이터)
|
||||
salesStatus: mockData.salesStatus,
|
||||
purchaseStatus: mockData.purchaseStatus,
|
||||
@@ -204,35 +207,28 @@ export function CEODashboard() {
|
||||
}, []);
|
||||
|
||||
// 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달)
|
||||
const handleMonthlyExpenseCardClick = useCallback(async (cardId: string) => {
|
||||
// 1. 먼저 API에서 데이터 fetch 시도
|
||||
const apiConfig = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId);
|
||||
|
||||
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
|
||||
const config = apiConfig ?? getMonthlyExpenseModalConfig(cardId);
|
||||
// TODO: D1.7 모달 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체
|
||||
const handleMonthlyExpenseCardClick = useCallback((cardId: string) => {
|
||||
const config = getMonthlyExpenseModalConfig(cardId);
|
||||
if (config) {
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}
|
||||
}, [monthlyExpenseDetailData]);
|
||||
}, []);
|
||||
|
||||
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
|
||||
const handleMonthlyExpenseClick = useCallback(() => {
|
||||
}, []);
|
||||
|
||||
// 카드/가지급금 관리 카드 클릭 (개별 카드 클릭 시 상세 모달)
|
||||
const handleCardManagementCardClick = useCallback(async (cardId: string) => {
|
||||
// 1. API에서 데이터 fetch (데이터 직접 반환)
|
||||
const modalData = await cardManagementModals.fetchModalData(cardId as CardManagementCardId);
|
||||
|
||||
// 2. API 데이터로 config 생성 (데이터 없으면 fallback)
|
||||
const config = getCardManagementModalConfigWithData(cardId, modalData);
|
||||
|
||||
// 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달
|
||||
// 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달
|
||||
const handleCardManagementCardClick = useCallback((cardId: string) => {
|
||||
const config = getCardManagementModalConfig('cm2');
|
||||
if (config) {
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}
|
||||
}, [cardManagementModals]);
|
||||
}, []);
|
||||
|
||||
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
|
||||
const handleEntertainmentCardClick = useCallback((cardId: string) => {
|
||||
|
||||
@@ -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);
|
||||
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 달러 포맷 함수
|
||||
*/
|
||||
export const formatUSD = (amount: number): string => {
|
||||
const formatUSD = (amount: number): string => {
|
||||
return '$ ' + new Intl.NumberFormat('en-US').format(amount);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,18 +9,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} 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 type {
|
||||
DashboardSettings,
|
||||
@@ -34,21 +19,13 @@ import type {
|
||||
WelfareCalculationType,
|
||||
SectionKey,
|
||||
} from '../types';
|
||||
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types';
|
||||
|
||||
// 현황판 항목 라벨 (구 오늘의 이슈)
|
||||
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
|
||||
orders: '수주',
|
||||
debtCollection: '채권 추심',
|
||||
safetyStock: '안전 재고',
|
||||
taxReport: '세금 신고',
|
||||
newVendor: '신규 업체 등록',
|
||||
annualLeave: '연차',
|
||||
lateness: '지각',
|
||||
absence: '결근',
|
||||
purchase: '발주',
|
||||
approvalRequest: '결재 요청',
|
||||
};
|
||||
import { DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types';
|
||||
import {
|
||||
SectionRow,
|
||||
StatusBoardItemsList,
|
||||
EntertainmentContent,
|
||||
WelfareContent,
|
||||
} from './DashboardSettingsSections';
|
||||
|
||||
interface DashboardSettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -65,6 +42,7 @@ export function DashboardSettingsDialog({
|
||||
}: DashboardSettingsDialogProps) {
|
||||
const [localSettings, setLocalSettings] = useState<DashboardSettings>(settings);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
todayIssueList: false,
|
||||
entertainment: false,
|
||||
welfare: false,
|
||||
statusBoard: false,
|
||||
@@ -192,8 +170,8 @@ export function DashboardSettingsDialog({
|
||||
// 접대비 설정 변경
|
||||
const handleEntertainmentChange = useCallback(
|
||||
(
|
||||
key: 'enabled' | 'limitType' | 'companyType',
|
||||
value: boolean | EntertainmentLimitType | CompanyType
|
||||
key: 'enabled' | 'limitType' | 'companyType' | 'highAmountThreshold',
|
||||
value: boolean | EntertainmentLimitType | CompanyType | number
|
||||
) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -248,85 +226,6 @@ export function DashboardSettingsDialog({
|
||||
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 => {
|
||||
switch (key) {
|
||||
@@ -336,8 +235,16 @@ export function DashboardSettingsDialog({
|
||||
label={SECTION_LABELS.todayIssueList}
|
||||
checked={localSettings.todayIssueList}
|
||||
onCheckedChange={handleTodayIssueListToggle}
|
||||
hasExpand
|
||||
isExpanded={expandedSections.todayIssueList}
|
||||
onToggleExpand={() => toggleSection('todayIssueList')}
|
||||
showGrip
|
||||
/>
|
||||
>
|
||||
<StatusBoardItemsList
|
||||
items={localSettings.statusBoard?.items ?? localSettings.todayIssue.items}
|
||||
onToggle={handleStatusBoardItemToggle}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
case 'dailyReport':
|
||||
@@ -361,26 +268,10 @@ export function DashboardSettingsDialog({
|
||||
onToggleExpand={() => toggleSection('statusBoard')}
|
||||
showGrip
|
||||
>
|
||||
<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={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[itemKey]}
|
||||
onCheckedChange={(checked) =>
|
||||
handleStatusBoardItemToggle(itemKey, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<StatusBoardItemsList
|
||||
items={localSettings.statusBoard?.items ?? localSettings.todayIssue.items}
|
||||
onToggle={handleStatusBoardItemToggle}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
@@ -415,211 +306,12 @@ export function DashboardSettingsDialog({
|
||||
onToggleExpand={() => toggleSection('entertainment')}
|
||||
showGrip
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">접대비 한도 관리</span>
|
||||
<Select
|
||||
value={localSettings.entertainment.limitType}
|
||||
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>
|
||||
<EntertainmentContent
|
||||
entertainment={localSettings.entertainment}
|
||||
onChange={handleEntertainmentChange}
|
||||
companyTypeInfoExpanded={expandedSections.companyTypeInfo}
|
||||
onToggleCompanyTypeInfo={() => toggleSection('companyTypeInfo')}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
@@ -634,87 +326,10 @@ export function DashboardSettingsDialog({
|
||||
onToggleExpand={() => toggleSection('welfare')}
|
||||
showGrip
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">복리후생비 한도 관리</span>
|
||||
<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>
|
||||
<WelfareContent
|
||||
welfare={localSettings.welfare}
|
||||
onChange={handleWelfareChange}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -19,9 +19,9 @@ export const mockData: CEODashboardData = {
|
||||
date: '2026년 1월 5일 월요일',
|
||||
cards: [
|
||||
{ id: 'dr1', label: '일일일보', amount: 3050000000, path: '/ko/accounting/daily-report' },
|
||||
{ id: 'dr2', label: '매출채권 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' },
|
||||
{ id: 'dr3', label: '매입채무 잔액', amount: 3050000000 },
|
||||
{ id: 'dr4', label: '운영자금 잔여', amount: 0, displayValue: '6.2개월' },
|
||||
{ id: 'dr2', label: '미수금 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' },
|
||||
{ id: 'dr3', label: '미지급금 잔액', amount: 3050000000 },
|
||||
{ id: 'dr4', label: '당월 예상 지출 합계', amount: 350000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
@@ -91,10 +91,11 @@ export const mockData: CEODashboardData = {
|
||||
cardManagement: {
|
||||
warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의',
|
||||
cards: [
|
||||
{ id: 'cm1', label: '카드', amount: 30123000, previousLabel: '미정리 5건 (가지급금 예정)' },
|
||||
{ id: 'cm2', label: '가지급금', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'cm3', label: '법인세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' },
|
||||
{ id: 'cm4', label: '대표자 종합세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' },
|
||||
{ id: 'cm1', label: '카드', amount: 3123000, previousLabel: '미정리 5건' },
|
||||
{ id: 'cm2', label: '경조사', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm3', label: '상품권', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm4', label: '접대비', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm_total', label: '총 가지급금 합계', amount: 350000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
@@ -135,10 +136,10 @@ export const mockData: CEODashboardData = {
|
||||
},
|
||||
entertainment: {
|
||||
cards: [
|
||||
{ id: 'et1', label: '매출', amount: 30530000000 },
|
||||
{ id: 'et2', label: '{1사분기} 접대비 총 한도', amount: 40123000 },
|
||||
{ id: 'et3', label: '{1사분기} 접대비 잔여한도', amount: 30123000 },
|
||||
{ id: 'et4', label: '{1사분기} 접대비 사용금액', amount: 10000000 },
|
||||
{ id: 'et1', label: '주말/심야', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'et2', label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, previousLabel: '불인정 5건' },
|
||||
{ id: 'et3', label: '고액 결제', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'et4', label: '증빙 미비', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
@@ -179,10 +180,10 @@ export const mockData: CEODashboardData = {
|
||||
},
|
||||
welfare: {
|
||||
cards: [
|
||||
{ id: 'wf1', label: '당해년도 복리후생비 한도', amount: 30123000 },
|
||||
{ id: 'wf2', label: '{1사분기} 복리후생비 총 한도', amount: 10123000 },
|
||||
{ id: 'wf3', label: '{1사분기} 복리후생비 잔여한도', amount: 5123000 },
|
||||
{ id: 'wf4', label: '{1사분기} 복리후생비 사용금액', amount: 5123000 },
|
||||
{ id: 'wf1', label: '비과세 한도 초과', amount: 3123000, previousLabel: '5건' },
|
||||
{ id: 'wf2', label: '사적 사용 의심', amount: 3123000, previousLabel: '5건' },
|
||||
{ id: 'wf3', label: '특정인 편중', amount: 3123000, previousLabel: '5건' },
|
||||
{ id: 'wf4', label: '항목별 한도 초과', amount: 3123000, previousLabel: '5건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
@@ -219,28 +220,22 @@ export const mockData: CEODashboardData = {
|
||||
id: 'rv2',
|
||||
label: '당월 미수금',
|
||||
amount: 10123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 60123000 },
|
||||
{ label: '입금', value: 30000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv3',
|
||||
label: '회사명',
|
||||
amount: 3123000,
|
||||
label: '미수금 거래처',
|
||||
amount: 31,
|
||||
unit: '건',
|
||||
subItems: [
|
||||
{ label: '매출', value: 6123000 },
|
||||
{ label: '입금', value: 3000000 },
|
||||
{ label: '연체', value: '21건' },
|
||||
{ label: '악성채권', value: '11건' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv4',
|
||||
label: '회사명',
|
||||
amount: 2123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 6123000 },
|
||||
{ label: '입금', value: 3000000 },
|
||||
],
|
||||
label: '미수금 Top 3',
|
||||
amount: 0,
|
||||
displayValue: '상세보기',
|
||||
},
|
||||
],
|
||||
checkPoints: [
|
||||
@@ -268,7 +263,7 @@ export const mockData: CEODashboardData = {
|
||||
{ id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' },
|
||||
{ id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' },
|
||||
{ id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' },
|
||||
{ id: 'dc4', label: '회수완료', amount: 280000000, subLabel: '10건' },
|
||||
{ id: 'dc4', label: '추심종료', amount: 280000000, subLabel: '10건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
|
||||
@@ -162,16 +162,6 @@ export function transformCm1ModalConfig(
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
@@ -196,46 +186,44 @@ export function transformCm2ModalConfig(
|
||||
// 테이블 데이터 매핑
|
||||
const tableData = (items || []).map((item) => ({
|
||||
date: item.loan_date,
|
||||
target: item.user_name,
|
||||
category: '-', // API에서 별도 필드 없음
|
||||
classification: item.status_label || '카드',
|
||||
category: '-',
|
||||
amount: item.amount,
|
||||
status: item.status_label || item.status,
|
||||
content: item.description,
|
||||
}));
|
||||
|
||||
// 대상 필터 옵션 동적 생성
|
||||
const uniqueTargets = [...new Set((items || []).map((item) => item.user_name))];
|
||||
const targetFilterOptions = [
|
||||
// 분류 필터 옵션 동적 생성
|
||||
const uniqueClassifications = [...new Set(tableData.map((item) => item.classification))];
|
||||
const classificationFilterOptions = [
|
||||
{ value: 'all', label: '전체' },
|
||||
...uniqueTargets.map((target) => ({
|
||||
value: target,
|
||||
label: target,
|
||||
...uniqueClassifications.map((cls) => ({
|
||||
value: cls,
|
||||
label: cls,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
title: '가지급금 상세',
|
||||
summaryCards: [
|
||||
{ label: '가지급금', value: formatKoreanCurrency(summary.total_outstanding) },
|
||||
{ label: '인정이자 4.6%', value: summary.recognized_interest, unit: '원' },
|
||||
{ label: '미설정', value: `${summary.pending_count ?? 0}건` },
|
||||
{ label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) },
|
||||
{ label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' },
|
||||
{ label: '미정리/미분류', value: `${summary.pending_count ?? 0}건` },
|
||||
],
|
||||
table: {
|
||||
title: '가지급금 관련 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '발생일시', align: 'center' },
|
||||
{ key: 'target', label: '대상', align: 'center' },
|
||||
{ key: 'date', label: '발생일', align: 'center' },
|
||||
{ key: 'classification', label: '분류', align: 'center' },
|
||||
{ key: 'category', label: '구분', align: 'center' },
|
||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||
{ key: 'status', label: '상태', align: 'center', highlightValue: '미설정' },
|
||||
{ key: 'content', label: '내용', align: 'left' },
|
||||
],
|
||||
data: tableData,
|
||||
filters: [
|
||||
{
|
||||
key: 'target',
|
||||
options: targetFilterOptions,
|
||||
key: 'classification',
|
||||
options: classificationFilterOptions,
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
@@ -247,16 +235,6 @@ export function transformCm2ModalConfig(
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
|
||||
@@ -153,16 +153,6 @@ export function getCardManagementModalConfig(cardId: string): DetailModalConfig
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
@@ -170,60 +160,68 @@ export function getCardManagementModalConfig(cardId: string): DetailModalConfig
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// P52: 가지급금 상세
|
||||
cm2: {
|
||||
title: '가지급금 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '가지급금', value: '4.5억원' },
|
||||
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
|
||||
{ label: '미설정', value: '10건' },
|
||||
{ label: '가지급금 합계', value: '4.5억원' },
|
||||
{ label: '가지급금 총액', value: 6000000, unit: '원' },
|
||||
{ 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: {
|
||||
title: '가지급금 관련 내역',
|
||||
title: '가지급금 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '발생일시', align: 'center' },
|
||||
{ key: 'target', label: '대상', align: 'center' },
|
||||
{ key: 'date', label: '발생일', align: 'center' },
|
||||
{ key: 'classification', label: '분류', align: 'center' },
|
||||
{ key: 'category', label: '구분', align: 'center' },
|
||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||
{ key: 'status', label: '상태', align: 'center', highlightValue: '미설정' },
|
||||
{ key: 'content', label: '내용', align: 'left' },
|
||||
{ key: 'response', label: '대응', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '미설정', content: '미설정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접비(미정리)', content: '접대비 불인정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미설정', content: '접대비 불인정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미설정', content: '미설정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '-', amount: 1000000, status: '미설정', content: '미설정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접대비', content: '접대비 불인정' },
|
||||
{ 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', classification: '카드', category: '카드명', amount: 1000000, response: '미증빙' },
|
||||
{ date: '2025-12-12', classification: '경조사', category: '계좌명', amount: 1000000, response: '미증빙' },
|
||||
{ date: '2025-12-12', classification: '상품권', category: '계좌명', amount: 1000000, response: '미증빙' },
|
||||
{ date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '주말 카드 사용' },
|
||||
{ date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '접대비 불인정' },
|
||||
{ date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '불인정 가맹점(귀금속)' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'target',
|
||||
key: 'classification',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '카드명', label: '카드명' },
|
||||
{ value: '계좌명', label: '계좌명' },
|
||||
{ value: '카드', label: '카드' },
|
||||
{ value: '경조사', label: '경조사' },
|
||||
{ value: '상품권', label: '상품권' },
|
||||
{ value: '접대비', label: '접대비' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'all', label: '정렬' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
{ value: 'latest', label: '최신순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
|
||||
@@ -5,18 +5,27 @@ import type { DetailModalConfig } from '../types';
|
||||
*/
|
||||
const entertainmentDetailConfig: DetailModalConfig = {
|
||||
title: '접대비 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
// 첫 번째 줄: 당해년도
|
||||
{ label: '당해년도 접대비 총한도', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 접대비 총 한도', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 접대비 사용금액', value: 6000000, 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: '원' },
|
||||
{ label: '당해년도 접대비 초과 금액', value: 0, 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: {
|
||||
title: '월별 접대비 사용 추이',
|
||||
data: [
|
||||
@@ -50,14 +59,14 @@ const entertainmentDetailConfig: DetailModalConfig = {
|
||||
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
|
||||
{ key: 'transDate', label: '거래일시', align: 'center', format: 'date' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'purpose', label: '사용용도', align: 'left' },
|
||||
{ key: 'content', label: '내용', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ 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, content: '미증빙' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '고액 결제' },
|
||||
{ cardName: '카드명', user: '김철수', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, content: '불인정 가맹점 (귀금속)' },
|
||||
{ cardName: '카드명', user: '이영희', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '접대비 불인정' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
@@ -71,14 +80,15 @@ const entertainmentDetailConfig: DetailModalConfig = {
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
key: 'content',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '주말/심야', label: '주말/심야' },
|
||||
{ value: '기피업종', label: '기피업종' },
|
||||
{ value: '고액 결제', label: '고액 결제' },
|
||||
{ value: '증빙 미비', label: '증빙 미비' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
@@ -91,24 +101,25 @@ const entertainmentDetailConfig: DetailModalConfig = {
|
||||
{
|
||||
title: '접대비 손금한도 계산 - 기본한도',
|
||||
columns: [
|
||||
{ key: 'type', label: '구분', align: 'left' },
|
||||
{ key: 'limit', label: '기본한도', align: 'right' },
|
||||
{ key: 'type', label: '법인 유형', align: 'left' },
|
||||
{ key: 'annualLimit', label: '연간 기본한도', align: 'right' },
|
||||
{ key: 'monthlyLimit', label: '월 환산', align: 'right' },
|
||||
],
|
||||
data: [
|
||||
{ type: '일반법인', limit: '3,600만원 (연 1,200만원)' },
|
||||
{ type: '중소기업', limit: '5,400만원 (연 3,600만원)' },
|
||||
{ type: '일반법인', annualLimit: '12,000,000원', monthlyLimit: '1,000,000원' },
|
||||
{ type: '중소기업', annualLimit: '36,000,000원', monthlyLimit: '3,000,000원' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '수입금액별 추가한도',
|
||||
columns: [
|
||||
{ key: 'range', label: '수입금액', align: 'left' },
|
||||
{ key: 'rate', label: '적용률', align: 'center' },
|
||||
{ key: 'range', label: '수입금액 구간', align: 'left' },
|
||||
{ key: 'formula', label: '추가한도 계산식', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ range: '100억원 이하', rate: '0.3%' },
|
||||
{ range: '100억원 초과 ~ 500억원 이하', rate: '0.2%' },
|
||||
{ range: '500억원 초과', rate: '0.03%' },
|
||||
{ range: '100억원 이하', formula: '수입금액 × 0.2%' },
|
||||
{ range: '100억 초과 ~ 500억 이하', formula: '2,000만원 + (수입금액 - 100억) × 0.1%' },
|
||||
{ range: '500억원 초과', formula: '6,000만원 + (수입금액 - 500억) × 0.03%' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -116,18 +127,20 @@ const entertainmentDetailConfig: DetailModalConfig = {
|
||||
calculationCards: {
|
||||
title: '접대비 계산',
|
||||
cards: [
|
||||
{ label: '기본한도', value: 36000000 },
|
||||
{ label: '추가한도', value: 91170000, operator: '+' },
|
||||
{ label: '접대비 손금한도', value: 127170000, operator: '=' },
|
||||
{ label: '중소기업 연간 기본한도', value: 36000000 },
|
||||
{ label: '당해년도 수입금액별 추가한도', value: 16000000, operator: '+' },
|
||||
{ label: '당해년도 접대비 총 한도', value: 52000000, operator: '=' },
|
||||
],
|
||||
},
|
||||
// 접대비 현황 (분기별)
|
||||
quarterlyTable: {
|
||||
title: '접대비 현황',
|
||||
rows: [
|
||||
{ label: '접대비 한도', q1: 31792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 127170000 },
|
||||
{ label: '접대비 사용', q1: 10000000, q2: 0, q3: 0, q4: 0, total: 10000000 },
|
||||
{ label: '접대비 잔여', q1: 21792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 117170000 },
|
||||
{ label: '한도금액', q1: 13000000, q2: 13000000, q3: 13000000, q4: 13000000, total: 52000000 },
|
||||
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
|
||||
{ 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',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
@@ -225,6 +228,11 @@ export function getEntertainmentModalConfig(cardId: string): DetailModalConfig |
|
||||
et_limit: entertainmentDetailConfig,
|
||||
et_remaining: entertainmentDetailConfig,
|
||||
et_used: entertainmentDetailConfig,
|
||||
// 대시보드 카드 ID (et1~et4) → 접대비 상세 모달
|
||||
et1: entertainmentDetailConfig,
|
||||
et2: entertainmentDetailConfig,
|
||||
et3: entertainmentDetailConfig,
|
||||
et4: entertainmentDetailConfig,
|
||||
};
|
||||
|
||||
return configs[cardId] || null;
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 당월 예상 지출 모달 설정
|
||||
* 당월 예상 지출 모달 설정 (D1.7 기획서 P48-51 반영)
|
||||
*/
|
||||
export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null {
|
||||
const configs: Record<string, DetailModalConfig> = {
|
||||
// P48: 매입 상세
|
||||
me1: {
|
||||
title: '당월 매입 상세',
|
||||
title: '매입 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '당월 매입', value: 3123000, unit: '원' },
|
||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '매입', value: 3123000, unit: '원' },
|
||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 매입 추이',
|
||||
title: '매입 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 45000000 },
|
||||
{ name: '2월', value: 52000000 },
|
||||
@@ -30,8 +36,8 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
title: '자재 유형별 구매 비율',
|
||||
data: [
|
||||
{ name: '원자재', value: 55000000, percentage: 55, color: '#60A5FA' },
|
||||
{ name: '부자재', value: 35000000, percentage: 35, color: '#34D399' },
|
||||
{ name: '포장재', value: 10000000, percentage: 10, color: '#FBBF24' },
|
||||
{ name: '부자재', value: 35000000, percentage: 35, color: '#FBBF24' },
|
||||
{ name: '포장재', value: 10000000, percentage: 10, color: '#F87171' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
@@ -41,36 +47,14 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
{ key: 'date', label: '매입일', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'amount', label: '매입금액', align: 'right', format: 'currency' },
|
||||
{ key: 'type', label: '매입유형', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ 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',
|
||||
},
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
@@ -78,15 +62,21 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// P49: 카드 상세
|
||||
me2: {
|
||||
title: '당월 카드 상세',
|
||||
title: '카드 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '당월 카드 사용', value: 6000000, unit: '원' },
|
||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '이용건', value: '10건' },
|
||||
{ label: '카드 사용', value: 6000000, unit: '원' },
|
||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '건수', value: '10건' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 카드 사용 추이',
|
||||
title: '카드 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 4500000 },
|
||||
{ name: '2월', value: 5200000 },
|
||||
@@ -104,8 +94,8 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
title: '사용자별 카드 사용 비율',
|
||||
data: [
|
||||
{ name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' },
|
||||
{ name: '김길동', value: 35000000, percentage: 35, color: '#34D399' },
|
||||
{ name: '이길동', value: 10000000, percentage: 10, color: '#FBBF24' },
|
||||
{ name: '김영희', value: 35000000, percentage: 35, color: '#FBBF24' },
|
||||
{ name: '이정현', value: 10000000, percentage: 10, color: '#F87171' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
@@ -114,30 +104,16 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ 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: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
|
||||
{ key: 'usageType', label: '계정과목', align: 'center', highlightValue: '미설정' },
|
||||
],
|
||||
data: [
|
||||
{ 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-10 09:45', 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: '접대비' },
|
||||
{ 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-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
@@ -145,21 +121,11 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
{ value: '김길동', label: '김길동' },
|
||||
{ value: '이길동', 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,
|
||||
totalLabel: '합계',
|
||||
@@ -167,14 +133,21 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// P50: 발행어음 상세
|
||||
me3: {
|
||||
title: '당월 발행어음 상세',
|
||||
title: '발행어음 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
presets: ['당해년도', '전전월', '전월', '당월', '어제'],
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '당월 발행어음 사용', value: 3123000, unit: '원' },
|
||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '발행어음', value: 3123000, unit: '원' },
|
||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 발행어음 추이',
|
||||
title: '발행어음 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 2000000 },
|
||||
{ name: '2월', value: 2500000 },
|
||||
@@ -188,15 +161,14 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
horizontalBarChart: {
|
||||
title: '당월 거래처별 발행어음',
|
||||
pieChart: {
|
||||
title: '거래처별 발행어음',
|
||||
data: [
|
||||
{ name: '거래처1', value: 50000000 },
|
||||
{ name: '거래처2', value: 35000000 },
|
||||
{ name: '거래처3', value: 20000000 },
|
||||
{ name: '거래처4', value: 6000000 },
|
||||
{ name: '거래처1', value: 50000000, percentage: 45, color: '#60A5FA' },
|
||||
{ name: '거래처2', value: 35000000, percentage: 32, color: '#FBBF24' },
|
||||
{ name: '거래처3', value: 20000000, percentage: 18, color: '#F87171' },
|
||||
{ name: '거래처4', value: 6000000, percentage: 5, color: '#34D399' },
|
||||
],
|
||||
color: '#60A5FA',
|
||||
},
|
||||
table: {
|
||||
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: 105000000, status: '보관중' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
@@ -238,16 +209,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
@@ -255,6 +216,7 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// P51: 당월 지출 예상 상세
|
||||
me4: {
|
||||
title: '당월 지출 예상 상세',
|
||||
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: '거래처명 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' },
|
||||
],
|
||||
filters: [
|
||||
@@ -291,14 +251,6 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '2025/12 계',
|
||||
@@ -314,4 +266,4 @@ export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig
|
||||
};
|
||||
|
||||
return configs[cardId] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,29 +7,36 @@ import type { DetailModalConfig } from '../types';
|
||||
export function getVatModalConfig(): DetailModalConfig {
|
||||
return {
|
||||
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: {
|
||||
title: '2026년 1사분기 세액 산출 내역',
|
||||
title: '2026년 1기 예정 부가세 요약',
|
||||
columns: [
|
||||
{ key: 'category', label: '구분', align: 'center' },
|
||||
{ key: 'amount', label: '금액', align: 'right' },
|
||||
{ key: 'note', label: '비고', align: 'left' },
|
||||
{ key: 'category', label: '구분', align: 'left' },
|
||||
{ key: 'supplyAmount', label: '공급가액', align: 'right' },
|
||||
{ key: 'taxAmount', label: '세액', align: 'right' },
|
||||
],
|
||||
data: [
|
||||
{ category: '매출세액', amount: '11,000,000', note: '과세매출 X 10%' },
|
||||
{ category: '매입세액', amount: '1,000,000', note: '공제대상 매입 X 10%' },
|
||||
{ category: '경감·공제세액', amount: '0', note: '해당없음' },
|
||||
],
|
||||
},
|
||||
// 예상 납부세액 계산
|
||||
calculationCards: {
|
||||
title: '예상 납부세액 계산',
|
||||
cards: [
|
||||
{ label: '매출세액', value: 11000000, unit: '원' },
|
||||
{ label: '매입세액', value: 1000000, unit: '원', operator: '-' },
|
||||
{ label: '경감·공제세액', value: 0, unit: '원', operator: '-' },
|
||||
{ label: '예상 납부세액', value: 10000000, unit: '원', operator: '=' },
|
||||
{ category: '매출(전자세금계산서)', supplyAmount: '100,000,000', taxAmount: '10,000,000' },
|
||||
{ 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: '10,000,000', taxAmount: '1,000,000' },
|
||||
{ category: '납부세액', supplyAmount: '', taxAmount: '6,000,000' },
|
||||
],
|
||||
},
|
||||
// 세금계산서 미발행/미수취 내역
|
||||
@@ -38,19 +45,17 @@ export function getVatModalConfig(): DetailModalConfig {
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', 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: 'vat', label: '부가세', align: 'right', format: 'currency' },
|
||||
{ key: 'invoiceStatus', label: '세금계산서 발행', align: 'center' },
|
||||
{ key: 'invoiceStatus', label: '세금계산서 미발행/미수취', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처1', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처2', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처3', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처4', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처5', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처6', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처7', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
@@ -62,25 +67,6 @@ export function getVatModalConfig(): DetailModalConfig {
|
||||
],
|
||||
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,
|
||||
totalLabel: '합계',
|
||||
@@ -88,4 +74,4 @@ export function getVatModalConfig(): DetailModalConfig {
|
||||
totalColumnKey: 'vat',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,18 +45,27 @@ export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): Detai
|
||||
|
||||
return {
|
||||
title: '복리후생비 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
// 1행: 당해년도 기준
|
||||
{ label: '당해년도 복리후생비 계정', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 한도', value: 600000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 사용', value: 6000000, 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: '원' },
|
||||
{ label: '당해년도 복리후생비 총 한도', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 초과 금액', value: 0, 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: {
|
||||
title: '월별 복리후생비 사용 추이',
|
||||
data: [
|
||||
@@ -89,36 +98,34 @@ export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): Detai
|
||||
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'usageType', label: '사용항목', align: 'center' },
|
||||
{ key: 'content', label: '내용', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '식비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, usageType: '건강검진' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, usageType: '경조사비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, usageType: '기타' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, usageType: '식비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, content: '비과세 한도 초과' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, content: '사적 사용 의심' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, content: '특정인 편중' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, content: '항목별 한도 초과' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, content: '비과세 한도 초과' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'usageType',
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '식비', label: '식비' },
|
||||
{ value: '건강검진', label: '건강검진' },
|
||||
{ value: '경조사비', label: '경조사비' },
|
||||
{ value: '기타', label: '기타' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
key: 'content',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '비과세 한도 초과', label: '비과세 한도 초과' },
|
||||
{ value: '사적 사용 의심', label: '사적 사용 의심' },
|
||||
{ value: '특정인 편중', label: '특정인 편중' },
|
||||
{ value: '항목별 한도 초과', label: '항목별 한도 초과' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -8,39 +7,22 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 type {
|
||||
DetailModalConfig,
|
||||
SummaryCardData,
|
||||
BarChartConfig,
|
||||
PieChartConfig,
|
||||
HorizontalBarChartConfig,
|
||||
TableConfig,
|
||||
TableFilterConfig,
|
||||
ComparisonSectionConfig,
|
||||
ReferenceTableConfig,
|
||||
CalculationCardsConfig,
|
||||
QuarterlyTableConfig,
|
||||
} from '../types';
|
||||
import type { DetailModalConfig } from '../types';
|
||||
import {
|
||||
DateFilterSection,
|
||||
PeriodSelectSection,
|
||||
SummaryCard,
|
||||
ReviewCardsSection,
|
||||
BarChartSection,
|
||||
PieChartSection,
|
||||
HorizontalBarChartSection,
|
||||
ComparisonSection,
|
||||
CalculationCardsSection,
|
||||
QuarterlyTableSection,
|
||||
ReferenceTableSection,
|
||||
TableSection,
|
||||
} from './DetailModalSections';
|
||||
|
||||
interface DetailModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -48,641 +30,6 @@ interface DetailModalProps {
|
||||
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) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} >
|
||||
@@ -702,6 +49,16 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
||||
</DialogHeader>
|
||||
|
||||
<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 && (
|
||||
<div className={cn(
|
||||
@@ -716,6 +73,11 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검토 필요 카드 영역 */}
|
||||
{config.reviewCards && (
|
||||
<ReviewCardsSection config={config.reviewCards} />
|
||||
)}
|
||||
|
||||
{/* 차트 영역 */}
|
||||
{(config.barChart || config.pieChart || config.horizontalBarChart) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
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 type { CardManagementData } from '../types';
|
||||
|
||||
// 카드별 아이콘 매핑
|
||||
const CARD_ICONS = [CreditCard, Wallet, Receipt, AlertTriangle];
|
||||
const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange'];
|
||||
const CARD_ICONS = [CreditCard, Gift, Receipt, AlertTriangle, Wallet];
|
||||
const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange', 'blue'];
|
||||
|
||||
interface CardManagementSectionProps {
|
||||
data: CardManagementData;
|
||||
@@ -28,8 +28,8 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
|
||||
return (
|
||||
<CollapsibleDashboardCard
|
||||
icon={<CreditCard style={{ color: '#ffffff' }} className="h-5 w-5" />}
|
||||
title="카드/가지급금 관리"
|
||||
subtitle="카드 및 가지급금 현황"
|
||||
title="가지급금 현황"
|
||||
subtitle="가지급금 관리 현황"
|
||||
>
|
||||
{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">
|
||||
@@ -38,7 +38,7 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
|
||||
</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) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Banknote,
|
||||
CircleDollarSign,
|
||||
LayoutGrid,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -159,8 +160,8 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
|
||||
'세금 신고': 'taxReport',
|
||||
'신규 업체 등록': 'newVendor',
|
||||
'연차': 'annualLeave',
|
||||
'지각': 'lateness',
|
||||
'결근': 'absence',
|
||||
'차량': 'vehicle',
|
||||
'장비': 'equipment',
|
||||
'발주': 'purchase',
|
||||
'결재 요청': 'approvalRequest',
|
||||
};
|
||||
@@ -274,6 +275,20 @@ interface EnhancedMonthlyExpenseSectionProps {
|
||||
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) {
|
||||
// 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환)
|
||||
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">
|
||||
{/* 카드 1: 매입 */}
|
||||
<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-purple-50 border-purple-200 dark:bg-purple-900/30 dark:border-purple-800"
|
||||
onClick={() => onCardClick?.(data.cards[0]?.id || 'me1')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div style={{ backgroundColor: '#8b5cf6' }} className="p-1.5 rounded-lg">
|
||||
<Receipt className="h-4 w-4 text-white" />
|
||||
{EXPENSE_CARD_CONFIGS.map((config, idx) => {
|
||||
const card = data.cards[idx];
|
||||
const CardIcon = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={config.defaultId}
|
||||
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}`}
|
||||
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>
|
||||
<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: 총 예상 지출 합계 (강조) */}
|
||||
<div
|
||||
|
||||
@@ -10,13 +10,7 @@ import {
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { formatCompactAmount } from '@/lib/utils/amount';
|
||||
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||
import {
|
||||
BarChart,
|
||||
@@ -39,24 +33,12 @@ interface PurchaseStatusSectionProps {
|
||||
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) {
|
||||
const [supplierFilter, setSupplierFilter] = useState<string[]>([]);
|
||||
const [sortOrder, setSortOrder] = useState('date-desc');
|
||||
|
||||
const filteredItems = data.dailyItems
|
||||
.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;
|
||||
});
|
||||
.filter((item) => supplierFilter.length === 0 || supplierFilter.includes(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}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={formatCompactAmount} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매입']}
|
||||
/>
|
||||
@@ -189,17 +171,6 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
|
||||
placeholder="전체 공급처"
|
||||
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 className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[500px]">
|
||||
|
||||
@@ -12,14 +12,8 @@ import {
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
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 { formatCompactAmount } from '@/lib/utils/amount';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -37,24 +31,12 @@ interface SalesStatusSectionProps {
|
||||
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) {
|
||||
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
||||
const [sortOrder, setSortOrder] = useState('date-desc');
|
||||
|
||||
const filteredItems = data.dailyItems
|
||||
.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;
|
||||
});
|
||||
.filter((item) => clientFilter.length === 0 || clientFilter.includes(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}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={formatCompactAmount} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']}
|
||||
/>
|
||||
@@ -158,7 +140,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data.clientSales} layout="vertical">
|
||||
<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} />
|
||||
<Tooltip
|
||||
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']}
|
||||
@@ -187,17 +169,6 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
|
||||
placeholder="전체 거래처"
|
||||
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 className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[500px]">
|
||||
|
||||
@@ -13,8 +13,8 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
|
||||
'세금 신고': 'taxReport',
|
||||
'신규 업체 등록': 'newVendor',
|
||||
'연차': 'annualLeave',
|
||||
'지각': 'lateness',
|
||||
'결근': 'absence',
|
||||
'차량': 'vehicle',
|
||||
'장비': 'equipment',
|
||||
'발주': 'purchase',
|
||||
'결재 요청': 'approvalRequest',
|
||||
};
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
import { useState } from 'react';
|
||||
import { PackageX } from 'lucide-react';
|
||||
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 { CollapsibleDashboardCard } from '../components';
|
||||
import type { UnshippedData } from '../types';
|
||||
@@ -20,16 +13,11 @@ interface UnshippedSectionProps {
|
||||
|
||||
export function UnshippedSection({ data }: UnshippedSectionProps) {
|
||||
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
||||
const [sortOrder, setSortOrder] = useState('due-asc');
|
||||
|
||||
const clients = [...new Set(data.items.map((item) => item.orderClient))];
|
||||
|
||||
const filteredItems = data.items
|
||||
.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;
|
||||
});
|
||||
.filter((item) => clientFilter.length === 0 || clientFilter.includes(item.orderClient));
|
||||
|
||||
return (
|
||||
<CollapsibleDashboardCard
|
||||
@@ -55,15 +43,6 @@ export function UnshippedSection({ data }: UnshippedSectionProps) {
|
||||
placeholder="전체 거래처"
|
||||
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 className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[550px]">
|
||||
|
||||
@@ -396,7 +396,7 @@ export const SECTION_LABELS: Record<SectionKey, string> = {
|
||||
dailyReport: '자금현황',
|
||||
statusBoard: '현황판',
|
||||
monthlyExpense: '당월 예상 지출 내역',
|
||||
cardManagement: '카드/가지급금 관리',
|
||||
cardManagement: '가지급금 현황',
|
||||
entertainment: '접대비 현황',
|
||||
welfare: '복리후생비 현황',
|
||||
receivable: '미수금 현황',
|
||||
@@ -422,10 +422,11 @@ export interface TodayIssueSettings {
|
||||
taxReport: boolean; // 세금 신고
|
||||
newVendor: boolean; // 신규 업체 등록
|
||||
annualLeave: boolean; // 연차
|
||||
lateness: boolean; // 지각
|
||||
absence: boolean; // 결근
|
||||
vehicle: boolean; // 차량
|
||||
equipment: boolean; // 장비
|
||||
purchase: boolean; // 발주
|
||||
approvalRequest: boolean; // 결재 요청
|
||||
fundStatus: boolean; // 자금 현황
|
||||
}
|
||||
|
||||
// 접대비 한도 관리 타입
|
||||
@@ -445,6 +446,7 @@ export interface EntertainmentSettings {
|
||||
enabled: boolean;
|
||||
limitType: EntertainmentLimitType;
|
||||
companyType: CompanyType;
|
||||
highAmountThreshold: number; // 고액 결제 기준 금액
|
||||
}
|
||||
|
||||
// 복리후생비 설정
|
||||
@@ -455,6 +457,7 @@ export interface WelfareSettings {
|
||||
fixedAmountPerMonth: number; // 직원당 정해 금액/월
|
||||
ratio: number; // 연봉 총액 X 비율 (%)
|
||||
annualTotal: number; // 연간 복리후생비총액
|
||||
singlePaymentThreshold: number; // 1회 결제 기준 금액
|
||||
}
|
||||
|
||||
// 대시보드 전체 설정
|
||||
@@ -662,10 +665,43 @@ export interface QuarterlyTableConfig {
|
||||
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 {
|
||||
title: string;
|
||||
dateFilter?: DateFilterConfig; // 기간선택기 + 검색
|
||||
periodSelect?: PeriodSelectConfig; // 신고기간 셀렉트 (부가세 등)
|
||||
summaryCards: SummaryCardData[];
|
||||
reviewCards?: ReviewCardsConfig; // 검토 필요 카드 섹션
|
||||
barChart?: BarChartConfig;
|
||||
pieChart?: PieChartConfig;
|
||||
horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용)
|
||||
@@ -691,10 +727,11 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
taxReport: false,
|
||||
newVendor: false,
|
||||
annualLeave: true,
|
||||
lateness: true,
|
||||
absence: false,
|
||||
vehicle: false,
|
||||
equipment: false,
|
||||
purchase: false,
|
||||
approvalRequest: false,
|
||||
fundStatus: true,
|
||||
},
|
||||
},
|
||||
dailyReport: true,
|
||||
@@ -704,6 +741,7 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
enabled: true,
|
||||
limitType: 'annual',
|
||||
companyType: 'medium',
|
||||
highAmountThreshold: 500000,
|
||||
},
|
||||
welfare: {
|
||||
enabled: true,
|
||||
@@ -712,6 +750,7 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
fixedAmountPerMonth: 200000,
|
||||
ratio: 20.5,
|
||||
annualTotal: 20000000,
|
||||
singlePaymentThreshold: 500000,
|
||||
},
|
||||
receivable: true,
|
||||
debtCollection: true,
|
||||
@@ -737,10 +776,11 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
taxReport: false,
|
||||
newVendor: false,
|
||||
annualLeave: true,
|
||||
lateness: true,
|
||||
absence: false,
|
||||
vehicle: false,
|
||||
equipment: false,
|
||||
purchase: false,
|
||||
approvalRequest: false,
|
||||
fundStatus: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -153,7 +153,9 @@ function MenuItemComponent({
|
||||
className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${
|
||||
isFav
|
||||
? 'opacity-100 text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
: isMobile
|
||||
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
@@ -216,7 +218,9 @@ function MenuItemComponent({
|
||||
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
||||
isFav
|
||||
? 'opacity-100 text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
: isMobile
|
||||
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
@@ -281,7 +285,9 @@ function MenuItemComponent({
|
||||
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
|
||||
isFav
|
||||
? 'opacity-100 text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
: isMobile
|
||||
? 'opacity-50 text-muted-foreground active:text-yellow-500'
|
||||
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
156
src/hooks/useDashboardFetch.ts
Normal file
156
src/hooks/useDashboardFetch.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* CEO Dashboard API 호출을 위한 제네릭 훅
|
||||
*
|
||||
* @param endpoint - API 엔드포인트 (예: 'daily-report/summary')
|
||||
* @param transformer - API 응답을 프론트엔드 데이터로 변환하는 함수
|
||||
* @param options - 추가 옵션
|
||||
*
|
||||
* @example
|
||||
* // 자동 fetch (마운트 시 즉시 호출)
|
||||
* const { data, loading, error, refetch } = useDashboardFetch(
|
||||
* 'daily-report/summary',
|
||||
* transformDailyReportResponse,
|
||||
* );
|
||||
*
|
||||
* // 수동 fetch (lazy: true → 마운트 시 호출하지 않음)
|
||||
* const { data, loading, error, refetch } = useDashboardFetch(
|
||||
* 'welfare/detail',
|
||||
* transformWelfareDetailResponse,
|
||||
* { lazy: true },
|
||||
* );
|
||||
* // 필요할 때 수동 호출
|
||||
* await refetch();
|
||||
*/
|
||||
export function useDashboardFetch<TApi, TResult>(
|
||||
endpoint: string | null,
|
||||
transformer: (data: TApi) => TResult,
|
||||
options?: {
|
||||
/** true이면 마운트 시 자동 호출하지 않음 */
|
||||
lazy?: boolean;
|
||||
/** 초기 로딩 상태 (기본: !lazy) */
|
||||
initialLoading?: boolean;
|
||||
},
|
||||
) {
|
||||
const lazy = options?.lazy ?? false;
|
||||
const [data, setData] = useState<TResult | null>(null);
|
||||
const [loading, setLoading] = useState(options?.initialLoading ?? !lazy);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!endpoint) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/proxy/${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 오류: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '데이터 조회 실패');
|
||||
}
|
||||
|
||||
const transformed = transformer(result.data);
|
||||
setData(transformed);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
|
||||
setError(errorMessage);
|
||||
console.error(`Dashboard API Error [${endpoint}]:`, err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, transformer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lazy && endpoint) {
|
||||
fetchData();
|
||||
}
|
||||
}, [lazy, endpoint, fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 API를 병렬 호출하는 제네릭 훅
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error, refetch } = useDashboardMultiFetch(
|
||||
* [
|
||||
* { endpoint: 'card-transactions/summary' },
|
||||
* { endpoint: 'loans/dashboard', fetchFn: fetchLoanDashboard },
|
||||
* ],
|
||||
* ([cardData, loanData]) => transformCardManagementResponse(cardData, loanData),
|
||||
* );
|
||||
*/
|
||||
export function useDashboardMultiFetch<TResult>(
|
||||
sources: Array<{
|
||||
endpoint: string;
|
||||
/** 커스텀 fetch 함수 (기본: fetchApi 패턴) */
|
||||
fetchFn?: () => Promise<unknown>;
|
||||
}>,
|
||||
transformer: (results: unknown[]) => TResult,
|
||||
options?: {
|
||||
lazy?: boolean;
|
||||
initialLoading?: boolean;
|
||||
},
|
||||
) {
|
||||
const lazy = options?.lazy ?? false;
|
||||
const [data, setData] = useState<TResult | null>(null);
|
||||
const [loading, setLoading] = useState(options?.initialLoading ?? !lazy);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// sources를 JSON으로 비교하여 안정적인 의존성 확보
|
||||
const sourcesKey = JSON.stringify(sources.map((s) => s.endpoint));
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const results = await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
if (source.fetchFn) {
|
||||
const result = await source.fetchFn();
|
||||
// fetchFn이 { success, data } 형태를 반환할 수 있음
|
||||
if (result && typeof result === 'object' && 'success' in result) {
|
||||
const r = result as { success: boolean; data: unknown };
|
||||
return r.success ? r.data : null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/proxy/${source.endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 오류: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (!json.success) {
|
||||
throw new Error(json.message || '데이터 조회 실패');
|
||||
}
|
||||
return json.data;
|
||||
}),
|
||||
);
|
||||
|
||||
const transformed = transformer(results);
|
||||
setData(transformed);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '데이터 로딩 실패';
|
||||
setError(errorMessage);
|
||||
console.error('Dashboard MultiFetch Error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sourcesKey, transformer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lazy) {
|
||||
fetchData();
|
||||
}
|
||||
}, [lazy, fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -74,16 +74,6 @@ export function transformPurchaseDetailResponse(api: PurchaseDashboardDetailApiR
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
@@ -165,16 +155,6 @@ export function transformCardDetailResponse(api: CardDashboardDetailApiResponse)
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
@@ -264,16 +244,6 @@ export function transformBillDetailResponse(api: BillDashboardDetailApiResponse)
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
@@ -398,16 +368,6 @@ export function transformExpectedExpenseDetailResponse(
|
||||
options: vendorOptions,
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
|
||||
@@ -161,6 +161,11 @@ export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): D
|
||||
|
||||
return {
|
||||
title: '복리후생비 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
// 1행: 당해년도 기준
|
||||
{ label: '당해년도 복리후생비 계정', value: summary.annual_account, unit: '원' },
|
||||
@@ -224,16 +229,6 @@ export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): D
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
|
||||
@@ -94,6 +94,19 @@ export function formatAmountManwon(amount: number): string {
|
||||
return `${manwon.toLocaleString("ko-KR")}만원`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 축 레이블용 축약 포맷
|
||||
*
|
||||
* - 1억 이상: "1.5억"
|
||||
* - 1만 이상: "5320만"
|
||||
* - 1만 미만: "5,000"
|
||||
*/
|
||||
export function formatCompactAmount(value: number): string {
|
||||
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억`;
|
||||
if (value >= 10000) return `${(value / 10000).toFixed(0)}만`;
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국식 금액 축약 포맷
|
||||
*
|
||||
|
||||
@@ -324,8 +324,7 @@ export const RECEIVING_STATUS_CONFIG = createStatusConfig({
|
||||
export const BAD_DEBT_COLLECTION_STATUS_CONFIG = createStatusConfig({
|
||||
collecting: { label: '추심중', style: 'border-orange-300 text-orange-600 bg-orange-50' },
|
||||
legalAction: { label: '법적조치', style: 'border-red-300 text-red-600 bg-red-50' },
|
||||
recovered: { label: '회수완료', style: 'border-green-300 text-green-600 bg-green-50' },
|
||||
badDebt: { label: '대손처리', style: 'border-gray-300 text-gray-600 bg-gray-50' },
|
||||
collectionEnd: { label: '추심종료', style: 'border-green-300 text-green-600 bg-green-50' },
|
||||
}, { includeAll: true });
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user