diff --git a/src/app/[locale]/(protected)/accounting/income-statement/page.tsx b/src/app/[locale]/(protected)/accounting/income-statement/page.tsx
new file mode 100644
index 00000000..d5739e66
--- /dev/null
+++ b/src/app/[locale]/(protected)/accounting/income-statement/page.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import { IncomeStatement } from '@/components/accounting/IncomeStatement';
+
+export default function IncomeStatementPage() {
+ return ;
+}
diff --git a/src/components/accounting/IncomeStatement/MonthlyView.tsx b/src/components/accounting/IncomeStatement/MonthlyView.tsx
new file mode 100644
index 00000000..2cd84a33
--- /dev/null
+++ b/src/components/accounting/IncomeStatement/MonthlyView.tsx
@@ -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
해당 월 데이터가 없습니다.
;
+ }
+ return (
+
+ );
+ }
+
+ // 전체 월 비교 보기 (가로 스크롤)
+ return (
+
+ );
+}
+
+// 개별 월 보기 — PeriodView와 동일한 2열(금액+소계) 구조
+function SingleMonthView({
+ sections,
+ monthLabel,
+ fiscalLabel,
+ unitLabel,
+}: {
+ sections: IncomeStatementSection[];
+ monthLabel: string;
+ fiscalLabel: string;
+ unitLabel: string;
+}) {
+ return (
+
+
+
+
+
+
+ 과 목
+
+
+ {fiscalLabel} {monthLabel}
+
+
+
+
+ 금 액
+
+
+
+
+ {sections.map((section) => {
+ const isHighlight = HIGHLIGHT_CODES.includes(section.code);
+ const highlightClass = isHighlight ? 'bg-green-50' : '';
+
+ // 계산 항목 또는 세부과목 없는 항목
+ if (section.items.length === 0) {
+ return (
+
+
+ {section.code}. {section.name}
+
+
+
+ {section.currentAmount !== 0 ? formatNumber(section.currentAmount) : ''}
+
+
+ );
+ }
+
+ // 세부과목 있는 항목
+ const lastIdx = section.items.length - 1;
+ return (
+
+ );
+ })}
+
+
+
+
+ (단위: {unitLabel})
+
+
+ );
+}
+
+function SingleMonthSectionRows({
+ section,
+ lastIdx,
+}: {
+ section: IncomeStatementSection;
+ lastIdx: number;
+}) {
+ return (
+ <>
+
+
+ {section.code}. {section.name}
+
+
+
+
+ {section.items.map((item, idx) => {
+ const isLast = idx === lastIdx;
+ return (
+
+
+ {item.name}
+
+
+ {item.current !== 0 ? formatNumber(item.current) : ''}
+
+
+ {isLast ? formatNumber(section.currentAmount) : ''}
+
+
+ );
+ })}
+ >
+ );
+}
+
+// 전체 월 비교 보기 (가로 스크롤, 과목 열 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();
+ const itemMap = new Map();
+ 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 (
+
+
+
+
+
+
+
+ 과목
+
+ {months.map((m) => (
+
+ {m.label}
+
+ ))}
+
+
+
+ {rows.map((row) => {
+ const isSection = row.type === 'section';
+ const highlightClass = row.isHighlight ? 'bg-green-50' : '';
+ const sectionBg = isSection && !row.isHighlight ? 'bg-muted/30' : '';
+
+ return (
+
+
+ {row.name}
+
+ {months.map((m, mi) => {
+ const amount = isSection
+ ? monthMaps[mi].sectionMap.get(row.sectionCode) ?? 0
+ : monthMaps[mi].itemMap.get(`${row.sectionCode}-${row.code}`) ?? 0;
+ return (
+
+ {formatNumber(amount)}
+
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+
+ (단위: {unitLabel})
+
+
+ );
+}
diff --git a/src/components/accounting/IncomeStatement/PeriodView.tsx b/src/components/accounting/IncomeStatement/PeriodView.tsx
new file mode 100644
index 00000000..20cc7b49
--- /dev/null
+++ b/src/components/accounting/IncomeStatement/PeriodView.tsx
@@ -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 (
+
+
+
+
+
+ {/* 1행: 과목 + 기수 헤더 (녹색 배경) */}
+
+
+ 과 목
+
+
+ {period.current.label}
+
+ {showPrevious && (
+
+ {period.previous.label}
+
+ )}
+
+ {/* 2행: 금 액 서브헤더 */}
+
+
+ 금 액
+
+ {showPrevious && (
+
+ 금 액
+
+ )}
+
+
+
+ {sections.map((section) => {
+ const isHighlight = HIGHLIGHT_CODES.includes(section.code);
+ const hasItems = section.items.length > 0;
+
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* 하단 단위 표기 */}
+
+ (단위: {unitLabel})
+
+
+ );
+}
+
+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 (
+
+
+ {code}. {name}
+
+
+
+ {formatNumber(currentAmount)}
+
+ {showPrevious && (
+ <>
+
+
+ {formatNumber(previousAmount)}
+
+ >
+ )}
+
+ );
+ }
+
+ // 세부과목 없는 합계 항목 (VIII 등) — 소계열에만 금액
+ if (!hasItems) {
+ return (
+
+
+ {code}. {name}
+
+
+
+ {currentAmount !== 0 ? formatNumber(currentAmount) : ''}
+
+ {showPrevious && (
+ <>
+
+
+ {previousAmount !== 0 ? formatNumber(previousAmount) : ''}
+
+ >
+ )}
+
+ );
+ }
+
+ // 세부 과목이 있는 항목 (I, II, IV, VI, VII, IX)
+ const lastIdx = items.length - 1;
+
+ return (
+ <>
+ {/* 섹션 헤더 — 금액 표시 안 함 */}
+
+
+ {code}. {name}
+
+
+
+ {showPrevious && (
+ <>
+
+
+ >
+ )}
+
+ {/* 세부 과목 행 — 들여쓰기 */}
+ {items.map((item, idx) => {
+ const isLast = idx === lastIdx;
+ return (
+
+
+ {item.name}
+
+ {/* 금액 열 */}
+
+ {item.current !== 0 ? formatNumber(item.current) : ''}
+
+ {/* 소계 열 — 마지막 세부항목에만 섹션 합계 표시 */}
+
+ {isLast ? formatNumber(currentAmount) : ''}
+
+ {showPrevious && (
+ <>
+
+ {item.previous !== 0 ? formatNumber(item.previous) : ''}
+
+
+ {isLast ? formatNumber(previousAmount) : ''}
+
+ >
+ )}
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/components/accounting/IncomeStatement/actions.ts b/src/components/accounting/IncomeStatement/actions.ts
new file mode 100644
index 00000000..ad51bd18
--- /dev/null
+++ b/src/components/accounting/IncomeStatement/actions.ts
@@ -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> {
+ 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> {
+ 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: '월별 손익계산서 조회에 실패했습니다.',
+ });
+}
diff --git a/src/components/accounting/IncomeStatement/index.tsx b/src/components/accounting/IncomeStatement/index.tsx
new file mode 100644
index 00000000..8b214ac8
--- /dev/null
+++ b/src/components/accounting/IncomeStatement/index.tsx
@@ -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 = {
+ won: '원',
+ thousand: '천원',
+ million: '백만원',
+};
+
+export function IncomeStatement() {
+ // 보기 모드
+ const [viewMode, setViewMode] = useState('period');
+ const [unit, setUnit] = useState('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(null);
+
+ // 월별 보기 상태
+ const [year, setYear] = useState(() => new Date().getFullYear());
+ const [selectedMonth, setSelectedMonth] = useState(null); // null = 전체
+ const [monthlyData, setMonthlyData] = useState(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(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 (
+
+
+
+ {/* 필터 영역 */}
+
+
+
+ {/* 1행: 보기 모드 + 단위 | 인쇄 */}
+
+
+ setViewMode(v as ViewMode)}
+ >
+
+
+ 기간 보기
+
+
+ 월별 보기
+
+
+
+
+
+
+
+
+ {/* 2행: 모드별 필터 */}
+ {viewMode === 'period' ? (
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ {Array.from({ length: 12 }, (_, i) => {
+ const m = String(i + 1).padStart(2, '0');
+ const isAvailable = availableMonths.includes(m);
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+ {/* 데이터 영역 */}
+
+ {isLoading ? (
+
+
+
+
+ 데이터를 불러오는 중...
+
+
+
+ ) : viewMode === 'period' && periodData ? (
+
+
+
+
+
+ ) : viewMode === 'monthly' && monthlyData ? (
+
+
+
+
+
+ ) : !isLoading ? (
+
+
+ 데이터가 없습니다.
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/accounting/IncomeStatement/types.ts b/src/components/accounting/IncomeStatement/types.ts
new file mode 100644
index 00000000..b24f9e89
--- /dev/null
+++ b/src/components/accounting/IncomeStatement/types.ts
@@ -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'];