feat(tkeg, gateway): tkeg 대시보드 리디자인 + gateway 구매관리 네이밍 수정

- tkeg: MUI 기반 대시보드 전면 리디자인 (theme, 메트릭 카드, 프로젝트 Autocomplete, Quick Action, 관리자 섹션)
- gateway: tkpurchase 카드 "소모품 관리" → "구매관리"로 복원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-16 15:36:02 +09:00
parent 9b586da720
commit 2699242d1f
12 changed files with 1891 additions and 1 deletions

View File

@@ -492,7 +492,7 @@
{ id: 'factory', name: '공장관리', desc: '작업장 현황, TBM, 설비관리', icon: '\uD83C\uDFED', subdomain: 'tkfb', path: '/pages/dashboard.html', pageKey: 'dashboard', color: '#1a56db' },
{ id: 'report_sys', name: '신고', desc: '사건·사고 신고 접수', icon: '\uD83D\uDEA8', subdomain: 'tkreport', accessKey: 'system2', color: '#dc2626' },
{ id: 'quality', name: '부적합관리', desc: '부적합 이슈 추적·처리', icon: '\uD83D\uDCCA', subdomain: 'tkqc', accessKey: 'system3', color: '#059669' },
{ id: 'purchase', name: '소모품 관리', desc: '소모품 신청·구매, 분석', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
{ id: 'purchase', name: '구매관리', desc: '자재 구매, 일용직 관리', icon: '\uD83D\uDED2', subdomain: 'tkpurchase', color: '#d97706' },
{ id: 'safety', name: '안전관리', desc: '안전 점검, 방문 관리', icon: '\uD83E\uDDBA', subdomain: 'tksafety', color: '#7c3aed' },
{ id: 'support', name: '행정지원', desc: '전사 행정 업무 지원', icon: '\uD83C\uDFE2', subdomain: 'tksupport', color: '#0284c7' },
{ id: 'admin', name: '통합관리', desc: '사용자·권한 관리', icon: '\u2699\uFE0F', subdomain: 'tkuser', minRole: 'admin', color: '#0891b2' }

224
tkeg/web/src/App.jsx Normal file
View File

@@ -0,0 +1,224 @@
import React, { useState, useEffect } from 'react';
import DashboardPage from './pages/dashboard/DashboardPage';
import { UserMenu, ErrorBoundary } from './components/common';
import NewMaterialsPage from './pages/NewMaterialsPage';
import BOMManagementPage from './pages/BOMManagementPage';
import UnifiedBOMPage from './pages/UnifiedBOMPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import PurchaseBatchPage from './pages/PurchaseBatchPage';
import PurchaseRequestPage from './pages/PurchaseRequestPage';
import SystemLogsPage from './pages/SystemLogsPage';
import LogMonitoringPage from './pages/LogMonitoringPage';
import InactiveProjectsPage from './pages/InactiveProjectsPage';
import api from './api';
import { config } from './config';
import './App.css';
function getSSOToken() {
const match = document.cookie.match(/sso_token=([^;]*)/);
return match ? match[1] : null;
}
function parseJwt(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
} catch { return null; }
}
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState(null);
const [currentPage, setCurrentPage] = useState('dashboard');
const [pageParams, setPageParams] = useState({});
const [projects, setProjects] = useState([]);
const [editingProject, setEditingProject] = useState(null);
const [editedProjectName, setEditedProjectName] = useState('');
const [showCreateProject, setShowCreateProject] = useState(false);
const [newProjectCode, setNewProjectCode] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [newClientName, setNewClientName] = useState('');
const [inactiveProjects, setInactiveProjects] = useState(() => {
try {
const saved = localStorage.getItem('tkeg_inactiveProjects');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch { return new Set(); }
});
useEffect(() => {
try {
localStorage.setItem('tkeg_inactiveProjects', JSON.stringify(Array.from(inactiveProjects)));
} catch {}
}, [inactiveProjects]);
const loadProjects = async () => {
try {
const response = await api.get('/jobs/');
if (response.data?.jobs) {
setProjects(response.data.jobs);
}
} catch (error) {
console.error('프로젝트 목록 로드 실패:', error);
}
};
const handleActivateProject = (project) => {
const projectId = project.job_no || project.id;
setInactiveProjects(prev => {
const newSet = new Set(prev);
newSet.delete(projectId);
return newSet;
});
};
// SSO 인증 확인
useEffect(() => {
const token = getSSOToken();
if (!token) {
window.location.href = config.ssoLoginUrl(window.location.href);
return;
}
const payload = parseJwt(token);
if (!payload) {
window.location.href = config.ssoLoginUrl();
return;
}
setUser({
user_id: payload.user_id,
username: payload.sub || payload.username,
name: payload.name || payload.username || payload.sub,
role: payload.role || 'user',
department: payload.department,
});
setIsAuthenticated(true);
setIsLoading(false);
loadProjects();
}, []);
const navigateToPage = (page, params = {}) => {
setCurrentPage(page);
setPageParams(params);
};
const handleLogout = () => {
document.cookie = `sso_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/${config.cookieDomain}`;
window.location.href = config.ssoLoginUrl();
};
const renderCurrentPage = () => {
switch (currentPage) {
case 'dashboard':
return (
<DashboardPage
user={user}
projects={projects}
pendingSignupCount={0}
navigateToPage={navigateToPage}
loadProjects={loadProjects}
createProject={() => {}}
updateProjectName={() => {}}
deleteProject={() => {}}
editingProject={editingProject}
setEditingProject={setEditingProject}
editedProjectName={editedProjectName}
setEditedProjectName={setEditedProjectName}
showCreateProject={showCreateProject}
setShowCreateProject={setShowCreateProject}
newProjectCode={newProjectCode}
setNewProjectCode={setNewProjectCode}
newProjectName={newProjectName}
setNewProjectName={setNewProjectName}
newClientName={newClientName}
setNewClientName={setNewClientName}
inactiveProjects={inactiveProjects}
setInactiveProjects={setInactiveProjects}
/>
);
case 'unified-bom':
return <UnifiedBOMPage onNavigate={navigateToPage} selectedProject={pageParams.selectedProject} user={user} />;
case 'materials':
return (
<BOMManagementPage
onNavigate={navigateToPage}
user={user}
selectedProject={pageParams.selectedProject}
fileId={pageParams.file_id}
jobNo={pageParams.jobNo}
bomName={pageParams.bomName}
revision={pageParams.revision}
filename={pageParams.filename}
/>
);
case 'purchase-batch':
return <PurchaseBatchPage onNavigate={navigateToPage} user={user} selectedProject={pageParams.selectedProject} />;
case 'purchase-request':
return <PurchaseRequestPage onNavigate={navigateToPage} user={user} selectedProject={pageParams.selectedProject} />;
case 'system-settings':
return <SystemSettingsPage onNavigate={navigateToPage} user={user} />;
case 'system-logs':
return <SystemLogsPage onNavigate={navigateToPage} user={user} />;
case 'log-monitoring':
return <LogMonitoringPage onNavigate={navigateToPage} user={user} />;
case 'inactive-projects':
return (
<InactiveProjectsPage
onNavigate={navigateToPage}
user={user}
projects={projects}
inactiveProjects={inactiveProjects}
onActivateProject={handleActivateProject}
onDeleteProject={() => {}}
/>
);
default:
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f7fafc' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '16px', color: '#718096' }}> 없는 페이지입니다.</div>
<button onClick={() => navigateToPage('dashboard')} style={{ background: '#4299e1', color: 'white', border: 'none', borderRadius: '6px', padding: '12px 24px', cursor: 'pointer', marginTop: '16px' }}>
대시보드로 돌아가기
</button>
</div>
</div>
);
}
};
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f7fafc' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 ...</div>
</div>
</div>
);
}
if (!isAuthenticated) return null;
return (
<ErrorBoundary errorContext={{ user, currentPage, pageParams }}>
<div style={{ minHeight: '100vh', background: '#f7fafc' }}>
<div style={{
background: 'white', borderBottom: '1px solid #e2e8f0',
padding: '16px 32px', display: 'flex', justifyContent: 'space-between', alignItems: 'center'
}}>
<div>
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
TK-EG BOM Management System
</h1>
<p style={{ color: '#718096', fontSize: '14px', margin: '4px 0 0 0' }}>
{user?.name || user?.username} 환영합니다
</p>
</div>
<UserMenu user={user} onNavigate={navigateToPage} onLogout={handleLogout} />
</div>
{renderCurrentPage()}
</div>
</ErrorBoundary>
);
}
export default App;

