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:
24
system3-nonconformance/api/Dockerfile
Normal file
24
system3-nonconformance/api/Dockerfile
Normal 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"]
|
||||
19
system3-nonconformance/api/database/database.py
Normal file
19
system3-nonconformance/api/database/database.py
Normal 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()
|
||||
225
system3-nonconformance/api/database/models.py
Normal file
225
system3-nonconformance/api/database/models.py
Normal 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")
|
||||
369
system3-nonconformance/api/database/schemas.py
Normal file
369
system3-nonconformance/api/database/schemas.py
Normal 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
|
||||
69
system3-nonconformance/api/main.py
Normal file
69
system3-nonconformance/api/main.py
Normal 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)
|
||||
41
system3-nonconformance/api/migrate_add_photo_fields.py
Normal file
41
system3-nonconformance/api/migrate_add_photo_fields.py
Normal 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()
|
||||
58
system3-nonconformance/api/migrations/001_init.sql
Normal file
58
system3-nonconformance/api/migrations/001_init.sql
Normal 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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 카테고리 업데이트 마이그레이션
|
||||
-- dimension_defect를 design_error로 변경
|
||||
UPDATE issues
|
||||
SET category = 'design_error'
|
||||
WHERE category = 'dimension_defect';
|
||||
|
||||
-- PostgreSQL enum 타입 업데이트 (필요한 경우)
|
||||
-- 기존 enum 타입 확인 후 필요시 재생성
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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를 가지고 있음)
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (기타) ✅ 새로 추가됨
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 완료 ==='
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 '삭제 사유 (선택사항)';
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
15
system3-nonconformance/api/requirements.txt
Normal file
15
system3-nonconformance/api/requirements.txt
Normal 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
|
||||
186
system3-nonconformance/api/routers/auth.py
Normal file
186
system3-nonconformance/api/routers/auth.py
Normal 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}"}
|
||||
163
system3-nonconformance/api/routers/daily_work.py
Normal file
163
system3-nonconformance/api/routers/daily_work.py
Normal 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)
|
||||
}
|
||||
404
system3-nonconformance/api/routers/inbox.py
Normal file
404
system3-nonconformance/api/routers/inbox.py
Normal 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)}")
|
||||
488
system3-nonconformance/api/routers/issues.py
Normal file
488
system3-nonconformance/api/routers/issues.py
Normal 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)}")
|
||||
212
system3-nonconformance/api/routers/management.py
Normal file
212
system3-nonconformance/api/routers/management.py
Normal 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
|
||||
330
system3-nonconformance/api/routers/page_permissions.py
Normal file
330
system3-nonconformance/api/routers/page_permissions.py
Normal 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)
|
||||
}
|
||||
129
system3-nonconformance/api/routers/projects.py
Normal file
129
system3-nonconformance/api/routers/projects.py
Normal 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": "프로젝트가 삭제되었습니다."}
|
||||
873
system3-nonconformance/api/routers/reports.py
Normal file
873
system3-nonconformance/api/routers/reports.py
Normal 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" # 기본 파란색
|
||||
734
system3-nonconformance/api/routers/reports.py.bak
Normal file
734
system3-nonconformance/api/routers/reports.py.bak
Normal 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" # 기본 파란색
|
||||
734
system3-nonconformance/api/routers/reports.py.bak2
Normal file
734
system3-nonconformance/api/routers/reports.py.bak2
Normal 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" # 기본 파란색
|
||||
98
system3-nonconformance/api/services/auth_service.py
Normal file
98
system3-nonconformance/api/services/auth_service.py
Normal 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}")
|
||||
136
system3-nonconformance/api/services/file_service.py
Normal file
136
system3-nonconformance/api/services/file_service.py
Normal 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}")
|
||||
8
system3-nonconformance/web/Dockerfile
Normal file
8
system3-nonconformance/web/Dockerfile
Normal 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;"]
|
||||
913
system3-nonconformance/web/admin.html
Normal file
913
system3-nonconformance/web/admin.html
Normal 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>
|
||||
306
system3-nonconformance/web/app.html
Normal file
306
system3-nonconformance/web/app.html
Normal 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>
|
||||
120
system3-nonconformance/web/check-projects.html
Normal file
120
system3-nonconformance/web/check-projects.html
Normal 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>
|
||||
661
system3-nonconformance/web/daily-work.html
Normal file
661
system3-nonconformance/web/daily-work.html
Normal 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>
|
||||
2151
system3-nonconformance/web/index.html
Normal file
2151
system3-nonconformance/web/index.html
Normal file
File diff suppressed because it is too large
Load Diff
1119
system3-nonconformance/web/issue-view.html
Normal file
1119
system3-nonconformance/web/issue-view.html
Normal file
File diff suppressed because it is too large
Load Diff
607
system3-nonconformance/web/issues-archive.html
Normal file
607
system3-nonconformance/web/issues-archive.html
Normal 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>
|
||||
2676
system3-nonconformance/web/issues-dashboard.html
Normal file
2676
system3-nonconformance/web/issues-dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
1500
system3-nonconformance/web/issues-inbox.html
Normal file
1500
system3-nonconformance/web/issues-inbox.html
Normal file
File diff suppressed because it is too large
Load Diff
2790
system3-nonconformance/web/issues-management.html
Normal file
2790
system3-nonconformance/web/issues-management.html
Normal file
File diff suppressed because it is too large
Load Diff
323
system3-nonconformance/web/mobile-fix.html
Normal file
323
system3-nonconformance/web/mobile-fix.html
Normal 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>
|
||||
|
||||
51
system3-nonconformance/web/nginx.conf
Normal file
51
system3-nonconformance/web/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
592
system3-nonconformance/web/project-management.html
Normal file
592
system3-nonconformance/web/project-management.html
Normal 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>
|
||||
501
system3-nonconformance/web/reports-daily.html
Normal file
501
system3-nonconformance/web/reports-daily.html
Normal 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>
|
||||
111
system3-nonconformance/web/reports-monthly.html
Normal file
111
system3-nonconformance/web/reports-monthly.html
Normal 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>
|
||||
110
system3-nonconformance/web/reports-weekly.html
Normal file
110
system3-nonconformance/web/reports-weekly.html
Normal 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>
|
||||
212
system3-nonconformance/web/reports.html
Normal file
212
system3-nonconformance/web/reports.html
Normal 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>
|
||||
297
system3-nonconformance/web/static/css/mobile-calendar.css
Normal file
297
system3-nonconformance/web/static/css/mobile-calendar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
346
system3-nonconformance/web/static/js/api.js
Normal file
346
system3-nonconformance/web/static/js/api.js
Normal 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'
|
||||
})
|
||||
};
|
||||
441
system3-nonconformance/web/static/js/app.js
Normal file
441
system3-nonconformance/web/static/js/app.js
Normal 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();
|
||||
});
|
||||
712
system3-nonconformance/web/static/js/components/common-header.js
Normal file
712
system3-nonconformance/web/static/js/components/common-header.js
Normal 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;
|
||||
@@ -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;
|
||||
272
system3-nonconformance/web/static/js/core/auth-manager.js
Normal file
272
system3-nonconformance/web/static/js/core/auth-manager.js
Normal 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 로드 완료');
|
||||
621
system3-nonconformance/web/static/js/core/keyboard-shortcuts.js
Normal file
621
system3-nonconformance/web/static/js/core/keyboard-shortcuts.js
Normal 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();
|
||||
368
system3-nonconformance/web/static/js/core/page-manager.js
Normal file
368
system3-nonconformance/web/static/js/core/page-manager.js
Normal 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;
|
||||
317
system3-nonconformance/web/static/js/core/page-preloader.js
Normal file
317
system3-nonconformance/web/static/js/core/page-preloader.js
Normal 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();
|
||||
267
system3-nonconformance/web/static/js/core/permissions.js
Normal file
267
system3-nonconformance/web/static/js/core/permissions.js
Normal 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);
|
||||
139
system3-nonconformance/web/static/js/date-utils.js
Normal file
139
system3-nonconformance/web/static/js/date-utils.js
Normal 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;
|
||||
134
system3-nonconformance/web/static/js/image-utils.js
Normal file
134
system3-nonconformance/web/static/js/image-utils.js
Normal 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;
|
||||
335
system3-nonconformance/web/sw.js
Normal file
335
system3-nonconformance/web/sw.js
Normal 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;
|
||||
}
|
||||
104
system3-nonconformance/web/sync-projects-from-db.html
Normal file
104
system3-nonconformance/web/sync-projects-from-db.html
Normal 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>
|
||||
|
||||
58
system3-nonconformance/web/test_api.html
Normal file
58
system3-nonconformance/web/test_api.html
Normal 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>
|
||||
Reference in New Issue
Block a user