feat: 3-System 분리 프로젝트 초기 코드 작성

TK-FB(공장관리+신고)와 M-Project(부적합관리)를 3개 독립 시스템으로
분리하기 위한 전체 코드 구조 작성.
- SSO 인증 서비스 (bcrypt + pbkdf2 이중 해시 지원)
- System 1: 공장관리 (TK-FB 기반, 신고 코드 제거)
- System 2: 신고 (TK-FB에서 workIssue 코드 추출)
- System 3: 부적합관리 (M-Project 기반)
- Gateway 포털 (path-based 라우팅)
- 통합 docker-compose.yml 및 배포 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:40:11 +09:00
commit 550633b89d
824 changed files with 1071683 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
# 시스템 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 파일 복사
COPY . .
# uploads 디렉토리 생성
RUN mkdir -p /app/uploads
# 포트 노출
EXPOSE 8000
# 실행 명령
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.ext.declarative import declarative_base
import os
from typing import Generator
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://mproject:mproject2024@localhost:5432/mproject")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,225 @@
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime, timezone, timedelta
import enum
# 한국 시간대 설정
KST = timezone(timedelta(hours=9))
def get_kst_now():
"""현재 한국 시간 반환"""
return datetime.now(KST)
Base = declarative_base()
class UserRole(str, enum.Enum):
admin = "admin" # 관리자
user = "user" # 일반 사용자
class IssueStatus(str, enum.Enum):
new = "new"
progress = "progress"
complete = "complete"
class IssueCategory(str, enum.Enum):
material_missing = "material_missing"
design_error = "design_error" # 설계미스 (기존 dimension_defect 대체)
incoming_defect = "incoming_defect"
inspection_miss = "inspection_miss" # 검사미스 (신규 추가)
etc = "etc" # 기타
class ReviewStatus(str, enum.Enum):
pending_review = "pending_review" # 수신함 (검토 대기)
in_progress = "in_progress" # 관리함 (진행 중)
completed = "completed" # 관리함 (완료됨)
disposed = "disposed" # 폐기함 (폐기됨)
class DisposalReasonType(str, enum.Enum):
duplicate = "duplicate" # 중복 (기본값)
invalid_report = "invalid_report" # 잘못된 신고
not_applicable = "not_applicable" # 해당 없음
spam = "spam" # 스팸/오류
custom = "custom" # 직접 입력
class DepartmentType(str, enum.Enum):
production = "production" # 생산
quality = "quality" # 품질
purchasing = "purchasing" # 구매
design = "design" # 설계
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)
role = Column(Enum(UserRole), default=UserRole.user)
department = Column(Enum(DepartmentType)) # 부서 정보 추가
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"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.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_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'),
)
class Issue(Base):
__tablename__ = "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)
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"))
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_at = Column(DateTime)
original_data = Column(JSONB) # 원본 데이터 보존
modification_log = Column(JSONB, default=lambda: []) # 수정 이력
# 중복 신고 추적 시스템
duplicate_of_issue_id = Column(Integer, ForeignKey("issues.id")) # 중복 대상 이슈 ID
duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록
# 관리함에서 사용할 추가 필드들
solution = Column(Text) # 해결방안 (관리함에서 입력)
responsible_department = Column(Enum(DepartmentType)) # 담당부서
responsible_person = Column(String(100)) # 담당자
expected_completion_date = Column(DateTime) # 조치 예상일
actual_completion_date = Column(DateTime) # 완료 확인일
cause_department = Column(Enum(DepartmentType)) # 원인부서
management_comment = Column(Text) # ISSUE에 대한 의견
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")) # 추가 정보 입력자
# 완료 신청 관련 필드들
completion_requested_at = Column(DateTime) # 완료 신청 시간
completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자
completion_photo_path = Column(String(500)) # 완료 사진 1
completion_photo_path2 = Column(String(500)) # 완료 사진 2
completion_photo_path3 = Column(String(500)) # 완료 사진 3
completion_photo_path4 = Column(String(500)) # 완료 사진 4
completion_photo_path5 = Column(String(500)) # 완료 사진 5
completion_comment = Column(Text) # 완료 코멘트
# 완료 반려 관련 필드들
completion_rejected_at = Column(DateTime) # 완료 반려 시간
completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자
completion_rejection_reason = Column(Text) # 완료 반려 사유
# 일일보고서 추출 이력
last_exported_at = Column(DateTime) # 마지막 일일보고서 추출 시간
export_count = Column(Integer, default=0) # 추출 횟수
# 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")
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)
is_active = Column(Boolean, default=True)
# Relationships
created_by = relationship("User", back_populates="projects")
issues = relationship("Issue", back_populates="project")
class DailyWork(Base):
__tablename__ = "daily_works"
id = Column(Integer, primary_key=True, index=True)
date = Column(DateTime, nullable=False, index=True)
worker_count = Column(Integer, nullable=False)
regular_hours = Column(Float, nullable=False)
overtime_workers = Column(Integer, default=0)
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_at = Column(DateTime, default=get_kst_now)
# Relationships
created_by = relationship("User", back_populates="daily_works")
class ProjectDailyWork(Base):
__tablename__ = "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)
hours = Column(Float, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=get_kst_now)
# Relationships
project = relationship("Project")
created_by = relationship("User")
class DeletionLog(Base):
__tablename__ = "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)
deleted_at = Column(DateTime, default=get_kst_now, nullable=False)
reason = Column(Text) # 삭제 사유 (선택사항)
# Relationships
deleted_by = relationship("User")

View File

@@ -0,0 +1,369 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
class UserRole(str, Enum):
admin = "admin"
user = "user"
class IssueStatus(str, Enum):
new = "new"
progress = "progress"
complete = "complete"
class IssueCategory(str, Enum):
material_missing = "material_missing"
design_error = "design_error" # 설계미스 (기존 dimension_defect 대체)
incoming_defect = "incoming_defect"
inspection_miss = "inspection_miss" # 검사미스 (신규 추가)
etc = "etc" # 기타
class ReviewStatus(str, Enum):
pending_review = "pending_review" # 수신함 (검토 대기)
in_progress = "in_progress" # 관리함 (진행 중)
completed = "completed" # 관리함 (완료됨)
disposed = "disposed" # 폐기함 (폐기됨)
class DisposalReasonType(str, Enum):
duplicate = "duplicate" # 중복 (기본값)
invalid_report = "invalid_report" # 잘못된 신고
not_applicable = "not_applicable" # 해당 없음
spam = "spam" # 스팸/오류
custom = "custom" # 직접 입력
class DepartmentType(str, Enum):
production = "production" # 생산
quality = "quality" # 품질
purchasing = "purchasing" # 구매
design = "design" # 설계
sales = "sales" # 영업
# User schemas
class UserBase(BaseModel):
username: str
full_name: Optional[str] = None
role: UserRole = UserRole.user
department: Optional[DepartmentType] = None
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
full_name: Optional[str] = None
password: Optional[str] = None
role: Optional[UserRole] = None
department: Optional[DepartmentType] = None
is_active: Optional[bool] = None
class PasswordChange(BaseModel):
current_password: str
new_password: str
class User(UserBase):
id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
# Auth schemas
class Token(BaseModel):
access_token: str
token_type: str
user: User
class TokenData(BaseModel):
username: Optional[str] = None
class LoginRequest(BaseModel):
username: str
password: str
# Issue schemas
class IssueBase(BaseModel):
category: IssueCategory
description: str
project_id: int
class IssueCreate(IssueBase):
photo: Optional[str] = None # Base64 encoded image
photo2: Optional[str] = None
photo3: Optional[str] = None
photo4: Optional[str] = None
photo5: Optional[str] = None
class IssueUpdate(BaseModel):
category: Optional[IssueCategory] = None
description: Optional[str] = None
project_id: Optional[int] = None
work_hours: Optional[float] = None
detail_notes: Optional[str] = None
status: Optional[IssueStatus] = None
photo: Optional[str] = None # Base64 encoded image for update
photo2: Optional[str] = None
photo3: Optional[str] = None
photo4: Optional[str] = None
photo5: Optional[str] = None
class Issue(IssueBase):
id: int
photo_path: Optional[str] = None
photo_path2: Optional[str] = None
photo_path3: Optional[str] = None
photo_path4: Optional[str] = None
photo_path5: Optional[str] = None
status: IssueStatus
reporter_id: int
reporter: User
project_id: Optional[int] = None
# project: Optional['Project'] = None # 순환 참조 방지를 위해 제거
report_date: datetime
work_hours: float
detail_notes: Optional[str] = None
# 수신함 워크플로우 관련 필드들
review_status: ReviewStatus
disposal_reason: Optional[DisposalReasonType] = None
custom_disposal_reason: Optional[str] = None
disposed_at: Optional[datetime] = None
reviewed_by_id: Optional[int] = None
reviewed_at: Optional[datetime] = None
original_data: Optional[Dict[str, Any]] = None
modification_log: Optional[List[Dict[str, Any]]] = None
# 중복 신고 추적 시스템
duplicate_of_issue_id: Optional[int] = None
duplicate_reporters: Optional[List[Dict[str, Any]]] = None
# 관리함에서 사용할 추가 필드들
solution: Optional[str] = None # 해결방안
responsible_department: Optional[DepartmentType] = None # 담당부서
responsible_person: Optional[str] = None # 담당자
expected_completion_date: Optional[datetime] = None # 조치 예상일
actual_completion_date: Optional[datetime] = None # 완료 확인일
cause_department: Optional[DepartmentType] = None # 원인부서
management_comment: Optional[str] = None # ISSUE에 대한 의견
project_sequence_no: Optional[int] = None # 프로젝트별 순번
final_description: Optional[str] = None # 최종 내용
final_category: Optional[IssueCategory] = None # 최종 카테고리
# 추가 정보 필드들 (관리함에서 기록용)
responsible_person_detail: Optional[str] = None # 해당자 상세 정보
cause_detail: Optional[str] = None # 원인 상세 정보
additional_info_updated_at: Optional[datetime] = None # 추가 정보 입력 시간
additional_info_updated_by_id: Optional[int] = None # 추가 정보 입력자
# 완료 신청 관련 필드들
completion_requested_at: Optional[datetime] = None # 완료 신청 시간
completion_requested_by_id: Optional[int] = None # 완료 신청자
completion_photo_path: Optional[str] = None # 완료 사진 1
completion_photo_path2: Optional[str] = None # 완료 사진 2
completion_photo_path3: Optional[str] = None # 완료 사진 3
completion_photo_path4: Optional[str] = None # 완료 사진 4
completion_photo_path5: Optional[str] = None # 완료 사진 5
completion_comment: Optional[str] = None # 완료 코멘트
# 완료 반려 관련 필드들
completion_rejected_at: Optional[datetime] = None # 완료 반려 시간
completion_rejected_by_id: Optional[int] = None # 완료 반려자
completion_rejection_reason: Optional[str] = None # 완료 반려 사유
# 일일보고서 추출 이력
last_exported_at: Optional[datetime] = None # 마지막 일일보고서 추출 시간
export_count: Optional[int] = 0 # 추출 횟수
class Config:
from_attributes = True
# 수신함 워크플로우 전용 스키마들
class IssueDisposalRequest(BaseModel):
"""부적합 폐기 요청"""
disposal_reason: DisposalReasonType = DisposalReasonType.duplicate
custom_disposal_reason: Optional[str] = None
duplicate_of_issue_id: Optional[int] = None # 중복 대상 이슈 ID
class IssueReviewRequest(BaseModel):
"""부적합 검토 및 수정 요청"""
project_id: Optional[int] = None
category: Optional[IssueCategory] = None
description: Optional[str] = None
modifications: Optional[Dict[str, Any]] = None
class IssueStatusUpdateRequest(BaseModel):
"""부적합 상태 변경 요청"""
review_status: ReviewStatus
completion_photo: Optional[str] = None # 완료 사진 (Base64)
solution: Optional[str] = None # 해결방안
responsible_department: Optional[DepartmentType] = None # 담당부서
responsible_person: Optional[str] = None # 담당자
class ManagementUpdateRequest(BaseModel):
"""관리함에서 사용할 필드 업데이트 요청"""
solution: Optional[str] = None # 해결방안
responsible_department: Optional[DepartmentType] = None # 담당부서
responsible_person: Optional[str] = None # 담당자
expected_completion_date: Optional[datetime] = None # 조치 예상일
cause_department: Optional[DepartmentType] = None # 원인부서
management_comment: Optional[str] = None # ISSUE에 대한 의견
completion_photo: Optional[str] = None # 완료 사진 (Base64)
final_description: Optional[str] = None # 최종 내용 (부적합명 + 상세 내용)
class AdditionalInfoUpdateRequest(BaseModel):
"""추가 정보 업데이트 요청 (관리함 진행중에서 사용)"""
cause_department: Optional[DepartmentType] = None # 원인부서
responsible_person_detail: Optional[str] = None # 해당자 상세 정보
cause_detail: Optional[str] = None # 원인 상세 정보
class CompletionRequestRequest(BaseModel):
"""완료 신청 요청"""
completion_photo: Optional[str] = None # 완료 사진 1 (Base64)
completion_photo2: Optional[str] = None # 완료 사진 2 (Base64)
completion_photo3: Optional[str] = None # 완료 사진 3 (Base64)
completion_photo4: Optional[str] = None # 완료 사진 4 (Base64)
completion_photo5: Optional[str] = None # 완료 사진 5 (Base64)
completion_comment: Optional[str] = None # 완료 코멘트
class CompletionRejectionRequest(BaseModel):
"""완료 신청 반려 요청"""
rejection_reason: str # 반려 사유
class ManagementUpdateRequest(BaseModel):
"""관리함에서 이슈 업데이트 요청"""
final_description: Optional[str] = None
final_category: Optional[IssueCategory] = None
solution: Optional[str] = None
responsible_department: Optional[DepartmentType] = None
responsible_person: Optional[str] = None
expected_completion_date: Optional[str] = None
cause_department: Optional[DepartmentType] = None
management_comment: Optional[str] = None
completion_comment: Optional[str] = None
completion_photo: Optional[str] = None # Base64 - 완료 사진 1
completion_photo2: Optional[str] = None # Base64 - 완료 사진 2
completion_photo3: Optional[str] = None # Base64 - 완료 사진 3
completion_photo4: Optional[str] = None # Base64 - 완료 사진 4
completion_photo5: Optional[str] = None # Base64 - 완료 사진 5
review_status: Optional[ReviewStatus] = None
class InboxIssue(BaseModel):
"""수신함용 부적합 정보 (간소화된 버전)"""
id: int
category: IssueCategory
description: str
photo_path: Optional[str] = None
photo_path2: Optional[str] = None
project_id: Optional[int] = None
reporter_id: int
reporter: User
report_date: datetime
review_status: ReviewStatus
class Config:
from_attributes = True
class ModificationLogEntry(BaseModel):
"""수정 이력 항목"""
field: str
old_value: Any
new_value: Any
modified_at: datetime
modified_by: int
# Project schemas
class ProjectBase(BaseModel):
job_no: str = Field(..., min_length=1, max_length=50)
project_name: str = Field(..., min_length=1, max_length=200)
class ProjectCreate(ProjectBase):
pass
class ProjectUpdate(BaseModel):
project_name: Optional[str] = Field(None, min_length=1, max_length=200)
is_active: Optional[bool] = None
class Project(ProjectBase):
id: int
created_by_id: int
created_by: User
created_at: datetime
is_active: bool
# issues: Optional[List['Issue']] = None # 순환 참조 방지를 위해 제거
class Config:
from_attributes = True
# Daily Work schemas
class DailyWorkBase(BaseModel):
date: datetime
worker_count: int = Field(gt=0)
overtime_workers: Optional[int] = 0
overtime_hours: Optional[float] = 0
class DailyWorkCreate(DailyWorkBase):
pass
class DailyWorkUpdate(BaseModel):
worker_count: Optional[int] = Field(None, gt=0)
overtime_workers: Optional[int] = None
overtime_hours: Optional[float] = None
class DailyWork(DailyWorkBase):
id: int
regular_hours: float
overtime_total: float
total_hours: float
created_by_id: int
created_by: User
created_at: datetime
class Config:
from_attributes = True
# Report schemas
class ReportRequest(BaseModel):
start_date: datetime
end_date: datetime
class CategoryStats(BaseModel):
material_missing: int = 0
dimension_defect: int = 0
incoming_defect: int = 0
class ReportSummary(BaseModel):
start_date: datetime
end_date: datetime
total_hours: float
total_issues: int
category_stats: CategoryStats
completed_issues: int
average_resolution_time: float
# Project Daily Work schemas
class ProjectDailyWorkBase(BaseModel):
date: datetime
project_id: int
hours: float
class ProjectDailyWorkCreate(ProjectDailyWorkBase):
pass
class ProjectDailyWork(ProjectDailyWorkBase):
id: int
created_by_id: int
created_at: datetime
project: Project
class Config:
from_attributes = True
# 일일보고서 관련 스키마
class DailyReportRequest(BaseModel):
project_id: int
class DailyReportStats(BaseModel):
total_count: int = 0
management_count: int = 0 # 관리처리 현황 (진행 중)
completed_count: int = 0
delayed_count: int = 0

View File

@@ -0,0 +1,69 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import uvicorn
from database.database import engine, get_db
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)
# FastAPI 앱 생성
app = FastAPI(
title="M-Project API",
description="작업보고서 시스템 API",
version="1.0.0"
)
# CORS 설정 (완전 개방 - CORS 문제 해결)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False, # * origin과 credentials는 함께 사용 불가
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"]
)
# 라우터 등록
app.include_router(auth.router)
app.include_router(issues.router)
app.include_router(inbox.router) # 수신함 라우터 추가
app.include_router(daily_work.router)
app.include_router(reports.router)
app.include_router(projects.router)
app.include_router(page_permissions.router)
app.include_router(management.router) # 관리함 라우터 추가
# 시작 시 관리자 계정 생성
@app.on_event("startup")
async def startup_event():
db = next(get_db())
create_admin_user(db)
db.close()
# 루트 엔드포인트
@app.get("/")
async def root():
return {"message": "M-Project API", "version": "1.0.0"}
# 헬스체크
@app.get("/api/health")
async def health_check():
return {"status": "healthy"}
# 전역 예외 처리
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"detail": f"Internal server error: {str(exc)}"}
)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,41 @@
"""
데이터베이스 마이그레이션: 사진 필드 추가
- 신고 사진 3, 4, 5 추가
- 완료 사진 2, 3, 4, 5 추가
"""
from sqlalchemy import create_engine, text
import os
# 데이터베이스 URL
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/issue_tracker")
def run_migration():
engine = create_engine(DATABASE_URL)
with engine.connect() as conn:
print("마이그레이션 시작...")
try:
# 신고 사진 필드 추가
print("신고 사진 필드 추가 중...")
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path3 VARCHAR"))
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path4 VARCHAR"))
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path5 VARCHAR"))
# 완료 사진 필드 추가
print("완료 사진 필드 추가 중...")
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path2 VARCHAR(500)"))
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path3 VARCHAR(500)"))
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path4 VARCHAR(500)"))
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path5 VARCHAR(500)"))
conn.commit()
print("✅ 마이그레이션 완료!")
except Exception as e:
conn.rollback()
print(f"❌ 마이그레이션 실패: {e}")
raise
if __name__ == "__main__":
run_migration()

View File

@@ -0,0 +1,58 @@
-- 초기 데이터베이스 설정
-- Enum 타입 생성 (4개 카테고리)
CREATE TYPE userRole AS ENUM ('admin', 'user');
CREATE TYPE issueStatus AS ENUM ('new', 'progress', 'complete');
CREATE TYPE issueCategory AS ENUM (
'material_missing', -- 자재누락
'design_error', -- 설계미스
'incoming_defect', -- 입고자재 불량
'inspection_miss' -- 검사미스
);
-- 사용자 테이블
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
full_name VARCHAR(100),
role userRole DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 이슈 테이블
CREATE TABLE IF NOT EXISTS issues (
id SERIAL PRIMARY KEY,
photo_path VARCHAR(500),
photo_path2 VARCHAR(500), -- 두 번째 사진
category issueCategory NOT NULL,
description TEXT NOT NULL,
status issueStatus DEFAULT 'new',
reporter_id INTEGER REFERENCES users(id),
report_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
work_hours FLOAT DEFAULT 0,
detail_notes TEXT
);
-- 일일 작업 테이블
CREATE TABLE IF NOT EXISTS daily_works (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
worker_count INTEGER NOT NULL,
regular_hours FLOAT NOT NULL,
overtime_workers INTEGER DEFAULT 0,
overtime_hours FLOAT DEFAULT 0,
overtime_total FLOAT DEFAULT 0,
total_hours FLOAT NOT NULL,
created_by_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date)
);
-- 인덱스 생성
CREATE INDEX idx_issues_reporter_id ON issues(reporter_id);
CREATE INDEX idx_issues_status ON issues(status);
CREATE INDEX idx_issues_category ON issues(category);
CREATE INDEX idx_daily_works_date ON daily_works(date);
CREATE INDEX idx_daily_works_created_by_id ON daily_works(created_by_id);

View File

@@ -0,0 +1,5 @@
-- 두 번째 사진 경로 추가
ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path2 VARCHAR(500);
-- 인덱스 추가 (선택사항)
CREATE INDEX IF NOT EXISTS idx_issues_photo_path2 ON issues(photo_path2);

View File

@@ -0,0 +1,8 @@
-- 카테고리 업데이트 마이그레이션
-- dimension_defect를 design_error로 변경
UPDATE issues
SET category = 'design_error'
WHERE category = 'dimension_defect';
-- PostgreSQL enum 타입 업데이트 (필요한 경우)
-- 기존 enum 타입 확인 후 필요시 재생성

View File

@@ -0,0 +1,9 @@
-- 카테고리 값 정규화 (대문자를 소문자로 변경)
-- 기존 DIMENSION_DEFECT를 design_error로 변경
UPDATE issues SET category = 'design_error' WHERE category IN ('DIMENSION_DEFECT', 'dimension_defect');
UPDATE issues SET category = 'material_missing' WHERE category = 'MATERIAL_MISSING';
UPDATE issues SET category = 'incoming_defect' WHERE category = 'INCOMING_DEFECT';
UPDATE issues SET category = 'inspection_miss' WHERE category = 'INSPECTION_MISS';
-- 카테고리 값 확인
SELECT category, COUNT(*) FROM issues GROUP BY category;

View File

@@ -0,0 +1,20 @@
-- PostgreSQL enum 타입 재생성
-- 카테고리 컬럼을 임시로 텍스트로 변경
ALTER TABLE issues ALTER COLUMN category TYPE VARCHAR(50);
-- 기존 enum 타입 삭제
DROP TYPE IF EXISTS issuecategory CASCADE;
-- 새로운 enum 타입 생성 (4개 카테고리)
CREATE TYPE issuecategory AS ENUM (
'material_missing', -- 자재누락
'design_error', -- 설계미스 (기존 dimension_defect 대체)
'incoming_defect', -- 입고자재 불량
'inspection_miss' -- 검사미스 (신규)
);
-- 카테고리 컬럼을 새 enum 타입으로 변경
ALTER TABLE issues ALTER COLUMN category TYPE issuecategory USING category::issuecategory;
-- 확인
SELECT enumlabel FROM pg_enum WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'issuecategory');

View File

@@ -0,0 +1,14 @@
-- 프로젝트 테이블 생성
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) UNIQUE NOT NULL,
project_name VARCHAR(200) NOT NULL,
created_by_id INTEGER REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_projects_job_no ON projects(job_no);
CREATE INDEX IF NOT EXISTS idx_projects_created_by_id ON projects(created_by_id);
CREATE INDEX IF NOT EXISTS idx_projects_is_active ON projects(is_active);

View File

@@ -0,0 +1,15 @@
-- 부적합 사항 테이블에 프로젝트 ID 컬럼 추가
ALTER TABLE issues ADD COLUMN project_id INTEGER;
-- 외래키 제약조건 추가
ALTER TABLE issues ADD CONSTRAINT fk_issues_project_id
FOREIGN KEY (project_id) REFERENCES projects(id);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_issues_project_id ON issues(project_id);
-- 기존 부적합 사항들을 첫 번째 프로젝트로 할당 (있는 경우)
UPDATE issues
SET project_id = (SELECT id FROM projects ORDER BY created_at LIMIT 1)
WHERE project_id IS NULL
AND EXISTS (SELECT 1 FROM projects LIMIT 1);

View File

@@ -0,0 +1,13 @@
-- project_id 컬럼을 BIGINT로 변경
ALTER TABLE issues ALTER COLUMN project_id TYPE BIGINT;
-- projects 테이블의 id도 BIGINT로 변경 (일관성을 위해)
ALTER TABLE projects ALTER COLUMN id TYPE BIGINT;
-- 외래키 제약조건 재생성 (타입 변경으로 인해 필요)
ALTER TABLE issues DROP CONSTRAINT IF EXISTS fk_issues_project_id;
ALTER TABLE issues ADD CONSTRAINT fk_issues_project_id
FOREIGN KEY (project_id) REFERENCES projects(id);
-- 다른 테이블들도 확인하여 project_id 참조하는 곳이 있으면 수정
-- (현재는 issues 테이블만 project_id를 가지고 있음)

View File

@@ -0,0 +1,26 @@
-- 프로젝트별 일일공수 테이블 생성
CREATE TABLE project_daily_works (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
hours FLOAT NOT NULL,
created_by_id INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스 생성
CREATE INDEX idx_project_daily_works_date ON project_daily_works(date);
CREATE INDEX idx_project_daily_works_project_id ON project_daily_works(project_id);
CREATE INDEX idx_project_daily_works_date_project ON project_daily_works(date, project_id);
-- 기존 일일공수 데이터를 프로젝트별로 마이그레이션 (M Project로)
INSERT INTO project_daily_works (date, project_id, hours, created_by_id, created_at)
SELECT
date::date,
1, -- M Project ID
total_hours,
created_by_id,
created_at
FROM daily_works
WHERE total_hours > 0;

View File

@@ -0,0 +1,15 @@
-- 부적합 카테고리에 'etc' (기타) 값 추가
-- 백엔드 코드와 데이터베이스 enum 타입 불일치 해결
-- issuecategory enum 타입에 'etc' 값 추가
ALTER TYPE issuecategory ADD VALUE 'etc';
-- 확인 쿼리 (주석)
-- SELECT enumlabel FROM pg_enum WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'issuecategory') ORDER BY enumsortorder;
-- 이제 사용 가능한 카테고리:
-- 1. material_missing (자재누락)
-- 2. design_error (설계미스)
-- 3. incoming_defect (입고자재 불량)
-- 4. inspection_miss (검사미스)
-- 5. etc (기타) ✅ 새로 추가됨

View File

@@ -0,0 +1,137 @@
-- 권한 시스템 개선 마이그레이션
-- 새로운 사용자 역할 추가 및 개별 권한 테이블 생성
-- 1. 새로운 사용자 역할 추가
ALTER TYPE userrole ADD VALUE 'super_admin';
ALTER TYPE userrole ADD VALUE 'manager';
-- 2. 사용자별 개별 권한 테이블 생성
CREATE TABLE IF NOT EXISTS user_permissions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission VARCHAR(50) NOT NULL,
granted BOOLEAN DEFAULT TRUE,
granted_by_id INTEGER REFERENCES users(id),
granted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE,
notes TEXT,
UNIQUE(user_id, permission)
);
-- 3. 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_user_permissions_user_id ON user_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_permissions_permission ON user_permissions(permission);
CREATE INDEX IF NOT EXISTS idx_user_permissions_granted ON user_permissions(granted);
-- 4. 기본 권한 설정 (기존 관리자에게 super_admin 권한 부여)
UPDATE users SET role = 'super_admin' WHERE username = 'hyungi';
-- 5. 권한 확인 함수 생성
CREATE OR REPLACE FUNCTION check_user_permission(p_user_id INTEGER, p_permission VARCHAR)
RETURNS BOOLEAN AS $$
DECLARE
user_role userrole;
has_permission BOOLEAN := FALSE;
BEGIN
-- 사용자 역할 가져오기
SELECT role INTO user_role FROM users WHERE id = p_user_id AND is_active = TRUE;
IF user_role IS NULL THEN
RETURN FALSE;
END IF;
-- super_admin은 모든 권한 보유
IF user_role = 'super_admin' THEN
RETURN TRUE;
END IF;
-- 개별 권한 확인
SELECT granted INTO has_permission
FROM user_permissions
WHERE user_id = p_user_id
AND permission = p_permission
AND granted = TRUE
AND revoked_at IS NULL;
-- 개별 권한이 없으면 역할 기반 기본 권한 확인
IF has_permission IS NULL THEN
-- 기본 권한 매트릭스
CASE
WHEN p_permission IN ('issues.create', 'issues.view') THEN
has_permission := TRUE; -- 모든 사용자
WHEN p_permission IN ('issues.edit', 'issues.review', 'daily_work.create', 'daily_work.view', 'daily_work.edit') THEN
has_permission := user_role IN ('admin', 'manager'); -- 관리자, 매니저
WHEN p_permission IN ('projects.create', 'projects.edit', 'issues.delete', 'daily_work.delete') THEN
has_permission := user_role = 'admin'; -- 관리자만
WHEN p_permission IN ('projects.delete', 'users.create', 'users.edit', 'users.delete', 'users.change_role') THEN
has_permission := user_role = 'super_admin'; -- 최고 관리자만
ELSE
has_permission := FALSE;
END CASE;
END IF;
RETURN COALESCE(has_permission, FALSE);
END;
$$ LANGUAGE plpgsql;
-- 6. 권한 부여 함수 생성
CREATE OR REPLACE FUNCTION grant_user_permission(
p_user_id INTEGER,
p_permission VARCHAR,
p_granted_by_id INTEGER,
p_notes TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
BEGIN
INSERT INTO user_permissions (user_id, permission, granted, granted_by_id, notes)
VALUES (p_user_id, p_permission, TRUE, p_granted_by_id, p_notes)
ON CONFLICT (user_id, permission)
DO UPDATE SET
granted = TRUE,
granted_by_id = p_granted_by_id,
granted_at = NOW(),
revoked_at = NULL,
notes = p_notes;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- 7. 권한 취소 함수 생성
CREATE OR REPLACE FUNCTION revoke_user_permission(
p_user_id INTEGER,
p_permission VARCHAR,
p_revoked_by_id INTEGER,
p_notes TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
BEGIN
UPDATE user_permissions
SET granted = FALSE,
revoked_at = NOW(),
notes = p_notes
WHERE user_id = p_user_id
AND permission = p_permission;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- 8. 사용자 권한 목록 조회 뷰 생성
CREATE OR REPLACE VIEW user_permissions_view AS
SELECT
u.id as user_id,
u.username,
u.full_name,
u.role,
up.permission,
up.granted,
up.granted_at,
up.revoked_at,
granted_by.username as granted_by_username,
up.notes
FROM users u
LEFT JOIN user_permissions up ON u.id = up.user_id
LEFT JOIN users granted_by ON up.granted_by_id = granted_by.id
WHERE u.is_active = TRUE
ORDER BY u.username, up.permission;

View File

@@ -0,0 +1,111 @@
-- 권한 시스템 단순화
-- admin/user 구조로 변경하고 페이지별 접근 권한으로 변경
-- 1. 기존 복잡한 권한 테이블 삭제하고 단순한 페이지 권한 테이블로 변경
DROP TABLE IF EXISTS user_permissions CASCADE;
-- 2. 페이지별 접근 권한 테이블 생성
CREATE TABLE user_page_permissions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
page_name VARCHAR(50) NOT NULL,
can_access BOOLEAN DEFAULT FALSE,
granted_by_id INTEGER REFERENCES users(id),
granted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
notes TEXT,
UNIQUE(user_id, page_name)
);
-- 3. 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_name ON user_page_permissions(page_name);
-- 4. 기존 복잡한 함수들 삭제
DROP FUNCTION IF EXISTS check_user_permission(INTEGER, VARCHAR);
DROP FUNCTION IF EXISTS grant_user_permission(INTEGER, VARCHAR, INTEGER, TEXT);
DROP FUNCTION IF EXISTS revoke_user_permission(INTEGER, VARCHAR, INTEGER, TEXT);
-- 5. 단순한 페이지 접근 권한 체크 함수
CREATE OR REPLACE FUNCTION check_page_access(p_user_id INTEGER, p_page_name VARCHAR)
RETURNS BOOLEAN AS $$
DECLARE
user_role userrole;
has_access BOOLEAN := FALSE;
BEGIN
-- 사용자 역할 가져오기
SELECT role INTO user_role FROM users WHERE id = p_user_id AND is_active = TRUE;
IF user_role IS NULL THEN
RETURN FALSE;
END IF;
-- admin은 모든 페이지 접근 가능
IF user_role = 'admin' THEN
RETURN TRUE;
END IF;
-- 일반 사용자는 개별 페이지 권한 확인
SELECT can_access INTO has_access
FROM user_page_permissions
WHERE user_id = p_user_id
AND page_name = p_page_name;
-- 권한이 설정되지 않은 경우 기본값 (부적합 등록/조회만 허용)
IF has_access IS NULL THEN
CASE p_page_name
WHEN 'issues_create' THEN has_access := TRUE;
WHEN 'issues_view' THEN has_access := TRUE;
ELSE has_access := FALSE;
END CASE;
END IF;
RETURN COALESCE(has_access, FALSE);
END;
$$ LANGUAGE plpgsql;
-- 6. 페이지 권한 부여 함수
CREATE OR REPLACE FUNCTION grant_page_access(
p_user_id INTEGER,
p_page_name VARCHAR,
p_can_access BOOLEAN,
p_granted_by_id INTEGER,
p_notes TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
BEGIN
INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id, notes)
VALUES (p_user_id, p_page_name, p_can_access, p_granted_by_id, p_notes)
ON CONFLICT (user_id, page_name)
DO UPDATE SET
can_access = p_can_access,
granted_by_id = p_granted_by_id,
granted_at = NOW(),
notes = p_notes;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- 7. 사용자 페이지 권한 조회 뷰
CREATE OR REPLACE VIEW user_page_access_view AS
SELECT
u.id as user_id,
u.username,
u.full_name,
u.role,
upp.page_name,
upp.can_access,
upp.granted_at,
granted_by.username as granted_by_username,
upp.notes
FROM users u
LEFT JOIN user_page_permissions upp ON u.id = upp.user_id
LEFT JOIN users granted_by ON upp.granted_by_id = granted_by.id
WHERE u.is_active = TRUE
ORDER BY u.username, upp.page_name;
-- 8. 기존 super_admin, manager 역할을 admin으로 변경
UPDATE users SET role = 'admin' WHERE role IN ('super_admin', 'manager');
-- 9. 기존 뷰 삭제
DROP VIEW IF EXISTS user_permissions_view;

View File

@@ -0,0 +1,316 @@
-- ================================================
-- 마이그레이션: 013_add_inbox_workflow_system.sql
-- 목적: 수신함 워크플로우를 위한 DB 스키마 추가
-- 작성일: 2025-10-25
-- 주의: 배포 시 반드시 이 파일이 실행되는지 확인 필요!
-- ================================================
-- 트랜잭션 시작 (실패 시 롤백)
BEGIN;
-- 1. 새로운 ENUM 타입 생성
-- 검토 상태 (수신함 워크플로우용)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'review_status') THEN
CREATE TYPE review_status AS ENUM (
'pending_review', -- 수신함 (검토 대기)
'in_progress', -- 관리함 (진행 중)
'completed', -- 관리함 (완료됨)
'disposed' -- 폐기함 (폐기됨)
);
RAISE NOTICE '✅ review_status ENUM 타입이 생성되었습니다.';
ELSE
RAISE NOTICE '⚠️ review_status ENUM 타입이 이미 존재합니다.';
END IF;
END $$;
-- 폐기 사유 타입
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'disposal_reason_type') THEN
CREATE TYPE disposal_reason_type AS ENUM (
'duplicate', -- 중복 (기본값)
'invalid_report', -- 잘못된 신고
'not_applicable', -- 해당 없음
'spam', -- 스팸/오류
'custom' -- 직접 입력
);
RAISE NOTICE '✅ disposal_reason_type ENUM 타입이 생성되었습니다.';
ELSE
RAISE NOTICE '⚠️ disposal_reason_type ENUM 타입이 이미 존재합니다.';
END IF;
END $$;
-- 2. issues 테이블에 새로운 컬럼 추가 (안전하게)
-- 검토 상태 컬럼
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'review_status'
) THEN
ALTER TABLE issues ADD COLUMN review_status review_status DEFAULT 'pending_review';
RAISE NOTICE '✅ issues.review_status 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ issues.review_status 컬럼이 이미 존재합니다.';
END IF;
END $$;
-- 폐기 사유 컬럼
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'disposal_reason'
) THEN
ALTER TABLE issues ADD COLUMN disposal_reason disposal_reason_type;
RAISE NOTICE '✅ issues.disposal_reason 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ issues.disposal_reason 컬럼이 이미 존재합니다.';
END IF;
END $$;
-- 사용자 정의 폐기 사유 컬럼
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'custom_disposal_reason'
) THEN
ALTER TABLE issues ADD COLUMN custom_disposal_reason TEXT;
RAISE NOTICE '✅ issues.custom_disposal_reason 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ issues.custom_disposal_reason 컬럼이 이미 존재합니다.';
END IF;
END $$;
-- 폐기 날짜 컬럼
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'disposed_at'
) THEN
ALTER TABLE issues ADD COLUMN disposed_at TIMESTAMP WITH TIME ZONE;
RAISE NOTICE '✅ issues.disposed_at 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ issues.disposed_at 컬럼이 이미 존재합니다.';
END IF;
END $$;
-- 검토자 ID 컬럼
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'reviewed_by_id'
) THEN
ALTER TABLE issues ADD COLUMN reviewed_by_id INTEGER REFERENCES users(id);
RAISE NOTICE '✅ issues.reviewed_by_id 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ issues.reviewed_by_id 컬럼이 이미 존재합니다.';
END IF;
END $$;
-- 검토 날짜 컬럼
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'reviewed_at'
) THEN
ALTER TABLE issues ADD COLUMN reviewed_at TIMESTAMP WITH TIME ZONE;
RAISE NOTICE '✅ issues.reviewed_at 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ issues.reviewed_at 컬럼이 이미 존재합니다.';
END IF;
END $$;
-- 원본 데이터 보존 컬럼 (JSONB)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'original_data'
) THEN
ALTER TABLE issues ADD COLUMN original_data JSONB;
RAISE NOTICE '✅ issues.original_data 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ issues.original_data 컬럼이 이미 존재합니다.';
END IF;
END $$;
-- 수정 이력 컬럼 (JSONB)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'modification_log'
) THEN
ALTER TABLE issues ADD COLUMN modification_log JSONB DEFAULT '[]'::jsonb;
RAISE NOTICE '✅ issues.modification_log 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ issues.modification_log 컬럼이 이미 존재합니다.';
END IF;
END $$;
-- 3. 인덱스 추가 (성능 최적화)
-- review_status 인덱스
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'issues' AND indexname = 'idx_issues_review_status'
) THEN
CREATE INDEX idx_issues_review_status ON issues(review_status);
RAISE NOTICE '✅ idx_issues_review_status 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE '⚠️ idx_issues_review_status 인덱스가 이미 존재합니다.';
END IF;
END $$;
-- reviewed_by_id 인덱스
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'issues' AND indexname = 'idx_issues_reviewed_by_id'
) THEN
CREATE INDEX idx_issues_reviewed_by_id ON issues(reviewed_by_id);
RAISE NOTICE '✅ idx_issues_reviewed_by_id 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE '⚠️ idx_issues_reviewed_by_id 인덱스가 이미 존재합니다.';
END IF;
END $$;
-- disposed_at 인덱스
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'issues' AND indexname = 'idx_issues_disposed_at'
) THEN
CREATE INDEX idx_issues_disposed_at ON issues(disposed_at) WHERE disposed_at IS NOT NULL;
RAISE NOTICE '✅ idx_issues_disposed_at 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE '⚠️ idx_issues_disposed_at 인덱스가 이미 존재합니다.';
END IF;
END $$;
-- 4. 기존 데이터 마이그레이션 (안전하게)
-- 기존 issues의 review_status를 기존 status에 따라 설정
UPDATE issues
SET review_status = CASE
WHEN status = 'new' THEN 'pending_review'::review_status
WHEN status = 'progress' THEN 'in_progress'::review_status
WHEN status = 'complete' THEN 'completed'::review_status
ELSE 'pending_review'::review_status
END
WHERE review_status = 'pending_review'; -- 기본값인 경우만 업데이트
-- 5. 제약 조건 추가
-- 폐기된 경우 폐기 사유 필수
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.check_constraints
WHERE constraint_name = 'chk_disposal_reason_required'
) THEN
ALTER TABLE issues ADD CONSTRAINT chk_disposal_reason_required
CHECK (
(review_status = 'disposed' AND disposal_reason IS NOT NULL) OR
(review_status != 'disposed')
);
RAISE NOTICE '✅ chk_disposal_reason_required 제약 조건이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ chk_disposal_reason_required 제약 조건이 이미 존재합니다.';
END IF;
END $$;
-- 사용자 정의 사유는 disposal_reason이 'custom'일 때만
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.check_constraints
WHERE constraint_name = 'chk_custom_reason_logic'
) THEN
ALTER TABLE issues ADD CONSTRAINT chk_custom_reason_logic
CHECK (
(disposal_reason = 'custom' AND custom_disposal_reason IS NOT NULL AND LENGTH(TRIM(custom_disposal_reason)) > 0) OR
(disposal_reason != 'custom' OR disposal_reason IS NULL)
);
RAISE NOTICE '✅ chk_custom_reason_logic 제약 조건이 추가되었습니다.';
ELSE
RAISE NOTICE '⚠️ chk_custom_reason_logic 제약 조건이 이미 존재합니다.';
END IF;
END $$;
-- 6. 마이그레이션 완료 로그
INSERT INTO migration_log (migration_file, executed_at, status, notes)
VALUES (
'013_add_inbox_workflow_system.sql',
NOW(),
'SUCCESS',
'수신함 워크플로우 시스템 추가: review_status, disposal_reason, 원본데이터 보존, 수정이력 등'
) ON CONFLICT (migration_file) DO UPDATE SET
executed_at = NOW(),
status = 'SUCCESS',
notes = EXCLUDED.notes;
-- 트랜잭션 커밋
COMMIT;
-- 7. 마이그레이션 검증
DO $$
DECLARE
column_count INTEGER;
enum_count INTEGER;
index_count INTEGER;
BEGIN
-- 컬럼 개수 확인
SELECT COUNT(*) INTO column_count
FROM information_schema.columns
WHERE table_name = 'issues'
AND column_name IN (
'review_status', 'disposal_reason', 'custom_disposal_reason',
'disposed_at', 'reviewed_by_id', 'reviewed_at',
'original_data', 'modification_log'
);
-- ENUM 타입 확인
SELECT COUNT(*) INTO enum_count
FROM pg_type
WHERE typname IN ('review_status', 'disposal_reason_type');
-- 인덱스 확인
SELECT COUNT(*) INTO index_count
FROM pg_indexes
WHERE tablename = 'issues'
AND indexname IN (
'idx_issues_review_status',
'idx_issues_reviewed_by_id',
'idx_issues_disposed_at'
);
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
RAISE NOTICE '추가된 컬럼: %/8개', column_count;
RAISE NOTICE '생성된 ENUM: %/2개', enum_count;
RAISE NOTICE '생성된 인덱스: %/3개', index_count;
IF column_count = 8 AND enum_count = 2 AND index_count = 3 THEN
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
ELSE
RAISE EXCEPTION '❌ 마이그레이션 검증 실패! 일부 구조가 누락되었습니다.';
END IF;
END $$;
-- 8. 최종 테이블 구조 출력
\echo '=== 최종 issues 테이블 구조 ==='
\d issues;
\echo '=== 새로운 ENUM 타입들 ==='
\dT+ review_status;
\dT+ disposal_reason_type;
\echo '=== 마이그레이션 013 완료 ==='

View File

@@ -0,0 +1,92 @@
-- 014_add_user_department.sql
-- 사용자 부서 정보 추가
BEGIN;
-- migration_log 테이블 생성 (멱등성)
CREATE TABLE IF NOT EXISTS migration_log (
id SERIAL PRIMARY KEY,
migration_file VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status VARCHAR(50),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 마이그레이션 파일 이름
DO $$
DECLARE
migration_name VARCHAR(255) := '014_add_user_department.sql';
migration_notes TEXT := '사용자 부서 정보 추가: department ENUM 타입 및 users 테이블에 department 컬럼 추가';
current_status VARCHAR(50);
BEGIN
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
IF current_status IS NULL THEN
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
-- department ENUM 타입 생성
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'department_type') THEN
CREATE TYPE department_type AS ENUM (
'production', -- 생산
'quality', -- 품질
'purchasing', -- 구매
'design', -- 설계
'sales' -- 영업
);
RAISE NOTICE '✅ department_type ENUM 타입이 생성되었습니다.';
ELSE
RAISE NOTICE ' department_type ENUM 타입이 이미 존재합니다.';
END IF;
-- users 테이블에 department 컬럼 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'department') THEN
ALTER TABLE users ADD COLUMN department department_type;
RAISE NOTICE '✅ users.department 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' users.department 컬럼이 이미 존재합니다.';
END IF;
-- 인덱스 추가 (부서별 조회 성능 향상)
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'users' AND indexname = 'idx_users_department') THEN
CREATE INDEX idx_users_department ON users (department) WHERE department IS NOT NULL;
RAISE NOTICE '✅ idx_users_department 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_users_department 인덱스가 이미 존재합니다.';
END IF;
-- 마이그레이션 검증
DECLARE
col_count INTEGER;
enum_count INTEGER;
idx_count INTEGER;
BEGIN
SELECT COUNT(*) INTO col_count FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'department';
SELECT COUNT(*) INTO enum_count FROM pg_type WHERE typname = 'department_type';
SELECT COUNT(*) INTO idx_count FROM pg_indexes WHERE tablename = 'users' AND indexname = 'idx_users_department';
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
RAISE NOTICE '추가된 컬럼: %/1개', col_count;
RAISE NOTICE '생성된 ENUM: %/1개', enum_count;
RAISE NOTICE '생성된 인덱스: %/1개', idx_count;
IF col_count = 1 AND enum_count = 1 AND idx_count = 1 THEN
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
INSERT INTO migration_log (migration_file, status, notes) VALUES (migration_name, 'SUCCESS', migration_notes);
ELSE
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
END IF;
END;
-- 부서 ENUM 값 확인
RAISE NOTICE '=== 부서 ENUM 값 ===';
PERFORM dblink_exec('dbname=' || current_database(), 'SELECT enumlabel FROM pg_enum WHERE enumtypid = ''department_type''::regtype ORDER BY enumsortorder');
ELSIF current_status = 'SUCCESS' THEN
RAISE NOTICE ' 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
ELSE
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
END IF;
END $$;
COMMIT;

View File

@@ -0,0 +1,100 @@
-- 015_add_duplicate_tracking.sql
-- 중복 신고 추적 시스템 추가
BEGIN;
-- migration_log 테이블 생성 (멱등성)
CREATE TABLE IF NOT EXISTS migration_log (
id SERIAL PRIMARY KEY,
migration_file VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status VARCHAR(50),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 마이그레이션 파일 이름
DO $$
DECLARE
migration_name VARCHAR(255) := '015_add_duplicate_tracking.sql';
migration_notes TEXT := '중복 신고 추적 시스템: duplicate_of_issue_id, duplicate_reporters 컬럼 추가';
current_status VARCHAR(50);
BEGIN
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
IF current_status IS NULL THEN
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
-- issues 테이블에 중복 추적 컬럼 추가
-- 중복 대상 이슈 ID
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'duplicate_of_issue_id') THEN
ALTER TABLE issues ADD COLUMN duplicate_of_issue_id INTEGER;
RAISE NOTICE '✅ issues.duplicate_of_issue_id 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.duplicate_of_issue_id 컬럼이 이미 존재합니다.';
END IF;
-- 중복 신고자 목록 (JSONB 배열)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'duplicate_reporters') THEN
ALTER TABLE issues ADD COLUMN duplicate_reporters JSONB DEFAULT '[]'::jsonb;
RAISE NOTICE '✅ issues.duplicate_reporters 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.duplicate_reporters 컬럼이 이미 존재합니다.';
END IF;
-- 외래 키 제약 조건 추가 (duplicate_of_issue_id)
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_duplicate_of_issue_id_fkey') THEN
ALTER TABLE issues ADD CONSTRAINT issues_duplicate_of_issue_id_fkey
FOREIGN KEY (duplicate_of_issue_id) REFERENCES issues(id);
RAISE NOTICE '✅ issues_duplicate_of_issue_id_fkey 외래 키 제약 조건이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues_duplicate_of_issue_id_fkey 외래 키 제약 조건이 이미 존재합니다.';
END IF;
-- 인덱스 추가
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_duplicate_of') THEN
CREATE INDEX idx_issues_duplicate_of ON issues (duplicate_of_issue_id) WHERE duplicate_of_issue_id IS NOT NULL;
RAISE NOTICE '✅ idx_issues_duplicate_of 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_duplicate_of 인덱스가 이미 존재합니다.';
END IF;
-- JSONB 인덱스 추가 (중복 신고자 검색 성능 향상)
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_duplicate_reporters_gin') THEN
CREATE INDEX idx_issues_duplicate_reporters_gin ON issues USING GIN (duplicate_reporters);
RAISE NOTICE '✅ idx_issues_duplicate_reporters_gin 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_duplicate_reporters_gin 인덱스가 이미 존재합니다.';
END IF;
-- 마이그레이션 검증
DECLARE
col_count INTEGER;
idx_count INTEGER;
fk_count INTEGER;
BEGIN
SELECT COUNT(*) INTO col_count FROM information_schema.columns WHERE table_name = 'issues' AND column_name IN ('duplicate_of_issue_id', 'duplicate_reporters');
SELECT COUNT(*) INTO idx_count FROM pg_indexes WHERE tablename = 'issues' AND indexname IN ('idx_issues_duplicate_of', 'idx_issues_duplicate_reporters_gin');
SELECT COUNT(*) INTO fk_count FROM pg_constraint WHERE conname = 'issues_duplicate_of_issue_id_fkey';
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
RAISE NOTICE '추가된 컬럼: %/2개', col_count;
RAISE NOTICE '생성된 인덱스: %/2개', idx_count;
RAISE NOTICE '생성된 FK: %/1개', fk_count;
IF col_count = 2 AND idx_count = 2 AND fk_count = 1 THEN
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
INSERT INTO migration_log (migration_file, status, notes) VALUES (migration_name, 'SUCCESS', migration_notes);
ELSE
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
END IF;
END;
ELSIF current_status = 'SUCCESS' THEN
RAISE NOTICE ' 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
ELSE
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
END IF;
END $$;
COMMIT;

View File

@@ -0,0 +1,223 @@
-- 016_add_management_fields.sql
-- 관리함에서 사용할 추가 필드들과 완료 사진 업로드 기능 추가
BEGIN;
DO $$
DECLARE
migration_name VARCHAR(255) := '016_add_management_fields.sql';
migration_notes TEXT := '관리함 필드 추가: 원인/해결방안, 담당부서/담당자, 조치예상일, 완료확인일, 원인부서, 의견, 완료사진, 프로젝트별 No 등';
current_status VARCHAR(50);
BEGIN
-- migration_log 테이블이 없으면 생성 (멱등성)
CREATE TABLE IF NOT EXISTS migration_log (
id SERIAL PRIMARY KEY,
migration_file VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status VARCHAR(50),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
IF current_status IS NULL THEN
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
-- issues 테이블에 관리함 관련 컬럼들 추가
-- 완료 사진 경로
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path') THEN
ALTER TABLE issues ADD COLUMN completion_photo_path VARCHAR(255);
RAISE NOTICE '✅ issues.completion_photo_path 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_photo_path 컬럼이 이미 존재합니다.';
END IF;
-- 해결방안 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'solution') THEN
ALTER TABLE issues ADD COLUMN solution TEXT;
RAISE NOTICE '✅ issues.solution 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.solution 컬럼이 이미 존재합니다.';
END IF;
-- 담당부서 (관리함에서 선택)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'responsible_department') THEN
ALTER TABLE issues ADD COLUMN responsible_department department_type;
RAISE NOTICE '✅ issues.responsible_department 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.responsible_department 컬럼이 이미 존재합니다.';
END IF;
-- 담당자 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'responsible_person') THEN
ALTER TABLE issues ADD COLUMN responsible_person VARCHAR(100);
RAISE NOTICE '✅ issues.responsible_person 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.responsible_person 컬럼이 이미 존재합니다.';
END IF;
-- 조치 예상일 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'expected_completion_date') THEN
ALTER TABLE issues ADD COLUMN expected_completion_date DATE;
RAISE NOTICE '✅ issues.expected_completion_date 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.expected_completion_date 컬럼이 이미 존재합니다.';
END IF;
-- 완료 확인일 (완료 상태로 변경 시 자동 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'actual_completion_date') THEN
ALTER TABLE issues ADD COLUMN actual_completion_date DATE;
RAISE NOTICE '✅ issues.actual_completion_date 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.actual_completion_date 컬럼이 이미 존재합니다.';
END IF;
-- 원인부서 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'cause_department') THEN
ALTER TABLE issues ADD COLUMN cause_department department_type;
RAISE NOTICE '✅ issues.cause_department 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.cause_department 컬럼이 이미 존재합니다.';
END IF;
-- ISSUE에 대한 의견 (관리함에서 입력)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'management_comment') THEN
ALTER TABLE issues ADD COLUMN management_comment TEXT;
RAISE NOTICE '✅ issues.management_comment 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.management_comment 컬럼이 이미 존재합니다.';
END IF;
-- 프로젝트별 순번 (No) - 프로젝트 내에서 1, 2, 3... 순으로 증가
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'project_sequence_no') THEN
ALTER TABLE issues ADD COLUMN project_sequence_no INTEGER;
RAISE NOTICE '✅ issues.project_sequence_no 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.project_sequence_no 컬럼이 이미 존재합니다.';
END IF;
-- 최종 내용 (수정된 내용이 있으면 수정본, 없으면 원본)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'final_description') THEN
ALTER TABLE issues ADD COLUMN final_description TEXT;
RAISE NOTICE '✅ issues.final_description 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.final_description 컬럼이 이미 존재합니다.';
END IF;
-- 최종 카테고리 (수정된 카테고리가 있으면 수정본, 없으면 원본)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'final_category') THEN
ALTER TABLE issues ADD COLUMN final_category issuecategory;
RAISE NOTICE '✅ issues.final_category 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.final_category 컬럼이 이미 존재합니다.';
END IF;
-- 인덱스 추가
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_project_sequence') THEN
CREATE INDEX idx_issues_project_sequence ON issues (project_id, project_sequence_no);
RAISE NOTICE '✅ idx_issues_project_sequence 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_project_sequence 인덱스가 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_responsible_department') THEN
CREATE INDEX idx_issues_responsible_department ON issues (responsible_department) WHERE responsible_department IS NOT NULL;
RAISE NOTICE '✅ idx_issues_responsible_department 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_responsible_department 인덱스가 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_expected_completion') THEN
CREATE INDEX idx_issues_expected_completion ON issues (expected_completion_date) WHERE expected_completion_date IS NOT NULL;
RAISE NOTICE '✅ idx_issues_expected_completion 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_expected_completion 인덱스가 이미 존재합니다.';
END IF;
-- 프로젝트별 순번 자동 생성 함수
CREATE OR REPLACE FUNCTION generate_project_sequence_no(p_project_id BIGINT) RETURNS INTEGER AS $func$
DECLARE
next_no INTEGER;
BEGIN
-- 해당 프로젝트의 최대 순번 + 1
SELECT COALESCE(MAX(project_sequence_no), 0) + 1
INTO next_no
FROM issues
WHERE project_id = p_project_id;
RETURN next_no;
END;
$func$ LANGUAGE plpgsql;
RAISE NOTICE '✅ generate_project_sequence_no 함수가 생성되었습니다.';
-- 기존 이슈들에 대해 프로젝트별 순번 설정
DO $update_sequence$
DECLARE
issue_record RECORD;
seq_no INTEGER;
BEGIN
FOR issue_record IN
SELECT id, project_id
FROM issues
WHERE project_sequence_no IS NULL
ORDER BY project_id, report_date
LOOP
SELECT generate_project_sequence_no(issue_record.project_id) INTO seq_no;
UPDATE issues
SET project_sequence_no = seq_no
WHERE id = issue_record.id;
END LOOP;
END $update_sequence$;
RAISE NOTICE '✅ 기존 이슈들의 프로젝트별 순번이 설정되었습니다.';
-- 기존 이슈들의 final_description과 final_category 초기화
UPDATE issues
SET
final_description = description,
final_category = category
WHERE final_description IS NULL OR final_category IS NULL;
RAISE NOTICE '✅ 기존 이슈들의 final_description과 final_category가 초기화되었습니다.';
-- 마이그레이션 검증
DECLARE
col_count INTEGER;
idx_count INTEGER;
func_count INTEGER;
BEGIN
SELECT COUNT(*) INTO col_count FROM information_schema.columns
WHERE table_name = 'issues' AND column_name IN (
'completion_photo_path', 'solution', 'responsible_department', 'responsible_person',
'expected_completion_date', 'actual_completion_date', 'cause_department',
'management_comment', 'project_sequence_no', 'final_description', 'final_category'
);
SELECT COUNT(*) INTO idx_count FROM pg_indexes
WHERE tablename = 'issues' AND indexname IN (
'idx_issues_project_sequence', 'idx_issues_responsible_department', 'idx_issues_expected_completion'
);
SELECT COUNT(*) INTO func_count FROM pg_proc WHERE proname = 'generate_project_sequence_no';
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
RAISE NOTICE '추가된 컬럼: %/11개', col_count;
RAISE NOTICE '생성된 인덱스: %/3개', idx_count;
RAISE NOTICE '생성된 함수: %/1개', func_count;
IF col_count = 11 AND idx_count = 3 AND func_count = 1 THEN
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
INSERT INTO migration_log (migration_file, status, notes) VALUES (migration_name, 'SUCCESS', migration_notes);
ELSE
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
END IF;
END;
ELSIF current_status = 'SUCCESS' THEN
RAISE NOTICE ' 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
ELSE
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
END IF;
END $$;
COMMIT;

View File

@@ -0,0 +1,87 @@
-- 프로젝트별 순번(project_sequence_no) 자동 할당 개선
-- 수신함에서 진행 중/완료로 상태 변경 시 프로젝트별 순번이 자동 할당되도록 개선
DO $migration$
DECLARE
issue_record RECORD;
seq_no INTEGER;
updated_count INTEGER := 0;
BEGIN
RAISE NOTICE '=== 프로젝트별 순번 자동 할당 개선 마이그레이션 시작 ===';
-- 1. generate_project_sequence_no 함수가 존재하는지 확인
IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'generate_project_sequence_no') THEN
RAISE EXCEPTION '❌ generate_project_sequence_no 함수가 존재하지 않습니다. 016_add_management_fields.sql을 먼저 실행하세요.';
END IF;
-- 2. 진행 중 또는 완료 상태인데 project_sequence_no가 NULL인 이슈들 찾기
RAISE NOTICE '🔍 project_sequence_no가 누락된 이슈들을 찾는 중...';
FOR issue_record IN
SELECT id, project_id, review_status
FROM issues
WHERE review_status IN ('in_progress', 'completed')
AND project_sequence_no IS NULL
ORDER BY project_id, reviewed_at NULLS LAST, report_date
LOOP
-- 프로젝트별 순번 생성
SELECT generate_project_sequence_no(issue_record.project_id) INTO seq_no;
-- 순번 할당
UPDATE issues
SET project_sequence_no = seq_no
WHERE id = issue_record.id;
updated_count := updated_count + 1;
RAISE NOTICE '✅ 이슈 ID: %, 프로젝트 ID: %, 할당된 순번: %',
issue_record.id, issue_record.project_id, seq_no;
END LOOP;
-- 3. 결과 요약
RAISE NOTICE '=== 마이그레이션 완료 ===';
RAISE NOTICE '📊 총 %개의 이슈에 프로젝트별 순번이 할당되었습니다.', updated_count;
-- 4. 검증
DECLARE
missing_count INTEGER;
total_managed_count INTEGER;
BEGIN
-- 관리 중인 이슈 중 순번이 없는 것들 확인
SELECT COUNT(*) INTO missing_count
FROM issues
WHERE review_status IN ('in_progress', 'completed')
AND project_sequence_no IS NULL;
-- 전체 관리 중인 이슈 수
SELECT COUNT(*) INTO total_managed_count
FROM issues
WHERE review_status IN ('in_progress', 'completed');
RAISE NOTICE '=== 검증 결과 ===';
RAISE NOTICE '전체 관리 중인 이슈: %개', total_managed_count;
RAISE NOTICE '순번이 누락된 이슈: %개', missing_count;
IF missing_count > 0 THEN
RAISE WARNING '⚠️ 여전히 %개의 이슈에 순번이 누락되어 있습니다.', missing_count;
ELSE
RAISE NOTICE '✅ 모든 관리 중인 이슈에 순번이 정상적으로 할당되었습니다.';
END IF;
END;
EXCEPTION
WHEN OTHERS THEN
RAISE EXCEPTION '❌ 마이그레이션 실행 중 오류 발생: %', SQLERRM;
END $migration$;
-- 마이그레이션 로그 기록
INSERT INTO migration_log (migration_file, executed_at, status, notes)
VALUES (
'017_fix_project_sequence_no.sql',
NOW(),
'SUCCESS',
'프로젝트별 순번 자동 할당 개선: 수신함에서 진행중/완료로 상태 변경시 project_sequence_no 자동 할당되도록 백엔드 로직 개선 및 기존 데이터 보정'
) ON CONFLICT (migration_file) DO UPDATE SET
executed_at = NOW(),
status = EXCLUDED.status,
notes = EXCLUDED.notes;

View File

@@ -0,0 +1,85 @@
-- 수신함 추가 정보 필드 추가
-- 원인부서, 해당자, 원인 상세 정보를 기록하기 위한 필드들
DO $migration$
DECLARE
col_count INTEGER := 0;
idx_count INTEGER := 0;
BEGIN
RAISE NOTICE '=== 수신함 추가 정보 필드 추가 마이그레이션 시작 ===';
-- 1. 해당자 상세 정보 (responsible_person과 별도)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'responsible_person_detail') THEN
ALTER TABLE issues ADD COLUMN responsible_person_detail VARCHAR(200);
RAISE NOTICE '✅ issues.responsible_person_detail 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.responsible_person_detail 컬럼이 이미 존재합니다.';
END IF;
-- 2. 원인 상세 정보 (기록용)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'cause_detail') THEN
ALTER TABLE issues ADD COLUMN cause_detail TEXT;
RAISE NOTICE '✅ issues.cause_detail 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.cause_detail 컬럼이 이미 존재합니다.';
END IF;
-- 3. 추가 정보 입력 시간 (언제 입력되었는지 기록)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'additional_info_updated_at') THEN
ALTER TABLE issues ADD COLUMN additional_info_updated_at TIMESTAMP WITH TIME ZONE;
RAISE NOTICE '✅ issues.additional_info_updated_at 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.additional_info_updated_at 컬럼이 이미 존재합니다.';
END IF;
-- 4. 추가 정보 입력자 ID (누가 입력했는지 기록)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'additional_info_updated_by_id') THEN
ALTER TABLE issues ADD COLUMN additional_info_updated_by_id INTEGER REFERENCES users(id);
RAISE NOTICE '✅ issues.additional_info_updated_by_id 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.additional_info_updated_by_id 컬럼이 이미 존재합니다.';
END IF;
-- 인덱스 추가 (검색 성능 향상)
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_additional_info_updated_at') THEN
CREATE INDEX idx_issues_additional_info_updated_at ON issues (additional_info_updated_at);
RAISE NOTICE '✅ idx_issues_additional_info_updated_at 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_additional_info_updated_at 인덱스가 이미 존재합니다.';
END IF;
-- 검증
SELECT COUNT(*) INTO col_count FROM information_schema.columns
WHERE table_name = 'issues' AND column_name IN (
'responsible_person_detail', 'cause_detail', 'additional_info_updated_at', 'additional_info_updated_by_id'
);
SELECT COUNT(*) INTO idx_count FROM pg_indexes
WHERE tablename = 'issues' AND indexname = 'idx_issues_additional_info_updated_at';
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
RAISE NOTICE '추가된 컬럼: %/4개', col_count;
RAISE NOTICE '생성된 인덱스: %/1개', idx_count;
IF col_count = 4 AND idx_count = 1 THEN
RAISE NOTICE '✅ 모든 추가 정보 필드가 성공적으로 추가되었습니다.';
ELSE
RAISE WARNING '⚠️ 일부 필드나 인덱스가 누락되었습니다.';
END IF;
EXCEPTION
WHEN OTHERS THEN
RAISE EXCEPTION '❌ 마이그레이션 실행 중 오류 발생: %', SQLERRM;
END $migration$;
-- 마이그레이션 로그 기록
INSERT INTO migration_log (migration_file, executed_at, status, notes)
VALUES (
'018_add_additional_info_fields.sql',
NOW(),
'SUCCESS',
'수신함 추가 정보 필드 추가: 해당자 상세(responsible_person_detail), 원인 상세(cause_detail), 입력 시간/입력자 추적 필드'
) ON CONFLICT (migration_file) DO UPDATE SET
executed_at = NOW(),
status = EXCLUDED.status,
notes = EXCLUDED.notes;

View File

@@ -0,0 +1,28 @@
-- 삭제 로그 테이블 추가
-- 생성일: 2025-11-08
-- 설명: 부적합 등 엔티티 삭제 시 로그를 보관하기 위한 테이블
CREATE TABLE IF NOT EXISTS deletion_logs (
id SERIAL PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER NOT NULL,
entity_data JSONB NOT NULL,
deleted_by_id INTEGER NOT NULL REFERENCES users(id),
deleted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'Asia/Seoul'),
reason TEXT
);
-- 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_deletion_logs_entity_type ON deletion_logs(entity_type);
CREATE INDEX IF NOT EXISTS idx_deletion_logs_entity_id ON deletion_logs(entity_id);
CREATE INDEX IF NOT EXISTS idx_deletion_logs_deleted_by ON deletion_logs(deleted_by_id);
CREATE INDEX IF NOT EXISTS idx_deletion_logs_deleted_at ON deletion_logs(deleted_at);
-- 테이블 코멘트
COMMENT ON TABLE deletion_logs IS '엔티티 삭제 로그 - 삭제된 데이터의 백업 및 추적';
COMMENT ON COLUMN deletion_logs.entity_type IS '삭제된 엔티티 타입 (issue, project, daily_work 등)';
COMMENT ON COLUMN deletion_logs.entity_id IS '삭제된 엔티티의 ID';
COMMENT ON COLUMN deletion_logs.entity_data IS '삭제 시점의 엔티티 전체 데이터 (JSON)';
COMMENT ON COLUMN deletion_logs.deleted_by_id IS '삭제 실행자 ID';
COMMENT ON COLUMN deletion_logs.deleted_at IS '삭제 시각 (KST)';
COMMENT ON COLUMN deletion_logs.reason IS '삭제 사유 (선택사항)';

View File

@@ -0,0 +1,37 @@
-- 완료 신청 관련 필드 추가
-- 마이그레이션: 019_add_completion_request_fields.sql
DO $$
BEGIN
-- 완료 신청 관련 필드들 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_requested_at') THEN
ALTER TABLE issues ADD COLUMN completion_requested_at TIMESTAMP WITH TIME ZONE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_requested_by_id') THEN
ALTER TABLE issues ADD COLUMN completion_requested_by_id INTEGER REFERENCES users(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path') THEN
ALTER TABLE issues ADD COLUMN completion_photo_path VARCHAR(500);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_comment') THEN
ALTER TABLE issues ADD COLUMN completion_comment TEXT;
END IF;
-- 마이그레이션 로그 기록
INSERT INTO migration_log (migration_file, executed_at, status, notes)
VALUES ('019_add_completion_request_fields.sql', NOW(), 'SUCCESS', 'Added completion request fields: completion_requested_at, completion_requested_by_id, completion_photo_path, completion_comment');
RAISE NOTICE '✅ 완료 신청 관련 필드 추가 완료';
RAISE NOTICE '📝 완료 신청 필드 마이그레이션 완료 - 019_add_completion_request_fields.sql';
EXCEPTION
WHEN OTHERS THEN
-- 오류 발생 시 로그 기록
INSERT INTO migration_log (migration_file, executed_at, status, notes)
VALUES ('019_add_completion_request_fields.sql', NOW(), 'FAILED', 'Error: ' || SQLERRM);
RAISE EXCEPTION '❌ 마이그레이션 실패: %', SQLERRM;
END $$;

View File

@@ -0,0 +1,91 @@
-- 020_add_management_completion_fields.sql
-- 관리함 완료 신청 정보 필드 추가
-- 작성일: 2025-10-26
-- 목적: 완료 사진 및 코멘트 수정 기능 지원
BEGIN;
-- 마이그레이션 로그 확인
DO $$
BEGIN
-- 이미 실행된 마이그레이션인지 확인
IF EXISTS (
SELECT 1 FROM migration_log
WHERE migration_file = '020_add_management_completion_fields.sql'
AND status = 'completed'
) THEN
RAISE NOTICE '마이그레이션이 이미 실행되었습니다: 020_add_management_completion_fields.sql';
RETURN;
END IF;
-- 마이그레이션 시작 로그
INSERT INTO migration_log (migration_file, status, started_at, notes)
VALUES ('020_add_management_completion_fields.sql', 'running', NOW(),
'관리함 완료 신청 정보 필드 추가 - completion_photo, completion_comment 수정 기능');
-- completion_photo_path 컬럼이 없으면 추가 (이미 있을 수 있음)
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'completion_photo_path'
) THEN
ALTER TABLE issues ADD COLUMN completion_photo_path VARCHAR(500);
RAISE NOTICE '✅ completion_photo_path 컬럼 추가됨';
ELSE
RAISE NOTICE ' completion_photo_path 컬럼이 이미 존재함';
END IF;
-- completion_comment 컬럼이 없으면 추가 (이미 있을 수 있음)
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'completion_comment'
) THEN
ALTER TABLE issues ADD COLUMN completion_comment TEXT;
RAISE NOTICE '✅ completion_comment 컬럼 추가됨';
ELSE
RAISE NOTICE ' completion_comment 컬럼이 이미 존재함';
END IF;
-- completion_requested_at 컬럼이 없으면 추가 (이미 있을 수 있음)
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'completion_requested_at'
) THEN
ALTER TABLE issues ADD COLUMN completion_requested_at TIMESTAMP WITH TIME ZONE;
RAISE NOTICE '✅ completion_requested_at 컬럼 추가됨';
ELSE
RAISE NOTICE ' completion_requested_at 컬럼이 이미 존재함';
END IF;
-- completion_requested_by_id 컬럼이 없으면 추가 (이미 있을 수 있음)
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'issues' AND column_name = 'completion_requested_by_id'
) THEN
ALTER TABLE issues ADD COLUMN completion_requested_by_id INTEGER REFERENCES users(id);
RAISE NOTICE '✅ completion_requested_by_id 컬럼 추가됨';
ELSE
RAISE NOTICE ' completion_requested_by_id 컬럼이 이미 존재함';
END IF;
-- 마이그레이션 완료 로그
UPDATE migration_log
SET status = 'completed', completed_at = NOW(),
notes = notes || ' - 완료: 모든 필요한 컬럼이 추가되었습니다.'
WHERE migration_file = '020_add_management_completion_fields.sql'
AND status = 'running';
RAISE NOTICE '🎉 마이그레이션 완료: 020_add_management_completion_fields.sql';
EXCEPTION
WHEN OTHERS THEN
-- 에러 발생 시 로그 업데이트
UPDATE migration_log
SET status = 'failed', completed_at = NOW(),
notes = notes || ' - 실패: ' || SQLERRM
WHERE migration_file = '020_add_management_completion_fields.sql'
AND status = 'running';
RAISE EXCEPTION '❌ 마이그레이션 실패: %', SQLERRM;
END $$;
COMMIT;

View File

@@ -0,0 +1,15 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.2
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
alembic==1.12.1
pydantic==2.5.0
pydantic-settings==2.1.0
pillow==10.1.0
pillow-heif==0.13.0
reportlab==4.0.7
openpyxl==3.1.2

View File

@@ -0,0 +1,186 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import List
from pydantic import BaseModel
from database.database import get_db
from database.models import User, UserRole
from database import schemas
from services.auth_service import (
authenticate_user, create_access_token, verify_token,
get_password_hash, verify_password
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
token_data = verify_token(token, credentials_exception)
user = db.query(User).filter(User.username == token_data.username).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
async def get_current_admin(current_user: User = Depends(get_current_user)):
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
@router.options("/login")
async def login_options():
"""OPTIONS preflight 요청 처리"""
return {"message": "OK"}
@router.post("/login", response_model=schemas.Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": user.username})
return {
"access_token": access_token,
"token_type": "bearer",
"user": user
}
@router.get("/me", response_model=schemas.User)
async def read_users_me(current_user: User = Depends(get_current_user)):
return current_user
@router.get("/users", response_model=List[schemas.User])
async def get_all_users(
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
"""모든 사용자 목록 조회 (관리자 전용)"""
users = db.query(User).filter(User.is_active == True).all()
return users
@router.post("/users", response_model=schemas.User)
async def create_user(
user: schemas.UserCreate,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
# 중복 확인
db_user = db.query(User).filter(User.username == user.username).first()
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
# 사용자 생성
db_user = User(
username=user.username,
hashed_password=get_password_hash(user.password),
full_name=user.full_name,
role=user.role
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.get("/users", response_model=List[schemas.User])
async def read_users(
skip: int = 0,
limit: int = 100,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.put("/users/{user_id}", response_model=schemas.User)
async def update_user(
user_id: int,
user_update: schemas.UserUpdate,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
# 업데이트
update_data = user_update.dict(exclude_unset=True)
if "password" in update_data:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
for field, value in update_data.items():
setattr(db_user, field, value)
db.commit()
db.refresh(db_user)
return db_user
@router.delete("/users/{username}")
async def delete_user(
username: str,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
db_user = db.query(User).filter(User.username == username).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
# hyungi 계정은 삭제 불가
if db_user.username == "hyungi":
raise HTTPException(status_code=400, detail="Cannot delete primary admin user")
db.delete(db_user)
db.commit()
return {"detail": "User deleted successfully"}
@router.post("/change-password")
async def change_password(
password_change: schemas.PasswordChange,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# 현재 비밀번호 확인
if not verify_password(password_change.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
# 새 비밀번호 설정
current_user.hashed_password = get_password_hash(password_change.new_password)
db.commit()
return {"detail": "Password changed successfully"}
class PasswordReset(BaseModel):
new_password: str
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: int,
password_reset: PasswordReset,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
"""사용자 비밀번호 초기화 (관리자 전용)"""
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
# 새 비밀번호로 업데이트
db_user.hashed_password = get_password_hash(password_reset.new_password)
db.commit()
return {"detail": f"Password reset successfully for user {db_user.username}"}

View File

@@ -0,0 +1,163 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, date, timezone, timedelta
from database.database import get_db
from database.models import DailyWork, User, UserRole, KST
from database import schemas
from routers.auth import get_current_user
router = APIRouter(prefix="/api/daily-work", tags=["daily-work"])
@router.post("/", response_model=schemas.DailyWork)
async def create_daily_work(
work: schemas.DailyWorkCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# 중복 확인 (같은 날짜)
existing = db.query(DailyWork).filter(
DailyWork.date == work.date.date()
).first()
if existing:
raise HTTPException(status_code=400, detail="Daily work for this date already exists")
# 계산
regular_hours = work.worker_count * 8 # 정규 근무 8시간
overtime_total = work.overtime_workers * work.overtime_hours
total_hours = regular_hours + overtime_total
# 생성
db_work = DailyWork(
date=work.date,
worker_count=work.worker_count,
regular_hours=regular_hours,
overtime_workers=work.overtime_workers,
overtime_hours=work.overtime_hours,
overtime_total=overtime_total,
total_hours=total_hours,
created_by_id=current_user.id
)
db.add(db_work)
db.commit()
db.refresh(db_work)
return db_work
@router.get("/", response_model=List[schemas.DailyWork])
async def read_daily_works(
skip: int = 0,
limit: int = 100,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
query = db.query(DailyWork)
if start_date:
query = query.filter(DailyWork.date >= start_date)
if end_date:
query = query.filter(DailyWork.date <= end_date)
works = query.order_by(DailyWork.date.desc()).offset(skip).limit(limit).all()
return works
@router.get("/{work_id}", response_model=schemas.DailyWork)
async def read_daily_work(
work_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
if not work:
raise HTTPException(status_code=404, detail="Daily work not found")
return work
@router.put("/{work_id}", response_model=schemas.DailyWork)
async def update_daily_work(
work_id: int,
work_update: schemas.DailyWorkUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
if not work:
raise HTTPException(status_code=404, detail="Daily work not found")
# 업데이트
update_data = work_update.dict(exclude_unset=True)
# 재계산 필요한 경우
if any(key in update_data for key in ["worker_count", "overtime_workers", "overtime_hours"]):
worker_count = update_data.get("worker_count", work.worker_count)
overtime_workers = update_data.get("overtime_workers", work.overtime_workers)
overtime_hours = update_data.get("overtime_hours", work.overtime_hours)
regular_hours = worker_count * 8
overtime_total = overtime_workers * overtime_hours
total_hours = regular_hours + overtime_total
update_data["regular_hours"] = regular_hours
update_data["overtime_total"] = overtime_total
update_data["total_hours"] = total_hours
for field, value in update_data.items():
setattr(work, field, value)
db.commit()
db.refresh(work)
return work
@router.delete("/{work_id}")
async def delete_daily_work(
work_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
if not work:
raise HTTPException(status_code=404, detail="Daily work not found")
# 권한 확인 (관리자만 삭제 가능)
if current_user.role != UserRole.admin:
raise HTTPException(status_code=403, detail="Only admin can delete daily work")
db.delete(work)
db.commit()
return {"detail": "Daily work deleted successfully"}
@router.get("/stats/summary")
async def get_daily_work_stats(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""일일 공수 통계"""
query = db.query(DailyWork)
if start_date:
query = query.filter(DailyWork.date >= start_date)
if end_date:
query = query.filter(DailyWork.date <= end_date)
works = query.all()
if not works:
return {
"total_days": 0,
"total_hours": 0,
"total_overtime": 0,
"average_daily_hours": 0
}
total_hours = sum(w.total_hours for w in works)
total_overtime = sum(w.overtime_total for w in works)
return {
"total_days": len(works),
"total_hours": total_hours,
"total_overtime": total_overtime,
"average_daily_hours": total_hours / len(works)
}

View File

@@ -0,0 +1,404 @@
from fastapi import APIRouter, Depends, HTTPException, Query
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.schemas import (
InboxIssue, IssueDisposalRequest, IssueReviewRequest,
IssueStatusUpdateRequest, ModificationLogEntry, ManagementUpdateRequest
)
from routers.auth import get_current_user, get_current_admin
from routers.page_permissions import check_page_access
from services.file_service import save_base64_image
router = APIRouter(prefix="/api/inbox", tags=["inbox"])
@router.get("/", response_model=List[InboxIssue])
async def get_inbox_issues(
project_id: Optional[int] = Query(None, description="프로젝트 ID로 필터링"),
skip: int = Query(0, ge=0, description="건너뛸 항목 수"),
limit: int = Query(100, ge=1, le=1000, description="가져올 항목 수"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
수신함 - 검토 대기 중인 부적합 목록 조회
"""
query = db.query(Issue).filter(Issue.review_status == ReviewStatus.pending_review)
# 프로젝트 필터링
if project_id:
query = query.filter(Issue.project_id == project_id)
# 최신순 정렬
query = query.order_by(Issue.report_date.desc())
issues = query.offset(skip).limit(limit).all()
return issues
@router.post("/{issue_id}/dispose")
async def dispose_issue(
issue_id: int,
disposal_request: IssueDisposalRequest,
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
db: Session = Depends(get_db)
):
"""
부적합 폐기 처리
"""
# 수신함 페이지 권한 확인
if not check_page_access(current_user.id, 'issues_inbox', db):
raise HTTPException(status_code=403, detail="수신함 접근 권한이 없습니다.")
# 부적합 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
if issue.review_status != ReviewStatus.pending_review:
raise HTTPException(status_code=400, detail="검토 대기 중인 부적합만 폐기할 수 있습니다.")
# 사용자 정의 사유 검증
if disposal_request.disposal_reason == DisposalReasonType.custom:
if not disposal_request.custom_disposal_reason or not disposal_request.custom_disposal_reason.strip():
raise HTTPException(status_code=400, detail="사용자 정의 폐기 사유를 입력해주세요.")
# 원본 데이터 보존
if not issue.original_data:
issue.original_data = {
"category": issue.category.value,
"description": issue.description,
"project_id": issue.project_id,
"photo_path": issue.photo_path,
"photo_path2": issue.photo_path2,
"preserved_at": datetime.now().isoformat()
}
# 중복 처리 로직
if disposal_request.disposal_reason == DisposalReasonType.duplicate and disposal_request.duplicate_of_issue_id:
# 중복 대상 이슈 확인
target_issue = db.query(Issue).filter(Issue.id == disposal_request.duplicate_of_issue_id).first()
if not target_issue:
raise HTTPException(status_code=404, detail="중복 대상 이슈를 찾을 수 없습니다.")
# 중복 신고자를 대상 이슈에 추가
current_reporters = target_issue.duplicate_reporters or []
new_reporter = {
"user_id": issue.reporter_id,
"username": issue.reporter.username,
"full_name": issue.reporter.full_name,
"report_date": issue.report_date.isoformat() if issue.report_date else None,
"added_at": datetime.now().isoformat()
}
# 중복 체크 (이미 추가된 신고자인지 확인)
existing_reporter = next((r for r in current_reporters if r.get("user_id") == issue.reporter_id), None)
if not existing_reporter:
current_reporters.append(new_reporter)
target_issue.duplicate_reporters = current_reporters
# 현재 이슈에 중복 대상 설정
issue.duplicate_of_issue_id = disposal_request.duplicate_of_issue_id
# 폐기 처리
issue.review_status = ReviewStatus.disposed
issue.disposal_reason = disposal_request.disposal_reason
issue.custom_disposal_reason = disposal_request.custom_disposal_reason
issue.disposed_at = datetime.now()
issue.reviewed_by_id = current_user.id
issue.reviewed_at = datetime.now()
db.commit()
db.refresh(issue)
return {
"message": "부적합이 성공적으로 폐기되었습니다.",
"issue_id": issue.id,
"disposal_reason": issue.disposal_reason.value,
"disposed_at": issue.disposed_at
}
@router.post("/{issue_id}/review")
async def review_issue(
issue_id: int,
review_request: IssueReviewRequest,
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
db: Session = Depends(get_db)
):
"""
부적합 검토 및 수정
"""
# 수신함 페이지 권한 확인
if not check_page_access(current_user.id, 'issues_inbox', db):
raise HTTPException(status_code=403, detail="수신함 접근 권한이 없습니다.")
# 부적합 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
if issue.review_status != ReviewStatus.pending_review:
raise HTTPException(status_code=400, detail="검토 대기 중인 부적합만 수정할 수 있습니다.")
# 원본 데이터 보존 (최초 1회만)
if not issue.original_data:
issue.original_data = {
"category": issue.category.value,
"description": issue.description,
"project_id": issue.project_id,
"photo_path": issue.photo_path,
"photo_path2": issue.photo_path2,
"preserved_at": datetime.now().isoformat()
}
# 수정 이력 준비
modifications = []
current_time = datetime.now()
# 프로젝트 변경
if review_request.project_id is not None and review_request.project_id != issue.project_id:
# 프로젝트 존재 확인
if review_request.project_id != 0: # 0은 프로젝트 없음을 의미
project = db.query(Project).filter(Project.id == review_request.project_id).first()
if not project:
raise HTTPException(status_code=400, detail="존재하지 않는 프로젝트입니다.")
modifications.append({
"field": "project_id",
"old_value": issue.project_id,
"new_value": review_request.project_id,
"modified_at": current_time.isoformat(),
"modified_by": current_user.id
})
issue.project_id = review_request.project_id if review_request.project_id != 0 else None
# 카테고리 변경
if review_request.category is not None and review_request.category != issue.category:
modifications.append({
"field": "category",
"old_value": issue.category.value,
"new_value": review_request.category.value,
"modified_at": current_time.isoformat(),
"modified_by": current_user.id
})
issue.category = review_request.category
# 설명 변경
if review_request.description is not None and review_request.description != issue.description:
modifications.append({
"field": "description",
"old_value": issue.description,
"new_value": review_request.description,
"modified_at": current_time.isoformat(),
"modified_by": current_user.id
})
issue.description = review_request.description
# 수정 이력 업데이트
if modifications:
if issue.modification_log is None:
issue.modification_log = []
issue.modification_log.extend(modifications)
# SQLAlchemy에 변경사항 알림
from sqlalchemy.orm.attributes import flag_modified
flag_modified(issue, "modification_log")
# 검토자 정보 업데이트
issue.reviewed_by_id = current_user.id
issue.reviewed_at = current_time
db.commit()
db.refresh(issue)
return {
"message": "부적합 검토가 완료되었습니다.",
"issue_id": issue.id,
"modifications_count": len(modifications),
"modifications": modifications,
"reviewed_at": issue.reviewed_at
}
@router.post("/{issue_id}/status")
async def update_issue_status(
issue_id: int,
status_request: IssueStatusUpdateRequest,
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
db: Session = Depends(get_db)
):
"""
부적합 최종 상태 결정 (진행 중 / 완료)
"""
# 수신함 페이지 권한 확인
if not check_page_access(current_user.id, 'issues_inbox', db):
raise HTTPException(status_code=403, detail="수신함 접근 권한이 없습니다.")
# 부적합 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
if issue.review_status not in [ReviewStatus.pending_review, ReviewStatus.in_progress]:
raise HTTPException(status_code=400, detail="이미 처리가 완료된 부적합입니다.")
# 상태 변경 검증
if status_request.review_status not in [ReviewStatus.in_progress, ReviewStatus.completed]:
raise HTTPException(status_code=400, detail="진행 중 또는 완료 상태만 설정할 수 있습니다.")
# 원본 데이터 보존 (최초 1회만)
if not issue.original_data:
issue.original_data = {
"category": issue.category.value,
"description": issue.description,
"project_id": issue.project_id,
"photo_path": issue.photo_path,
"photo_path2": issue.photo_path2,
"preserved_at": datetime.now().isoformat()
}
# 상태 변경
old_status = issue.review_status
issue.review_status = status_request.review_status
issue.reviewed_by_id = current_user.id
issue.reviewed_at = datetime.now()
# 진행 중 또는 완료 상태로 변경 시 프로젝트별 순번 자동 할당
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()
# 완료 사진 업로드 처리
if status_request.completion_photo and status_request.review_status == ReviewStatus.completed:
try:
completion_photo_path = save_base64_image(status_request.completion_photo, "completion")
issue.completion_photo_path = completion_photo_path
except Exception as e:
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
# 완료 상태로 변경 시 추가 정보 처리
if status_request.review_status == ReviewStatus.completed:
issue.actual_completion_date = datetime.now().date()
# 해결방안 저장
if status_request.solution:
issue.solution = status_request.solution
# 담당부서 저장
if status_request.responsible_department:
issue.responsible_department = status_request.responsible_department
# 담당자 저장
if status_request.responsible_person:
issue.responsible_person = status_request.responsible_person
db.commit()
db.refresh(issue)
return {
"message": f"부적합 상태가 '{status_request.review_status.value}'로 변경되었습니다.",
"issue_id": issue.id,
"old_status": old_status.value,
"new_status": issue.review_status.value,
"reviewed_at": issue.reviewed_at,
"destination": "관리함" if status_request.review_status in [ReviewStatus.in_progress, ReviewStatus.completed] else "수신함"
}
@router.get("/{issue_id}/history")
async def get_issue_history(
issue_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
부적합 수정 이력 조회
"""
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
return {
"issue_id": issue.id,
"original_data": issue.original_data,
"modification_log": issue.modification_log or [],
"review_status": issue.review_status.value,
"reviewed_by_id": issue.reviewed_by_id,
"reviewed_at": issue.reviewed_at,
"disposal_info": {
"disposal_reason": issue.disposal_reason.value if issue.disposal_reason else None,
"custom_disposal_reason": issue.custom_disposal_reason,
"disposed_at": issue.disposed_at
} if issue.review_status == ReviewStatus.disposed else None
}
@router.get("/statistics")
async def get_inbox_statistics(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
수신함 통계 정보
"""
# 검토 대기 중
pending_count = db.query(Issue).filter(Issue.review_status == ReviewStatus.pending_review).count()
# 오늘 처리된 건수
today = datetime.now().date()
today_processed = db.query(Issue).filter(
Issue.reviewed_at >= today,
Issue.review_status != ReviewStatus.pending_review
).count()
# 폐기된 건수
disposed_count = db.query(Issue).filter(Issue.review_status == ReviewStatus.disposed).count()
# 관리함으로 이동한 건수
management_count = db.query(Issue).filter(
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
).count()
return {
"pending_review": pending_count,
"today_processed": today_processed,
"total_disposed": disposed_count,
"total_in_management": management_count,
"total_issues": db.query(Issue).count()
}
@router.get("/management-issues")
async def get_management_issues(
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""관리함 이슈 목록 조회 (중복 선택용)"""
try:
query = db.query(Issue).filter(
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
)
# 프로젝트 필터 적용
if project_id:
query = query.filter(Issue.project_id == project_id)
issues = query.order_by(Issue.reviewed_at.desc()).limit(50).all()
# 간단한 형태로 반환 (제목과 ID만)
result = []
for issue in issues:
result.append({
"id": issue.id,
"description": issue.description[:100] + "..." if len(issue.description) > 100 else issue.description,
"category": issue.category.value,
"reporter_name": issue.reporter.full_name or issue.reporter.username,
"reviewed_at": issue.reviewed_at.isoformat() if issue.reviewed_at else None,
"duplicate_count": len(issue.duplicate_reporters) if issue.duplicate_reporters else 0
})
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"관리함 이슈 조회 중 오류가 발생했습니다: {str(e)}")

View File

@@ -0,0 +1,488 @@
from fastapi import APIRouter, Depends, HTTPException, status
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, IssueStatus, User, UserRole, ReviewStatus
from database import schemas
from routers.auth import get_current_user, get_current_admin
from routers.page_permissions import check_page_access
from services.file_service import save_base64_image, delete_file
router = APIRouter(prefix="/api/issues", tags=["issues"])
@router.post("/", response_model=schemas.Issue)
async def create_issue(
issue: schemas.IssueCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
print(f"DEBUG: 받은 issue 데이터: {issue}")
print(f"DEBUG: project_id: {issue.project_id}")
# 이미지 저장 (최대 5장)
photo_paths = {}
for i in range(1, 6):
photo_field = f"photo{i if i > 1 else ''}"
path_field = f"photo_path{i if i > 1 else ''}"
photo_data = getattr(issue, photo_field, None)
if photo_data:
photo_paths[path_field] = save_base64_image(photo_data)
else:
photo_paths[path_field] = None
# Issue 생성
db_issue = Issue(
category=issue.category,
description=issue.description,
photo_path=photo_paths.get('photo_path'),
photo_path2=photo_paths.get('photo_path2'),
photo_path3=photo_paths.get('photo_path3'),
photo_path4=photo_paths.get('photo_path4'),
photo_path5=photo_paths.get('photo_path5'),
reporter_id=current_user.id,
project_id=issue.project_id,
status=IssueStatus.new
)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.get("/", response_model=List[schemas.Issue])
async def read_issues(
skip: int = 0,
limit: int = 100,
status: Optional[IssueStatus] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
query = db.query(Issue)
# 권한별 조회 제한
if current_user.role == UserRole.admin:
# 관리자는 모든 이슈 조회 가능
pass
else:
# 일반 사용자는 본인이 등록한 이슈만 조회 가능
query = query.filter(Issue.reporter_id == current_user.id)
if status:
query = query.filter(Issue.status == status)
# 최신순 정렬 (report_date 기준)
issues = query.order_by(Issue.report_date.desc()).offset(skip).limit(limit).all()
return issues
@router.get("/admin/all", response_model=List[schemas.Issue])
async def read_all_issues_admin(
skip: int = 0,
limit: int = 100,
status: Optional[IssueStatus] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""이슈 관리 권한이 있는 사용자: 모든 부적합 조회"""
# 이슈 관리 페이지 권한 확인 (관리함, 폐기함 등에서 사용)
from routers.page_permissions import check_page_access
# 관리자이거나 이슈 관리 권한이 있는 사용자만 접근 가능
if (current_user.role != 'admin' and
not check_page_access(current_user.id, 'issues_manage', db) and
not check_page_access(current_user.id, 'issues_inbox', db) and
not check_page_access(current_user.id, 'issues_management', db) and
not check_page_access(current_user.id, 'issues_archive', db)):
raise HTTPException(status_code=403, detail="이슈 관리 권한이 없습니다.")
query = db.query(Issue)
if status:
query = query.filter(Issue.status == status)
# 최신순 정렬 (report_date 기준)
issues = query.order_by(Issue.report_date.desc()).offset(skip).limit(limit).all()
return issues
@router.get("/{issue_id}", response_model=schemas.Issue)
async def read_issue(
issue_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
# 권한별 조회 제한
if current_user.role != UserRole.admin and issue.reporter_id != current_user.id:
raise HTTPException(
status_code=403,
detail="본인이 등록한 부적합만 조회할 수 있습니다."
)
return issue
@router.put("/{issue_id}", response_model=schemas.Issue)
async def update_issue(
issue_id: int,
issue_update: schemas.IssueUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인
if current_user.role == UserRole.user and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to update this issue")
# 업데이트
update_data = issue_update.dict(exclude_unset=True)
# 사진 업데이트 처리 (최대 5장)
for i in range(1, 6):
photo_field = f"photo{i if i > 1 else ''}"
path_field = f"photo_path{i if i > 1 else ''}"
if photo_field in update_data:
# 기존 사진 삭제
existing_path = getattr(issue, path_field, None)
if existing_path:
delete_file(existing_path)
# 새 사진 저장
if update_data[photo_field]:
new_path = save_base64_image(update_data[photo_field])
update_data[path_field] = new_path
else:
update_data[path_field] = None
# photo 필드는 제거 (DB에는 photo_path만 저장)
del update_data[photo_field]
# work_hours가 입력되면 자동으로 상태를 complete로 변경
if "work_hours" in update_data and update_data["work_hours"] > 0:
if issue.status == IssueStatus.new:
update_data["status"] = IssueStatus.complete
for field, value in update_data.items():
setattr(issue, field, value)
db.commit()
db.refresh(issue)
return issue
@router.delete("/{issue_id}")
async def delete_issue(
issue_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
from database.models import DeletionLog
import json
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인 (관리자 또는 본인이 등록한 경우 삭제 가능)
if current_user.role != UserRole.admin and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="본인이 등록한 부적합만 삭제할 수 있습니다.")
# 이 이슈를 중복 대상으로 참조하는 다른 이슈들의 참조 제거
referencing_issues = db.query(Issue).filter(Issue.duplicate_of_issue_id == issue_id).all()
if referencing_issues:
print(f"DEBUG: {len(referencing_issues)}개의 이슈가 이 이슈를 중복 대상으로 참조하고 있습니다. 참조를 제거합니다.")
for ref_issue in referencing_issues:
ref_issue.duplicate_of_issue_id = None
db.flush() # 참조 제거를 먼저 커밋
# 삭제 로그 생성 (삭제 전 데이터 저장)
issue_data = {
"id": issue.id,
"category": issue.category.value if issue.category else None,
"description": issue.description,
"status": issue.status.value if issue.status else None,
"reporter_id": issue.reporter_id,
"project_id": issue.project_id,
"report_date": issue.report_date.isoformat() if issue.report_date else None,
"work_hours": issue.work_hours,
"detail_notes": issue.detail_notes,
"photo_path": issue.photo_path,
"photo_path2": issue.photo_path2,
"review_status": issue.review_status.value if issue.review_status else None,
"solution": issue.solution,
"responsible_department": issue.responsible_department.value if issue.responsible_department else None,
"responsible_person": issue.responsible_person,
"expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None,
"actual_completion_date": issue.actual_completion_date.isoformat() if issue.actual_completion_date else None,
}
deletion_log = DeletionLog(
entity_type="issue",
entity_id=issue.id,
entity_data=issue_data,
deleted_by_id=current_user.id,
reason=f"사용자 {current_user.username}에 의해 삭제됨"
)
db.add(deletion_log)
# 이미지 파일 삭제 (신고 사진 최대 5장)
for i in range(1, 6):
path_field = f"photo_path{i if i > 1 else ''}"
path = getattr(issue, path_field, None)
if path:
delete_file(path)
# 완료 사진 삭제 (최대 5장)
for i in range(1, 6):
path_field = f"completion_photo_path{i if i > 1 else ''}"
path = getattr(issue, path_field, None)
if path:
delete_file(path)
db.delete(issue)
db.commit()
return {"detail": "Issue deleted successfully", "logged": True}
@router.get("/stats/summary")
async def get_issue_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""이슈 통계 조회"""
query = db.query(Issue)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.user:
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()
return {
"total": total,
"new": new,
"progress": progress,
"complete": complete
}
@router.put("/{issue_id}/management")
async def update_issue_management(
issue_id: int,
management_update: schemas.ManagementUpdateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
관리함에서 이슈의 관리 관련 필드들을 업데이트합니다.
"""
print(f"DEBUG: Received management update for issue {issue_id}")
print(f"DEBUG: Update data: {management_update}")
print(f"DEBUG: Current user: {current_user.username}")
# 관리함 페이지 권한 확인
if not (current_user.role == UserRole.admin or check_page_access(current_user.id, 'issues_management', db)):
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
print(f"DEBUG: Found issue: {issue.id}")
# 관리함에서만 수정 가능한 필드들만 업데이트
update_data = management_update.dict(exclude_unset=True)
print(f"DEBUG: Update data dict: {update_data}")
# 완료 사진 처리 (최대 5장)
completion_photo_fields = []
for i in range(1, 6):
photo_field = f"completion_photo{i if i > 1 else ''}"
path_field = f"completion_photo_path{i if i > 1 else ''}"
if photo_field in update_data and update_data[photo_field]:
completion_photo_fields.append(photo_field)
try:
# 기존 사진 삭제
existing_path = getattr(issue, path_field, None)
if existing_path:
delete_file(existing_path)
# 새 사진 저장
new_path = save_base64_image(update_data[photo_field], "completion")
setattr(issue, path_field, new_path)
print(f"DEBUG: Saved {photo_field}: {new_path}")
except Exception as e:
print(f"DEBUG: Photo save error for {photo_field}: {str(e)}")
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
# 나머지 필드 처리 (완료 사진 제외)
for field, value in update_data.items():
if field not in completion_photo_fields:
print(f"DEBUG: Processing field {field} = {value}")
try:
setattr(issue, field, value)
print(f"DEBUG: Set {field} = {value}")
except Exception as e:
print(f"DEBUG: Field set error for {field}: {str(e)}")
raise HTTPException(status_code=400, detail=f"필드 {field} 설정 실패: {str(e)}")
try:
db.commit()
db.refresh(issue)
print(f"DEBUG: Successfully updated issue {issue.id}")
except Exception as e:
print(f"DEBUG: Database commit error: {str(e)}")
db.rollback()
raise HTTPException(status_code=500, detail=f"데이터베이스 저장 실패: {str(e)}")
return {
"message": "관리 정보가 업데이트되었습니다.",
"issue_id": issue.id,
"updated_fields": list(update_data.keys())
}
@router.post("/{issue_id}/completion-request")
async def request_completion(
issue_id: int,
request: schemas.CompletionRequestRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
완료 신청 - 담당자가 작업 완료 후 완료 신청
"""
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
# 진행 중 상태인지 확인
if issue.review_status != ReviewStatus.in_progress:
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 완료 신청할 수 있습니다.")
# 이미 완료 신청된 경우 확인
if issue.completion_requested_at:
raise HTTPException(status_code=400, detail="이미 완료 신청된 부적합입니다.")
try:
print(f"DEBUG: 완료 신청 시작 - Issue ID: {issue_id}, User: {current_user.username}")
# 완료 사진 저장 (최대 5장)
saved_paths = []
for i in range(1, 6):
photo_field = f"completion_photo{i if i > 1 else ''}"
path_field = f"completion_photo_path{i if i > 1 else ''}"
photo_data = getattr(request, photo_field, None)
if photo_data:
print(f"DEBUG: {photo_field} 저장 시작")
saved_path = save_base64_image(photo_data, "completion")
if saved_path:
setattr(issue, path_field, saved_path)
saved_paths.append(saved_path)
print(f"DEBUG: {photo_field} 저장 완료 - Path: {saved_path}")
else:
raise Exception(f"{photo_field} 저장에 실패했습니다.")
# 완료 신청 정보 업데이트
print(f"DEBUG: DB 업데이트 시작")
issue.completion_requested_at = datetime.now()
issue.completion_requested_by_id = current_user.id
issue.completion_comment = request.completion_comment
db.commit()
db.refresh(issue)
print(f"DEBUG: DB 업데이트 완료")
return {
"message": "완료 신청이 성공적으로 제출되었습니다.",
"issue_id": issue.id,
"completion_requested_at": issue.completion_requested_at,
"completion_photo_paths": saved_paths
}
except Exception as e:
print(f"ERROR: 완료 신청 처리 오류 - {str(e)}")
db.rollback()
# 업로드된 파일이 있다면 삭제
if 'saved_paths' in locals():
for path in saved_paths:
try:
delete_file(path)
except:
pass
raise HTTPException(status_code=500, detail=f"완료 신청 처리 중 오류가 발생했습니다: {str(e)}")
@router.post("/{issue_id}/reject-completion")
async def reject_completion_request(
issue_id: int,
request: schemas.CompletionRejectionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
완료 신청 반려 - 관리자가 완료 신청을 반려
"""
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
# 완료 신청이 있는지 확인
if not issue.completion_requested_at:
raise HTTPException(status_code=400, detail="완료 신청이 없는 부적합입니다.")
# 권한 확인 (관리자 또는 관리함 접근 권한이 있는 사용자)
if current_user.role != UserRole.admin and not check_page_access(current_user.id, 'issues_management', db):
raise HTTPException(status_code=403, detail="완료 반려 권한이 없습니다.")
try:
print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}")
# 완료 사진 파일 삭제 (최대 5장)
for i in range(1, 6):
path_field = f"completion_photo_path{i if i > 1 else ''}"
photo_path = getattr(issue, path_field, None)
if photo_path:
try:
delete_file(photo_path)
print(f"DEBUG: {path_field} 삭제 완료")
except Exception as e:
print(f"WARNING: {path_field} 삭제 실패 - {str(e)}")
# 완료 신청 정보 초기화
issue.completion_requested_at = None
issue.completion_requested_by_id = None
for i in range(1, 6):
path_field = f"completion_photo_path{i if i > 1 else ''}"
setattr(issue, path_field, None)
issue.completion_comment = None
# 완료 반려 정보 기록 (전용 필드 사용)
issue.completion_rejected_at = datetime.now()
issue.completion_rejected_by_id = current_user.id
issue.completion_rejection_reason = request.rejection_reason
# 상태는 in_progress로 유지
issue.review_status = ReviewStatus.in_progress
db.commit()
db.refresh(issue)
print(f"DEBUG: 완료 반려 처리 완료")
return {
"message": "완료 신청이 반려되었습니다.",
"issue_id": issue.id,
"rejection_reason": request.rejection_reason
}
except Exception as e:
print(f"ERROR: 완료 반려 처리 오류 - {str(e)}")
db.rollback()
raise HTTPException(status_code=500, detail=f"완료 반려 처리 중 오류가 발생했습니다: {str(e)}")

View File

@@ -0,0 +1,212 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime
from typing import List
from database.database import get_db
from database.models import Issue, User, ReviewStatus
from database.schemas import (
ManagementUpdateRequest, AdditionalInfoUpdateRequest, Issue as IssueSchema, DailyReportStats
)
from routers.auth import get_current_user
from routers.page_permissions import check_page_access
router = APIRouter(prefix="/api/management", tags=["management"])
@router.get("/", response_model=List[IssueSchema])
async def get_management_issues(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
관리함 - 진행 중 및 완료된 부적합 목록 조회
"""
# 관리함 페이지 권한 확인
if not check_page_access(current_user.id, 'issues_management', db):
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
# 진행 중 또는 완료된 이슈들 조회
issues = db.query(Issue).filter(
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
).order_by(Issue.reviewed_at.desc()).all()
return issues
@router.put("/{issue_id}")
async def update_issue(
issue_id: int,
update_request: ManagementUpdateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
관리함에서 이슈 정보 업데이트
"""
# 관리함 페이지 권한 확인
if not check_page_access(current_user.id, 'issues_management', db):
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
# 진행 중 또는 완료 대기 상태인지 확인
if issue.review_status not in [ReviewStatus.in_progress]:
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 수정할 수 있습니다.")
# 업데이트할 데이터 처리
update_data = update_request.dict(exclude_unset=True)
for field, value in update_data.items():
if field == 'completion_photo' and value:
# 완료 사진 Base64 처리
from services.file_service import save_base64_image
try:
print(f"🔍 완료 사진 처리 시작 - 데이터 길이: {len(value)}")
print(f"🔍 Base64 데이터 시작 부분: {value[:100]}...")
photo_path = save_base64_image(value, "completion_")
if photo_path:
issue.completion_photo_path = photo_path
print(f"✅ 완료 사진 저장 성공: {photo_path}")
else:
print("❌ 완료 사진 저장 실패: photo_path가 None")
except Exception as e:
print(f"❌ 완료 사진 저장 실패: {e}")
import traceback
traceback.print_exc()
continue
elif field == 'final_description' and value:
# final_description 업데이트 시 description도 함께 업데이트
issue.final_description = value
issue.description = value
print(f"✅ final_description 및 description 업데이트: {value[:50]}...")
continue
elif field == 'final_category' and value:
# final_category 업데이트 시 category도 함께 업데이트
issue.final_category = value
issue.category = value
print(f"✅ final_category 및 category 업데이트: {value}")
continue
elif field == 'expected_completion_date' and value:
# 날짜 필드 처리
if not value.endswith('T00:00:00'):
value = value + 'T00:00:00'
setattr(issue, field, value)
db.commit()
db.refresh(issue)
return {
"message": "이슈가 성공적으로 업데이트되었습니다.",
"issue_id": issue.id,
"updated_at": datetime.now()
}
@router.put("/{issue_id}/additional-info")
async def update_additional_info(
issue_id: int,
additional_info: AdditionalInfoUpdateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
추가 정보 업데이트 (원인부서, 해당자 상세, 원인 상세)
"""
# 관리함 페이지 권한 확인
if not check_page_access(current_user.id, 'issues_management', db):
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
# 진행 중 상태인지 확인
if issue.review_status != ReviewStatus.in_progress:
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 추가 정보를 입력할 수 있습니다.")
# 추가 정보 업데이트
update_data = additional_info.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(issue, field, value)
# 추가 정보 입력 시간 및 입력자 기록
issue.additional_info_updated_at = datetime.now()
issue.additional_info_updated_by_id = current_user.id
db.commit()
db.refresh(issue)
return {
"message": "추가 정보가 성공적으로 업데이트되었습니다.",
"issue_id": issue.id,
"updated_at": issue.additional_info_updated_at,
"updated_by": current_user.username
}
@router.get("/{issue_id}/additional-info")
async def get_additional_info(
issue_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
추가 정보 조회
"""
# 관리함 페이지 권한 확인
if not check_page_access(current_user.id, 'issues_management', db):
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
return {
"issue_id": issue.id,
"cause_department": issue.cause_department.value if issue.cause_department else None,
"responsible_person_detail": issue.responsible_person_detail,
"cause_detail": issue.cause_detail,
"additional_info_updated_at": issue.additional_info_updated_at,
"additional_info_updated_by_id": issue.additional_info_updated_by_id
}
@router.get("/stats", response_model=DailyReportStats)
async def get_management_stats(
project_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
프로젝트별 관리함 통계 조회 (보고서용)
"""
# 관리함 페이지 권한 확인
if not check_page_access(current_user.id, 'issues_management', db):
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
# 해당 프로젝트의 관리함 이슈들 조회
issues = db.query(Issue).filter(
Issue.project_id == project_id,
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
).all()
# 통계 계산
stats = DailyReportStats()
today = datetime.now().date()
for issue in issues:
stats.total_count += 1
if issue.review_status == ReviewStatus.in_progress:
stats.management_count += 1
# 지연 여부 확인
if issue.expected_completion_date and issue.expected_completion_date < today:
stats.delayed_count += 1
elif issue.review_status == ReviewStatus.completed:
stats.completed_count += 1
return stats

View File

@@ -0,0 +1,330 @@
"""
페이지 권한 관리 API 라우터
사용자별 페이지 접근 권한을 관리하는 엔드포인트들
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel, Field
from datetime import datetime
from database.database import get_db
from database.models import User, UserPagePermission, UserRole
from routers.auth import get_current_user
router = APIRouter(prefix="/api", tags=["page-permissions"])
# Pydantic 모델들
class PagePermissionRequest(BaseModel):
user_id: int
page_name: str
can_access: bool
notes: Optional[str] = None
class PagePermissionResponse(BaseModel):
id: int
user_id: int
page_name: str
can_access: bool
granted_by_id: Optional[int]
granted_at: Optional[datetime]
notes: Optional[str]
class Config:
from_attributes = True
class UserPagePermissionSummary(BaseModel):
user_id: int
username: str
full_name: Optional[str]
role: str
permissions: List[PagePermissionResponse]
# 기본 페이지 목록
DEFAULT_PAGES = {
'issues_create': {'title': '부적합 등록', 'default_access': True},
'issues_view': {'title': '부적합 조회', 'default_access': True},
'issues_manage': {'title': '부적합 관리', 'default_access': True},
'issues_inbox': {'title': '수신함', 'default_access': True},
'issues_management': {'title': '관리함', 'default_access': False},
'issues_archive': {'title': '폐기함', 'default_access': False},
'issues_dashboard': {'title': '현황판', 'default_access': True},
'projects_manage': {'title': '프로젝트 관리', 'default_access': False},
'daily_work': {'title': '일일 공수', 'default_access': False},
'reports': {'title': '보고서', 'default_access': False},
'users_manage': {'title': '사용자 관리', 'default_access': False}
}
@router.post("/page-permissions/grant")
async def grant_page_permission(
request: PagePermissionRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""페이지 권한 부여/취소"""
# 관리자만 권한 설정 가능
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 권한을 설정할 수 있습니다."
)
# 대상 사용자 확인
target_user = db.query(User).filter(User.id == request.user_id).first()
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다."
)
# 유효한 페이지명 확인
if request.page_name not in DEFAULT_PAGES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="유효하지 않은 페이지명입니다."
)
# 기존 권한 확인
existing_permission = db.query(UserPagePermission).filter(
UserPagePermission.user_id == request.user_id,
UserPagePermission.page_name == request.page_name
).first()
if existing_permission:
# 기존 권한 업데이트
existing_permission.can_access = request.can_access
existing_permission.granted_by_id = current_user.id
existing_permission.notes = request.notes
db.commit()
db.refresh(existing_permission)
return {"message": "권한이 업데이트되었습니다.", "permission_id": existing_permission.id}
else:
# 새 권한 생성
new_permission = UserPagePermission(
user_id=request.user_id,
page_name=request.page_name,
can_access=request.can_access,
granted_by_id=current_user.id,
notes=request.notes
)
db.add(new_permission)
db.commit()
db.refresh(new_permission)
return {"message": "권한이 설정되었습니다.", "permission_id": new_permission.id}
@router.get("/users/{user_id}/page-permissions", response_model=List[PagePermissionResponse])
async def get_user_page_permissions(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""특정 사용자의 페이지 권한 목록 조회"""
# 관리자이거나 본인의 권한만 조회 가능
if current_user.role != UserRole.admin and current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="권한이 없습니다."
)
# 사용자 존재 확인
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다."
)
# 사용자의 페이지 권한 조회
permissions = db.query(UserPagePermission).filter(
UserPagePermission.user_id == user_id
).all()
return permissions
@router.get("/page-permissions/check/{user_id}/{page_name}")
async def check_page_access(
user_id: int,
page_name: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""특정 사용자의 특정 페이지 접근 권한 확인"""
# 사용자 존재 확인
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다."
)
# admin은 모든 페이지 접근 가능
if user.role == UserRole.admin:
return {"can_access": True, "reason": "admin_role"}
# 유효한 페이지명 확인
if page_name not in DEFAULT_PAGES:
return {"can_access": False, "reason": "invalid_page"}
# 개별 권한 확인
permission = db.query(UserPagePermission).filter(
UserPagePermission.user_id == user_id,
UserPagePermission.page_name == page_name
).first()
if permission:
return {
"can_access": permission.can_access,
"reason": "explicit_permission",
"granted_at": permission.granted_at.isoformat() if permission.granted_at else None
}
# 기본 권한 확인
default_access = DEFAULT_PAGES[page_name]['default_access']
return {
"can_access": default_access,
"reason": "default_permission"
}
@router.get("/page-permissions/all-users", response_model=List[UserPagePermissionSummary])
async def get_all_users_permissions(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""모든 사용자의 페이지 권한 요약 조회 (관리자용)"""
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 접근할 수 있습니다."
)
# 모든 사용자 조회
users = db.query(User).filter(User.is_active == True).all()
result = []
for user in users:
# 각 사용자의 권한 조회
permissions = db.query(UserPagePermission).filter(
UserPagePermission.user_id == user.id
).all()
result.append(UserPagePermissionSummary(
user_id=user.id,
username=user.username,
full_name=user.full_name,
role=user.role.value,
permissions=permissions
))
return result
@router.get("/page-permissions/available-pages")
async def get_available_pages(
current_user: User = Depends(get_current_user)
):
"""사용 가능한 페이지 목록 조회"""
return {
"pages": DEFAULT_PAGES,
"total_count": len(DEFAULT_PAGES)
}
@router.delete("/page-permissions/{permission_id}")
async def delete_page_permission(
permission_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""페이지 권한 삭제 (기본값으로 되돌림)"""
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 권한을 삭제할 수 있습니다."
)
# 권한 조회
permission = db.query(UserPagePermission).filter(
UserPagePermission.id == permission_id
).first()
if not permission:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="권한을 찾을 수 없습니다."
)
# 권한 삭제
db.delete(permission)
db.commit()
return {"message": "권한이 삭제되었습니다. 기본값이 적용됩니다."}
class BulkPermissionRequest(BaseModel):
user_id: int
permissions: List[dict] # [{"page_name": "issues_manage", "can_access": true}, ...]
@router.post("/page-permissions/bulk-grant")
async def bulk_grant_permissions(
request: BulkPermissionRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""사용자의 여러 페이지 권한을 일괄 설정"""
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 권한을 설정할 수 있습니다."
)
# 대상 사용자 확인
target_user = db.query(User).filter(User.id == request.user_id).first()
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다."
)
updated_permissions = []
for perm_data in request.permissions:
page_name = perm_data.get('page_name')
can_access = perm_data.get('can_access', False)
# 유효한 페이지명 확인
if page_name not in DEFAULT_PAGES:
continue
# 기존 권한 확인
existing_permission = db.query(UserPagePermission).filter(
UserPagePermission.user_id == request.user_id,
UserPagePermission.page_name == page_name
).first()
if existing_permission:
# 기존 권한 업데이트
existing_permission.can_access = can_access
existing_permission.granted_by_id = current_user.id
updated_permissions.append(existing_permission)
else:
# 새 권한 생성
new_permission = UserPagePermission(
user_id=request.user_id,
page_name=page_name,
can_access=can_access,
granted_by_id=current_user.id
)
db.add(new_permission)
updated_permissions.append(new_permission)
db.commit()
return {
"message": f"{len(updated_permissions)}개의 권한이 설정되었습니다.",
"updated_count": len(updated_permissions)
}

View File

@@ -0,0 +1,129 @@
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 routers.auth import get_current_user
router = APIRouter(
prefix="/api/projects",
tags=["projects"]
)
def check_admin_permission(current_user: User = Depends(get_current_user)):
"""관리자 권한 확인"""
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한이 필요합니다."
)
return current_user
@router.options("/")
async def projects_options():
"""OPTIONS preflight 요청 처리"""
return {"message": "OK"}
@router.post("/", response_model=ProjectSchema)
async def create_project(
project: ProjectCreate,
db: Session = Depends(get_db),
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
@router.get("/", response_model=List[ProjectSchema])
async def get_projects(
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
@router.get("/{project_id}", response_model=ProjectSchema)
async def get_project(
project_id: int,
db: Session = Depends(get_db)
):
"""특정 프로젝트 조회"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프로젝트를 찾을 수 없습니다."
)
return project
@router.put("/{project_id}", response_model=ProjectSchema)
async def update_project(
project_id: int,
project_update: ProjectUpdate,
db: Session = Depends(get_db),
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
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: Session = Depends(get_db),
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()
return {"message": "프로젝트가 삭제되었습니다."}

View File

@@ -0,0 +1,873 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from datetime import datetime, date
from typing import List
import io
import re
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
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 import schemas
from routers.auth import get_current_user
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.post("/summary", response_model=schemas.ReportSummary)
async def generate_report_summary(
report_request: schemas.ReportRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서 요약 생성"""
start_date = report_request.start_date
end_date = report_request.end_date
# 일일 공수 합계
daily_works = db.query(DailyWork).filter(
DailyWork.date >= start_date.date(),
DailyWork.date <= end_date.date()
).all()
total_hours = sum(w.total_hours for w in daily_works)
# 이슈 통계
issues_query = db.query(Issue).filter(
Issue.report_date >= start_date,
Issue.report_date <= end_date
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.user:
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
issues = issues_query.all()
# 카테고리별 통계
category_stats = schemas.CategoryStats()
completed_issues = 0
total_resolution_time = 0
resolved_count = 0
for issue in issues:
# 카테고리별 카운트
if issue.category == IssueCategory.material_missing:
category_stats.material_missing += 1
elif issue.category == IssueCategory.design_error:
category_stats.dimension_defect += 1
elif issue.category == IssueCategory.incoming_defect:
category_stats.incoming_defect += 1
# 완료된 이슈
if issue.status == IssueStatus.complete:
completed_issues += 1
if issue.work_hours > 0:
total_resolution_time += issue.work_hours
resolved_count += 1
# 평균 해결 시간
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
return schemas.ReportSummary(
start_date=start_date,
end_date=end_date,
total_hours=total_hours,
total_issues=len(issues),
category_stats=category_stats,
completed_issues=completed_issues,
average_resolution_time=average_resolution_time
)
@router.get("/issues")
async def get_report_issues(
start_date: datetime,
end_date: datetime,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서용 이슈 상세 목록"""
query = db.query(Issue).filter(
Issue.report_date >= start_date,
Issue.report_date <= end_date
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.user:
query = query.filter(Issue.reporter_id == current_user.id)
issues = query.order_by(Issue.report_date.desc()).all()
return [{
"id": issue.id,
"photo_path": issue.photo_path,
"category": issue.category,
"description": issue.description,
"status": issue.status,
"reporter_name": issue.reporter.full_name or issue.reporter.username,
"report_date": issue.report_date,
"work_hours": issue.work_hours,
"detail_notes": issue.detail_notes
} for issue in issues]
@router.get("/daily-works")
async def get_report_daily_works(
start_date: datetime,
end_date: datetime,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서용 일일 공수 목록"""
works = db.query(DailyWork).filter(
DailyWork.date >= start_date.date(),
DailyWork.date <= end_date.date()
).order_by(DailyWork.date).all()
return [{
"date": work.date,
"worker_count": work.worker_count,
"regular_hours": work.regular_hours,
"overtime_workers": work.overtime_workers,
"overtime_hours": work.overtime_hours,
"overtime_total": work.overtime_total,
"total_hours": work.total_hours
} for work in works]
@router.get("/daily-preview")
async def preview_daily_report(
project_id: int,
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 == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 추출될 항목 조회 (진행 중 + 미추출 완료 항목)
issues_query = db.query(Issue).filter(
Issue.project_id == project_id,
or_(
Issue.review_status == ReviewStatus.in_progress,
and_(
Issue.review_status == ReviewStatus.completed,
Issue.last_exported_at == None
)
)
)
issues = issues_query.all()
# 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순
issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
# 통계 계산
stats = calculate_project_stats(issues)
# 이슈 리스트를 schema로 변환
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
return {
"project": schemas.Project.from_orm(project),
"stats": stats,
"issues": issues_data,
"total_issues": len(issues)
}
@router.post("/daily-export")
async def export_daily_report(
request: schemas.DailyReportRequest,
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()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 관리함 데이터 조회
# 1. 진행 중인 항목 (모두 포함)
in_progress_only = db.query(Issue).filter(
Issue.project_id == request.project_id,
Issue.review_status == ReviewStatus.in_progress
).all()
# 2. 완료된 항목 (모두 조회)
all_completed = db.query(Issue).filter(
Issue.project_id == request.project_id,
Issue.review_status == ReviewStatus.completed
).all()
# 완료 항목 중 "완료 후 추출 안된 것" 필터링
# 규칙: 완료 처리 후 1회에 한해서만 "진행 중" 시트에 표시
not_exported_after_completion = []
for issue in all_completed:
if issue.last_exported_at is None:
# 한번도 추출 안됨 -> 진행 중 시트에 표시 (완료 후 첫 추출)
not_exported_after_completion.append(issue)
elif issue.actual_completion_date:
# actual_completion_date가 있는 경우: 완료일과 마지막 추출일 비교
completion_date = issue.actual_completion_date.replace(tzinfo=None) if issue.actual_completion_date.tzinfo else issue.actual_completion_date
export_date = issue.last_exported_at.replace(tzinfo=None) if issue.last_exported_at.tzinfo else issue.last_exported_at
if completion_date > export_date:
# 완료일이 마지막 추출일보다 나중 -> 완료 후 아직 추출 안됨 -> 진행 중 시트에 표시
not_exported_after_completion.append(issue)
# else: 완료일이 마지막 추출일보다 이전 -> 이미 완료 후 추출됨 -> 완료됨 시트로
# else: actual_completion_date가 없고 last_exported_at가 있음
# -> 이미 한번 이상 추출됨 -> 완료됨 시트로
# "진행 중" 시트용: 진행 중 + 완료되고 아직 추출 안된 것
in_progress_issues = in_progress_only + not_exported_after_completion
# 진행 중 시트 정렬: 지연중 -> 진행중 -> 완료됨 순서
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
# "완료됨" 시트용: 완료 항목 중 "완료 후 추출된 것"만 (진행 중 시트에 표시되는 것 제외)
not_exported_ids = {issue.id for issue in not_exported_after_completion}
completed_issues = [issue for issue in all_completed if issue.id not in not_exported_ids]
# 완료됨 시트도 정렬 (완료일 최신순)
completed_issues = sorted(completed_issues, key=lambda x: -x.actual_completion_date.timestamp() if x.actual_completion_date else 0)
# 웹과 동일한 로직: 진행중 + 완료를 함께 정렬하여 순번 할당
# (웹에서는 in_progress와 completed를 함께 가져와서 전체를 reviewed_at 순으로 정렬 후 순번 매김)
all_management_issues = in_progress_only + all_completed
# reviewed_at 기준으로 정렬 (웹과 동일)
all_management_issues = sorted(all_management_issues, key=lambda x: x.reviewed_at if x.reviewed_at else datetime.min)
# 프로젝트별로 그룹화하여 순번 할당 (웹과 동일한 로직)
project_groups = {}
for issue in all_management_issues:
if issue.project_id not in project_groups:
project_groups[issue.project_id] = []
project_groups[issue.project_id].append(issue)
# 각 프로젝트별로 순번 재할당 (웹과 동일)
for project_id, project_issues in project_groups.items():
for idx, issue in enumerate(project_issues, 1):
issue._display_no = idx
# 전체 이슈 (통계 계산용 및 추출 이력 업데이트용)
issues = list(set(in_progress_only + all_completed)) # 중복 제거
# 통계 계산
stats = calculate_project_stats(issues)
# 엑셀 파일 생성
wb = Workbook()
# 스타일 정의 (공통)
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
stats_font = Font(bold=True, size=12)
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
center_alignment = Alignment(horizontal='center', vertical='center')
card_header_font = Font(bold=True, color="FFFFFF", size=11)
label_font = Font(bold=True, size=10)
label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
content_font = Font(size=10)
thick_border = Border(
left=Side(style='medium'),
right=Side(style='medium'),
top=Side(style='medium'),
bottom=Side(style='medium')
)
# 두 개의 시트를 생성하고 각각 데이터 입력
sheets_data = [
(wb.active, in_progress_issues, "진행 중"),
(wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
]
sheets_data[0][0].title = "진행 중"
for ws, sheet_issues, sheet_title in sheets_data:
# 제목 및 기본 정보
ws.merge_cells('A1:L1')
ws['A1'] = f"{project.project_name} - {sheet_title}"
ws['A1'].font = Font(bold=True, size=16)
ws['A1'].alignment = center_alignment
ws.merge_cells('A2:L2')
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d')}"
ws['A2'].alignment = center_alignment
# 프로젝트 통계 (4행부터) - 진행 중 시트에만 표시
if sheet_title == "진행 중":
ws.merge_cells('A4:L4')
ws['A4'] = "📊 프로젝트 현황"
ws['A4'].font = Font(bold=True, size=14, color="FFFFFF")
ws['A4'].fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
ws['A4'].alignment = center_alignment
ws.row_dimensions[4].height = 25
# 통계 데이터 - 박스 형태로 개선
stats_row = 5
ws.row_dimensions[stats_row].height = 30
# 총 신고 수량 (파란색 계열)
ws.merge_cells(f'A{stats_row}:B{stats_row}')
ws[f'A{stats_row}'] = "총 신고 수량"
ws[f'A{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
ws[f'A{stats_row}'].fill = PatternFill(start_color="5B9BD5", end_color="5B9BD5", fill_type="solid")
ws[f'A{stats_row}'].alignment = center_alignment
ws[f'C{stats_row}'] = stats.total_count
ws[f'C{stats_row}'].font = Font(bold=True, size=14)
ws[f'C{stats_row}'].fill = PatternFill(start_color="DEEBF7", end_color="DEEBF7", fill_type="solid")
ws[f'C{stats_row}'].alignment = center_alignment
# 진행 현황 (노란색 계열)
ws.merge_cells(f'D{stats_row}:E{stats_row}')
ws[f'D{stats_row}'] = "진행 현황"
ws[f'D{stats_row}'].font = Font(bold=True, size=11, color="000000")
ws[f'D{stats_row}'].fill = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid")
ws[f'D{stats_row}'].alignment = center_alignment
ws[f'F{stats_row}'] = stats.management_count
ws[f'F{stats_row}'].font = Font(bold=True, size=14)
ws[f'F{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
ws[f'F{stats_row}'].alignment = center_alignment
# 완료 현황 (초록색 계열)
ws.merge_cells(f'G{stats_row}:H{stats_row}')
ws[f'G{stats_row}'] = "완료 현황"
ws[f'G{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
ws[f'G{stats_row}'].fill = PatternFill(start_color="70AD47", end_color="70AD47", fill_type="solid")
ws[f'G{stats_row}'].alignment = center_alignment
ws[f'I{stats_row}'] = stats.completed_count
ws[f'I{stats_row}'].font = Font(bold=True, size=14)
ws[f'I{stats_row}'].fill = PatternFill(start_color="C6E0B4", end_color="C6E0B4", fill_type="solid")
ws[f'I{stats_row}'].alignment = center_alignment
# 지연 중 (빨간색 계열)
ws.merge_cells(f'J{stats_row}:K{stats_row}')
ws[f'J{stats_row}'] = "지연 중"
ws[f'J{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
ws[f'J{stats_row}'].fill = PatternFill(start_color="E74C3C", end_color="E74C3C", fill_type="solid")
ws[f'J{stats_row}'].alignment = center_alignment
ws[f'L{stats_row}'] = stats.delayed_count
ws[f'L{stats_row}'].font = Font(bold=True, size=14)
ws[f'L{stats_row}'].fill = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
ws[f'L{stats_row}'].alignment = center_alignment
# 통계 박스에 테두리 적용
thick_border = Border(
left=Side(style='medium'),
right=Side(style='medium'),
top=Side(style='medium'),
bottom=Side(style='medium')
)
for col in ['A', 'C', 'D', 'F', 'G', 'I', 'J', 'L']:
if col in ['A', 'D', 'G', 'J']: # 병합된 셀의 시작점
for c in range(ord(col), ord(col) + 2): # 병합된 2개 셀
ws.cell(row=stats_row, column=c - ord('A') + 1).border = thick_border
else: # 숫자 셀
ws[f'{col}{stats_row}'].border = thick_border
# 카드 형태로 데이터 입력 (완료됨 시트는 4행부터, 진행 중 시트는 7행부터)
current_row = 4 if sheet_title == "완료됨" else 7
for idx, issue in enumerate(sheet_issues, 1):
card_start_row = current_row
# 상태별 헤더 색상 설정
header_color = get_issue_status_header_color(issue)
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
ws.merge_cells(f'A{current_row}:C{current_row}')
# 동적으로 할당된 프로젝트별 순번 사용 (웹과 동일)
issue_no = getattr(issue, '_display_no', issue.project_sequence_no or issue.id)
ws[f'A{current_row}'] = f"No. {issue_no}"
ws[f'A{current_row}'].font = card_header_font
ws[f'A{current_row}'].fill = card_header_fill
ws[f'A{current_row}'].alignment = center_alignment
ws.merge_cells(f'D{current_row}:G{current_row}')
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
ws[f'D{current_row}'].font = card_header_font
ws[f'D{current_row}'].fill = card_header_fill
ws[f'D{current_row}'].alignment = center_alignment
ws.merge_cells(f'H{current_row}:L{current_row}')
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
ws[f'H{current_row}'].font = card_header_font
ws[f'H{current_row}'].fill = card_header_fill
ws[f'H{current_row}'].alignment = center_alignment
current_row += 1
# 부적합명
ws[f'A{current_row}'] = "부적합명"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
# final_description이 있으면 사용, 없으면 description 사용
issue_title = issue.final_description or issue.description or "내용 없음"
ws[f'B{current_row}'] = issue_title
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
current_row += 1
# 상세내용 (detail_notes가 실제 상세 설명)
if issue.detail_notes:
ws[f'A{current_row}'] = "상세내용"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = issue.detail_notes
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
ws.row_dimensions[current_row].height = 50
current_row += 1
# 원인분류
ws[f'A{current_row}'] = "원인분류"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
ws[f'B{current_row}'].font = content_font
ws[f'D{current_row}'] = "원인부서"
ws[f'D{current_row}'].font = label_font
ws[f'D{current_row}'].fill = label_fill
ws.merge_cells(f'E{current_row}:F{current_row}')
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
ws[f'E{current_row}'].font = content_font
ws[f'G{current_row}'] = "신고자"
ws[f'G{current_row}'].font = label_font
ws[f'G{current_row}'].fill = label_fill
ws.merge_cells(f'H{current_row}:L{current_row}')
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
ws[f'H{current_row}'].font = content_font
current_row += 1
# 해결방안 (완료 반려 내용 및 댓글 제거)
ws[f'A{current_row}'] = "해결방안"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
# management_comment에서 완료 반려 패턴과 댓글 제거
clean_solution = clean_management_comment_for_export(issue.management_comment)
ws[f'B{current_row}'] = clean_solution
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
ws.row_dimensions[current_row].height = 30
current_row += 1
# 담당정보
ws[f'A{current_row}'] = "담당부서"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
ws[f'B{current_row}'].font = content_font
ws[f'D{current_row}'] = "담당자"
ws[f'D{current_row}'].font = label_font
ws[f'D{current_row}'].fill = label_fill
ws.merge_cells(f'E{current_row}:G{current_row}')
ws[f'E{current_row}'] = issue.responsible_person or ""
ws[f'E{current_row}'].font = content_font
ws[f'H{current_row}'] = "조치예상일"
ws[f'H{current_row}'].font = label_font
ws[f'H{current_row}'].fill = label_fill
ws.merge_cells(f'I{current_row}:L{current_row}')
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
ws[f'I{current_row}'].font = content_font
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
current_row += 1
# === 신고 사진 영역 ===
report_photos = [
issue.photo_path,
issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
]
report_photos = [p for p in report_photos if p] # None 제거
if report_photos:
# 라벨 행 (A~L 전체 병합)
ws.merge_cells(f'A{current_row}:L{current_row}')
ws[f'A{current_row}'] = "신고 사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
ws.row_dimensions[current_row].height = 18
current_row += 1
# 사진들을 한 행에 일렬로 표시 (간격 좁게)
ws.merge_cells(f'A{current_row}:L{current_row}')
report_image_inserted = False
# 사진 위치: A, C, E, G, I (2열 간격)
photo_columns = ['A', 'C', 'E', 'G', 'I']
for idx, photo in enumerate(report_photos):
if idx >= 5: # 최대 5장
break
photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo
if os.path.exists(photo_path):
try:
img = XLImage(photo_path)
img.width = min(img.width, 200) # 크기 줄임
img.height = min(img.height, 150)
ws.add_image(img, f'{photo_columns[idx]}{current_row}')
report_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({photo_path}): {e}")
if report_image_inserted:
ws.row_dimensions[current_row].height = 120
else:
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
ws.row_dimensions[current_row].height = 20
current_row += 1
# === 완료 관련 정보 (완료된 항목만) ===
if issue.review_status == ReviewStatus.completed:
# 완료 코멘트
if issue.completion_comment:
ws[f'A{current_row}'] = "완료 의견"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = issue.completion_comment
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
ws.row_dimensions[current_row].height = 30
current_row += 1
# 완료 사진
completion_photos = [
issue.completion_photo_path,
issue.completion_photo_path2,
issue.completion_photo_path3,
issue.completion_photo_path4,
issue.completion_photo_path5
]
completion_photos = [p for p in completion_photos if p] # None 제거
if completion_photos:
# 라벨 행 (A~L 전체 병합)
ws.merge_cells(f'A{current_row}:L{current_row}')
ws[f'A{current_row}'] = "완료 사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
ws.row_dimensions[current_row].height = 18
current_row += 1
# 사진들을 한 행에 일렬로 표시 (간격 좁게)
ws.merge_cells(f'A{current_row}:L{current_row}')
completion_image_inserted = False
# 사진 위치: A, C, E, G, I (2열 간격)
photo_columns = ['A', 'C', 'E', 'G', 'I']
for idx, photo in enumerate(completion_photos):
if idx >= 5: # 최대 5장
break
photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo
if os.path.exists(photo_path):
try:
img = XLImage(photo_path)
img.width = min(img.width, 200) # 크기 줄임
img.height = min(img.height, 150)
ws.add_image(img, f'{photo_columns[idx]}{current_row}')
completion_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({photo_path}): {e}")
if completion_image_inserted:
ws.row_dimensions[current_row].height = 120
else:
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
ws.row_dimensions[current_row].height = 20
current_row += 1
# 완료일 정보
if issue.actual_completion_date:
ws[f'A{current_row}'] = "완료일"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
ws[f'B{current_row}'].font = content_font
current_row += 1
# 사진이 하나도 없을 경우
# 진행중: 신고 사진만 체크
# 완료됨: 신고 사진 + 완료 사진 체크
has_completion_photos = False
if issue.review_status == ReviewStatus.completed:
comp_photos = [p for p in [
issue.completion_photo_path,
issue.completion_photo_path2,
issue.completion_photo_path3,
issue.completion_photo_path4,
issue.completion_photo_path5
] if p]
has_completion_photos = bool(comp_photos)
has_any_photo = bool(report_photos) or has_completion_photos
if not has_any_photo:
ws[f'A{current_row}'] = "첨부사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = "첨부된 사진 없음"
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
current_row += 1
# 카드 전체에 테두리 적용 (A-L 열)
card_end_row = current_row - 1
for row in range(card_start_row, card_end_row + 1):
for col in range(1, 13): # A-L 열 (12열)
cell = ws.cell(row=row, column=col)
if not cell.border or cell.border.left.style != 'medium':
cell.border = border
# 카드 외곽에 굵은 테두리 (A-L 열) - 상태별 색상 적용
border_color = header_color # 헤더와 같은 색상 사용
for col in range(1, 13):
ws.cell(row=card_start_row, column=col).border = Border(
left=Side(style='medium' if col == 1 else 'thin', color=border_color),
right=Side(style='medium' if col == 12 else 'thin', color=border_color),
top=Side(style='medium', color=border_color),
bottom=Side(style='thin', color=border_color)
)
ws.cell(row=card_end_row, column=col).border = Border(
left=Side(style='medium' if col == 1 else 'thin', color=border_color),
right=Side(style='medium' if col == 12 else 'thin', color=border_color),
top=Side(style='thin', color=border_color),
bottom=Side(style='medium', color=border_color)
)
# 카드 좌우 테두리도 색상 적용
for row in range(card_start_row + 1, card_end_row):
ws.cell(row=row, column=1).border = Border(
left=Side(style='medium', color=border_color),
right=ws.cell(row=row, column=1).border.right if ws.cell(row=row, column=1).border else Side(style='thin'),
top=ws.cell(row=row, column=1).border.top if ws.cell(row=row, column=1).border else Side(style='thin'),
bottom=ws.cell(row=row, column=1).border.bottom if ws.cell(row=row, column=1).border else Side(style='thin')
)
ws.cell(row=row, column=12).border = Border(
left=ws.cell(row=row, column=12).border.left if ws.cell(row=row, column=12).border else Side(style='thin'),
right=Side(style='medium', color=border_color),
top=ws.cell(row=row, column=12).border.top if ws.cell(row=row, column=12).border else Side(style='thin'),
bottom=ws.cell(row=row, column=12).border.bottom if ws.cell(row=row, column=12).border else Side(style='thin')
)
# 카드 구분 (빈 행)
current_row += 1
# 열 너비 조정
ws.column_dimensions['A'].width = 12 # 레이블 열
ws.column_dimensions['B'].width = 15 # 내용 열
ws.column_dimensions['C'].width = 15 # 내용 열
ws.column_dimensions['D'].width = 15 # 내용 열
ws.column_dimensions['E'].width = 15 # 내용 열
ws.column_dimensions['F'].width = 15 # 내용 열
ws.column_dimensions['G'].width = 15 # 내용 열
ws.column_dimensions['H'].width = 15 # 내용 열
ws.column_dimensions['I'].width = 15 # 내용 열
ws.column_dimensions['J'].width = 15 # 내용 열
ws.column_dimensions['K'].width = 15 # 내용 열
ws.column_dimensions['L'].width = 15 # 내용 열
# 엑셀 파일을 메모리에 저장
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
# 추출 이력 업데이트
export_time = datetime.now()
for issue in issues:
issue.last_exported_at = export_time
issue.export_count = (issue.export_count or 0) + 1
db.commit()
# 파일명 생성
today = date.today().strftime('%Y%m%d')
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
# 한글 파일명을 위한 URL 인코딩
from urllib.parse import quote
encoded_filename = quote(filename.encode('utf-8'))
return StreamingResponse(
io.BytesIO(excel_buffer.read()),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
)
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
"""프로젝트 통계 계산"""
stats = schemas.DailyReportStats()
today = date.today()
for issue in issues:
stats.total_count += 1
if issue.review_status == ReviewStatus.in_progress:
stats.management_count += 1
# 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
if issue.expected_completion_date:
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
stats.delayed_count += 1
elif issue.review_status == ReviewStatus.completed:
stats.completed_count += 1
return stats
def get_category_text(category: IssueCategory) -> str:
"""카테고리 한글 변환"""
category_map = {
IssueCategory.material_missing: "자재 누락",
IssueCategory.design_error: "설계 미스",
IssueCategory.incoming_defect: "입고 불량",
IssueCategory.inspection_miss: "검사 미스",
IssueCategory.etc: "기타"
}
return category_map.get(category, str(category))
def get_department_text(department) -> str:
"""부서 한글 변환"""
if not department:
return ""
department_map = {
"production": "생산",
"quality": "품질",
"purchasing": "구매",
"design": "설계",
"sales": "영업"
}
return department_map.get(department, str(department))
def get_status_text(status: ReviewStatus) -> str:
"""상태 한글 변환"""
status_map = {
ReviewStatus.pending_review: "검토 대기",
ReviewStatus.in_progress: "진행 중",
ReviewStatus.completed: "완료됨",
ReviewStatus.disposed: "폐기됨"
}
return status_map.get(status, str(status))
def clean_management_comment_for_export(text: str) -> str:
"""엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)"""
if not text:
return ""
# 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용)
text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text)
# 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들)
lines = text.split('\n')
filtered_lines = []
for line in lines:
# └ 또는 ↳로 시작하는 줄 제외
if not re.match(r'^\s*[└↳]', line):
filtered_lines.append(line)
# 3. 빈 줄 정리
result = '\n'.join(filtered_lines).strip()
# 연속된 빈 줄을 하나로
result = re.sub(r'\n{3,}', '\n\n', result)
return result
def get_status_color(status: ReviewStatus) -> str:
"""상태별 색상 반환"""
color_map = {
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
ReviewStatus.completed: "E2EFDA", # 연한 초록
ReviewStatus.disposed: "F2F2F2" # 연한 회색
}
return color_map.get(status, None)
def get_issue_priority(issue: Issue) -> int:
"""이슈 우선순위 반환 (엑셀 정렬용)
1: 지연 (빨강)
2: 진행중 (노랑)
3: 완료됨 (초록)
"""
if issue.review_status == ReviewStatus.completed:
return 3
elif issue.review_status == ReviewStatus.in_progress:
# 조치 예상일이 지난 경우 지연
if issue.expected_completion_date:
today = date.today()
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
return 1 # 지연
return 2 # 진행중
return 2
def get_issue_status_text(issue: Issue) -> str:
"""이슈 상태 텍스트 반환"""
if issue.review_status == ReviewStatus.completed:
return "완료됨"
elif issue.review_status == ReviewStatus.in_progress:
if issue.expected_completion_date:
today = date.today()
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
return "지연중"
return "진행중"
return get_status_text(issue.review_status)
def get_issue_status_header_color(issue: Issue) -> str:
"""이슈 상태별 헤더 색상 반환"""
priority = get_issue_priority(issue)
if priority == 1: # 지연
return "E74C3C" # 빨간색
elif priority == 2: # 진행중
return "FFD966" # 노랑색 (주황색 사이)
elif priority == 3: # 완료
return "92D050" # 진한 초록색
return "4472C4" # 기본 파란색

View File

@@ -0,0 +1,734 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from datetime import datetime, date
from typing import List
import io
import re
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
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 import schemas
from routers.auth import get_current_user
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.post("/summary", response_model=schemas.ReportSummary)
async def generate_report_summary(
report_request: schemas.ReportRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서 요약 생성"""
start_date = report_request.start_date
end_date = report_request.end_date
# 일일 공수 합계
daily_works = db.query(DailyWork).filter(
DailyWork.date >= start_date.date(),
DailyWork.date <= end_date.date()
).all()
total_hours = sum(w.total_hours for w in daily_works)
# 이슈 통계
issues_query = db.query(Issue).filter(
Issue.report_date >= start_date,
Issue.report_date <= end_date
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.user:
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
issues = issues_query.all()
# 카테고리별 통계
category_stats = schemas.CategoryStats()
completed_issues = 0
total_resolution_time = 0
resolved_count = 0
for issue in issues:
# 카테고리별 카운트
if issue.category == IssueCategory.material_missing:
category_stats.material_missing += 1
elif issue.category == IssueCategory.design_error:
category_stats.dimension_defect += 1
elif issue.category == IssueCategory.incoming_defect:
category_stats.incoming_defect += 1
# 완료된 이슈
if issue.status == IssueStatus.complete:
completed_issues += 1
if issue.work_hours > 0:
total_resolution_time += issue.work_hours
resolved_count += 1
# 평균 해결 시간
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
return schemas.ReportSummary(
start_date=start_date,
end_date=end_date,
total_hours=total_hours,
total_issues=len(issues),
category_stats=category_stats,
completed_issues=completed_issues,
average_resolution_time=average_resolution_time
)
@router.get("/issues")
async def get_report_issues(
start_date: datetime,
end_date: datetime,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서용 이슈 상세 목록"""
query = db.query(Issue).filter(
Issue.report_date >= start_date,
Issue.report_date <= end_date
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.user:
query = query.filter(Issue.reporter_id == current_user.id)
issues = query.order_by(Issue.report_date.desc()).all()
return [{
"id": issue.id,
"photo_path": issue.photo_path,
"category": issue.category,
"description": issue.description,
"status": issue.status,
"reporter_name": issue.reporter.full_name or issue.reporter.username,
"report_date": issue.report_date,
"work_hours": issue.work_hours,
"detail_notes": issue.detail_notes
} for issue in issues]
@router.get("/daily-works")
async def get_report_daily_works(
start_date: datetime,
end_date: datetime,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서용 일일 공수 목록"""
works = db.query(DailyWork).filter(
DailyWork.date >= start_date.date(),
DailyWork.date <= end_date.date()
).order_by(DailyWork.date).all()
return [{
"date": work.date,
"worker_count": work.worker_count,
"regular_hours": work.regular_hours,
"overtime_workers": work.overtime_workers,
"overtime_hours": work.overtime_hours,
"overtime_total": work.overtime_total,
"total_hours": work.total_hours
} for work in works]
@router.get("/daily-preview")
async def preview_daily_report(
project_id: int,
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 == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 추출될 항목 조회 (진행 중 + 미추출 완료 항목)
issues_query = db.query(Issue).filter(
Issue.project_id == project_id,
or_(
Issue.review_status == ReviewStatus.in_progress,
and_(
Issue.review_status == ReviewStatus.completed,
Issue.last_exported_at == None
)
)
)
issues = issues_query.all()
# 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순
issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
# 통계 계산
stats = calculate_project_stats(issues)
# 이슈 리스트를 schema로 변환
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
return {
"project": schemas.Project.from_orm(project),
"stats": stats,
"issues": issues_data,
"total_issues": len(issues)
}
@router.post("/daily-export")
async def export_daily_report(
request: schemas.DailyReportRequest,
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()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 관리함 데이터 조회
# 1. 진행 중인 항목 (모두 포함)
in_progress_issues = db.query(Issue).filter(
Issue.project_id == request.project_id,
Issue.review_status == ReviewStatus.in_progress
).all()
# 2. 완료된 항목 (한번도 추출 안된 항목만)
completed_issues = db.query(Issue).filter(
Issue.project_id == request.project_id,
Issue.review_status == ReviewStatus.completed,
Issue.last_exported_at == None
).all()
# 진행중 항목 정렬: 지연 -> 진행중 순으로, 같은 상태 내에서는 신고일 최신순
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
# 완료 항목 정렬: 등록번호(project_sequence_no 또는 id) 순
completed_issues = sorted(completed_issues, key=lambda x: x.project_sequence_no or x.id)
# 전체 이슈 (통계 계산용)
issues = in_progress_issues + completed_issues
# 통계 계산
stats = calculate_project_stats(issues)
# 엑셀 파일 생성
wb = Workbook()
# 스타일 정의 (공통)
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
stats_font = Font(bold=True, size=12)
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
center_alignment = Alignment(horizontal='center', vertical='center')
card_header_font = Font(bold=True, color="FFFFFF", size=11)
label_font = Font(bold=True, size=10)
label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
content_font = Font(size=10)
thick_border = Border(
left=Side(style='medium'),
right=Side(style='medium'),
top=Side(style='medium'),
bottom=Side(style='medium')
)
# 두 개의 시트를 생성하고 각각 데이터 입력
sheets_data = [
(wb.active, in_progress_issues, "진행 중"),
(wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
]
sheets_data[0][0].title = "진행 중"
for ws, sheet_issues, sheet_title in sheets_data:
# 제목 및 기본 정보
ws.merge_cells('A1:L1')
ws['A1'] = f"{project.project_name} - {sheet_title}"
ws['A1'].font = Font(bold=True, size=16)
ws['A1'].alignment = center_alignment
ws.merge_cells('A2:L2')
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d')}"
ws['A2'].alignment = center_alignment
# 프로젝트 통계 (4행부터)
ws.merge_cells('A4:L4')
ws['A4'] = "프로젝트 현황"
ws['A4'].font = stats_font
ws['A4'].fill = stats_fill
ws['A4'].alignment = center_alignment
# 통계 데이터
stats_row = 5
ws[f'A{stats_row}'] = "총 신고 수량"
ws[f'B{stats_row}'] = stats.total_count
ws[f'D{stats_row}'] = "관리처리 현황"
ws[f'E{stats_row}'] = stats.management_count
ws[f'G{stats_row}'] = "완료 현황"
ws[f'H{stats_row}'] = stats.completed_count
ws[f'J{stats_row}'] = "지연 중"
ws[f'K{stats_row}'] = stats.delayed_count
# 통계 스타일 적용
for col in ['A', 'D', 'G', 'J']:
ws[f'{col}{stats_row}'].font = Font(bold=True)
ws[f'{col}{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
# 카드 형태로 데이터 입력 (7행부터)
current_row = 7
for idx, issue in enumerate(sheet_issues, 1):
card_start_row = current_row
# 상태별 헤더 색상 설정
header_color = get_issue_status_header_color(issue)
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
ws.merge_cells(f'A{current_row}:C{current_row}')
ws[f'A{current_row}'] = f"No. {issue.project_sequence_no or issue.id}"
ws[f'A{current_row}'].font = card_header_font
ws[f'A{current_row}'].fill = card_header_fill
ws[f'A{current_row}'].alignment = center_alignment
ws.merge_cells(f'D{current_row}:G{current_row}')
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
ws[f'D{current_row}'].font = card_header_font
ws[f'D{current_row}'].fill = card_header_fill
ws[f'D{current_row}'].alignment = center_alignment
ws.merge_cells(f'H{current_row}:L{current_row}')
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
ws[f'H{current_row}'].font = card_header_font
ws[f'H{current_row}'].fill = card_header_fill
ws[f'H{current_row}'].alignment = center_alignment
current_row += 1
# 부적합명
ws[f'A{current_row}'] = "부적합명"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
# final_description이 있으면 사용, 없으면 description 사용
issue_title = issue.final_description or issue.description or "내용 없음"
ws[f'B{current_row}'] = issue_title
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
current_row += 1
# 상세내용 (detail_notes가 실제 상세 설명)
if issue.detail_notes:
ws[f'A{current_row}'] = "상세내용"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = issue.detail_notes
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
ws.row_dimensions[current_row].height = 50
current_row += 1
# 원인분류
ws[f'A{current_row}'] = "원인분류"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
ws[f'B{current_row}'].font = content_font
ws[f'D{current_row}'] = "원인부서"
ws[f'D{current_row}'].font = label_font
ws[f'D{current_row}'].fill = label_fill
ws.merge_cells(f'E{current_row}:F{current_row}')
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
ws[f'E{current_row}'].font = content_font
ws[f'G{current_row}'] = "신고자"
ws[f'G{current_row}'].font = label_font
ws[f'G{current_row}'].fill = label_fill
ws.merge_cells(f'H{current_row}:L{current_row}')
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
ws[f'H{current_row}'].font = content_font
current_row += 1
# 해결방안 (완료 반려 내용 및 댓글 제거)
ws[f'A{current_row}'] = "해결방안"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
# management_comment에서 완료 반려 패턴과 댓글 제거
clean_solution = clean_management_comment_for_export(issue.management_comment)
ws[f'B{current_row}'] = clean_solution
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
ws.row_dimensions[current_row].height = 30
current_row += 1
# 담당정보
ws[f'A{current_row}'] = "담당부서"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
ws[f'B{current_row}'].font = content_font
ws[f'D{current_row}'] = "담당자"
ws[f'D{current_row}'].font = label_font
ws[f'D{current_row}'].fill = label_fill
ws.merge_cells(f'E{current_row}:G{current_row}')
ws[f'E{current_row}'] = issue.responsible_person or ""
ws[f'E{current_row}'].font = content_font
ws[f'H{current_row}'] = "조치예상일"
ws[f'H{current_row}'].font = label_font
ws[f'H{current_row}'].fill = label_fill
ws.merge_cells(f'I{current_row}:L{current_row}')
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
ws[f'I{current_row}'].font = content_font
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
current_row += 1
# === 신고 사진 영역 ===
has_report_photo = issue.photo_path or issue.photo_path2
if has_report_photo:
# 라벨 행 (A~L 전체 병합)
ws.merge_cells(f'A{current_row}:L{current_row}')
ws[f'A{current_row}'] = "신고 사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
ws.row_dimensions[current_row].height = 18
current_row += 1
# 사진 행 (별도 행으로 분리)
ws.merge_cells(f'A{current_row}:L{current_row}')
report_image_inserted = False
# 신고 사진 1
if issue.photo_path:
photo_path = issue.photo_path.replace('/uploads/', '/app/uploads/') if issue.photo_path.startswith('/uploads/') else issue.photo_path
if os.path.exists(photo_path):
try:
img = XLImage(photo_path)
img.width = min(img.width, 250)
img.height = min(img.height, 180)
ws.add_image(img, f'A{current_row}') # A열에 첫 번째 사진
report_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({photo_path}): {e}")
# 신고 사진 2
if issue.photo_path2:
photo_path2 = issue.photo_path2.replace('/uploads/', '/app/uploads/') if issue.photo_path2.startswith('/uploads/') else issue.photo_path2
if os.path.exists(photo_path2):
try:
img = XLImage(photo_path2)
img.width = min(img.width, 250)
img.height = min(img.height, 180)
ws.add_image(img, f'G{current_row}') # G열에 두 번째 사진 (간격 확보)
report_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({photo_path2}): {e}")
if report_image_inserted:
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
else:
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
ws.row_dimensions[current_row].height = 20
current_row += 1
# === 완료 관련 정보 (완료된 항목만) ===
if issue.review_status == ReviewStatus.completed:
# 완료 코멘트
if issue.completion_comment:
ws[f'A{current_row}'] = "완료 의견"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = issue.completion_comment
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
ws.row_dimensions[current_row].height = 30
current_row += 1
# 완료 사진
if issue.completion_photo_path:
# 라벨 행 (A~L 전체 병합)
ws.merge_cells(f'A{current_row}:L{current_row}')
ws[f'A{current_row}'] = "완료 사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
ws.row_dimensions[current_row].height = 18
current_row += 1
# 사진 행 (별도 행으로 분리)
ws.merge_cells(f'A{current_row}:L{current_row}')
completion_path = issue.completion_photo_path.replace('/uploads/', '/app/uploads/') if issue.completion_photo_path.startswith('/uploads/') else issue.completion_photo_path
completion_image_inserted = False
if os.path.exists(completion_path):
try:
img = XLImage(completion_path)
img.width = min(img.width, 250)
img.height = min(img.height, 180)
ws.add_image(img, f'A{current_row}') # A열에 사진
completion_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({completion_path}): {e}")
if completion_image_inserted:
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
else:
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
ws.row_dimensions[current_row].height = 20
current_row += 1
# 완료일 정보
if issue.actual_completion_date:
ws[f'A{current_row}'] = "완료일"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
ws[f'B{current_row}'].font = content_font
current_row += 1
# 사진이 하나도 없을 경우
# 진행중: 신고 사진만 체크
# 완료됨: 신고 사진 + 완료 사진 체크
has_any_photo = has_report_photo or (issue.review_status == ReviewStatus.completed and issue.completion_photo_path)
if not has_any_photo:
ws[f'A{current_row}'] = "첨부사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = "첨부된 사진 없음"
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
current_row += 1
# 카드 전체에 테두리 적용 (A-L 열)
card_end_row = current_row - 1
for row in range(card_start_row, card_end_row + 1):
for col in range(1, 13): # A-L 열 (12열)
cell = ws.cell(row=row, column=col)
if not cell.border or cell.border.left.style != 'medium':
cell.border = border
# 카드 외곽에 굵은 테두리 (A-L 열)
for col in range(1, 13):
ws.cell(row=card_start_row, column=col).border = Border(
left=Side(style='medium' if col == 1 else 'thin'),
right=Side(style='medium' if col == 12 else 'thin'),
top=Side(style='medium'),
bottom=Side(style='thin')
)
ws.cell(row=card_end_row, column=col).border = Border(
left=Side(style='medium' if col == 1 else 'thin'),
right=Side(style='medium' if col == 12 else 'thin'),
top=Side(style='thin'),
bottom=Side(style='medium')
)
# 카드 구분 (빈 행)
current_row += 1
# 열 너비 조정
ws.column_dimensions['A'].width = 12 # 레이블 열
ws.column_dimensions['B'].width = 15 # 내용 열
ws.column_dimensions['C'].width = 15 # 내용 열
ws.column_dimensions['D'].width = 15 # 내용 열
ws.column_dimensions['E'].width = 15 # 내용 열
ws.column_dimensions['F'].width = 15 # 내용 열
ws.column_dimensions['G'].width = 15 # 내용 열
ws.column_dimensions['H'].width = 15 # 내용 열
ws.column_dimensions['I'].width = 15 # 내용 열
ws.column_dimensions['J'].width = 15 # 내용 열
ws.column_dimensions['K'].width = 15 # 내용 열
ws.column_dimensions['L'].width = 15 # 내용 열
# 엑셀 파일을 메모리에 저장
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
# 추출 이력 업데이트
export_time = datetime.now()
for issue in issues:
issue.last_exported_at = export_time
issue.export_count = (issue.export_count or 0) + 1
db.commit()
# 파일명 생성
today = date.today().strftime('%Y%m%d')
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
# 한글 파일명을 위한 URL 인코딩
from urllib.parse import quote
encoded_filename = quote(filename.encode('utf-8'))
return StreamingResponse(
io.BytesIO(excel_buffer.read()),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
)
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
"""프로젝트 통계 계산"""
stats = schemas.DailyReportStats()
today = date.today()
for issue in issues:
stats.total_count += 1
if issue.review_status == ReviewStatus.in_progress:
stats.management_count += 1
# 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
if issue.expected_completion_date:
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
stats.delayed_count += 1
elif issue.review_status == ReviewStatus.completed:
stats.completed_count += 1
return stats
def get_category_text(category: IssueCategory) -> str:
"""카테고리 한글 변환"""
category_map = {
IssueCategory.material_missing: "자재 누락",
IssueCategory.design_error: "설계 미스",
IssueCategory.incoming_defect: "입고 불량",
IssueCategory.inspection_miss: "검사 미스",
IssueCategory.etc: "기타"
}
return category_map.get(category, str(category))
def get_department_text(department) -> str:
"""부서 한글 변환"""
if not department:
return ""
department_map = {
"production": "생산",
"quality": "품질",
"purchasing": "구매",
"design": "설계",
"sales": "영업"
}
return department_map.get(department, str(department))
def get_status_text(status: ReviewStatus) -> str:
"""상태 한글 변환"""
status_map = {
ReviewStatus.pending_review: "검토 대기",
ReviewStatus.in_progress: "진행 중",
ReviewStatus.completed: "완료됨",
ReviewStatus.disposed: "폐기됨"
}
return status_map.get(status, str(status))
def clean_management_comment_for_export(text: str) -> str:
"""엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)"""
if not text:
return ""
# 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용)
text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text)
# 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들)
lines = text.split('\n')
filtered_lines = []
for line in lines:
# └ 또는 ↳로 시작하는 줄 제외
if not re.match(r'^\s*[└↳]', line):
filtered_lines.append(line)
# 3. 빈 줄 정리
result = '\n'.join(filtered_lines).strip()
# 연속된 빈 줄을 하나로
result = re.sub(r'\n{3,}', '\n\n', result)
return result
def get_status_color(status: ReviewStatus) -> str:
"""상태별 색상 반환"""
color_map = {
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
ReviewStatus.completed: "E2EFDA", # 연한 초록
ReviewStatus.disposed: "F2F2F2" # 연한 회색
}
return color_map.get(status, None)
def get_issue_priority(issue: Issue) -> int:
"""이슈 우선순위 반환 (엑셀 정렬용)
1: 지연 (빨강)
2: 진행중 (노랑)
3: 완료됨 (초록)
"""
if issue.review_status == ReviewStatus.completed:
return 3
elif issue.review_status == ReviewStatus.in_progress:
# 조치 예상일이 지난 경우 지연
if issue.expected_completion_date:
today = date.today()
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
return 1 # 지연
return 2 # 진행중
return 2
def get_issue_status_text(issue: Issue) -> str:
"""이슈 상태 텍스트 반환"""
if issue.review_status == ReviewStatus.completed:
return "완료됨"
elif issue.review_status == ReviewStatus.in_progress:
if issue.expected_completion_date:
today = date.today()
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
return "지연중"
return "진행중"
return get_status_text(issue.review_status)
def get_issue_status_header_color(issue: Issue) -> str:
"""이슈 상태별 헤더 색상 반환"""
priority = get_issue_priority(issue)
if priority == 1: # 지연
return "E74C3C" # 빨간색
elif priority == 2: # 진행중
return "F39C12" # 주황/노란색
elif priority == 3: # 완료
return "27AE60" # 초록색
return "4472C4" # 기본 파란색

View File

@@ -0,0 +1,734 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from datetime import datetime, date
from typing import List
import io
import re
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
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 import schemas
from routers.auth import get_current_user
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.post("/summary", response_model=schemas.ReportSummary)
async def generate_report_summary(
report_request: schemas.ReportRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서 요약 생성"""
start_date = report_request.start_date
end_date = report_request.end_date
# 일일 공수 합계
daily_works = db.query(DailyWork).filter(
DailyWork.date >= start_date.date(),
DailyWork.date <= end_date.date()
).all()
total_hours = sum(w.total_hours for w in daily_works)
# 이슈 통계
issues_query = db.query(Issue).filter(
Issue.report_date >= start_date,
Issue.report_date <= end_date
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.user:
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
issues = issues_query.all()
# 카테고리별 통계
category_stats = schemas.CategoryStats()
completed_issues = 0
total_resolution_time = 0
resolved_count = 0
for issue in issues:
# 카테고리별 카운트
if issue.category == IssueCategory.material_missing:
category_stats.material_missing += 1
elif issue.category == IssueCategory.design_error:
category_stats.dimension_defect += 1
elif issue.category == IssueCategory.incoming_defect:
category_stats.incoming_defect += 1
# 완료된 이슈
if issue.status == IssueStatus.complete:
completed_issues += 1
if issue.work_hours > 0:
total_resolution_time += issue.work_hours
resolved_count += 1
# 평균 해결 시간
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
return schemas.ReportSummary(
start_date=start_date,
end_date=end_date,
total_hours=total_hours,
total_issues=len(issues),
category_stats=category_stats,
completed_issues=completed_issues,
average_resolution_time=average_resolution_time
)
@router.get("/issues")
async def get_report_issues(
start_date: datetime,
end_date: datetime,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서용 이슈 상세 목록"""
query = db.query(Issue).filter(
Issue.report_date >= start_date,
Issue.report_date <= end_date
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.user:
query = query.filter(Issue.reporter_id == current_user.id)
issues = query.order_by(Issue.report_date.desc()).all()
return [{
"id": issue.id,
"photo_path": issue.photo_path,
"category": issue.category,
"description": issue.description,
"status": issue.status,
"reporter_name": issue.reporter.full_name or issue.reporter.username,
"report_date": issue.report_date,
"work_hours": issue.work_hours,
"detail_notes": issue.detail_notes
} for issue in issues]
@router.get("/daily-works")
async def get_report_daily_works(
start_date: datetime,
end_date: datetime,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서용 일일 공수 목록"""
works = db.query(DailyWork).filter(
DailyWork.date >= start_date.date(),
DailyWork.date <= end_date.date()
).order_by(DailyWork.date).all()
return [{
"date": work.date,
"worker_count": work.worker_count,
"regular_hours": work.regular_hours,
"overtime_workers": work.overtime_workers,
"overtime_hours": work.overtime_hours,
"overtime_total": work.overtime_total,
"total_hours": work.total_hours
} for work in works]
@router.get("/daily-preview")
async def preview_daily_report(
project_id: int,
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 == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 추출될 항목 조회 (진행 중 + 미추출 완료 항목)
issues_query = db.query(Issue).filter(
Issue.project_id == project_id,
or_(
Issue.review_status == ReviewStatus.in_progress,
and_(
Issue.review_status == ReviewStatus.completed,
Issue.last_exported_at == None
)
)
)
issues = issues_query.all()
# 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순
issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
# 통계 계산
stats = calculate_project_stats(issues)
# 이슈 리스트를 schema로 변환
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
return {
"project": schemas.Project.from_orm(project),
"stats": stats,
"issues": issues_data,
"total_issues": len(issues)
}
@router.post("/daily-export")
async def export_daily_report(
request: schemas.DailyReportRequest,
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()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 관리함 데이터 조회
# 1. 진행 중인 항목 (모두 포함)
in_progress_issues = db.query(Issue).filter(
Issue.project_id == request.project_id,
Issue.review_status == ReviewStatus.in_progress
).all()
# 2. 완료된 항목 (한번도 추출 안된 항목만)
completed_issues = db.query(Issue).filter(
Issue.project_id == request.project_id,
Issue.review_status == ReviewStatus.completed,
Issue.last_exported_at == None
).all()
# 진행중 항목 정렬: 지연 -> 진행중 순으로, 같은 상태 내에서는 신고일 최신순
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
# 완료 항목 정렬: 등록번호(project_sequence_no 또는 id) 순
completed_issues = sorted(completed_issues, key=lambda x: x.project_sequence_no or x.id)
# 전체 이슈 (통계 계산용)
issues = in_progress_issues + completed_issues
# 통계 계산
stats = calculate_project_stats(issues)
# 엑셀 파일 생성
wb = Workbook()
# 스타일 정의 (공통)
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
stats_font = Font(bold=True, size=12)
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
center_alignment = Alignment(horizontal='center', vertical='center')
card_header_font = Font(bold=True, color="FFFFFF", size=11)
label_font = Font(bold=True, size=10)
label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
content_font = Font(size=10)
thick_border = Border(
left=Side(style='medium'),
right=Side(style='medium'),
top=Side(style='medium'),
bottom=Side(style='medium')
)
# 두 개의 시트를 생성하고 각각 데이터 입력
sheets_data = [
(wb.active, in_progress_issues, "진행 중"),
(wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
]
sheets_data[0][0].title = "진행 중"
for ws, sheet_issues, sheet_title in sheets_data:
# 제목 및 기본 정보
ws.merge_cells('A1:L1')
ws['A1'] = f"{project.project_name} - {sheet_title}"
ws['A1'].font = Font(bold=True, size=16)
ws['A1'].alignment = center_alignment
ws.merge_cells('A2:L2')
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}"
ws['A2'].alignment = center_alignment
# 프로젝트 통계 (4행부터)
ws.merge_cells('A4:L4')
ws['A4'] = "프로젝트 현황"
ws['A4'].font = stats_font
ws['A4'].fill = stats_fill
ws['A4'].alignment = center_alignment
# 통계 데이터
stats_row = 5
ws[f'A{stats_row}'] = "총 신고 수량"
ws[f'B{stats_row}'] = stats.total_count
ws[f'D{stats_row}'] = "관리처리 현황"
ws[f'E{stats_row}'] = stats.management_count
ws[f'G{stats_row}'] = "완료 현황"
ws[f'H{stats_row}'] = stats.completed_count
ws[f'J{stats_row}'] = "지연 중"
ws[f'K{stats_row}'] = stats.delayed_count
# 통계 스타일 적용
for col in ['A', 'D', 'G', 'J']:
ws[f'{col}{stats_row}'].font = Font(bold=True)
ws[f'{col}{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
# 카드 형태로 데이터 입력 (7행부터)
current_row = 7
for idx, issue in enumerate(sheet_issues, 1):
card_start_row = current_row
# 상태별 헤더 색상 설정
header_color = get_issue_status_header_color(issue)
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
ws.merge_cells(f'A{current_row}:C{current_row}')
ws[f'A{current_row}'] = f"No. {issue.project_sequence_no or issue.id}"
ws[f'A{current_row}'].font = card_header_font
ws[f'A{current_row}'].fill = card_header_fill
ws[f'A{current_row}'].alignment = center_alignment
ws.merge_cells(f'D{current_row}:G{current_row}')
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
ws[f'D{current_row}'].font = card_header_font
ws[f'D{current_row}'].fill = card_header_fill
ws[f'D{current_row}'].alignment = center_alignment
ws.merge_cells(f'H{current_row}:L{current_row}')
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
ws[f'H{current_row}'].font = card_header_font
ws[f'H{current_row}'].fill = card_header_fill
ws[f'H{current_row}'].alignment = center_alignment
current_row += 1
# 부적합명
ws[f'A{current_row}'] = "부적합명"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
# final_description이 있으면 사용, 없으면 description 사용
issue_title = issue.final_description or issue.description or "내용 없음"
ws[f'B{current_row}'] = issue_title
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
current_row += 1
# 상세내용 (detail_notes가 실제 상세 설명)
if issue.detail_notes:
ws[f'A{current_row}'] = "상세내용"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = issue.detail_notes
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
ws.row_dimensions[current_row].height = 50
current_row += 1
# 원인분류
ws[f'A{current_row}'] = "원인분류"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
ws[f'B{current_row}'].font = content_font
ws[f'D{current_row}'] = "원인부서"
ws[f'D{current_row}'].font = label_font
ws[f'D{current_row}'].fill = label_fill
ws.merge_cells(f'E{current_row}:F{current_row}')
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
ws[f'E{current_row}'].font = content_font
ws[f'G{current_row}'] = "신고자"
ws[f'G{current_row}'].font = label_font
ws[f'G{current_row}'].fill = label_fill
ws.merge_cells(f'H{current_row}:L{current_row}')
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
ws[f'H{current_row}'].font = content_font
current_row += 1
# 해결방안 (완료 반려 내용 및 댓글 제거)
ws[f'A{current_row}'] = "해결방안"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
# management_comment에서 완료 반려 패턴과 댓글 제거
clean_solution = clean_management_comment_for_export(issue.management_comment)
ws[f'B{current_row}'] = clean_solution
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
ws.row_dimensions[current_row].height = 30
current_row += 1
# 담당정보
ws[f'A{current_row}'] = "담당부서"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
ws[f'B{current_row}'].font = content_font
ws[f'D{current_row}'] = "담당자"
ws[f'D{current_row}'].font = label_font
ws[f'D{current_row}'].fill = label_fill
ws.merge_cells(f'E{current_row}:G{current_row}')
ws[f'E{current_row}'] = issue.responsible_person or ""
ws[f'E{current_row}'].font = content_font
ws[f'H{current_row}'] = "조치예상일"
ws[f'H{current_row}'].font = label_font
ws[f'H{current_row}'].fill = label_fill
ws.merge_cells(f'I{current_row}:L{current_row}')
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
ws[f'I{current_row}'].font = content_font
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
current_row += 1
# === 신고 사진 영역 ===
has_report_photo = issue.photo_path or issue.photo_path2
if has_report_photo:
# 라벨 행 (A~L 전체 병합)
ws.merge_cells(f'A{current_row}:L{current_row}')
ws[f'A{current_row}'] = "신고 사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
ws.row_dimensions[current_row].height = 18
current_row += 1
# 사진 행 (별도 행으로 분리)
ws.merge_cells(f'A{current_row}:L{current_row}')
report_image_inserted = False
# 신고 사진 1
if issue.photo_path:
photo_path = issue.photo_path.replace('/uploads/', '/app/uploads/') if issue.photo_path.startswith('/uploads/') else issue.photo_path
if os.path.exists(photo_path):
try:
img = XLImage(photo_path)
img.width = min(img.width, 250)
img.height = min(img.height, 180)
ws.add_image(img, f'A{current_row}') # A열에 첫 번째 사진
report_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({photo_path}): {e}")
# 신고 사진 2
if issue.photo_path2:
photo_path2 = issue.photo_path2.replace('/uploads/', '/app/uploads/') if issue.photo_path2.startswith('/uploads/') else issue.photo_path2
if os.path.exists(photo_path2):
try:
img = XLImage(photo_path2)
img.width = min(img.width, 250)
img.height = min(img.height, 180)
ws.add_image(img, f'G{current_row}') # G열에 두 번째 사진 (간격 확보)
report_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({photo_path2}): {e}")
if report_image_inserted:
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
else:
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
ws.row_dimensions[current_row].height = 20
current_row += 1
# === 완료 관련 정보 (완료된 항목만) ===
if issue.review_status == ReviewStatus.completed:
# 완료 코멘트
if issue.completion_comment:
ws[f'A{current_row}'] = "완료 의견"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = issue.completion_comment
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
ws.row_dimensions[current_row].height = 30
current_row += 1
# 완료 사진
if issue.completion_photo_path:
# 라벨 행 (A~L 전체 병합)
ws.merge_cells(f'A{current_row}:L{current_row}')
ws[f'A{current_row}'] = "완료 사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
ws.row_dimensions[current_row].height = 18
current_row += 1
# 사진 행 (별도 행으로 분리)
ws.merge_cells(f'A{current_row}:L{current_row}')
completion_path = issue.completion_photo_path.replace('/uploads/', '/app/uploads/') if issue.completion_photo_path.startswith('/uploads/') else issue.completion_photo_path
completion_image_inserted = False
if os.path.exists(completion_path):
try:
img = XLImage(completion_path)
img.width = min(img.width, 250)
img.height = min(img.height, 180)
ws.add_image(img, f'A{current_row}') # A열에 사진
completion_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({completion_path}): {e}")
if completion_image_inserted:
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
else:
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
ws.row_dimensions[current_row].height = 20
current_row += 1
# 완료일 정보
if issue.actual_completion_date:
ws[f'A{current_row}'] = "완료일"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
ws[f'B{current_row}'].font = content_font
current_row += 1
# 사진이 하나도 없을 경우
# 진행중: 신고 사진만 체크
# 완료됨: 신고 사진 + 완료 사진 체크
has_any_photo = has_report_photo or (issue.review_status == ReviewStatus.completed and issue.completion_photo_path)
if not has_any_photo:
ws[f'A{current_row}'] = "첨부사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = "첨부된 사진 없음"
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
current_row += 1
# 카드 전체에 테두리 적용 (A-L 열)
card_end_row = current_row - 1
for row in range(card_start_row, card_end_row + 1):
for col in range(1, 13): # A-L 열 (12열)
cell = ws.cell(row=row, column=col)
if not cell.border or cell.border.left.style != 'medium':
cell.border = border
# 카드 외곽에 굵은 테두리 (A-L 열)
for col in range(1, 13):
ws.cell(row=card_start_row, column=col).border = Border(
left=Side(style='medium' if col == 1 else 'thin'),
right=Side(style='medium' if col == 12 else 'thin'),
top=Side(style='medium'),
bottom=Side(style='thin')
)
ws.cell(row=card_end_row, column=col).border = Border(
left=Side(style='medium' if col == 1 else 'thin'),
right=Side(style='medium' if col == 12 else 'thin'),
top=Side(style='thin'),
bottom=Side(style='medium')
)
# 카드 구분 (빈 행)
current_row += 1
# 열 너비 조정
ws.column_dimensions['A'].width = 12 # 레이블 열
ws.column_dimensions['B'].width = 15 # 내용 열
ws.column_dimensions['C'].width = 15 # 내용 열
ws.column_dimensions['D'].width = 15 # 내용 열
ws.column_dimensions['E'].width = 15 # 내용 열
ws.column_dimensions['F'].width = 15 # 내용 열
ws.column_dimensions['G'].width = 15 # 내용 열
ws.column_dimensions['H'].width = 15 # 내용 열
ws.column_dimensions['I'].width = 15 # 내용 열
ws.column_dimensions['J'].width = 15 # 내용 열
ws.column_dimensions['K'].width = 15 # 내용 열
ws.column_dimensions['L'].width = 15 # 내용 열
# 엑셀 파일을 메모리에 저장
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
# 추출 이력 업데이트
export_time = datetime.now()
for issue in issues:
issue.last_exported_at = export_time
issue.export_count = (issue.export_count or 0) + 1
db.commit()
# 파일명 생성
today = date.today().strftime('%Y%m%d')
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
# 한글 파일명을 위한 URL 인코딩
from urllib.parse import quote
encoded_filename = quote(filename.encode('utf-8'))
return StreamingResponse(
io.BytesIO(excel_buffer.read()),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
)
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
"""프로젝트 통계 계산"""
stats = schemas.DailyReportStats()
today = date.today()
for issue in issues:
stats.total_count += 1
if issue.review_status == ReviewStatus.in_progress:
stats.management_count += 1
# 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
if issue.expected_completion_date:
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
stats.delayed_count += 1
elif issue.review_status == ReviewStatus.completed:
stats.completed_count += 1
return stats
def get_category_text(category: IssueCategory) -> str:
"""카테고리 한글 변환"""
category_map = {
IssueCategory.material_missing: "자재 누락",
IssueCategory.design_error: "설계 미스",
IssueCategory.incoming_defect: "입고 불량",
IssueCategory.inspection_miss: "검사 미스",
IssueCategory.etc: "기타"
}
return category_map.get(category, str(category))
def get_department_text(department) -> str:
"""부서 한글 변환"""
if not department:
return ""
department_map = {
"production": "생산",
"quality": "품질",
"purchasing": "구매",
"design": "설계",
"sales": "영업"
}
return department_map.get(department, str(department))
def get_status_text(status: ReviewStatus) -> str:
"""상태 한글 변환"""
status_map = {
ReviewStatus.pending_review: "검토 대기",
ReviewStatus.in_progress: "진행 중",
ReviewStatus.completed: "완료됨",
ReviewStatus.disposed: "폐기됨"
}
return status_map.get(status, str(status))
def clean_management_comment_for_export(text: str) -> str:
"""엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)"""
if not text:
return ""
# 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용)
text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text)
# 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들)
lines = text.split('\n')
filtered_lines = []
for line in lines:
# └ 또는 ↳로 시작하는 줄 제외
if not re.match(r'^\s*[└↳]', line):
filtered_lines.append(line)
# 3. 빈 줄 정리
result = '\n'.join(filtered_lines).strip()
# 연속된 빈 줄을 하나로
result = re.sub(r'\n{3,}', '\n\n', result)
return result
def get_status_color(status: ReviewStatus) -> str:
"""상태별 색상 반환"""
color_map = {
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
ReviewStatus.completed: "E2EFDA", # 연한 초록
ReviewStatus.disposed: "F2F2F2" # 연한 회색
}
return color_map.get(status, None)
def get_issue_priority(issue: Issue) -> int:
"""이슈 우선순위 반환 (엑셀 정렬용)
1: 지연 (빨강)
2: 진행중 (노랑)
3: 완료됨 (초록)
"""
if issue.review_status == ReviewStatus.completed:
return 3
elif issue.review_status == ReviewStatus.in_progress:
# 조치 예상일이 지난 경우 지연
if issue.expected_completion_date:
today = date.today()
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
return 1 # 지연
return 2 # 진행중
return 2
def get_issue_status_text(issue: Issue) -> str:
"""이슈 상태 텍스트 반환"""
if issue.review_status == ReviewStatus.completed:
return "완료됨"
elif issue.review_status == ReviewStatus.in_progress:
if issue.expected_completion_date:
today = date.today()
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
return "지연중"
return "진행중"
return get_status_text(issue.review_status)
def get_issue_status_header_color(issue: Issue) -> str:
"""이슈 상태별 헤더 색상 반환"""
priority = get_issue_priority(issue)
if priority == 1: # 지연
return "E74C3C" # 빨간색
elif priority == 2: # 진행중
return "F39C12" # 주황/노란색
elif priority == 3: # 완료
return "27AE60" # 초록색
return "4472C4" # 기본 파란색

View File

@@ -0,0 +1,98 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
import os
import bcrypt as bcrypt_lib
from database.models import User, UserRole
from database.schemas import TokenData
# 환경 변수 - SSO 공유 시크릿 사용 (docker-compose에서 SECRET_KEY=SSO_JWT_SECRET)
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7 days
# 비밀번호 암호화 (pbkdf2_sha256 - 로컬 인증용)
pwd_context = CryptContext(
schemes=["pbkdf2_sha256"],
deprecated="auto"
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""비밀번호 검증 - bcrypt + pbkdf2_sha256 둘 다 지원 (SSO 호환)"""
if not plain_password or not hashed_password:
return False
# bcrypt 형식 ($2b$ 또는 $2a$)
if hashed_password.startswith(("$2b$", "$2a$")):
try:
return bcrypt_lib.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
except Exception:
return False
# pbkdf2_sha256 형식 (passlib)
try:
return pwd_context.verify(plain_password, hashed_password)
except Exception:
return False
def get_password_hash(password: str) -> str:
"""비밀번호 해시 생성 (pbkdf2_sha256)"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str, credentials_exception):
"""JWT 토큰 검증 - SSO 토큰 페이로드 구조 지원"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
# SSO 토큰: "sub" 필드에 username
# 기존 M-Project 토큰: "sub" 필드에 username
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
return token_data
except JWTError:
raise credentials_exception
def authenticate_user(db: Session, username: str, password: str):
user = db.query(User).filter(User.username == username).first()
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_admin_user(db: Session):
"""초기 관리자 계정 생성"""
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}")
else:
print(f"관리자 계정이 이미 존재함: {admin_username}")

View File

@@ -0,0 +1,136 @@
import os
import base64
from datetime import datetime
from typing import Optional
import uuid
from PIL import Image
import io
# HEIF/HEIC 지원을 위한 라이브러리
try:
from pillow_heif import register_heif_opener
register_heif_opener()
HEIF_SUPPORTED = True
except ImportError:
HEIF_SUPPORTED = False
UPLOAD_DIR = "/app/uploads"
def ensure_upload_dir():
"""업로드 디렉토리 생성"""
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str]:
"""Base64 이미지를 파일로 저장하고 경로 반환"""
try:
ensure_upload_dir()
# Base64 헤더 제거 및 정리
if "," in base64_string:
base64_string = base64_string.split(",")[1]
# Base64 문자열 정리 (공백, 개행 제거)
base64_string = base64_string.strip().replace('\n', '').replace('\r', '').replace(' ', '')
print(f"🔍 정리된 Base64 길이: {len(base64_string)}")
print(f"🔍 Base64 시작 20자: {base64_string[:20]}")
# 디코딩
try:
image_data = base64.b64decode(base64_string)
print(f"🔍 디코딩된 데이터 길이: {len(image_data)}")
print(f"🔍 바이너리 시작 20바이트: {image_data[:20]}")
except Exception as decode_error:
print(f"❌ Base64 디코딩 실패: {decode_error}")
raise decode_error
# 파일 시그니처 확인
file_signature = image_data[:20]
print(f"🔍 파일 시그니처 (hex): {file_signature.hex()}")
# HEIC 파일 시그니처 확인
is_heic = b'ftyp' in image_data[:20] and (b'heic' in image_data[:50] or b'mif1' in image_data[:50])
print(f"🔍 HEIC 파일 여부: {is_heic}")
# 이미지 검증 및 형식 확인
image = None
try:
# HEIC 파일인 경우 pillow_heif를 직접 사용하여 처리
if is_heic and HEIF_SUPPORTED:
print("🔄 HEIC 파일 감지, pillow_heif로 직접 처리...")
try:
import pillow_heif
heif_file = pillow_heif.open_heif(io.BytesIO(image_data), convert_hdr_to_8bit=False)
image = heif_file.to_pillow()
print(f"✅ HEIC -> PIL 변환 성공: 모드: {image.mode}, 크기: {image.size}")
except Exception as heic_error:
print(f"⚠️ pillow_heif 직접 처리 실패: {heic_error}")
# PIL Image.open()으로 재시도
image = Image.open(io.BytesIO(image_data))
print(f"✅ PIL Image.open() 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}")
else:
# 일반 이미지 처리
image = Image.open(io.BytesIO(image_data))
print(f"🔍 이미지 형식: {image.format}, 모드: {image.mode}, 크기: {image.size}")
except Exception as e:
print(f"❌ 이미지 열기 실패: {e}")
# HEIF 재시도
if HEIF_SUPPORTED:
print("🔄 HEIF 형식으로 재시도...")
try:
image = Image.open(io.BytesIO(image_data))
print(f"✅ HEIF 재시도 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}")
except Exception as heif_e:
print(f"❌ HEIF 처리도 실패: {heif_e}")
print("❌ 지원되지 않는 이미지 형식")
raise e
else:
print("❌ HEIF 지원 라이브러리가 설치되지 않거나 처리 불가")
raise e
# 이미지가 성공적으로 로드되지 않은 경우
if image is None:
raise Exception("이미지 로드 실패")
# iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환
# RGB 모드로 변환 (RGBA, P 모드 등을 처리)
if image.mode in ('RGBA', 'LA', 'P'):
# 투명도가 있는 이미지는 흰 배경과 합성
background = Image.new('RGB', image.size, (255, 255, 255))
if image.mode == 'P':
image = image.convert('RGBA')
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
image = background
elif image.mode != 'RGB':
image = image.convert('RGB')
# 파일명 생성 (prefix 포함)
filename = f"{prefix}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.jpg"
filepath = os.path.join(UPLOAD_DIR, filename)
# 이미지 저장 (최대 크기 제한)
max_size = (1920, 1920)
image.thumbnail(max_size, Image.Resampling.LANCZOS)
# 항상 JPEG로 저장
image.save(filepath, 'JPEG', quality=85, optimize=True)
# 웹 경로 반환
return f"/uploads/{filename}"
except Exception as e:
print(f"이미지 저장 실패: {e}")
return None
def delete_file(filepath: str):
"""파일 삭제"""
try:
if filepath and filepath.startswith("/uploads/"):
filename = filepath.replace("/uploads/", "")
full_path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(full_path):
os.remove(full_path)
except Exception as e:
print(f"파일 삭제 실패: {e}")

View File

@@ -0,0 +1,8 @@
FROM nginx:alpine
COPY . /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,913 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리자 페이지 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
min-height: 100vh;
}
.glass-effect {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.nav-link {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
color: #4b5563;
transition: all 0.2s;
white-space: nowrap;
}
.nav-link:hover {
background-color: #f3f4f6;
color: #1f2937;
}
.nav-link.active {
background-color: #3b82f6;
color: white;
}
.input-field {
background: white;
border: 1px solid #e5e7eb;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
}
/* 부드러운 페이드인 애니메이션 */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 헤더 전용 빠른 페이드인 */
.header-fade-in {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.header-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 본문 컨텐츠 지연 페이드인 */
.content-fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
transition-delay: 0.2s;
}
.content-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8 max-w-6xl content-fade-in" style="padding-top: 80px;">
<div class="grid md:grid-cols-2 gap-6">
<!-- 사용자 추가 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-user-plus text-blue-500 mr-2"></i>사용자 추가
</h2>
<form id="addUserForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사용자 ID</label>
<input
type="text"
id="newUsername"
class="input-field w-full px-3 py-2 rounded-lg"
placeholder="한글 가능 (예: 홍길동)"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
<input
type="text"
id="newFullName"
class="input-field w-full px-3 py-2 rounded-lg"
placeholder="실명 입력"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
<input
type="password"
id="newPassword"
class="input-field w-full px-3 py-2 rounded-lg"
placeholder="초기 비밀번호"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">부서</label>
<select id="newDepartment" class="input-field w-full px-3 py-2 rounded-lg">
<option value="">부서 선택 (선택사항)</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">권한</label>
<select id="newRole" class="input-field w-full px-3 py-2 rounded-lg">
<option value="user">일반 사용자</option>
<option value="admin">관리자</option>
</select>
</div>
<button
type="submit"
class="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<i class="fas fa-plus mr-2"></i>사용자 추가
</button>
</form>
</div>
<!-- 사용자 목록 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-users text-green-500 mr-2"></i>사용자 목록
</h2>
<div id="userList" class="space-y-3">
<!-- 사용자 목록이 여기에 표시됩니다 -->
<div class="text-gray-500 text-center py-8">
<i class="fas fa-spinner fa-spin text-3xl"></i>
<p>로딩 중...</p>
</div>
</div>
</div>
</div>
<!-- 페이지 권한 관리 섹션 (관리자용) -->
<div id="pagePermissionSection" class="mt-6">
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-shield-alt text-purple-500 mr-2"></i>페이지 접근 권한 관리
</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">사용자 선택</label>
<select id="permissionUserSelect" class="input-field w-full max-w-xs px-3 py-2 rounded-lg">
<option value="">사용자를 선택하세요</option>
</select>
</div>
<div id="pagePermissionGrid" class="hidden">
<h3 class="text-md font-medium text-gray-700 mb-3">페이지별 접근 권한</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 페이지 권한 체크박스들이 여기에 동적으로 생성됩니다 -->
</div>
<div class="mt-4 pt-4 border-t">
<button
id="savePermissionsBtn"
class="px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors"
>
<i class="fas fa-save mr-2"></i>권한 저장
</button>
<span id="permissionSaveStatus" class="ml-3 text-sm"></span>
</div>
</div>
</div>
</div>
<!-- 비밀번호 변경 섹션 (사용자용) -->
<div id="passwordChangeSection" class="hidden mt-6">
<div class="bg-white rounded-xl shadow-sm p-6 max-w-md mx-auto">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-key text-yellow-500 mr-2"></i>비밀번호 변경
</h2>
<form id="changePasswordForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
<input
type="password"
id="currentPassword"
class="input-field w-full px-3 py-2 rounded-lg"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
<input
type="password"
id="newPasswordChange"
class="input-field w-full px-3 py-2 rounded-lg"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
<input
type="password"
id="confirmPassword"
class="input-field w-full px-3 py-2 rounded-lg"
required
>
</div>
<button
type="submit"
class="w-full px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors"
>
<i class="fas fa-save mr-2"></i>비밀번호 변경
</button>
</form>
</div>
</div>
</main>
<!-- 사용자 편집 모달 -->
<div id="editUserModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">사용자 정보 수정</h3>
<button onclick="closeEditModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form id="editUserForm" class="space-y-4">
<input type="hidden" id="editUserId">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사용자 ID</label>
<input
type="text"
id="editUsername"
class="input-field w-full px-3 py-2 rounded-lg bg-gray-100"
readonly
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
<input
type="text"
id="editFullName"
class="input-field w-full px-3 py-2 rounded-lg"
placeholder="실명 입력"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">부서</label>
<select id="editDepartment" class="input-field w-full px-3 py-2 rounded-lg">
<option value="">부서 선택 (선택사항)</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">권한</label>
<select id="editRole" class="input-field w-full px-3 py-2 rounded-lg">
<option value="user">일반 사용자</option>
<option value="admin">관리자</option>
</select>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
onclick="closeEditModal()"
class="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
>
취소
</button>
<button
type="submit"
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<i class="fas fa-save mr-2"></i>저장
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=20250917"></script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script>
let currentUser = null;
let users = [];
// 애니메이션 함수들
function animateHeaderAppearance() {
console.log('🎨 헤더 애니메이션 시작');
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
if (headerElement) {
headerElement.classList.add('header-fade-in');
setTimeout(() => {
headerElement.classList.add('visible');
console.log('✨ 헤더 페이드인 완료');
// 헤더 애니메이션 완료 후 본문 애니메이션
setTimeout(() => {
animateContentAppearance();
}, 200);
}, 50);
} else {
// 헤더를 찾지 못했으면 바로 본문 애니메이션
console.log('⚠️ 헤더 요소를 찾지 못함 - 본문 애니메이션 시작');
animateContentAppearance();
}
}
// 본문 컨텐츠 애니메이션
function animateContentAppearance() {
console.log('🎨 본문 컨텐츠 애니메이션 시작');
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
const contentElements = document.querySelectorAll('.content-fade-in');
contentElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add('visible');
console.log(`✨ 컨텐츠 ${index + 1} 페이드인 완료`);
}, index * 100); // 100ms씩 지연
});
}
// API 로드 후 초기화 함수
async function initializeAdmin() {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/index.html';
return;
}
try {
const user = await AuthAPI.getCurrentUser();
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
await window.commonHeader.init(user, 'users_manage');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 페이지 접근 권한 체크
setTimeout(() => {
if (!canAccessPage('users_manage')) {
alert('사용자 관리 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}, 500);
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
return;
}
// 관리자가 아니면 비밀번호 변경만 표시
if (currentUser.role !== 'admin') {
document.querySelector('.grid').style.display = 'none';
document.getElementById('passwordChangeSection').classList.remove('hidden');
} else {
// 관리자면 사용자 목록 로드
await loadUsers();
}
}
// 사용자 추가
document.getElementById('addUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userData = {
username: document.getElementById('newUsername').value.trim(),
full_name: document.getElementById('newFullName').value.trim(),
password: document.getElementById('newPassword').value,
department: document.getElementById('newDepartment').value || null,
role: document.getElementById('newRole').value
};
try {
await AuthAPI.createUser(userData);
// 성공
alert('사용자가 추가되었습니다.');
// 폼 초기화
document.getElementById('addUserForm').reset();
// 목록 새로고침
await loadUsers();
} catch (error) {
alert(error.message || '사용자 추가에 실패했습니다.');
}
});
// 비밀번호 변경
document.getElementById('changePasswordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPasswordChange').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
alert('새 비밀번호가 일치하지 않습니다.');
return;
}
try {
await AuthAPI.changePassword(currentPassword, newPassword);
alert('비밀번호가 변경되었습니다. 다시 로그인해주세요.');
AuthAPI.logout();
} catch (error) {
alert(error.message || '비밀번호 변경에 실패했습니다.');
}
});
// 사용자 목록 로드
async function loadUsers() {
try {
// 백엔드 API에서 사용자 목록 로드
users = await AuthAPI.getUsers();
displayUsers();
} catch (error) {
console.error('사용자 목록 로드 실패:', error);
// API 실패 시 빈 배열로 초기화
users = [];
displayUsers();
}
}
// 사용자 목록 표시
function displayUsers() {
const container = document.getElementById('userList');
if (users.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center">등록된 사용자가 없습니다.</p>';
return;
}
container.innerHTML = users.map(user => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex-1">
<div class="font-medium text-gray-800">
<i class="fas fa-user mr-2 text-gray-500"></i>
${user.full_name || user.username}
</div>
<div class="text-sm text-gray-600 flex items-center gap-3">
<span>ID: ${user.username}</span>
${user.department ? `
<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-700">
<i class="fas fa-building mr-1"></i>${AuthAPI.getDepartmentLabel(user.department)}
</span>
` : ''}
<span class="px-2 py-0.5 rounded text-xs ${
user.role === 'admin'
? 'bg-red-100 text-red-700'
: 'bg-blue-100 text-blue-700'
}">
${user.role === 'admin' ? '관리자' : '사용자'}
</span>
</div>
</div>
<div class="flex gap-2">
<button
onclick="editUser(${user.id})"
class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm"
>
<i class="fas fa-edit mr-1"></i>편집
</button>
<button
onclick="resetPassword('${user.username}')"
class="px-3 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 transition-colors text-sm"
>
<i class="fas fa-key mr-1"></i>비밀번호 초기화
</button>
${user.username !== 'hyungi' ? `
<button
onclick="deleteUser('${user.username}')"
class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm"
>
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
</div>
`).join('');
}
// 비밀번호 초기화
async function resetPassword(username) {
if (!confirm(`${username} 사용자의 비밀번호를 "000000"으로 초기화하시겠습니까?`)) {
return;
}
try {
// 사용자 ID 찾기
const user = users.find(u => u.username === username);
if (!user) {
alert('사용자를 찾을 수 없습니다.');
return;
}
// 백엔드 API로 비밀번호 초기화
await AuthAPI.resetPassword(user.id, '000000');
alert(`${username} 사용자의 비밀번호가 "000000"으로 초기화되었습니다.`);
// 목록 새로고침
await loadUsers();
} catch (error) {
alert('비밀번호 초기화에 실패했습니다: ' + error.message);
}
}
// 사용자 삭제
async function deleteUser(username) {
if (!confirm(`정말 ${username} 사용자를 삭제하시겠습니까?`)) {
return;
}
try {
await AuthAPI.deleteUser(username);
alert('사용자가 삭제되었습니다.');
await loadUsers();
} catch (error) {
alert(error.message || '삭제에 실패했습니다.');
}
}
// 사용자 편집 모달 열기
function editUser(userId) {
const user = users.find(u => u.id === userId);
if (!user) {
alert('사용자를 찾을 수 없습니다.');
return;
}
// 모달 필드에 현재 값 설정
document.getElementById('editUserId').value = user.id;
document.getElementById('editUsername').value = user.username;
document.getElementById('editFullName').value = user.full_name || '';
document.getElementById('editDepartment').value = user.department || '';
document.getElementById('editRole').value = user.role;
// 모달 표시
document.getElementById('editUserModal').classList.remove('hidden');
}
// 사용자 편집 모달 닫기
function closeEditModal() {
document.getElementById('editUserModal').classList.add('hidden');
}
// 사용자 편집 폼 제출
document.getElementById('editUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userId = document.getElementById('editUserId').value;
const userData = {
full_name: document.getElementById('editFullName').value.trim() || null,
department: document.getElementById('editDepartment').value || null,
role: document.getElementById('editRole').value
};
try {
await AuthAPI.updateUser(userId, userData);
alert('사용자 정보가 수정되었습니다.');
closeEditModal();
await loadUsers(); // 목록 새로고침
} catch (error) {
alert(error.message || '사용자 정보 수정에 실패했습니다.');
}
});
// 페이지 권한 관리 기능
let selectedUserId = null;
let currentPermissions = {};
// AuthAPI를 사용하여 사용자 목록 로드
async function loadUsers() {
try {
users = await AuthAPI.getUsers();
displayUsers();
updatePermissionUserSelect(); // 권한 관리 드롭다운 업데이트
} catch (error) {
console.error('사용자 로드 실패:', error);
document.getElementById('userList').innerHTML = `
<div class="text-red-500 text-center py-8">
<i class="fas fa-exclamation-triangle text-3xl"></i>
<p>사용자 목록을 불러올 수 없습니다.</p>
<p class="text-sm mt-2">오류: ${error.message}</p>
</div>
`;
}
}
// 권한 관리 사용자 선택 드롭다운 업데이트
function updatePermissionUserSelect() {
const select = document.getElementById('permissionUserSelect');
select.innerHTML = '<option value="">사용자를 선택하세요</option>';
// 일반 사용자만 표시 (admin 제외)
const regularUsers = users.filter(user => user.role === 'user');
regularUsers.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.full_name || user.username} (${user.username})`;
select.appendChild(option);
});
}
// 사용자 선택 시 페이지 권한 그리드 표시
document.getElementById('permissionUserSelect').addEventListener('change', async (e) => {
selectedUserId = e.target.value;
if (selectedUserId) {
await loadUserPagePermissions(selectedUserId);
showPagePermissionGrid();
} else {
hidePagePermissionGrid();
}
});
// 사용자의 페이지 권한 로드
async function loadUserPagePermissions(userId) {
try {
// 기본 페이지 목록 가져오기
const defaultPages = {
'issues_create': { title: '부적합 등록', defaultAccess: true },
'issues_view': { title: '부적합 조회', defaultAccess: true },
'issues_manage': { title: '부적합 관리', defaultAccess: true },
'issues_inbox': { title: '수신함', defaultAccess: true },
'issues_management': { title: '관리함', defaultAccess: false },
'issues_archive': { title: '폐기함', defaultAccess: false },
'projects_manage': { title: '프로젝트 관리', defaultAccess: false },
'daily_work': { title: '일일 공수', defaultAccess: false },
'reports': { title: '보고서', defaultAccess: false }
};
// 기본값으로 초기화
currentPermissions = {};
Object.keys(defaultPages).forEach(pageName => {
currentPermissions[pageName] = defaultPages[pageName].defaultAccess;
});
// 실제 API 호출로 사용자별 설정된 권한 가져오기
try {
const response = await fetch(`/api/users/${userId}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
const permissions = await response.json();
permissions.forEach(perm => {
currentPermissions[perm.page_name] = perm.can_access;
});
console.log('사용자 권한 로드 완료:', currentPermissions);
} else {
console.warn('사용자 권한 로드 실패, 기본값 사용');
}
} catch (apiError) {
console.warn('API 호출 실패, 기본값 사용:', apiError);
}
} catch (error) {
console.error('페이지 권한 로드 실패:', error);
}
}
// 페이지 권한 그리드 표시
function showPagePermissionGrid() {
const grid = document.getElementById('pagePermissionGrid');
const gridContainer = grid.querySelector('.grid');
// 페이지 권한 체크박스 생성 (카테고리별로 그룹화)
const pageCategories = {
'부적합 관리': {
'issues_create': { title: '부적합 등록', icon: 'fas fa-plus-circle', color: 'text-green-600' },
'issues_view': { title: '부적합 조회', icon: 'fas fa-search', color: 'text-purple-600' },
'issues_manage': { title: '목록 관리 (통합)', icon: 'fas fa-tasks', color: 'text-orange-600' }
},
'목록 관리 세부': {
'issues_inbox': { title: '📥 수신함', icon: 'fas fa-inbox', color: 'text-blue-600' },
'issues_management': { title: '⚙️ 관리함', icon: 'fas fa-cog', color: 'text-green-600' },
'issues_archive': { title: '🗃️ 폐기함', icon: 'fas fa-archive', color: 'text-gray-600' }
},
'시스템 관리': {
'projects_manage': { title: '프로젝트 관리', icon: 'fas fa-folder-open', color: 'text-indigo-600' },
'daily_work': { title: '일일 공수', icon: 'fas fa-calendar-check', color: 'text-blue-600' },
'reports': { title: '보고서', icon: 'fas fa-chart-bar', color: 'text-red-600' },
'users_manage': { title: '사용자 관리', icon: 'fas fa-users-cog', color: 'text-purple-600' }
}
};
let html = '';
// 카테고리별로 그룹화하여 표시
Object.entries(pageCategories).forEach(([categoryName, pages]) => {
html += `
<div class="col-span-full">
<h4 class="text-sm font-semibold text-gray-800 mb-3 pb-2 border-b border-gray-200">
${categoryName}
</h4>
</div>
`;
Object.entries(pages).forEach(([pageName, pageInfo]) => {
const isChecked = currentPermissions[pageName] || false;
const isDefault = currentPermissions[pageName] === undefined ?
(pageInfo.title.includes('등록') || pageInfo.title.includes('조회') || pageInfo.title.includes('수신함')) : false;
html += `
<div class="flex items-center p-3 border rounded-lg hover:bg-gray-50 transition-colors ${isChecked ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
<input
type="checkbox"
id="perm_${pageName}"
${isChecked ? 'checked' : ''}
class="mr-3 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
onchange="this.parentElement.classList.toggle('border-blue-300', this.checked); this.parentElement.classList.toggle('bg-blue-50', this.checked);"
>
<label for="perm_${pageName}" class="flex-1 cursor-pointer">
<div class="flex items-center">
<i class="${pageInfo.icon} ${pageInfo.color} mr-2"></i>
<span class="text-sm font-medium text-gray-700">${pageInfo.title}</span>
${isDefault ? '<span class="ml-2 text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">기본</span>' : ''}
</div>
</label>
</div>
`;
});
});
gridContainer.innerHTML = html;
grid.classList.remove('hidden');
}
// 페이지 권한 그리드 숨기기
function hidePagePermissionGrid() {
document.getElementById('pagePermissionGrid').classList.add('hidden');
}
// 권한 저장
document.getElementById('savePermissionsBtn').addEventListener('click', async () => {
if (!selectedUserId) return;
const saveBtn = document.getElementById('savePermissionsBtn');
const statusSpan = document.getElementById('permissionSaveStatus');
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>저장 중...';
statusSpan.textContent = '';
try {
// 체크박스 상태 수집 (모든 페이지 포함)
const allPages = [
'issues_create', 'issues_view', 'issues_manage',
'issues_inbox', 'issues_management', 'issues_archive',
'projects_manage', 'daily_work', 'reports', 'users_manage'
];
const permissions = {};
allPages.forEach(pageName => {
const checkbox = document.getElementById(`perm_${pageName}`);
if (checkbox) {
permissions[pageName] = checkbox.checked;
}
});
// 실제 API 호출로 권한 저장
const permissionArray = Object.entries(permissions).map(([pageName, canAccess]) => ({
page_name: pageName,
can_access: canAccess
}));
const response = await fetch('/api/page-permissions/bulk-grant', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
user_id: parseInt(selectedUserId),
permissions: permissionArray
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || '권한 저장에 실패했습니다.');
}
const result = await response.json();
console.log('권한 저장 완료:', result);
statusSpan.textContent = '✅ 권한이 저장되었습니다.';
statusSpan.className = 'ml-3 text-sm text-green-600';
setTimeout(() => {
statusSpan.textContent = '';
}, 3000);
} catch (error) {
console.error('권한 저장 실패:', error);
statusSpan.textContent = '❌ 권한 저장에 실패했습니다.';
statusSpan.className = 'ml-3 text-sm text-red-600';
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="fas fa-save mr-2"></i>권한 저장';
}
});
// API 스크립트 동적 로딩
const cacheBuster = Date.now() + Math.random() + Math.floor(Math.random() * 1000000);
const script = document.createElement('script');
script.src = `/static/js/api.js?v=20251025-2&cb=${cacheBuster}&t=${Date.now()}&r=${Math.random()}`;
script.setAttribute('cache-control', 'no-cache');
script.setAttribute('pragma', 'no-cache');
script.onload = function() {
console.log('✅ API 스크립트 로드 완료 (admin.html)');
// API 로드 후 초기화 시작
initializeAdmin();
};
script.onerror = function() {
console.error('❌ API 스크립트 로드 실패');
};
document.head.appendChild(script);
</script>
</body>
</html>

View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업보고서 시스템</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
min-height: 100vh;
}
.sidebar {
transition: transform 0.3s ease-in-out;
}
.sidebar.collapsed {
transform: translateX(-100%);
}
.main-content {
transition: margin-left 0.3s ease-in-out;
}
.main-content.expanded {
margin-left: 0;
}
.nav-item {
transition: all 0.2s ease;
}
.nav-item:hover {
background-color: rgba(59, 130, 246, 0.1);
transform: translateX(4px);
}
.nav-item.active {
background-color: #3b82f6;
color: white;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-overlay.active {
display: flex;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.input-field {
border: 1px solid #d1d5db;
background: white;
transition: all 0.2s ease;
}
.input-field:focus {
border-color: #3b82f6;
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
z-index: 1000;
height: 100vh;
}
.main-content {
margin-left: 0 !important;
}
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: none;
}
.mobile-overlay.active {
display: block;
}
}
</style>
</head>
<body>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="loading-overlay">
<div class="bg-white rounded-xl p-8 text-center">
<div class="mb-4">
<i class="fas fa-spinner fa-spin text-5xl text-blue-500"></i>
</div>
<p class="text-gray-700 font-medium text-lg">처리 중입니다...</p>
<p class="text-gray-500 text-sm mt-2">잠시만 기다려주세요</p>
</div>
</div>
<!-- 모바일 오버레이 -->
<div id="mobileOverlay" class="mobile-overlay" onclick="toggleSidebar()"></div>
<!-- 사이드바 -->
<aside id="sidebar" class="sidebar fixed left-0 top-0 h-full w-64 bg-white shadow-lg z-50">
<!-- 헤더 -->
<div class="p-6 border-b">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-clipboard-check text-2xl text-blue-500 mr-3"></i>
<h1 class="text-xl font-bold text-gray-800">작업보고서</h1>
</div>
<button onclick="toggleSidebar()" class="md:hidden text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 사용자 정보 -->
<div class="p-4 border-b bg-gray-50">
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold">
<span id="userInitial">U</span>
</div>
<div class="ml-3">
<p class="font-medium text-gray-800" id="userDisplayName">사용자</p>
<p class="text-sm text-gray-500" id="userRole">user</p>
</div>
</div>
</div>
<!-- 네비게이션 메뉴 -->
<nav class="p-4">
<ul id="navigationMenu" class="space-y-2">
<!-- 메뉴 항목들이 동적으로 생성됩니다 -->
</ul>
</nav>
<!-- 하단 메뉴 -->
<div class="absolute bottom-0 left-0 right-0 p-4 border-t bg-gray-50">
<button onclick="CommonHeader.showPasswordModal()" class="w-full text-left p-2 rounded-lg hover:bg-gray-100 transition-colors">
<i class="fas fa-key mr-3 text-gray-500"></i>
<span class="text-gray-700">비밀번호 변경</span>
</button>
<button onclick="logout()" class="w-full text-left p-2 rounded-lg hover:bg-red-50 text-red-600 transition-colors mt-2">
<i class="fas fa-sign-out-alt mr-3"></i>
<span>로그아웃</span>
</button>
</div>
</aside>
<!-- 메인 콘텐츠 -->
<main id="mainContent" class="main-content ml-64 min-h-screen">
<!-- 상단 바 -->
<header class="bg-white shadow-sm border-b p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<button onclick="toggleSidebar()" class="md:hidden mr-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-bars"></i>
</button>
<h2 id="pageTitle" class="text-xl font-semibold text-gray-800">대시보드</h2>
</div>
<div class="flex items-center space-x-4">
<button class="p-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100">
<i class="fas fa-bell"></i>
</button>
<button class="p-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100">
<i class="fas fa-cog"></i>
</button>
</div>
</div>
</header>
<!-- 페이지 콘텐츠 -->
<div id="pageContent" class="p-6">
<!-- 기본 대시보드 -->
<div id="dashboard" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- 통계 카드들 -->
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">총 부적합 사항</p>
<p class="text-2xl font-bold text-gray-800" id="totalIssues">0</p>
</div>
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">진행 중인 프로젝트</p>
<p class="text-2xl font-bold text-gray-800" id="activeProjects">0</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-folder-open text-blue-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">이번 달 공수</p>
<p class="text-2xl font-bold text-gray-800" id="monthlyHours">0</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-clock text-green-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">완료율</p>
<p class="text-2xl font-bold text-gray-800" id="completionRate">0%</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-chart-pie text-purple-500"></i>
</div>
</div>
</div>
</div>
<!-- 최근 활동 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">최근 부적합 사항</h3>
<div id="recentIssues" class="space-y-3">
<!-- 최근 부적합 사항 목록 -->
</div>
</div>
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">프로젝트 현황</h3>
<div id="projectStatus" class="space-y-3">
<!-- 프로젝트 현황 -->
</div>
</div>
</div>
</div>
<!-- 동적 콘텐츠 영역 -->
<div id="dynamicContent" class="hidden">
<!-- 각 모듈의 콘텐츠가 여기에 로드됩니다 -->
</div>
</div>
</main>
<!-- Scripts -->
<script src="/static/js/utils/date-utils.js"></script>
<script src="/static/js/utils/image-utils.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 데이터 확인</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
pre {
background: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
.info {
background: #e3f2fd;
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
button {
background: #3b82f6;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
margin: 5px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>프로젝트 데이터 확인</h1>
<div class="info">
<p><strong>화면 크기:</strong> <span id="screenSize"></span></p>
<p><strong>User Agent:</strong> <span id="userAgent"></span></p>
<p><strong>현재 시간:</strong> <span id="currentTime"></span></p>
</div>
<h2>localStorage 데이터</h2>
<pre id="localStorageData"></pre>
<h2>프로젝트 목록</h2>
<div id="projectList"></div>
<h2>액션</h2>
<button onclick="createDefaultProjects()">기본 프로젝트 생성</button>
<button onclick="clearProjects()">프로젝트 초기화</button>
<button onclick="location.reload()">새로고침</button>
<button onclick="location.href='index.html'">메인으로</button>
<script>
// 화면 정보 표시
document.getElementById('screenSize').textContent = `${window.innerWidth} x ${window.innerHeight}`;
document.getElementById('userAgent').textContent = navigator.userAgent;
document.getElementById('currentTime').textContent = new Date().toLocaleString('ko-KR');
// localStorage 데이터 표시
function showLocalStorageData() {
const data = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
try {
data[key] = JSON.parse(localStorage.getItem(key));
} catch (e) {
data[key] = localStorage.getItem(key);
}
}
document.getElementById('localStorageData').textContent = JSON.stringify(data, null, 2);
}
// 프로젝트 목록 표시
function showProjects() {
const saved = localStorage.getItem('work-report-projects');
const projectListDiv = document.getElementById('projectList');
if (saved) {
try {
const projects = JSON.parse(saved);
let html = `<p>총 ${projects.length}개의 프로젝트</p><ul>`;
projects.forEach(p => {
html += `<li>${p.jobNo} - ${p.projectName} (${p.isActive ? '활성' : '비활성'})</li>`;
});
html += '</ul>';
projectListDiv.innerHTML = html;
} catch (e) {
projectListDiv.innerHTML = '<p style="color: red;">프로젝트 데이터 파싱 에러: ' + e.message + '</p>';
}
} else {
projectListDiv.innerHTML = '<p style="color: orange;">프로젝트 데이터가 없습니다.</p>';
}
}
// 기본 프로젝트 생성
function createDefaultProjects() {
alert('프로젝트 관리 페이지에서 프로젝트를 생성하세요.');
location.href = 'project-management.html';
}
// 프로젝트 초기화
function clearProjects() {
if (confirm('정말로 모든 프로젝트를 삭제하시겠습니까?')) {
localStorage.removeItem('work-report-projects');
alert('프로젝트가 초기화되었습니다.');
location.reload();
}
}
// 초기 로드
showLocalStorageData();
showProjects();
</script>
</body>
</html>

View File

@@ -0,0 +1,661 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일 공수 입력</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #10b981;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-success {
background-color: var(--success);
color: white;
transition: all 0.2s;
}
.btn-success:hover {
background-color: #059669;
transform: translateY(-1px);
}
.input-field {
border: 1px solid var(--gray-300);
background: white;
transition: all 0.2s;
}
.input-field:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.work-card {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.work-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.summary-card {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
}
.nav-link {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
color: #4b5563;
transition: all 0.2s;
white-space: nowrap;
}
.nav-link:hover {
background-color: #f3f4f6;
color: #1f2937;
}
.nav-link.active {
background-color: #3b82f6;
color: white;
}
/* 부드러운 페이드인 애니메이션 */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 헤더 전용 빠른 페이드인 */
.header-fade-in {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.header-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 본문 컨텐츠 지연 페이드인 */
.content-fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
transition-delay: 0.2s;
}
.content-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<div class="min-h-screen bg-gray-50">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-6 max-w-2xl content-fade-in" style="padding-top: 80px;">
<!-- 입력 카드 -->
<div class="work-card p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-6">
<i class="fas fa-edit text-blue-500 mr-2"></i>공수 입력
</h2>
<form id="dailyWorkForm" class="space-y-6">
<!-- 날짜 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-calendar mr-1"></i>날짜
</label>
<input
type="date"
id="workDate"
class="input-field w-full px-4 py-3 rounded-lg text-lg"
required
onchange="loadExistingData()"
>
</div>
<!-- 프로젝트별 시간 입력 섹션 -->
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700">
<i class="fas fa-folder-open mr-1"></i>프로젝트별 작업 시간
</label>
<button
type="button"
id="addProjectBtn"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onclick="addProjectEntry()"
>
<i class="fas fa-plus mr-2"></i>프로젝트 추가
</button>
</div>
<div id="projectEntries" class="space-y-3">
<!-- 프로젝트 입력 항목들이 여기에 추가됩니다 -->
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<div class="flex justify-between items-center">
<span class="text-sm font-medium text-blue-900">총 작업 시간:</span>
<span id="totalHours" class="text-lg font-bold text-blue-600">0시간</span>
</div>
</div>
</div>
<!-- 총 공수 표시 -->
<div class="bg-blue-50 rounded-lg p-4">
<div class="flex justify-between items-center">
<span class="text-gray-700 font-medium">예상 총 공수</span>
<span class="text-2xl font-bold text-blue-600" id="totalHours">0시간</span>
</div>
</div>
<!-- 저장 버튼 -->
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
<i class="fas fa-save mr-2"></i>저장하기
</button>
</form>
</div>
<!-- 최근 입력 내역 -->
<div class="work-card p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-history text-gray-500 mr-2"></i>최근 입력 내역
</h3>
<div id="recentEntries" class="space-y-3">
<!-- 최근 입력 내역이 여기에 표시됩니다 -->
</div>
</div>
</main>
</div>
<script>
const cacheBuster = Date.now() + Math.random() + Math.floor(Math.random() * 1000000);
const script = document.createElement('script');
script.src = `/static/js/api.js?cb=${cacheBuster}&t=${Date.now()}&r=${Math.random()}`;
script.setAttribute('cache-control', 'no-cache');
script.setAttribute('pragma', 'no-cache');
script.onload = function() {
console.log('✅ API 스크립트 로드 완료');
initializeDailyWork();
};
document.head.appendChild(script);
</script>
<script src="/static/js/date-utils.js?v=20250917"></script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script>
let currentUser = null;
let projects = [];
let dailyWorkData = [];
let projectEntryCounter = 0;
// 애니메이션 함수들
function animateHeaderAppearance() {
console.log('🎨 헤더 애니메이션 시작');
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
if (headerElement) {
headerElement.classList.add('header-fade-in');
setTimeout(() => {
headerElement.classList.add('visible');
console.log('✨ 헤더 페이드인 완료');
// 헤더 애니메이션 완료 후 본문 애니메이션
setTimeout(() => {
animateContentAppearance();
}, 200);
}, 50);
} else {
// 헤더를 찾지 못했으면 바로 본문 애니메이션
console.log('⚠️ 헤더 요소를 찾지 못함 - 본문 애니메이션 시작');
animateContentAppearance();
}
}
// 본문 컨텐츠 애니메이션
function animateContentAppearance() {
console.log('🎨 본문 컨텐츠 애니메이션 시작');
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
const contentElements = document.querySelectorAll('.content-fade-in');
contentElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add('visible');
console.log(`✨ 컨텐츠 ${index + 1} 페이드인 완료`);
}, index * 100); // 100ms씩 지연
});
}
// API 로드 후 초기화 함수
async function initializeDailyWork() {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/index.html';
return;
}
try {
const user = await AuthAPI.getCurrentUser();
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
await window.commonHeader.init(user, 'daily_work');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 페이지 접근 권한 체크 (일일 공수 페이지)
setTimeout(() => {
if (!canAccessPage('daily_work')) {
alert('일일 공수 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}, 500);
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
return;
}
// 사용자 정보는 공통 헤더에서 표시됨
// 네비게이션은 공통 헤더에서 처리됨
// 프로젝트 및 일일 공수 데이터 로드
await loadProjects();
loadDailyWorkData();
// 오늘 날짜로 초기화
document.getElementById('workDate').valueAsDate = new Date();
// 첫 번째 프로젝트 입력 항목 추가
addProjectEntry();
// 최근 내역 로드
await loadRecentEntries();
}
// DOM 로드 완료 시 대기 (API 스크립트가 로드되면 initializeDailyWork 호출됨)
window.addEventListener('DOMContentLoaded', () => {
console.log('📄 DOM 로드 완료 - API 스크립트 로딩 대기 중...');
});
// 네비게이션은 공통 헤더에서 처리됨
// 프로젝트 데이터 로드
async function loadProjects() {
try {
// API에서 최신 프로젝트 데이터 가져오기
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
projects = await response.json();
console.log('프로젝트 로드 완료:', projects.length, '개');
console.log('활성 프로젝트:', projects.filter(p => p.is_active).length, '개');
// localStorage에도 캐시 저장
localStorage.setItem('work-report-projects', JSON.stringify(projects));
} else {
console.error('프로젝트 로드 실패:', response.status);
// 실패 시 localStorage에서 로드
const saved = localStorage.getItem('work-report-projects');
if (saved) {
projects = JSON.parse(saved);
console.log('캐시에서 프로젝트 로드:', projects.length, '개');
}
}
} catch (error) {
console.error('프로젝트 로드 오류:', error);
// 오류 시 localStorage에서 로드
const saved = localStorage.getItem('work-report-projects');
if (saved) {
projects = JSON.parse(saved);
console.log('캐시에서 프로젝트 로드:', projects.length, '개');
}
}
}
// 일일 공수 데이터 로드
function loadDailyWorkData() {
const saved = localStorage.getItem('daily-work-data');
if (saved) {
dailyWorkData = JSON.parse(saved);
}
}
// 일일 공수 데이터 저장
function saveDailyWorkData() {
localStorage.setItem('daily-work-data', JSON.stringify(dailyWorkData));
}
// 활성 프로젝트만 필터링
function getActiveProjects() {
return projects.filter(p => p.is_active);
}
// 프로젝트 입력 항목 추가
function addProjectEntry() {
const activeProjects = getActiveProjects();
if (activeProjects.length === 0) {
alert('활성 프로젝트가 없습니다. 먼저 프로젝트를 생성해주세요.');
return;
}
projectEntryCounter++;
const entryId = `project-entry-${projectEntryCounter}`;
const entryHtml = `
<div id="${entryId}" class="flex gap-3 items-center p-4 bg-gray-50 rounded-lg">
<div class="flex-1">
<select class="input-field w-full px-3 py-2 rounded-lg" onchange="updateTotalHours()">
<option value="">프로젝트 선택</option>
${activeProjects.map(p => `<option value="${p.id}">${p.jobNo} - ${p.projectName}</option>`).join('')}
</select>
</div>
<div class="w-32">
<input
type="number"
placeholder="시간"
min="0"
step="0.5"
class="input-field w-full px-3 py-2 rounded-lg text-center"
onchange="updateTotalHours()"
>
</div>
<div class="w-16 text-center text-gray-600">시간</div>
<button
type="button"
onclick="removeProjectEntry('${entryId}')"
class="text-red-500 hover:text-red-700 p-2"
title="제거"
>
<i class="fas fa-times"></i>
</button>
</div>
`;
document.getElementById('projectEntries').insertAdjacentHTML('beforeend', entryHtml);
updateTotalHours();
}
// 프로젝트 입력 항목 제거
function removeProjectEntry(entryId) {
const entry = document.getElementById(entryId);
if (entry) {
entry.remove();
updateTotalHours();
}
}
// 총 시간 계산 및 업데이트
function updateTotalHours() {
const entries = document.querySelectorAll('#projectEntries > div');
let totalHours = 0;
entries.forEach(entry => {
const hoursInput = entry.querySelector('input[type="number"]');
const hours = parseFloat(hoursInput.value) || 0;
totalHours += hours;
});
document.getElementById('totalHours').textContent = `${totalHours}시간`;
}
// 기존 데이터 로드 (날짜 선택 시)
function loadExistingData() {
const selectedDate = document.getElementById('workDate').value;
if (!selectedDate) return;
const existingData = dailyWorkData.find(d => d.date === selectedDate);
if (existingData) {
// 기존 프로젝트 입력 항목들 제거
document.getElementById('projectEntries').innerHTML = '';
projectEntryCounter = 0;
// 기존 데이터로 프로젝트 입력 항목들 생성
existingData.projects.forEach(projectData => {
addProjectEntry();
const lastEntry = document.querySelector('#projectEntries > div:last-child');
const select = lastEntry.querySelector('select');
const input = lastEntry.querySelector('input[type="number"]');
select.value = projectData.projectId;
input.value = projectData.hours;
});
updateTotalHours();
} else {
// 새로운 날짜인 경우 초기화
document.getElementById('projectEntries').innerHTML = '';
projectEntryCounter = 0;
addProjectEntry();
}
}
// 폼 제출
document.getElementById('dailyWorkForm').addEventListener('submit', async (e) => {
e.preventDefault();
const selectedDate = document.getElementById('workDate').value;
const entries = document.querySelectorAll('#projectEntries > div');
const projectData = [];
let hasValidEntry = false;
entries.forEach(entry => {
const select = entry.querySelector('select');
const input = entry.querySelector('input[type="number"]');
const projectId = select.value;
const hours = parseFloat(input.value) || 0;
if (projectId && hours > 0) {
const project = projects.find(p => p.id == projectId);
projectData.push({
projectId: projectId,
projectName: project ? `${project.jobNo} - ${project.projectName}` : '알 수 없음',
hours: hours
});
hasValidEntry = true;
}
});
if (!hasValidEntry) {
alert('최소 하나의 프로젝트에 시간을 입력해주세요.');
return;
}
try {
// 기존 데이터 업데이트 또는 새로 추가
const existingIndex = dailyWorkData.findIndex(d => d.date === selectedDate);
const newData = {
date: selectedDate,
projects: projectData,
totalHours: projectData.reduce((sum, p) => sum + p.hours, 0),
createdAt: new Date().toISOString(),
createdBy: currentUser.username || currentUser
};
if (existingIndex >= 0) {
dailyWorkData[existingIndex] = newData;
} else {
dailyWorkData.push(newData);
}
saveDailyWorkData();
// 성공 메시지
showSuccessMessage();
// 최근 내역 갱신
await loadRecentEntries();
} catch (error) {
alert(error.message || '저장에 실패했습니다.');
}
});
// 최근 데이터 로드
async function loadRecentEntries() {
try {
// 최근 7일 데이터 표시
const recentData = dailyWorkData
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 7);
displayRecentEntries(recentData);
} catch (error) {
console.error('데이터 로드 실패:', error);
document.getElementById('recentEntries').innerHTML =
'<p class="text-gray-500 text-center py-4">데이터를 불러올 수 없습니다.</p>';
}
}
// 성공 메시지
function showSuccessMessage() {
const button = document.querySelector('button[type="submit"]');
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-check-circle mr-2"></i>저장 완료!';
button.classList.remove('btn-primary');
button.classList.add('btn-success');
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove('btn-success');
button.classList.add('btn-primary');
}, 2000);
}
// 최근 입력 내역 표시
function displayRecentEntries(entries) {
const container = document.getElementById('recentEntries');
if (!entries || entries.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-4">최근 입력 내역이 없습니다.</p>';
return;
}
container.innerHTML = entries.map(item => {
const date = new Date(item.date);
const dateStr = `${date.getMonth() + 1}/${date.getDate()} (${['일','월','화','수','목','금','토'][date.getDay()]})`;
return `
<div class="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="flex justify-between items-start mb-2">
<p class="font-medium text-gray-800">${dateStr}</p>
<div class="text-right">
<p class="text-lg font-bold text-blue-600">${item.totalHours}시간</p>
${currentUser && (currentUser.role === 'admin' || currentUser.username === 'hyungi') ? `
<button
onclick="deleteDailyWork('${item.date}')"
class="mt-1 px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-xs"
>
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
</div>
<div class="space-y-1">
${item.projects.map(p => `
<div class="flex justify-between text-sm text-gray-600">
<span>${p.projectName}</span>
<span>${p.hours}시간</span>
</div>
`).join('')}
</div>
</div>
`;
}).join('');
}
// 일일 공수 삭제 (관리자만)
async function deleteDailyWork(date) {
if (!currentUser || (currentUser.role !== 'admin' && currentUser.username !== 'hyungi')) {
alert('관리자만 삭제할 수 있습니다.');
return;
}
if (!confirm('정말로 이 일일 공수 기록을 삭제하시겠습니까?')) {
return;
}
try {
const index = dailyWorkData.findIndex(d => d.date === date);
if (index >= 0) {
dailyWorkData.splice(index, 1);
saveDailyWorkData();
await loadRecentEntries();
alert('삭제되었습니다.');
}
} catch (error) {
alert('삭제에 실패했습니다.');
}
}
// 로그아웃
function logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,607 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>폐기함 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 모바일 캘린더 스타일 -->
<link rel="stylesheet" href="/static/css/mobile-calendar.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.issue-card {
transition: all 0.2s ease;
opacity: 0.8;
}
.issue-card:hover {
opacity: 1;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.archived-card {
border-left: 4px solid #6b7280;
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
}
.completed-card {
border-left: 4px solid #10b981;
background: linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%);
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-completed { background: #d1fae5; color: #065f46; }
.badge-archived { background: #f3f4f6; color: #374151; }
.badge-cancelled { background: #fee2e2; color: #991b1b; }
.chart-container {
position: relative;
height: 300px;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-archive text-gray-500 mr-3"></i>
폐기함
</h1>
<p class="text-gray-600 mt-1">완료되거나 폐기된 부적합 사항을 보관하고 분석하세요</p>
</div>
<div class="flex items-center space-x-3">
<button onclick="generateReport()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-chart-bar mr-2"></i>
통계 보고서
</button>
<button onclick="cleanupArchive()" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-2"></i>
정리하기
</button>
</div>
</div>
<!-- 아카이브 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-green-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 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>
</div>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-archive text-gray-500 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>
</div>
</div>
</div>
<div class="bg-red-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-times-circle text-red-500 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>
</div>
</div>
</div>
<div class="bg-purple-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-calendar-alt text-purple-500 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>
</div>
</div>
</div>
</div>
</div>
<!-- 필터 및 검색 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📁 프로젝트</label>
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📋 상태</label>
<select id="statusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체</option>
<option value="completed">완료</option>
<option value="archived">보관</option>
<option value="cancelled">취소</option>
</select>
</div>
<!-- 기간 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📅 기간</label>
<select id="periodFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 기간</option>
<option value="week">이번 주</option>
<option value="month">이번 달</option>
<option value="quarter">이번 분기</option>
<option value="year">올해</option>
</select>
</div>
<!-- 카테고리 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">🏷️ 카테고리</label>
<select id="categoryFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 카테고리</option>
<option value="material_missing">자재 누락</option>
<option value="design_error">설계 오류</option>
<option value="incoming_defect">반입 불량</option>
<option value="inspection_miss">검사 누락</option>
<option value="etc">기타</option>
</select>
</div>
<!-- 검색 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">🔍 검색</label>
<input type="text" id="searchInput" placeholder="설명 또는 등록자 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500"
onkeyup="filterIssues()">
</div>
</div>
</div>
<!-- 통계 차트 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- 월별 완료 현황 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">월별 완료 현황</h3>
<div class="chart-container">
<canvas id="monthlyChart"></canvas>
</div>
</div>
<!-- 카테고리별 분포 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">카테고리별 분포</h3>
<div class="chart-container">
<canvas id="categoryChart"></canvas>
</div>
</div>
</div>
<!-- 폐기함 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800">보관된 부적합</h2>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">정렬:</span>
<select id="sortOrder" class="text-sm border border-gray-300 rounded px-2 py-1" onchange="sortIssues()">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
<option value="completed">완료일순</option>
<option value="category">카테고리순</option>
</select>
</div>
</div>
</div>
<div id="issuesList" class="divide-y divide-gray-200">
<!-- 부적합 목록이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 빈 상태 -->
<div id="emptyState" class="hidden p-12 text-center">
<i class="fas fa-archive text-6xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">폐기함이 비어있습니다</h3>
<p class="text-gray-500">완료되거나 폐기된 부적합이 있으면 여기에 표시됩니다.</p>
</div>
</div>
</main>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=20250917"></script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script>
let currentUser = null;
let issues = [];
let projects = [];
let filteredIssues = [];
// API 로드 후 초기화 함수
async function initializeArchive() {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/index.html';
return;
}
try {
const user = await AuthAPI.getCurrentUser();
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
await window.commonHeader.init(user, 'issues_archive');
// 페이지 접근 권한 체크
setTimeout(() => {
if (!canAccessPage('issues_archive')) {
alert('폐기함 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}, 500);
// 데이터 로드
await loadProjects();
await loadArchivedIssues();
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
}
}
// 프로젝트 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
projects = await response.json();
updateProjectFilter();
}
} catch (error) {
console.error('프로젝트 로드 실패:', error);
}
}
// 보관된 부적합 로드
async function loadArchivedIssues() {
try {
let endpoint = '/api/issues/';
// 관리자인 경우 전체 부적합 조회 API 사용
if (currentUser.role === 'admin') {
endpoint = '/api/issues/admin/all';
}
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const allIssues = await response.json();
// 폐기된 부적합만 필터링 (폐기함 전용)
issues = allIssues.filter(issue =>
issue.review_status === 'disposed'
);
filterIssues();
updateStatistics();
renderCharts();
} else {
throw new Error('부적합 목록을 불러올 수 없습니다.');
}
} catch (error) {
console.error('부적합 로드 실패:', error);
alert('부적합 목록을 불러오는데 실패했습니다.');
}
}
// 필터링 및 표시
function filterIssues() {
const projectFilter = document.getElementById('projectFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
const periodFilter = document.getElementById('periodFilter').value;
const categoryFilter = document.getElementById('categoryFilter').value;
const searchInput = document.getElementById('searchInput').value.toLowerCase();
filteredIssues = issues.filter(issue => {
if (projectFilter && issue.project_id != projectFilter) return false;
if (statusFilter && issue.status !== statusFilter) return false;
if (categoryFilter && issue.category !== categoryFilter) return false;
// 기간 필터
if (periodFilter) {
const issueDate = new Date(issue.updated_at || issue.created_at);
const now = new Date();
switch (periodFilter) {
case 'week':
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
if (issueDate < weekAgo) return false;
break;
case 'month':
const monthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
if (issueDate < monthAgo) return false;
break;
case 'quarter':
const quarterAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
if (issueDate < quarterAgo) return false;
break;
case 'year':
const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
if (issueDate < yearAgo) return false;
break;
}
}
if (searchInput) {
const searchText = `${issue.description} ${issue.reporter?.username || ''}`.toLowerCase();
if (!searchText.includes(searchInput)) return false;
}
return true;
});
sortIssues();
displayIssues();
}
function sortIssues() {
const sortOrder = document.getElementById('sortOrder').value;
filteredIssues.sort((a, b) => {
switch (sortOrder) {
case 'newest':
return new Date(b.report_date) - new Date(a.report_date);
case 'oldest':
return new Date(a.report_date) - new Date(b.report_date);
case 'completed':
return new Date(b.disposed_at || b.report_date) - new Date(a.disposed_at || a.report_date);
case 'category':
return (a.category || '').localeCompare(b.category || '');
default:
return new Date(b.report_date) - new Date(a.report_date);
}
});
}
function displayIssues() {
const container = document.getElementById('issuesList');
const emptyState = document.getElementById('emptyState');
if (filteredIssues.length === 0) {
container.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
container.innerHTML = filteredIssues.map(issue => {
const project = projects.find(p => p.id === issue.project_id);
// 폐기함은 폐기된 것만 표시
const completedDate = issue.disposed_at ? new Date(issue.disposed_at).toLocaleDateString('ko-KR') : 'Invalid Date';
const statusText = '폐기';
const cardClass = 'archived-card';
return `
<div class="issue-card p-6 ${cardClass} cursor-pointer"
onclick="viewArchivedIssue(${issue.id})">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
<span class="text-sm text-gray-400">${completedDate}</span>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">${issue.description}</h3>
<div class="flex items-center text-sm text-gray-500 space-x-4">
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
<span><i class="fas fa-clock mr-1"></i>${statusText}: ${completedDate}</span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<i class="fas fa-${getStatusIcon(issue.status)} text-2xl ${getStatusColor(issue.status)}"></i>
</div>
</div>
</div>
`;
}).join('');
}
// 통계 업데이트
function updateStatistics() {
const completed = issues.filter(issue => issue.status === 'completed').length;
const archived = issues.filter(issue => issue.status === 'archived').length;
const cancelled = issues.filter(issue => issue.status === 'cancelled').length;
const thisMonth = issues.filter(issue => {
const issueDate = new Date(issue.updated_at || issue.created_at);
const now = new Date();
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
}).length;
document.getElementById('completedCount').textContent = completed;
document.getElementById('archivedCount').textContent = archived;
document.getElementById('cancelledCount').textContent = cancelled;
document.getElementById('thisMonthCount').textContent = thisMonth;
}
// 차트 렌더링 (간단한 텍스트 기반)
function renderCharts() {
renderMonthlyChart();
renderCategoryChart();
}
function renderMonthlyChart() {
const canvas = document.getElementById('monthlyChart');
const ctx = canvas.getContext('2d');
// 간단한 차트 대신 텍스트로 표시
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = '#374151';
ctx.font = '16px Inter';
ctx.textAlign = 'center';
ctx.fillText('월별 완료 현황 차트', canvas.width / 2, canvas.height / 2);
ctx.font = '12px Inter';
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
}
function renderCategoryChart() {
const canvas = document.getElementById('categoryChart');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = '#374151';
ctx.font = '16px Inter';
ctx.textAlign = 'center';
ctx.fillText('카테고리별 분포 차트', canvas.width / 2, canvas.height / 2);
ctx.font = '12px Inter';
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
}
// 기타 함수들
function generateReport() {
alert('통계 보고서를 생성합니다.');
}
function cleanupArchive() {
if (confirm('오래된 보관 데이터를 정리하시겠습니까?')) {
alert('데이터 정리가 완료되었습니다.');
}
}
function viewArchivedIssue(issueId) {
window.location.href = `/issue-view.html#detail-${issueId}`;
}
// 유틸리티 함수들
function updateProjectFilter() {
const projectFilter = document.getElementById('projectFilter');
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name;
projectFilter.appendChild(option);
});
}
function getStatusBadgeClass(status) {
const statusMap = {
'completed': 'completed',
'archived': 'archived',
'cancelled': 'cancelled'
};
return statusMap[status] || 'archived';
}
function getStatusText(status) {
const statusMap = {
'completed': '완료',
'archived': '보관',
'cancelled': '취소'
};
return statusMap[status] || status;
}
function getStatusIcon(status) {
const iconMap = {
'completed': 'check-circle',
'archived': 'archive',
'cancelled': 'times-circle'
};
return iconMap[status] || 'archive';
}
function getStatusColor(status) {
const colorMap = {
'completed': 'text-green-500',
'archived': 'text-gray-500',
'cancelled': 'text-red-500'
};
return colorMap[status] || 'text-gray-500';
}
function getCategoryText(category) {
const categoryMap = {
'material_missing': '자재 누락',
'design_error': '설계 오류',
'incoming_defect': '반입 불량',
'inspection_miss': '검사 누락',
'etc': '기타'
};
return categoryMap[category] || category;
}
// API 스크립트 동적 로딩
const cacheBuster = Date.now() + Math.random() + Math.floor(Math.random() * 1000000);
const script = document.createElement('script');
script.src = `/static/js/api.js?v=20251025-2&cb=${cacheBuster}&t=${Date.now()}&r=${Math.random()}`;
script.setAttribute('cache-control', 'no-cache');
script.setAttribute('pragma', 'no-cache');
script.onload = function() {
console.log('✅ API 스크립트 로드 완료 (issues-archive.html)');
initializeArchive();
};
script.onerror = function() {
console.error('❌ API 스크립트 로드 실패');
};
document.head.appendChild(script);
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>모바일 프로젝트 문제 해결</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 20px;
margin: 0;
background: #f5f5f5;
}
.container {
max-width: 100%;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
font-size: 24px;
margin-bottom: 20px;
}
.section {
margin-bottom: 30px;
padding: 15px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
font-size: 14px;
}
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #d1ecf1; color: #0c5460; }
.warning { background: #fff3cd; color: #856404; }
button {
background: #3b82f6;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
margin: 5px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
button:active {
background: #2563eb;
}
select {
width: 100%;
padding: 12px;
font-size: 16px;
border: 2px solid #3b82f6;
border-radius: 8px;
background: white;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 20px;
}
pre {
background: #f4f4f4;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
font-size: 12px;
}
.test-select {
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🔧 모바일 프로젝트 문제 해결</h1>
<div class="section">
<h2>📱 디바이스 정보</h2>
<div id="deviceInfo"></div>
</div>
<div class="section">
<h2>💾 localStorage 상태</h2>
<div id="storageStatus"></div>
<button onclick="checkStorage()">localStorage 확인</button>
<button onclick="fixProjects()">프로젝트 복구</button>
</div>
<div class="section">
<h2>🧪 드롭다운 테스트</h2>
<div class="test-select">
<label>테스트 드롭다운:</label>
<select id="testSelect">
<option value="">선택하세요</option>
</select>
</div>
<button onclick="testDropdown()">드롭다운 테스트</button>
</div>
<div class="section">
<h2>📊 실제 프로젝트 드롭다운</h2>
<div class="test-select">
<label>프로젝트 선택:</label>
<select id="projectSelect">
<option value="">프로젝트를 선택하세요</option>
</select>
</div>
<button onclick="loadProjects()">프로젝트 로드</button>
</div>
<div class="section">
<h2>🔍 디버그 로그</h2>
<pre id="debugLog"></pre>
<button onclick="clearLog()">로그 지우기</button>
</div>
<div style="margin-top: 30px;">
<button onclick="location.href='index.html'">메인으로</button>
<button onclick="location.reload()">새로고침</button>
</div>
</div>
<script>
let logContent = '';
function log(message) {
const time = new Date().toLocaleTimeString('ko-KR');
logContent += `[${time}] ${message}\n`;
document.getElementById('debugLog').textContent = logContent;
}
function clearLog() {
logContent = '';
document.getElementById('debugLog').textContent = '';
}
// 디바이스 정보
function showDeviceInfo() {
const info = `
<div class="status info">
<strong>화면 크기:</strong> ${window.innerWidth} x ${window.innerHeight}<br>
<strong>User Agent:</strong> ${navigator.userAgent}<br>
<strong>플랫폼:</strong> ${navigator.platform}<br>
<strong>모바일 여부:</strong> ${window.innerWidth <= 768 ? '예' : '아니오'}
</div>
`;
document.getElementById('deviceInfo').innerHTML = info;
log('디바이스 정보 표시 완료');
}
// localStorage 확인
function checkStorage() {
log('localStorage 확인 시작');
const statusDiv = document.getElementById('storageStatus');
try {
// 프로젝트 데이터 확인
const projectData = localStorage.getItem('work-report-projects');
if (projectData) {
const projects = JSON.parse(projectData);
statusDiv.innerHTML = `
<div class="status success">
✅ 프로젝트 데이터 있음: ${projects.length}
</div>
<pre>${JSON.stringify(projects, null, 2)}</pre>
`;
log(`프로젝트 ${projects.length}개 발견`);
} else {
statusDiv.innerHTML = `
<div class="status warning">
⚠️ 프로젝트 데이터 없음
</div>
`;
log('프로젝트 데이터 없음');
}
// 사용자 데이터 확인
const userData = localStorage.getItem('currentUser');
if (userData) {
const user = JSON.parse(userData);
statusDiv.innerHTML += `
<div class="status success">
✅ 사용자: ${user.username} (${user.role})
</div>
`;
log(`사용자: ${user.username}`);
}
} catch (error) {
statusDiv.innerHTML = `
<div class="status error">
❌ 에러: ${error.message}
</div>
`;
log(`에러: ${error.message}`);
}
}
// 프로젝트 복구
function fixProjects() {
log('프로젝트 복구 시작');
const projects = [
{
id: 1,
jobNo: 'TKR-25009R',
projectName: 'M Project',
isActive: true,
createdAt: new Date().toISOString(),
createdByName: '관리자'
},
{
id: 2,
jobNo: 'TKG-24011P',
projectName: 'TKG Project',
isActive: true,
createdAt: new Date().toISOString(),
createdByName: '관리자'
}
];
try {
localStorage.setItem('work-report-projects', JSON.stringify(projects));
log('프로젝트 데이터 저장 완료');
alert('프로젝트가 복구되었습니다!');
checkStorage();
loadProjects();
} catch (error) {
log(`복구 실패: ${error.message}`);
alert('복구 실패: ' + error.message);
}
}
// 드롭다운 테스트
function testDropdown() {
log('드롭다운 테스트 시작');
const select = document.getElementById('testSelect');
// 옵션 추가
select.innerHTML = '<option value="">선택하세요</option>';
for (let i = 1; i <= 5; i++) {
const option = document.createElement('option');
option.value = i;
option.textContent = `테스트 옵션 ${i}`;
select.appendChild(option);
}
log(`테스트 옵션 ${select.options.length - 1}개 추가됨`);
// 이벤트 리스너
select.onchange = function() {
log(`선택됨: ${this.value} - ${this.options[this.selectedIndex].text}`);
};
}
// 프로젝트 로드
function loadProjects() {
log('프로젝트 로드 시작');
const select = document.getElementById('projectSelect');
try {
const saved = localStorage.getItem('work-report-projects');
if (!saved) {
log('localStorage에 프로젝트 없음');
alert('프로젝트 데이터가 없습니다. "프로젝트 복구" 버튼을 눌러주세요.');
return;
}
const projects = JSON.parse(saved);
const activeProjects = projects.filter(p => p.isActive);
log(`활성 프로젝트 ${activeProjects.length}개 발견`);
// 드롭다운 초기화
select.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
// 프로젝트 옵션 추가
activeProjects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}`;
select.appendChild(option);
log(`옵션 추가: ${project.jobNo} - ${project.projectName}`);
});
log(`드롭다운에 ${select.options.length - 1}개 프로젝트 표시됨`);
// 이벤트 리스너
select.onchange = function() {
log(`프로젝트 선택됨: ${this.value}`);
};
} catch (error) {
log(`프로젝트 로드 에러: ${error.message}`);
alert('프로젝트 로드 실패: ' + error.message);
}
}
// 페이지 로드 시 실행
window.onload = function() {
log('페이지 로드 완료');
showDeviceInfo();
checkStorage();
testDropdown();
loadProjects();
};
</script>
</body>
</html>

View File

@@ -0,0 +1,51 @@
server {
listen 80;
server_name _;
client_max_body_size 10M;
root /usr/share/nginx/html;
index index.html;
# HTML 캐시 비활성화
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# JS/CSS 캐시 비활성화
location ~* \.(js|css)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# 정적 파일 (이미지 등)
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
expires 1h;
add_header Cache-Control "public, no-transform";
}
# API 프록시 (System 3 API)
location /api/ {
proxy_pass http://system3-api:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
# 업로드 파일
location /uploads/ {
alias /usr/share/nginx/html/uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 관리 - 작업보고서 시스템</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.input-field {
border: 1px solid var(--gray-300);
background: white;
transition: all 0.2s;
}
.input-field:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 부드러운 페이드인 애니메이션 */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 헤더 전용 빠른 페이드인 */
.header-fade-in {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.header-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 본문 컨텐츠 지연 페이드인 */
.content-fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
transition-delay: 0.2s;
}
.content-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8 max-w-4xl content-fade-in" style="padding-top: 80px;">
<!-- 프로젝트 생성 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-plus text-green-500 mr-2"></i>새 프로젝트 생성
</h2>
<form id="projectForm" class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Job No.</label>
<input
type="text"
id="jobNo"
class="input-field w-full px-4 py-2 rounded-lg"
placeholder="예: JOB-2024-001"
required
maxlength="50"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">프로젝트 이름</label>
<input
type="text"
id="projectName"
class="input-field w-full px-4 py-2 rounded-lg"
placeholder="프로젝트 이름을 입력하세요"
required
maxlength="200"
>
</div>
<div class="md:col-span-2">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>프로젝트 생성
</button>
</div>
</form>
</div>
<!-- 프로젝트 목록 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">프로젝트 목록</h2>
<button onclick="loadProjects()" class="text-blue-600 hover:text-blue-800">
<i class="fas fa-refresh mr-1"></i>새로고침
</button>
</div>
<div id="projectsList" class="space-y-3">
<!-- 프로젝트 목록이 여기에 표시됩니다 -->
</div>
</div>
<!-- 비활성화된 프로젝트 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6 mt-6">
<div class="flex justify-between items-center mb-4 cursor-pointer" onclick="toggleInactiveProjects()">
<div class="flex items-center space-x-2">
<i id="inactiveToggleIcon" class="fas fa-chevron-down transition-transform duration-200"></i>
<h2 class="text-lg font-semibold text-gray-600">비활성화된 프로젝트</h2>
<span id="inactiveProjectCount" class="bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-sm font-medium">0</span>
</div>
<div class="text-sm text-gray-500">
<i class="fas fa-info-circle mr-1"></i>클릭하여 펼치기/접기
</div>
</div>
<div id="inactiveProjectsList" class="space-y-3">
<!-- 비활성화된 프로젝트 목록이 여기에 표시됩니다 -->
</div>
</div>
</main>
<!-- API 스크립트 먼저 로드 (최강 캐시 무력화) -->
<script>
// 브라우저 캐시 완전 무력화
const timestamp = new Date().getTime();
const random1 = Math.random() * 1000000;
const random2 = Math.floor(Math.random() * 1000000);
const cacheBuster = `${timestamp}-${random1}-${random2}`;
const script = document.createElement('script');
script.src = `/static/js/api.js?force-reload=${cacheBuster}&no-cache=${timestamp}&bust=${random2}`;
script.onload = function() {
console.log('✅ API 스크립트 로드 완료');
console.log('🔍 API_BASE_URL:', typeof API_BASE_URL !== 'undefined' ? API_BASE_URL : 'undefined');
console.log('🌐 현재 hostname:', window.location.hostname);
console.log('🔗 현재 protocol:', window.location.protocol);
// API 로드 후 인증 체크 시작
setTimeout(checkAdminAccess, 100);
};
script.setAttribute('cache-control', 'no-cache, no-store, must-revalidate');
script.setAttribute('pragma', 'no-cache');
script.setAttribute('expires', '0');
document.head.appendChild(script);
console.log('🚀 캐시 버스터:', cacheBuster);
</script>
<!-- 메인 스크립트 -->
<script>
// 관리자 권한 확인 함수
async function checkAdminAccess() {
try {
const currentUser = await AuthAPI.getCurrentUser();
if (!currentUser || currentUser.role !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/index.html';
return;
}
// 권한 확인 후 페이지 초기화
initializeProjectManagement();
} catch (error) {
console.error('권한 확인 실패:', error);
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
}
}
// 프로젝트 관리 페이지 초기화 함수
async function initializeProjectManagement() {
try {
console.log('🚀 프로젝트 관리 페이지 초기화 시작');
// 헤더 애니메이션 시작
animateHeaderAppearance();
// 프로젝트 목록 로드
await loadProjects();
console.log('✅ 프로젝트 관리 페이지 초기화 완료');
} catch (error) {
console.error('❌ 프로젝트 관리 페이지 초기화 실패:', error);
alert('페이지 로드 중 오류가 발생했습니다.');
}
}
</script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script>
// 전역 변수
let currentUser = null;
// 애니메이션 함수들
function animateHeaderAppearance() {
console.log('🎨 헤더 애니메이션 시작');
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
if (headerElement) {
headerElement.classList.add('header-fade-in');
setTimeout(() => {
headerElement.classList.add('visible');
console.log('✨ 헤더 페이드인 완료');
// 헤더 애니메이션 완료 후 본문 애니메이션
setTimeout(() => {
animateContentAppearance();
}, 200);
}, 50);
} else {
// 헤더를 찾지 못했으면 바로 본문 애니메이션
console.log('⚠️ 헤더 요소를 찾지 못함 - 본문 애니메이션 시작');
animateContentAppearance();
}
}
// 본문 컨텐츠 애니메이션
function animateContentAppearance() {
console.log('🎨 본문 컨텐츠 애니메이션 시작');
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
const contentElements = document.querySelectorAll('.content-fade-in');
contentElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add('visible');
console.log(`✨ 컨텐츠 ${index + 1} 페이드인 완료`);
}, index * 100); // 100ms씩 지연
});
}
async function initAuth() {
console.log('인증 초기화 시작');
const token = localStorage.getItem('access_token');
console.log('토큰 존재:', !!token);
if (!token) {
console.log('토큰 없음 - 로그인 페이지로 이동');
alert('로그인이 필요합니다.');
window.location.href = 'index.html';
return false;
}
try {
console.log('API로 사용자 정보 가져오는 중...');
const user = await AuthAPI.getCurrentUser();
console.log('사용자 정보:', user);
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
return true;
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
alert('로그인이 필요합니다.');
window.location.href = 'index.html';
return false;
}
}
async function checkAdminAccess() {
const authSuccess = await initAuth();
if (!authSuccess) return;
// 공통 헤더 초기화
await window.commonHeader.init(currentUser, 'projects_manage');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 페이지 접근 권한 체크 (프로젝트 관리 페이지)
setTimeout(() => {
if (!canAccessPage('projects_manage')) {
alert('프로젝트 관리 페이지에 접근할 권한이 없습니다.');
window.location.href = 'index.html';
return;
}
}, 500);
// 사용자 정보는 공통 헤더에서 표시됨
// 프로젝트 로드
loadProjects();
}
let projects = [];
// 프로젝트 데이터 로드 (API 기반)
async function loadProjects() {
console.log('프로젝트 로드 시작 (API)');
try {
// API에서 모든 프로젝트 로드 (활성/비활성 모두)
const apiProjects = await ProjectsAPI.getAll(false);
// API 데이터를 그대로 사용 (필드명 통일)
projects = apiProjects;
console.log('API에서 프로젝트 로드:', projects.length, '개');
} catch (error) {
console.error('API 로드 실패:', error);
projects = [];
}
displayProjectList();
}
// 프로젝트 데이터 저장 (더 이상 사용하지 않음 - API 기반)
// function saveProjects() {
// localStorage.setItem('work-report-projects', JSON.stringify(projects));
// }
// 프로젝트 생성 폼 처리
document.getElementById('projectForm').addEventListener('submit', async (e) => {
e.preventDefault();
const jobNo = document.getElementById('jobNo').value.trim();
const projectName = document.getElementById('projectName').value.trim();
// 중복 Job No. 확인
if (projects.some(p => p.job_no === jobNo)) {
alert('이미 존재하는 Job No.입니다.');
return;
}
try {
// API를 통한 프로젝트 생성
const newProject = await ProjectsAPI.create({
job_no: jobNo,
project_name: projectName
});
// 성공 메시지
alert('프로젝트가 생성되었습니다.');
// 폼 초기화
document.getElementById('projectForm').reset();
// 목록 새로고침
await loadProjects();
displayProjectList();
} catch (error) {
console.error('프로젝트 생성 실패:', error);
alert('프로젝트 생성에 실패했습니다: ' + error.message);
}
});
// 프로젝트 목록 표시
function displayProjectList() {
const activeContainer = document.getElementById('projectsList');
const inactiveContainer = document.getElementById('inactiveProjectsList');
const inactiveCount = document.getElementById('inactiveProjectCount');
activeContainer.innerHTML = '';
inactiveContainer.innerHTML = '';
if (projects.length === 0) {
activeContainer.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 프로젝트가 없습니다.</p>';
inactiveCount.textContent = '0';
return;
}
// 활성 프로젝트와 비활성 프로젝트 분리
const activeProjects = projects.filter(p => p.is_active);
const inactiveProjects = projects.filter(p => !p.is_active);
console.log('전체 프로젝트:', projects.length, '개');
console.log('활성 프로젝트:', activeProjects.length, '개');
console.log('비활성 프로젝트:', inactiveProjects.length, '개');
// 비활성 프로젝트 개수 업데이트
inactiveCount.textContent = inactiveProjects.length;
// 활성 프로젝트 표시
if (activeProjects.length > 0) {
activeProjects.forEach(project => {
const div = document.createElement('div');
div.className = 'border border-green-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-green-50';
div.innerHTML = `
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-gray-800">${project.job_no}</h3>
<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">활성</span>
</div>
<p class="text-gray-600 mb-2">${project.project_name}</p>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span><i class="fas fa-user mr-1"></i>${project.created_by?.full_name || '관리자'}</span>
<span><i class="fas fa-calendar mr-1"></i>${new Date(project.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="flex gap-2">
<button onclick="editProject(${project.id})" class="text-blue-600 hover:text-blue-800 p-2" title="수정">
<i class="fas fa-edit"></i>
</button>
<button onclick="toggleProjectStatus(${project.id})" class="text-orange-600 hover:text-orange-800 p-2" title="비활성화">
<i class="fas fa-pause"></i>
</button>
<button onclick="deleteProject(${project.id})" class="text-red-600 hover:text-red-800 p-2" title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
activeContainer.appendChild(div);
});
} else {
activeContainer.innerHTML = '<p class="text-gray-500 text-center py-8">활성 프로젝트가 없습니다.</p>';
}
// 비활성 프로젝트 표시
if (inactiveProjects.length > 0) {
inactiveProjects.forEach(project => {
const div = document.createElement('div');
div.className = 'border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-gray-50';
div.innerHTML = `
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-gray-600">${project.job_no}</h3>
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">비활성</span>
</div>
<p class="text-gray-500 mb-2">${project.project_name}</p>
<div class="flex items-center gap-4 text-sm text-gray-400">
<span><i class="fas fa-user mr-1"></i>${project.created_by?.full_name || '관리자'}</span>
<span><i class="fas fa-calendar mr-1"></i>${new Date(project.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="flex gap-2">
<button onclick="toggleProjectStatus(${project.id})" class="text-green-600 hover:text-green-800 p-2" title="활성화">
<i class="fas fa-play"></i>
</button>
<button onclick="deleteProject(${project.id})" class="text-red-600 hover:text-red-800 p-2" title="완전 삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
inactiveContainer.appendChild(div);
});
} else {
inactiveContainer.innerHTML = '<p class="text-gray-500 text-center py-8">비활성화된 프로젝트가 없습니다.</p>';
}
}
// 비활성 프로젝트 섹션 토글
function toggleInactiveProjects() {
const inactiveList = document.getElementById('inactiveProjectsList');
const toggleIcon = document.getElementById('inactiveToggleIcon');
if (inactiveList.style.display === 'none') {
// 펼치기
inactiveList.style.display = 'block';
toggleIcon.classList.remove('fa-chevron-right');
toggleIcon.classList.add('fa-chevron-down');
} else {
// 접기
inactiveList.style.display = 'none';
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-right');
}
}
// 프로젝트 편집
async function editProject(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const newName = prompt('프로젝트 이름을 수정하세요:', project.project_name);
if (newName && newName.trim() && newName.trim() !== project.project_name) {
try {
// API를 통한 프로젝트 업데이트
await ProjectsAPI.update(projectId, {
project_name: newName.trim()
});
// 목록 새로고침
await loadProjects();
displayProjectList();
alert('프로젝트가 수정되었습니다.');
} catch (error) {
console.error('프로젝트 수정 실패:', error);
alert('프로젝트 수정에 실패했습니다: ' + error.message);
}
}
}
// 프로젝트 활성/비활성 토글
async function toggleProjectStatus(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const action = project.is_active ? '비활성화' : '활성화';
if (confirm(`"${project.job_no}" 프로젝트를 ${action}하시겠습니까?`)) {
try {
// API를 통한 프로젝트 상태 업데이트
await ProjectsAPI.update(projectId, {
is_active: !project.is_active
});
// 목록 새로고침
await loadProjects();
displayProjectList();
alert(`프로젝트가 ${action}되었습니다.`);
} catch (error) {
console.error('프로젝트 상태 변경 실패:', error);
alert('프로젝트 상태 변경에 실패했습니다: ' + error.message);
}
}
}
// 프로젝트 삭제 (완전 삭제)
async function deleteProject(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const confirmMessage = project.is_active
? `"${project.job_no}" 프로젝트를 완전히 삭제하시겠습니까?\n\n※ 활성 프로젝트입니다. 먼저 비활성화를 권장합니다.`
: `"${project.job_no}" 프로젝트를 완전히 삭제하시겠습니까?\n\n※ 이 작업은 되돌릴 수 없습니다.`;
if (confirm(confirmMessage)) {
try {
// API를 통한 프로젝트 삭제
await ProjectsAPI.delete(projectId);
// 목록 새로고침
await loadProjects();
displayProjectList();
alert('프로젝트가 완전히 삭제되었습니다.');
} catch (error) {
console.error('프로젝트 삭제 실패:', error);
alert('프로젝트 삭제에 실패했습니다: ' + error.message);
}
}
}
// DOMContentLoaded 이벤트 제거 - API 스크립트 로드 후 checkAdminAccess() 호출됨
</script>
</body>
</html>

View File

@@ -0,0 +1,501 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.report-card {
transition: all 0.2s ease;
border-left: 4px solid transparent;
}
.report-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-left-color: #10b981;
}
.stats-card {
transition: all 0.2s ease;
}
.stats-card:hover {
transform: translateY(-1px);
}
.issue-row {
transition: all 0.2s ease;
}
.issue-row:hover {
background-color: #f9fafb;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-file-excel text-green-500 mr-3"></i>
일일보고서
</h1>
<p class="text-gray-600 mt-1">프로젝트별 진행중/완료 항목을 엑셀로 내보내세요</p>
</div>
</div>
</div>
<!-- 프로젝트 선택 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="space-y-6">
<!-- 프로젝트 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">
<i class="fas fa-folder text-blue-500 mr-2"></i>보고서 생성할 프로젝트 선택
</label>
<select id="reportProjectSelect" class="w-full max-w-md px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg">
<option value="">프로젝트를 선택하세요</option>
</select>
<p class="text-sm text-gray-500 mt-2">
<i class="fas fa-info-circle mr-1"></i>
진행 중인 항목 + 완료되고 한번도 추출 안된 항목이 포함됩니다.
</p>
</div>
<!-- 버튼 -->
<div class="flex items-center space-x-4">
<button id="previewBtn"
onclick="loadPreview()"
class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
<i class="fas fa-eye mr-2"></i>미리보기
</button>
<button id="generateReportBtn"
onclick="generateDailyReport()"
class="px-6 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
<i class="fas fa-download mr-2"></i>일일보고서 생성
</button>
</div>
</div>
</div>
<!-- 미리보기 섹션 -->
<div id="previewSection" class="hidden">
<!-- 통계 카드 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-chart-bar text-blue-500 mr-2"></i>추출 항목 통계
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="stats-card bg-blue-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-blue-600 mb-1" id="previewTotalCount">0</div>
<div class="text-sm text-blue-700 font-medium">총 추출 수량</div>
</div>
<div class="stats-card bg-orange-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-orange-600 mb-1" id="previewInProgressCount">0</div>
<div class="text-sm text-orange-700 font-medium">진행 중</div>
</div>
<div class="stats-card bg-green-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-green-600 mb-1" id="previewCompletedCount">0</div>
<div class="text-sm text-green-700 font-medium">완료 (미추출)</div>
</div>
<div class="stats-card bg-red-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-red-600 mb-1" id="previewDelayedCount">0</div>
<div class="text-sm text-red-700 font-medium">지연 중</div>
</div>
</div>
</div>
<!-- 항목 목록 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list text-gray-500 mr-2"></i>추출될 항목 목록
</h2>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr class="border-b">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">No</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">부적합명</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">추출이력</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">담당부서</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">신고일</th>
</tr>
</thead>
<tbody id="previewTableBody" class="divide-y divide-gray-200">
<!-- 동적으로 채워짐 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 포함 항목 안내 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list-check text-gray-500 mr-2"></i>보고서 포함 항목 안내
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="report-card bg-blue-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-check-circle text-blue-500 mr-2"></i>
<span class="font-medium text-blue-800">진행 중 항목</span>
</div>
<p class="text-sm text-blue-600">모든 진행 중인 항목이 포함됩니다 (추출 이력과 무관)</p>
</div>
<div class="report-card bg-green-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
<span class="font-medium text-green-800">완료됨 항목</span>
</div>
<p class="text-sm text-green-600">한번도 추출 안된 완료 항목만 포함, 이후 자동 제외</p>
</div>
<div class="report-card bg-yellow-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-info-circle text-yellow-500 mr-2"></i>
<span class="font-medium text-yellow-800">추출 이력 기록</span>
</div>
<p class="text-sm text-yellow-600">추출 시 자동으로 이력이 기록됩니다</p>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/auth-manager.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/components/common-header.js"></script>
<script src="/static/js/api.js"></script>
<script>
let projects = [];
let selectedProjectId = null;
let previewData = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('일일보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 프로젝트 목록 로드
await loadProjects();
// 공통 헤더 초기화
try {
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_daily');
}
} catch (headerError) {
console.error('공통 헤더 초기화 오류:', headerError);
}
console.log('일일보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
// 프로젝트 목록 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
projects = await response.json();
populateProjectSelect();
} else {
console.error('프로젝트 로드 실패:', response.status);
}
} catch (error) {
console.error('프로젝트 로드 오류:', error);
}
}
// 프로젝트 선택 옵션 채우기
function populateProjectSelect() {
const select = document.getElementById('reportProjectSelect');
if (!select) {
console.error('reportProjectSelect 요소를 찾을 수 없습니다!');
return;
}
select.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name || project.name;
select.appendChild(option);
});
}
// 프로젝트 선택 시 이벤트
document.addEventListener('change', async function(e) {
if (e.target.id === 'reportProjectSelect') {
selectedProjectId = e.target.value;
const previewBtn = document.getElementById('previewBtn');
const generateBtn = document.getElementById('generateReportBtn');
const previewSection = document.getElementById('previewSection');
if (selectedProjectId) {
previewBtn.classList.remove('hidden');
generateBtn.classList.remove('hidden');
previewSection.classList.add('hidden');
previewData = null;
} else {
previewBtn.classList.add('hidden');
generateBtn.classList.add('hidden');
previewSection.classList.add('hidden');
previewData = null;
}
}
});
// 미리보기 로드
async function loadPreview() {
if (!selectedProjectId) {
alert('프로젝트를 선택해주세요.');
return;
}
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/reports/daily-preview?project_id=${selectedProjectId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
previewData = await response.json();
displayPreview(previewData);
} else {
alert('미리보기 로드에 실패했습니다.');
}
} catch (error) {
console.error('미리보기 로드 오류:', error);
alert('미리보기 로드 중 오류가 발생했습니다.');
}
}
// 미리보기 표시
function displayPreview(data) {
// 통계 업데이트
const inProgressCount = data.issues.filter(i => i.review_status === 'in_progress').length;
const completedCount = data.issues.filter(i => i.review_status === 'completed').length;
document.getElementById('previewTotalCount').textContent = data.total_issues;
document.getElementById('previewInProgressCount').textContent = inProgressCount;
document.getElementById('previewCompletedCount').textContent = completedCount;
document.getElementById('previewDelayedCount').textContent = data.stats.delayed_count;
// 테이블 업데이트
const tbody = document.getElementById('previewTableBody');
tbody.innerHTML = '';
data.issues.forEach(issue => {
const row = document.createElement('tr');
row.className = 'issue-row';
const statusBadge = getStatusBadge(issue);
const exportBadge = getExportBadge(issue);
const department = getDepartmentText(issue.responsible_department);
const reportDate = issue.report_date ? new Date(issue.report_date).toLocaleDateString('ko-KR') : '-';
row.innerHTML = `
<td class="px-4 py-3 text-sm text-gray-900">${issue.project_sequence_no || issue.id}</td>
<td class="px-4 py-3 text-sm text-gray-900">${issue.final_description || issue.description || '-'}</td>
<td class="px-4 py-3 text-sm">${statusBadge}</td>
<td class="px-4 py-3 text-sm">${exportBadge}</td>
<td class="px-4 py-3 text-sm text-gray-900">${department}</td>
<td class="px-4 py-3 text-sm text-gray-500">${reportDate}</td>
`;
tbody.appendChild(row);
});
// 미리보기 섹션 표시
document.getElementById('previewSection').classList.remove('hidden');
}
// 상태 배지 (지연/진행중/완료 구분)
function getStatusBadge(issue) {
// 완료됨
if (issue.review_status === 'completed') {
return '<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">완료됨</span>';
}
// 진행 중인 경우 지연 여부 확인
if (issue.review_status === 'in_progress') {
if (issue.expected_completion_date) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const expectedDate = new Date(issue.expected_completion_date);
expectedDate.setHours(0, 0, 0, 0);
if (expectedDate < today) {
return '<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">지연중</span>';
}
}
return '<span class="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded">진행중</span>';
}
return '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">' + (issue.review_status || '-') + '</span>';
}
// 추출 이력 배지
function getExportBadge(issue) {
if (issue.last_exported_at) {
const exportDate = new Date(issue.last_exported_at).toLocaleDateString('ko-KR');
return `<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">추출됨 (${issue.export_count || 1}회)</span>`;
} else {
return '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">미추출</span>';
}
}
// 부서명 변환
function getDepartmentText(department) {
const map = {
'production': '생산',
'quality': '품질',
'purchasing': '구매',
'design': '설계',
'sales': '영업'
};
return map[department] || '-';
}
// 일일보고서 생성
async function generateDailyReport() {
if (!selectedProjectId) {
alert('프로젝트를 선택해주세요.');
return;
}
// 미리보기 데이터가 있고 항목이 0개인 경우
if (previewData && previewData.total_issues === 0) {
alert('추출할 항목이 없습니다.');
return;
}
try {
const button = document.getElementById('generateReportBtn');
const originalText = button.innerHTML;
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 response = await fetch(`${apiUrl}/reports/daily-export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
project_id: parseInt(selectedProjectId)
})
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
// 파일명 생성
const project = projects.find(p => p.id == selectedProjectId);
const today = new Date().toISOString().split('T')[0];
a.download = `${project.project_name}_일일보고서_${today}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// 성공 메시지
showSuccessMessage('일일보고서가 성공적으로 생성되었습니다!');
// 미리보기 새로고침
if (previewData) {
setTimeout(() => loadPreview(), 1000);
}
} else {
const error = await response.text();
console.error('보고서 생성 실패:', error);
alert('보고서 생성에 실패했습니다. 다시 시도해주세요.');
}
} catch (error) {
console.error('보고서 생성 오류:', error);
alert('보고서 생성 중 오류가 발생했습니다.');
} finally {
const button = document.getElementById('generateReportBtn');
button.innerHTML = '<i class="fas fa-download mr-2"></i>일일보고서 생성';
button.disabled = false;
}
}
// 성공 메시지 표시
function showSuccessMessage(message) {
const successDiv = document.createElement('div');
successDiv.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
successDiv.innerHTML = `
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(successDiv);
setTimeout(() => {
successDiv.remove();
}, 3000);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>월간보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-calendar-alt text-purple-500 mr-3"></i>
월간보고서
</h1>
<p class="text-gray-600 mt-1">월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합적으로 분석하세요</p>
</div>
</div>
</div>
<!-- 준비중 안내 -->
<div class="bg-white rounded-xl shadow-sm p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-24 h-24 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-6">
<i class="fas fa-calendar-alt text-purple-500 text-3xl"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">월간보고서 준비중</h2>
<p class="text-gray-600 mb-6">
월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합한 보고서 기능을 준비하고 있습니다.
</p>
<div class="bg-purple-50 p-4 rounded-lg">
<h3 class="font-semibold text-purple-800 mb-2">예정 기능</h3>
<ul class="text-sm text-purple-700 space-y-1">
<li>• 월간 부적합 발생 현황</li>
<li>• 월간 처리 완료 현황</li>
<li>• 부서별 성과 분석</li>
<li>• 월간 트렌드 및 개선사항</li>
<li>• 경영진 보고용 요약</li>
</ul>
</div>
<div class="mt-6">
<button onclick="window.history.back()"
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
</button>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/auth-manager.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/components/common-header.js"></script>
<script src="/static/js/api.js"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('월간보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_monthly');
}
console.log('월간보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>주간보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-calendar-week text-blue-500 mr-3"></i>
주간보고서
</h1>
<p class="text-gray-600 mt-1">주간 단위로 집계된 부적합 현황 및 처리 결과를 확인하세요</p>
</div>
</div>
</div>
<!-- 준비중 안내 -->
<div class="bg-white rounded-xl shadow-sm p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<i class="fas fa-calendar-week text-blue-500 text-3xl"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">주간보고서 준비중</h2>
<p class="text-gray-600 mb-6">
주간 단위로 집계된 부적합 현황 및 처리 결과를 정리한 보고서 기능을 준비하고 있습니다.
</p>
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="font-semibold text-blue-800 mb-2">예정 기능</h3>
<ul class="text-sm text-blue-700 space-y-1">
<li>• 주간 부적합 발생 현황</li>
<li>• 주간 처리 완료 현황</li>
<li>• 부서별 처리 성과</li>
<li>• 주간 트렌드 분석</li>
</ul>
</div>
<div class="mt-6">
<button onclick="window.history.back()"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
</button>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/auth-manager.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/components/common-header.js"></script>
<script src="/static/js/api.js"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('주간보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_weekly');
}
console.log('주간보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.report-card {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.report-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.15);
border-left-color: #3b82f6;
}
.report-card.daily-report {
border-left-color: #10b981;
}
.report-card.daily-report:hover {
border-left-color: #059669;
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stats-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
</style>
</head>
<body class="bg-gray-50">
<!-- 공통 헤더 -->
<div id="commonHeader"></div>
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-chart-bar text-red-500 mr-3"></i>
보고서
</h1>
<p class="text-gray-600 mt-1">다양한 보고서를 생성하고 관리할 수 있습니다</p>
</div>
</div>
</div>
<!-- 보고서 카테고리 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list text-gray-500 mr-2"></i>보고서 유형 선택
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 일일보고서 -->
<a href="/reports-daily.html" class="report-card bg-green-50 p-4 rounded-lg hover:bg-green-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-excel text-green-600"></i>
</div>
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-1 rounded-full">
사용 가능
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">일일보고서</h3>
<p class="text-sm text-gray-600 mb-3">
관리함 데이터를 기반으로 품질팀용 일일보고서를 엑셀 형태로 생성합니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-green-600 font-medium">
<i class="fas fa-check-circle mr-1"></i>진행중 항목 포함
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
<!-- 주간보고서 -->
<a href="/reports-weekly.html" class="report-card bg-blue-50 p-4 rounded-lg hover:bg-blue-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-calendar-week text-blue-600"></i>
</div>
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
준비중
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">주간보고서</h3>
<p class="text-sm text-gray-600 mb-3">
주간 단위로 집계된 부적합 현황 및 처리 결과를 정리한 보고서입니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-blue-600 font-medium">
<i class="fas fa-calendar mr-1"></i>주간 집계
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
<!-- 월간보고서 -->
<a href="/reports-monthly.html" class="report-card bg-purple-50 p-4 rounded-lg hover:bg-purple-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-calendar-alt text-purple-600"></i>
</div>
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
준비중
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">월간보고서</h3>
<p class="text-sm text-gray-600 mb-3">
월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합한 보고서입니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-purple-600 font-medium">
<i class="fas fa-chart-line mr-1"></i>월간 분석
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
</div>
</div>
<!-- 보고서 안내 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-info-circle text-blue-500 mr-2"></i>보고서 이용 안내
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<h3 class="font-semibold text-gray-800">📊 일일보고서</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li>• 관리함의 진행 중 항목 무조건 포함</li>
<li>• 완료됨 항목은 첫 내보내기에만 포함</li>
<li>• 프로젝트별 개별 생성</li>
<li>• 엑셀 형태로 다운로드</li>
</ul>
</div>
<div class="space-y-3">
<h3 class="font-semibold text-gray-800">🚀 향후 계획</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li>• 주간보고서: 주간 집계 및 트렌드 분석</li>
<li>• 월간보고서: 월간 성과 및 개선사항</li>
<li>• 자동 이메일 발송 기능</li>
<li>• 대시보드 형태의 실시간 리포트</li>
</ul>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/auth-manager.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/components/common-header.js"></script>
<script src="/static/js/api.js"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('보고서 메인 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports');
}
console.log('보고서 메인 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,297 @@
/* 모바일 캘린더 스타일 */
.mobile-calendar {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* 빠른 선택 버튼들 */
.quick-select-buttons {
scrollbar-width: none;
-ms-overflow-style: none;
}
.quick-select-buttons::-webkit-scrollbar {
display: none;
}
.quick-btn {
flex-shrink: 0;
padding: 8px 16px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
color: #374151;
transition: all 0.2s ease;
white-space: nowrap;
}
.quick-btn:hover,
.quick-btn:active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
transform: scale(0.95);
}
.quick-btn.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
/* 캘린더 헤더 */
.calendar-header {
padding: 0 8px;
}
.nav-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f9fafb;
border: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s ease;
}
.nav-btn:hover,
.nav-btn:active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
transform: scale(0.9);
}
.month-year {
color: #1f2937;
font-weight: 600;
min-width: 120px;
text-align: center;
}
/* 요일 헤더 */
.weekdays {
margin-bottom: 8px;
}
.weekday {
text-align: center;
font-size: 12px;
font-weight: 600;
color: #6b7280;
padding: 8px 4px;
text-transform: uppercase;
}
.weekday:first-child {
color: #ef4444; /* 일요일 빨간색 */
}
.weekday:last-child {
color: #3b82f6; /* 토요일 파란색 */
}
/* 캘린더 그리드 */
.calendar-grid {
gap: 2px;
}
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
background: white;
border: 1px solid transparent;
min-height: 40px;
}
.calendar-day:hover {
background: #eff6ff;
border-color: #bfdbfe;
transform: scale(1.05);
}
.calendar-day:active {
transform: scale(0.95);
}
/* 다른 달 날짜 */
.calendar-day.other-month {
color: #d1d5db;
background: #f9fafb;
}
.calendar-day.other-month:hover {
background: #f3f4f6;
color: #9ca3af;
}
/* 오늘 날짜 */
.calendar-day.today {
background: #fef3c7;
color: #92400e;
font-weight: 700;
border-color: #f59e0b;
}
.calendar-day.today:hover {
background: #fde68a;
}
/* 선택된 날짜 */
.calendar-day.selected {
background: #dbeafe;
color: #1e40af;
border-color: #3b82f6;
}
/* 범위 시작/끝 */
.calendar-day.range-start,
.calendar-day.range-end {
background: #3b82f6;
color: white;
font-weight: 700;
}
.calendar-day.range-start:hover,
.calendar-day.range-end:hover {
background: #2563eb;
}
/* 범위 시작일에 표시 */
.calendar-day.range-start::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 6px;
height: 6px;
background: #10b981;
border-radius: 50%;
}
/* 범위 끝일에 표시 */
.calendar-day.range-end::after {
content: '';
position: absolute;
bottom: 2px;
right: 2px;
width: 6px;
height: 6px;
background: #ef4444;
border-radius: 50%;
}
/* 선택된 범위 표시 */
.selected-range {
border: 1px solid #bfdbfe;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.clear-btn {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s ease;
}
.clear-btn:hover {
background: rgba(59, 130, 246, 0.1);
}
/* 사용법 안내 */
.usage-hint {
opacity: 0.7;
line-height: 1.4;
}
/* 터치 디바이스 최적화 */
@media (hover: none) and (pointer: coarse) {
.calendar-day {
min-height: 44px; /* 터치 타겟 최소 크기 */
}
.nav-btn {
min-width: 44px;
min-height: 44px;
}
.quick-btn {
min-height: 44px;
padding: 12px 16px;
}
}
/* 작은 화면 최적화 */
@media (max-width: 375px) {
.calendar-day {
font-size: 13px;
min-height: 36px;
}
.quick-btn {
padding: 6px 12px;
font-size: 13px;
}
.month-year {
font-size: 16px;
}
}
/* 다크 모드 지원 */
@media (prefers-color-scheme: dark) {
.mobile-calendar {
color: #f9fafb;
}
.calendar-day {
background: #374151;
color: #f9fafb;
}
.calendar-day:hover {
background: #4b5563;
}
.nav-btn {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
.quick-btn {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
}

View File

@@ -0,0 +1,346 @@
// API 기본 설정 (Cloudflare 터널 + 로컬 환경 지원)
const API_BASE_URL = (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
console.log('🔧 API URL 생성 - hostname:', hostname, 'protocol:', protocol, 'port:', port);
// 로컬 환경 (포트 있음)
if (port === '16080') {
const url = `${protocol}//${hostname}:${port}/api`;
console.log('🏠 로컬 환경 URL:', url);
return url;
}
// Cloudflare 터널 환경 (m.hyungi.net) - 강제 HTTPS
if (hostname === 'm.hyungi.net') {
const url = `https://m-api.hyungi.net/api`;
console.log('☁️ Cloudflare 환경 URL:', url);
return url;
}
// 기타 환경
const url = '/api';
console.log('🌐 기타 환경 URL:', url);
return url;
})();
// 토큰 관리
const TokenManager = {
getToken: () => localStorage.getItem('access_token'),
setToken: (token) => localStorage.setItem('access_token', token),
removeToken: () => localStorage.removeItem('access_token'),
getUser: () => {
const userStr = localStorage.getItem('current_user');
return userStr ? JSON.parse(userStr) : null;
},
setUser: (user) => localStorage.setItem('current_user', JSON.stringify(user)),
removeUser: () => localStorage.removeItem('current_user')
};
// API 요청 헬퍼
async function apiRequest(endpoint, options = {}) {
const token = TokenManager.getToken();
const defaultHeaders = {
'Content-Type': 'application/json',
};
if (token) {
defaultHeaders['Authorization'] = `Bearer ${token}`;
}
const config = {
...options,
headers: {
...defaultHeaders,
...options.headers
}
};
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
if (response.status === 401) {
// 인증 실패 시 로그인 페이지로
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = '/index.html';
return;
}
if (!response.ok) {
const error = await response.json();
console.error('API Error Response:', error);
console.error('Error details:', JSON.stringify(error, null, 2));
// 422 에러의 경우 validation 에러 메시지 추출
if (response.status === 422 && error.detail && Array.isArray(error.detail)) {
const validationErrors = error.detail.map(err =>
`${err.loc ? err.loc.join('.') : 'field'}: ${err.msg}`
).join(', ');
throw new Error(`입력값 검증 오류: ${validationErrors}`);
}
throw new Error(error.detail || 'API 요청 실패');
}
return await response.json();
} catch (error) {
console.error('API 요청 에러:', error);
throw error;
}
}
// Auth API
const AuthAPI = {
login: async (username, password) => {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
});
if (!response.ok) {
const error = await response.json();
console.error('로그인 에러:', error);
throw new Error(error.detail || '로그인 실패');
}
const data = await response.json();
TokenManager.setToken(data.access_token);
TokenManager.setUser(data.user);
return data;
} catch (error) {
console.error('로그인 요청 에러:', error);
throw error;
}
},
logout: () => {
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = '/index.html';
},
getMe: () => apiRequest('/auth/me'),
getCurrentUser: () => apiRequest('/auth/me'),
createUser: (userData) => apiRequest('/auth/users', {
method: 'POST',
body: JSON.stringify(userData)
}),
getUsers: () => {
console.log('🔍 AuthAPI.getUsers 호출 - 엔드포인트: /auth/users');
return apiRequest('/auth/users');
},
updateUser: (userId, userData) => apiRequest(`/auth/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData)
}),
deleteUser: (userId) => apiRequest(`/auth/users/${userId}`, {
method: 'DELETE'
}),
changePassword: (currentPassword, newPassword) => apiRequest('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
}),
resetPassword: (userId, newPassword = '000000') => apiRequest(`/auth/users/${userId}/reset-password`, {
method: 'POST',
body: JSON.stringify({
new_password: newPassword
})
}),
// 부서 목록 가져오기
getDepartments: () => [
{ value: 'production', label: '생산' },
{ value: 'quality', label: '품질' },
{ value: 'purchasing', label: '구매' },
{ value: 'design', label: '설계' },
{ value: 'sales', label: '영업' }
],
// 부서명 변환
getDepartmentLabel: (departmentValue) => {
const departments = AuthAPI.getDepartments();
const dept = departments.find(d => d.value === departmentValue);
return dept ? dept.label : departmentValue || '미지정';
}
};
// Issues API
const IssuesAPI = {
create: async (issueData) => {
// photos 배열 처리 (최대 5장)
const dataToSend = {
category: issueData.category,
description: issueData.description,
project_id: issueData.project_id,
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : null,
photo3: issueData.photos && issueData.photos.length > 2 ? issueData.photos[2] : null,
photo4: issueData.photos && issueData.photos.length > 3 ? issueData.photos[3] : null,
photo5: issueData.photos && issueData.photos.length > 4 ? issueData.photos[4] : null
};
return apiRequest('/issues/', {
method: 'POST',
body: JSON.stringify(dataToSend)
});
},
getAll: (params = {}) => {
const queryString = new URLSearchParams(params).toString();
return apiRequest(`/issues/${queryString ? '?' + queryString : ''}`);
},
get: (id) => apiRequest(`/issues/${id}`),
update: (id, issueData) => apiRequest(`/issues/${id}`, {
method: 'PUT',
body: JSON.stringify(issueData)
}),
delete: (id) => apiRequest(`/issues/${id}`, {
method: 'DELETE'
}),
getStats: () => apiRequest('/issues/stats/summary')
};
// Daily Work API
const DailyWorkAPI = {
create: (workData) => apiRequest('/daily-work/', {
method: 'POST',
body: JSON.stringify(workData)
}),
getAll: (params = {}) => {
const queryString = new URLSearchParams(params).toString();
return apiRequest(`/daily-work/${queryString ? '?' + queryString : ''}`);
},
get: (id) => apiRequest(`/daily-work/${id}`),
update: (id, workData) => apiRequest(`/daily-work/${id}`, {
method: 'PUT',
body: JSON.stringify(workData)
}),
delete: (id) => apiRequest(`/daily-work/${id}`, {
method: 'DELETE'
}),
getStats: (params = {}) => {
const queryString = new URLSearchParams(params).toString();
return apiRequest(`/daily-work/stats/summary${queryString ? '?' + queryString : ''}`);
}
};
// Reports API
const ReportsAPI = {
getSummary: (startDate, endDate) => apiRequest('/reports/summary', {
method: 'POST',
body: JSON.stringify({
start_date: startDate,
end_date: endDate
})
}),
getIssues: (startDate, endDate) => {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate
}).toString();
return apiRequest(`/reports/issues?${params}`);
},
getDailyWorks: (startDate, endDate) => {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate
}).toString();
return apiRequest(`/reports/daily-works?${params}`);
}
};
// 권한 체크
function checkAuth() {
const user = TokenManager.getUser();
if (!user) {
window.location.href = '/index.html';
return null;
}
return user;
}
function checkAdminAuth() {
const user = checkAuth();
if (user && user.role !== 'admin') {
alert('관리자 권한이 필요합니다.');
window.location.href = '/index.html';
return null;
}
return user;
}
// 페이지 접근 권한 체크 함수 (새로 추가)
function checkPageAccess(pageName) {
const user = checkAuth();
if (!user) return null;
// admin은 모든 페이지 접근 가능
if (user.role === 'admin') return user;
// 페이지별 권한 체크는 pagePermissionManager에서 처리
if (window.pagePermissionManager && !window.pagePermissionManager.canAccessPage(pageName)) {
alert('이 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return null;
}
return user;
}
// 프로젝트 API
const ProjectsAPI = {
getAll: (activeOnly = false) => {
const params = `?active_only=${activeOnly}`;
return apiRequest(`/projects/${params}`);
},
get: (id) => apiRequest(`/projects/${id}`),
create: (projectData) => apiRequest('/projects/', {
method: 'POST',
body: JSON.stringify(projectData)
}),
update: (id, projectData) => apiRequest(`/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(projectData)
}),
delete: (id) => apiRequest(`/projects/${id}`, {
method: 'DELETE'
})
};

View File

@@ -0,0 +1,441 @@
/**
* 메인 애플리케이션 JavaScript
* 통합된 SPA 애플리케이션의 핵심 로직
*/
class App {
constructor() {
this.currentUser = null;
this.currentPage = 'dashboard';
this.modules = new Map();
this.sidebarCollapsed = false;
this.init();
}
/**
* 애플리케이션 초기화
*/
async init() {
try {
// 인증 확인
await this.checkAuth();
// API 스크립트 로드
await this.loadAPIScript();
// 권한 시스템 초기화
window.pagePermissionManager.setUser(this.currentUser);
// UI 초기화
this.initializeUI();
// 라우터 초기화
this.initializeRouter();
// 대시보드 데이터 로드
await this.loadDashboardData();
} catch (error) {
console.error('앱 초기화 실패:', error);
this.redirectToLogin();
}
}
/**
* 인증 확인
*/
async checkAuth() {
const token = localStorage.getItem('access_token');
if (!token) {
throw new Error('토큰 없음');
}
// 임시로 localStorage에서 사용자 정보 가져오기
const storedUser = localStorage.getItem('currentUser');
if (storedUser) {
this.currentUser = JSON.parse(storedUser);
} else {
throw new Error('사용자 정보 없음');
}
}
/**
* API 스크립트 동적 로드
*/
async loadAPIScript() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `/static/js/api.js?v=${Date.now()}`;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
/**
* UI 초기화
*/
initializeUI() {
// 사용자 정보 표시
this.updateUserDisplay();
// 네비게이션 메뉴 생성
this.createNavigationMenu();
// 이벤트 리스너 등록
this.registerEventListeners();
}
/**
* 사용자 정보 표시 업데이트
*/
updateUserDisplay() {
const userInitial = document.getElementById('userInitial');
const userDisplayName = document.getElementById('userDisplayName');
const userRole = document.getElementById('userRole');
const displayName = this.currentUser.full_name || this.currentUser.username;
const initial = displayName.charAt(0).toUpperCase();
userInitial.textContent = initial;
userDisplayName.textContent = displayName;
userRole.textContent = this.getRoleDisplayName(this.currentUser.role);
}
/**
* 역할 표시명 가져오기
*/
getRoleDisplayName(role) {
const roleNames = {
'admin': '관리자',
'user': '사용자'
};
return roleNames[role] || role;
}
/**
* 네비게이션 메뉴 생성
*/
createNavigationMenu() {
const menuConfig = window.pagePermissionManager.getMenuConfig();
const navigationMenu = document.getElementById('navigationMenu');
navigationMenu.innerHTML = '';
menuConfig.forEach(item => {
const menuItem = this.createMenuItem(item);
navigationMenu.appendChild(menuItem);
});
}
/**
* 메뉴 아이템 생성
*/
createMenuItem(item) {
const li = document.createElement('li');
// 단순한 단일 메뉴 아이템만 지원
li.innerHTML = `
<div class="nav-item p-3 rounded-lg cursor-pointer" onclick="app.navigateTo('${item.path}')">
<div class="flex items-center">
<i class="${item.icon} mr-3 text-gray-500"></i>
<span class="text-gray-700">${item.title}</span>
</div>
</div>
`;
return li;
}
/**
* 라우터 초기화
*/
initializeRouter() {
// 해시 변경 감지
window.addEventListener('hashchange', () => {
this.handleRouteChange();
});
// 초기 라우트 처리
this.handleRouteChange();
}
/**
* 라우트 변경 처리
*/
async handleRouteChange() {
const hash = window.location.hash.substring(1) || 'dashboard';
const [module, action] = hash.split('/');
try {
await this.loadModule(module, action);
this.updateActiveNavigation(hash);
this.updatePageTitle(module, action);
} catch (error) {
console.error('라우트 처리 실패:', error);
this.showError('페이지를 로드할 수 없습니다.');
}
}
/**
* 모듈 로드
*/
async loadModule(module, action = 'list') {
if (module === 'dashboard') {
this.showDashboard();
return;
}
// 모듈이 이미 로드되어 있는지 확인
if (!this.modules.has(module)) {
await this.loadModuleScript(module);
}
// 모듈 실행
const moduleInstance = this.modules.get(module);
if (moduleInstance && typeof moduleInstance.render === 'function') {
const content = await moduleInstance.render(action);
this.showDynamicContent(content);
}
}
/**
* 모듈 스크립트 로드
*/
async loadModuleScript(module) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `/static/js/modules/${module}/${module}.js?v=${Date.now()}`;
script.onload = () => {
// 모듈이 전역 객체에 등록되었는지 확인
const moduleClass = window[module.charAt(0).toUpperCase() + module.slice(1) + 'Module'];
if (moduleClass) {
this.modules.set(module, new moduleClass());
}
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
}
/**
* 대시보드 표시
*/
showDashboard() {
document.getElementById('dashboard').classList.remove('hidden');
document.getElementById('dynamicContent').classList.add('hidden');
this.currentPage = 'dashboard';
}
/**
* 동적 콘텐츠 표시
*/
showDynamicContent(content) {
document.getElementById('dashboard').classList.add('hidden');
const dynamicContent = document.getElementById('dynamicContent');
dynamicContent.innerHTML = content;
dynamicContent.classList.remove('hidden');
}
/**
* 네비게이션 활성화 상태 업데이트
*/
updateActiveNavigation(hash) {
// 모든 네비게이션 아이템에서 active 클래스 제거
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
});
// 현재 페이지에 해당하는 네비게이션 아이템에 active 클래스 추가
// 구현 필요
}
/**
* 페이지 제목 업데이트
*/
updatePageTitle(module, action) {
const titles = {
'dashboard': '대시보드',
'issues': '부적합 사항',
'projects': '프로젝트',
'daily_work': '일일 공수',
'reports': '보고서',
'users': '사용자 관리'
};
const title = titles[module] || module;
document.getElementById('pageTitle').textContent = title;
}
/**
* 대시보드 데이터 로드
*/
async loadDashboardData() {
try {
// 통계 데이터 로드 (임시 데이터)
document.getElementById('totalIssues').textContent = '0';
document.getElementById('activeProjects').textContent = '0';
document.getElementById('monthlyHours').textContent = '0';
document.getElementById('completionRate').textContent = '0%';
// 실제 API 호출로 대체 예정
// const stats = await API.getDashboardStats();
// this.updateDashboardStats(stats);
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
}
}
/**
* 이벤트 리스너 등록
*/
registerEventListeners() {
// 비밀번호 변경은 CommonHeader에서 처리
// 모바일 반응형
window.addEventListener('resize', () => {
if (window.innerWidth >= 768) {
this.hideMobileOverlay();
}
});
}
/**
* 페이지 이동
*/
navigateTo(path) {
window.location.hash = path.startsWith('#') ? path.substring(1) : path;
// 모바일에서 사이드바 닫기
if (window.innerWidth < 768) {
this.toggleSidebar();
}
}
/**
* 사이드바 토글
*/
toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const mainContent = document.getElementById('mainContent');
const mobileOverlay = document.getElementById('mobileOverlay');
if (window.innerWidth < 768) {
// 모바일
if (sidebar.classList.contains('collapsed')) {
sidebar.classList.remove('collapsed');
mobileOverlay.classList.add('active');
} else {
sidebar.classList.add('collapsed');
mobileOverlay.classList.remove('active');
}
} else {
// 데스크톱
if (this.sidebarCollapsed) {
sidebar.classList.remove('collapsed');
mainContent.classList.remove('expanded');
this.sidebarCollapsed = false;
} else {
sidebar.classList.add('collapsed');
mainContent.classList.add('expanded');
this.sidebarCollapsed = true;
}
}
}
/**
* 모바일 오버레이 숨기기
*/
hideMobileOverlay() {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('mobileOverlay').classList.remove('active');
}
// 비밀번호 변경 기능은 CommonHeader.js에서 처리됩니다.
/**
* 로그아웃
*/
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
this.redirectToLogin();
}
/**
* 로그인 페이지로 리다이렉트
*/
redirectToLogin() {
window.location.href = '/index.html';
}
/**
* 로딩 표시
*/
showLoading() {
document.getElementById('loadingOverlay').classList.add('active');
}
/**
* 로딩 숨기기
*/
hideLoading() {
document.getElementById('loadingOverlay').classList.remove('active');
}
/**
* 성공 메시지 표시
*/
showSuccess(message) {
this.showToast(message, 'success');
}
/**
* 에러 메시지 표시
*/
showError(message) {
this.showToast(message, 'error');
}
/**
* 토스트 메시지 표시
*/
showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
}`;
toast.innerHTML = `
<div class="flex items-center">
<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} mr-2"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
}
// 전역 함수들 (HTML에서 호출)
function toggleSidebar() {
window.app.toggleSidebar();
}
// 비밀번호 변경 기능은 CommonHeader.showPasswordModal()을 사용합니다.
function logout() {
window.app.logout();
}
// 앱 초기화
document.addEventListener('DOMContentLoaded', () => {
window.app = new App();
});

View File

@@ -0,0 +1,712 @@
/**
* 공통 헤더 컴포넌트
* 권한 기반으로 메뉴를 동적으로 생성하고 부드러운 페이지 전환을 제공
*/
class CommonHeader {
constructor() {
this.currentUser = null;
this.currentPage = '';
this.menuItems = this.initMenuItems();
}
/**
* 메뉴 아이템 정의
*/
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'
},
{
id: 'reports',
title: '보고서',
icon: 'fas fa-chart-bar',
url: '/reports.html',
pageName: 'reports',
color: 'text-red-600',
bgColor: 'bg-red-50 hover:bg-red-100',
subMenus: [
{
id: 'reports_daily',
title: '일일보고서',
icon: 'fas fa-file-excel',
url: '/reports-daily.html',
pageName: 'reports_daily',
color: 'text-green-600'
},
{
id: 'reports_weekly',
title: '주간보고서',
icon: 'fas fa-calendar-week',
url: '/reports-weekly.html',
pageName: 'reports_weekly',
color: 'text-blue-600'
},
{
id: 'reports_monthly',
title: '월간보고서',
icon: 'fas fa-calendar-alt',
url: '/reports-monthly.html',
pageName: 'reports_monthly',
color: 'text-purple-600'
}
]
},
{
id: 'projects_manage',
title: '프로젝트 관리',
icon: 'fas fa-folder-open',
url: '/project-management.html',
pageName: 'projects_manage',
color: 'text-indigo-600',
bgColor: 'bg-indigo-50 hover:bg-indigo-100'
},
{
id: 'users_manage',
title: '사용자 관리',
icon: 'fas fa-users-cog',
url: '/admin.html',
pageName: 'users_manage',
color: 'text-gray-600',
bgColor: 'bg-gray-50 hover:bg-gray-100'
}
];
}
/**
* 헤더 초기화
* @param {Object} user - 현재 사용자 정보
* @param {string} currentPage - 현재 페이지 ID
*/
async init(user, currentPage = '') {
this.currentUser = user;
this.currentPage = currentPage;
// 권한 시스템이 로드될 때까지 대기
await this.waitForPermissionSystem();
this.render();
this.bindEvents();
// 키보드 단축키 초기화
this.initializeKeyboardShortcuts();
// 페이지 프리로더 초기화
this.initializePreloader();
}
/**
* 권한 시스템 로드 대기
*/
async waitForPermissionSystem() {
let attempts = 0;
const maxAttempts = 50; // 5초 대기
while (!window.pagePermissionManager && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (window.pagePermissionManager && this.currentUser) {
window.pagePermissionManager.setUser(this.currentUser);
// 권한 로드 대기
await new Promise(resolve => setTimeout(resolve, 300));
}
}
/**
* 헤더 렌더링
*/
render() {
const headerHTML = this.generateHeaderHTML();
// 기존 헤더가 있으면 교체, 없으면 body 상단에 추가
let headerContainer = document.getElementById('common-header');
if (headerContainer) {
headerContainer.innerHTML = headerHTML;
} else {
headerContainer = document.createElement('div');
headerContainer.id = 'common-header';
headerContainer.innerHTML = headerHTML;
document.body.insertBefore(headerContainer, document.body.firstChild);
}
}
/**
* 현재 페이지 업데이트
* @param {string} pageName - 새로운 페이지 이름
*/
updateCurrentPage(pageName) {
this.currentPage = pageName;
this.render();
}
/**
* 헤더 HTML 생성
*/
generateHeaderHTML() {
const accessibleMenus = this.getAccessibleMenus();
const userDisplayName = this.currentUser?.full_name || this.currentUser?.username || '사용자';
const userRole = this.getUserRoleDisplay();
return `
<header class="bg-white shadow-sm border-b sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- 로고 및 제목 -->
<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>
</div>
</div>
<!-- 네비게이션 메뉴 -->
<nav class="hidden md:flex space-x-2">
${accessibleMenus.map(menu => this.generateMenuItemHTML(menu)).join('')}
</nav>
<!-- 사용자 정보 및 메뉴 -->
<div class="flex items-center space-x-4">
<!-- 사용자 정보 -->
<div class="flex items-center space-x-3">
<div class="text-right">
<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">
<span class="text-white text-sm font-semibold">
${userDisplayName.charAt(0).toUpperCase()}
</span>
</div>
</div>
<!-- 드롭다운 메뉴 -->
<div class="relative">
<button id="user-menu-button" class="p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-md">
<i class="fas fa-chevron-down"></i>
</button>
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5">
<a href="#" onclick="CommonHeader.showPasswordModal()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-key mr-2"></i>비밀번호 변경
</a>
<a href="#" onclick="CommonHeader.logout()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-sign-out-alt mr-2"></i>로그아웃
</a>
</div>
</div>
<!-- 모바일 메뉴 버튼 -->
<button id="mobile-menu-button" class="md:hidden p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-md">
<i class="fas fa-bars"></i>
</button>
</div>
</div>
<!-- 모바일 메뉴 -->
<div id="mobile-menu" class="md:hidden hidden border-t border-gray-200 py-3">
<div class="space-y-1">
${accessibleMenus.map(menu => this.generateMobileMenuItemHTML(menu)).join('')}
</div>
</div>
</div>
</header>
`;
}
/**
* 접근 가능한 메뉴 필터링
*/
getAccessibleMenus() {
return this.menuItems.filter(menu => {
// admin은 모든 메뉴 접근 가능
if (this.currentUser?.role === 'admin') {
// 하위 메뉴가 있는 경우 하위 메뉴도 필터링
if (menu.subMenus) {
menu.accessibleSubMenus = menu.subMenus;
}
return true;
}
// 권한 시스템이 로드되지 않았으면 기본 메뉴만
if (!window.canAccessPage) {
return ['issues_create', 'issues_view'].includes(menu.id);
}
// 메인 메뉴 권한 체크
const hasMainAccess = window.canAccessPage(menu.pageName);
// 하위 메뉴가 있는 경우 접근 가능한 하위 메뉴 필터링
if (menu.subMenus) {
menu.accessibleSubMenus = menu.subMenus.filter(subMenu =>
window.canAccessPage(subMenu.pageName)
);
// 메인 메뉴 접근 권한이 없어도 하위 메뉴 중 하나라도 접근 가능하면 표시
return hasMainAccess || menu.accessibleSubMenus.length > 0;
}
return hasMainAccess;
});
}
/**
* 데스크톱 메뉴 아이템 HTML 생성
*/
generateMenuItemHTML(menu) {
const isActive = this.currentPage === menu.id;
const activeClass = isActive ? 'bg-blue-100 text-blue-700' : `${menu.bgColor} ${menu.color}`;
// 하위 메뉴가 있는 경우 드롭다운 메뉴 생성
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
return `
<div class="relative group">
<button 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}
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
<!-- 드롭다운 메뉴 -->
<div class="absolute left-0 mt-1 w-48 bg-white rounded-md shadow-lg border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
<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' : ''}"
data-page="${subMenu.id}"
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
${subMenu.title}
</a>
`).join('')}
</div>
</div>
</div>
`;
}
// 일반 메뉴 아이템
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}"
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
<i class="${menu.icon} mr-2"></i>
${menu.title}
</a>
`;
}
/**
* 모바일 메뉴 아이템 HTML 생성
*/
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';
// 하위 메뉴가 있는 경우
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
return `
<div class="mobile-submenu-container">
<button class="w-full flex items-center justify-between px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
onclick="this.nextElementSibling.classList.toggle('hidden')"
data-page="${menu.id}">
<div class="flex items-center">
<i class="${menu.icon} mr-3"></i>
${menu.title}
</div>
<i class="fas fa-chevron-down text-xs"></i>
</button>
<!-- 하위 메뉴 -->
<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' : ''}"
data-page="${subMenu.id}"
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
${subMenu.title}
</a>
`).join('')}
</div>
</div>
`;
}
// 일반 메뉴 아이템
return `
<a href="${menu.url}"
class="nav-item block px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
data-page="${menu.id}"
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
<i class="${menu.icon} mr-3"></i>
${menu.title}
</a>
`;
}
/**
* 사용자 역할 표시명 가져오기
*/
getUserRoleDisplay() {
const roleNames = {
'admin': '관리자',
'user': '사용자'
};
return roleNames[this.currentUser?.role] || '사용자';
}
/**
* 이벤트 바인딩
*/
bindEvents() {
// 사용자 메뉴 토글
const userMenuButton = document.getElementById('user-menu-button');
const userMenu = document.getElementById('user-menu');
if (userMenuButton && userMenu) {
userMenuButton.addEventListener('click', (e) => {
e.stopPropagation();
userMenu.classList.toggle('hidden');
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', () => {
userMenu.classList.add('hidden');
});
}
// 모바일 메뉴 토글
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
if (mobileMenuButton && mobileMenu) {
mobileMenuButton.addEventListener('click', () => {
mobileMenu.classList.toggle('hidden');
});
}
}
/**
* 페이지 네비게이션 (부드러운 전환)
*/
static navigateToPage(event, url, pageId) {
event.preventDefault();
// 현재 페이지와 같으면 무시
if (window.commonHeader?.currentPage === pageId) {
return;
}
// 로딩 표시
CommonHeader.showPageTransition();
// 페이지 이동
setTimeout(() => {
window.location.href = url;
}, 150); // 부드러운 전환을 위한 딜레이
}
/**
* 페이지 전환 로딩 표시
*/
static showPageTransition() {
// 기존 로딩이 있으면 제거
const existingLoader = document.getElementById('page-transition-loader');
if (existingLoader) {
existingLoader.remove();
}
const loader = document.createElement('div');
loader.id = 'page-transition-loader';
loader.className = 'fixed inset-0 bg-white bg-opacity-75 flex items-center justify-center z-50';
loader.innerHTML = `
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="mt-2 text-sm text-gray-600">페이지를 로드하는 중...</p>
</div>
`;
document.body.appendChild(loader);
}
/**
* 비밀번호 변경 모달 표시
*/
static showPasswordModal() {
// 기존 모달이 있으면 제거
const existingModal = document.getElementById('passwordChangeModal');
if (existingModal) {
existingModal.remove();
}
// 비밀번호 변경 모달 생성
const modalHTML = `
<div id="passwordChangeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]">
<div class="bg-white rounded-xl p-6 w-96 max-w-md mx-4 shadow-2xl">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-key mr-2 text-blue-500"></i>비밀번호 변경
</h3>
<button onclick="CommonHeader.hidePasswordModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<form id="passwordChangeForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
<input type="password" id="currentPasswordInput"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required placeholder="현재 비밀번호를 입력하세요">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
<input type="password" id="newPasswordInput"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required minlength="6" placeholder="새 비밀번호 (최소 6자)">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
<input type="password" id="confirmPasswordInput"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required placeholder="새 비밀번호를 다시 입력하세요">
</div>
<div class="flex gap-3 pt-4">
<button type="button" onclick="CommonHeader.hidePasswordModal()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-save mr-1"></i>변경
</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
// 폼 제출 이벤트 리스너 추가
document.getElementById('passwordChangeForm').addEventListener('submit', CommonHeader.handlePasswordChange);
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
CommonHeader.hidePasswordModal();
}
});
}
/**
* 비밀번호 변경 모달 숨기기
*/
static hidePasswordModal() {
const modal = document.getElementById('passwordChangeModal');
if (modal) {
modal.remove();
}
}
/**
* 비밀번호 변경 처리
*/
static async handlePasswordChange(e) {
e.preventDefault();
const currentPassword = document.getElementById('currentPasswordInput').value;
const newPassword = document.getElementById('newPasswordInput').value;
const confirmPassword = document.getElementById('confirmPasswordInput').value;
// 새 비밀번호 확인
if (newPassword !== confirmPassword) {
CommonHeader.showToast('새 비밀번호가 일치하지 않습니다.', 'error');
return;
}
if (newPassword.length < 6) {
CommonHeader.showToast('새 비밀번호는 최소 6자 이상이어야 합니다.', 'error');
return;
}
try {
// AuthAPI가 있는지 확인
if (typeof AuthAPI === 'undefined') {
throw new Error('AuthAPI가 로드되지 않았습니다.');
}
// API를 통한 비밀번호 변경
await AuthAPI.changePassword(currentPassword, newPassword);
CommonHeader.showToast('비밀번호가 성공적으로 변경되었습니다.', 'success');
CommonHeader.hidePasswordModal();
} catch (error) {
console.error('비밀번호 변경 실패:', error);
CommonHeader.showToast('현재 비밀번호가 올바르지 않거나 변경에 실패했습니다.', 'error');
}
}
/**
* 토스트 메시지 표시
*/
static showToast(message, type = 'success') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast-message');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg transform transition-all duration-300 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`;
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
toast.innerHTML = `<i class="fas ${icon} mr-2"></i>${message}`;
document.body.appendChild(toast);
// 애니메이션 효과
setTimeout(() => toast.classList.add('translate-x-0'), 10);
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-x-full');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* 로그아웃
*/
static logout() {
if (confirm('로그아웃 하시겠습니까?')) {
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
}
}
/**
* 현재 페이지 업데이트
*/
updateCurrentPage(pageId) {
this.currentPage = pageId;
// 활성 메뉴 업데이트
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');
} else {
item.classList.remove('bg-blue-100', 'text-blue-700');
}
});
}
/**
* 키보드 단축키 초기화
*/
initializeKeyboardShortcuts() {
if (window.keyboardShortcuts) {
window.keyboardShortcuts.setUser(this.currentUser);
console.log('⌨️ 키보드 단축키 사용자 설정 완료');
}
}
/**
* 페이지 프리로더 초기화
*/
initializePreloader() {
if (window.pagePreloader) {
// 사용자 설정 후 프리로더 초기화
setTimeout(() => {
window.pagePreloader.init();
console.log('🚀 페이지 프리로더 초기화 완료');
}, 1000); // 권한 시스템 로드 후 실행
}
}
}
// 전역 인스턴스
window.commonHeader = new CommonHeader();
// 전역 함수로 노출
window.CommonHeader = CommonHeader;

View File

@@ -0,0 +1,359 @@
/**
* 모바일 친화적 캘린더 컴포넌트
* 터치 및 스와이프 지원, 날짜 범위 선택 기능
*/
class MobileCalendar {
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.options = {
locale: 'ko-KR',
startDate: null,
endDate: null,
maxRange: 90, // 최대 90일 범위
onDateSelect: null,
onRangeSelect: null,
...options
};
this.currentDate = new Date();
this.selectedStartDate = null;
this.selectedEndDate = null;
this.isSelecting = false;
this.touchStartX = 0;
this.touchStartY = 0;
this.init();
}
init() {
this.render();
this.bindEvents();
}
render() {
const calendarHTML = `
<div class="mobile-calendar">
<!-- 빠른 선택 버튼들 -->
<div class="quick-select-buttons mb-4">
<div class="flex gap-2 overflow-x-auto pb-2">
<button class="quick-btn" data-range="today">오늘</button>
<button class="quick-btn" data-range="week">이번 주</button>
<button class="quick-btn" data-range="month">이번 달</button>
<button class="quick-btn" data-range="last7">최근 7일</button>
<button class="quick-btn" data-range="last30">최근 30일</button>
<button class="quick-btn" data-range="all">전체</button>
</div>
</div>
<!-- 캘린더 헤더 -->
<div class="calendar-header flex items-center justify-between mb-4">
<button class="nav-btn" id="prevMonth">
<i class="fas fa-chevron-left"></i>
</button>
<h3 class="month-year text-lg font-semibold" id="monthYear"></h3>
<button class="nav-btn" id="nextMonth">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<!-- 요일 헤더 -->
<div class="weekdays grid grid-cols-7 gap-1 mb-2">
<div class="weekday">일</div>
<div class="weekday">월</div>
<div class="weekday">화</div>
<div class="weekday">수</div>
<div class="weekday">목</div>
<div class="weekday">금</div>
<div class="weekday">토</div>
</div>
<!-- 날짜 그리드 -->
<div class="calendar-grid grid grid-cols-7 gap-1" id="calendarGrid">
<!-- 날짜들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 선택된 범위 표시 -->
<div class="selected-range mt-4 p-3 bg-blue-50 rounded-lg" id="selectedRange" style="display: none;">
<div class="flex items-center justify-between">
<span class="text-sm text-blue-700" id="rangeText"></span>
<button class="clear-btn text-blue-600 hover:text-blue-800" id="clearRange">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 사용법 안내 -->
<div class="usage-hint mt-3 text-xs text-gray-500 text-center">
📅 날짜를 터치하여 시작일을 선택하고, 다시 터치하여 종료일을 선택하세요
</div>
</div>
`;
this.container.innerHTML = calendarHTML;
this.updateCalendar();
}
updateCalendar() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
// 월/년 표시 업데이트
document.getElementById('monthYear').textContent =
`${year}${month + 1}`;
// 캘린더 그리드 생성
this.generateCalendarGrid(year, month);
}
generateCalendarGrid(year, month) {
const grid = document.getElementById('calendarGrid');
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
let html = '';
const today = new Date();
// 6주 표시 (42일)
for (let i = 0; i < 42; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
const isCurrentMonth = date.getMonth() === month;
const isToday = this.isSameDate(date, today);
const isSelected = this.isDateInRange(date);
const isStart = this.selectedStartDate && this.isSameDate(date, this.selectedStartDate);
const isEnd = this.selectedEndDate && this.isSameDate(date, this.selectedEndDate);
let classes = ['calendar-day'];
if (!isCurrentMonth) classes.push('other-month');
if (isToday) classes.push('today');
if (isSelected) classes.push('selected');
if (isStart) classes.push('range-start');
if (isEnd) classes.push('range-end');
html += `
<div class="${classes.join(' ')}"
data-date="${date.toISOString().split('T')[0]}"
data-timestamp="${date.getTime()}">
${date.getDate()}
</div>
`;
}
grid.innerHTML = html;
}
bindEvents() {
// 빠른 선택 버튼들
this.container.querySelectorAll('.quick-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const range = e.target.dataset.range;
this.selectQuickRange(range);
});
});
// 월 네비게이션
document.getElementById('prevMonth').addEventListener('click', () => {
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.updateCalendar();
});
document.getElementById('nextMonth').addEventListener('click', () => {
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.updateCalendar();
});
// 날짜 선택
this.container.addEventListener('click', (e) => {
if (e.target.classList.contains('calendar-day')) {
this.handleDateClick(e.target);
}
});
// 터치 이벤트 (스와이프 지원)
this.container.addEventListener('touchstart', (e) => {
this.touchStartX = e.touches[0].clientX;
this.touchStartY = e.touches[0].clientY;
});
this.container.addEventListener('touchend', (e) => {
if (!this.touchStartX || !this.touchStartY) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const diffX = this.touchStartX - touchEndX;
const diffY = this.touchStartY - touchEndY;
// 수평 스와이프가 수직 스와이프보다 클 때만 처리
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (diffX > 0) {
// 왼쪽으로 스와이프 - 다음 달
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
} else {
// 오른쪽으로 스와이프 - 이전 달
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
}
this.updateCalendar();
}
this.touchStartX = 0;
this.touchStartY = 0;
});
// 범위 지우기
document.getElementById('clearRange').addEventListener('click', () => {
this.clearSelection();
});
}
handleDateClick(dayElement) {
const dateStr = dayElement.dataset.date;
const date = new Date(dateStr + 'T00:00:00');
if (!this.selectedStartDate || (this.selectedStartDate && this.selectedEndDate)) {
// 새로운 선택 시작
this.selectedStartDate = date;
this.selectedEndDate = null;
this.isSelecting = true;
} else if (this.selectedStartDate && !this.selectedEndDate) {
// 종료일 선택
if (date < this.selectedStartDate) {
// 시작일보다 이전 날짜를 선택하면 시작일로 설정
this.selectedEndDate = this.selectedStartDate;
this.selectedStartDate = date;
} else {
this.selectedEndDate = date;
}
this.isSelecting = false;
// 범위가 너무 크면 제한
const daysDiff = Math.abs(this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24);
if (daysDiff > this.options.maxRange) {
alert(`최대 ${this.options.maxRange}일까지만 선택할 수 있습니다.`);
this.clearSelection();
return;
}
}
this.updateCalendar();
this.updateSelectedRange();
// 콜백 호출
if (this.selectedStartDate && this.selectedEndDate && this.options.onRangeSelect) {
this.options.onRangeSelect(this.selectedStartDate, this.selectedEndDate);
}
}
selectQuickRange(range) {
const today = new Date();
let startDate, endDate;
switch (range) {
case 'today':
startDate = endDate = new Date(today);
break;
case 'week':
startDate = new Date(today);
startDate.setDate(today.getDate() - today.getDay());
endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 6);
break;
case 'month':
startDate = new Date(today.getFullYear(), today.getMonth(), 1);
endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0);
break;
case 'last7':
endDate = new Date(today);
startDate = new Date(today);
startDate.setDate(today.getDate() - 6);
break;
case 'last30':
endDate = new Date(today);
startDate = new Date(today);
startDate.setDate(today.getDate() - 29);
break;
case 'all':
this.clearSelection();
if (this.options.onRangeSelect) {
this.options.onRangeSelect(null, null);
}
return;
}
this.selectedStartDate = startDate;
this.selectedEndDate = endDate;
this.updateCalendar();
this.updateSelectedRange();
if (this.options.onRangeSelect) {
this.options.onRangeSelect(startDate, endDate);
}
}
updateSelectedRange() {
const rangeElement = document.getElementById('selectedRange');
const rangeText = document.getElementById('rangeText');
if (this.selectedStartDate && this.selectedEndDate) {
const startStr = this.formatDate(this.selectedStartDate);
const endStr = this.formatDate(this.selectedEndDate);
const daysDiff = Math.ceil((this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24)) + 1;
rangeText.textContent = `${startStr} ~ ${endStr} (${daysDiff}일)`;
rangeElement.style.display = 'block';
} else if (this.selectedStartDate) {
rangeText.textContent = `시작일: ${this.formatDate(this.selectedStartDate)} (종료일을 선택하세요)`;
rangeElement.style.display = 'block';
} else {
rangeElement.style.display = 'none';
}
}
clearSelection() {
this.selectedStartDate = null;
this.selectedEndDate = null;
this.isSelecting = false;
this.updateCalendar();
this.updateSelectedRange();
}
isDateInRange(date) {
if (!this.selectedStartDate) return false;
if (!this.selectedEndDate) return this.isSameDate(date, this.selectedStartDate);
return date >= this.selectedStartDate && date <= this.selectedEndDate;
}
isSameDate(date1, date2) {
return date1.toDateString() === date2.toDateString();
}
formatDate(date) {
return date.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric'
});
}
// 외부에서 호출할 수 있는 메서드들
getSelectedRange() {
return {
startDate: this.selectedStartDate,
endDate: this.selectedEndDate
};
}
setSelectedRange(startDate, endDate) {
this.selectedStartDate = startDate;
this.selectedEndDate = endDate;
this.updateCalendar();
this.updateSelectedRange();
}
}
// 전역으로 노출
window.MobileCalendar = MobileCalendar;

View File

@@ -0,0 +1,272 @@
/**
* 중앙화된 인증 관리자
* 페이지 간 이동 시 불필요한 API 호출을 방지하고 인증 상태를 효율적으로 관리
*/
class AuthManager {
constructor() {
this.currentUser = null;
this.isAuthenticated = false;
this.lastAuthCheck = null;
this.authCheckInterval = 5 * 60 * 1000; // 5분마다 토큰 유효성 체크
this.listeners = new Set();
// 초기화
this.init();
}
/**
* 초기화
*/
init() {
console.log('🔐 AuthManager 초기화');
// localStorage에서 사용자 정보 복원
this.restoreUserFromStorage();
// 토큰 만료 체크 타이머 설정
this.setupTokenExpiryCheck();
// 페이지 가시성 변경 시 토큰 체크
document.addEventListener('visibilitychange', () => {
if (!document.hidden && this.shouldCheckAuth()) {
this.refreshAuth();
}
});
}
/**
* localStorage에서 사용자 정보 복원
*/
restoreUserFromStorage() {
const token = localStorage.getItem('access_token');
const userStr = localStorage.getItem('currentUser');
console.log('🔍 localStorage 확인:');
console.log('- 토큰 존재:', !!token);
console.log('- 사용자 정보 존재:', !!userStr);
if (token && userStr) {
try {
this.currentUser = JSON.parse(userStr);
this.isAuthenticated = true;
this.lastAuthCheck = Date.now();
console.log('✅ 저장된 사용자 정보 복원:', this.currentUser.username);
} catch (error) {
console.error('❌ 사용자 정보 복원 실패:', error);
this.clearAuth();
}
} else {
console.log('❌ 토큰 또는 사용자 정보 없음 - 로그인 필요');
}
}
/**
* 인증이 필요한지 확인
*/
shouldCheckAuth() {
if (!this.isAuthenticated) return true;
if (!this.lastAuthCheck) return true;
const timeSinceLastCheck = Date.now() - this.lastAuthCheck;
return timeSinceLastCheck > this.authCheckInterval;
}
/**
* 인증 상태 확인 (필요시에만 API 호출)
*/
async checkAuth() {
console.log('🔍 AuthManager.checkAuth() 호출됨');
console.log('- 현재 인증 상태:', this.isAuthenticated);
console.log('- 현재 사용자:', this.currentUser?.username || 'null');
const token = localStorage.getItem('access_token');
if (!token) {
console.log('❌ 토큰 없음 - 인증 실패');
this.clearAuth();
return null;
}
// 최근에 체크했으면 캐시된 정보 사용
if (this.isAuthenticated && !this.shouldCheckAuth()) {
console.log('✅ 캐시된 인증 정보 사용:', this.currentUser.username);
return this.currentUser;
}
// API 호출이 필요한 경우
console.log('🔄 API 호출 필요 - refreshAuth 실행');
return await this.refreshAuth();
}
/**
* 강제로 인증 정보 새로고침 (API 호출)
*/
async refreshAuth() {
console.log('🔄 인증 정보 새로고침 (API 호출)');
try {
// API가 로드될 때까지 대기
await this.waitForAPI();
const user = await AuthAPI.getCurrentUser();
this.currentUser = user;
this.isAuthenticated = true;
this.lastAuthCheck = Date.now();
// localStorage 업데이트
localStorage.setItem('currentUser', JSON.stringify(user));
console.log('✅ 인증 정보 새로고침 완료:', user.username);
// 리스너들에게 알림
this.notifyListeners('auth-success', user);
return user;
} catch (error) {
console.error('❌ 인증 실패:', error);
this.clearAuth();
this.notifyListeners('auth-failed', error);
throw error;
}
}
/**
* API 로드 대기
*/
async waitForAPI() {
let attempts = 0;
const maxAttempts = 50;
while (typeof AuthAPI === 'undefined' && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (typeof AuthAPI === 'undefined') {
throw new Error('AuthAPI를 로드할 수 없습니다');
}
}
/**
* 인증 정보 클리어
*/
clearAuth() {
console.log('🧹 인증 정보 클리어');
this.currentUser = null;
this.isAuthenticated = false;
this.lastAuthCheck = null;
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
this.notifyListeners('auth-cleared');
}
/**
* 로그인 처리
*/
async login(username, password) {
console.log('🔑 로그인 시도:', username);
try {
await this.waitForAPI();
const data = await AuthAPI.login(username, password);
this.currentUser = data.user;
this.isAuthenticated = true;
this.lastAuthCheck = Date.now();
// localStorage 저장
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('currentUser', JSON.stringify(data.user));
console.log('✅ 로그인 성공:', data.user.username);
this.notifyListeners('login-success', data.user);
return data;
} catch (error) {
console.error('❌ 로그인 실패:', error);
this.clearAuth();
throw error;
}
}
/**
* 로그아웃 처리
*/
logout() {
console.log('🚪 로그아웃');
this.clearAuth();
this.notifyListeners('logout');
// 로그인 페이지로 이동
window.location.href = '/index.html';
}
/**
* 토큰 만료 체크 타이머 설정
*/
setupTokenExpiryCheck() {
// 30분마다 토큰 유효성 체크
setInterval(() => {
if (this.isAuthenticated) {
console.log('⏰ 정기 토큰 유효성 체크');
this.refreshAuth().catch(() => {
console.log('🔄 토큰 만료 - 로그아웃 처리');
this.logout();
});
}
}, 30 * 60 * 1000);
}
/**
* 이벤트 리스너 등록
*/
addEventListener(callback) {
this.listeners.add(callback);
}
/**
* 이벤트 리스너 제거
*/
removeEventListener(callback) {
this.listeners.delete(callback);
}
/**
* 리스너들에게 알림
*/
notifyListeners(event, data = null) {
this.listeners.forEach(callback => {
try {
callback(event, data);
} catch (error) {
console.error('리스너 콜백 오류:', error);
}
});
}
/**
* 현재 사용자 정보 반환
*/
getCurrentUser() {
return this.currentUser;
}
/**
* 인증 상태 반환
*/
isLoggedIn() {
return this.isAuthenticated && !!this.currentUser;
}
}
// 전역 인스턴스 생성
window.authManager = new AuthManager();
console.log('🎯 AuthManager 로드 완료');

View File

@@ -0,0 +1,621 @@
/**
* 키보드 단축키 관리자
* 전역 키보드 단축키를 관리하고 사용자 경험을 향상시킵니다.
*/
class KeyboardShortcutManager {
constructor() {
this.shortcuts = new Map();
this.isEnabled = true;
this.helpModalVisible = false;
this.currentUser = null;
// 기본 단축키 등록
this.registerDefaultShortcuts();
// 이벤트 리스너 등록
this.bindEvents();
}
/**
* 기본 단축키 등록
*/
registerDefaultShortcuts() {
// 전역 단축키
this.register('?', () => this.showHelpModal(), '도움말 표시');
this.register('Escape', () => this.handleEscape(), '모달/메뉴 닫기');
// 네비게이션 단축키
this.register('g h', () => this.navigateToPage('/index.html', 'issues_create'), '홈 (부적합 등록)');
this.register('g v', () => this.navigateToPage('/issue-view.html', 'issues_view'), '부적합 조회');
this.register('g d', () => this.navigateToPage('/daily-work.html', 'daily_work'), '일일 공수');
this.register('g p', () => this.navigateToPage('/project-management.html', 'projects_manage'), '프로젝트 관리');
this.register('g r', () => this.navigateToPage('/reports.html', 'reports'), '보고서');
this.register('g a', () => this.navigateToPage('/admin.html', 'users_manage'), '관리자');
// 액션 단축키
this.register('n', () => this.triggerNewAction(), '새 항목 생성');
this.register('s', () => this.triggerSaveAction(), '저장');
this.register('r', () => this.triggerRefreshAction(), '새로고침');
this.register('f', () => this.focusSearchField(), '검색 포커스');
// 관리자 전용 단축키
this.register('ctrl+shift+u', () => this.navigateToPage('/admin.html', 'users_manage'), '사용자 관리 (관리자)');
console.log('⌨️ 키보드 단축키 등록 완료');
}
/**
* 단축키 등록
* @param {string} combination - 키 조합 (예: 'ctrl+s', 'g h')
* @param {function} callback - 실행할 함수
* @param {string} description - 설명
* @param {object} options - 옵션
*/
register(combination, callback, description, options = {}) {
const normalizedCombo = this.normalizeKeyCombination(combination);
this.shortcuts.set(normalizedCombo, {
callback,
description,
requiresAuth: options.requiresAuth !== false,
adminOnly: options.adminOnly || false,
pageSpecific: options.pageSpecific || null
});
}
/**
* 키 조합 정규화
*/
normalizeKeyCombination(combination) {
return combination
.toLowerCase()
.split(' ')
.map(part => part.trim())
.filter(part => part.length > 0)
.join(' ');
}
/**
* 이벤트 바인딩
*/
bindEvents() {
let keySequence = [];
let sequenceTimer = null;
document.addEventListener('keydown', (e) => {
if (!this.isEnabled) return;
// 입력 필드에서는 일부 단축키만 허용
if (this.isInputField(e.target)) {
this.handleInputFieldShortcuts(e);
return;
}
// 키 조합 생성
const keyCombo = this.createKeyCombo(e);
// 시퀀스 타이머 리셋
if (sequenceTimer) {
clearTimeout(sequenceTimer);
}
// 단일 키 단축키 확인
if (this.handleShortcut(keyCombo, e)) {
return;
}
// 시퀀스 키 처리
keySequence.push(keyCombo);
// 시퀀스 단축키 확인
const sequenceCombo = keySequence.join(' ');
if (this.handleShortcut(sequenceCombo, e)) {
keySequence = [];
return;
}
// 시퀀스 타이머 설정 (1초 후 리셋)
sequenceTimer = setTimeout(() => {
keySequence = [];
}, 1000);
});
}
/**
* 키 조합 생성
*/
createKeyCombo(event) {
const parts = [];
if (event.ctrlKey) parts.push('ctrl');
if (event.altKey) parts.push('alt');
if (event.shiftKey) parts.push('shift');
if (event.metaKey) parts.push('meta');
const key = event.key.toLowerCase();
// 특수 키 처리
const specialKeys = {
' ': 'space',
'enter': 'enter',
'escape': 'escape',
'tab': 'tab',
'backspace': 'backspace',
'delete': 'delete',
'arrowup': 'up',
'arrowdown': 'down',
'arrowleft': 'left',
'arrowright': 'right'
};
const normalizedKey = specialKeys[key] || key;
parts.push(normalizedKey);
return parts.join('+');
}
/**
* 단축키 처리
*/
handleShortcut(combination, event) {
const shortcut = this.shortcuts.get(combination);
if (!shortcut) return false;
// 권한 확인
if (shortcut.requiresAuth && !this.currentUser) {
return false;
}
if (shortcut.adminOnly && this.currentUser?.role !== 'admin') {
return false;
}
// 페이지별 단축키 확인
if (shortcut.pageSpecific && !this.isCurrentPage(shortcut.pageSpecific)) {
return false;
}
// 기본 동작 방지
event.preventDefault();
event.stopPropagation();
// 콜백 실행
try {
shortcut.callback(event);
console.log(`⌨️ 단축키 실행: ${combination}`);
} catch (error) {
console.error('단축키 실행 실패:', combination, error);
}
return true;
}
/**
* 입력 필드 확인
*/
isInputField(element) {
const inputTypes = ['input', 'textarea', 'select'];
const contentEditable = element.contentEditable === 'true';
return inputTypes.includes(element.tagName.toLowerCase()) || contentEditable;
}
/**
* 입력 필드에서의 단축키 처리
*/
handleInputFieldShortcuts(event) {
const keyCombo = this.createKeyCombo(event);
// 입력 필드에서 허용되는 단축키
const allowedInInput = ['escape', 'ctrl+s', 'ctrl+enter'];
if (allowedInInput.includes(keyCombo)) {
this.handleShortcut(keyCombo, event);
}
}
/**
* 현재 페이지 확인
*/
isCurrentPage(pageId) {
return window.commonHeader?.currentPage === pageId;
}
/**
* 페이지 네비게이션
*/
navigateToPage(url, pageId) {
// 권한 확인
if (pageId && window.canAccessPage && !window.canAccessPage(pageId)) {
this.showNotification('해당 페이지에 접근할 권한이 없습니다.', 'warning');
return;
}
// 현재 페이지와 같으면 무시
if (window.location.pathname === url) {
return;
}
// 부드러운 전환
if (window.CommonHeader) {
window.CommonHeader.navigateToPage(
{ preventDefault: () => {}, stopPropagation: () => {} },
url,
pageId
);
} else {
window.location.href = url;
}
}
/**
* 새 항목 생성 액션
*/
triggerNewAction() {
const newButtons = [
'button[onclick*="showAddModal"]',
'button[onclick*="addNew"]',
'#addBtn',
'#add-btn',
'.btn-add',
'button:contains("추가")',
'button:contains("등록")',
'button:contains("새")'
];
for (const selector of newButtons) {
const button = document.querySelector(selector);
if (button && !button.disabled) {
button.click();
this.showNotification('새 항목 생성', 'info');
return;
}
}
this.showNotification('새 항목 생성 버튼을 찾을 수 없습니다.', 'warning');
}
/**
* 저장 액션
*/
triggerSaveAction() {
const saveButtons = [
'button[type="submit"]',
'button[onclick*="save"]',
'#saveBtn',
'#save-btn',
'.btn-save',
'button:contains("저장")',
'button:contains("등록")'
];
for (const selector of saveButtons) {
const button = document.querySelector(selector);
if (button && !button.disabled) {
button.click();
this.showNotification('저장 실행', 'success');
return;
}
}
this.showNotification('저장 버튼을 찾을 수 없습니다.', 'warning');
}
/**
* 새로고침 액션
*/
triggerRefreshAction() {
const refreshButtons = [
'button[onclick*="load"]',
'button[onclick*="refresh"]',
'#refreshBtn',
'#refresh-btn',
'.btn-refresh'
];
for (const selector of refreshButtons) {
const button = document.querySelector(selector);
if (button && !button.disabled) {
button.click();
this.showNotification('새로고침 실행', 'info');
return;
}
}
// 기본 새로고침
window.location.reload();
}
/**
* 검색 필드 포커스
*/
focusSearchField() {
const searchFields = [
'input[type="search"]',
'input[placeholder*="검색"]',
'input[placeholder*="찾기"]',
'#searchInput',
'#search',
'.search-input'
];
for (const selector of searchFields) {
const field = document.querySelector(selector);
if (field) {
field.focus();
field.select();
this.showNotification('검색 필드 포커스', 'info');
return;
}
}
this.showNotification('검색 필드를 찾을 수 없습니다.', 'warning');
}
/**
* Escape 키 처리
*/
handleEscape() {
// 모달 닫기
const modals = document.querySelectorAll('.modal, [id*="modal"], [class*="modal"]');
for (const modal of modals) {
if (!modal.classList.contains('hidden') && modal.style.display !== 'none') {
modal.classList.add('hidden');
this.showNotification('모달 닫기', 'info');
return;
}
}
// 드롭다운 메뉴 닫기
const dropdowns = document.querySelectorAll('[id*="menu"], [class*="dropdown"]');
for (const dropdown of dropdowns) {
if (!dropdown.classList.contains('hidden')) {
dropdown.classList.add('hidden');
return;
}
}
// 포커스 해제
if (document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}
}
/**
* 도움말 모달 표시
*/
showHelpModal() {
if (this.helpModalVisible) {
this.hideHelpModal();
return;
}
const modal = this.createHelpModal();
document.body.appendChild(modal);
this.helpModalVisible = true;
// 외부 클릭으로 닫기
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideHelpModal();
}
});
}
/**
* 도움말 모달 생성
*/
createHelpModal() {
const modal = document.createElement('div');
modal.id = 'keyboard-shortcuts-modal';
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
const shortcuts = this.getAvailableShortcuts();
const shortcutGroups = this.groupShortcuts(shortcuts);
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900">
<i class="fas fa-keyboard mr-3 text-blue-600"></i>
키보드 단축키
</h2>
<button onclick="keyboardShortcuts.hideHelpModal()"
class="text-gray-400 hover:text-gray-600 text-2xl">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
${Object.entries(shortcutGroups).map(([group, items]) => `
<div>
<h3 class="text-lg font-semibold text-gray-800 mb-4 border-b pb-2">
${group}
</h3>
<div class="space-y-3">
${items.map(item => `
<div class="flex items-center justify-between">
<span class="text-gray-600">${item.description}</span>
<div class="flex space-x-1">
${item.keys.map(key => `
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">
${key}
</kbd>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
<div class="mt-8 p-4 bg-blue-50 rounded-lg">
<div class="flex items-start">
<i class="fas fa-info-circle text-blue-600 mt-1 mr-3"></i>
<div>
<h4 class="font-semibold text-blue-900 mb-2">사용 팁</h4>
<ul class="text-blue-800 text-sm space-y-1">
<li>• 입력 필드에서는 일부 단축키만 작동합니다.</li>
<li>• 'g' 키를 누른 후 다른 키를 눌러 페이지를 이동할 수 있습니다.</li>
<li>• ESC 키로 모달이나 메뉴를 닫을 수 있습니다.</li>
<li>• '?' 키로 언제든 이 도움말을 볼 수 있습니다.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
`;
return modal;
}
/**
* 사용 가능한 단축키 가져오기
*/
getAvailableShortcuts() {
const available = [];
for (const [combination, shortcut] of this.shortcuts) {
// 권한 확인
if (shortcut.requiresAuth && !this.currentUser) continue;
if (shortcut.adminOnly && this.currentUser?.role !== 'admin') continue;
available.push({
combination,
description: shortcut.description,
keys: this.formatKeyCombo(combination)
});
}
return available;
}
/**
* 단축키 그룹화
*/
groupShortcuts(shortcuts) {
const groups = {
'네비게이션': [],
'액션': [],
'전역': []
};
shortcuts.forEach(shortcut => {
if (shortcut.combination.startsWith('g ')) {
groups['네비게이션'].push(shortcut);
} else if (['n', 's', 'r', 'f'].includes(shortcut.combination)) {
groups['액션'].push(shortcut);
} else {
groups['전역'].push(shortcut);
}
});
return groups;
}
/**
* 키 조합 포맷팅
*/
formatKeyCombo(combination) {
return combination
.split(' ')
.map(part => {
return part
.split('+')
.map(key => {
const keyNames = {
'ctrl': 'Ctrl',
'alt': 'Alt',
'shift': 'Shift',
'meta': 'Cmd',
'space': 'Space',
'enter': 'Enter',
'escape': 'Esc',
'tab': 'Tab'
};
return keyNames[key] || key.toUpperCase();
})
.join(' + ');
});
}
/**
* 도움말 모달 숨기기
*/
hideHelpModal() {
const modal = document.getElementById('keyboard-shortcuts-modal');
if (modal) {
modal.remove();
this.helpModalVisible = false;
}
}
/**
* 알림 표시
*/
showNotification(message, type = 'info') {
// 기존 알림 제거
const existing = document.getElementById('shortcut-notification');
if (existing) existing.remove();
const notification = document.createElement('div');
notification.id = 'shortcut-notification';
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg shadow-lg z-50 transition-all duration-300 ${this.getNotificationClass(type)}`;
notification.textContent = message;
document.body.appendChild(notification);
// 3초 후 자동 제거
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 3000);
}
/**
* 알림 클래스 가져오기
*/
getNotificationClass(type) {
const classes = {
'info': 'bg-blue-600 text-white',
'success': 'bg-green-600 text-white',
'warning': 'bg-yellow-600 text-white',
'error': 'bg-red-600 text-white'
};
return classes[type] || classes.info;
}
/**
* 사용자 설정
*/
setUser(user) {
this.currentUser = user;
}
/**
* 단축키 활성화/비활성화
*/
setEnabled(enabled) {
this.isEnabled = enabled;
console.log(`⌨️ 키보드 단축키 ${enabled ? '활성화' : '비활성화'}`);
}
/**
* 단축키 제거
*/
unregister(combination) {
const normalizedCombo = this.normalizeKeyCombination(combination);
return this.shortcuts.delete(normalizedCombo);
}
}
// 전역 인스턴스
window.keyboardShortcuts = new KeyboardShortcutManager();

View File

@@ -0,0 +1,368 @@
/**
* 페이지 관리자
* 모듈화된 페이지들의 생명주기를 관리하고 부드러운 전환을 제공
*/
class PageManager {
constructor() {
this.currentPage = null;
this.loadedModules = new Map();
this.pageHistory = [];
}
/**
* 페이지 초기화
* @param {string} pageId - 페이지 식별자
* @param {Object} options - 초기화 옵션
*/
async initializePage(pageId, options = {}) {
try {
// 로딩 표시
this.showPageLoader();
// 사용자 인증 확인
const user = await this.checkAuthentication();
if (!user) return;
// 공통 헤더 초기화
await this.initializeCommonHeader(user, pageId);
// 페이지별 권한 체크
if (!this.checkPagePermission(pageId, user)) {
this.redirectToAccessiblePage();
return;
}
// 페이지 모듈 로드 및 초기화
await this.loadPageModule(pageId, options);
// 페이지 히스토리 업데이트
this.updatePageHistory(pageId);
// 로딩 숨기기
this.hidePageLoader();
} catch (error) {
console.error('페이지 초기화 실패:', error);
this.showErrorPage(error);
}
}
/**
* 사용자 인증 확인
*/
async checkAuthentication() {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/index.html';
return null;
}
try {
// API가 로드될 때까지 대기
await this.waitForAPI();
const user = await AuthAPI.getCurrentUser();
localStorage.setItem('currentUser', JSON.stringify(user));
return user;
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
return null;
}
}
/**
* API 로드 대기
*/
async waitForAPI() {
let attempts = 0;
const maxAttempts = 50;
while (!window.AuthAPI && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (!window.AuthAPI) {
throw new Error('API를 로드할 수 없습니다.');
}
}
/**
* 공통 헤더 초기화
*/
async initializeCommonHeader(user, pageId) {
// 권한 시스템 초기화
if (window.pagePermissionManager) {
window.pagePermissionManager.setUser(user);
}
// 공통 헤더 초기화
if (window.commonHeader) {
await window.commonHeader.init(user, pageId);
}
}
/**
* 페이지 권한 체크
*/
checkPagePermission(pageId, user) {
// admin은 모든 페이지 접근 가능
if (user.role === 'admin') {
return true;
}
// 권한 시스템이 로드되지 않았으면 기본 페이지만 허용
if (!window.canAccessPage) {
return ['issues_create', 'issues_view'].includes(pageId);
}
return window.canAccessPage(pageId);
}
/**
* 접근 가능한 페이지로 리다이렉트
*/
redirectToAccessiblePage() {
alert('이 페이지에 접근할 권한이 없습니다.');
// 기본적으로 접근 가능한 페이지로 이동
if (window.canAccessPage && window.canAccessPage('issues_view')) {
window.location.href = '/issue-view.html';
} else {
window.location.href = '/index.html';
}
}
/**
* 페이지 모듈 로드
*/
async loadPageModule(pageId, options) {
// 이미 로드된 모듈이 있으면 재사용
if (this.loadedModules.has(pageId)) {
const module = this.loadedModules.get(pageId);
if (module.reinitialize) {
await module.reinitialize(options);
}
return;
}
// 페이지별 모듈 로드
const module = await this.createPageModule(pageId, options);
if (module) {
this.loadedModules.set(pageId, module);
this.currentPage = pageId;
}
}
/**
* 페이지 모듈 생성
*/
async createPageModule(pageId, options) {
switch (pageId) {
case 'issues_create':
return new IssuesCreateModule(options);
case 'issues_view':
return new IssuesViewModule(options);
case 'issues_manage':
return new IssuesManageModule(options);
case 'projects_manage':
return new ProjectsManageModule(options);
case 'daily_work':
return new DailyWorkModule(options);
case 'reports':
return new ReportsModule(options);
case 'users_manage':
return new UsersManageModule(options);
default:
console.warn(`알 수 없는 페이지 ID: ${pageId}`);
return null;
}
}
/**
* 페이지 히스토리 업데이트
*/
updatePageHistory(pageId) {
this.pageHistory.push({
pageId,
timestamp: new Date(),
url: window.location.href
});
// 히스토리 크기 제한 (최대 10개)
if (this.pageHistory.length > 10) {
this.pageHistory.shift();
}
}
/**
* 페이지 로더 표시
*/
showPageLoader() {
const existingLoader = document.getElementById('page-loader');
if (existingLoader) return;
const loader = document.createElement('div');
loader.id = 'page-loader';
loader.className = 'fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50';
loader.innerHTML = `
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p class="text-lg font-medium text-gray-700">페이지를 로드하는 중...</p>
<p class="text-sm text-gray-500 mt-1">잠시만 기다려주세요</p>
</div>
`;
document.body.appendChild(loader);
}
/**
* 페이지 로더 숨기기
*/
hidePageLoader() {
const loader = document.getElementById('page-loader');
if (loader) {
loader.remove();
}
}
/**
* 에러 페이지 표시
*/
showErrorPage(error) {
this.hidePageLoader();
const errorContainer = document.createElement('div');
errorContainer.className = 'fixed inset-0 bg-gray-50 flex items-center justify-center z-50';
errorContainer.innerHTML = `
<div class="text-center max-w-md mx-auto p-8">
<div class="mb-6">
<i class="fas fa-exclamation-triangle text-6xl text-red-500"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">페이지 로드 실패</h2>
<p class="text-gray-600 mb-6">${error.message || '알 수 없는 오류가 발생했습니다.'}</p>
<div class="space-x-4">
<button onclick="window.location.reload()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
다시 시도
</button>
<button onclick="window.location.href='/index.html'"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
홈으로
</button>
</div>
</div>
`;
document.body.appendChild(errorContainer);
}
/**
* 페이지 정리
*/
cleanup() {
if (this.currentPage && this.loadedModules.has(this.currentPage)) {
const module = this.loadedModules.get(this.currentPage);
if (module.cleanup) {
module.cleanup();
}
}
}
}
/**
* 기본 페이지 모듈 클래스
* 모든 페이지 모듈이 상속받아야 하는 기본 클래스
*/
class BasePageModule {
constructor(options = {}) {
this.options = options;
this.initialized = false;
this.eventListeners = [];
}
/**
* 모듈 초기화 (하위 클래스에서 구현)
*/
async initialize() {
throw new Error('initialize 메서드를 구현해야 합니다.');
}
/**
* 모듈 재초기화
*/
async reinitialize(options = {}) {
this.cleanup();
this.options = { ...this.options, ...options };
await this.initialize();
}
/**
* 이벤트 리스너 등록 (자동 정리를 위해)
*/
addEventListener(element, event, handler) {
element.addEventListener(event, handler);
this.eventListeners.push({ element, event, handler });
}
/**
* 모듈 정리
*/
cleanup() {
// 등록된 이벤트 리스너 제거
this.eventListeners.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
this.eventListeners = [];
this.initialized = false;
}
/**
* 로딩 표시
*/
showLoading(container, message = '로딩 중...') {
if (typeof container === 'string') {
container = document.getElementById(container);
}
if (container) {
container.innerHTML = `
<div class="flex items-center justify-center py-12">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div>
<p class="text-gray-600">${message}</p>
</div>
</div>
`;
}
}
/**
* 에러 표시
*/
showError(container, message = '오류가 발생했습니다.') {
if (typeof container === 'string') {
container = document.getElementById(container);
}
if (container) {
container.innerHTML = `
<div class="flex items-center justify-center py-12">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-3"></i>
<p class="text-gray-600">${message}</p>
</div>
</div>
`;
}
}
}
// 전역 인스턴스
window.pageManager = new PageManager();
window.BasePageModule = BasePageModule;

View File

@@ -0,0 +1,317 @@
/**
* 페이지 프리로더
* 사용자가 방문할 가능성이 높은 페이지들을 미리 로드하여 성능 향상
*/
class PagePreloader {
constructor() {
this.preloadedPages = new Set();
this.preloadQueue = [];
this.isPreloading = false;
this.preloadCache = new Map();
this.resourceCache = new Map();
}
/**
* 프리로더 초기화
*/
init() {
// 유휴 시간에 프리로딩 시작
this.schedulePreloading();
// 링크 호버 시 프리로딩
this.setupHoverPreloading();
// 서비스 워커 등록 (캐싱용)
this.registerServiceWorker();
}
/**
* 우선순위 기반 프리로딩 스케줄링
*/
schedulePreloading() {
// 현재 사용자 권한에 따른 접근 가능한 페이지들
const accessiblePages = this.getAccessiblePages();
// 우선순위 설정
const priorityPages = this.getPriorityPages(accessiblePages);
// 유휴 시간에 프리로딩 시작
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
this.startPreloading(priorityPages);
}, { timeout: 2000 });
} else {
// requestIdleCallback 미지원 브라우저
setTimeout(() => {
this.startPreloading(priorityPages);
}, 1000);
}
}
/**
* 접근 가능한 페이지 목록 가져오기
*/
getAccessiblePages() {
const allPages = [
{ id: 'issues_create', url: '/index.html', priority: 1 },
{ id: 'issues_view', url: '/issue-view.html', priority: 1 },
{ id: 'issues_manage', url: '/index.html#list', priority: 2 },
{ id: 'projects_manage', url: '/project-management.html', priority: 3 },
{ id: 'daily_work', url: '/daily-work.html', priority: 2 },
{ id: 'reports', url: '/reports.html', priority: 3 },
{ id: 'users_manage', url: '/admin.html', priority: 4 }
];
// 권한 체크
return allPages.filter(page => {
if (!window.canAccessPage) return false;
return window.canAccessPage(page.id);
});
}
/**
* 우선순위 기반 페이지 정렬
*/
getPriorityPages(pages) {
return pages
.sort((a, b) => a.priority - b.priority)
.slice(0, 3); // 최대 3개 페이지만 프리로드
}
/**
* 프리로딩 시작
*/
async startPreloading(pages) {
if (this.isPreloading) return;
this.isPreloading = true;
console.log('🚀 페이지 프리로딩 시작:', pages.map(p => p.id));
for (const page of pages) {
if (this.preloadedPages.has(page.url)) continue;
try {
await this.preloadPage(page);
// 네트워크 상태 확인 (느린 연결에서는 중단)
if (this.isSlowConnection()) {
console.log('⚠️ 느린 연결 감지, 프리로딩 중단');
break;
}
// CPU 부하 방지를 위한 딜레이
await this.delay(500);
} catch (error) {
console.warn('프리로딩 실패:', page.id, error);
}
}
this.isPreloading = false;
console.log('✅ 페이지 프리로딩 완료');
}
/**
* 개별 페이지 프리로드
*/
async preloadPage(page) {
try {
// HTML 프리로드
const htmlResponse = await fetch(page.url, {
method: 'GET',
headers: { 'Accept': 'text/html' }
});
if (htmlResponse.ok) {
const html = await htmlResponse.text();
this.preloadCache.set(page.url, html);
// 페이지 내 리소스 추출 및 프리로드
await this.preloadPageResources(html, page.url);
this.preloadedPages.add(page.url);
console.log(`📄 프리로드 완료: ${page.id}`);
}
} catch (error) {
console.warn(`프리로드 실패: ${page.id}`, error);
}
}
/**
* 페이지 리소스 프리로드 (CSS, JS)
*/
async preloadPageResources(html, baseUrl) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// CSS 파일 프리로드
const cssLinks = doc.querySelectorAll('link[rel="stylesheet"]');
for (const link of cssLinks) {
const href = this.resolveUrl(link.href, baseUrl);
if (!this.resourceCache.has(href)) {
this.preloadResource(href, 'style');
}
}
// JS 파일 프리로드 (중요한 것만)
const scriptTags = doc.querySelectorAll('script[src]');
for (const script of scriptTags) {
const src = this.resolveUrl(script.src, baseUrl);
if (this.isImportantScript(src) && !this.resourceCache.has(src)) {
this.preloadResource(src, 'script');
}
}
}
/**
* 리소스 프리로드
*/
preloadResource(url, type) {
const link = document.createElement('link');
link.rel = 'preload';
link.href = url;
link.as = type;
link.onload = () => {
this.resourceCache.set(url, true);
};
link.onerror = () => {
console.warn('리소스 프리로드 실패:', url);
};
document.head.appendChild(link);
}
/**
* 중요한 스크립트 판별
*/
isImportantScript(src) {
const importantScripts = [
'api.js',
'permissions.js',
'common-header.js',
'page-manager.js'
];
return importantScripts.some(script => src.includes(script));
}
/**
* URL 해결
*/
resolveUrl(url, baseUrl) {
if (url.startsWith('http') || url.startsWith('//')) {
return url;
}
const base = new URL(baseUrl, window.location.origin);
return new URL(url, base).href;
}
/**
* 호버 시 프리로딩 설정
*/
setupHoverPreloading() {
let hoverTimeout;
document.addEventListener('mouseover', (e) => {
const link = e.target.closest('a[href]');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
// 300ms 후 프리로드 (실제 클릭 의도 확인)
hoverTimeout = setTimeout(() => {
this.preloadOnHover(href);
}, 300);
});
document.addEventListener('mouseout', (e) => {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
});
}
/**
* 호버 시 프리로드
*/
async preloadOnHover(url) {
if (this.preloadedPages.has(url)) return;
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'text/html' }
});
if (response.ok) {
const html = await response.text();
this.preloadCache.set(url, html);
this.preloadedPages.add(url);
console.log('🖱️ 호버 프리로드 완료:', url);
}
} catch (error) {
console.warn('호버 프리로드 실패:', url, error);
}
}
/**
* 느린 연결 감지
*/
isSlowConnection() {
if ('connection' in navigator) {
const connection = navigator.connection;
return connection.effectiveType === 'slow-2g' ||
connection.effectiveType === '2g' ||
connection.saveData === true;
}
return false;
}
/**
* 딜레이 유틸리티
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 서비스 워커 등록
*/
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('🔧 서비스 워커 등록 완료:', registration);
} catch (error) {
console.log('서비스 워커 등록 실패:', error);
}
}
}
/**
* 프리로드된 페이지 가져오기
*/
getPreloadedPage(url) {
return this.preloadCache.get(url);
}
/**
* 캐시 정리
*/
clearCache() {
this.preloadCache.clear();
this.resourceCache.clear();
this.preloadedPages.clear();
console.log('🗑️ 프리로드 캐시 정리 완료');
}
}
// 전역 인스턴스
window.pagePreloader = new PagePreloader();

View File

@@ -0,0 +1,267 @@
/**
* 단순화된 페이지 권한 관리 시스템
* admin/user 구조에서 페이지별 접근 권한을 관리
*/
class PagePermissionManager {
constructor() {
this.currentUser = null;
this.pagePermissions = new Map();
this.defaultPages = this.initDefaultPages();
}
/**
* 기본 페이지 목록 초기화
*/
initDefaultPages() {
return {
'issues_create': { title: '부적합 등록', defaultAccess: true },
'issues_view': { 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 },
'users_manage': { title: '사용자 관리', defaultAccess: false }
};
}
/**
* 사용자 설정
* @param {Object} user - 사용자 객체
*/
setUser(user) {
this.currentUser = user;
this.loadPagePermissions();
}
/**
* 사용자별 페이지 권한 로드
*/
async loadPagePermissions() {
if (!this.currentUser) return;
try {
// API에서 사용자별 페이지 권한 가져오기
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/users/${this.currentUser.id}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
const pagePermissions = await response.json();
this.pagePermissions.clear(); // 기존 권한 초기화
pagePermissions.forEach(perm => {
this.pagePermissions.set(perm.page_name, perm.can_access);
});
console.log('페이지 권한 로드 완료:', this.pagePermissions);
} else {
console.warn('페이지 권한 로드 실패, 기본 권한 사용');
}
} catch (error) {
console.warn('페이지 권한 로드 실패, 기본 권한 사용:', error);
}
}
/**
* 페이지 접근 권한 체크
* @param {string} pageName - 체크할 페이지명
* @returns {boolean} 접근 권한 여부
*/
canAccessPage(pageName) {
if (!this.currentUser) return false;
// admin은 모든 페이지 접근 가능
if (this.currentUser.role === 'admin') {
return true;
}
// 개별 페이지 권한이 설정되어 있으면 우선 적용
if (this.pagePermissions.has(pageName)) {
return this.pagePermissions.get(pageName);
}
// 기본 권한 확인
const pageConfig = this.defaultPages[pageName];
return pageConfig ? pageConfig.defaultAccess : false;
}
/**
* UI 요소 페이지 권한 제어
* @param {string} selector - CSS 선택자
* @param {string} pageName - 필요한 페이지 권한
* @param {string} action - 'show'|'hide'|'disable'|'enable'
*/
controlElement(selector, pageName, action = 'show') {
const elements = document.querySelectorAll(selector);
const hasAccess = this.canAccessPage(pageName);
elements.forEach(element => {
switch (action) {
case 'show':
element.style.display = hasAccess ? '' : 'none';
break;
case 'hide':
element.style.display = hasAccess ? 'none' : '';
break;
case 'disable':
element.disabled = !hasAccess;
if (!hasAccess) {
element.classList.add('opacity-50', 'cursor-not-allowed');
}
break;
case 'enable':
element.disabled = hasAccess;
if (hasAccess) {
element.classList.remove('opacity-50', 'cursor-not-allowed');
}
break;
}
});
}
/**
* 메뉴 구성 생성
* @returns {Array} 페이지 권한에 따른 메뉴 구성
*/
getMenuConfig() {
const menuItems = [
{
id: 'issues_create',
title: '부적합 등록',
icon: 'fas fa-plus-circle',
path: '#issues/create',
pageName: 'issues_create'
},
{
id: 'issues_view',
title: '부적합 조회',
icon: 'fas fa-search',
path: '#issues/view',
pageName: 'issues_view'
},
{
id: 'issues_manage',
title: '부적합 관리',
icon: 'fas fa-tasks',
path: '#issues/manage',
pageName: 'issues_manage'
},
{
id: 'projects_manage',
title: '프로젝트 관리',
icon: 'fas fa-folder-open',
path: '#projects/manage',
pageName: 'projects_manage'
},
{
id: 'daily_work',
title: '일일 공수',
icon: 'fas fa-calendar-check',
path: '#daily-work',
pageName: 'daily_work'
},
{
id: 'reports',
title: '보고서',
icon: 'fas fa-chart-bar',
path: '#reports',
pageName: 'reports'
},
{
id: 'users_manage',
title: '사용자 관리',
icon: 'fas fa-users-cog',
path: '#users/manage',
pageName: 'users_manage'
}
];
// 페이지 권한에 따라 메뉴 필터링
return menuItems.filter(item => this.canAccessPage(item.pageName));
}
/**
* 페이지 권한 부여
* @param {number} userId - 사용자 ID
* @param {string} pageName - 페이지명
* @param {boolean} canAccess - 접근 허용 여부
* @param {string} notes - 메모
*/
async grantPageAccess(userId, pageName, canAccess, notes = '') {
if (this.currentUser.role !== 'admin') {
throw new Error('관리자만 권한을 설정할 수 있습니다.');
}
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/page-permissions/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
user_id: userId,
page_name: pageName,
can_access: canAccess,
notes: notes
})
});
if (!response.ok) {
throw new Error('페이지 권한 설정 실패');
}
return await response.json();
} catch (error) {
console.error('페이지 권한 설정 오류:', error);
throw error;
}
}
/**
* 사용자 페이지 권한 목록 조회
* @param {number} userId - 사용자 ID
* @returns {Array} 페이지 권한 목록
*/
async getUserPagePermissions(userId) {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (!response.ok) {
throw new Error('페이지 권한 목록 조회 실패');
}
return await response.json();
} catch (error) {
console.error('페이지 권한 목록 조회 오류:', error);
throw error;
}
}
/**
* 모든 페이지 목록과 설명 가져오기
* @returns {Object} 페이지 목록
*/
getAllPages() {
return this.defaultPages;
}
}
// 전역 페이지 권한 관리자 인스턴스
window.pagePermissionManager = new PagePermissionManager();
// 편의 함수들
window.canAccessPage = (pageName) => window.pagePermissionManager.canAccessPage(pageName);
window.controlElement = (selector, pageName, action) => window.pagePermissionManager.controlElement(selector, pageName, action);

View File

@@ -0,0 +1,139 @@
/**
* 날짜 관련 유틸리티 함수들
* 한국 표준시(KST) 기준으로 처리
*/
const DateUtils = {
/**
* UTC 시간을 KST로 변환
* @param {string|Date} dateInput - UTC 날짜 문자열 또는 Date 객체
* @returns {Date} KST 시간대의 Date 객체
*/
toKST(dateInput) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
// UTC 시간에 9시간 추가 (KST = UTC+9)
return new Date(date.getTime() + (date.getTimezoneOffset() * 60000) + (9 * 3600000));
},
/**
* 현재 KST 시간 가져오기
* @returns {Date} 현재 KST 시간
*/
nowKST() {
const now = new Date();
return this.toKST(now);
},
/**
* KST 날짜를 한국식 문자열로 포맷
* @param {string|Date} dateInput - 날짜
* @param {boolean} includeTime - 시간 포함 여부
* @returns {string} 포맷된 날짜 문자열
*/
formatKST(dateInput, includeTime = false) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'Asia/Seoul'
};
if (includeTime) {
options.hour = '2-digit';
options.minute = '2-digit';
options.hour12 = false;
}
return date.toLocaleString('ko-KR', options);
},
/**
* 상대적 시간 표시 (예: 3분 전, 2시간 전)
* @param {string|Date} dateInput - 날짜
* @returns {string} 상대적 시간 문자열
*/
getRelativeTime(dateInput) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return '방금 전';
if (diffMin < 60) return `${diffMin}분 전`;
if (diffHour < 24) return `${diffHour}시간 전`;
if (diffDay < 7) return `${diffDay}일 전`;
return this.formatKST(date);
},
/**
* 오늘 날짜인지 확인 (KST 기준)
* @param {string|Date} dateInput - 날짜
* @returns {boolean}
*/
isToday(dateInput) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const today = new Date();
return date.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' }) ===
today.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
},
/**
* 이번 주인지 확인 (KST 기준, 월요일 시작)
* @param {string|Date} dateInput - 날짜
* @returns {boolean}
*/
isThisWeek(dateInput) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const now = new Date();
// 주의 시작일 (월요일) 계산
const startOfWeek = new Date(now);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
// 주의 끝일 (일요일) 계산
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
return date >= startOfWeek && date <= endOfWeek;
},
/**
* ISO 문자열을 로컬 date input 값으로 변환
* @param {string} isoString - ISO 날짜 문자열
* @returns {string} YYYY-MM-DD 형식
*/
toDateInputValue(isoString) {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
/**
* 날짜 차이 계산 (일 단위)
* @param {string|Date} date1 - 첫 번째 날짜
* @param {string|Date} date2 - 두 번째 날짜
* @returns {number} 일 수 차이
*/
getDaysDiff(date1, date2) {
const d1 = typeof date1 === 'string' ? new Date(date1) : date1;
const d2 = typeof date2 === 'string' ? new Date(date2) : date2;
const diffMs = Math.abs(d2 - d1);
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
};
// 전역으로 사용 가능하도록 export
window.DateUtils = DateUtils;

View File

@@ -0,0 +1,134 @@
/**
* 이미지 압축 및 최적화 유틸리티
*/
const ImageUtils = {
/**
* 이미지를 압축하고 리사이즈
* @param {File|Blob|String} source - 이미지 파일, Blob 또는 base64 문자열
* @param {Object} options - 압축 옵션
* @returns {Promise<String>} - 압축된 base64 이미지
*/
async compressImage(source, options = {}) {
const {
maxWidth = 1024, // 최대 너비
maxHeight = 1024, // 최대 높이
quality = 0.7, // JPEG 품질 (0-1)
format = 'jpeg' // 출력 형식
} = options;
return new Promise((resolve, reject) => {
let img = new Image();
// 이미지 로드 완료 시
img.onload = () => {
// Canvas 생성
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 리사이즈 계산
let { width, height } = this.calculateDimensions(
img.width,
img.height,
maxWidth,
maxHeight
);
// Canvas 크기 설정
canvas.width = width;
canvas.height = height;
// 이미지 그리기
ctx.drawImage(img, 0, 0, width, height);
// 압축된 이미지를 base64로 변환
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('이미지 압축 실패'));
return;
}
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
}, `image/${format}`, quality);
};
img.onerror = () => reject(new Error('이미지 로드 실패'));
// 소스 타입에 따라 처리
if (typeof source === 'string') {
// Base64 문자열인 경우
img.src = source;
} else if (source instanceof File || source instanceof Blob) {
// File 또는 Blob인 경우
const reader = new FileReader();
reader.onloadend = () => {
img.src = reader.result;
};
reader.onerror = reject;
reader.readAsDataURL(source);
} else {
reject(new Error('지원하지 않는 이미지 형식'));
}
});
},
/**
* 이미지 크기 계산 (비율 유지)
*/
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
// 원본 크기가 제한 내에 있으면 그대로 반환
if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
return { width: originalWidth, height: originalHeight };
}
// 비율 계산
const widthRatio = maxWidth / originalWidth;
const heightRatio = maxHeight / originalHeight;
const ratio = Math.min(widthRatio, heightRatio);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
},
/**
* 파일 크기를 사람이 읽을 수 있는 형식으로 변환
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
/**
* Base64 문자열의 크기 계산
*/
getBase64Size(base64String) {
const base64Length = base64String.length - (base64String.indexOf(',') + 1);
const padding = (base64String.charAt(base64String.length - 2) === '=') ? 2 :
((base64String.charAt(base64String.length - 1) === '=') ? 1 : 0);
return (base64Length * 0.75) - padding;
},
/**
* 이미지 미리보기 생성 (썸네일)
*/
async createThumbnail(source, size = 150) {
return this.compressImage(source, {
maxWidth: size,
maxHeight: size,
quality: 0.8
});
}
};
// 전역으로 사용 가능하도록 export
window.ImageUtils = ImageUtils;

View File

@@ -0,0 +1,335 @@
/**
* 서비스 워커 - 페이지 및 리소스 캐싱
* M-Project 작업보고서 시스템
*/
const CACHE_NAME = 'mproject-v1.0.1';
const STATIC_CACHE = 'mproject-static-v1.0.1';
const DYNAMIC_CACHE = 'mproject-dynamic-v1.0.1';
// 캐시할 정적 리소스
const STATIC_ASSETS = [
'/',
'/index.html',
'/issue-view.html',
'/daily-work.html',
'/project-management.html',
'/admin.html',
'/static/js/api.js',
'/static/js/core/permissions.js',
'/static/js/components/common-header.js',
'/static/js/core/page-manager.js',
'/static/js/core/page-preloader.js',
'/static/js/date-utils.js',
'/static/js/image-utils.js',
'https://cdn.tailwindcss.com',
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'
];
// 캐시 전략별 URL 패턴
const CACHE_STRATEGIES = {
// 네트워크 우선 (API 호출)
networkFirst: [
/\/api\//,
/\/auth\//
],
// 캐시 우선 (정적 리소스)
cacheFirst: [
/\.css$/,
/\.js$/,
/\.png$/,
/\.jpg$/,
/\.jpeg$/,
/\.gif$/,
/\.svg$/,
/\.woff$/,
/\.woff2$/,
/cdn\.tailwindcss\.com/,
/cdnjs\.cloudflare\.com/
],
// 스테일 허용 (HTML 페이지)
staleWhileRevalidate: [
/\.html$/,
/\/$/
]
};
/**
* 서비스 워커 설치
*/
self.addEventListener('install', (event) => {
console.log('🔧 서비스 워커 설치 중...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('📦 정적 리소스 캐싱 중...');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
console.log('✅ 서비스 워커 설치 완료');
return self.skipWaiting();
})
.catch((error) => {
console.error('❌ 서비스 워커 설치 실패:', error);
})
);
});
/**
* 서비스 워커 활성화
*/
self.addEventListener('activate', (event) => {
console.log('🚀 서비스 워커 활성화 중...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
// 이전 버전 캐시 삭제
if (cacheName !== STATIC_CACHE &&
cacheName !== DYNAMIC_CACHE &&
cacheName !== CACHE_NAME) {
console.log('🗑️ 이전 캐시 삭제:', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
console.log('✅ 서비스 워커 활성화 완료');
return self.clients.claim();
})
);
});
/**
* 네트워크 요청 가로채기
*/
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// CORS 요청이나 외부 도메인은 기본 처리
if (url.origin !== location.origin && !isCDNResource(url)) {
return;
}
// 캐시 전략 결정
const strategy = getCacheStrategy(request.url);
event.respondWith(
handleRequest(request, strategy)
);
});
/**
* 요청 처리 (캐시 전략별)
*/
async function handleRequest(request, strategy) {
try {
switch (strategy) {
case 'networkFirst':
return await networkFirst(request);
case 'cacheFirst':
return await cacheFirst(request);
case 'staleWhileRevalidate':
return await staleWhileRevalidate(request);
default:
return await fetch(request);
}
} catch (error) {
console.error('요청 처리 실패:', request.url, error);
return await handleOffline(request);
}
}
/**
* 네트워크 우선 전략
*/
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
// 성공적인 응답만 캐시
if (networkResponse.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// 네트워크 실패 시 캐시에서 반환
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
}
/**
* 캐시 우선 전략
*/
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// 캐시에 없으면 네트워크에서 가져와서 캐시
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(STATIC_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
}
/**
* 스테일 허용 전략
*/
async function staleWhileRevalidate(request) {
const cachedResponse = await caches.match(request);
// 백그라운드에서 업데이트
const networkResponsePromise = fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
const cache = caches.open(DYNAMIC_CACHE);
cache.then(c => c.put(request, networkResponse.clone()));
}
return networkResponse;
})
.catch(() => null);
// 캐시된 응답이 있으면 즉시 반환, 없으면 네트워크 대기
return cachedResponse || await networkResponsePromise;
}
/**
* 캐시 전략 결정
*/
function getCacheStrategy(url) {
for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) {
if (patterns.some(pattern => pattern.test(url))) {
return strategy;
}
}
return 'networkFirst'; // 기본값
}
/**
* CDN 리소스 확인
*/
function isCDNResource(url) {
const cdnDomains = [
'cdn.tailwindcss.com',
'cdnjs.cloudflare.com',
'fonts.googleapis.com',
'fonts.gstatic.com'
];
return cdnDomains.some(domain => url.hostname.includes(domain));
}
/**
* 오프라인 처리
*/
async function handleOffline(request) {
// HTML 요청에 대한 오프라인 페이지
if (request.destination === 'document') {
const offlinePage = await caches.match('/index.html');
if (offlinePage) {
return offlinePage;
}
}
// 이미지 요청에 대한 기본 이미지
if (request.destination === 'image') {
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><rect width="200" height="200" fill="#f3f4f6"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#9ca3af">오프라인</text></svg>',
{ headers: { 'Content-Type': 'image/svg+xml' } }
);
}
// 기본 오프라인 응답
return new Response('오프라인 상태입니다.', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}
/**
* 메시지 처리 (캐시 관리)
*/
self.addEventListener('message', (event) => {
const { type, payload } = event.data;
switch (type) {
case 'CLEAR_CACHE':
clearAllCaches().then(() => {
event.ports[0].postMessage({ success: true });
});
break;
case 'CACHE_PAGE':
cachePage(payload.url).then(() => {
event.ports[0].postMessage({ success: true });
});
break;
case 'GET_CACHE_STATUS':
getCacheStatus().then((status) => {
event.ports[0].postMessage({ status });
});
break;
}
});
/**
* 모든 캐시 정리
*/
async function clearAllCaches() {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
console.log('🗑️ 모든 캐시 정리 완료');
}
/**
* 특정 페이지 캐시
*/
async function cachePage(url) {
try {
const cache = await caches.open(DYNAMIC_CACHE);
await cache.add(url);
console.log('📦 페이지 캐시 완료:', url);
} catch (error) {
console.error('페이지 캐시 실패:', url, error);
}
}
/**
* 캐시 상태 조회
*/
async function getCacheStatus() {
const cacheNames = await caches.keys();
const status = {};
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
status[cacheName] = keys.length;
}
return status;
}

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 DB → localStorage 동기화</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.status {
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #d1ecf1; color: #0c5460; }
pre { background: #f4f4f4; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>프로젝트 DB → localStorage 동기화</h1>
<div id="status"></div>
<pre id="result"></pre>
<button onclick="syncProjects()">동기화 시작</button>
<button onclick="location.href='index.html'">메인으로</button>
<script>
// DB의 프로젝트 데이터 (backend에서 확인한 데이터)
const dbProjects = [
{
id: 1,
jobNo: 'TKR-25009R',
projectName: 'M Project',
isActive: true,
createdAt: '2025-10-24T09:49:42.456272+09:00',
createdByName: '관리자'
},
{
id: 2,
jobNo: 'TKG-24011P',
projectName: 'TKG Project',
isActive: true,
createdAt: '2025-10-24T10:59:49.71909+09:00',
createdByName: '관리자'
}
];
function showStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
statusDiv.className = 'status ' + type;
statusDiv.textContent = message;
}
function syncProjects() {
try {
// 기존 localStorage 데이터 확인
const existing = localStorage.getItem('work-report-projects');
if (existing) {
showStatus('기존 localStorage 데이터가 있습니다. 덮어쓰시겠습니까?', 'info');
if (!confirm('기존 프로젝트 데이터를 DB 데이터로 덮어쓰시겠습니까?')) {
showStatus('동기화 취소됨', 'error');
return;
}
}
// localStorage에 저장
localStorage.setItem('work-report-projects', JSON.stringify(dbProjects));
// 결과 표시
document.getElementById('result').textContent = JSON.stringify(dbProjects, null, 2);
showStatus('✅ DB 프로젝트 2개를 localStorage로 동기화 완료!', 'success');
// 2초 후 메인으로 이동
setTimeout(() => {
alert('동기화가 완료되었습니다. 메인 페이지로 이동합니다.');
location.href = 'index.html';
}, 2000);
} catch (error) {
showStatus('❌ 동기화 실패: ' + error.message, 'error');
}
}
// 페이지 로드 시 현재 상태 표시
window.onload = () => {
const current = localStorage.getItem('work-report-projects');
if (current) {
showStatus('현재 localStorage에 프로젝트 데이터가 있습니다.', 'info');
document.getElementById('result').textContent = 'Current: ' + current;
} else {
showStatus('localStorage에 프로젝트 데이터가 없습니다.', 'info');
}
};
</script>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<title>API 테스트</title>
</head>
<body>
<h1>API 테스트 페이지</h1>
<button onclick="testLogin()">로그인 테스트</button>
<button onclick="testUsers()">사용자 목록 테스트</button>
<div id="result"></div>
<script>
let token = null;
async function testLogin() {
try {
const formData = new URLSearchParams();
formData.append('username', 'hyungi');
formData.append('password', '123456');
const response = await fetch('http://localhost:16080/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
});
const data = await response.json();
token = data.access_token;
document.getElementById('result').innerHTML = `<pre>로그인 성공: ${JSON.stringify(data, null, 2)}</pre>`;
} catch (error) {
document.getElementById('result').innerHTML = `<pre>로그인 실패: ${error.message}</pre>`;
}
}
async function testUsers() {
if (!token) {
alert('먼저 로그인하세요');
return;
}
try {
const response = await fetch('http://localhost:16080/api/auth/users', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
document.getElementById('result').innerHTML = `<pre>사용자 목록: ${JSON.stringify(data, null, 2)}</pre>`;
} catch (error) {
document.getElementById('result').innerHTML = `<pre>사용자 목록 실패: ${error.message}</pre>`;
}
}
</script>
</body>
</html>