Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
백엔드: - GET /dashboard/projects 엔드포인트 추가 - 프로젝트 목록 조회 API 구현 프론트엔드: - parseMaterialInfo 결과를 useMemo로 캐싱 - parsedMaterialsMap으로 중복 계산 방지 - getParsedInfo() 함수로 캐시된 값 사용 - 성능 개선: 1315개 자재 × 6번 계산 → 1315개 자재 × 1번 계산 - 약 80% 계산 감소 (8000번 → 1500번) 효과: - 페이지 로딩 속도 대폭 향상 - 메모리 사용량 감소 - 필터/정렬 기능 유지하면서 가벼워짐
549 lines
20 KiB
Python
549 lines
20 KiB
Python
"""
|
||
대시보드 API
|
||
사용자별 맞춤형 대시보드 데이터 제공
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import text, func
|
||
from typing import Optional, Dict, Any, List
|
||
from datetime import datetime, timedelta
|
||
|
||
from ..database import get_db
|
||
from ..auth.middleware import get_current_user
|
||
from ..services.activity_logger import ActivityLogger
|
||
from ..utils.logger import get_logger
|
||
|
||
logger = get_logger(__name__)
|
||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||
|
||
|
||
@router.get("/stats")
|
||
async def get_dashboard_stats(
|
||
current_user: dict = Depends(get_current_user),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
사용자별 맞춤형 대시보드 통계 데이터 조회
|
||
|
||
Returns:
|
||
dict: 사용자 역할에 맞는 통계 데이터
|
||
"""
|
||
try:
|
||
username = current_user.get('username')
|
||
user_role = current_user.get('role', 'user')
|
||
|
||
# 역할별 맞춤 통계 생성
|
||
if user_role == 'admin':
|
||
stats = await get_admin_stats(db)
|
||
elif user_role == 'manager':
|
||
stats = await get_manager_stats(db, username)
|
||
elif user_role == 'designer':
|
||
stats = await get_designer_stats(db, username)
|
||
elif user_role == 'purchaser':
|
||
stats = await get_purchaser_stats(db, username)
|
||
else:
|
||
stats = await get_user_stats(db, username)
|
||
|
||
return {
|
||
"success": True,
|
||
"user_role": user_role,
|
||
"stats": stats
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Dashboard stats error: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"대시보드 통계 조회 실패: {str(e)}")
|
||
|
||
|
||
@router.get("/activities")
|
||
async def get_user_activities(
|
||
current_user: dict = Depends(get_current_user),
|
||
limit: int = Query(10, ge=1, le=50),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
사용자 활동 이력 조회
|
||
|
||
Args:
|
||
limit: 조회할 활동 수 (1-50)
|
||
|
||
Returns:
|
||
dict: 사용자 활동 이력
|
||
"""
|
||
try:
|
||
username = current_user.get('username')
|
||
|
||
activity_logger = ActivityLogger(db)
|
||
activities = activity_logger.get_user_activities(
|
||
username=username,
|
||
limit=limit
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"activities": activities,
|
||
"total": len(activities)
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"User activities error: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"활동 이력 조회 실패: {str(e)}")
|
||
|
||
|
||
@router.get("/recent-activities")
|
||
async def get_recent_activities(
|
||
current_user: dict = Depends(get_current_user),
|
||
days: int = Query(7, ge=1, le=30),
|
||
limit: int = Query(20, ge=1, le=100),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
최근 전체 활동 조회 (관리자/매니저용)
|
||
|
||
Args:
|
||
days: 조회 기간 (일)
|
||
limit: 조회할 활동 수
|
||
|
||
Returns:
|
||
dict: 최근 활동 이력
|
||
"""
|
||
try:
|
||
user_role = current_user.get('role', 'user')
|
||
|
||
# 관리자와 매니저만 전체 활동 조회 가능
|
||
if user_role not in ['admin', 'manager']:
|
||
raise HTTPException(status_code=403, detail="권한이 없습니다")
|
||
|
||
activity_logger = ActivityLogger(db)
|
||
activities = activity_logger.get_recent_activities(
|
||
days=days,
|
||
limit=limit
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"activities": activities,
|
||
"period_days": days,
|
||
"total": len(activities)
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Recent activities error: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"최근 활동 조회 실패: {str(e)}")
|
||
|
||
|
||
async def get_admin_stats(db: Session) -> Dict[str, Any]:
|
||
"""관리자용 통계"""
|
||
try:
|
||
# 전체 프로젝트 수
|
||
total_projects_query = text("SELECT COUNT(*) FROM jobs WHERE status != 'deleted'")
|
||
total_projects = db.execute(total_projects_query).scalar()
|
||
|
||
# 활성 사용자 수 (최근 30일 로그인)
|
||
active_users_query = text("""
|
||
SELECT COUNT(DISTINCT username)
|
||
FROM user_activity_logs
|
||
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||
""")
|
||
active_users = db.execute(active_users_query).scalar() or 0
|
||
|
||
# 오늘 업로드된 파일 수
|
||
today_uploads_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM files
|
||
WHERE DATE(upload_date) = CURRENT_DATE
|
||
""")
|
||
today_uploads = db.execute(today_uploads_query).scalar() or 0
|
||
|
||
# 전체 자재 수
|
||
total_materials_query = text("SELECT COUNT(*) FROM materials")
|
||
total_materials = db.execute(total_materials_query).scalar() or 0
|
||
|
||
return {
|
||
"title": "시스템 관리자",
|
||
"subtitle": "전체 시스템을 관리하고 모니터링합니다",
|
||
"metrics": [
|
||
{"label": "전체 프로젝트 수", "value": total_projects, "icon": "📋", "color": "#667eea"},
|
||
{"label": "활성 사용자 수", "value": active_users, "icon": "👥", "color": "#48bb78"},
|
||
{"label": "시스템 상태", "value": "정상", "icon": "🟢", "color": "#38b2ac"},
|
||
{"label": "오늘 업로드", "value": today_uploads, "icon": "📤", "color": "#ed8936"}
|
||
]
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Admin stats error: {str(e)}")
|
||
raise
|
||
|
||
|
||
async def get_manager_stats(db: Session, username: str) -> Dict[str, Any]:
|
||
"""매니저용 통계"""
|
||
try:
|
||
# 담당 프로젝트 수 (향후 assigned_to 필드 활용)
|
||
assigned_projects_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM jobs
|
||
WHERE (assigned_to = :username OR created_by = :username)
|
||
AND status != 'deleted'
|
||
""")
|
||
assigned_projects = db.execute(assigned_projects_query, {"username": username}).scalar() or 0
|
||
|
||
# 이번 주 완료된 작업 (활동 로그 기반)
|
||
week_completed_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM user_activity_logs
|
||
WHERE activity_type IN ('PROJECT_CREATE', 'PURCHASE_CONFIRM')
|
||
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||
""")
|
||
week_completed = db.execute(week_completed_query).scalar() or 0
|
||
|
||
# 승인 대기 (구매 확정 대기 등)
|
||
pending_approvals_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM material_purchase_tracking
|
||
WHERE purchase_status = 'PENDING'
|
||
OR purchase_status = 'REQUESTED'
|
||
""")
|
||
pending_approvals = db.execute(pending_approvals_query).scalar() or 0
|
||
|
||
return {
|
||
"title": "프로젝트 매니저",
|
||
"subtitle": "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
|
||
"metrics": [
|
||
{"label": "담당 프로젝트", "value": assigned_projects, "icon": "📋", "color": "#667eea"},
|
||
{"label": "팀 진행률", "value": "87%", "icon": "📈", "color": "#48bb78"},
|
||
{"label": "승인 대기", "value": pending_approvals, "icon": "⏳", "color": "#ed8936"},
|
||
{"label": "이번 주 완료", "value": week_completed, "icon": "✅", "color": "#38b2ac"}
|
||
]
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Manager stats error: {str(e)}")
|
||
raise
|
||
|
||
|
||
async def get_designer_stats(db: Session, username: str) -> Dict[str, Any]:
|
||
"""설계자용 통계"""
|
||
try:
|
||
# 내가 업로드한 BOM 파일 수
|
||
my_files_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM files
|
||
WHERE uploaded_by = :username
|
||
AND is_active = true
|
||
""")
|
||
my_files = db.execute(my_files_query, {"username": username}).scalar() or 0
|
||
|
||
# 분류된 자재 수
|
||
classified_materials_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM materials m
|
||
JOIN files f ON m.file_id = f.id
|
||
WHERE f.uploaded_by = :username
|
||
AND m.classified_category IS NOT NULL
|
||
""")
|
||
classified_materials = db.execute(classified_materials_query, {"username": username}).scalar() or 0
|
||
|
||
# 검증 대기 자재 수
|
||
pending_verification_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM materials m
|
||
JOIN files f ON m.file_id = f.id
|
||
WHERE f.uploaded_by = :username
|
||
AND m.is_verified = false
|
||
""")
|
||
pending_verification = db.execute(pending_verification_query, {"username": username}).scalar() or 0
|
||
|
||
# 이번 주 업로드 수
|
||
week_uploads_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM files
|
||
WHERE uploaded_by = :username
|
||
AND upload_date >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||
""")
|
||
week_uploads = db.execute(week_uploads_query, {"username": username}).scalar() or 0
|
||
|
||
# 분류 완료율 계산
|
||
total_materials_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM materials m
|
||
JOIN files f ON m.file_id = f.id
|
||
WHERE f.uploaded_by = :username
|
||
""")
|
||
total_materials = db.execute(total_materials_query, {"username": username}).scalar() or 1
|
||
|
||
classification_rate = f"{(classified_materials / total_materials * 100):.0f}%" if total_materials > 0 else "0%"
|
||
|
||
return {
|
||
"title": "설계 담당자",
|
||
"subtitle": "BOM 파일을 관리하고 자재를 분류합니다",
|
||
"metrics": [
|
||
{"label": "내 BOM 파일", "value": my_files, "icon": "📄", "color": "#667eea"},
|
||
{"label": "분류 완료율", "value": classification_rate, "icon": "🎯", "color": "#48bb78"},
|
||
{"label": "검증 대기", "value": pending_verification, "icon": "⏳", "color": "#ed8936"},
|
||
{"label": "이번 주 업로드", "value": week_uploads, "icon": "📤", "color": "#9f7aea"}
|
||
]
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Designer stats error: {str(e)}")
|
||
raise
|
||
|
||
|
||
async def get_purchaser_stats(db: Session, username: str) -> Dict[str, Any]:
|
||
"""구매자용 통계"""
|
||
try:
|
||
# 구매 요청 수
|
||
purchase_requests_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM material_purchase_tracking
|
||
WHERE purchase_status IN ('PENDING', 'REQUESTED')
|
||
""")
|
||
purchase_requests = db.execute(purchase_requests_query).scalar() or 0
|
||
|
||
# 발주 완료 수
|
||
orders_completed_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM material_purchase_tracking
|
||
WHERE purchase_status = 'CONFIRMED'
|
||
AND confirmed_by = :username
|
||
""")
|
||
orders_completed = db.execute(orders_completed_query, {"username": username}).scalar() or 0
|
||
|
||
# 입고 대기 수
|
||
receiving_pending_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM material_purchase_tracking
|
||
WHERE purchase_status = 'ORDERED'
|
||
""")
|
||
receiving_pending = db.execute(receiving_pending_query).scalar() or 0
|
||
|
||
# 이번 달 구매 금액 (임시 데이터)
|
||
monthly_amount = "₩2.3M" # 실제로는 계산 필요
|
||
|
||
return {
|
||
"title": "구매 담당자",
|
||
"subtitle": "구매 요청을 처리하고 발주를 관리합니다",
|
||
"metrics": [
|
||
{"label": "구매 요청", "value": purchase_requests, "icon": "🛒", "color": "#667eea"},
|
||
{"label": "발주 완료", "value": orders_completed, "icon": "✅", "color": "#48bb78"},
|
||
{"label": "입고 대기", "value": receiving_pending, "icon": "📦", "color": "#ed8936"},
|
||
{"label": "이번 달 금액", "value": monthly_amount, "icon": "💰", "color": "#9f7aea"}
|
||
]
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Purchaser stats error: {str(e)}")
|
||
raise
|
||
|
||
|
||
async def get_user_stats(db: Session, username: str) -> Dict[str, Any]:
|
||
"""일반 사용자용 통계"""
|
||
try:
|
||
# 내 활동 수 (최근 7일)
|
||
my_activities_query = text("""
|
||
SELECT COUNT(*)
|
||
FROM user_activity_logs
|
||
WHERE username = :username
|
||
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||
""")
|
||
my_activities = db.execute(my_activities_query, {"username": username}).scalar() or 0
|
||
|
||
# 접근 가능한 프로젝트 수 (임시)
|
||
accessible_projects = 5
|
||
|
||
return {
|
||
"title": "일반 사용자",
|
||
"subtitle": "할당된 업무를 수행하고 프로젝트에 참여합니다",
|
||
"metrics": [
|
||
{"label": "내 업무", "value": 6, "icon": "📋", "color": "#667eea"},
|
||
{"label": "완료율", "value": "75%", "icon": "📈", "color": "#48bb78"},
|
||
{"label": "대기 중", "value": 2, "icon": "⏳", "color": "#ed8936"},
|
||
{"label": "이번 주 활동", "value": my_activities, "icon": "🎯", "color": "#9f7aea"}
|
||
]
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"User stats error: {str(e)}")
|
||
raise
|
||
|
||
|
||
@router.get("/quick-actions")
|
||
async def get_quick_actions(
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
사용자 역할별 빠른 작업 메뉴 조회
|
||
|
||
Returns:
|
||
dict: 역할별 빠른 작업 목록
|
||
"""
|
||
try:
|
||
user_role = current_user.get('role', 'user')
|
||
|
||
quick_actions = {
|
||
"admin": [
|
||
{"title": "사용자 관리", "icon": "👤", "path": "/admin/users", "color": "#667eea"},
|
||
{"title": "시스템 설정", "icon": "⚙️", "path": "/admin/settings", "color": "#48bb78"},
|
||
{"title": "백업 관리", "icon": "💾", "path": "/admin/backup", "color": "#ed8936"},
|
||
{"title": "활동 로그", "icon": "📊", "path": "/admin/logs", "color": "#9f7aea"}
|
||
],
|
||
"manager": [
|
||
{"title": "프로젝트 생성", "icon": "➕", "path": "/projects/new", "color": "#667eea"},
|
||
{"title": "팀 관리", "icon": "👥", "path": "/team", "color": "#48bb78"},
|
||
{"title": "진행 상황", "icon": "📊", "path": "/progress", "color": "#38b2ac"},
|
||
{"title": "승인 처리", "icon": "✅", "path": "/approvals", "color": "#ed8936"}
|
||
],
|
||
"designer": [
|
||
{"title": "BOM 업로드", "icon": "📤", "path": "/upload", "color": "#667eea"},
|
||
{"title": "자재 분류", "icon": "🔧", "path": "/materials", "color": "#48bb78"},
|
||
{"title": "리비전 관리", "icon": "🔄", "path": "/revisions", "color": "#38b2ac"},
|
||
{"title": "분류 검증", "icon": "✅", "path": "/verify", "color": "#ed8936"}
|
||
],
|
||
"purchaser": [
|
||
{"title": "구매 확정", "icon": "🛒", "path": "/purchase", "color": "#667eea"},
|
||
{"title": "발주 관리", "icon": "📋", "path": "/orders", "color": "#48bb78"},
|
||
{"title": "공급업체", "icon": "🏢", "path": "/suppliers", "color": "#38b2ac"},
|
||
{"title": "입고 처리", "icon": "📦", "path": "/receiving", "color": "#ed8936"}
|
||
],
|
||
"user": [
|
||
{"title": "내 업무", "icon": "📋", "path": "/my-tasks", "color": "#667eea"},
|
||
{"title": "프로젝트 보기", "icon": "👁️", "path": "/projects", "color": "#48bb78"},
|
||
{"title": "리포트 다운로드", "icon": "📊", "path": "/reports", "color": "#38b2ac"},
|
||
{"title": "도움말", "icon": "❓", "path": "/help", "color": "#9f7aea"}
|
||
]
|
||
}
|
||
|
||
return {
|
||
"success": True,
|
||
"user_role": user_role,
|
||
"quick_actions": quick_actions.get(user_role, quick_actions["user"])
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Quick actions error: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"빠른 작업 조회 실패: {str(e)}")
|
||
|
||
|
||
@router.get("/projects")
|
||
async def get_projects(
|
||
current_user: dict = Depends(get_current_user),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
프로젝트 목록 조회
|
||
|
||
Returns:
|
||
dict: 프로젝트 목록
|
||
"""
|
||
try:
|
||
query = text("""
|
||
SELECT
|
||
id,
|
||
job_no,
|
||
official_project_code,
|
||
job_name,
|
||
project_type,
|
||
created_at,
|
||
updated_at
|
||
FROM projects
|
||
ORDER BY created_at DESC
|
||
""")
|
||
|
||
results = db.execute(query).fetchall()
|
||
|
||
projects = []
|
||
for row in results:
|
||
projects.append({
|
||
"id": row.id,
|
||
"job_no": row.job_no,
|
||
"official_project_code": row.official_project_code,
|
||
"job_name": row.job_name,
|
||
"project_name": row.job_name, # 호환성을 위해 추가
|
||
"project_type": row.project_type,
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"updated_at": row.updated_at.isoformat() if row.updated_at else None
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"projects": projects,
|
||
"count": len(projects)
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"프로젝트 목록 조회 실패: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"프로젝트 목록 조회 실패: {str(e)}")
|
||
|
||
|
||
@router.patch("/projects/{project_id}")
|
||
async def update_project_name(
|
||
project_id: int,
|
||
job_name: str = Query(..., description="새 프로젝트 이름"),
|
||
current_user: dict = Depends(get_current_user),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
프로젝트 이름 수정
|
||
|
||
Args:
|
||
project_id: 프로젝트 ID
|
||
job_name: 새 프로젝트 이름
|
||
|
||
Returns:
|
||
dict: 수정 결과
|
||
"""
|
||
try:
|
||
# 프로젝트 존재 확인
|
||
query = text("SELECT * FROM projects WHERE id = :project_id")
|
||
result = db.execute(query, {"project_id": project_id}).fetchone()
|
||
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||
|
||
# 프로젝트 이름 업데이트
|
||
update_query = text("""
|
||
UPDATE projects
|
||
SET job_name = :job_name,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = :project_id
|
||
RETURNING *
|
||
""")
|
||
|
||
updated = db.execute(update_query, {
|
||
"job_name": job_name,
|
||
"project_id": project_id
|
||
}).fetchone()
|
||
|
||
db.commit()
|
||
|
||
# 활동 로그 기록
|
||
ActivityLogger.log_activity(
|
||
db=db,
|
||
user_id=current_user.get('user_id'),
|
||
action="UPDATE_PROJECT",
|
||
target_type="PROJECT",
|
||
target_id=project_id,
|
||
details=f"프로젝트 이름 변경: {job_name}"
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "프로젝트 이름이 수정되었습니다",
|
||
"project": {
|
||
"id": updated.id,
|
||
"job_no": updated.job_no,
|
||
"job_name": updated.job_name,
|
||
"official_project_code": updated.official_project_code
|
||
}
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"프로젝트 수정 실패: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"프로젝트 수정 실패: {str(e)}")
|