From 793c736f698bf006c946e767ed659e362cdb1e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 20 Mar 2026 09:51:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[accounting]=20=EC=86=90=EC=9D=B5?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../accounting/income-statement/page.tsx | 7 + .../IncomeStatement/MonthlyView.tsx | 287 ++++++++++++++++ .../accounting/IncomeStatement/PeriodView.tsx | 222 +++++++++++++ .../accounting/IncomeStatement/actions.ts | 71 ++++ .../accounting/IncomeStatement/index.tsx | 309 ++++++++++++++++++ .../accounting/IncomeStatement/types.ts | 97 ++++++ 6 files changed, 993 insertions(+) create mode 100644 src/app/[locale]/(protected)/accounting/income-statement/page.tsx create mode 100644 src/components/accounting/IncomeStatement/MonthlyView.tsx create mode 100644 src/components/accounting/IncomeStatement/PeriodView.tsx create mode 100644 src/components/accounting/IncomeStatement/actions.ts create mode 100644 src/components/accounting/IncomeStatement/index.tsx create mode 100644 src/components/accounting/IncomeStatement/types.ts 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'];