feat: unify 8081 dashboard design system and views

This commit is contained in:
hyunho
2026-04-01 14:02:05 +09:00
parent 637b390024
commit fb5b0f00c2
27 changed files with 12596 additions and 818 deletions

34
incoming-files/README.md Normal file
View File

@@ -0,0 +1,34 @@
# incoming-files Layout
`8081` 1차 구조 정리 기준으로 `incoming-files`는 아래처럼 해석한다.
## Served
- 실제 URL에서 직접 서빙되는 HTML
- 현재 사용 파일:
- `served/payment.html`
- `served/mh.html`
주의:
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
- 새 기능을 붙일 때도 실제 서비스 파일은 `served/` 기준으로 수정한다.
## Reference
- 원본 참고 자산
- 복구 비교용 자산
- 직접 서빙하지 않는 파일
예:
- 원본 `xlsx`, `csv`
- 샘플 스타일 파일
- 원본/백업 HTML
- 디자인 비교용 파일
## Temporary Comparison Copies
- 현재 루트의 `payment.html`, `mh.html`은 당장 삭제하지 않는다.
- 이 두 파일은 기존 recovery 작업본과 현재 `served/*`를 비교하거나 되돌릴 때만 본다.
- 다음 차수에서 안전성이 확보되면 `reference/` 하위로 재배치 여부를 검토한다.

File diff suppressed because it is too large Load Diff

View File

