feat: [accounting] 손익계산서 페이지 신규 추가
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { IncomeStatement } from '@/components/accounting/IncomeStatement';
|
||||
|
||||
export default function IncomeStatementPage() {
|
||||
return <IncomeStatement />;
|
||||
}
|
||||
287
src/components/accounting/IncomeStatement/MonthlyView.tsx
Normal file
287
src/components/accounting/IncomeStatement/MonthlyView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
src/components/accounting/IncomeStatement/PeriodView.tsx
Normal file
222
src/components/accounting/IncomeStatement/PeriodView.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
src/components/accounting/IncomeStatement/actions.ts
Normal file
71
src/components/accounting/IncomeStatement/actions.ts
Normal 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: '월별 손익계산서 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
309
src/components/accounting/IncomeStatement/index.tsx
Normal file
309
src/components/accounting/IncomeStatement/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/accounting/IncomeStatement/types.ts
Normal file
97
src/components/accounting/IncomeStatement/types.ts
Normal 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'];
|
||||
Reference in New Issue
Block a user