feat: tkuser 통합 관리 서비스 + 전체 시스템 SSO 쿠키 인증 통합

- tkuser 서비스 신규 추가 (API + Web)
  - 사용자/권한/프로젝트/부서/작업자/작업장/설비/작업/휴가 통합 관리
  - 작업장 탭: 공장→작업장 드릴다운 네비게이션 + 구역지도 클릭 연동
  - 작업 탭: 공정(work_types)→작업(tasks) 계층 관리
  - 휴가 탭: 유형 관리 + 연차 배정(근로기준법 자동계산)
- 전 시스템 SSO 쿠키 인증으로 통합 (.technicalkorea.net 공유)
- System 2: 작업 이슈 리포트 기능 강화
- System 3: tkuser API 연동, 페이지 권한 체계 적용
- docker-compose에 tkuser-api, tkuser-web 서비스 추가
- ARCHITECTURE.md, DEPLOYMENT.md 문서 작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-12 13:45:52 +09:00
parent 6495b8af32
commit 733bb0cb35
96 changed files with 9721 additions and 825 deletions

View File

@@ -3,10 +3,18 @@ from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.ext.declarative import declarative_base
import os
from typing import Generator
from urllib.parse import quote_plus
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://mproject:mproject2024@localhost:5432/mproject")
# DB 설정 — 개별 환경변수에서 읽어서 URL 구성 (비밀번호 특수문자 처리)
DB_HOST = os.getenv("DB_HOST", "mariadb")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_USER = os.getenv("DB_USER", "hyungi_user")
DB_PASSWORD = os.getenv("DB_PASSWORD", "password")
DB_NAME = os.getenv("DB_NAME", "hyungi")
engine = create_engine(DATABASE_URL)
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
engine = create_engine(DATABASE_URL, pool_recycle=3600)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

View File

