feat: [accounting] 손익계산서 페이지 신규 추가

This commit is contained in:
유병철
2026-03-20 09:51:18 +09:00
parent 41602a3c1e
commit 793c736f69
6 changed files with 993 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
'use client';
import { IncomeStatement } from '@/components/accounting/IncomeStatement';
export default function IncomeStatementPage() {
return <IncomeStatement />;
}

View File

@@ -0,0 +1,287 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/utils/amount';
import type { IncomeStatementMonthlyData, IncomeStatementSection } from './types';
import { HIGHLIGHT_CODES } from './types';
interface MonthlyViewProps {
data: IncomeStatementMonthlyData;
selectedMonth: string | null; // null = 전체
unitLabel: string;
}
export function MonthlyView({ data, selectedMonth, unitLabel }: MonthlyViewProps) {
const { months, fiscalLabel } = data;
if (selectedMonth) {
// 개별 월 보기
const monthData = months.find((m) => m.month === selectedMonth);
if (!monthData) {
return <div className="text-center py-8 text-muted-foreground text-sm"> .</div>;
}
return (
<SingleMonthView
sections={monthData.sections}
monthLabel={monthData.label}
fiscalLabel={fiscalLabel}
unitLabel={unitLabel}
/>
);
}
// 전체 월 비교 보기 (가로 스크롤)
return (
<AllMonthsView
months={months}
fiscalLabel={fiscalLabel}
unitLabel={unitLabel}
/>
);
}
// 개별 월 보기 — PeriodView와 동일한 2열(금액+소계) 구조
function SingleMonthView({
sections,
monthLabel,
fiscalLabel,
unitLabel,
}: {
sections: IncomeStatementSection[];
monthLabel: string;
fiscalLabel: string;
unitLabel: string;
}) {
return (
<div className="space-y-1">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-green-700">
<TableHead
rowSpan={2}
className="font-semibold text-xs md:text-sm text-white w-[40%] border-r border-green-600"
>
</TableHead>
<TableHead
colSpan={2}
className="font-semibold text-center text-xs md:text-sm text-white border-b border-green-600"
>
{fiscalLabel} {monthLabel}
</TableHead>
</TableRow>
<TableRow className="bg-green-700">
<TableHead
colSpan={2}
className="font-semibold text-center text-xs md:text-sm text-white"
>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sections.map((section) => {
const isHighlight = HIGHLIGHT_CODES.includes(section.code);
const highlightClass = isHighlight ? 'bg-green-50' : '';
// 계산 항목 또는 세부과목 없는 항목
if (section.items.length === 0) {
return (
<TableRow key={section.code} className={highlightClass}>
<TableCell className="text-xs md:text-sm font-semibold">
{section.code}. {section.name}
</TableCell>
<TableCell />
<TableCell className={`text-right text-xs md:text-sm whitespace-nowrap ${isHighlight ? 'font-bold' : 'font-semibold'}`}>
{section.currentAmount !== 0 ? formatNumber(section.currentAmount) : ''}
</TableCell>
</TableRow>
);
}
// 세부과목 있는 항목
const lastIdx = section.items.length - 1;
return (
<SingleMonthSectionRows
key={section.code}
section={section}
lastIdx={lastIdx}
/>
);
})}
</TableBody>
</Table>
</div>
<div className="text-xs text-muted-foreground text-right px-1">
(: {unitLabel})
</div>
</div>
);
}
function SingleMonthSectionRows({
section,
lastIdx,
}: {
section: IncomeStatementSection;
lastIdx: number;
}) {
return (
<>
<TableRow>
<TableCell className="text-xs md:text-sm font-semibold">
{section.code}. {section.name}
</TableCell>
<TableCell />
<TableCell />
</TableRow>
{section.items.map((item, idx) => {
const isLast = idx === lastIdx;
return (
<TableRow key={item.code}>
<TableCell className="text-xs md:text-sm pl-8 text-muted-foreground">
{item.name}
</TableCell>
<TableCell className="text-right text-xs md:text-sm whitespace-nowrap">
{item.current !== 0 ? formatNumber(item.current) : ''}
</TableCell>
<TableCell className="text-right text-xs md:text-sm whitespace-nowrap font-semibold">
{isLast ? formatNumber(section.currentAmount) : ''}
</TableCell>
</TableRow>
);
})}
</>
);
}
// 전체 월 비교 보기 (가로 스크롤, 과목 열 sticky)
function AllMonthsView({
months,
fiscalLabel,
unitLabel,
}: {
months: IncomeStatementMonthlyData['months'];
fiscalLabel: string;
unitLabel: string;
}) {
const baseSections = months[0]?.sections || [];
const rows: {
type: 'section' | 'item';
code: string;
name: string;
sectionCode: string;
isHighlight: boolean;
}[] = [];
baseSections.forEach((section) => {
const isHighlight = HIGHLIGHT_CODES.includes(section.code);
rows.push({
type: 'section',
code: section.code,
name: `${section.code}. ${section.name}`,
sectionCode: section.code,
isHighlight,
});
section.items.forEach((item) => {
rows.push({
type: 'item',
code: item.code,
name: item.name,
sectionCode: section.code,
isHighlight: false,
});
});
});
const monthMaps = months.map((m) => {
const sectionMap = new Map<string, number>();
const itemMap = new Map<string, number>();
m.sections.forEach((s) => {
sectionMap.set(s.code, s.currentAmount);
s.items.forEach((it) => {
itemMap.set(`${s.code}-${it.code}`, it.current);
});
});
return { sectionMap, itemMap };
});
return (
<div className="space-y-1">
<div className="rounded-md border overflow-x-auto">
<div style={{ minWidth: `${200 + months.length * 120}px` }}>
<Table>
<TableHeader>
<TableRow>
<TableHead
className="font-semibold text-xs md:text-sm sticky left-0 z-20 bg-background min-w-[200px]"
style={{ boxShadow: '2px 0 4px -2px rgba(0,0,0,0.1)' }}
>
</TableHead>
{months.map((m) => (
<TableHead
key={m.month}
className="font-semibold text-right text-xs md:text-sm min-w-[110px] whitespace-nowrap"
>
{m.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const isSection = row.type === 'section';
const highlightClass = row.isHighlight ? 'bg-green-50' : '';
const sectionBg = isSection && !row.isHighlight ? 'bg-muted/30' : '';
return (
<TableRow
key={`${row.sectionCode}-${row.code}`}
className={`${highlightClass} ${sectionBg}`.trim() || undefined}
>
<TableCell
className={`text-xs md:text-sm sticky left-0 z-10 bg-inherit ${
isSection ? 'font-semibold' : 'pl-8 text-muted-foreground'
}`}
style={{ boxShadow: '2px 0 4px -2px rgba(0,0,0,0.1)' }}
>
{row.name}
</TableCell>
{months.map((m, mi) => {
const amount = isSection
? monthMaps[mi].sectionMap.get(row.sectionCode) ?? 0
: monthMaps[mi].itemMap.get(`${row.sectionCode}-${row.code}`) ?? 0;
return (
<TableCell
key={m.month}
className={`text-right text-xs md:text-sm whitespace-nowrap ${
isSection ? 'font-semibold' : ''
}`}
>
{formatNumber(amount)}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
<div className="text-xs text-muted-foreground text-right px-1">
(: {unitLabel})
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/utils/amount';
import type { IncomeStatementData } from './types';
import { HIGHLIGHT_CODES } from './types';
interface PeriodViewProps {
data: IncomeStatementData;
showPrevious: boolean;
unitLabel: string;
}
export function PeriodView({ data, showPrevious, unitLabel }: PeriodViewProps) {
const { period, sections } = data;
const colSpan = showPrevious ? 5 : 3; // 과목 + (금액+소계) * 기수
return (
<div className="space-y-1">
<div className="rounded-md border overflow-x-auto">
<div className="min-w-[400px]">
<Table>
<TableHeader>
{/* 1행: 과목 + 기수 헤더 (녹색 배경) */}
<TableRow className="bg-green-700">
<TableHead
rowSpan={2}
className="font-semibold text-xs md:text-sm text-white w-[40%] border-r border-green-600"
>
</TableHead>
<TableHead
colSpan={2}
className="font-semibold text-center text-xs md:text-sm text-white border-b border-green-600"
>
{period.current.label}
</TableHead>
{showPrevious && (
<TableHead
colSpan={2}
className="font-semibold text-center text-xs md:text-sm text-white border-b border-green-600 border-l border-green-600"
>
{period.previous.label}
</TableHead>
)}
</TableRow>
{/* 2행: 금 액 서브헤더 */}
<TableRow className="bg-green-700">
<TableHead
colSpan={2}
className="font-semibold text-center text-xs md:text-sm text-white"
>
</TableHead>
{showPrevious && (
<TableHead
colSpan={2}
className="font-semibold text-center text-xs md:text-sm text-white border-l border-green-600"
>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{sections.map((section) => {
const isHighlight = HIGHLIGHT_CODES.includes(section.code);
const hasItems = section.items.length > 0;
return (
<SectionRows
key={section.code}
code={section.code}
name={section.name}
currentAmount={section.currentAmount}
previousAmount={section.previousAmount}
items={section.items}
isHighlight={isHighlight}
hasItems={hasItems}
showPrevious={showPrevious}
/>
);
})}
</TableBody>
</Table>
</div>
</div>
{/* 하단 단위 표기 */}
<div className="text-xs text-muted-foreground text-right px-1">
(: {unitLabel})
</div>
</div>
);
}
interface SectionRowsProps {
code: string;
name: string;
currentAmount: number;
previousAmount: number;
items: { code: string; name: string; current: number; previous: number }[];
isHighlight: boolean;
hasItems: boolean;
showPrevious: boolean;
}
function SectionRows({
code,
name,
currentAmount,
previousAmount,
items,
isHighlight,
hasItems,
showPrevious,
}: SectionRowsProps) {
const highlightClass = isHighlight ? 'bg-green-50' : '';
// 계산 항목 (III, V, VIII, X 등) — 소계열에만 금액 표시
if (!hasItems && isHighlight) {
return (
<TableRow className={highlightClass}>
<TableCell className="text-xs md:text-sm font-semibold">
{code}. {name}
</TableCell>
<TableCell />
<TableCell className="text-right text-xs md:text-sm font-bold whitespace-nowrap">
{formatNumber(currentAmount)}
</TableCell>
{showPrevious && (
<>
<TableCell />
<TableCell className="text-right text-xs md:text-sm font-bold whitespace-nowrap">
{formatNumber(previousAmount)}
</TableCell>
</>
)}
</TableRow>
);
}
// 세부과목 없는 합계 항목 (VIII 등) — 소계열에만 금액
if (!hasItems) {
return (
<TableRow className={highlightClass}>
<TableCell className="text-xs md:text-sm font-semibold">
{code}. {name}
</TableCell>
<TableCell />
<TableCell className="text-right text-xs md:text-sm font-semibold whitespace-nowrap">
{currentAmount !== 0 ? formatNumber(currentAmount) : ''}
</TableCell>
{showPrevious && (
<>
<TableCell />
<TableCell className="text-right text-xs md:text-sm font-semibold whitespace-nowrap">
{previousAmount !== 0 ? formatNumber(previousAmount) : ''}
</TableCell>
</>
)}
</TableRow>
);
}
// 세부 과목이 있는 항목 (I, II, IV, VI, VII, IX)
const lastIdx = items.length - 1;
return (
<>
{/* 섹션 헤더 — 금액 표시 안 함 */}
<TableRow>
<TableCell className="text-xs md:text-sm font-semibold">
{code}. {name}
</TableCell>
<TableCell />
<TableCell />
{showPrevious && (
<>
<TableCell />
<TableCell />
</>
)}
</TableRow>
{/* 세부 과목 행 — 들여쓰기 */}
{items.map((item, idx) => {
const isLast = idx === lastIdx;
return (
<TableRow key={item.code}>
<TableCell className="text-xs md:text-sm pl-8 text-muted-foreground">
{item.name}
</TableCell>
{/* 금액 열 */}
<TableCell className="text-right text-xs md:text-sm whitespace-nowrap">
{item.current !== 0 ? formatNumber(item.current) : ''}
</TableCell>
{/* 소계 열 — 마지막 세부항목에만 섹션 합계 표시 */}
<TableCell className="text-right text-xs md:text-sm whitespace-nowrap font-semibold">
{isLast ? formatNumber(currentAmount) : ''}
</TableCell>
{showPrevious && (
<>
<TableCell className="text-right text-xs md:text-sm whitespace-nowrap">
{item.previous !== 0 ? formatNumber(item.previous) : ''}
</TableCell>
<TableCell className="text-right text-xs md:text-sm whitespace-nowrap font-semibold">
{isLast ? formatNumber(previousAmount) : ''}
</TableCell>
</>
)}
</TableRow>
);
})}
</>
);
}

View File

@@ -0,0 +1,71 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type {
IncomeStatementResponseApi,
IncomeStatementMonthlyResponseApi,
IncomeStatementSectionApi,
IncomeStatementData,
IncomeStatementMonthlyData,
IncomeStatementSection,
UnitType,
} from './types';
function transformSection(s: IncomeStatementSectionApi): IncomeStatementSection {
return {
code: s.code,
name: s.name,
currentAmount: s.current_amount,
previousAmount: s.previous_amount,
items: s.items || [],
isCalculated: s.is_calculated,
};
}
export async function getIncomeStatement(params: {
startDate: string;
endDate: string;
unit?: UnitType;
}): Promise<ActionResult<IncomeStatementData>> {
return executeServerAction({
url: buildApiUrl('/api/v1/income-statement', {
start_date: params.startDate,
end_date: params.endDate,
unit: params.unit,
}),
transform: (data: IncomeStatementResponseApi): IncomeStatementData => ({
period: {
current: data.period.current,
previous: data.period.previous,
},
unit: data.unit,
sections: data.sections.map(transformSection),
}),
errorMessage: '손익계산서 조회에 실패했습니다.',
});
}
export async function getMonthlyIncomeStatement(params: {
year: number;
unit?: UnitType;
}): Promise<ActionResult<IncomeStatementMonthlyData>> {
return executeServerAction({
url: buildApiUrl('/api/v1/income-statement/monthly', {
year: params.year,
unit: params.unit,
}),
transform: (data: IncomeStatementMonthlyResponseApi): IncomeStatementMonthlyData => ({
year: data.year,
fiscalYear: data.fiscal_year,
fiscalLabel: data.fiscal_label,
unit: data.unit,
months: data.months.map((m) => ({
month: m.month,
label: m.label,
sections: m.sections.map(transformSection),
})),
}),
errorMessage: '월별 손익계산서 조회에 실패했습니다.',
});
}

View File

@@ -0,0 +1,309 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { format, startOfYear, endOfYear } from 'date-fns';
import { FileText, Loader2, Printer } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { printElement } from '@/lib/print-utils';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { PeriodView } from './PeriodView';
import { MonthlyView } from './MonthlyView';
import { getIncomeStatement, getMonthlyIncomeStatement } from './actions';
import type {
ViewMode,
UnitType,
IncomeStatementData,
IncomeStatementMonthlyData,
} from './types';
const UNIT_OPTIONS: { value: UnitType; label: string }[] = [
{ value: 'won', label: '원' },
{ value: 'thousand', label: '천원' },
{ value: 'million', label: '백만원' },
];
const UNIT_LABEL_MAP: Record<UnitType, string> = {
won: '원',
thousand: '천원',
million: '백만원',
};
export function IncomeStatement() {
// 보기 모드
const [viewMode, setViewMode] = useState<ViewMode>('period');
const [unit, setUnit] = useState<UnitType>('won');
// 기간 보기 상태
const [startDate, setStartDate] = useState(() => format(startOfYear(new Date()), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
const [showPrevious, setShowPrevious] = useState(true);
const [periodData, setPeriodData] = useState<IncomeStatementData | null>(null);
// 월별 보기 상태
const [year, setYear] = useState(() => new Date().getFullYear());
const [selectedMonth, setSelectedMonth] = useState<string | null>(null); // null = 전체
const [monthlyData, setMonthlyData] = useState<IncomeStatementMonthlyData | null>(null);
// 로딩
const [isLoading, setIsLoading] = useState(false);
// 연도 옵션 (2025 ~ 현재 연도)
const yearOptions = Array.from(
{ length: new Date().getFullYear() - 2024 },
(_, i) => 2025 + i
);
// 기간 보기 데이터 로드
const loadPeriodData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getIncomeStatement({ startDate, endDate, unit });
if (result.success && result.data) {
setPeriodData(result.data);
} else {
toast.error(result.error || '손익계산서 조회에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('손익계산서 조회 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate, unit]);
// 월별 보기 데이터 로드
const loadMonthlyData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getMonthlyIncomeStatement({ year, unit });
if (result.success && result.data) {
setMonthlyData(result.data);
} else {
toast.error(result.error || '월별 손익계산서 조회에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('월별 손익계산서 조회 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [year, unit]);
// 데이터 로드 트리거
useEffect(() => {
if (viewMode === 'period') {
loadPeriodData();
} else {
loadMonthlyData();
}
}, [viewMode, loadPeriodData, loadMonthlyData]);
// 인쇄
const printAreaRef = useRef<HTMLDivElement>(null);
const handlePrint = useCallback(() => {
if (printAreaRef.current) {
printElement(printAreaRef.current, {
title: viewMode === 'period'
? `손익계산서_${startDate}_${endDate}`
: `손익계산서_${year}년_월별`,
styles: `
.print-container { font-size: 11px; }
table { width: 100%; }
.bg-green-50 { background-color: #f0fdf4 !important; -webkit-print-color-adjust: exact; }
.bg-muted\\/30 { background-color: #f5f5f5 !important; -webkit-print-color-adjust: exact; }
`,
});
}
}, [viewMode, startDate, endDate, year]);
// 월 선택 버튼 (월별 보기용)
const availableMonths = monthlyData?.months.map((m) => m.month) || [];
return (
<PageLayout>
<PageHeader
title="손익계산서"
description="기간별/월별 손익계산서를 조회합니다."
icon={FileText}
/>
{/* 필터 영역 */}
<Card>
<CardContent className="p-3 md:p-4">
<div className="flex flex-col gap-3">
{/* 1행: 보기 모드 + 단위 | 인쇄 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Tabs
value={viewMode}
onValueChange={(v) => setViewMode(v as ViewMode)}
>
<TabsList className="h-8">
<TabsTrigger value="period" className="text-xs px-3 h-7">
</TabsTrigger>
<TabsTrigger value="monthly" className="text-xs px-3 h-7">
</TabsTrigger>
</TabsList>
</Tabs>
<Select value={unit} onValueChange={(v) => setUnit(v as UnitType)}>
<SelectTrigger className="h-8 w-[80px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{UNIT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
onClick={handlePrint}
className="h-8 px-3 text-xs shrink-0"
>
<Printer className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* 2행: 모드별 필터 */}
{viewMode === 'period' ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
size="sm"
className="w-full sm:w-auto sm:min-w-[280px]"
/>
<div className="flex items-center gap-1.5">
<Button
variant={showPrevious ? 'default' : 'outline'}
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => setShowPrevious(true)}
>
+
</Button>
<Button
variant={!showPrevious ? 'default' : 'outline'}
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => setShowPrevious(false)}
>
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Select
value={String(year)}
onValueChange={(v) => setYear(Number(v))}
>
<SelectTrigger className="h-8 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((y) => (
<SelectItem key={y} value={String(y)} className="text-xs">
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 -mb-1">
<Button
variant={selectedMonth === null ? 'default' : 'outline'}
size="sm"
className="h-7 px-2.5 text-xs shrink-0"
onClick={() => setSelectedMonth(null)}
>
</Button>
{Array.from({ length: 12 }, (_, i) => {
const m = String(i + 1).padStart(2, '0');
const isAvailable = availableMonths.includes(m);
return (
<Button
key={m}
variant={selectedMonth === m ? 'default' : 'outline'}
size="sm"
className="h-7 px-2 text-xs shrink-0"
onClick={() => setSelectedMonth(m)}
disabled={!isAvailable}
>
{i + 1}
</Button>
);
})}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* 데이터 영역 */}
<div ref={printAreaRef}>
{isLoading ? (
<Card>
<CardContent className="py-16">
<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 text-sm"> ...</span>
</div>
</CardContent>
</Card>
) : viewMode === 'period' && periodData ? (
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-4">
<PeriodView
data={periodData}
showPrevious={showPrevious}
unitLabel={UNIT_LABEL_MAP[unit]}
/>
</CardContent>
</Card>
) : viewMode === 'monthly' && monthlyData ? (
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-4">
<MonthlyView
data={monthlyData}
selectedMonth={selectedMonth}
unitLabel={UNIT_LABEL_MAP[unit]}
/>
</CardContent>
</Card>
) : !isLoading ? (
<Card>
<CardContent className="py-16 text-center text-muted-foreground text-sm">
.
</CardContent>
</Card>
) : null}
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,97 @@
// 손익계산서 API 응답 타입 (snake_case)
export interface IncomeStatementItemApi {
code: string;
name: string;
current: number;
previous: number;
}
export interface IncomeStatementSectionApi {
code: string;
name: string;
current_amount: number;
previous_amount: number;
items: IncomeStatementItemApi[];
is_calculated: boolean;
}
export interface IncomeStatementPeriodApi {
start: string;
end: string;
label: string;
}
export interface IncomeStatementResponseApi {
period: {
current: IncomeStatementPeriodApi;
previous: IncomeStatementPeriodApi;
};
unit: string;
sections: IncomeStatementSectionApi[];
}
export interface IncomeStatementMonthApi {
month: string;
label: string;
sections: IncomeStatementSectionApi[];
}
export interface IncomeStatementMonthlyResponseApi {
year: number;
fiscal_year: number;
fiscal_label: string;
unit: string;
months: IncomeStatementMonthApi[];
}
// 프론트엔드 타입 (camelCase)
export interface IncomeStatementItem {
code: string;
name: string;
current: number;
previous: number;
}
export interface IncomeStatementSection {
code: string;
name: string;
currentAmount: number;
previousAmount: number;
items: IncomeStatementItem[];
isCalculated: boolean;
}
export interface IncomeStatementPeriod {
start: string;
end: string;
label: string;
}
export interface IncomeStatementData {
period: {
current: IncomeStatementPeriod;
previous: IncomeStatementPeriod;
};
unit: string;
sections: IncomeStatementSection[];
}
export interface IncomeStatementMonth {
month: string;
label: string;
sections: IncomeStatementSection[];
}
export interface IncomeStatementMonthlyData {
year: number;
fiscalYear: number;
fiscalLabel: string;
unit: string;
months: IncomeStatementMonth[];
}
export type UnitType = 'won' | 'thousand' | 'million';
export type ViewMode = 'period' | 'monthly';
// 강조 표시 대상 코드 (매출총이익, 영업이익, 당기순이익)
export const HIGHLIGHT_CODES = ['III', 'V', 'X'];