feat: unify 8081 dashboard design system and views
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user