This commit is contained in:
Hyungi Ahn
2025-07-15 09:44:57 +09:00
10 changed files with 1595 additions and 0 deletions

42
frontend/src/App.css Normal file
View 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
View 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;

View 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

View 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;

View 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;

View 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;

View 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
View 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
View 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>,
)

View 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;