@@ -1,5 +1,4 @@
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum, Index, JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime, timezone, timedelta
@@ -15,8 +14,11 @@ def get_kst_now():
Base = declarative_base()
class UserRole(str, enum.Enum):
admin = "admin" # 관리자
user = "user" # 일반 사용
system = "system" # 시스템 관리자
admin = "admin" # 관리
support_team = "support_team" # 지원팀
leader = "leader" # 리더
user = "user" # 일반 사용자
class IssueStatus(str, enum.Enum):
new = "new"
@@ -51,77 +53,76 @@ class DepartmentType(str, enum.Enum):
sales = "sales" # 영업
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String)
__tablename__ = "sso_users"
# Column mapping: Python attr → DB column
id = Column("user_id", Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
hashed_password = Column("password_hash", String(255), nullable=False)
full_name = Column("name", String(100))
role = Column(Enum(UserRole), default=UserRole.user)
department = Column(Enum(DepartmentType)) # 부서 정보 추가
department = Column(String(50))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=get_kst_now)
# Relationships
issues = relationship("Issue", back_populates="reporter", foreign_keys="Issue.reporter_id")
reviewed_issues = relationship("Issue", foreign_keys="Issue.reviewed_by_id")
daily_works = relationship("DailyWork", back_populates="created_by")
projects = relationship("Project", back_populates="created_by")
page_permissions = relationship("UserPagePermission", back_populates="user", foreign_keys="UserPagePermission.user_id")
class UserPagePermission(Base):
__tablename__ = "user_page_permissions"
__tablename__ = "qc_user_page_permissions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("sso_users.user_id", ondelete="CASCADE"), nullable=False)
page_name = Column(String(50), nullable=False)
can_access = Column(Boolean, default=False)
granted_by_id = Column(Integer, ForeignKey("users.id"))
granted_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
granted_at = Column(DateTime, default=get_kst_now)
notes = Column(Text)
# Relationships
user = relationship("User", back_populates="page_permissions", foreign_keys=[user_id])
granted_by = relationship("User", foreign_keys=[granted_by_id], post_update=True)
# Unique constraint
__table_args__ = (
Index('idx_user_page_permissions_user_id', 'user_id'),
Index('idx_user_page_permissions_page_name', 'page_name'),
Index('idx_qc_user_page_perm_user_id', 'user_id'),
Index('idx_qc_user_page_perm_page_name', 'page_name'),
)
class Issue(Base):
__tablename__ = "issues"
__tablename__ = "qc_issues"
id = Column(Integer, primary_key=True, index=True)
photo_path = Column(String)
photo_path2 = Column(String)
photo_path3 = Column(String)
photo_path4 = Column(String)
photo_path5 = Column(String)
photo_path = Column(String(500))
photo_path2 = Column(String(500))
photo_path3 = Column(String(500))
photo_path4 = Column(String(500))
photo_path5 = Column(String(500))
category = Column(Enum(IssueCategory), nullable=False)
description = Column(Text, nullable=False)
status = Column(Enum(IssueStatus), default=IssueStatus.new)
reporter_id = Column(Integer, ForeignKey("users.id"))
project_id = Column(BigInteger, ForeignKey("projects.id"))
reporter_id = Column(Integer, ForeignKey("sso_users.user_id"))
project_id = Column(Integer) # FK 제거 — projects는 tkuser에서 관리
report_date = Column(DateTime, default=get_kst_now)
work_hours = Column(Float, default=0)
detail_notes = Column(Text)
# 수신함 워크플로우 관련 컬럼들
review_status = Column(Enum(ReviewStatus), default=ReviewStatus.pending_review)
disposal_reason = Column(Enum(DisposalReasonType))
custom_disposal_reason = Column(Text)
disposed_at = Column(DateTime)
reviewed_by_id = Column(Integer, ForeignKey("users.id"))
reviewed_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
reviewed_at = Column(DateTime)
original_data = Column(JSONB) # 원본 데이터 보존
modification_log = Column(JSONB, default=lambda: []) # 수정 이력
original_data = Column(JSON)
modification_log = Column(JSON, default=lambda: [])
# 중복 신고 추적 시스템
duplicate_of_issue_id = Column(Integer, ForeignKey("issues.id")) # 중복 대상 이슈 ID
duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록
duplicate_of_issue_id = Column(Integer, ForeignKey("qc_issues.id"))
duplicate_reporters = Column(JSON, default=lambda: [])
# 관리함에서 사용할 추가 필드들
solution = Column(Text) # 해결방안 (관리함에서 입력)
responsible_department = Column(Enum(DepartmentType)) # 담당부서
@@ -133,16 +134,16 @@ class Issue(Base):
project_sequence_no = Column(Integer) # 프로젝트별 순번 (No)
final_description = Column(Text) # 최종 내용 (수정본 또는 원본)
final_category = Column(Enum(IssueCategory)) # 최종 카테고리 (수정본 또는 원본)
# 추가 정보 필드들 (관리함에서 기록용)
responsible_person_detail = Column(String(200)) # 해당자 상세 정보
cause_detail = Column(Text) # 원인 상세 정보
additional_info_updated_at = Column(DateTime) # 추가 정보 입력 시간
additional_info_updated_by_id = Column(Integer, ForeignKey("users.id")) # 추가 정보 입력자
additional_info_updated_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 추가 정보 입력자
# 완료 신청 관련 필드들
completion_requested_at = Column(DateTime) # 완료 신청 시간
completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자
completion_requested_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 완료 신청자
completion_photo_path = Column(String(500)) # 완료 사진 1
completion_photo_path2 = Column(String(500)) # 완료 사진 2
completion_photo_path3 = Column(String(500)) # 완료 사진 3
@@ -152,7 +153,7 @@ class Issue(Base):
# 완료 반려 관련 필드들
completion_rejected_at = Column(DateTime) # 완료 반려 시간
completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자
completion_rejected_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 완료 반려자
completion_rejection_reason = Column(Text) # 완료 반려 사유
# 일일보고서 추출 이력
@@ -162,26 +163,29 @@ class Issue(Base):
# Relationships
reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id])
reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues")
project = relationship("Project", back_populates="issues")
project = relationship("Project", back_populates="issues",
primaryjoin="Issue.project_id == Project.id",
foreign_keys=[project_id])
duplicate_of = relationship("Issue", remote_side=[id], foreign_keys=[duplicate_of_issue_id])
class Project(Base):
__tablename__ = "projects"
id = Column(BigInteger, primary_key=True, index=True)
job_no = Column(String, unique=True, nullable=False, index=True)
project_name = Column(String, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=get_kst_now)
# Column mapping: Python attr id → DB column project_id
id = Column("project_id", Integer, primary_key=True, index=True)
job_no = Column(String(50), unique=True, nullable=False, index=True)
project_name = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=get_kst_now)
# Relationships
created_by = relationship("User", back_populates="projects")
issues = relationship("Issue", back_populates="project")
issues = relationship("Issue", back_populates="project",
primaryjoin="Project.id == Issue.project_id",
foreign_keys="[Issue.project_id]")
class DailyWork(Base):
__tablename__ = "daily_works"
__tablename__ = "qc_daily_works"
id = Column(Integer, primary_key=True, index=True)
date = Column(DateTime, nullable=False, index=True)
worker_count = Column(Integer, nullable=False)
@@ -190,20 +194,20 @@ class DailyWork(Base):
overtime_hours = Column(Float, default=0)
overtime_total = Column(Float, default=0)
total_hours = Column(Float, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"))
created_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
created_at = Column(DateTime, default=get_kst_now)
# Relationships
created_by = relationship("User", back_populates="daily_works")
class ProjectDailyWork(Base):
__tablename__ = "project_daily_works"
__tablename__ = "qc_project_daily_works"
id = Column(Integer, primary_key=True, index=True)
date = Column(DateTime, nullable=False, index=True)
project_id = Column(BigInteger, ForeignKey("projects.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False)
hours = Column(Float, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"))
created_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
created_at = Column(DateTime, default=get_kst_now)
# Relationships
@@ -211,13 +215,13 @@ class ProjectDailyWork(Base):
created_by = relationship("User")
class DeletionLog(Base):
__tablename__ = "deletion_logs"
__tablename__ = "qc_deletion_logs"
id = Column(Integer, primary_key=True, index=True)
entity_type = Column(String(50), nullable=False) # 'issue', 'project', 'daily_work' 등
entity_id = Column(Integer, nullable=False) # 삭제된 엔티티의 ID
entity_data = Column(JSONB, nullable=False) # 삭제된 데이터 전체 (JSON)
deleted_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
entity_data = Column(JSON, nullable=False) # 삭제된 데이터 전체 (JSON)
deleted_by_id = Column(Integer, ForeignKey("sso_users.user_id"), nullable=False)
deleted_at = Column(DateTime, default=get_kst_now, nullable=False)
reason = Column(Text) # 삭제 사유 (선택사항)

View File

@@ -4,7 +4,10 @@ from typing import Optional, List, Dict, Any
from enum import Enum
class UserRole(str, Enum):
system = "system"
admin = "admin"
support_team = "support_team"
leader = "leader"
user = "user"
class IssueStatus(str, Enum):
@@ -285,12 +288,11 @@ class ProjectUpdate(BaseModel):
class Project(ProjectBase):
id: int
created_by_id: int
created_by: User
created_by_id: Optional[int] = None
created_by: Optional[User] = None
created_at: datetime
is_active: bool
# issues: Optional[List['Issue']] = None # 순환 참조 방지를 위해 제거
class Config:
from_attributes = True

View File

@@ -8,10 +8,12 @@ from database.models import Base
from routers import auth, issues, daily_work, reports, projects, page_permissions, inbox, management
from services.auth_service import create_admin_user
# 데이터베이스 테이블 생성
# 메타데이터 캐시 클리어
Base.metadata.clear()
Base.metadata.create_all(bind=engine)
# 데이터베이스 테이블 생성 (sso_users, projects는 이미 존재하므로 제외)
tables_to_create = [
table for name, table in Base.metadata.tables.items()
if name not in ("sso_users", "projects")
]
Base.metadata.create_all(bind=engine, tables=tables_to_create)
# FastAPI 앱 생성
app = FastAPI(

View File

@@ -5,7 +5,7 @@ python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.2
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
pymysql==1.1.0
alembic==1.12.1
pydantic==2.5.0
pydantic-settings==2.1.0
@@ -13,3 +13,4 @@ pillow==10.1.0
pillow-heif==0.13.0
reportlab==4.0.7
openpyxl==3.1.2
httpx==0.27.0

View File

@@ -35,7 +35,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
sso_role = payload.get("role", "user")
role_map = {
"Admin": UserRole.admin,
"System Admin": UserRole.admin,
"System Admin": UserRole.system,
"system": UserRole.system,
"admin": UserRole.admin,
"support_team": UserRole.support_team,
"leader": UserRole.leader,
}
mapped_role = role_map.get(sso_role, UserRole.user)
@@ -50,7 +54,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
return sso_user
async def get_current_admin(current_user: User = Depends(get_current_user)):
if current_user.role != UserRole.admin:
if current_user.role not in [UserRole.admin, UserRole.system]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"

View File

@@ -1,10 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime
from database.database import get_db
from database.models import Issue, User, Project, ReviewStatus, DisposalReasonType
from database.models import Issue, User, ReviewStatus, DisposalReasonType
from utils.tkuser_client import get_token_from_request
import utils.tkuser_client as tkuser_client
from database.schemas import (
InboxIssue, IssueDisposalRequest, IssueReviewRequest,
IssueStatusUpdateRequest, ModificationLogEntry, ManagementUpdateRequest
@@ -123,6 +125,7 @@ async def dispose_issue(
async def review_issue(
issue_id: int,
review_request: IssueReviewRequest,
request: Request,
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
db: Session = Depends(get_db)
):
@@ -157,9 +160,10 @@ async def review_issue(
# 프로젝트 변경
if review_request.project_id is not None and review_request.project_id != issue.project_id:
# 프로젝트 존재 확인
# 프로젝트 존재 확인 (tkuser API)
if review_request.project_id != 0: # 0은 프로젝트 없음을 의미
project = db.query(Project).filter(Project.id == review_request.project_id).first()
token = get_token_from_request(request)
project = await tkuser_client.get_project(token, review_request.project_id)
if not project:
raise HTTPException(status_code=400, detail="존재하지 않는 프로젝트입니다.")
@@ -264,12 +268,11 @@ async def update_issue_status(
# 진행 중 또는 완료 상태로 변경 시 프로젝트별 순번 자동 할당
if status_request.review_status in [ReviewStatus.in_progress, ReviewStatus.completed]:
if not issue.project_sequence_no:
from sqlalchemy import text
result = db.execute(
text("SELECT generate_project_sequence_no(:project_id)"),
{"project_id": issue.project_id}
)
issue.project_sequence_no = result.scalar()
from sqlalchemy import func
max_seq = db.query(func.coalesce(func.max(Issue.project_sequence_no), 0)).filter(
Issue.project_id == issue.project_id
).scalar()
issue.project_sequence_no = max_seq + 1
# 완료 사진 업로드 처리
if status_request.completion_photo and status_request.review_status == ReviewStatus.completed:

View File

@@ -259,9 +259,9 @@ async def get_issue_stats(
query = query.filter(Issue.reporter_id == current_user.id)
total = query.count()
new = query.filter(Issue.status == IssueStatus.NEW).count()
progress = query.filter(Issue.status == IssueStatus.PROGRESS).count()
complete = query.filter(Issue.status == IssueStatus.COMPLETE).count()
new = query.filter(Issue.status == IssueStatus.new).count()
progress = query.filter(Issue.status == IssueStatus.progress).count()
complete = query.filter(Issue.status == IssueStatus.complete).count()
return {
"total": total,

View File

@@ -1,10 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database.database import get_db
from database.models import Project, User, UserRole
from database.schemas import ProjectCreate, ProjectUpdate, Project as ProjectSchema
from fastapi import APIRouter, Depends, HTTPException, Request, status
from database.models import User, UserRole
from database.schemas import ProjectCreate, ProjectUpdate
from routers.auth import get_current_user
from utils.tkuser_client import get_token_from_request
import utils.tkuser_client as tkuser_client
router = APIRouter(
prefix="/api/projects",
@@ -25,57 +24,36 @@ async def projects_options():
"""OPTIONS preflight 요청 처리"""
return {"message": "OK"}
@router.post("/", response_model=ProjectSchema)
@router.post("/")
async def create_project(
project: ProjectCreate,
db: Session = Depends(get_db),
request: Request,
current_user: User = Depends(check_admin_permission)
):
"""프로젝트 생성 (관리자만)"""
# Job No. 중복 확인
existing_project = db.query(Project).filter(Project.job_no == project.job_no).first()
if existing_project:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 Job No.입니다."
)
# 프로젝트 생성
db_project = Project(
job_no=project.job_no,
project_name=project.project_name,
created_by_id=current_user.id
)
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
"""프로젝트 생성 (관리자만) - tkuser API로 프록시"""
token = get_token_from_request(request)
return await tkuser_client.create_project(token, project.dict())
@router.get("/", response_model=List[ProjectSchema])
@router.get("/")
async def get_projects(
request: Request,
skip: int = 0,
limit: int = 100,
active_only: bool = True,
db: Session = Depends(get_db)
):
"""프로젝트 목록 조회"""
query = db.query(Project)
if active_only:
query = query.filter(Project.is_active == True)
projects = query.offset(skip).limit(limit).all()
return projects
"""프로젝트 목록 조회 - tkuser API로 프록시"""
token = get_token_from_request(request)
projects = await tkuser_client.get_projects(token, active_only=active_only)
return projects[skip:skip + limit]
@router.get("/{project_id}", response_model=ProjectSchema)
@router.get("/{project_id}")
async def get_project(
project_id: int,
db: Session = Depends(get_db)
request: Request,
):
"""특정 프로젝트 조회"""
project = db.query(Project).filter(Project.id == project_id).first()
"""특정 프로젝트 조회 - tkuser API로 프록시"""
token = get_token_from_request(request)
project = await tkuser_client.get_project(token, project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -83,47 +61,26 @@ async def get_project(
)
return project
@router.put("/{project_id}", response_model=ProjectSchema)
@router.put("/{project_id}")
async def update_project(
project_id: int,
project_update: ProjectUpdate,
db: Session = Depends(get_db),
request: Request,
current_user: User = Depends(check_admin_permission)
):
"""프로젝트 수정 (관리자만)"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프로젝트를 찾을 수 없습니다."
)
# 업데이트할 필드만 수정
update_data = project_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
db.commit()
db.refresh(project)
return project
"""프로젝트 수정 (관리자만) - tkuser API로 프록시"""
token = get_token_from_request(request)
return await tkuser_client.update_project(
token, project_id, project_update.dict(exclude_unset=True)
)
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: Session = Depends(get_db),
request: Request,
current_user: User = Depends(check_admin_permission)
):
"""프로젝트 삭제 (비활성화) (관리자만)"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프로젝트를 찾을 수 없습니다."
)
# 실제 삭제 대신 비활성화
project.is_active = False
db.commit()
"""프로젝트 삭제 (비활성화) (관리자만) - tkuser API로 프록시"""
token = get_token_from_request(request)
await tkuser_client.delete_project(token, project_id)
return {"message": "프로젝트가 삭제되었습니다."}

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
@@ -13,9 +13,11 @@ from openpyxl.drawing.image import Image as XLImage
import os
from database.database import get_db
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, ReviewStatus
from database import schemas
from routers.auth import get_current_user
from utils.tkuser_client import get_token_from_request
import utils.tkuser_client as tkuser_client
router = APIRouter(prefix="/api/reports", tags=["reports"])
@@ -140,6 +142,7 @@ async def get_report_daily_works(
@router.get("/daily-preview")
async def preview_daily_report(
project_id: int,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
@@ -149,8 +152,9 @@ async def preview_daily_report(
if current_user.role != UserRole.admin:
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
# 프로젝트 확인
project = db.query(Project).filter(Project.id == project_id).first()
# 프로젝트 확인 (tkuser API)
token = get_token_from_request(request)
project = await tkuser_client.get_project(token, project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
@@ -178,7 +182,7 @@ async def preview_daily_report(
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
return {
"project": schemas.Project.from_orm(project),
"project": project,
"stats": stats,
"issues": issues_data,
"total_issues": len(issues)
@@ -186,31 +190,33 @@ async def preview_daily_report(
@router.post("/daily-export")
async def export_daily_report(
request: schemas.DailyReportRequest,
report_req: schemas.DailyReportRequest,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""품질팀용 일일보고서 엑셀 내보내기"""
# 권한 확인 (품질팀만 접근 가능)
if current_user.role != UserRole.admin:
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
# 프로젝트 확인
project = db.query(Project).filter(Project.id == request.project_id).first()
# 프로젝트 확인 (tkuser API)
token = get_token_from_request(request)
project = await tkuser_client.get_project(token, report_req.project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 관리함 데이터 조회
# 1. 진행 중인 항목 (모두 포함)
in_progress_only = db.query(Issue).filter(
Issue.project_id == request.project_id,
Issue.project_id == report_req.project_id,
Issue.review_status == ReviewStatus.in_progress
).all()
# 2. 완료된 항목 (모두 조회)
all_completed = db.query(Issue).filter(
Issue.project_id == request.project_id,
Issue.project_id == report_req.project_id,
Issue.review_status == ReviewStatus.completed
).all()
@@ -307,7 +313,7 @@ async def export_daily_report(
for ws, sheet_issues, sheet_title in sheets_data:
# 제목 및 기본 정보
ws.merge_cells('A1:L1')
ws['A1'] = f"{project.project_name} - {sheet_title}"
ws['A1'] = f"{project['project_name']} - {sheet_title}"
ws['A1'].font = Font(bold=True, size=16)
ws['A1'].alignment = center_alignment
@@ -725,7 +731,7 @@ async def export_daily_report(
# 파일명 생성
today = date.today().strftime('%Y%m%d')
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
filename = f"{project['project_name']}_일일보고서_{today}.xlsx"
# 한글 파일명을 위한 URL 인코딩
from urllib.parse import quote

View File

@@ -75,21 +75,11 @@ def authenticate_user(db: Session, username: str, password: str):
return user
def create_admin_user(db: Session):
"""초기 관리자 계정 생성"""
"""관리자 계정 확인 (SSO에서 관리, 여기서는 조회만)"""
admin_username = os.getenv("ADMIN_USERNAME", "hyungi")
admin_password = os.getenv("ADMIN_PASSWORD", "djg3-jj34-X3Q3")
existing_admin = db.query(User).filter(User.username == admin_username).first()
if not existing_admin:
admin_user = User(
username=admin_username,
hashed_password=get_password_hash(admin_password),
full_name="관리자",
role=UserRole.admin,
is_active=True
)
db.add(admin_user)
db.commit()
print(f"관리자 계정 생성됨: {admin_username}")
if existing_admin:
print(f"관리자 계정 확인됨: {admin_username} (role: {existing_admin.role.value})")
else:
print(f"관리자 계정이 이미 존재함: {admin_username}")
print(f"경고: 관리자 계정이 sso_users에 없습니다: {admin_username}")

View File

@@ -0,0 +1,106 @@
import httpx
import os
from fastapi import HTTPException, Request
TKUSER_API_URL = os.getenv("TKUSER_API_URL", "http://tkuser-api:3000")
def get_token_from_request(request: Request) -> str:
"""Request 헤더에서 Bearer 토큰 추출"""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:]
return request.cookies.get("sso_token", "")
def _headers(token: str) -> dict:
if not token:
raise HTTPException(status_code=401, detail="인증 토큰이 필요합니다.")
return {"Authorization": f"Bearer {token}"}
def _map_project(data: dict) -> dict:
"""tkuser API 응답을 S3 프론트엔드 형식으로 매핑 (project_id → id)"""
return {
"id": data.get("project_id"),
"job_no": data.get("job_no"),
"project_name": data.get("project_name"),
"is_active": data.get("is_active", True),
"created_at": data.get("created_at"),
}
async def get_projects(token: str, active_only: bool = True) -> list:
"""프로젝트 목록 조회"""
endpoint = "/api/projects/active" if active_only else "/api/projects"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{TKUSER_API_URL}{endpoint}", headers=_headers(token))
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="프로젝트 목록 조회 실패")
body = resp.json()
if not body.get("success"):
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
return [_map_project(p) for p in body.get("data", [])]
async def get_project(token: str, project_id: int) -> dict | None:
"""특정 프로젝트 조회. 없으면 None 반환"""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{TKUSER_API_URL}/api/projects/{project_id}", headers=_headers(token)
)
if resp.status_code == 404:
return None
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="프로젝트 조회 실패")
body = resp.json()
if not body.get("success"):
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
return _map_project(body.get("data"))
async def create_project(token: str, data: dict) -> dict:
"""프로젝트 생성"""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{TKUSER_API_URL}/api/projects", headers=_headers(token), json=data
)
if resp.status_code == 409:
raise HTTPException(status_code=400, detail="이미 존재하는 Job No.입니다.")
if resp.status_code not in (200, 201):
raise HTTPException(status_code=resp.status_code, detail="프로젝트 생성 실패")
body = resp.json()
if not body.get("success"):
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
return _map_project(body.get("data"))
async def update_project(token: str, project_id: int, data: dict) -> dict:
"""프로젝트 수정"""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.put(
f"{TKUSER_API_URL}/api/projects/{project_id}",
headers=_headers(token),
json=data,
)
if resp.status_code == 404:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="프로젝트 수정 실패")
body = resp.json()
if not body.get("success"):
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
return _map_project(body.get("data"))
async def delete_project(token: str, project_id: int) -> dict:
"""프로젝트 삭제 (비활성화)"""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(
f"{TKUSER_API_URL}/api/projects/{project_id}", headers=_headers(token)
)
if resp.status_code == 404:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="프로젝트 삭제 실패")
return resp.json()

View File

@@ -339,7 +339,7 @@
async function loadProjects() {
try {
// API에서 최신 프로젝트 데이터 가져오기
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>폐기함 - 작업보고서</title>
<title>폐기함 - 부적합 관리</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
@@ -91,39 +91,39 @@
<!-- 아카이브 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-green-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-green-600">완료</p>
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
<p class="text-sm text-slate-500">완료</p>
<p class="text-2xl font-bold text-slate-800" id="completedCount">0</p>
</div>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #475569;">
<div class="flex items-center">
<i class="fas fa-archive text-gray-500 text-xl mr-3"></i>
<i class="fas fa-archive text-slate-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-gray-600">보관</p>
<p class="text-2xl font-bold text-gray-700" id="archivedCount">0</p>
<p class="text-sm text-slate-500">보관</p>
<p class="text-2xl font-bold text-slate-800" id="archivedCount">0</p>
</div>
</div>
</div>
<div class="bg-red-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #dc2626;">
<div class="flex items-center">
<i class="fas fa-times-circle text-red-500 text-xl mr-3"></i>
<i class="fas fa-times-circle text-red-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-red-600">취소</p>
<p class="text-2xl font-bold text-red-700" id="cancelledCount">0</p>
<p class="text-sm text-slate-500">취소</p>
<p class="text-2xl font-bold text-slate-800" id="cancelledCount">0</p>
</div>
</div>
</div>
<div class="bg-purple-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #7c3aed;">
<div class="flex items-center">
<i class="fas fa-calendar-alt text-purple-500 text-xl mr-3"></i>
<i class="fas fa-calendar-alt text-purple-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-purple-600">이번 달</p>
<p class="text-2xl font-bold text-purple-700" id="thisMonthCount">0</p>
<p class="text-sm text-slate-500">이번 달</p>
<p class="text-2xl font-bold text-slate-800" id="thisMonthCount">0</p>
</div>
</div>
</div>
@@ -287,7 +287,7 @@
// 프로젝트 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,

View File

@@ -18,29 +18,27 @@
/* 대시보드 카드 스타일 */
.dashboard-card {
transition: all 0.3s ease;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: all 0.2s ease;
background: #ffffff;
border-left: 4px solid #64748b;
}
.dashboard-card:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 이슈 카드 스타일 (세련된 모던 스타일) */
/* 이슈 카드 스타일 */
.issue-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.2s ease;
border-left: 4px solid transparent;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
background: #ffffff;
}
.issue-card:hover {
transform: translateY(-8px) scale(1.02);
border-left-color: #3b82f6;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(59, 130, 246, 0.1),
0 0 20px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
border-left-color: #475569;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.issue-card label {
@@ -92,7 +90,7 @@
}
.progress-bar {
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
background: #475569;
transition: width 0.8s ease;
}
@@ -155,55 +153,43 @@
<!-- 전체 통계 대시보드 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="dashboard-card text-white p-6 rounded-xl">
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #475569;">
<div class="flex items-center justify-between">
<div>
<p class="text-blue-100 text-sm flex items-center space-x-1">
<span>전체 진행 중</span>
<div class="w-1.5 h-1.5 bg-blue-200 rounded-full animate-pulse"></div>
</p>
<p class="text-3xl font-bold" id="totalInProgress">0</p>
<p class="text-sm text-slate-500">전체 진행 중</p>
<p class="text-3xl font-bold text-slate-800" id="totalInProgress">0</p>
</div>
<i class="fas fa-tasks text-4xl text-blue-200"></i>
<i class="fas fa-tasks text-3xl text-slate-300"></i>
</div>
</div>
<div class="bg-gradient-to-br from-green-400 to-green-600 text-white p-6 rounded-xl dashboard-card">
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #16a34a;">
<div class="flex items-center justify-between">
<div>
<p class="text-green-100 text-sm flex items-center space-x-1">
<span>오늘 신규</span>
<div class="w-1.5 h-1.5 bg-green-200 rounded-full animate-pulse"></div>
</p>
<p class="text-3xl font-bold" id="todayNew">0</p>
<p class="text-sm text-slate-500">오늘 신규</p>
<p class="text-3xl font-bold text-slate-800" id="todayNew">0</p>
</div>
<i class="fas fa-plus-circle text-4xl text-green-200"></i>
<i class="fas fa-plus-circle text-3xl text-green-300"></i>
</div>
</div>
<div class="bg-gradient-to-br from-purple-400 to-purple-600 text-white p-6 rounded-xl dashboard-card">
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #7c3aed;">
<div class="flex items-center justify-between">
<div>
<p class="text-purple-100 text-sm flex items-center space-x-1">
<span>완료 대기</span>
<div class="w-1.5 h-1.5 bg-purple-200 rounded-full animate-pulse"></div>
</p>
<p class="text-3xl font-bold" id="pendingCompletion">0</p>
<p class="text-sm text-slate-500">완료 대기</p>
<p class="text-3xl font-bold text-slate-800" id="pendingCompletion">0</p>
</div>
<i class="fas fa-hourglass-half text-4xl text-purple-200"></i>
<i class="fas fa-hourglass-half text-3xl text-purple-300"></i>
</div>
</div>
<div class="bg-gradient-to-br from-red-400 to-red-600 text-white p-6 rounded-xl dashboard-card">
<div class="dashboard-card p-6 rounded-xl shadow-sm" style="border-left-color: #dc2626;">
<div class="flex items-center justify-between">
<div>
<p class="text-red-100 text-sm flex items-center space-x-1">
<span>지연 중</span>
<div class="w-1.5 h-1.5 bg-red-200 rounded-full animate-pulse"></div>
</p>
<p class="text-3xl font-bold" id="overdue">0</p>
<p class="text-sm text-slate-500">지연 중</p>
<p class="text-3xl font-bold text-slate-800" id="overdue">0</p>
</div>
<i class="fas fa-clock text-4xl text-red-200"></i>
<i class="fas fa-clock text-3xl text-red-300"></i>
</div>
</div>
</div>
@@ -323,7 +309,7 @@
// 데이터 로드 함수들
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>수신함 - 작업보고서</title>
<title>수신함 - 부적합 관리</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
@@ -200,30 +200,30 @@
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-yellow-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #d97706;">
<div class="flex items-center">
<i class="fas fa-plus-circle text-yellow-500 text-xl mr-3"></i>
<i class="fas fa-plus-circle text-amber-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-yellow-600">금일 신규</p>
<p class="text-2xl font-bold text-yellow-700" id="todayNewCount">0</p>
<p class="text-sm text-slate-500">금일 신규</p>
<p class="text-2xl font-bold text-slate-800" id="todayNewCount">0</p>
</div>
</div>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-green-600">금일 처리</p>
<p class="text-2xl font-bold text-green-700" id="todayProcessedCount">0</p>
<p class="text-sm text-slate-500">금일 처리</p>
<p class="text-2xl font-bold text-slate-800" id="todayProcessedCount">0</p>
</div>
</div>
</div>
<div class="bg-red-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #dc2626;">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-red-500 text-xl mr-3"></i>
<i class="fas fa-exclamation-triangle text-red-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-red-600">미해결</p>
<p class="text-2xl font-bold text-red-700" id="unresolvedCount">0</p>
<p class="text-sm text-slate-500">미해결</p>
<p class="text-2xl font-bold text-slate-800" id="unresolvedCount">0</p>
</div>
</div>
</div>
@@ -668,7 +668,7 @@
async function loadProjects() {
console.log('🔄 프로젝트 로드 시작');
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리함 - 작업보고서</title>
<title>관리함 - 부적합 관리</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
@@ -273,39 +273,39 @@
<!-- 프로젝트별 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #475569;">
<div class="flex items-center">
<i class="fas fa-chart-bar text-gray-500 text-xl mr-3"></i>
<i class="fas fa-chart-bar text-slate-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-gray-600">총 부적합</p>
<p class="text-2xl font-bold text-gray-700" id="totalCount">0</p>
<p class="text-sm text-slate-500">총 부적합</p>
<p class="text-2xl font-bold text-slate-800" id="totalCount">0</p>
</div>
</div>
</div>
<div class="bg-blue-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #2563eb;">
<div class="flex items-center">
<i class="fas fa-cog text-blue-500 text-xl mr-3"></i>
<i class="fas fa-cog text-blue-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-blue-600">진행 중</p>
<p class="text-2xl font-bold text-blue-700" id="inProgressCount">0</p>
<p class="text-sm text-slate-500">진행 중</p>
<p class="text-2xl font-bold text-slate-800" id="inProgressCount">0</p>
</div>
</div>
</div>
<div class="bg-purple-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #7c3aed;">
<div class="flex items-center">
<i class="fas fa-hourglass-half text-purple-500 text-xl mr-3"></i>
<i class="fas fa-hourglass-half text-purple-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-purple-600">완료 대기</p>
<p class="text-2xl font-bold text-purple-700" id="pendingCompletionCount">0</p>
<p class="text-sm text-slate-500">완료 대기</p>
<p class="text-2xl font-bold text-slate-800" id="pendingCompletionCount">0</p>
</div>
</div>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<div class="bg-white p-4 rounded-lg border-l-4" style="border-left-color: #16a34a;">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
<i class="fas fa-check-circle text-green-400 text-xl mr-3"></i>
<div>
<p class="text-sm text-green-600">완료됨</p>
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
<p class="text-sm text-slate-500">완료됨</p>
<p class="text-2xl font-bold text-slate-800" id="completedCount">0</p>
</div>
</div>
</div>
@@ -472,7 +472,7 @@
// 프로젝트 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`,

View File

@@ -5,7 +5,7 @@ server {
client_max_body_size 10M;
root /usr/share/nginx/html;
index index.html;
index issues-dashboard.html;
# HTML 캐시 비활성화
location ~* \.html$ {
@@ -46,6 +46,6 @@ server {
}
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ /issues-dashboard.html;
}
}

View File

@@ -234,7 +234,7 @@
// 프로젝트 목록 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
@@ -302,7 +302,7 @@
}
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/reports/daily-preview?project_id=${selectedProjectId}`, {
headers: {
'Authorization': `Bearer ${TokenManager.getToken()}`
@@ -427,7 +427,7 @@
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>생성 중...';
button.disabled = true;
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/reports/daily-export`, {
method: 'POST',
headers: {

View File

@@ -41,6 +41,11 @@ const API_BASE_URL = (() => {
return protocol + '//' + hostname + ':16080/api';
}
// 통합 Docker 환경에서 직접 접근 (포트 30280)
if (port === '30280') {
return protocol + '//' + hostname + ':30200/api';
}
// 기타 환경
return '/api';
})();
@@ -77,6 +82,10 @@ const TokenManager = {
}
};
// 전역 노출 (permissions.js 등 다른 스크립트에서 접근)
window.TokenManager = TokenManager;
window.API_BASE_URL = API_BASE_URL;
// API 요청 헬퍼
async function apiRequest(endpoint, options = {}) {
const token = TokenManager.getToken();

View File

@@ -10,81 +10,66 @@ class CommonHeader {
this.menuItems = this.initMenuItems();
}
/**
* 사용자 관리 URL (tkuser 서브도메인 또는 로컬 포트)
*/
_getUserManageUrl() {
const hostname = window.location.hostname;
if (hostname.includes('technicalkorea.net')) {
return window.location.protocol + '//tkuser.technicalkorea.net';
}
return window.location.protocol + '//' + hostname + ':30380';
}
/**
* 메뉴 아이템 정의
*/
initMenuItems() {
return [
{
id: 'daily_work',
title: '일일 공수',
icon: 'fas fa-calendar-check',
url: '/daily-work.html',
pageName: 'daily_work',
color: 'text-blue-600',
bgColor: 'bg-blue-50 hover:bg-blue-100'
},
{
id: 'issues_create',
title: '부적합 등록',
icon: 'fas fa-plus-circle',
url: '/index.html',
pageName: 'issues_create',
color: 'text-green-600',
bgColor: 'bg-green-50 hover:bg-green-100'
},
{
id: 'issues_view',
title: '신고내용조회',
icon: 'fas fa-search',
url: '/issue-view.html',
pageName: 'issues_view',
color: 'text-purple-600',
bgColor: 'bg-purple-50 hover:bg-purple-100'
},
{
id: 'issues_manage',
title: '목록 관리',
icon: 'fas fa-tasks',
url: '/index.html#list',
pageName: 'issues_manage',
color: 'text-orange-600',
bgColor: 'bg-orange-50 hover:bg-orange-100',
subMenus: [
{
id: 'issues_inbox',
title: '수신함',
icon: 'fas fa-inbox',
url: '/issues-inbox.html',
pageName: 'issues_inbox',
color: 'text-blue-600'
},
{
id: 'issues_management',
title: '관리함',
icon: 'fas fa-cog',
url: '/issues-management.html',
pageName: 'issues_management',
color: 'text-green-600'
},
{
id: 'issues_archive',
title: '폐기함',
icon: 'fas fa-archive',
url: '/issues-archive.html',
pageName: 'issues_archive',
color: 'text-gray-600'
}
]
},
{
id: 'issues_dashboard',
title: '현황판',
icon: 'fas fa-chart-line',
url: '/issues-dashboard.html',
pageName: 'issues_dashboard',
color: 'text-purple-600',
bgColor: 'bg-purple-50 hover:bg-purple-100'
color: 'text-slate-600',
bgColor: 'text-slate-600 hover:bg-slate-100'
},
{
id: 'issues_inbox',
title: '수신함',
icon: 'fas fa-inbox',
url: '/issues-inbox.html',
pageName: 'issues_inbox',
color: 'text-slate-600',
bgColor: 'text-slate-600 hover:bg-slate-100'
},
{
id: 'issues_management',
title: '관리함',
icon: 'fas fa-cog',
url: '/issues-management.html',
pageName: 'issues_management',
color: 'text-slate-600',
bgColor: 'text-slate-600 hover:bg-slate-100'
},
{
id: 'issues_archive',
title: '폐기함',
icon: 'fas fa-archive',
url: '/issues-archive.html',
pageName: 'issues_archive',
color: 'text-slate-600',
bgColor: 'text-slate-600 hover:bg-slate-100'
},
{
id: 'daily_work',
title: '일일 공수',
icon: 'fas fa-calendar-check',
url: '/daily-work.html',
pageName: 'daily_work',
color: 'text-slate-600',
bgColor: 'text-slate-600 hover:bg-slate-100'
},
{
id: 'reports',
@@ -92,8 +77,8 @@ class CommonHeader {
icon: 'fas fa-chart-bar',
url: '/reports.html',
pageName: 'reports',
color: 'text-red-600',
bgColor: 'bg-red-50 hover:bg-red-100',
color: 'text-slate-600',
bgColor: 'text-slate-600 hover:bg-slate-100',
subMenus: [
{
id: 'reports_daily',
@@ -101,7 +86,7 @@ class CommonHeader {
icon: 'fas fa-file-excel',
url: '/reports-daily.html',
pageName: 'reports_daily',
color: 'text-green-600'
color: 'text-slate-600'
},
{
id: 'reports_weekly',
@@ -109,7 +94,7 @@ class CommonHeader {
icon: 'fas fa-calendar-week',
url: '/reports-weekly.html',
pageName: 'reports_weekly',
color: 'text-blue-600'
color: 'text-slate-600'
},
{
id: 'reports_monthly',
@@ -117,7 +102,7 @@ class CommonHeader {
icon: 'fas fa-calendar-alt',
url: '/reports-monthly.html',
pageName: 'reports_monthly',
color: 'text-purple-600'
color: 'text-slate-600'
}
]
},
@@ -127,17 +112,18 @@ class CommonHeader {
icon: 'fas fa-folder-open',
url: '/project-management.html',
pageName: 'projects_manage',
color: 'text-indigo-600',
bgColor: 'bg-indigo-50 hover:bg-indigo-100'
color: 'text-slate-600',
bgColor: 'text-slate-600 hover:bg-slate-100'
},
{
id: 'users_manage',
title: '사용자 관리',
icon: 'fas fa-users-cog',
url: '/admin.html',
url: this._getUserManageUrl(),
pageName: 'users_manage',
color: 'text-gray-600',
bgColor: 'bg-gray-50 hover:bg-gray-100'
color: 'text-slate-600',
bgColor: 'text-slate-600 hover:bg-slate-100',
external: true
}
];
}
@@ -225,8 +211,8 @@ class CommonHeader {
<!-- 로고 및 제목 -->
<div class="flex items-center">
<div class="flex-shrink-0 flex items-center">
<i class="fas fa-clipboard-check text-2xl text-blue-600 mr-3"></i>
<h1 class="text-xl font-bold text-gray-900">작업보고서</h1>
<i class="fas fa-shield-halved text-2xl text-slate-700 mr-3"></i>
<h1 class="text-xl font-bold text-gray-900">부적합 관리</h1>
</div>
</div>
@@ -243,7 +229,7 @@ class CommonHeader {
<div class="text-sm font-medium text-gray-900">${userDisplayName}</div>
<div class="text-xs text-gray-500">${userRole}</div>
</div>
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
<div class="w-8 h-8 bg-slate-600 rounded-full flex items-center justify-center">
<span class="text-white text-sm font-semibold">
${userDisplayName.charAt(0).toUpperCase()}
</span>
@@ -299,7 +285,7 @@ class CommonHeader {
// 권한 시스템이 로드되지 않았으면 기본 메뉴만
if (!window.canAccessPage) {
return ['issues_create', 'issues_view'].includes(menu.id);
return ['issues_dashboard', 'issues_inbox'].includes(menu.id);
}
// 메인 메뉴 권한 체크
@@ -324,8 +310,8 @@ class CommonHeader {
*/
generateMenuItemHTML(menu) {
const isActive = this.currentPage === menu.id;
const activeClass = isActive ? 'bg-blue-100 text-blue-700' : `${menu.bgColor} ${menu.color}`;
const activeClass = isActive ? 'bg-slate-700 text-white' : `${menu.bgColor} ${menu.color}`;
// 하위 메뉴가 있는 경우 드롭다운 메뉴 생성
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
return `
@@ -342,7 +328,7 @@ class CommonHeader {
<div class="py-1">
${menu.accessibleSubMenus.map(subMenu => `
<a href="${subMenu.url}"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 ${this.currentPage === subMenu.id ? 'bg-blue-50 text-blue-700' : ''}"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100 ${this.currentPage === subMenu.id ? 'bg-slate-100 text-slate-800 font-medium' : ''}"
data-page="${subMenu.id}"
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
@@ -355,9 +341,21 @@ class CommonHeader {
`;
}
// 외부 링크 (tkuser 등)
if (menu.external) {
return `
<a href="${menu.url}"
class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
data-page="${menu.id}">
<i class="${menu.icon} mr-2"></i>
${menu.title}
</a>
`;
}
// 일반 메뉴 아이템
return `
<a href="${menu.url}"
<a href="${menu.url}"
class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
data-page="${menu.id}"
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
@@ -372,7 +370,7 @@ class CommonHeader {
*/
generateMobileMenuItemHTML(menu) {
const isActive = this.currentPage === menu.id;
const activeClass = isActive ? 'bg-blue-50 text-blue-700 border-blue-500' : 'text-gray-700 hover:bg-gray-50';
const activeClass = isActive ? 'bg-slate-100 text-slate-800 border-slate-600' : 'text-gray-700 hover:bg-gray-50';
// 하위 메뉴가 있는 경우
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
@@ -392,7 +390,7 @@ class CommonHeader {
<div class="hidden ml-6 mt-1 space-y-1">
${menu.accessibleSubMenus.map(subMenu => `
<a href="${subMenu.url}"
class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 ${this.currentPage === subMenu.id ? 'bg-blue-50 text-blue-700' : ''}"
class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-slate-100 ${this.currentPage === subMenu.id ? 'bg-slate-100 text-slate-800' : ''}"
data-page="${subMenu.id}"
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
@@ -684,10 +682,11 @@ class CommonHeader {
document.querySelectorAll('.nav-item').forEach(item => {
const itemPageId = item.getAttribute('data-page');
if (itemPageId === pageId) {
item.classList.add('bg-blue-100', 'text-blue-700');
item.classList.remove('bg-blue-50', 'hover:bg-blue-100');
item.classList.add('bg-slate-700', 'text-white');
item.classList.remove('text-slate-600', 'hover:bg-slate-100');
} else {
item.classList.remove('bg-blue-100', 'text-blue-700');
item.classList.remove('bg-slate-700', 'text-white');
item.classList.add('text-slate-600');
}
});
}

View File

@@ -54,7 +54,7 @@ class PageManager {
async checkAuthentication() {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/index.html';
window.location.href = '/issues-dashboard.html';
return null;
}
@@ -69,7 +69,7 @@ class PageManager {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
window.location.href = '/issues-dashboard.html';
return null;
}
}
@@ -117,7 +117,7 @@ class PageManager {
// 권한 시스템이 로드되지 않았으면 기본 페이지만 허용
if (!window.canAccessPage) {
return ['issues_create', 'issues_view'].includes(pageId);
return ['issues_dashboard', 'issues_inbox'].includes(pageId);
}
return window.canAccessPage(pageId);
@@ -130,11 +130,7 @@ class PageManager {
alert('이 페이지에 접근할 권한이 없습니다.');
// 기본적으로 접근 가능한 페이지로 이동
if (window.canAccessPage && window.canAccessPage('issues_view')) {
window.location.href = '/issue-view.html';
} else {
window.location.href = '/index.html';
}
window.location.href = '/issues-dashboard.html';
}
/**
@@ -250,7 +246,7 @@ class PageManager {
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
다시 시도
</button>
<button onclick="window.location.href='/index.html'"
<button onclick="window.location.href='/issues-dashboard.html'"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
홈으로
</button>

View File

@@ -15,13 +15,11 @@ class PagePermissionManager {
*/
initDefaultPages() {
return {
'issues_create': { title: '부적합 등록', defaultAccess: true },
'issues_view': { title: '부적합 조회', defaultAccess: true },
'issues_dashboard': { title: '현황판', defaultAccess: true },
'issues_manage': { title: '부적합 관리', defaultAccess: true },
'issues_inbox': { title: '수신함', defaultAccess: true },
'issues_management': { title: '관리함', defaultAccess: false },
'issues_archive': { title: '폐기함', defaultAccess: false },
'issues_dashboard': { title: '현황판', defaultAccess: true },
'projects_manage': { title: '프로젝트 관리', defaultAccess: false },
'daily_work': { title: '일일 공수', defaultAccess: false },
'reports': { title: '보고서', defaultAccess: false },
@@ -41,15 +39,32 @@ class PagePermissionManager {
/**
* 사용자별 페이지 권한 로드
*/
/**
* SSO 토큰 직접 읽기 (api.js 로딩 전에도 동작)
*/
_getToken() {
// 1) window.TokenManager (api.js 로딩 완료 시)
if (window.TokenManager) return window.TokenManager.getToken();
// 2) SSO 쿠키 직접 읽기
const match = document.cookie.match(/(?:^|; )sso_token=([^;]*)/);
if (match) return decodeURIComponent(match[1]);
// 3) localStorage 폴백
return localStorage.getItem('sso_token') || localStorage.getItem('access_token');
}
async loadPagePermissions() {
if (!this.currentUser) return;
const userId = this.currentUser.id || this.currentUser.user_id;
if (!userId) return;
try {
// API에서 사용자별 페이지 권한 가져오기
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/users/${this.currentUser.id}/page-permissions`, {
const apiUrl = window.API_BASE_URL || '/api';
const token = this._getToken();
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
'Authorization': `Bearer ${token}`
}
});
@@ -199,12 +214,12 @@ class PagePermissionManager {
}
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/page-permissions/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
'Authorization': `Bearer ${this._getToken()}`
},
body: JSON.stringify({
user_id: userId,
@@ -232,10 +247,10 @@ class PagePermissionManager {
*/
async getUserPagePermissions(userId) {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const apiUrl = window.API_BASE_URL || '/api';
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
'Authorization': `Bearer ${this._getToken()}`
}
});

View File

@@ -18,7 +18,7 @@
formData.append('username', 'hyungi');
formData.append('password', '123456');
const response = await fetch('http://localhost:16080/api/auth/login', {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
@@ -41,7 +41,7 @@
}
try {
const response = await fetch('http://localhost:16080/api/auth/users', {
const response = await fetch('/api/auth/users', {
headers: {
'Authorization': `Bearer ${token}`
}