@@ -110,35 +110,35 @@ const App = () => {
};
const costCategories = [
{ name: '인건비', color: '#6366f1' },
{ name: '출장비', color: '#f43f5e' },
{ name: '복리후생비', color: '#fbbf24' },
{ name: '구매비', color: '#0ea5e9' },
{ name: '외주비', color: '#94a3b8' }
{ name: '인건비', color: '#0f3a2f' },
{ name: '출장비', color: '#a94832' },
{ name: '복리후생비', color: '#d68a3a' },
{ name: '구매비', color: '#4b87b3' },
{ name: '외주비', color: '#66756d' }
];
const positionStyles = {
'수석연구원': { bg: 'bg-purple-50', text: 'text-purple-600', border: 'border-purple-100', icon: 'bg-purple-600' },
'책임연구원': { bg: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-100', icon: 'bg-blue-600' },
'선임연구원': { bg: 'bg-indigo-50', text: 'text-indigo-600', border: 'border-indigo-100', icon: 'bg-indigo-600' },
'전임연구원': { bg: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-100', icon: 'bg-emerald-600' },
'주임연구원': { bg: 'bg-slate-50', text: 'text-slate-600', border: 'border-slate-100', icon: 'bg-slate-600' },
'연구원': { bg: 'bg-slate-50', text: 'text-slate-500', border: 'border-slate-100', icon: 'bg-slate-400' },
'미지정': { bg: 'bg-gray-50', text: 'text-gray-400', border: 'border-gray-100', icon: 'bg-gray-300' }
'수석연구원': { bg: 'position-chip position-executive', text: 'position-text position-executive', border: 'position-border position-executive', icon: 'position-dot position-executive' },
'책임연구원': { bg: 'position-chip position-principal', text: 'position-text position-principal', border: 'position-border position-principal', icon: 'position-dot position-principal' },
'선임연구원': { bg: 'position-chip position-senior', text: 'position-text position-senior', border: 'position-border position-senior', icon: 'position-dot position-senior' },
'전임연구원': { bg: 'position-chip position-associate', text: 'position-text position-associate', border: 'position-border position-associate', icon: 'position-dot position-associate' },
'주임연구원': { bg: 'position-chip position-staff', text: 'position-text position-staff', border: 'position-border position-staff', icon: 'position-dot position-staff' },
'연구원': { bg: 'position-chip position-member', text: 'position-text position-member', border: 'position-border position-member', icon: 'position-dot position-member' },
'미지정': { bg: 'position-chip position-unset', text: 'position-text position-unset', border: 'position-border position-unset', icon: 'position-dot position-unset' }
};
const positionOrder = { '수석연구원': 1, '책임연구원': 2, '선임연구원': 3, '연구원': 4 };
const positionColorMap = {
'수석연구원': '#7c3aed',
'책임연구원': '#2563eb',
'선임연구원': '#4f46e5',
'전임연구원': '#059669',
'주임연구원': '#475569',
'연구원': '#64748b',
'미지정': '#9ca3af'
'수석연구원': '#0f3a2f',
'책임연구원': '#1a5645',
'선임연구원': '#2f9973',
'전임연구원': '#4b87b3',
'주임연구원': '#9a6422',
'연구원': '#66756d',
'미지정': '#b7aa93'
};
const getPositionStyle = (pos) => positionStyles[pos] || positionStyles['미지정'];
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#94a3b8';
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#66756d';
const getPositionColor = (name) => positionColorMap[name] || positionColorMap['미지정'];
const twoLineClampStyle = {
display: '-webkit-box',
@@ -164,7 +164,7 @@ const App = () => {
const buildDonutGradient = (items) => {
const total = items.reduce((sum, item) => sum + (item.value || 0), 0);
if (total <= 0) return 'conic-gradient(#e2e8f0 0deg 360deg)';
if (total <= 0) return 'conic-gradient(#eadcc4 0deg 360deg)';
let start = 0;
const slices = items.map((item) => {
const deg = ((item.value || 0) / total) * 360;
@@ -177,7 +177,7 @@ const App = () => {
};
const renderBreakdownTooltip = (breakdown, total) => (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] font-bold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{costCategories.map((cat) => {
const val = breakdown?.[cat.name] || 0;
const ratio = total > 0 ? ((val / total) * 100).toFixed(1) : '0.0';
@@ -195,7 +195,7 @@ const App = () => {
);
const renderPositionBreakdownTooltip = (breakdown, totalHrs) => (
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] font-bold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{Object.entries(breakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -226,9 +226,9 @@ const App = () => {
return (
<div className="mt-2 grid grid-cols-[72px_1fr] items-center gap-2">
<div className="self-center text-center">
<div className="text-[16px] leading-none font-black text-slate-800">{Number(totalWorkers || 0)}</div>
<div className="text-[16px] leading-none font-black payment-strong">{Number(totalWorkers || 0)}</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black text-slate-600 leading-tight">
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black payment-muted leading-tight">
{entries.map(([pos, val]) => {
const count = details?.[pos]?.names?.size || 0;
const hrsText = Number(val || 0).toFixed(1).replace(/\.0$/, '');
@@ -258,7 +258,7 @@ const App = () => {
{cells.map((cell) => {
const amount = Math.round(breakdown?.[cell.key] || 0);
return (
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black text-slate-700 whitespace-nowrap">
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black payment-muted whitespace-nowrap">
{amount === 0 ? '-' : `${amount.toLocaleString()}`}
</div>
);
@@ -1134,23 +1134,23 @@ const App = () => {
const isAllFiltersApplied = selectedRev !== '전체' && selectedD1 !== '전체' && selectedD2 !== '전체' && selectedProject !== '전체';
return (
<div className="min-h-screen bg-[#f8fafc] p-6 font-sans text-slate-900">
<div className="w-full mx-auto space-y-6">
<div className="payment-theme min-h-screen p-6 font-sans">
<div className="w-full mx-auto space-y-6" style={{ maxWidth: '2000px' }}>
{!isAllFiltersApplied && (
<>
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 bg-[#f8fafc] pb-3">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 payment-kpi-grid pb-3">
{[
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'text-indigo-600' },
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'text-slate-600' },
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'text-rose-600' },
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'text-amber-600' },
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'text-slate-500' },
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'text-indigo-600' },
{ label: '참여인원', value: `${viewData.kpis.workers}`, totalValue: `${viewData.kpisAll.workers}`, icon: Users, color: 'text-white', bg: 'bg-slate-900' },
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'payment-kpi-income' },
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'payment-kpi-labor' },
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'payment-kpi-travel' },
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'payment-kpi-welfare' },
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'payment-kpi-others' },
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'payment-kpi-hours' },
{ label: '참여인원', value: `${viewData.kpis.workers}`, totalValue: `${viewData.kpisAll.workers}`, icon: Users, color: 'payment-kpi-inverse', bg: 'payment-kpi-people' },
].map((kpi, i) => (
<div key={i} className={`${kpi.bg || 'bg-white'} ${kpi.color} p-4 rounded-[22px] border border-slate-100 shadow-sm flex flex-col h-24`}>
<div key={i} className={`payment-kpi-card ${kpi.bg || ''} ${kpi.color} p-4 rounded-[22px] flex flex-col h-24`}>
<span className="text-[11px] font-black uppercase opacity-60 flex justify-between">{kpi.label} <kpi.icon size={10}/></span>
<div className="flex flex-col leading-tight mt-1 gap-1">
<span className="text-lg font-black truncate">{kpi.value}</span>
@@ -1163,16 +1163,16 @@ const App = () => {
)}
{/* 상세 분석 테이블 */}
<section className="bg-white rounded-[35px] shadow-sm border border-slate-100 overflow-visible">
<div className={`px-6 py-4 border-b border-slate-50 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 bg-white/95 backdrop-blur-sm`}>
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="text-indigo-600" /> 분야별 프로젝트 상세 분석</h2>
<section className="payment-panel payment-table-panel rounded-[35px] overflow-visible">
<div className={`payment-panel-head px-6 py-4 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 backdrop-blur-sm`}>
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="payment-icon-accent" /> 분야별 프로젝트 상세 분석</h2>
<div className="group relative shrink-0">
<button type="button" className="px-3 py-2 bg-slate-900 text-white rounded-xl text-[12px] font-black tracking-wide shadow-sm border border-slate-800">
<button type="button" className="payment-filter-toggle px-3 py-2 rounded-xl text-[12px] font-black tracking-wide shadow-sm border">
카테고리 필터
</button>
<div className="absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
<div className="payment-filter-pop absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
<div className="flex items-center gap-2">
<div className="flex gap-2 bg-slate-50/80 p-1.5 rounded-2xl border border-slate-100 flex-1 min-w-[420px]">
<div className="payment-filter-bar flex gap-2 p-1.5 rounded-2xl flex-1 min-w-[420px]">
<select value={selectedRev} onChange={e => {setSelectedRev(e.target.value); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체');}} className="filter-select flex-1">
<option value="전체">대분류 전체</option>
{Object.keys(viewData.hierarchy)
@@ -1209,7 +1209,7 @@ const App = () => {
className="filter-select flex-[1.1]"
/>
</div>
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="p-1.5 bg-white rounded-xl border border-slate-200 text-slate-400 hover:text-indigo-600 transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="payment-reset-btn p-1.5 rounded-xl transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
</div>
</div>
</div>
@@ -1226,17 +1226,17 @@ const App = () => {
<col style={{ width: '23%' }} />
<col style={{ width: '26%' }} />
</colgroup>
<thead className="bg-slate-50/80">
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
<thead className="payment-table-head">
<tr className="text-[12px] font-extrabold uppercase tracking-widest payment-table-head-row">
<th className="px-4 py-3 whitespace-nowrap">대분류</th>
<th className="px-4 py-3 whitespace-nowrap">중분류</th>
<th className="px-4 py-3 whitespace-nowrap">소분류</th>
<th className="px-4 py-3 whitespace-nowrap">{viewData.isAllFiltersOff ? '' : '프로젝트명'}</th>
<th className="px-4 py-3 whitespace-nowrap">프로젝트명</th>
<th className="px-4 py-3 text-right whitespace-nowrap">수입(매출)</th>
<th className="px-4 py-3 text-right whitespace-nowrap">지출 합계</th>
<th className="px-4 py-3 whitespace-nowrap text-center">
<div className="text-[11px] font-black text-slate-500 mb-1 text-center">지출 구성비</div>
<div className="grid grid-cols-5 text-[10px] font-black text-slate-600 normal-case tracking-normal">
<div className="text-[11px] font-black payment-subhead mb-1 text-center">지출 구성비</div>
<div className="grid grid-cols-5 text-[10px] font-black payment-subhead normal-case tracking-normal">
<span className="py-1 text-center">인건비</span>
<span className="py-1 text-center">출장비</span>
<span className="py-1 text-center">복리후생비</span>
@@ -1250,7 +1250,7 @@ const App = () => {
<tbody className="text-[13px] font-bold">
{viewData.finalDisplayList.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-slate-400 font-bold">표시할 데이터가 없습니다.</td>
<td colSpan={8} className="px-4 py-12 text-center payment-empty font-bold">표시할 데이터가 없습니다.</td>
</tr>
)}
{viewData.finalDisplayList.map((item, idx) => {
@@ -1259,16 +1259,16 @@ const App = () => {
return (
<tr
key={`subtotal-${idx}`}
className={`h-12 border-y ${isGrandTotal ? 'bg-indigo-100 border-indigo-300 shadow-[inset_0_1px_0_rgba(99,102,241,0.35)]' : 'bg-amber-50 border-amber-200'}`}
className={`h-12 border-y ${isGrandTotal ? 'payment-subtotal payment-subtotal-grand shadow-[inset_0_1px_0_rgba(33,70,52,0.18)]' : 'payment-subtotal payment-subtotal-mid'}`}
>
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'text-indigo-900 text-[14px] font-extrabold' : 'text-amber-900 font-black'}`}>
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-label-grand text-[14px] font-extrabold' : 'payment-subtotal-label-mid font-black'}`}>
{item.subtotalLabel}
</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'text-indigo-800 text-[14px]' : 'text-amber-800'}`}>{formatWonDash(item.income)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'text-indigo-900 text-[14px]' : 'text-amber-900'}`}>{formatWonRoundedDash(item.total)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-income-grand text-[14px]' : 'payment-subtotal-income-mid'}`}>{formatWonDash(item.income)}</td>
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-total-grand text-[14px]' : 'payment-subtotal-total-mid'}`}>{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
<td className="px-4 py-3">
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'bg-indigo-200/80' : 'bg-amber-100'}`}>
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'payment-progress-track-grand' : 'payment-progress-track-mid'}`}>
{Object.entries(item.positionBreakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -1284,12 +1284,12 @@ const App = () => {
}
return (
<tr key={`row-${idx}`} className="h-12 hover:bg-indigo-50/30 transition-all border-b border-slate-50 group">
<tr key={`row-${idx}`} className="payment-data-row h-12 transition-all border-b group">
{item.d1Span > 0 && (
<td
rowSpan={item.d1Span}
onClick={() => handleD1Click(item.d1)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d1}</span>
</td>
@@ -1298,7 +1298,7 @@ const App = () => {
<td
rowSpan={item.d2Span}
onClick={() => handleD2Click(item.d1, item.d2)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d2}</span>
</td>
@@ -1307,22 +1307,22 @@ const App = () => {
<td
rowSpan={item.d3Span}
onClick={() => handleD3Click(item.d1, item.d2, item.d3)}
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
>
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d3}</span>
</td>
)}
<td
onClick={() => { if (!viewData.isAllFiltersOff) handleD4Click(item.d1, item.d2, item.d3, item.name); }}
className={`px-4 py-3 text-slate-700 transition-colors ${viewData.isAllFiltersOff ? '' : 'truncate cursor-pointer hover:bg-indigo-50 hover:text-indigo-800'}`}
onClick={() => { handleD4Click(item.d1, item.d2, item.d3, item.name); }}
className="px-4 py-3 payment-project-cell font-extrabold truncate cursor-pointer transition-colors"
>
{viewData.isAllFiltersOff ? '\u00A0' : item.name}
{item.name}
</td>
<td className="px-4 py-3 text-right text-emerald-700 font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
<td className="px-4 py-3 text-right text-rose-700 font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3 text-right payment-income font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
<td className="px-4 py-3 text-right payment-expense font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
<td className="px-4 py-3">
<div className="h-2.5 bg-slate-100 rounded-full overflow-hidden flex shadow-inner">
<div className="h-2.5 payment-progress-track rounded-full overflow-hidden flex shadow-inner">
{Object.entries(item.positionBreakdown || {})
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, val]) => {
@@ -1343,8 +1343,8 @@ const App = () => {
{/* 하단 상세 차트 */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pb-12">
<div className="lg:col-span-5 bg-white p-8 rounded-[40px] shadow-sm border border-slate-100 min-h-[480px] flex flex-col">
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="text-indigo-600"/> 지출 구성 상세</h3>
<div className="lg:col-span-5 payment-panel p-8 rounded-[40px] min-h-[480px] flex flex-col">
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="payment-icon-accent"/> 지출 구성 상세</h3>
<div className="flex-1">
{viewData.categoryData.length > 0 ? (
<div className="h-full flex flex-col gap-5">
@@ -1354,9 +1354,9 @@ const App = () => {
className="relative h-56 w-56 rounded-full"
style={{ background: buildDonutGradient(viewData.categoryData) }}
>
<div className="absolute inset-11 rounded-full bg-white border border-slate-100 flex flex-col items-center justify-center">
<span className="text-[12px] font-black text-slate-500"> 지출</span>
<span className="text-[15px] font-black text-slate-900">
<div className="absolute inset-11 payment-donut-center rounded-full flex flex-col items-center justify-center">
<span className="text-[12px] font-black payment-subhead"> 지출</span>
<span className="text-[15px] font-black payment-strong">
{formatWon(viewData.categoryData.reduce((sum, item) => sum + (item.value || 0), 0))}
</span>
</div>
@@ -1374,13 +1374,13 @@ const App = () => {
if (!isSelectable) return;
setSelectedExpenseDetailCategory((prev) => (prev === item.name ? '' : item.name));
}}
className={`flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'hover:bg-slate-50 cursor-pointer' : 'cursor-default'} ${isSelected ? 'bg-indigo-50' : ''}`}
className={`payment-cost-row flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'cursor-pointer' : 'cursor-default'} ${isSelected ? 'payment-cost-row-active' : ''}`}
>
<span className="flex items-center gap-2 text-slate-600 truncate">
<span className="flex items-center gap-2 payment-muted truncate">
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getCostColor(item.name) }}></span>
{item.name} ({item.ratio}%)
</span>
<span className="text-slate-900">{formatWon(item.value)}</span>
<span className="payment-strong">{formatWon(item.value)}</span>
</button>
);
})}
@@ -1388,20 +1388,20 @@ const App = () => {
</div>
{viewData.isAllFiltersOff && (
<div className="w-full mt-4 text-[12px] text-slate-400 font-bold text-center">
<div className="w-full mt-4 text-[12px] payment-empty font-bold text-center">
상세 내역은 필터 적용 표시됩니다.
</div>
)}
{!viewData.isAllFiltersOff && selectedExpenseDetailCategory && selectedExpenseDetailCategory !== '인건비' && (
<div className="w-full mt-5 pt-4 border-t border-slate-100">
<div className="text-[12px] font-black text-slate-600 mb-2">
<div className="w-full mt-5 pt-4 payment-divider-top">
<div className="text-[12px] font-black payment-subhead mb-2">
{selectedExpenseDetailCategory} 지출 구성 상세 내역
</div>
{(viewData.expenseDetailByCategory?.[selectedExpenseDetailCategory] || []).length > 0 ? (
<div className="max-h-56 overflow-y-auto rounded-lg border border-slate-100 custom-scrollbar">
<div className="max-h-56 overflow-y-auto rounded-lg payment-mini-table-shell custom-scrollbar">
<table className="w-full text-[12px] table-fixed border-collapse">
<thead className="bg-slate-50 text-slate-500 font-black">
<thead className="payment-mini-table-head font-black">
<tr>
<th className="px-2 py-2 text-left w-[74px]">발행월</th>
<th className="px-2 py-2 text-left w-[88px]">발행일</th>
@@ -1412,7 +1412,7 @@ const App = () => {
</thead>
<tbody>
{(viewData.expenseDetailByCategory[selectedExpenseDetailCategory] || []).map((row, idx) => (
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="border-t border-slate-50 text-slate-700">
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="payment-mini-table-row">
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueMonth || '-'}</td>
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueDate || '-'}</td>
<td className="px-2 py-1.5 truncate">{row.summary || '-'}</td>
@@ -1424,21 +1424,21 @@ const App = () => {
</table>
</div>
) : (
<div className="text-[12px] text-slate-400 font-bold">표시할 전표 데이터가 없습니다.</div>
<div className="text-[12px] payment-empty font-bold">표시할 전표 데이터가 없습니다.</div>
)}
</div>
)}
</div>
) : (
<div className="h-full flex items-center justify-center text-slate-300 text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
<div className="h-full flex items-center justify-center payment-empty text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
)}
</div>
</div>
<div className="lg:col-span-7 bg-white p-8 rounded-[40px] shadow-sm border border-slate-100 flex flex-col h-[560px] overflow-hidden">
<div className="lg:col-span-7 payment-panel p-8 rounded-[40px] flex flex-col h-[560px] overflow-hidden">
<h3 className="text-lg font-black mb-4 flex items-center gap-3 shrink-0">
<UserCheck className="text-indigo-600"/> 직급별 인원 투입 상세
<span className="ml-1 text-[11px] font-black text-indigo-600 bg-indigo-50 border border-indigo-100 px-2 py-1 rounded-lg">
<UserCheck className="payment-icon-accent"/> 직급별 인원 투입 상세
<span className="payment-mode-chip ml-1 text-[11px] font-black px-2 py-1 rounded-lg">
기준: {viewData.positionGroupMode}
</span>
</h3>
@@ -1453,33 +1453,33 @@ const App = () => {
})
.map(([pName, positions]) => (
<div key={pName} className="mb-8 last:mb-0">
<div className="bg-slate-900 px-4 py-1.5 rounded-xl text-[12px] font-black text-white mb-4 sticky top-0 z-10">{pName}</div>
<div className="payment-group-title px-4 py-1.5 rounded-xl text-[12px] font-black mb-4 sticky top-0 z-10">{pName}</div>
<div className="grid grid-cols-1 gap-3">
{Object.entries(positions)
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
.map(([pos, data]) => {
const style = getPositionStyle(pos);
return (
<div key={pos} className={`bg-white border ${style.border} rounded-[28px] p-5 flex items-center gap-6 hover:shadow-md transition-all`}>
<div key={pos} className={`payment-position-card border ${style.border} rounded-[28px] p-5 flex items-center gap-6 transition-all`}>
<div className={`flex items-center gap-3 w-1/4 shrink-0 px-4 py-2 rounded-2xl ${style.bg} border ${style.border}`}>
<div className={`w-3 h-3 rounded-full ${style.icon} shadow-sm`}></div>
<div className={`text-[14px] font-black ${style.text}`}>{pos}</div>
</div>
<div className="flex-1 grid grid-cols-2 gap-8 border-l border-slate-100 pl-8">
<div className="flex-1 grid grid-cols-2 gap-8 payment-divider-left pl-8">
<div>
<div className="text-[11px] text-slate-400 font-black uppercase mb-1">Estimated Cost</div>
<div className="text-[16px] font-black text-indigo-600 font-mono">{Math.round(data.labor).toLocaleString()}</div>
<div className="text-[11px] payment-empty font-black uppercase mb-1">Estimated Cost</div>
<div className="text-[16px] font-black payment-icon-accent font-mono">{Math.round(data.labor).toLocaleString()}</div>
</div>
<div>
<div className="text-[11px] text-slate-400 font-black uppercase mb-1">Hours & Count</div>
<div className="text-[16px] font-black text-slate-900">{data.hrs.toFixed(2)}h <span className="text-slate-300 mx-1">|</span> {data.names.size}</div>
<div className="text-[11px] payment-empty font-black uppercase mb-1">Hours & Count</div>
<div className="text-[16px] font-black payment-strong">{data.hrs.toFixed(2)}h <span className="payment-divider-mark mx-1">|</span> {data.names.size}</div>
</div>
</div>
<div className="w-1/3 min-w-[260px] border-l border-slate-100 pl-4">
<div className="w-1/3 min-w-[260px] payment-divider-left pl-4">
<div className="overflow-x-auto overflow-y-hidden custom-scrollbar">
<div className="grid grid-rows-2 grid-flow-col auto-cols-max gap-x-1.5 gap-y-1.5 min-w-max pb-1">
{Array.from(data.names).map(name => (
<span key={name} className="px-2 py-0.5 bg-slate-50 text-slate-500 rounded-lg text-[11px] font-bold border border-slate-100 whitespace-nowrap">{name}</span>
<span key={name} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">{name}</span>
))}
</div>
</div>
@@ -1491,7 +1491,7 @@ const App = () => {
</div>
))
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-300 gap-3">
<div className="h-full flex flex-col items-center justify-center payment-empty gap-3">
<Info size={40} />
<span className="text-sm font-bold">표시할 데이터가 없습니다.</span>
</div>
@@ -1500,18 +1500,18 @@ const App = () => {
</div>
</div>
<section className="bg-white rounded-[35px] shadow-sm border border-slate-100 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-50 flex items-center justify-between gap-4">
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="text-indigo-600" /> 프로젝트별 Activity 분석</h3>
<section className="payment-panel rounded-[35px] overflow-hidden">
<div className="payment-panel-head px-6 py-4 flex items-center justify-between gap-4">
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="payment-icon-accent" /> 프로젝트별 Activity 분석</h3>
</div>
<div className="p-6">
{viewData.projectActivityList.length > 0 ? (
<div className="space-y-4">
{viewData.projectActivityList.map((project) => (
<div key={`activity-${project.projectName}`} className="border border-slate-200 rounded-2xl overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between gap-3">
<div className="text-[14px] font-black text-slate-900 truncate">{project.projectName}</div>
<div className="text-[12px] font-black text-indigo-700 whitespace-nowrap"> {formatHours(project.totalHours)}h · {project.workerCount}</div>
<div key={`activity-${project.projectName}`} className="payment-activity-card border rounded-2xl overflow-hidden">
<div className="payment-activity-card-head px-4 py-3 flex items-center justify-between gap-3">
<div className="text-[14px] font-black payment-strong truncate">{project.projectName}</div>
<div className="text-[12px] font-black payment-icon-accent whitespace-nowrap"> {formatHours(project.totalHours)}h · {project.workerCount}</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed">
@@ -1521,8 +1521,8 @@ const App = () => {
<col style={{ width: '90px' }} />
<col style={{ width: 'auto' }} />
</colgroup>
<thead className="bg-slate-50/70 border-b border-slate-100">
<tr className="text-[11px] font-black text-slate-500 uppercase tracking-wide">
<thead className="payment-mini-table-head border-b">
<tr className="text-[11px] font-black payment-subhead uppercase tracking-wide">
<th className="px-3 py-2 whitespace-nowrap">Activity</th>
<th className="px-3 py-2 text-right whitespace-nowrap">투입시간</th>
<th className="px-3 py-2 text-right whitespace-nowrap">투입인원</th>
@@ -1531,14 +1531,14 @@ const App = () => {
</thead>
<tbody>
{project.activities.map((activity) => (
<tr key={`${project.projectName}-${activity.activityName}`} className="border-b border-slate-50 last:border-b-0">
<td className="px-3 py-2 text-[12px] font-black text-slate-800 whitespace-nowrap truncate">{activity.activityName}</td>
<td className="px-3 py-2 text-[12px] font-black text-right text-indigo-700 whitespace-nowrap">{formatHours(activity.hours)}h</td>
<td className="px-3 py-2 text-[12px] font-black text-right text-slate-700 whitespace-nowrap">{activity.workerCount}</td>
<td className="px-3 py-2 text-[12px] text-slate-600">
<tr key={`${project.projectName}-${activity.activityName}`} className="payment-mini-table-row last:border-b-0">
<td className="px-3 py-2 text-[12px] font-black payment-strong whitespace-nowrap truncate">{activity.activityName}</td>
<td className="px-3 py-2 text-[12px] font-black text-right payment-icon-accent whitespace-nowrap">{formatHours(activity.hours)}h</td>
<td className="px-3 py-2 text-[12px] font-black text-right payment-muted whitespace-nowrap">{activity.workerCount}</td>
<td className="px-3 py-2 text-[12px] payment-muted">
<div className="flex flex-wrap gap-1.5">
{activity.members.map((m) => (
<span key={`${activity.activityName}-${m.name}`} className="px-2 py-0.5 rounded-lg bg-slate-50 border border-slate-100 text-[11px] font-bold text-slate-600 whitespace-nowrap">
<span key={`${activity.activityName}-${m.name}`} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">
{m.name} ({formatHours(m.hours)}h)
</span>
))}
@@ -1553,24 +1553,46 @@ const App = () => {
))}
</div>
) : (
<div className="py-10 text-center text-slate-300 text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
<div className="py-10 text-center payment-empty text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
)}
</div>
</section>
</div>
<style>{`
@import url('/design-tokens.css');
@import url('/design-patterns.css');
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap');
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: #f8fafc; }
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: var(--ds-bg); color: var(--ds-ink); }
.payment-theme { color: var(--ds-ink); }
.payment-kpi-income, .payment-kpi-hours { color: var(--ds-brand-soft); }
.payment-kpi-labor, .payment-kpi-others { color: var(--ds-text-soft); }
.payment-kpi-travel { color: var(--ds-status-danger); }
.payment-kpi-welfare { color: var(--ds-status-warning); }
.payment-filter-pop { border: 1px solid var(--ds-line); background: rgba(255,250,243,0.98); }
.payment-subtotal { border-color: var(--ds-line); }
.payment-subtotal-grand { background: #efe2ca; }
.payment-subtotal-mid { background: #f6e6c9; }
.payment-subtotal-label-grand, .payment-subtotal-total-grand { color: var(--ds-brand-deep); }
.payment-subtotal-income-grand { color: var(--ds-brand-soft); }
.payment-subtotal-label-mid, .payment-subtotal-total-mid { color: #9a6422; }
.payment-subtotal-income-mid { color: #7b5a20; }
.payment-donut-center { background: rgba(255,250,243,0.98); border: 1px solid var(--ds-line-soft); }
.payment-cost-row:hover { background: rgba(234,220,196,0.34); }
.payment-cost-row-active { background: rgba(242,196,132,0.18); }
.payment-position-card { background: rgba(255,250,243,0.96); box-shadow: var(--ds-shadow-soft); }
.payment-activity-card { border-color: var(--ds-line-soft); }
.payment-activity-card-head { background: rgba(246,237,221,0.68); border-bottom: 1px solid var(--ds-line-soft); }
.filter-select {
background-color: transparent; border: none; padding: 0.35rem 1.6rem 0.35rem 0.5rem; font-size: 10px; font-weight: 800;
outline: none; appearance: none; cursor: pointer; transition: all 0.2s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
color: var(--ds-ink);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2366756d'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 0.4rem center; background-size: 0.6rem;
}
.filter-select:hover { color: #6366f1; background-color: white; border-radius: 8px; }
.filter-select:hover { color: var(--ds-brand-soft); background-color: rgba(255,255,255,0.98); border-radius: 8px; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: var(--ds-line); border-radius: 10px; }
`}</style>
</div>
);

View File

@@ -0,0 +1,13 @@
# Reference Assets
이 디렉터리는 앞으로 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으기 위한 공간이다.
1차 정리에서는 위험한 대량 이동을 피하기 위해 기존 참고 파일을 즉시 옮기지 않는다.
대신 실제 서빙 파일은 `incoming-files/served/`로 고정하고, 다음 차수에서 참고 자산을 단계적으로 재배치한다.
예상 대상:
- 원본 HTML/CSS 참고본
- 원본 xlsx/csv
- 복구 비교용 자산
- 디자인 레퍼런스 파일

View File

@@ -0,0 +1,14 @@
# Served Assets
이 디렉터리는 `8081`에서 실제 URL 응답으로 직접 서빙되는 integration HTML 파일만 둔다.
현재 사용 중:
- `payment.html`
- `mh.html`
규칙:
- `/integrations/payment` 는 이 디렉터리의 `payment.html`을 읽는다.
- `/integrations/mh` 는 이 디렉터리의 `mh.html`을 읽는다.
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
html,
body {
margin: 0;
padding: 0;
}
body.mh-business-theme {
overflow-x: hidden;
background:
radial-gradient(circle at top left, rgba(214, 138, 58, 0.16), transparent 24%),
radial-gradient(circle at top right, rgba(47, 153, 115, 0.10), transparent 20%),
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
}
body.mh-business-theme .wrap {
width: min(100%, 2000px);
max-width: 2000px;
margin: 0 auto;
padding: 18px 18px 26px;
box-sizing: border-box;
}
body.mh-business-theme .top,
body.mh-business-theme .status {
display: none !important;
}
body.mh-business-theme .cards {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px;
margin: 0 0 16px;
}
body.mh-business-theme .business-shell {
width: 100%;
box-sizing: border-box;
margin-top: 2px;
padding: 18px;
border-radius: 32px;
background:
radial-gradient(circle at 16% 14%, rgba(255,255,255,0.05), transparent 18%),
radial-gradient(circle at 88% 8%, rgba(255,255,255,0.04), transparent 16%),
linear-gradient(145deg, #0b352b 0%, #174e41 52%, #245f50 100%);
box-shadow: 0 26px 54px rgba(15, 58, 47, 0.16);
border: 1px solid rgba(255,255,255,0.08);
}
body.mh-business-theme .cards-toolbar {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 14px;
padding: 10px 0 2px;
}
body.mh-business-theme .cards-toolbar-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
body.mh-business-theme .cards-toolbar-search {
margin-left: auto;
display: flex;
align-items: center;
min-width: min(360px, 100%);
flex: 1 1 320px;
max-width: 520px;
}
body.mh-business-theme .cards-toolbar-search .search {
width: 100%;
min-width: 0;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.10);
color: #f4efe6;
padding: 14px 18px;
font-size: 14px;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
}
body.mh-business-theme .cards-toolbar-search .search::placeholder {
color: rgba(244, 239, 230, 0.74);
}
body.mh-business-theme #btnUpload {
display: none !important;
}
body.mh-business-theme .cards-toolbar-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
body.mh-business-theme .summary-year-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 60px;
padding: 10px 16px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08);
color: #f4efe6;
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
body.mh-business-theme .summary-year-chip.active {
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
color: #0a2a22;
border-color: rgba(242, 196, 132, 0.58);
box-shadow: 0 12px 28px rgba(10, 42, 34, 0.18);
}
body.mh-business-theme .summary-filter-chip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 98px;
padding: 18px 22px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.07) 100%);
color: #f4efe6;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 16px 30px rgba(7, 28, 22, 0.14);
cursor: pointer;
text-align: center;
}
body.mh-business-theme .summary-filter-chip.active {
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
color: #0a2a22;
border-color: rgba(242, 196, 132, 0.58);
}
body.mh-business-theme .summary-filter-chip .label {
color: rgba(244, 239, 230, 0.78);
font-size: 13px;
font-weight: 900;
}
body.mh-business-theme .summary-filter-chip.active .label {
color: rgba(10, 42, 34, 0.78);
}
body.mh-business-theme .summary-filter-chip .count {
color: #fff7e6;
font-size: 32px;
line-height: 1;
font-weight: 900;
}
body.mh-business-theme .summary-filter-chip.active .count {
color: #b86b1f;
}
body.mh-business-theme .summary-filter-chip .meta {
color: #f2c484;
font-size: 11px;
font-weight: 800;
text-align: center;
}
body.mh-business-theme .summary-filter-chip.active .meta {
color: #7c5a20;
}
body.mh-business-theme .card {
grid-column: span 2;
min-height: 110px;
border-radius: 24px;
border: 1px solid rgba(217, 197, 168, 0.55);
background: linear-gradient(180deg, rgba(255,250,243,0.96) 0%, rgba(248,242,232,0.96) 100%);
padding: 18px 20px;
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
}
body.mh-business-theme .card.management {
grid-column: span 2;
}
body.mh-business-theme .card .k {
color: #5b6d63;
font-size: 12px;
font-weight: 900;
}
body.mh-business-theme .card .v {
margin-top: 8px;
color: #17392f;
font-size: 30px;
font-weight: 900;
}
body.mh-business-theme .card .n {
margin-top: 8px;
color: #7b6953;
font-size: 11px;
font-weight: 700;
}
body.mh-business-theme .panel {
border-radius: 28px;
border: 1px solid rgba(217, 197, 168, 0.55);
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
}
body.mh-business-theme .table-wrap {
width: 100%;
max-width: 100%;
border-radius: 28px;
overflow-x: hidden !important;
}
body.mh-business-theme .table-vat-note {
display: none !important;
}
body.mh-business-theme table {
width: 100% !important;
min-width: 0 !important;
table-layout: fixed;
background: rgba(255, 250, 243, 0.96);
}
body.mh-business-theme thead th {
background: #0f352b;
color: #fff5e6;
border-right: 1px solid rgba(242, 196, 132, 0.2);
}
body.mh-business-theme tbody td {
background: rgba(255, 250, 243, 0.96);
}
body.mh-business-theme .group-row td {
padding: 12px 14px 10px;
background: linear-gradient(180deg, rgba(255, 248, 238, 0.98) 0%, rgba(242, 222, 192, 0.78) 100%);
border-top: 1px solid rgba(214, 138, 58, 0.26);
border-bottom: 1px solid rgba(217, 197, 168, 0.54);
}
body.mh-business-theme .group-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 250, 243, 0.98);
border: 1px solid rgba(214, 138, 58, 0.3);
color: #17392f;
font-size: 12px;
font-weight: 900;
box-shadow: 0 8px 18px rgba(15, 58, 47, 0.08);
cursor: pointer;
}
body.mh-business-theme .group-chip .group-toggle {
margin-left: 4px;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(242, 196, 132, 0.18);
color: #b66e22;
font-size: 14px;
line-height: 1;
}
body.mh-business-theme .project-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: none;
color: #17392f;
font: inherit;
font-weight: 900;
text-align: left;
cursor: pointer;
}
body.mh-business-theme .project-link:hover {
color: #0f6a55;
}
@media (max-width: 1280px) {
body.mh-business-theme .cards-toolbar-metrics {
grid-template-columns: 1fr;
}
body.mh-business-theme .card {
grid-column: span 4;
}
}
@media (max-width: 880px) {
body.mh-business-theme .wrap {
padding: 12px 12px 20px;
}
body.mh-business-theme .cards {
grid-template-columns: 1fr;
}
body.mh-business-theme .card {
grid-column: auto;
}
body.mh-business-theme .cards-toolbar-search {
margin-left: 0;
max-width: none;
flex-basis: 100%;
}
}

View File

@@ -0,0 +1,498 @@
(function () {
window.__mhLedgerEnhancementLoaded = false;
if (typeof S === "undefined" || typeof E === "undefined" || typeof render !== "function") return;
window.__mhLedgerEnhancementLoaded = true;
if (!S.dashboard) S.dashboard = { year: "", section: "active" };
if (!S.collapsedGroups) S.collapsedGroups = {};
function bgToday() {
var now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function bgParseDate(value) {
var text = String(value || "").trim();
if (!text) return null;
var match = text.match(/(20\d{2})\D?(\d{1,2})\D?(\d{1,2})/);
if (match) {
var parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return isNaN(parsed.getTime()) ? null : parsed;
}
var fallback = new Date(text);
if (isNaN(fallback.getTime())) return null;
return new Date(fallback.getFullYear(), fallback.getMonth(), fallback.getDate());
}
function bgYearFromText(value) {
var match = String(value || "").trim().match(/(20\d{2})/);
return match ? match[1] : "";
}
function bgStartYear(row) {
return bgYearFromText(row && row.sDate);
}
function bgEndYear(row) {
return bgYearFromText(row && row.eDate);
}
function bgDisplayYear(row) {
var start = bgStartYear(row);
if (start) return start;
var contractMatch = String((row && row.cDate) || "").trim().match(/(20\d{2})/);
if (contractMatch) return contractMatch[1];
var nameMatch = String((row && row.name) || "").trim().match(/^(20\d{2})/);
if (nameMatch) return nameMatch[1];
return bgEndYear(row) || "미지정";
}
function bgCompletionYear(row) {
return bgEndYear(row) || bgDisplayYear(row);
}
function bgDateOrYearStart(row) {
var yearText = bgDisplayYear(row);
return bgParseDate(row && row.sDate) || bgParseDate(row && row.cDate) || (/^20\d{2}$/.test(yearText) ? new Date(Number(yearText), 0, 1) : null);
}
function bgDateOrYearEnd(row) {
var completionYear = bgCompletionYear(row);
return bgParseDate(row && row.eDate) || (/^20\d{2}$/.test(completionYear) ? new Date(Number(completionYear), 11, 31) : null);
}
function bgYearCutoff(year) {
var targetYear = Number(year || 0);
if (!targetYear) return null;
var today = bgToday();
if (targetYear < today.getFullYear()) return new Date(targetYear, 11, 31);
if (targetYear === today.getFullYear()) return today;
return null;
}
function bgYearStartDate(year) {
var targetYear = Number(year || 0);
return targetYear ? new Date(targetYear, 0, 1) : null;
}
function bgActiveInYear(row, year) {
var cutoff = bgYearCutoff(year);
var yearStart = bgYearStartDate(year);
var startDate = bgDateOrYearStart(row);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && yearStart && startDate)) return false;
if (startDate > cutoff) return false;
if (endDate && endDate < yearStart) return false;
return !(endDate && endDate <= cutoff);
}
function bgStartedInYear(row, year) {
var cutoff = bgYearCutoff(year);
var startDate = bgDateOrYearStart(row);
if (!(cutoff && startDate)) return false;
return startDate.getFullYear() === Number(year || 0) && startDate <= cutoff;
}
function bgCompletedInYear(row, year) {
var cutoff = bgYearCutoff(year);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && endDate)) return false;
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
}
function bgYearRange(row) {
var years = [];
var startYear = Number(bgDisplayYear(row) || 0);
var endYear = Number(bgCompletionYear(row) || 0);
if (startYear && endYear && endYear >= startYear) {
for (var year = startYear; year <= endYear; year += 1) years.push(String(year));
} else if (startYear) {
years.push(String(startYear));
}
return years;
}
function bgYears(rows) {
var currentYear = new Date().getFullYear();
var years = Array.from(new Set((Array.isArray(rows) ? rows : []).flatMap(bgYearRange).filter(function (year) {
return /^20\d{2}$/.test(year);
}))).sort(function (a, b) {
return Number(b) - Number(a);
});
years = years.filter(function (year) {
var numericYear = Number(year);
return numericYear >= 2018 && numericYear <= currentYear;
});
return years.length ? years : [String(currentYear)];
}
function bgEnsureYear(rows) {
var years = bgYears(rows);
if (!years.includes(S.dashboard.year)) S.dashboard.year = years[0];
return years;
}
function bgTotals(targetRows) {
return (Array.isArray(targetRows) ? targetRows : []).reduce(function (acc, row) {
acc.c += Number((row && row.cSup) || 0);
acc.col += Number((row && row.col) || 0);
acc.recv += Number((row && row.recv) || 0);
return acc;
}, { c: 0, col: 0, recv: 0 });
}
function isSupportServiceRow(row) {
var category = String((row && row.cat) || "").trim();
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
}
function isBaronProjectRow(row) {
var category = String((row && row.cat) || "").trim();
if (category.indexOf("바론") < 0) return false;
if (isSupportServiceRow(row)) return false;
return true;
}
function bgSummarize(rows, selectedYear) {
var items = Array.isArray(rows) ? rows : [];
var targetYear = selectedYear || bgEnsureYear(items)[0];
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
var managementRows = newProjectRows.filter(isSupportServiceRow);
return {
targetYear: targetYear,
activeRows: activeRows,
newProjectRows: newProjectRows,
completedRows: completedRows,
managementRows: managementRows,
managementTotals: bgTotals(managementRows)
};
}
function bgMatches(row) {
var section = S.dashboard.section || "active";
var selectedYear = S.dashboard.year || bgEnsureYear(S.all)[0];
if (section === "new") return bgStartedInYear(row, selectedYear);
if (section === "completed") return bgCompletedInYear(row, selectedYear);
return bgActiveInYear(row, selectedYear);
}
function normalizeStatusLabel(status) {
var value = String(status || "").trim();
if (!value) return "-";
if (value.indexOf("진행") >= 0) return "과업 진행중";
return value;
}
function formatSplitPercent(split) {
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
return "분담율 " + numeric.toFixed(2) + "%";
}
function projectYear(row) {
var start = String((row && row.sDate) || "").trim();
var startMatch = start.match(/(20\d{2})/);
if (startMatch) return startMatch[1];
var name = String((row && row.name) || "").trim();
var nameMatch = name.match(/^(20\d{2})/);
if (nameMatch) return nameMatch[1];
var end = String((row && row.eDate) || "").trim();
var endMatch = end.match(/(20\d{2})/);
if (endMatch) return endMatch[1];
return "미지정";
}
function groupSortRank(row) {
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
var startYear = Number(projectYear(row) || 0);
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
if (!startYear) return 9998;
return startYear;
}
function tableGroupLabel(row) {
var startYear = projectYear(row);
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
return "미지정";
}
function renderLedgerTable() {
var table = document.querySelector(".panel table");
if (!table || !E.tbody) return;
var thead = table.querySelector("thead");
if (thead) {
thead.innerHTML = '<tr>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="cat" data-label="구분"><span class="th-title">구분</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCatMenu" class="th-menu" data-filter="cat"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="code" data-label="사업코드"><span class="th-title">사업코드</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCodeMenu" class="th-menu" data-filter="code"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="name" data-label="사업명(계약명)"><span class="th-title">사업명(계약명)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterNameMenu" class="th-menu" data-filter="name"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="client" data-label="발주처(계약처)"><span class="th-title">발주처(계약처)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterClientMenu" class="th-menu" data-filter="client"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="order" data-label="발주방법"><span class="th-title">발주방법</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOrderMenu" class="th-menu" data-filter="order"></div></div></th>'
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="status" data-label="진행상태"><span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterStatusMenu" class="th-menu" data-filter="status"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="amount" data-label="계약금"><span class="th-title">계약금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterAmountMenu" class="th-menu" data-filter="amount"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="outsource" data-label="외주비"><span class="th-title">외주비</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="receivable" data-label="미수금"><span class="th-title">미수금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterReceivableMenu" class="th-menu" data-filter="receivable"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="collected" data-label="수금액"><span class="th-title">수금액</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div></div></th>'
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
+ "</tr>";
}
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
var ar = groupSortRank(a);
var br = groupSortRank(b);
if (ar !== br) return ar - br;
return Number(b.recv || 0) - Number(a.recv || 0);
});
S.viewRows = rows;
var lastGroupLabel = "";
E.tbody.innerHTML = rows.map(function (r) {
var groupLabel = tableGroupLabel(r);
var isCollapsed = !!S.collapsedGroups[groupLabel];
var groupRow = "";
if (groupLabel !== lastGroupLabel) {
groupRow = '<tr class="group-row"><td colspan="11"><button type="button" class="group-chip" data-group-label="' + escAttr(groupLabel) + '"><span>' + esc(groupLabel) + '</span><span class="group-toggle" aria-hidden="true">' + (isCollapsed ? "" : "") + "</span></button></td></tr>";
lastGroupLabel = groupLabel;
}
if (isCollapsed) return groupRow;
return groupRow + '<tr class="' + (isSettledRow(r) ? 'settled' : '') + '">'
+ '<td><div class="badge ' + esc(String(r.cat || "").indexOf("바론") >= 0 ? 'badge-baron' : 'badge-family') + '">' + esc(r.cat || "-") + '</div></td>'
+ '<td><div class="subline" style="margin-top:0;font-size:12px;color:#66756d">' + esc(r.code || "-") + '</div></td>'
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
+ '<td class="num"><strong>' + esc(won(r.col || 0)) + '</strong></td>'
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
+ '</tr>';
}).join("");
}
function renderCollectionBoard(r) {
var payments = Array.isArray(r.payments) && r.payments.length ? r.payments : [{
pay: r.pay || "-",
issueDate: r.issueDate || "",
collectDate: r.collectDateSummary || r.colDate || "",
collected: r.col || 0,
receivable: r.recv || Math.max(0, Number(r.sTot || 0) - Number(r.col || 0)),
note: r.note || "",
status: r.status || ""
}];
return '<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">기성 차수별 세금계산서 발행 및 수금 내역</div></div></div><div class="ledger-pill">총 수금 ' + esc(won(r.col || 0)) + '</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>기성 차수</th><th>세금계산서 발행일</th><th>수금일</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th>비고</th></tr></thead><tbody>'
+ payments.map(function (payment, index) {
var noteParts = [];
if (payment.status) noteParts.push(payment.status);
if (payment.note) noteParts.push(payment.note);
return '<tr><td><span class="ledger-main">' + esc((index + 1) + "차") + '</span><span class="ledger-muted">' + esc(payment.pay || "-") + '</span></td><td><span class="ledger-main">' + esc(payment.issueDate ? d(payment.issueDate) : "-") + '</span></td><td><span class="ledger-main">' + esc(payment.collectDate ? d(payment.collectDate) : "-") + '</span></td><td class="ledger-amount">' + esc(won(payment.collected || 0)) + '</td><td class="ledger-amount" style="color:#a94832">' + esc(won(payment.receivable || 0)) + '</td><td><span class="ledger-note">' + esc(noteParts.join(" / ") || "-") + '</span></td></tr>';
}).join("")
+ "</tbody></table></div></div>";
}
function renderContactCard(label, name, company, department, phone, email) {
var hasValue = [name, company, department, phone, email].some(function (value) {
return String(value || "").trim() !== "";
});
if (!hasValue) {
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="summary-note">등록된 담당자 정보가 없습니다.</div></div>';
}
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="project-meta-grid">'
+ '<div class="kv"><div class="kvk">이름</div><div class="kvv">' + esc(name || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">소속</div><div class="kvv">' + esc(company || "-") + '</div><div class="summary-note">' + esc(department || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">연락처</div><div class="kvv">' + esc(phone || "-") + '</div></div>'
+ '<div class="kv"><div class="kvk">이메일</div><div class="kvv">' + esc(email || "-") + '</div></div>'
+ "</div></div>";
}
function renderProjectInline(r) {
var payments = Array.isArray(r.payments) ? r.payments : [];
var latestCollect = d(r.collectDateSummary || r.colDate);
var hasOutsource = (Array.isArray(r.outsourceItems) && r.outsourceItems.length > 0) || Number(r.outsourceCost || 0) > 0 || Number(r.outsourcePaid || 0) > 0 || Number(r.outsourceRemaining || 0) > 0;
var clientDisplay = typeof normalizeClientDisplay === "function" ? normalizeClientDisplay(r.client) : (String(r.client || "").trim() || "-");
var splitDisplay = typeof formatSplitDisplay === "function" ? formatSplitDisplay(r.split) : formatSplitPercent(r.split).replace("분담율 ", "");
var summaryCards = [
'<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">' + esc(won(r.cSup || 0)) + '</div><div class="summary-note"></div></div>',
'<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">' + esc(won(r.col || 0)) + '</div><div class="summary-note">' + esc(latestCollect === "-" ? "수금일 없음" : "최종 수금일 " + latestCollect) + '</div></div>',
'<div class="summary-card"><div class="summary-label">수금률</div><div class="summary-value">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</div><div class="summary-note">' + esc(payments.length ? "기성 " + payments.length + "차까지 반영" : "차수 정보 없음") + '</div></div>',
'<div class="summary-card receivable"><div class="summary-label">미수금액</div><div class="summary-value">' + esc(won(r.recv || 0)) + '</div><div class="summary-note">잔여 수금 필요 금액</div></div>'
].join("");
var boards = [
hasOutsource && typeof renderOutsourceBoard === "function" ? renderOutsourceBoard(r) : "",
renderCollectionBoard(r)
].filter(Boolean).join("");
return '<div class="inline-panel"><div class="project-head project-head-grid"><div class="project-head-main"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">' + esc(r.corp || "-") + '</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">' + esc(clientDisplay) + '</div><div class="summary-note">' + esc(splitDisplay ? "분담율 " + splitDisplay : "분담율 -") + '</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">' + esc(r.order || "-") + '</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">' + esc(r.pm || "-") + '</div></div></div></div><div class="inline-card"><div class="summary-grid">' + summaryCards + '</div><div class="project-progress progress"><div class="bar" style="width:' + esc(String(Math.max(0, Math.min(100, Number(r.rate || 0))))) + '%"></div></div></div></div><div class="project-contact-stack">' + renderContactCard("계약 / 청구 담당자", r.cmNm, r.cmCo, r.cmDp, r.cmPh, r.cmEm) + renderContactCard("부서 담당자", r.dmNm, r.dmCo, r.dmDp, r.dmPh, r.dmEm) + '</div></div><div class="ledger-stack">' + boards + '</div></div>';
}
function openProjectWindow(r) {
var popupKey = typeof rowKey === "function"
? rowKey(r).replace(/[^0-9a-zA-Z]/g, "_")
: String((r.code || "project") + "_" + (r.name || "")).replace(/[^0-9a-zA-Z_]/g, "_");
var popup = window.open("", "business_project_" + popupKey, "width=1600,height=980,resizable=yes,scrollbars=yes");
if (!popup) return;
var styleText = Array.from(document.querySelectorAll("style")).map(function (el) {
return el.textContent || "";
}).join("\n");
var detailHtml = renderProjectInline(r);
var pageHtml = '<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>'
+ esc(r.name || "사업 상세")
+ '</title><link rel="stylesheet" href="/design-tokens.css?v=20260401-01"><link rel="stylesheet" href="/design-patterns.css?v=20260401-01"><style>' + styleText
+ 'body{margin:0;background:#f1eadf;color:#10251d;font-family:"Pretendard","Noto Sans KR","Malgun Gothic",sans-serif;}'
+ '.popup-wrap{max-width:1680px;margin:0 auto;padding:20px;}'
+ '@media (max-width: 1180px){.project-head-grid{grid-template-columns:1fr;}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.project-meta-grid{grid-template-columns:1fr;}}'
+ '@media (max-width: 760px){.popup-wrap{padding:14px;}.summary-grid{grid-template-columns:1fr;}.ledger-head{flex-direction:column;align-items:flex-start;}.ledger-pill{white-space:normal;}.ledger-table-wrap{padding:0 10px 12px;overflow-x:auto;}}'
+ '</style></head><body><div class="popup-wrap"><div class="popup-head"><div class="popup-title">' + esc(r.name || "-") + '</div><div class="popup-sub">사업코드 ' + esc(r.code || "-") + ' · 계약법인 ' + esc(r.corp || "-") + '</div></div>' + detailHtml + "</div></body></html>";
popup.document.open();
popup.document.write(pageHtml);
popup.document.close();
popup.focus();
}
async function tryLoadDbDefaultBusinessLedger() {
if (window.__mhBusinessDefaultLoaded) return;
window.__mhBusinessDefaultLoaded = true;
try {
var response = await fetch("/api/integration/business-ledger-default");
if (!response.ok) throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
var fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
var buffer = await response.arrayBuffer();
if (!buffer || !buffer.byteLength) throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
await loadLedgerFile(buffer, fileName);
} catch (error) {
console.error(error);
}
}
function applyDashboardChrome() {
if (!E.cards) return;
document.body.setAttribute("data-mh-ledger-enhanced", "true");
var wrap = document.querySelector(".wrap");
var panel = document.querySelector(".panel");
if (wrap && panel) {
var shell = wrap.querySelector(".business-shell");
if (!shell) {
shell = document.createElement("div");
shell.className = "business-shell";
wrap.insertBefore(shell, E.cards);
}
if (E.cards.parentNode !== shell) shell.appendChild(E.cards);
if (panel.parentNode !== shell) shell.appendChild(panel);
}
var years = bgEnsureYear(S.all);
var summary = bgSummarize(S.all, S.dashboard.year);
var rows = Array.isArray(S.rows) ? S.rows : [];
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
var totals = bgTotals(visibleBaronProjectRows);
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
var toolbarHtml = '<div class="cards-toolbar">'
+ '<div class="cards-toolbar-row">'
+ years.map(function (year) {
return '<button type="button" class="summary-year-chip ' + (S.dashboard.year === year ? "active" : "") + '" data-dashboard-year="' + escAttr(year) + '">' + esc(year) + "</button>";
}).join("")
+ '<div class="cards-toolbar-search"></div>'
+ "</div>"
+ '<div class="cards-toolbar-metrics">'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
+ "</div></div>";
var cards = [
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
{ label: "계약금", value: won(totals.c), note: "" },
{ label: "수금액", value: won(totals.col), note: "" },
{ label: "미수금", value: won(totals.recv), note: "" },
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
];
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
return '<div class="card ' + esc(card.className || "") + '"><div class="k">' + esc(card.label) + '</div><div class="v">' + esc(card.value) + '</div><div class="n">' + esc(card.note || "") + "</div></div>";
}).join("");
var searchWrap = E.cards.querySelector(".cards-toolbar-search");
if (searchWrap && E.search) {
searchWrap.appendChild(E.search);
E.search.placeholder = "전체 검색";
}
}
var originalRender = render;
render = function () {
originalRender();
applyDashboardChrome();
renderLedgerTable();
};
filter = function () {
bgEnsureYear(S.all);
var q = String(E.search.value || "").trim().toLowerCase();
var searched = !q ? S.all.slice() : S.all.filter(function (r) {
return [r.code, r.name, r.client, r.pm, r.status, r.cat, r.corp, r.pay, (r.payments || []).map(function (p) { return p.pay; }).join(" "), r.periodText].join(" ").toLowerCase().includes(q);
});
S.rows = searched.filter(function (r) {
return bgMatches(r) && matchesColumnFilters(r);
});
render();
};
if (E.cards && !E.cards.dataset.dashboardBound) {
E.cards.dataset.dashboardBound = "true";
E.cards.addEventListener("click", function (event) {
var yearButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-year]") : null;
if (yearButton) {
S.dashboard.year = yearButton.getAttribute("data-dashboard-year") || S.dashboard.year;
filter();
return;
}
var sectionButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-section]") : null;
if (sectionButton) {
S.dashboard.section = sectionButton.getAttribute("data-dashboard-section") || "active";
filter();
}
});
}
if (E.tbody && !E.tbody.dataset.projectBound) {
E.tbody.dataset.projectBound = "true";
E.tbody.addEventListener("click", function (event) {
var groupButton = event.target && event.target.closest ? event.target.closest("[data-group-label]") : null;
if (groupButton) {
var label = groupButton.getAttribute("data-group-label") || "";
if (label) {
S.collapsedGroups[label] = !S.collapsedGroups[label];
render();
}
return;
}
var trigger = event.target && event.target.closest ? event.target.closest(".project-link") : null;
if (!trigger) return;
var key = trigger.getAttribute("data-project-key") || "";
var rows = Array.isArray(S.viewRows) ? S.viewRows : [];
var row = rows.find(function (item) {
return (String(item.code || "") + "|" + String(item.name || "")) === key;
});
if (row) openProjectWindow(row);
});
}
setTimeout(function () {
try {
filter();
if (typeof loadLedgerFile === "function") {
tryLoadDbDefaultBusinessLedger();
}
} catch (error) {
console.error(error);
}
}, 0);
window.addEventListener("message", function (event) {
var data = event.data || {};
if (data.source !== "total-upload" || data.type !== "business") return;
setTimeout(function () {
try {
applyDashboardChrome();
renderLedgerTable();
} catch (error) {
console.error(error);
}
}, 50);
});
})();

File diff suppressed because one or more lines are too long