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:
@@ -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
224
tkeg/web/src/App.jsx
Normal 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
15
tkeg/web/src/main.jsx
Normal 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>,
|
||||
)
|
||||
1119
tkeg/web/src/pages/DashboardPage.old.jsx
Normal file
1119
tkeg/web/src/pages/DashboardPage.old.jsx
Normal file
File diff suppressed because it is too large
Load Diff
49
tkeg/web/src/pages/dashboard/AdminSection.jsx
Normal file
49
tkeg/web/src/pages/dashboard/AdminSection.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
tkeg/web/src/pages/dashboard/CreateProjectDialog.jsx
Normal file
85
tkeg/web/src/pages/dashboard/CreateProjectDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
tkeg/web/src/pages/dashboard/DashboardPage.jsx
Normal file
84
tkeg/web/src/pages/dashboard/DashboardPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
tkeg/web/src/pages/dashboard/MetricCards.jsx
Normal file
64
tkeg/web/src/pages/dashboard/MetricCards.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
tkeg/web/src/pages/dashboard/ProjectSelectorBar.jsx
Normal file
52
tkeg/web/src/pages/dashboard/ProjectSelectorBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
tkeg/web/src/pages/dashboard/QuickActionCards.jsx
Normal file
72
tkeg/web/src/pages/dashboard/QuickActionCards.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
tkeg/web/src/pages/dashboard/useDashboardData.js
Normal file
84
tkeg/web/src/pages/dashboard/useDashboardData.js
Normal 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
42
tkeg/web/src/theme.js
Normal 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;
|
||||
Reference in New Issue
Block a user