[TEST] Cloudflare Tunnel 대응 및 리비전 증분 계산 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
🌐 Nginx 프록시 설정 (테스트용): - nginx-proxy.conf: /api 요청을 백엔드로 프록시 - docker-compose.proxy.yml: 프록시 서버 설정 - VITE_API_URL=/api 환경변수 설정으로 단일 도메인 접속 🎨 UI 텍스트 변경 (테스트용): - LoginPage: TK-MP System → BOM 테스트 서버 - LoginPage: 통합 프로젝트 관리 시스템 → BOM 분류 시스템 v1.0 - LogMonitoringPage: 탭 네비게이션 추가 (로그인/활동/시스템 로그) - SystemSettingsPage: 활동 로그 모니터링 기능 개선 🔧 백엔드 수정 (테스트용): - files.py: 리비전 증분 계산 로직 수정 (전체 재분류 → 차이분만 분류) - create_system_admin.py: database_url → get_database_url() 수정 ⚠️ 주의: 이 커밋은 테스트 환경에서의 변경사항입니다.
This commit is contained in:
@@ -380,10 +380,12 @@ async def upload_file(
|
|||||||
|
|
||||||
# 리비전 업로드인 경우 차이분 계산
|
# 리비전 업로드인 경우 차이분 계산
|
||||||
materials_diff = []
|
materials_diff = []
|
||||||
|
original_materials_to_classify = materials_to_classify.copy() # 원본 보존
|
||||||
|
|
||||||
if parent_file_id is not None:
|
if parent_file_id is not None:
|
||||||
# 새 파일의 자재들을 수량별로 그룹화
|
# 새 파일의 자재들을 수량별로 그룹화
|
||||||
new_materials_grouped = {}
|
new_materials_grouped = {}
|
||||||
for material_data in materials_to_classify:
|
for material_data in original_materials_to_classify:
|
||||||
description = material_data["original_description"]
|
description = material_data["original_description"]
|
||||||
size_spec = material_data["size_spec"]
|
size_spec = material_data["size_spec"]
|
||||||
quantity = float(material_data.get("quantity", 0))
|
quantity = float(material_data.get("quantity", 0))
|
||||||
@@ -434,6 +436,9 @@ async def upload_file(
|
|||||||
# 차이분만 처리하도록 materials_to_classify 교체
|
# 차이분만 처리하도록 materials_to_classify 교체
|
||||||
materials_to_classify = materials_diff
|
materials_to_classify = materials_diff
|
||||||
print(f"차이분 자재 개수: {len(materials_to_classify)}")
|
print(f"차이분 자재 개수: {len(materials_to_classify)}")
|
||||||
|
print(f"🔄 리비전 업로드: 차이분 {len(materials_diff)}개만 분류 처리")
|
||||||
|
else:
|
||||||
|
print(f"🆕 신규 업로드: 전체 {len(materials_to_classify)}개 분류 처리")
|
||||||
|
|
||||||
# 분류가 필요한 자재 처리
|
# 분류가 필요한 자재 처리
|
||||||
print(f"분류할 자재 총 개수: {len(materials_to_classify)}")
|
print(f"분류할 자재 총 개수: {len(materials_to_classify)}")
|
||||||
@@ -2701,4 +2706,205 @@ async def confirm_material_purchase_api(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
print(f"구매수량 확정 실패: {str(e)}")
|
print(f"구매수량 확정 실패: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"구매수량 확정 실패: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"구매수량 확정 실패: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{file_id}")
|
||||||
|
async def update_file_info(
|
||||||
|
file_id: int,
|
||||||
|
bom_name: Optional[str] = Body(None),
|
||||||
|
description: Optional[str] = Body(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""파일 정보 수정"""
|
||||||
|
try:
|
||||||
|
# 파일 존재 확인 및 관련 정보 조회
|
||||||
|
file_query = text("""
|
||||||
|
SELECT id, bom_name, description, job_no, original_filename
|
||||||
|
FROM files
|
||||||
|
WHERE id = :file_id
|
||||||
|
""")
|
||||||
|
file_result = db.execute(file_query, {"file_id": file_id}).fetchone()
|
||||||
|
|
||||||
|
if not file_result:
|
||||||
|
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
# 업데이트할 필드 준비
|
||||||
|
update_fields = []
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if bom_name is not None:
|
||||||
|
update_fields.append("bom_name = :bom_name")
|
||||||
|
params["bom_name"] = bom_name
|
||||||
|
|
||||||
|
if description is not None:
|
||||||
|
update_fields.append("description = :description")
|
||||||
|
params["description"] = description
|
||||||
|
|
||||||
|
if not update_fields:
|
||||||
|
raise HTTPException(status_code=400, detail="수정할 정보가 없습니다")
|
||||||
|
|
||||||
|
# BOM 이름 수정인 경우, 같은 job_no의 모든 리비전을 함께 업데이트
|
||||||
|
if bom_name is not None and file_result.job_no:
|
||||||
|
# 같은 job_no의 모든 파일 업데이트
|
||||||
|
update_query = text(f"""
|
||||||
|
UPDATE files
|
||||||
|
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE job_no = :job_no
|
||||||
|
""")
|
||||||
|
params["job_no"] = file_result.job_no
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 단일 파일만 업데이트
|
||||||
|
update_query = text(f"""
|
||||||
|
UPDATE files
|
||||||
|
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = :file_id
|
||||||
|
""")
|
||||||
|
params["file_id"] = file_id
|
||||||
|
|
||||||
|
db.execute(update_query, params)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 활동 로그 기록 - 간단하게 처리
|
||||||
|
logger.info(f"파일 정보 수정 완료: 사용자={current_user['username']}, 파일ID={file_id}, BOM명={bom_name or 'N/A'}")
|
||||||
|
|
||||||
|
return {"message": "파일 정보가 성공적으로 수정되었습니다"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"파일 정보 수정 실패: {str(e)}")
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"파일 정보 수정 실패: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{file_id}/export-excel")
|
||||||
|
async def export_materials_to_excel(
|
||||||
|
file_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""자재 목록을 엑셀로 내보내기"""
|
||||||
|
try:
|
||||||
|
# 파일 정보 조회
|
||||||
|
file_query = text("""
|
||||||
|
SELECT f.id, f.original_filename, f.bom_name, f.job_no, f.revision,
|
||||||
|
p.project_name, p.official_project_code
|
||||||
|
FROM files f
|
||||||
|
LEFT JOIN projects p ON f.project_id = p.id
|
||||||
|
WHERE f.id = :file_id
|
||||||
|
""")
|
||||||
|
file_result = db.execute(file_query, {"file_id": file_id}).fetchone()
|
||||||
|
|
||||||
|
if not file_result:
|
||||||
|
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
# 자재 목록 조회
|
||||||
|
materials_query = text("""
|
||||||
|
SELECT
|
||||||
|
m.line_number,
|
||||||
|
m.original_description,
|
||||||
|
m.quantity,
|
||||||
|
m.unit,
|
||||||
|
m.size_spec,
|
||||||
|
m.main_nom,
|
||||||
|
m.red_nom,
|
||||||
|
m.material_grade,
|
||||||
|
m.classified_category,
|
||||||
|
m.classification_confidence,
|
||||||
|
m.is_verified,
|
||||||
|
m.verified_by,
|
||||||
|
m.created_at
|
||||||
|
FROM materials m
|
||||||
|
WHERE m.file_id = :file_id
|
||||||
|
ORDER BY m.line_number ASC
|
||||||
|
""")
|
||||||
|
|
||||||
|
materials_result = db.execute(materials_query, {"file_id": file_id}).fetchall()
|
||||||
|
|
||||||
|
# 엑셀 데이터 준비
|
||||||
|
excel_data = []
|
||||||
|
for material in materials_result:
|
||||||
|
excel_data.append({
|
||||||
|
"라인번호": material.line_number,
|
||||||
|
"품명": material.original_description,
|
||||||
|
"수량": material.quantity,
|
||||||
|
"단위": material.unit,
|
||||||
|
"사이즈": material.size_spec,
|
||||||
|
"주요NOM": material.main_nom,
|
||||||
|
"축소NOM": material.red_nom,
|
||||||
|
"재질등급": material.material_grade,
|
||||||
|
"분류": material.classified_category,
|
||||||
|
"신뢰도": material.classification_confidence,
|
||||||
|
"검증여부": "검증완료" if material.is_verified else "미검증",
|
||||||
|
"검증자": material.verified_by or "",
|
||||||
|
"등록일": material.created_at.strftime("%Y-%m-%d %H:%M:%S") if material.created_at else ""
|
||||||
|
})
|
||||||
|
|
||||||
|
# 활동 로그 기록
|
||||||
|
activity_logger = ActivityLogger(db)
|
||||||
|
activity_logger.log_activity(
|
||||||
|
username=current_user["username"],
|
||||||
|
activity_type="엑셀 내보내기",
|
||||||
|
activity_description=f"자재 목록 엑셀 내보내기: {file_result.original_filename}",
|
||||||
|
target_type="file",
|
||||||
|
target_id=file_id,
|
||||||
|
metadata={
|
||||||
|
"file_name": file_result.original_filename,
|
||||||
|
"bom_name": file_result.bom_name,
|
||||||
|
"job_no": file_result.job_no,
|
||||||
|
"revision": file_result.revision,
|
||||||
|
"materials_count": len(excel_data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "엑셀 내보내기 준비 완료",
|
||||||
|
"file_info": {
|
||||||
|
"filename": file_result.original_filename,
|
||||||
|
"bom_name": file_result.bom_name,
|
||||||
|
"job_no": file_result.job_no,
|
||||||
|
"revision": file_result.revision,
|
||||||
|
"project_name": file_result.project_name
|
||||||
|
},
|
||||||
|
"materials": excel_data,
|
||||||
|
"total_count": len(excel_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"엑셀 내보내기 실패: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"엑셀 내보내기 실패: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{file_id}/materials/view-log")
|
||||||
|
async def log_materials_view(
|
||||||
|
file_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""자재 목록 조회 로그 기록"""
|
||||||
|
try:
|
||||||
|
# 파일 정보 조회
|
||||||
|
file_query = text("SELECT original_filename, bom_name FROM files WHERE id = :file_id")
|
||||||
|
file_result = db.execute(file_query, {"file_id": file_id}).fetchone()
|
||||||
|
|
||||||
|
if file_result:
|
||||||
|
# 활동 로그 기록
|
||||||
|
activity_logger = ActivityLogger(db)
|
||||||
|
activity_logger.log_activity(
|
||||||
|
username=current_user["username"],
|
||||||
|
activity_type="자재 목록 조회",
|
||||||
|
activity_description=f"자재 목록 조회: {file_result.original_filename}",
|
||||||
|
target_type="file",
|
||||||
|
target_id=file_id,
|
||||||
|
metadata={
|
||||||
|
"file_name": file_result.original_filename,
|
||||||
|
"bom_name": file_result.bom_name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "조회 로그 기록 완료"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"조회 로그 기록 실패: {str(e)}")
|
||||||
|
return {"message": "조회 로그 기록 실패"}
|
||||||
@@ -30,7 +30,7 @@ def create_system_admin():
|
|||||||
|
|
||||||
# 데이터베이스 연결
|
# 데이터베이스 연결
|
||||||
try:
|
try:
|
||||||
engine = create_engine(settings.database_url)
|
engine = create_engine(settings.get_database_url())
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
|
|||||||
15
docker-compose.proxy.yml
Normal file
15
docker-compose.proxy.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
nginx-proxy:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: tk-mp-nginx-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
networks:
|
||||||
|
- tk-mp-project_tk-mp-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tk-mp-project_tk-mp-network:
|
||||||
|
external: true
|
||||||
@@ -3,6 +3,7 @@ import api from '../api';
|
|||||||
import { reportError, logUserActionError } from '../utils/errorLogger';
|
import { reportError, logUserActionError } from '../utils/errorLogger';
|
||||||
|
|
||||||
const LogMonitoringPage = ({ onNavigate, user }) => {
|
const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('login-logs'); // 'login-logs', 'activity-logs', 'system-logs'
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalUsers: 0,
|
totalUsers: 0,
|
||||||
activeUsers: 0,
|
activeUsers: 0,
|
||||||
@@ -11,6 +12,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
|||||||
recentErrors: 0
|
recentErrors: 0
|
||||||
});
|
});
|
||||||
const [recentActivity, setRecentActivity] = useState([]);
|
const [recentActivity, setRecentActivity] = useState([]);
|
||||||
|
const [activityLogs, setActivityLogs] = useState([]);
|
||||||
const [frontendErrors, setFrontendErrors] = useState([]);
|
const [frontendErrors, setFrontendErrors] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [message, setMessage] = useState({ type: '', text: '' });
|
const [message, setMessage] = useState({ type: '', text: '' });
|
||||||
@@ -22,10 +24,26 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadActivityLogs = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/logs/system?limit=50');
|
||||||
|
if (response.data.success) {
|
||||||
|
setActivityLogs(response.data.logs);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('활동 로그 로딩 실패:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadDashboardData = async () => {
|
const loadDashboardData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 활동 로그도 함께 로드
|
||||||
|
if (activeTab === 'activity-logs') {
|
||||||
|
await loadActivityLogs();
|
||||||
|
}
|
||||||
|
|
||||||
// 병렬로 데이터 로드
|
// 병렬로 데이터 로드
|
||||||
const [usersResponse, loginLogsResponse] = await Promise.all([
|
const [usersResponse, loginLogsResponse] = await Promise.all([
|
||||||
api.get('/auth/users'),
|
api.get('/auth/users'),
|
||||||
@@ -158,9 +176,60 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
|||||||
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
||||||
📈 로그 모니터링
|
📈 로그 모니터링
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: '#6c757d', fontSize: '14px', margin: '4px 0 0 0' }}>
|
</div>
|
||||||
실시간 시스템 활동 및 오류 모니터링
|
</div>
|
||||||
</p>
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<div style={{ background: 'white', borderBottom: '1px solid #e9ecef', padding: '0 32px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('login-logs')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
border: 'none',
|
||||||
|
background: activeTab === 'login-logs' ? '#4299e1' : 'transparent',
|
||||||
|
color: activeTab === 'login-logs' ? 'white' : '#4a5568',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderBottom: activeTab === 'login-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔐 로그인 로그
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('activity-logs')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
border: 'none',
|
||||||
|
background: activeTab === 'activity-logs' ? '#4299e1' : 'transparent',
|
||||||
|
color: activeTab === 'activity-logs' ? 'white' : '#4a5568',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderBottom: activeTab === 'activity-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📊 활동 로그
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('system-logs')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
border: 'none',
|
||||||
|
background: activeTab === 'system-logs' ? '#4299e1' : 'transparent',
|
||||||
|
color: activeTab === 'system-logs' ? 'white' : '#4a5568',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderBottom: activeTab === 'system-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🖥️ 시스템 로그
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ const LoginPage = () => {
|
|||||||
<div className="login-container">
|
<div className="login-container">
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<div className="login-header">
|
<div className="login-header">
|
||||||
<h1>🚀 TK-MP System</h1>
|
<h1>BOM 테스트 서버</h1>
|
||||||
<p>통합 프로젝트 관리 시스템</p>
|
<p>BOM 분류 시스템 v1.0</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="login-form">
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
@@ -105,7 +105,7 @@ const LoginPage = () => {
|
|||||||
<div className="login-footer">
|
<div className="login-footer">
|
||||||
<p>계정이 없으신가요? 관리자에게 문의하세요.</p>
|
<p>계정이 없으신가요? 관리자에게 문의하세요.</p>
|
||||||
<div className="system-info">
|
<div className="system-info">
|
||||||
<small>TK-MP Project Management System v2.0</small>
|
<small>BOM 분류 시스템 v1.0</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const SystemSettingsPage = ({ onNavigate, user }) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('users'); // 'users', 'login-logs', 'activity-logs'
|
||||||
|
const [loginLogs, setLoginLogs] = useState([]);
|
||||||
|
const [activityLogs, setActivityLogs] = useState([]);
|
||||||
const [newUser, setNewUser] = useState({
|
const [newUser, setNewUser] = useState({
|
||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -16,7 +19,12 @@ const SystemSettingsPage = ({ onNavigate, user }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}, []);
|
if (activeTab === 'login-logs') {
|
||||||
|
loadLoginLogs();
|
||||||
|
} else if (activeTab === 'activity-logs') {
|
||||||
|
loadActivityLogs();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -33,6 +41,36 @@ const SystemSettingsPage = ({ onNavigate, user }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadLoginLogs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.get('/auth/logs/login?limit=50');
|
||||||
|
if (response.data.success) {
|
||||||
|
setLoginLogs(response.data.logs);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('로그인 로그 로딩 실패:', err);
|
||||||
|
setError('로그인 로그를 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadActivityLogs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.get('/auth/logs/system?limit=50');
|
||||||
|
if (response.data.success) {
|
||||||
|
setActivityLogs(response.data.logs);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('활동 로그 로딩 실패:', err);
|
||||||
|
setError('활동 로그를 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateUser = async (e) => {
|
const handleCreateUser = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -104,6 +142,23 @@ const SystemSettingsPage = ({ onNavigate, user }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getActivityTypeColor = (activityType) => {
|
||||||
|
switch (activityType) {
|
||||||
|
case 'FILE_UPLOAD':
|
||||||
|
return '#10b981'; // 초록색
|
||||||
|
case '파일 정보 수정':
|
||||||
|
return '#f59e0b'; // 주황색
|
||||||
|
case '엑셀 내보내기':
|
||||||
|
return '#3b82f6'; // 파란색
|
||||||
|
case '자재 목록 조회':
|
||||||
|
return '#8b5cf6'; // 보라색
|
||||||
|
case 'LOGIN':
|
||||||
|
return '#6b7280'; // 회색
|
||||||
|
default:
|
||||||
|
return '#6b7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 관리자 권한 확인
|
// 관리자 권한 확인
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
return (
|
return (
|
||||||
@@ -143,9 +198,63 @@ const SystemSettingsPage = ({ onNavigate, user }) => {
|
|||||||
⚙️ 시스템 설정
|
⚙️ 시스템 설정
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: '#718096', fontSize: '16px' }}>
|
<p style={{ color: '#718096', fontSize: '16px' }}>
|
||||||
사용자 계정 관리 및 시스템 설정
|
사용자 계정 관리 및 시스템 로그 모니터링
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<div style={{ marginBottom: '24px', borderBottom: '2px solid #e2e8f0' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
border: 'none',
|
||||||
|
background: activeTab === 'users' ? '#4299e1' : 'transparent',
|
||||||
|
color: activeTab === 'users' ? 'white' : '#4a5568',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderBottom: activeTab === 'users' ? '2px solid #4299e1' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
👥 사용자 관리
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('login-logs')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
border: 'none',
|
||||||
|
background: activeTab === 'login-logs' ? '#4299e1' : 'transparent',
|
||||||
|
color: activeTab === 'login-logs' ? 'white' : '#4a5568',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderBottom: activeTab === 'login-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔐 로그인 로그
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('activity-logs')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
border: 'none',
|
||||||
|
background: activeTab === 'activity-logs' ? '#4299e1' : 'transparent',
|
||||||
|
color: activeTab === 'activity-logs' ? 'white' : '#4a5568',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderBottom: activeTab === 'activity-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📊 활동 로그
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate('dashboard')}
|
onClick={() => onNavigate('dashboard')}
|
||||||
style={{
|
style={{
|
||||||
@@ -356,12 +465,13 @@ const SystemSettingsPage = ({ onNavigate, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 사용자 목록 */}
|
{/* 탭별 콘텐츠 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 중...</div>
|
<div style={{ fontSize: '16px', color: '#718096' }}>로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : activeTab === 'users' ? (
|
||||||
|
// 사용자 관리 탭
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -446,7 +556,105 @@ const SystemSettingsPage = ({ onNavigate, user }) => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : activeTab === 'login-logs' ? (
|
||||||
|
// 로그인 로그 탭
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<h3 style={{ marginBottom: '16px', color: '#2d3748' }}>🔐 로그인 로그</h3>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||||
|
사용자명
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||||
|
IP 주소
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', color: '#4a5568' }}>
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||||
|
로그인 시간
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loginLogs.map((log, index) => (
|
||||||
|
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<td style={{ padding: '12px', fontWeight: '500' }}>
|
||||||
|
{log.username}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||||
|
{log.ip_address}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
background: log.status === 'success' ? '#48bb78' : '#f56565',
|
||||||
|
color: 'white',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{log.status === 'success' ? '성공' : '실패'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||||
|
{new Date(log.login_time).toLocaleString('ko-KR')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : activeTab === 'activity-logs' ? (
|
||||||
|
// 활동 로그 탭
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<h3 style={{ marginBottom: '16px', color: '#2d3748' }}>📊 활동 로그</h3>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #e2e8f0' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||||
|
사용자명
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||||
|
활동 유형
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||||
|
상세 내용
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600', color: '#4a5568' }}>
|
||||||
|
시간
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activityLogs.map((log, index) => (
|
||||||
|
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<td style={{ padding: '12px', fontWeight: '500' }}>
|
||||||
|
{log.username}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<span style={{
|
||||||
|
background: getActivityTypeColor(log.activity_type),
|
||||||
|
color: 'white',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{log.activity_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||||
|
{log.activity_description}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', color: '#4a5568' }}>
|
||||||
|
{new Date(log.created_at).toLocaleString('ko-KR')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
36
nginx-proxy.conf
Normal file
36
nginx-proxy.conf
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# 클라이언트 최대 업로드 크기
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# 프론트엔드 정적 파일
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:3000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 요청을 백엔드로 프록시
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Server $host;
|
||||||
|
proxy_redirect off;
|
||||||
|
|
||||||
|
proxy_request_buffering off;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user