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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user