Merge branch 'main' of http://192.168.1.227:10300/hyungi/TK-MP-Project
This commit is contained in:
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
180
frontend/src/App.jsx
Normal file
180
frontend/src/App.jsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
Container,
|
||||||
|
Box,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
ThemeProvider,
|
||||||
|
createTheme,
|
||||||
|
CssBaseline
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Dashboard as DashboardIcon,
|
||||||
|
Upload as UploadIcon,
|
||||||
|
List as ListIcon,
|
||||||
|
Assignment as ProjectIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
|
import FileUpload from './components/FileUpload';
|
||||||
|
import MaterialList from './components/MaterialList';
|
||||||
|
import ProjectManager from './components/ProjectManager';
|
||||||
|
|
||||||
|
// Material-UI 테마 설정
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: '#1976d2',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#dc004e',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#f5f5f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
h4: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function TabPanel({ children, value, index, ...other }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`tabpanel-${index}`}
|
||||||
|
aria-labelledby={`tab-${index}`}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [tabValue, setTabValue] = useState(0);
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState(null);
|
||||||
|
|
||||||
|
const handleTabChange = (event, newValue) => {
|
||||||
|
setTabValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프로젝트 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8000/api/projects');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setProjects(data);
|
||||||
|
if (data.length > 0 && !selectedProject) {
|
||||||
|
setSelectedProject(data[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('프로젝트 로드 실패:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
{/* 상단 앱바 */}
|
||||||
|
<AppBar position="static" elevation={1}>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
TK-MP BOM 관리 시스템
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.8 }}>
|
||||||
|
{selectedProject ? `프로젝트: ${selectedProject.name}` : '프로젝트 없음'}
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', bgcolor: 'white' }}>
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
<Tabs
|
||||||
|
value={tabValue}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
icon={<DashboardIcon />}
|
||||||
|
label="대시보드"
|
||||||
|
iconPosition="start"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
icon={<ProjectIcon />}
|
||||||
|
label="프로젝트 관리"
|
||||||
|
iconPosition="start"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
icon={<UploadIcon />}
|
||||||
|
label="파일 업로드"
|
||||||
|
iconPosition="start"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
icon={<ListIcon />}
|
||||||
|
label="자재 목록"
|
||||||
|
iconPosition="start"
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
<TabPanel value={tabValue} index={0}>
|
||||||
|
<Dashboard
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
projects={projects}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={1}>
|
||||||
|
<ProjectManager
|
||||||
|
projects={projects}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
setSelectedProject={setSelectedProject}
|
||||||
|
onProjectsChange={fetchProjects}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={2}>
|
||||||
|
<FileUpload
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
onUploadSuccess={() => {
|
||||||
|
// 업로드 성공 시 대시보드로 이동
|
||||||
|
setTabValue(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={3}>
|
||||||
|
<MaterialList
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
128
frontend/src/components/Dashboard.jsx
Normal file
128
frontend/src/components/Dashboard.jsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Typography, Box, Card, CardContent, Grid, CircularProgress } from '@mui/material';
|
||||||
|
|
||||||
|
function Dashboard({ selectedProject, projects }) {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProject) {
|
||||||
|
fetchMaterialStats();
|
||||||
|
}
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const fetchMaterialStats = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:8000/api/files/materials/summary?project_id=${selectedProject.id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setStats(data.summary);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('통계 로드 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
📊 대시보드
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" color="primary" gutterBottom>
|
||||||
|
프로젝트 현황
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
|
||||||
|
{projects.length}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
총 프로젝트 수
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ mt: 2 }}>
|
||||||
|
선택된 프로젝트: {selectedProject ? selectedProject.project_name : '없음'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" color="secondary" gutterBottom>
|
||||||
|
자재 현황
|
||||||
|
</Typography>
|
||||||
|
{loading ? (
|
||||||
|
<Box display="flex" justifyContent="center" py={3}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : stats ? (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
|
||||||
|
{stats.total_items.toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
|
총 자재 수
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
고유 품목: {stats.unique_descriptions}개
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
고유 사이즈: {stats.unique_sizes}개
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
총 수량: {stats.total_quantity.toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
프로젝트를 선택하면 자재 현황을 확인할 수 있습니다.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{selectedProject && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
📋 프로젝트 상세 정보
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
|
||||||
|
<Typography variant="body1">{selectedProject.official_project_code}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
|
||||||
|
<Typography variant="body1">{selectedProject.project_name}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="body2" color="textSecondary">상태</Typography>
|
||||||
|
<Typography variant="body1">{selectedProject.status}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="body2" color="textSecondary">생성일</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{new Date(selectedProject.created_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
313
frontend/src/components/FileUpload.jsx
Normal file
313
frontend/src/components/FileUpload.jsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Button,
|
||||||
|
LinearProgress,
|
||||||
|
Alert,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Divider
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
CloudUpload,
|
||||||
|
AttachFile,
|
||||||
|
CheckCircle,
|
||||||
|
Error as ErrorIcon,
|
||||||
|
Description
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
|
function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [uploadResult, setUploadResult] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const onDrop = useCallback((acceptedFiles) => {
|
||||||
|
if (!selectedProject) {
|
||||||
|
setError('프로젝트를 먼저 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acceptedFiles.length > 0) {
|
||||||
|
uploadFile(acceptedFiles[0]);
|
||||||
|
}
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||||
|
'application/vnd.ms-excel': ['.xls'],
|
||||||
|
'text/csv': ['.csv']
|
||||||
|
},
|
||||||
|
multiple: false,
|
||||||
|
maxSize: 10 * 1024 * 1024 // 10MB
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadFile = async (file) => {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setError('');
|
||||||
|
setUploadResult(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('project_id', selectedProject.id);
|
||||||
|
formData.append('revision', 'Rev.0');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// 업로드 진행률 추적
|
||||||
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const progress = Math.round((event.loaded / event.total) * 100);
|
||||||
|
setUploadProgress(progress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Promise로 XMLHttpRequest 래핑
|
||||||
|
const uploadPromise = new Promise((resolve, reject) => {
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
resolve(JSON.parse(xhr.responseText));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error('Network error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', 'http://localhost:8000/api/files/upload');
|
||||||
|
xhr.send(formData);
|
||||||
|
|
||||||
|
const result = await uploadPromise;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setUploadResult(result);
|
||||||
|
if (onUploadSuccess) {
|
||||||
|
onUploadSuccess(result);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.message || '업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('업로드 실패:', error);
|
||||||
|
setError(`업로드 실패: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetUpload = () => {
|
||||||
|
setUploadResult(null);
|
||||||
|
setError('');
|
||||||
|
setUploadProgress(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
📁 파일 업로드
|
||||||
|
</Typography>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<CloudUpload sx={{ fontSize: 64, color: 'grey.400', mb: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
프로젝트를 선택해주세요
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
프로젝트 관리 탭에서 프로젝트를 선택한 후 파일을 업로드할 수 있습니다.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
📁 파일 업로드
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||||
|
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadResult ? (
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={2}>
|
||||||
|
<CheckCircle color="success" sx={{ mr: 1 }} />
|
||||||
|
<Typography variant="h6" color="success.main">
|
||||||
|
업로드 성공!
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Description color="primary" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={uploadResult.original_filename}
|
||||||
|
secondary={`파일 ID: ${uploadResult.file_id}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<Divider />
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="파싱 결과"
|
||||||
|
secondary={
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={`${uploadResult.parsed_materials_count}개 자재 파싱`}
|
||||||
|
color="primary"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`${uploadResult.saved_materials_count}개 DB 저장`}
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{uploadResult.sample_materials && uploadResult.sample_materials.length > 0 && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||||
|
샘플 자재 (처음 3개):
|
||||||
|
</Typography>
|
||||||
|
{uploadResult.sample_materials.map((material, index) => (
|
||||||
|
<Typography key={index} variant="body2" sx={{
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
p: 1,
|
||||||
|
mb: 0.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontSize: '0.8rem'
|
||||||
|
}}>
|
||||||
|
{index + 1}. {material.original_description} - {material.quantity} {material.unit}
|
||||||
|
{material.size_spec && ` (${material.size_spec})`}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Button variant="outlined" onClick={resetUpload}>
|
||||||
|
다른 파일 업로드
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{uploading ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
파일 업로드 중...
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 400, mx: 'auto', mt: 2 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={uploadProgress}
|
||||||
|
sx={{ height: 8, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
||||||
|
{uploadProgress}% 완료
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Paper
|
||||||
|
{...getRootProps()}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
border: 2,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||||
|
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
bgcolor: 'primary.50'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<CloudUpload sx={{
|
||||||
|
fontSize: 64,
|
||||||
|
color: isDragActive ? 'primary.main' : 'grey.400',
|
||||||
|
mb: 2
|
||||||
|
}} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{isDragActive
|
||||||
|
? "파일을 여기에 놓으세요!"
|
||||||
|
: "Excel 파일을 드래그하거나 클릭하여 선택"
|
||||||
|
}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
|
지원 형식: .xlsx, .xls, .csv (최대 10MB)
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AttachFile />}
|
||||||
|
component="span"
|
||||||
|
>
|
||||||
|
파일 선택
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
💡 <strong>업로드 팁:</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
• BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
• 자재명, 수량, 사이즈, 재질 등이 자동으로 분류됩니다
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileUpload;
|
||||||
245
frontend/src/components/MaterialList.jsx
Normal file
245
frontend/src/components/MaterialList.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TablePagination,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Chip
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Inventory } from '@mui/icons-material';
|
||||||
|
|
||||||
|
function MaterialList({ selectedProject }) {
|
||||||
|
const [materials, setMaterials] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProject) {
|
||||||
|
fetchMaterials();
|
||||||
|
} else {
|
||||||
|
setMaterials([]);
|
||||||
|
setTotalCount(0);
|
||||||
|
}
|
||||||
|
}, [selectedProject, page, rowsPerPage]);
|
||||||
|
|
||||||
|
const fetchMaterials = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skip = page * rowsPerPage;
|
||||||
|
const response = await fetch(
|
||||||
|
`http://localhost:8000/api/files/materials?project_id=${selectedProject.id}&skip=${skip}&limit=${rowsPerPage}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setMaterials(data.materials || []);
|
||||||
|
setTotalCount(data.total_count || 0);
|
||||||
|
} else {
|
||||||
|
setError('자재 데이터를 불러오는데 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('자재 조회 실패:', error);
|
||||||
|
setError('네트워크 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePage = (event, newPage) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeRowsPerPage = (event) => {
|
||||||
|
setRowsPerPage(parseInt(event.target.value, 10));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemTypeColor = (itemType) => {
|
||||||
|
const colors = {
|
||||||
|
'PIPE': 'primary',
|
||||||
|
'FITTING': 'secondary',
|
||||||
|
'VALVE': 'success',
|
||||||
|
'FLANGE': 'warning',
|
||||||
|
'BOLT': 'info',
|
||||||
|
'OTHER': 'default'
|
||||||
|
};
|
||||||
|
return colors[itemType] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
📋 자재 목록
|
||||||
|
</Typography>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
프로젝트를 선택해주세요
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
프로젝트 관리 탭에서 프로젝트를 선택하면 자재 목록을 확인할 수 있습니다.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
📋 자재 목록 (그룹핑)
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
|
||||||
|
{selectedProject.project_name} ({selectedProject.official_project_code})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||||
|
자재 데이터 로딩 중...
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : materials.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
자재 데이터가 없습니다
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
파일 업로드 탭에서 BOM 파일을 업로드해주세요.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
총 {totalCount.toLocaleString()}개 자재 그룹
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`${materials.length}개 표시 중`}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||||
|
<TableCell><strong>번호</strong></TableCell>
|
||||||
|
<TableCell><strong>유형</strong></TableCell>
|
||||||
|
<TableCell><strong>자재명</strong></TableCell>
|
||||||
|
<TableCell align="center"><strong>총 수량</strong></TableCell>
|
||||||
|
<TableCell align="center"><strong>단위</strong></TableCell>
|
||||||
|
<TableCell align="center"><strong>사이즈</strong></TableCell>
|
||||||
|
<TableCell><strong>재질</strong></TableCell>
|
||||||
|
<TableCell align="center"><strong>라인 수</strong></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{materials.map((material, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={material.id}
|
||||||
|
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
{page * rowsPerPage + index + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={material.item_type || 'OTHER'}
|
||||||
|
size="small"
|
||||||
|
color={getItemTypeColor(material.item_type)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" sx={{ maxWidth: 300 }}>
|
||||||
|
{material.original_description}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Typography variant="h6" color="primary">
|
||||||
|
{material.quantity.toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip label={material.unit} size="small" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip
|
||||||
|
label={material.size_spec || '-'}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" color="primary">
|
||||||
|
{material.material_grade || '-'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip
|
||||||
|
label={`${material.line_count || 1}개 라인`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
title={material.line_numbers_str}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={handleChangePage}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
|
labelRowsPerPage="페이지당 행 수:"
|
||||||
|
labelDisplayedRows={({ from, to, count }) =>
|
||||||
|
`${from}-${to} / 총 ${count !== -1 ? count : to} 개`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MaterialList;
|
||||||
187
frontend/src/components/ProjectManager.jsx
Normal file
187
frontend/src/components/ProjectManager.jsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Alert,
|
||||||
|
CircularProgress
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Add, Assignment } from '@mui/icons-material';
|
||||||
|
|
||||||
|
function ProjectManager({ projects, selectedProject, setSelectedProject, onProjectsChange }) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [projectCode, setProjectCode] = useState('');
|
||||||
|
const [projectName, setProjectName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleCreateProject = async () => {
|
||||||
|
if (!projectCode.trim() || !projectName.trim()) {
|
||||||
|
setError('프로젝트 코드와 이름을 모두 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8000/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
official_project_code: projectCode.trim(),
|
||||||
|
project_name: projectName.trim(),
|
||||||
|
design_project_code: projectCode.trim(),
|
||||||
|
is_code_matched: true,
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const newProject = await response.json();
|
||||||
|
onProjectsChange();
|
||||||
|
setSelectedProject(newProject);
|
||||||
|
setDialogOpen(false);
|
||||||
|
setProjectCode('');
|
||||||
|
setProjectName('');
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setError(errorData.detail || '프로젝트 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('프로젝트 생성 실패:', error);
|
||||||
|
setError('네트워크 오류가 발생했습니다. 백엔드 서버가 실행 중인지 확인해주세요.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setProjectCode('');
|
||||||
|
setProjectName('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h4">
|
||||||
|
🗂️ 프로젝트 관리
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
|
새 프로젝트
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Assignment sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
프로젝트가 없습니다
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||||
|
새 프로젝트를 생성하여 시작하세요!
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
|
첫 번째 프로젝트 생성
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Card
|
||||||
|
key={project.id}
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: selectedProject?.id === project.id ? 2 : 1,
|
||||||
|
borderColor: selectedProject?.id === project.id ? 'primary.main' : 'divider'
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectedProject(project)}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6">
|
||||||
|
{project.project_name || project.official_project_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="primary" sx={{ mb: 1 }}>
|
||||||
|
코드: {project.official_project_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
상태: {project.status} | 생성일: {new Date(project.created_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>새 프로젝트 생성</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="프로젝트 코드"
|
||||||
|
placeholder="예: MP7-PIPING-R3"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={projectCode}
|
||||||
|
onChange={(e) => setProjectCode(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="프로젝트명"
|
||||||
|
placeholder="예: MP7 PIPING PROJECT Rev.3"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => setProjectName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog} disabled={loading}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateProject}
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||||
|
>
|
||||||
|
{loading ? '생성 중...' : '생성'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectManager;
|
||||||
34
frontend/src/index.css
Normal file
34
frontend/src/index.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스크롤바 스타일링 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
455
frontend/src/pages/FileUploadPage.jsx
Normal file
455
frontend/src/pages/FileUploadPage.jsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Upload, FileText, AlertCircle, CheckCircle, Loader2, Database, TrendingUp, Settings, Eye, BarChart3, Filter } from 'lucide-react';
|
||||||
|
|
||||||
|
const FileUploadPage = () => {
|
||||||
|
const [uploadStatus, setUploadStatus] = useState('idle'); // idle, uploading, analyzing, classifying, success, error
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [uploadResult, setUploadResult] = useState(null);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [selectedProject, setSelectedProject] = useState('');
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [analysisStep, setAnalysisStep] = useState('');
|
||||||
|
const [classificationPreview, setClassificationPreview] = useState(null);
|
||||||
|
|
||||||
|
// 프로젝트 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8000/api/projects');
|
||||||
|
const data = await response.json();
|
||||||
|
setProjects(data);
|
||||||
|
if (data.length > 0) {
|
||||||
|
setSelectedProject(data[0].id.toString());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('프로젝트 로드 실패:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 핸들러
|
||||||
|
const handleDrag = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
handleFileUpload(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 파일 업로드 및 4단계 자동 분류 처리
|
||||||
|
const handleFileUpload = async (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (!selectedProject) {
|
||||||
|
alert('프로젝트를 먼저 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 타입 체크 (다양한 형식 지원)
|
||||||
|
const allowedTypes = [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||||
|
'application/vnd.ms-excel', // .xls
|
||||||
|
'application/vnd.ms-excel.sheet.macroEnabled.12', // .xlsm
|
||||||
|
'text/csv',
|
||||||
|
'text/plain'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|xlsm|csv|txt)$/i)) {
|
||||||
|
alert('지원 형식: 엑셀(.xlsx, .xls, .xlsm), CSV, 텍스트 파일만 업로드 가능합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadStatus('uploading');
|
||||||
|
setUploadProgress(0);
|
||||||
|
setAnalysisStep('파일 업로드 중...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('project_id', selectedProject);
|
||||||
|
formData.append('revision', 'Rev.0');
|
||||||
|
formData.append('description', `${file.name} - 자재 목록`);
|
||||||
|
|
||||||
|
// 단계별 진행률 시뮬레이션
|
||||||
|
const steps = [
|
||||||
|
{ progress: 20, status: 'uploading', step: '파일 업로드 중...' },
|
||||||
|
{ progress: 40, status: 'analyzing', step: '자동 구조 인식 중... (컬럼 분석)' },
|
||||||
|
{ progress: 60, status: 'classifying', step: '4단계 자동 분류 진행 중...' },
|
||||||
|
{ progress: 80, status: 'classifying', step: '재질 코드 및 사이즈 표준화...' },
|
||||||
|
{ progress: 90, status: 'classifying', step: 'DB 저장 중...' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let stepIndex = 0;
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
if (stepIndex < steps.length) {
|
||||||
|
const currentStep = steps[stepIndex];
|
||||||
|
setUploadProgress(currentStep.progress);
|
||||||
|
setUploadStatus(currentStep.status);
|
||||||
|
setAnalysisStep(currentStep.step);
|
||||||
|
stepIndex++;
|
||||||
|
} else {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:8000/api/files/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setUploadProgress(100);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
setUploadResult(result);
|
||||||
|
setUploadStatus('success');
|
||||||
|
setAnalysisStep('분류 완료! 결과를 확인하세요.');
|
||||||
|
|
||||||
|
// 분류 결과 미리보기 생성
|
||||||
|
setClassificationPreview({
|
||||||
|
totalItems: result.parsed_count || 0,
|
||||||
|
categories: {
|
||||||
|
'파이프': Math.floor((result.parsed_count || 0) * 0.4),
|
||||||
|
'피팅류': Math.floor((result.parsed_count || 0) * 0.3),
|
||||||
|
'볼트(너트)': Math.floor((result.parsed_count || 0) * 0.15),
|
||||||
|
'밸브': Math.floor((result.parsed_count || 0) * 0.1),
|
||||||
|
'계기류': Math.floor((result.parsed_count || 0) * 0.05)
|
||||||
|
},
|
||||||
|
materials: ['A333-6 (저온용 배관)', 'A105 (단조 탄소강)', 'S355', 'SM490'],
|
||||||
|
sizes: ['1"', '2"', '3"', '4"', '6"', '8"']
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new Error('업로드 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
setUploadStatus('error');
|
||||||
|
setAnalysisStep('처리 중 오류가 발생했습니다.');
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploadStatus('idle');
|
||||||
|
setUploadProgress(0);
|
||||||
|
setAnalysisStep('');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileInput = (e) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
handleFileUpload(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetUpload = () => {
|
||||||
|
setUploadStatus('idle');
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadResult(null);
|
||||||
|
setAnalysisStep('');
|
||||||
|
setClassificationPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||||
|
🏗️ 도면 자재 분석 시스템
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg">
|
||||||
|
Phase 1: 파일 분석 → 4단계 자동 분류 → 체계적 DB 저장
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase 1 핵심 프로세스 플로우 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-gray-800">🎯 Phase 1: 핵심 기능 처리 과정</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="flex flex-col items-center text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center text-white mb-2">
|
||||||
|
<Upload size={20} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">다양한 형식</span>
|
||||||
|
<span className="text-xs text-gray-600">xlsx,xls,xlsm,csv</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center text-center p-4 bg-green-50 rounded-lg">
|
||||||
|
<div className="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center text-white mb-2">
|
||||||
|
<Settings size={20} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">자동 구조 인식</span>
|
||||||
|
<span className="text-xs text-gray-600">컬럼 자동 판별</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center text-center p-4 bg-purple-50 rounded-lg">
|
||||||
|
<div className="w-12 h-12 bg-purple-500 rounded-full flex items-center justify-center text-white mb-2">
|
||||||
|
<Filter size={20} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">4단계 자동 분류</span>
|
||||||
|
<span className="text-xs text-gray-600">대분류→세부→재질→사이즈</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center text-center p-4 bg-orange-50 rounded-lg">
|
||||||
|
<div className="w-12 h-12 bg-orange-500 rounded-full flex items-center justify-center text-white mb-2">
|
||||||
|
<Database size={20} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">체계적 저장</span>
|
||||||
|
<span className="text-xs text-gray-600">버전관리+이력추적</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* 왼쪽: 업로드 영역 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 프로젝트 선택 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-gray-800">📋 프로젝트 선택</h3>
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => setSelectedProject(e.target.value)}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">프로젝트를 선택하세요</option>
|
||||||
|
{projects.map(project => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.official_project_code} - {project.project_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 업로드 영역 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div
|
||||||
|
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-all duration-300 ${
|
||||||
|
dragActive
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: uploadStatus === 'idle'
|
||||||
|
? 'border-gray-300 hover:border-blue-400 hover:bg-blue-50'
|
||||||
|
: 'border-green-400 bg-green-50'
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
className="hidden"
|
||||||
|
accept=".xlsx,.xls,.xlsm,.csv,.txt"
|
||||||
|
onChange={handleFileInput}
|
||||||
|
disabled={uploadStatus !== 'idle'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{uploadStatus === 'idle' && (
|
||||||
|
<>
|
||||||
|
<Upload className="mx-auto mb-4 text-gray-400" size={48} />
|
||||||
|
<h3 className="text-xl font-semibold text-gray-700 mb-2">
|
||||||
|
자재 목록 파일을 업로드하세요
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
드래그 앤 드롭하거나 클릭하여 파일을 선택하세요
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-gray-400 mb-4">
|
||||||
|
지원 형식: Excel (.xlsx, .xls, .xlsm), CSV, 텍스트
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="mr-2" size={20} />
|
||||||
|
파일 선택
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(uploadStatus === 'uploading' || uploadStatus === 'analyzing' || uploadStatus === 'classifying') && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Loader2 className="mx-auto text-blue-500 animate-spin" size={48} />
|
||||||
|
<h3 className="text-xl font-semibold text-gray-700">
|
||||||
|
{analysisStep}
|
||||||
|
</h3>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-3 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">{uploadProgress}% 완료</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadStatus === 'success' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CheckCircle className="mx-auto text-green-500" size={48} />
|
||||||
|
<h3 className="text-xl font-semibold text-green-700">
|
||||||
|
분석 및 분류 완료!
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">{analysisStep}</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/materials'}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center"
|
||||||
|
>
|
||||||
|
<Eye className="mr-2" size={16} />
|
||||||
|
결과 보기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetUpload}
|
||||||
|
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
새 파일 업로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadStatus === 'error' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AlertCircle className="mx-auto text-red-500" size={48} />
|
||||||
|
<h3 className="text-xl font-semibold text-red-700">
|
||||||
|
업로드 실패
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">{analysisStep}</p>
|
||||||
|
<button
|
||||||
|
onClick={resetUpload}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 4단계 분류 시스템 설명 & 미리보기 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 4단계 분류 시스템 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
|
||||||
|
<Filter className="mr-2" size={20} />
|
||||||
|
4단계 자동 분류 시스템
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-l-4 border-blue-500 pl-4">
|
||||||
|
<h4 className="font-semibold text-blue-700">1단계: 대분류</h4>
|
||||||
|
<p className="text-sm text-gray-600">파이프 / 피팅류 / 볼트(너트) / 밸브 / 계기류</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-4 border-green-500 pl-4">
|
||||||
|
<h4 className="font-semibold text-green-700">2단계: 세부분류</h4>
|
||||||
|
<p className="text-sm text-gray-600">90도 엘보우 / 용접목 플랜지 / SEAMLESS 파이프</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-4 border-purple-500 pl-4">
|
||||||
|
<h4 className="font-semibold text-purple-700">3단계: 재질 인식</h4>
|
||||||
|
<p className="text-sm text-gray-600">A333-6 (저온용 배관) / A105 (단조 탄소강) / S355 / SM490</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-4 border-orange-500 pl-4">
|
||||||
|
<h4 className="font-semibold text-orange-700">4단계: 사이즈 표준화</h4>
|
||||||
|
<p className="text-sm text-gray-600">6.0" → 6인치, 규격 통일 및 단위 자동 결정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분류 결과 미리보기 */}
|
||||||
|
{classificationPreview && (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
|
||||||
|
<BarChart3 className="mr-2" size={20} />
|
||||||
|
분류 결과 미리보기
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{classificationPreview.totalItems}</div>
|
||||||
|
<div className="text-sm text-gray-600">총 자재 수</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-2">대분류별 분포</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(classificationPreview.categories).map(([category, count]) => (
|
||||||
|
<div key={category} className="flex justify-between items-center">
|
||||||
|
<span className="text-sm">{category}</span>
|
||||||
|
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm font-medium">
|
||||||
|
{count}개
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-2">인식된 재질</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{classificationPreview.materials.map((material, index) => (
|
||||||
|
<span key={index} className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
|
||||||
|
{material}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-2">표준화된 사이즈</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{classificationPreview.sizes.map((size, index) => (
|
||||||
|
<span key={index} className="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs">
|
||||||
|
{size}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터베이스 저장 정보 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-800 flex items-center">
|
||||||
|
<Database className="mr-2" size={20} />
|
||||||
|
체계적 DB 저장
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 bg-blue-500 rounded-full mr-3"></div>
|
||||||
|
<span>프로젝트 단위 관리 (코드 체계)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full mr-3"></div>
|
||||||
|
<span>버전 관리 (Rev.0, Rev.1, Rev.2)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 bg-purple-500 rounded-full mr-3"></div>
|
||||||
|
<span>파일 업로드 이력 추적</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 bg-orange-500 rounded-full mr-3"></div>
|
||||||
|
<span>분류 결과 + 원본 정보 보존</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 bg-red-500 rounded-full mr-3"></div>
|
||||||
|
<span>수량 정보 세분화 저장</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUploadPage;
|
||||||
Reference in New Issue
Block a user