- 기수: 코드브릿지엑스 설립 2025년 기준 (1기=2025, 2기=2026) - 당기만/당기+전기 토글 버튼 - 월별 보기 모드 (전체/개별 월 선택) - 월별 전체: 가로 스크롤 비교 테이블 - buildSections 공통 로직 분리
421 lines
22 KiB
PHP
421 lines
22 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 summaryItems = ['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>
|
|
{summaryItems.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') : '';
|
|
|
|
return (
|
|
<tr key={code} className={rowClass}>
|
|
<td className={cellBorder + ' px-4 py-1.5 font-semibold sticky left-0 bg-white z-10 ' + rowClass}>
|
|
<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' : '')}>
|
|
{fmt(sec?.current_amount)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</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
|