15
tkeg/web/src/main.jsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ThemeProvider, CssBaseline } from '@mui/material'
import theme from './theme'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</React.StrictMode>,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import React from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
import PeopleIcon from '@mui/icons-material/People';
import SettingsIcon from '@mui/icons-material/Settings';
import ArticleIcon from '@mui/icons-material/Article';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import ArchiveIcon from '@mui/icons-material/Archive';
const ADMIN_ITEMS = [
{ key: 'users', page: 'user-management', label: '사용자 관리', icon: PeopleIcon },
{ key: 'settings', page: 'system-settings', label: '시스템 설정', icon: SettingsIcon },
{ key: 'logs', page: 'system-logs', label: '시스템 로그', icon: ArticleIcon },
{ key: 'monitor', page: 'log-monitoring', label: '모니터링', icon: MonitorHeartIcon },
{ key: 'inactive', page: 'inactive-projects', label: '비활성 프로젝트', icon: ArchiveIcon },
];
export default function AdminSection({ user, navigateToPage }) {
if (user?.role !== 'admin') return null;
return (
<>
<Divider sx={{ my: 1 }} />
<Box>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1.5, fontWeight: 600 }}>
관리자 전용
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)', md: 'repeat(5, 1fr)' }, gap: 1.5 }}>
{ADMIN_ITEMS.map(({ key, page, label, icon: Icon }) => (
<Card key={key} variant="outlined" sx={{ transition: 'box-shadow 0.2s', '&:hover': { boxShadow: '0 2px 8px rgba(0,0,0,0.08)' } }}>
<CardActionArea
onClick={() => navigateToPage(page)}
sx={{ p: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75 }}
>
<Icon sx={{ fontSize: 24, color: 'text.secondary' }} />
<Typography variant="body2" sx={{ fontWeight: 500, textAlign: 'center' }}>
{label}
</Typography>
</CardActionArea>
</Card>
))}
</Box>
</Box>
</>
);
}

