Files
sam-manage/resources/views/finance/income-statement.blade.php
김보곤 2bf05e350e feat: [finance] 손익계산서 월별 전체보기에 세부내역 표시
- 기존: 전체 월 보기 시 I.매출액, IV.판매비 등 합계만 표시
- 변경: 기간 보기와 동일하게 하위 계정과목(용역매출, 직원급여 등) 세부내역 표시
2026-03-19 20:54:54 +09:00

438 lines
23 KiB
PHP

@extends('layouts.app')
@section('title', '손익계산서')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="income-statement-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script src="https://unpkg.com/lucide@0.469.0?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useEffect, useCallback } = React;
const createIcon = (name) => {
const _def=((n)=>{const a={'check-circle':'CircleCheck','alert-circle':'CircleAlert'};if(a[n]&&lucide[a[n]])return lucide[a[n]];const p=n.split('-').map(w=>w.charAt(0).toUpperCase()+w.slice(1)).join('');return lucide[p]||(lucide.icons&&lucide.icons[n])||null;})(name);
const _c = s => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
return ({ className = "w-5 h-5", ...props }) => {
if (!_def) return null;
const [, attrs, children = []] = _def;
const sp = { className };
Object.entries(attrs).forEach(([k, v]) => { sp[_c(k)] = v; });
Object.assign(sp, props);
return React.createElement("svg", sp, ...children.map(([tag, ca], i) => {
const cp = { key: i };
if (ca) Object.entries(ca).forEach(([k, v]) => { cp[_c(k)] = v; });
return React.createElement(tag, cp);
}));
};
};
const Search = createIcon('search');
const BarChart = createIcon('bar-chart-3');
const Printer = createIcon('printer');
const Calendar = createIcon('calendar');
const fmt = (n) => {
if (n === 0 || n === null || n === undefined) return '';
if (n < 0) return '(' + Math.abs(n).toLocaleString() + ')';
return n.toLocaleString();
};
const UNIT_LABELS = { won: '원', thousand: '천원', million: '백만원' };
// ============================================================
// 손익계산서 테이블 (기간 보기)
// ============================================================
function PeriodTable({ data, showPrev, cellBorder }) {
const sectionCodes = ['III', 'V', 'VIII', 'X'];
return (
<table className="w-full text-sm">
<thead>
<tr className="bg-emerald-600 text-white">
<th className="px-4 py-2 text-left" style={{width: showPrev ? '40%' : '55%'}}> </th>
<th className="px-4 py-2 text-center" colSpan="2">
{data.period.current.label}
</th>
{showPrev && (
<th className="px-4 py-2 text-center" colSpan="2">
{data.period.previous.label}
</th>
)}
</tr>
<tr className="bg-emerald-500 text-white text-xs">
<th className="px-4 py-1"></th>
<th className="px-4 py-1 text-right"> </th>
<th className="px-4 py-1 text-right"></th>
{showPrev && <th className="px-4 py-1 text-right"> </th>}
{showPrev && <th className="px-4 py-1 text-right"></th>}
</tr>
</thead>
<tbody>
{data.sections.map((section) => {
const isCalc = section.is_calculated;
const isHighlight = sectionCodes.includes(section.code);
const rowClass = isCalc ? (isHighlight ? 'bg-emerald-50 font-bold' : 'bg-gray-50 font-semibold') : '';
return (
<React.Fragment key={section.code}>
<tr className={rowClass}>
<td className={cellBorder + ' px-4 py-2 font-semibold'}>
<span className="text-gray-500 mr-2">{section.code}.</span>
{section.name}
</td>
{isCalc ? (
<>
<td className={cellBorder + ' px-4 py-2 text-right'}></td>
<td className={cellBorder + ' px-4 py-2 text-right text-emerald-700'}>{fmt(section.current_amount)}</td>
{showPrev && <td className={cellBorder + ' px-4 py-2 text-right'}></td>}
{showPrev && <td className={cellBorder + ' px-4 py-2 text-right text-emerald-700'}>{fmt(section.previous_amount)}</td>}
</>
) : (
<>
<td className={cellBorder + ' px-4 py-2 text-right'}></td>
<td className={cellBorder + ' px-4 py-2 text-right font-semibold'}>
{section.items.length === 0 ? fmt(section.current_amount) : ''}
</td>
{showPrev && <td className={cellBorder + ' px-4 py-2 text-right'}></td>}
{showPrev && (
<td className={cellBorder + ' px-4 py-2 text-right font-semibold'}>
{section.items.length === 0 ? fmt(section.previous_amount) : ''}
</td>
)}
</>
)}
</tr>
{!isCalc && section.items.map((item, ii) => {
const isLast = ii === section.items.length - 1;
return (
<tr key={ii} className="hover:bg-gray-50">
<td className={cellBorder + ' px-4 py-1.5 pl-12 text-gray-700'}>{item.name}</td>
<td className={cellBorder + ' px-4 py-1.5 text-right text-gray-700'}>{fmt(item.current)}</td>
<td className={cellBorder + ' px-4 py-1.5 text-right'}>{isLast ? fmt(section.current_amount) : ''}</td>
{showPrev && <td className={cellBorder + ' px-4 py-1.5 text-right text-gray-700'}>{fmt(item.previous)}</td>}
{showPrev && <td className={cellBorder + ' px-4 py-1.5 text-right'}>{isLast ? fmt(section.previous_amount) : ''}</td>}
</tr>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
);
}
// ============================================================
// 손익계산서 테이블 (월별 보기)
// ============================================================
function MonthlyTable({ monthlyData, selectedMonth, cellBorder }) {
const sectionCodes = ['III', 'V', 'VIII', 'X'];
// 선택된 월 또는 전체
const months = selectedMonth === 'all'
? monthlyData.months
: monthlyData.months.filter(m => m.month === selectedMonth);
if (months.length === 0) {
return <div className="p-8 text-center text-gray-400">해당 월의 데이터가 없습니다.</div>;
}
// 단일 월: 간결한 1열 테이블
if (months.length === 1) {
const sec = months[0].sections;
return (
<table className="w-full text-sm">
<thead>
<tr className="bg-emerald-600 text-white">
<th className="px-4 py-2 text-left" style={{width: '60%'}}> </th>
<th className="px-4 py-2 text-center" colSpan="2">{months[0].label}</th>
</tr>
</thead>
<tbody>
{sec.map((section) => {
const isCalc = section.is_calculated;
const isHighlight = sectionCodes.includes(section.code);
const rowClass = isCalc ? (isHighlight ? 'bg-emerald-50 font-bold' : 'bg-gray-50 font-semibold') : '';
return (
<React.Fragment key={section.code}>
<tr className={rowClass}>
<td className={cellBorder + ' px-4 py-2 font-semibold'}>
<span className="text-gray-500 mr-2">{section.code}.</span>{section.name}
</td>
<td className={cellBorder + ' px-4 py-2 text-right'}></td>
<td className={cellBorder + ' px-4 py-2 text-right ' + (isCalc ? 'text-emerald-700' : 'font-semibold')}>
{(isCalc || section.items.length === 0) ? fmt(section.current_amount) : ''}
</td>
</tr>
{!isCalc && section.items.map((item, ii) => {
const isLast = ii === section.items.length - 1;
return (
<tr key={ii} className="hover:bg-gray-50">
<td className={cellBorder + ' px-4 py-1.5 pl-12 text-gray-700'}>{item.name}</td>
<td className={cellBorder + ' px-4 py-1.5 text-right text-gray-700'}>{fmt(item.current)}</td>
<td className={cellBorder + ' px-4 py-1.5 text-right'}>{isLast ? fmt(section.current_amount) : ''}</td>
</tr>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
);
}
// 전체 월: 가로 스크롤 월별 비교 (세부내역 포함)
const allCodes = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
return (
<div className="overflow-x-auto">
<table className="text-sm" style={{minWidth: months.length * 110 + 200 + 'px'}}>
<thead>
<tr className="bg-emerald-600 text-white">
<th className="px-4 py-2 text-left sticky left-0 bg-emerald-600 z-10" style={{minWidth: '200px'}}> </th>
{months.map(m => (
<th key={m.month} className="px-3 py-2 text-center" style={{minWidth: '110px'}}>{m.label}</th>
))}
</tr>
</thead>
<tbody>
{allCodes.map(code => {
const first = months[0].sections.find(s => s.code === code);
if (!first) return null;
const isCalc = first.is_calculated;
const isHighlight = sectionCodes.includes(code);
const rowClass = isCalc ? (isHighlight ? 'bg-emerald-50 font-bold' : 'bg-gray-50 font-semibold') : '';
const items = first.items || [];
return (
<React.Fragment key={code}>
<tr className={rowClass}>
<td className={cellBorder + ' px-4 py-1.5 font-semibold sticky left-0 z-10 ' + (isCalc ? rowClass : 'bg-white')}>
<span className="text-gray-500 mr-1">{code}.</span>{first.name}
</td>
{months.map(m => {
const sec = m.sections.find(s => s.code === code);
return (
<td key={m.month} className={cellBorder + ' px-3 py-1.5 text-right ' + (isHighlight && isCalc ? 'text-emerald-700' : 'font-semibold')}>
{(isCalc || items.length === 0) ? fmt(sec?.current_amount) : ''}
</td>
);
})}
</tr>
{!isCalc && items.map((item, ii) => (
<tr key={code + '-' + ii} className="hover:bg-gray-50">
<td className={cellBorder + ' px-4 py-1 pl-10 text-gray-700 sticky left-0 bg-white z-10'}>{item.name}</td>
{months.map(m => {
const sec = m.sections.find(s => s.code === code);
const mItem = sec?.items?.[ii];
return (
<td key={m.month} className={cellBorder + ' px-3 py-1 text-right text-gray-700'}>
{fmt(mItem?.current)}
</td>
);
})}
</tr>
))}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
);
}
// ============================================================
// 메인 컴포넌트
// ============================================================
function IncomeStatement() {
const thisYear = new Date().getFullYear();
const [startDate, setStartDate] = useState(thisYear + '-01-01');
const [endDate, setEndDate] = useState(thisYear + '-12-31');
const [unit, setUnit] = useState('won');
const [showPrev, setShowPrev] = useState(false);
const [viewMode, setViewMode] = useState('period'); // period | monthly
const [selectedMonth, setSelectedMonth] = useState('all');
const [data, setData] = useState(null);
const [monthlyData, setMonthlyData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => { handleSearch(); }, []);
const handleSearch = useCallback(() => {
setLoading(true);
if (viewMode === 'period') {
const params = new URLSearchParams({ start_date: startDate, end_date: endDate, unit });
fetch('/finance/income-statement/data?' + params)
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
} else {
const year = startDate.slice(0, 4);
const params = new URLSearchParams({ year, unit });
fetch('/finance/income-statement/monthly?' + params)
.then(r => r.json())
.then(d => { setMonthlyData(d); setLoading(false); })
.catch(() => setLoading(false));
}
}, [startDate, endDate, unit, viewMode]);
const switchViewMode = (mode) => {
setViewMode(mode);
setData(null);
setMonthlyData(null);
};
const cellBorder = 'border border-gray-200';
const btnActive = 'bg-emerald-600 text-white';
const btnInactive = 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50';
return (
<div className="p-4 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart className="w-6 h-6 text-emerald-600" />
<h1 className="text-xl font-bold text-gray-800">손익계산서</h1>
</div>
<div className="flex items-center gap-2 no-print">
<button onClick={() => window.print()} className="flex items-center gap-1 px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
<Printer className="w-4 h-4" /> 인쇄
</button>
</div>
</div>
{/* 조회 조건 */}
<div className="no-print bg-white rounded-lg border border-gray-200 p-4 space-y-3">
{/* 1행: 보기 모드 + 당기/전기 토글 */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex rounded-lg overflow-hidden">
<button onClick={() => switchViewMode('period')}
className={'px-3 py-1.5 text-sm font-medium ' + (viewMode === 'period' ? btnActive : btnInactive)}>
기간 보기
</button>
<button onClick={() => switchViewMode('monthly')}
className={'px-3 py-1.5 text-sm font-medium ' + (viewMode === 'monthly' ? btnActive : btnInactive)}>
월별 보기
</button>
</div>
{viewMode === 'period' && (
<div className="flex rounded-lg overflow-hidden">
<button onClick={() => setShowPrev(false)}
className={'px-3 py-1.5 text-sm font-medium ' + (!showPrev ? btnActive : btnInactive)}>
당기만
</button>
<button onClick={() => setShowPrev(true)}
className={'px-3 py-1.5 text-sm font-medium ' + (showPrev ? btnActive : btnInactive)}>
당기 + 전기
</button>
</div>
)}
{viewMode === 'monthly' && monthlyData && (
<div className="flex items-center gap-1">
<button onClick={() => setSelectedMonth('all')}
className={'px-2.5 py-1 text-xs rounded font-medium ' + (selectedMonth === 'all' ? btnActive : btnInactive)}>
전체
</button>
{monthlyData.months.map(m => (
<button key={m.month} onClick={() => setSelectedMonth(m.month)}
className={'px-2.5 py-1 text-xs rounded font-medium ' + (selectedMonth === m.month ? btnActive : btnInactive)}>
{m.label}
</button>
))}
</div>
)}
</div>
{/* 2행: 기간 + 단위 + 조회 */}
<div className="flex flex-wrap items-end gap-4">
{viewMode === 'period' ? (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">기간</label>
<div className="flex items-center gap-1">
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)}
className="px-2 py-1.5 text-sm border border-gray-300 rounded" />
<span className="text-gray-400">~</span>
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)}
className="px-2 py-1.5 text-sm border border-gray-300 rounded" />
</div>
</div>
) : (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">연도</label>
<select value={startDate.slice(0, 4)}
onChange={e => { setStartDate(e.target.value + '-01-01'); setEndDate(e.target.value + '-12-31'); }}
className="px-2 py-1.5 text-sm border border-gray-300 rounded">
{[thisYear - 1, thisYear, thisYear + 1].map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">단위</label>
<select value={unit} onChange={e => setUnit(e.target.value)}
className="px-2 py-1.5 text-sm border border-gray-300 rounded">
<option value="won"></option>
<option value="thousand">천원</option>
<option value="million">백만원</option>
</select>
</div>
<button onClick={handleSearch} disabled={loading}
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 text-white text-sm rounded hover:bg-emerald-700 disabled:opacity-50">
<Search className="w-4 h-4" />
{loading ? '조회중...' : '조회'}
</button>
</div>
</div>
{/* 테이블 영역 */}
{viewMode === 'period' && data && (
<div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
<PeriodTable data={data} showPrev={showPrev} cellBorder={cellBorder} />
<div className="px-4 py-2 text-right text-xs text-gray-400 border-t border-gray-200">
(단위: {UNIT_LABELS[data.unit] || data.unit})
</div>
</div>
)}
{viewMode === 'monthly' && monthlyData && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200 text-sm font-semibold text-gray-700">
{monthlyData.fiscal_label} ({monthlyData.year})
</div>
<MonthlyTable monthlyData={monthlyData} selectedMonth={selectedMonth} cellBorder={cellBorder} />
<div className="px-4 py-2 text-right text-xs text-gray-400 border-t border-gray-200">
(단위: {UNIT_LABELS[monthlyData.unit] || monthlyData.unit})
</div>
</div>
)}
{loading && (
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center text-gray-400">
조회 중입니다...
</div>
)}
</div>
);
}
ReactDOM.render(<IncomeStatement />, document.getElementById('income-statement-root'));
</script>
@endverbatim
@endpush