View File

@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import api from '../../api';
export default function CreateProjectDialog({ open, onClose, onCreated }) {
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [client, setClient] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (!code.trim() || !name.trim()) return;
setSubmitting(true);
try {
await api.post('/dashboard/projects', null, {
params: {
official_project_code: code.trim(),
project_name: name.trim(),
client_name: client.trim() || undefined,
},
});
setCode('');
setName('');
setClient('');
onCreated?.();
onClose();
} catch (err) {
alert(err.response?.data?.detail || '프로젝트 생성 실패');
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>프로젝트 생성</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
label="프로젝트 코드"
placeholder="예: J24-001"
value={code}
onChange={e => setCode(e.target.value)}
size="small"
required
fullWidth
/>
<TextField
label="프로젝트명"
placeholder="예: 울산 SK에너지 확장"
value={name}
onChange={e => setName(e.target.value)}
size="small"
required
fullWidth
/>
<TextField
label="고객사 (선택)"
placeholder="예: Samsung Engineering"
value={client}
onChange={e => setClient(e.target.value)}
size="small"
fullWidth
/>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} color="inherit">취소</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={submitting || !code.trim() || !name.trim()}
>
생성
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import ProjectSelectorBar from './ProjectSelectorBar';
import MetricCards from './MetricCards';
import QuickActionCards from './QuickActionCards';
import AdminSection from './AdminSection';
import CreateProjectDialog from './CreateProjectDialog';
import useDashboardData from './useDashboardData';
export default function DashboardPage({
user,
projects,
navigateToPage,
loadProjects,
inactiveProjects,
setInactiveProjects,
// legacy props (kept for App.jsx compat)
showCreateProject,
setShowCreateProject,
...rest
}) {
const [selectedProject, setSelectedProject] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
const { loading, getMetrics } = useDashboardData(user, projects, selectedProject);
const roleLabelMap = {
admin: '시스템 관리자',
manager: '프로젝트 매니저',
designer: '설계 담당자',
purchaser: '구매 담당자',
};
const roleLabel = roleLabelMap[user?.role] || '사용자';
return (
<Box sx={{ maxWidth: 1100, mx: 'auto', p: { xs: 2, sm: 4 } }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ color: '#111827', mb: 0.5 }}>Dashboard</Typography>
<Typography variant="body1" color="text.secondary">
{user?.name || user?.username} 환영합니다 {roleLabel}
</Typography>
</Box>
{/* Project selector */}
<Box sx={{ mb: 3 }}>
<ProjectSelectorBar
projects={projects}
selectedProject={selectedProject}
onSelectProject={setSelectedProject}
onCreateProject={() => setDialogOpen(true)}
inactiveProjects={inactiveProjects}
/>
</Box>
{/* Metric cards */}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : (
<Box sx={{ mb: 3 }}>
<MetricCards metrics={getMetrics()} />
</Box>
)}
{/* Quick action cards */}
<Box sx={{ mb: 3 }}>
<QuickActionCards selectedProject={selectedProject} navigateToPage={navigateToPage} />
</Box>
{/* Admin section */}
<AdminSection user={user} navigateToPage={navigateToPage} />
{/* Create project dialog */}
<CreateProjectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onCreated={loadProjects}
/>
</Box>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Typography from '@mui/material/Typography';
import FolderIcon from '@mui/icons-material/Folder';
import DescriptionIcon from '@mui/icons-material/Description';
import Inventory2Icon from '@mui/icons-material/Inventory2';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
const ICONS = {
projects: FolderIcon,
bom: DescriptionIcon,
materials: Inventory2Icon,
purchase: ShoppingCartIcon,
};
export default function MetricCards({ metrics }) {
const entries = Object.entries(metrics);
return (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 2 }}>
{entries.map(([key, { label, value, color }]) => {
const Icon = ICONS[key] || FolderIcon;
return (
<Card
key={key}
sx={{
p: 2.5,
display: 'flex',
alignItems: 'center',
gap: 2,
borderLeft: `4px solid ${color}`,
transition: 'box-shadow 0.2s',
'&:hover': { boxShadow: '0 4px 12px rgba(0,0,0,0.1)' },
}}
>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 1.5,
bgcolor: `${color}14`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon sx={{ color, fontSize: 22 }} />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#111827', lineHeight: 1.2 }}>
{value}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.25 }}>
{label}
</Typography>
</Box>
</Card>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import Box from '@mui/material/Box';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import AddIcon from '@mui/icons-material/Add';
export default function ProjectSelectorBar({
projects,
selectedProject,
onSelectProject,
onCreateProject,
inactiveProjects,
}) {
const activeProjects = projects.filter(p => {
const id = p.job_no || p.official_project_code || p.id;
return !inactiveProjects.has(id);
});
return (
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
<Autocomplete
size="small"
sx={{ flex: 1, maxWidth: 500 }}
options={activeProjects}
value={selectedProject}
onChange={(_, v) => onSelectProject(v)}
getOptionLabel={(opt) => {
const name = opt.job_name || opt.project_name || '';
const code = opt.job_no || opt.official_project_code || '';
return name ? `${name} (${code})` : code;
}}
isOptionEqualToValue={(opt, val) =>
(opt.job_no || opt.id) === (val.job_no || val.id)
}
renderInput={(params) => (
<TextField {...params} placeholder="프로젝트 선택..." />
)}
noOptionsText="프로젝트 없음"
/>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={onCreateProject}
sx={{ whiteSpace: 'nowrap' }}
>
프로젝트 생성
</Button>
</Box>
);
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import DescriptionIcon from '@mui/icons-material/Description';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
const ACTIONS = [
{
key: 'bom',
page: 'unified-bom',
title: 'BOM 관리',
desc: 'BOM 파일 업로드, 리비전 관리, 자재 분류를 통합 관리합니다.',
icon: DescriptionIcon,
color: '#2563eb',
tags: ['Upload', 'Revision', 'Materials'],
},
{
key: 'purchase',
page: 'purchase-request',
title: '구매요청 관리',
desc: '구매요청을 관리하고 Excel로 내보내기합니다.',
icon: ShoppingCartIcon,
color: '#16a34a',
tags: ['Request', 'Excel Export'],
},
];
export default function QuickActionCards({ selectedProject, navigateToPage }) {
if (!selectedProject) return null;
return (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
{ACTIONS.map(({ key, page, title, desc, icon: Icon, color, tags }) => (
<Card key={key} sx={{ transition: 'box-shadow 0.2s', '&:hover': { boxShadow: '0 4px 16px rgba(0,0,0,0.12)' } }}>
<CardActionArea
onClick={() => navigateToPage(page, { selectedProject })}
sx={{ p: 3, display: 'flex', flexDirection: 'row', alignItems: 'flex-start', gap: 2 }}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: 2,
bgcolor: `${color}14`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon sx={{ color, fontSize: 28 }} />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" sx={{ mb: 0.5 }}>{title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{desc}
</Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
{tags.map(t => (
<Chip key={t} label={t} size="small" sx={{ bgcolor: `${color}14`, color, fontWeight: 500, fontSize: '0.75rem' }} />
))}
</Box>
</Box>
</CardActionArea>
</Card>
))}
</Box>
);
}

View File

@@ -0,0 +1,84 @@
import { useState, useEffect, useCallback } from 'react';
import api, { fetchMaterialsSummary } from '../../api';
export default function useDashboardData(user, projects, selectedProject) {
const [stats, setStats] = useState(null);
const [projectStats, setProjectStats] = useState(null);
const [loading, setLoading] = useState(true);
// Always fetch system-wide stats
useEffect(() => {
let cancelled = false;
setLoading(true);
api.get('/dashboard/stats')
.then(res => {
if (!cancelled && res.data?.success) {
setStats(res.data.stats);
}
})
.catch(err => console.error('Dashboard stats error:', err))
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [user]);
// Fetch project-specific data when a project is selected
useEffect(() => {
if (!selectedProject) {
setProjectStats(null);
return;
}
let cancelled = false;
const jobNo = selectedProject.job_no || selectedProject.official_project_code;
Promise.all([
fetchMaterialsSummary({ project_id: jobNo }).catch(() => ({ data: {} })),
api.get('/purchase-request/list', { params: { job_no: jobNo } }).catch(() => ({ data: {} })),
api.get('/files', { params: { job_no: jobNo } }).catch(() => ({ data: {} })),
]).then(([materialsRes, purchaseRes, filesRes]) => {
if (cancelled) return;
const materials = materialsRes.data;
const purchases = purchaseRes.data;
const files = filesRes.data;
setProjectStats({
bomCount: files.files?.length || files.total || 0,
totalMaterials: materials.total_count || materials.total || 0,
purchaseRequests: purchases.total || (Array.isArray(purchases.requests) ? purchases.requests.length : 0),
});
});
return () => { cancelled = true; };
}, [selectedProject]);
// Build metric values
const getMetrics = useCallback(() => {
if (selectedProject && projectStats) {
return {
projects: { label: '프로젝트', value: 1, color: '#2563eb' },
bom: { label: 'BOM 파일', value: projectStats.bomCount, color: '#16a34a' },
materials: { label: '총 자재', value: projectStats.totalMaterials, color: '#9333ea' },
purchase: { label: '구매요청', value: projectStats.purchaseRequests, color: '#d97706' },
};
}
// System-wide fallback
const metrics = stats?.metrics || [];
const findMetric = (label) => {
const m = metrics.find(m => m.label.includes(label));
return m ? m.value : 0;
};
return {
projects: { label: '전체 프로젝트', value: findMetric('프로젝트') || projects.length, color: '#2563eb' },
bom: { label: 'BOM 파일', value: findMetric('업로드') || '-', color: '#16a34a' },
materials: { label: '전체 자재', value: findMetric('자재') || '-', color: '#9333ea' },
purchase: { label: '구매요청', value: findMetric('구매') || '-', color: '#d97706' },
};
}, [stats, projectStats, selectedProject, projects]);
return { stats, projectStats, loading, getMetrics };
}

42
tkeg/web/src/theme.js Normal file
View File

@@ -0,0 +1,42 @@
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: { main: '#2563eb' },
success: { main: '#16a34a' },
warning: { main: '#d97706' },
background: {
default: '#f9fafb',
paper: '#ffffff',
},
divider: '#e5e7eb',
text: {
primary: '#111827',
secondary: '#6b7280',
},
},
shape: { borderRadius: 8 },
typography: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
h4: { fontWeight: 700 },
h5: { fontWeight: 700 },
h6: { fontWeight: 600 },
},
components: {
MuiCard: {
styleOverrides: {
root: {
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e5e7eb',
},
},
},
MuiButton: {
styleOverrides: {
root: { textTransform: 'none', fontWeight: 600 },
},
},
},
});
export default theme;