feat: 5장 사진 지원 및 엑셀 내보내기 UI 개선

- 신고 및 완료 사진 5장 지원 (photo_path3, photo_path4, photo_path5 추가)
- 엑셀 일일 리포트 개선:
  - 사진 5장 모두 한 행에 일렬 배치 (A, C, E, G, I 열)
  - 상태별 색상 구분 (지연중: 빨강, 진행중: 노랑, 완료: 진한 초록)
  - 우선순위 기반 정렬 (지연중 → 진행중 → 완료됨)
  - 프로젝트 현황 통계 박스 UI 개선 (색상 구분)
- 프론트엔드 모든 페이지 5장 사진 표시 (flex-wrap 레이아웃)
  - 관리함, 수신함, 현황판, 신고내용 확인 페이지

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2025-11-08 14:44:39 +09:00
parent 2fc7d4bc2c
commit 637b690eda
13 changed files with 1563 additions and 515 deletions

View File

@@ -95,7 +95,10 @@ class Issue(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
photo_path = Column(String) photo_path = Column(String)
photo_path2 = Column(String) # 두 번째 사진 경로 photo_path2 = Column(String)
photo_path3 = Column(String)
photo_path4 = Column(String)
photo_path5 = Column(String)
category = Column(Enum(IssueCategory), nullable=False) category = Column(Enum(IssueCategory), nullable=False)
description = Column(Text, nullable=False) description = Column(Text, nullable=False)
status = Column(Enum(IssueStatus), default=IssueStatus.new) status = Column(Enum(IssueStatus), default=IssueStatus.new)
@@ -120,7 +123,6 @@ class Issue(Base):
duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록 duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록
# 관리함에서 사용할 추가 필드들 # 관리함에서 사용할 추가 필드들
completion_photo_path = Column(String) # 완료 사진 경로
solution = Column(Text) # 해결방안 (관리함에서 입력) solution = Column(Text) # 해결방안 (관리함에서 입력)
responsible_department = Column(Enum(DepartmentType)) # 담당부서 responsible_department = Column(Enum(DepartmentType)) # 담당부서
responsible_person = Column(String(100)) # 담당자 responsible_person = Column(String(100)) # 담당자
@@ -141,7 +143,11 @@ class Issue(Base):
# 완료 신청 관련 필드들 # 완료 신청 관련 필드들
completion_requested_at = Column(DateTime) # 완료 신청 시간 completion_requested_at = Column(DateTime) # 완료 신청 시간
completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자 completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자
completion_photo_path = Column(String(500)) # 완료 사진 경로 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_comment = Column(Text) # 완료 코멘트
# 완료 반려 관련 필드들 # 완료 반려 관련 필드들
@@ -149,6 +155,10 @@ class Issue(Base):
completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자 completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자
completion_rejection_reason = Column(Text) # 완료 반려 사유 completion_rejection_reason = Column(Text) # 완료 반려 사유
# 일일보고서 추출 이력
last_exported_at = Column(DateTime) # 마지막 일일보고서 추출 시간
export_count = Column(Integer, default=0) # 추출 횟수
# Relationships # Relationships
reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id]) reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id])
reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues") reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues")

View File

@@ -89,7 +89,10 @@ class IssueBase(BaseModel):
class IssueCreate(IssueBase): class IssueCreate(IssueBase):
photo: Optional[str] = None # Base64 encoded image photo: Optional[str] = None # Base64 encoded image
photo2: Optional[str] = None # Second Base64 encoded image photo2: Optional[str] = None
photo3: Optional[str] = None
photo4: Optional[str] = None
photo5: Optional[str] = None
class IssueUpdate(BaseModel): class IssueUpdate(BaseModel):
category: Optional[IssueCategory] = None category: Optional[IssueCategory] = None
@@ -99,12 +102,18 @@ class IssueUpdate(BaseModel):
detail_notes: Optional[str] = None detail_notes: Optional[str] = None
status: Optional[IssueStatus] = None status: Optional[IssueStatus] = None
photo: Optional[str] = None # Base64 encoded image for update photo: Optional[str] = None # Base64 encoded image for update
photo2: Optional[str] = None # Second Base64 encoded image for update photo2: Optional[str] = None
photo3: Optional[str] = None
photo4: Optional[str] = None
photo5: Optional[str] = None
class Issue(IssueBase): class Issue(IssueBase):
id: int id: int
photo_path: Optional[str] = None photo_path: Optional[str] = None
photo_path2: 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 status: IssueStatus
reporter_id: int reporter_id: int
reporter: User reporter: User
@@ -129,7 +138,6 @@ class Issue(IssueBase):
duplicate_reporters: Optional[List[Dict[str, Any]]] = None duplicate_reporters: Optional[List[Dict[str, Any]]] = None
# 관리함에서 사용할 추가 필드들 # 관리함에서 사용할 추가 필드들
completion_photo_path: Optional[str] = None # 완료 사진 경로
solution: Optional[str] = None # 해결방안 solution: Optional[str] = None # 해결방안
responsible_department: Optional[DepartmentType] = None # 담당부서 responsible_department: Optional[DepartmentType] = None # 담당부서
responsible_person: Optional[str] = None # 담당자 responsible_person: Optional[str] = None # 담당자
@@ -150,7 +158,11 @@ class Issue(IssueBase):
# 완료 신청 관련 필드들 # 완료 신청 관련 필드들
completion_requested_at: Optional[datetime] = None # 완료 신청 시간 completion_requested_at: Optional[datetime] = None # 완료 신청 시간
completion_requested_by_id: Optional[int] = None # 완료 신청자 completion_requested_by_id: Optional[int] = None # 완료 신청자
completion_photo_path: Optional[str] = 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_comment: Optional[str] = None # 완료 코멘트
# 완료 반려 관련 필드들 # 완료 반려 관련 필드들
@@ -158,6 +170,10 @@ class Issue(IssueBase):
completion_rejected_by_id: Optional[int] = None # 완료 반려자 completion_rejected_by_id: Optional[int] = None # 완료 반려자
completion_rejection_reason: Optional[str] = None # 완료 반려 사유 completion_rejection_reason: Optional[str] = None # 완료 반려 사유
# 일일보고서 추출 이력
last_exported_at: Optional[datetime] = None # 마지막 일일보고서 추출 시간
export_count: Optional[int] = 0 # 추출 횟수
class Config: class Config:
from_attributes = True from_attributes = True
@@ -202,7 +218,11 @@ class AdditionalInfoUpdateRequest(BaseModel):
class CompletionRequestRequest(BaseModel): class CompletionRequestRequest(BaseModel):
"""완료 신청 요청""" """완료 신청 요청"""
completion_photo: str # 완료 사진 (Base64) 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 # 완료 코멘트 completion_comment: Optional[str] = None # 완료 코멘트
class CompletionRejectionRequest(BaseModel): class CompletionRejectionRequest(BaseModel):
@@ -220,7 +240,11 @@ class ManagementUpdateRequest(BaseModel):
cause_department: Optional[DepartmentType] = None cause_department: Optional[DepartmentType] = None
management_comment: Optional[str] = None management_comment: Optional[str] = None
completion_comment: Optional[str] = None completion_comment: Optional[str] = None
completion_photo: Optional[str] = None # Base64 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 review_status: Optional[ReviewStatus] = None
class InboxIssue(BaseModel): class InboxIssue(BaseModel):

View File

@@ -20,22 +20,26 @@ async def create_issue(
): ):
print(f"DEBUG: 받은 issue 데이터: {issue}") print(f"DEBUG: 받은 issue 데이터: {issue}")
print(f"DEBUG: project_id: {issue.project_id}") print(f"DEBUG: project_id: {issue.project_id}")
# 이미지 저장 # 이미지 저장 (최대 5장)
photo_path = None photo_paths = {}
photo_path2 = None for i in range(1, 6):
photo_field = f"photo{i if i > 1 else ''}"
if issue.photo: path_field = f"photo_path{i if i > 1 else ''}"
photo_path = save_base64_image(issue.photo) photo_data = getattr(issue, photo_field, None)
if photo_data:
if issue.photo2: photo_paths[path_field] = save_base64_image(photo_data)
photo_path2 = save_base64_image(issue.photo2) else:
photo_paths[path_field] = None
# Issue 생성 # Issue 생성
db_issue = Issue( db_issue = Issue(
category=issue.category, category=issue.category,
description=issue.description, description=issue.description,
photo_path=photo_path, photo_path=photo_paths.get('photo_path'),
photo_path2=photo_path2, 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, reporter_id=current_user.id,
project_id=issue.project_id, project_id=issue.project_id,
status=IssueStatus.new status=IssueStatus.new
@@ -135,38 +139,27 @@ async def update_issue(
# 업데이트 # 업데이트
update_data = issue_update.dict(exclude_unset=True) update_data = issue_update.dict(exclude_unset=True)
# 첫 번째 사진 업데이트되는 경우 처리 # 사진 업데이트 처리 (최대 5장)
if "photo" in update_data: for i in range(1, 6):
# 기존 사진 삭제 photo_field = f"photo{i if i > 1 else ''}"
if issue.photo_path: path_field = f"photo_path{i if i > 1 else ''}"
delete_file(issue.photo_path)
if photo_field in update_data:
# 사진 저장 # 기존 사진 삭제
if update_data["photo"]: existing_path = getattr(issue, path_field, None)
photo_path = save_base64_image(update_data["photo"]) if existing_path:
update_data["photo_path"] = photo_path delete_file(existing_path)
else:
update_data["photo_path"] = None # 새 사진 저장
if update_data[photo_field]:
# photo 필드는 제거 (DB에는 photo_path만 저장) new_path = save_base64_image(update_data[photo_field])
del update_data["photo"] update_data[path_field] = new_path
else:
# 두 번째 사진이 업데이트되는 경우 처리 update_data[path_field] = None
if "photo2" in update_data:
# 기존 사진 삭제 # photo 필드는 제거 (DB에는 photo_path만 저장)
if issue.photo_path2: del update_data[photo_field]
delete_file(issue.photo_path2)
# 새 사진 저장
if update_data["photo2"]:
photo_path2 = save_base64_image(update_data["photo2"])
update_data["photo_path2"] = photo_path2
else:
update_data["photo_path2"] = None
# photo2 필드는 제거 (DB에는 photo_path2만 저장)
del update_data["photo2"]
# work_hours가 입력되면 자동으로 상태를 complete로 변경 # work_hours가 입력되면 자동으로 상태를 complete로 변경
if "work_hours" in update_data and update_data["work_hours"] > 0: if "work_hours" in update_data and update_data["work_hours"] > 0:
@@ -235,13 +228,19 @@ async def delete_issue(
) )
db.add(deletion_log) db.add(deletion_log)
# 이미지 파일 삭제 # 이미지 파일 삭제 (신고 사진 최대 5장)
if issue.photo_path: for i in range(1, 6):
delete_file(issue.photo_path) path_field = f"photo_path{i if i > 1 else ''}"
if issue.photo_path2: path = getattr(issue, path_field, None)
delete_file(issue.photo_path2) if path:
if issue.completion_photo_path: delete_file(path)
delete_file(issue.completion_photo_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.delete(issue)
db.commit() db.commit()
@@ -300,18 +299,32 @@ async def update_issue_management(
update_data = management_update.dict(exclude_unset=True) update_data = management_update.dict(exclude_unset=True)
print(f"DEBUG: Update data dict: {update_data}") print(f"DEBUG: Update data dict: {update_data}")
for field, value in update_data.items(): # 완료 사진 처리 (최대 5장)
print(f"DEBUG: Processing field {field} = {value}") completion_photo_fields = []
if field == 'completion_photo' and value: 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: try:
completion_photo_path = save_base64_image(value, "completion") # 기존 사진 삭제
setattr(issue, 'completion_photo_path', completion_photo_path) existing_path = getattr(issue, path_field, None)
print(f"DEBUG: Saved completion photo: {completion_photo_path}") 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: except Exception as e:
print(f"DEBUG: Photo save error: {str(e)}") print(f"DEBUG: Photo save error for {photo_field}: {str(e)}")
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}") raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
elif field != 'completion_photo': # completion_photo는 위에서 처리됨
# 나머지 필드 처리 (완료 사진 제외)
for field, value in update_data.items():
if field not in completion_photo_fields:
print(f"DEBUG: Processing field {field} = {value}")
try: try:
setattr(issue, field, value) setattr(issue, field, value)
print(f"DEBUG: Set {field} = {value}") print(f"DEBUG: Set {field} = {value}")
@@ -359,22 +372,28 @@ async def request_completion(
try: try:
print(f"DEBUG: 완료 신청 시작 - Issue ID: {issue_id}, User: {current_user.username}") print(f"DEBUG: 완료 신청 시작 - Issue ID: {issue_id}, User: {current_user.username}")
# 완료 사진 저장 # 완료 사진 저장 (최대 5장)
completion_photo_path = None saved_paths = []
if request.completion_photo: for i in range(1, 6):
print(f"DEBUG: 완료 사진 저장 시작") photo_field = f"completion_photo{i if i > 1 else ''}"
completion_photo_path = save_base64_image(request.completion_photo, "completion") path_field = f"completion_photo_path{i if i > 1 else ''}"
print(f"DEBUG: 완료 사진 저장 완료 - Path: {completion_photo_path}") photo_data = getattr(request, photo_field, None)
if not completion_photo_path: if photo_data:
raise Exception("완료 사진 저장에 실패했습니다.") 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 업데이트 시작") print(f"DEBUG: DB 업데이트 시작")
issue.completion_requested_at = datetime.now() issue.completion_requested_at = datetime.now()
issue.completion_requested_by_id = current_user.id issue.completion_requested_by_id = current_user.id
issue.completion_photo_path = completion_photo_path
issue.completion_comment = request.completion_comment issue.completion_comment = request.completion_comment
db.commit() db.commit()
@@ -385,18 +404,19 @@ async def request_completion(
"message": "완료 신청이 성공적으로 제출되었습니다.", "message": "완료 신청이 성공적으로 제출되었습니다.",
"issue_id": issue.id, "issue_id": issue.id,
"completion_requested_at": issue.completion_requested_at, "completion_requested_at": issue.completion_requested_at,
"completion_photo_path": completion_photo_path "completion_photo_paths": saved_paths
} }
except Exception as e: except Exception as e:
print(f"ERROR: 완료 신청 처리 오류 - {str(e)}") print(f"ERROR: 완료 신청 처리 오류 - {str(e)}")
db.rollback() db.rollback()
# 업로드된 파일이 있다면 삭제 # 업로드된 파일이 있다면 삭제
if 'completion_photo_path' in locals() and completion_photo_path: if 'saved_paths' in locals():
try: for path in saved_paths:
delete_file(completion_photo_path) try:
except: delete_file(path)
pass except:
pass
raise HTTPException(status_code=500, detail=f"완료 신청 처리 중 오류가 발생했습니다: {str(e)}") raise HTTPException(status_code=500, detail=f"완료 신청 처리 중 오류가 발생했습니다: {str(e)}")
@router.post("/{issue_id}/reject-completion") @router.post("/{issue_id}/reject-completion")
@@ -425,18 +445,23 @@ async def reject_completion_request(
try: try:
print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}") print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}")
# 완료 사진 파일 삭제 # 완료 사진 파일 삭제 (최대 5장)
if issue.completion_photo_path: for i in range(1, 6):
try: path_field = f"completion_photo_path{i if i > 1 else ''}"
delete_file(issue.completion_photo_path) photo_path = getattr(issue, path_field, None)
print(f"DEBUG: 완료 사진 삭제 완료") if photo_path:
except Exception as e: try:
print(f"WARNING: 완료 사진 삭제 실패 - {str(e)}") 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_at = None
issue.completion_requested_by_id = None issue.completion_requested_by_id = None
issue.completion_photo_path = 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_comment = None
# 완료 반려 정보 기록 (전용 필드 사용) # 완료 반려 정보 기록 (전용 필드 사용)

View File

@@ -5,9 +5,12 @@ from sqlalchemy import func, and_, or_
from datetime import datetime, date from datetime import datetime, date
from typing import List from typing import List
import io import io
import re
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter 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.database import get_db
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
@@ -134,6 +137,53 @@ async def get_report_daily_works(
"total_hours": work.total_hours "total_hours": work.total_hours
} for work in works] } 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") @router.post("/daily-export")
async def export_daily_report( async def export_daily_report(
request: schemas.DailyReportRequest, request: schemas.DailyReportRequest,
@@ -151,23 +201,79 @@ async def export_daily_report(
if not project: if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다") raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 관리함 데이터 조회 (진행 중 + 완료됨) # 관리함 데이터 조회
issues_query = db.query(Issue).filter( # 1. 진행 중인 항목 (모두 포함)
in_progress_only = db.query(Issue).filter(
Issue.project_id == request.project_id, Issue.project_id == request.project_id,
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed]) Issue.review_status == ReviewStatus.in_progress
).order_by(Issue.report_date.desc()) ).all()
issues = issues_query.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) stats = calculate_project_stats(issues)
# 엑셀 파일 생성 # 엑셀 파일 생성
wb = Workbook() wb = Workbook()
ws = wb.active
ws.title = "일일보고서" # 스타일 정의 (공통)
# 스타일 정의
header_font = Font(bold=True, color="FFFFFF") header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
stats_font = Font(bold=True, size=12) stats_font = Font(bold=True, size=12)
@@ -179,113 +285,452 @@ async def export_daily_report(
bottom=Side(style='thin') bottom=Side(style='thin')
) )
center_alignment = Alignment(horizontal='center', vertical='center') center_alignment = Alignment(horizontal='center', vertical='center')
card_header_font = Font(bold=True, color="FFFFFF", size=11)
# 제목 및 기본 정보 label_font = Font(bold=True, size=10)
ws.merge_cells('A1:L1') label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
ws['A1'] = f"{project.project_name} - 일일보고서" content_font = Font(size=10)
ws['A1'].font = Font(bold=True, size=16) thick_border = Border(
ws['A1'].alignment = center_alignment left=Side(style='medium'),
right=Side(style='medium'),
ws.merge_cells('A2:L2') top=Side(style='medium'),
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d')}" bottom=Side(style='medium')
ws['A2'].alignment = center_alignment )
# 프로젝트 통계 (4행부터) # 두 개의 시트를 생성하고 각각 데이터 입력
ws.merge_cells('A4:L4') sheets_data = [
ws['A4'] = "프로젝트 현황" (wb.active, in_progress_issues, "진행 중"),
ws['A4'].font = stats_font (wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
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행부터)
headers = [
"번호", "프로젝트", "부적합명", "상세내용", "원인분류",
"해결방안", "담당부서", "담당자", "마감일", "상태",
"신고일", "완료일"
] ]
header_row = 7 sheets_data[0][0].title = "진행 중"
for col, header in enumerate(headers, 1):
cell = ws.cell(row=header_row, column=col, value=header) for ws, sheet_issues, sheet_title in sheets_data:
cell.font = header_font # 제목 및 기본 정보
cell.fill = header_fill ws.merge_cells('A1:L1')
cell.alignment = center_alignment ws['A1'] = f"{project.project_name} - {sheet_title}"
cell.border = border ws['A1'].font = Font(bold=True, size=16)
ws['A1'].alignment = center_alignment
# 데이터 입력
current_row = header_row + 1 ws.merge_cells('A2:L2')
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d')}"
for issue in issues: ws['A2'].alignment = center_alignment
# 완료됨 항목의 첫 내보내기 여부 확인 (실제로는 DB에 플래그를 저장해야 함)
# 지금은 모든 완료됨 항목을 포함 # 프로젝트 통계 (4행부터) - 진행 중 시트에만 표시
if sheet_title == "진행 중":
ws.cell(row=current_row, column=1, value=issue.id) ws.merge_cells('A4:L4')
ws.cell(row=current_row, column=2, value=project.project_name) ws['A4'] = "📊 프로젝트 현황"
ws.cell(row=current_row, column=3, value=issue.description or "") ws['A4'].font = Font(bold=True, size=14, color="FFFFFF")
ws.cell(row=current_row, column=4, value=issue.detail_notes or "") ws['A4'].fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
ws.cell(row=current_row, column=5, value=get_category_text(issue.category)) ws['A4'].alignment = center_alignment
ws.cell(row=current_row, column=6, value=issue.solution or "") ws.row_dimensions[4].height = 25
ws.cell(row=current_row, column=7, value=get_department_text(issue.responsible_department))
ws.cell(row=current_row, column=8, value=issue.responsible_person or "") # 통계 데이터 - 박스 형태로 개선
ws.cell(row=current_row, column=9, value=issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else "") stats_row = 5
ws.cell(row=current_row, column=10, value=get_status_text(issue.review_status)) ws.row_dimensions[stats_row].height = 30
ws.cell(row=current_row, column=11, value=issue.report_date.strftime('%Y-%m-%d') if issue.report_date else "")
ws.cell(row=current_row, column=12, value=issue.actual_completion_date.strftime('%Y-%m-%d') if issue.actual_completion_date else "") # 총 신고 수량 (파란색 계열)
ws.merge_cells(f'A{stats_row}:B{stats_row}')
# 상태별 색상 적용 ws[f'A{stats_row}'] = "총 신고 수량"
status_color = get_status_color(issue.review_status) ws[f'A{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
if status_color: ws[f'A{stats_row}'].fill = PatternFill(start_color="5B9BD5", end_color="5B9BD5", fill_type="solid")
for col in range(1, len(headers) + 1): ws[f'A{stats_row}'].alignment = center_alignment
ws.cell(row=current_row, column=col).fill = PatternFill(
start_color=status_color, end_color=status_color, fill_type="solid" 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),
for col in range(1, len(headers) + 1): right=Side(style='medium' if col == 12 else 'thin', color=border_color),
ws.cell(row=current_row, column=col).border = border top=Side(style='thin', color=border_color),
ws.cell(row=current_row, column=col).alignment = Alignment(vertical='center') bottom=Side(style='medium', color=border_color)
)
current_row += 1
# 카드 좌우 테두리도 색상 적용
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
# 열 너비 자동 조정 # 열 너비 조정
for col in range(1, len(headers) + 1): ws.column_dimensions['A'].width = 12 # 레이블 열
column_letter = get_column_letter(col) ws.column_dimensions['B'].width = 15 # 내용 열
ws.column_dimensions[column_letter].width = 15 ws.column_dimensions['C'].width = 15 # 내용 열
ws.column_dimensions['D'].width = 15 # 내용 열
# 특정 열 너비 조정 ws.column_dimensions['E'].width = 15 # 내용 열
ws.column_dimensions['C'].width = 20 # 부적합명 ws.column_dimensions['F'].width = 15 # 내용 열
ws.column_dimensions['D'].width = 30 # 상세내용 ws.column_dimensions['G'].width = 15 # 내용
ws.column_dimensions['F'].width = 25 # 해결방안 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() excel_buffer = io.BytesIO()
wb.save(excel_buffer) wb.save(excel_buffer)
excel_buffer.seek(0) 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') today = date.today().strftime('%Y%m%d')
filename = f"{project.project_name}_일일보고서_{today}.xlsx" filename = f"{project.project_name}_일일보고서_{today}.xlsx"
# 한글 파일명을 위한 URL 인코딩 # 한글 파일명을 위한 URL 인코딩
from urllib.parse import quote from urllib.parse import quote
encoded_filename = quote(filename.encode('utf-8')) encoded_filename = quote(filename.encode('utf-8'))
return StreamingResponse( return StreamingResponse(
io.BytesIO(excel_buffer.read()), io.BytesIO(excel_buffer.read()),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -297,22 +742,24 @@ async def export_daily_report(
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats: def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
"""프로젝트 통계 계산""" """프로젝트 통계 계산"""
stats = schemas.DailyReportStats() stats = schemas.DailyReportStats()
today = date.today() today = date.today()
for issue in issues: for issue in issues:
stats.total_count += 1 stats.total_count += 1
if issue.review_status == ReviewStatus.in_progress: if issue.review_status == ReviewStatus.in_progress:
stats.management_count += 1 stats.management_count += 1
# 지연 여부 확인 # 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
if issue.expected_completion_date and issue.expected_completion_date < today: if issue.expected_completion_date:
stats.delayed_count += 1 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: elif issue.review_status == ReviewStatus.completed:
stats.completed_count += 1 stats.completed_count += 1
return stats return stats
def get_category_text(category: IssueCategory) -> str: def get_category_text(category: IssueCategory) -> str:
@@ -350,6 +797,29 @@ def get_status_text(status: ReviewStatus) -> str:
} }
return status_map.get(status, str(status)) 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: def get_status_color(status: ReviewStatus) -> str:
"""상태별 색상 반환""" """상태별 색상 반환"""
color_map = { color_map = {
@@ -358,3 +828,46 @@ def get_status_color(status: ReviewStatus) -> str:
ReviewStatus.disposed: "F2F2F2" # 연한 회색 ReviewStatus.disposed: "F2F2F2" # 연한 회색
} }
return color_map.get(status, None) return color_map.get(status, None)
def get_issue_priority(issue: Issue) -> int:
"""이슈 우선순위 반환 (엑셀 정렬용)
1: 지연 (빨강)
2: 진행중 (노랑)
3: 완료됨 (초록)
"""
if issue.review_status == ReviewStatus.completed:
return 3
elif issue.review_status == ReviewStatus.in_progress:
# 조치 예상일이 지난 경우 지연
if issue.expected_completion_date:
today = date.today()
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
return 1 # 지연
return 2 # 진행중
return 2
def get_issue_status_text(issue: Issue) -> str:
"""이슈 상태 텍스트 반환"""
if issue.review_status == ReviewStatus.completed:
return "완료됨"
elif issue.review_status == ReviewStatus.in_progress:
if issue.expected_completion_date:
today = date.today()
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
if expected_date < today:
return "지연중"
return "진행중"
return get_status_text(issue.review_status)
def get_issue_status_header_color(issue: Issue) -> str:
"""이슈 상태별 헤더 색상 반환"""
priority = get_issue_priority(issue)
if priority == 1: # 지연
return "E74C3C" # 빨간색
elif priority == 2: # 진행중
return "FFD966" # 노랑색 (주황색 사이)
elif priority == 3: # 완료
return "92D050" # 진한 초록색
return "4472C4" # 기본 파란색

View File

@@ -54,31 +54,30 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str
print(f"🔍 HEIC 파일 여부: {is_heic}") print(f"🔍 HEIC 파일 여부: {is_heic}")
# 이미지 검증 및 형식 확인 # 이미지 검증 및 형식 확인
image = None
try: try:
# HEIC 파일인 경우 바로 HEIF 처리 시도 # HEIC 파일인 경우 pillow_heif를 직접 사용하여 처리
if is_heic and HEIF_SUPPORTED: if is_heic and HEIF_SUPPORTED:
print("🔄 HEIC 파일 감지, HEIF 처리 시도...") print("🔄 HEIC 파일 감지, pillow_heif로 직접 처리...")
image = Image.open(io.BytesIO(image_data)) try:
print(f"✅ HEIF 이미지 로드 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}") 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: else:
# 일반 이미지 처리 # 일반 이미지 처리
image = Image.open(io.BytesIO(image_data)) image = Image.open(io.BytesIO(image_data))
print(f"🔍 이미지 형식: {image.format}, 모드: {image.mode}, 크기: {image.size}") print(f"🔍 이미지 형식: {image.format}, 모드: {image.mode}, 크기: {image.size}")
except Exception as e: except Exception as e:
print(f"❌ 이미지 열기 실패: {e}") print(f"❌ 이미지 열기 실패: {e}")
# HEIC 파일인 경우 원본 파일로 저장 # HEIF 재시도
if is_heic: if HEIF_SUPPORTED:
print("🔄 HEIC 파일 - 원본 바이너리 파일로 저장 시도...")
filename = f"{prefix}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.heic"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, 'wb') as f:
f.write(image_data)
print(f"✅ 원본 HEIC 파일 저장: {filepath}")
return f"/uploads/{filename}"
# HEIC가 아닌 경우에만 HEIF 재시도
elif HEIF_SUPPORTED:
print("🔄 HEIF 형식으로 재시도...") print("🔄 HEIF 형식으로 재시도...")
try: try:
image = Image.open(io.BytesIO(image_data)) image = Image.open(io.BytesIO(image_data))
@@ -90,6 +89,10 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str
else: else:
print("❌ HEIF 지원 라이브러리가 설치되지 않거나 처리 불가") print("❌ HEIF 지원 라이브러리가 설치되지 않거나 처리 불가")
raise e raise e
# 이미지가 성공적으로 로드되지 않은 경우
if image is None:
raise Exception("이미지 로드 실패")
# iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환 # iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환
# RGB 모드로 변환 (RGBA, P 모드 등을 처리) # RGB 모드로 변환 (RGBA, P 모드 등을 처리)

View File

@@ -271,17 +271,17 @@
</div> </div>
<form id="reportForm" class="space-y-4"> <form id="reportForm" class="space-y-4">
<!-- 사진 업로드 (선택사항, 최대 2장) --> <!-- 사진 업로드 (선택사항, 최대 5장) -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700"> <label class="text-sm font-medium text-gray-700">
📸 사진 첨부 📸 사진 첨부
</label> </label>
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full"> <span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
선택사항 • 최대 2 선택사항 • 최대 5
</span> </span>
</div> </div>
<!-- 사진 미리보기 영역 --> <!-- 사진 미리보기 영역 -->
<div id="photoPreviewContainer" class="grid grid-cols-2 gap-2 mb-3" style="display: none;"> <div id="photoPreviewContainer" class="grid grid-cols-2 gap-2 mb-3" style="display: none;">
<!-- 첫 번째 사진 --> <!-- 첫 번째 사진 -->
@@ -298,6 +298,27 @@
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
<!-- 세 번째 사진 -->
<div id="photo3Container" class="relative hidden">
<img id="previewImg3" class="w-full h-24 object-cover rounded-xl border-2 border-gray-200">
<button type="button" onclick="removePhoto(2)" class="absolute -top-1 -right-1 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 flex items-center justify-center shadow-lg">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 네 번째 사진 -->
<div id="photo4Container" class="relative hidden">
<img id="previewImg4" class="w-full h-24 object-cover rounded-xl border-2 border-gray-200">
<button type="button" onclick="removePhoto(3)" class="absolute -top-1 -right-1 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 flex items-center justify-center shadow-lg">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 다섯 번째 사진 -->
<div id="photo5Container" class="relative hidden">
<img id="previewImg5" class="w-full h-24 object-cover rounded-xl border-2 border-gray-200">
<button type="button" onclick="removePhoto(4)" class="absolute -top-1 -right-1 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 flex items-center justify-center shadow-lg">
<i class="fas fa-times"></i>
</button>
</div>
</div> </div>
<!-- 업로드 버튼들 --> <!-- 업로드 버튼들 -->
@@ -329,7 +350,7 @@
<!-- 현재 상태 표시 --> <!-- 현재 상태 표시 -->
<div class="text-center mt-2"> <div class="text-center mt-2">
<p class="text-sm text-gray-500" id="photoUploadText">사진 추가 (0/2)</p> <p class="text-sm text-gray-500" id="photoUploadText">사진 추가 (0/5)</p>
</div> </div>
<!-- 숨겨진 입력 필드들 --> <!-- 숨겨진 입력 필드들 -->
@@ -880,23 +901,23 @@
// 사진 업로드 처리 함수 // 사진 업로드 처리 함수
async function handlePhotoUpload(files) { async function handlePhotoUpload(files) {
const filesArray = Array.from(files); const filesArray = Array.from(files);
// 현재 사진 개수 확인 // 현재 사진 개수 확인
if (currentPhotos.length >= 2) { if (currentPhotos.length >= 5) {
alert('최대 2장까지 업로드 가능합니다.'); alert('최대 5장까지 업로드 가능합니다.');
return; return;
} }
// 추가 가능한 개수만큼만 처리 // 추가 가능한 개수만큼만 처리
const availableSlots = 2 - currentPhotos.length; const availableSlots = 5 - currentPhotos.length;
const filesToProcess = filesArray.slice(0, availableSlots); const filesToProcess = filesArray.slice(0, availableSlots);
// 로딩 표시 // 로딩 표시
showUploadProgress(true); showUploadProgress(true);
try { try {
for (const file of filesToProcess) { for (const file of filesToProcess) {
if (currentPhotos.length >= 2) break; if (currentPhotos.length >= 5) break;
// 원본 파일 크기 확인 // 원본 파일 크기 확인
const originalSize = file.size; const originalSize = file.size;
@@ -923,18 +944,18 @@
const cameraBtn = document.getElementById('cameraUpload'); const cameraBtn = document.getElementById('cameraUpload');
const galleryBtn = document.getElementById('galleryUpload'); const galleryBtn = document.getElementById('galleryUpload');
const uploadText = document.getElementById('photoUploadText'); const uploadText = document.getElementById('photoUploadText');
if (show) { if (show) {
cameraBtn.classList.add('opacity-50', 'cursor-not-allowed'); cameraBtn.classList.add('opacity-50', 'cursor-not-allowed');
galleryBtn.classList.add('opacity-50', 'cursor-not-allowed'); galleryBtn.classList.add('opacity-50', 'cursor-not-allowed');
uploadText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>이미지 처리 중...'; uploadText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>이미지 처리 중...';
uploadText.classList.add('text-blue-600'); uploadText.classList.add('text-blue-600');
} else { } else {
if (currentPhotos.length < 2) { if (currentPhotos.length < 5) {
cameraBtn.classList.remove('opacity-50', 'cursor-not-allowed'); cameraBtn.classList.remove('opacity-50', 'cursor-not-allowed');
galleryBtn.classList.remove('opacity-50', 'cursor-not-allowed'); galleryBtn.classList.remove('opacity-50', 'cursor-not-allowed');
} }
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`; uploadText.textContent = `사진 추가 (${currentPhotos.length}/5)`;
uploadText.classList.remove('text-blue-600'); uploadText.classList.remove('text-blue-600');
} }
} }
@@ -964,43 +985,37 @@
// 사진 미리보기 업데이트 // 사진 미리보기 업데이트
function updatePhotoPreview() { function updatePhotoPreview() {
const container = document.getElementById('photoPreviewContainer'); const container = document.getElementById('photoPreviewContainer');
const photo1Container = document.getElementById('photo1Container');
const photo2Container = document.getElementById('photo2Container');
const uploadText = document.getElementById('photoUploadText'); const uploadText = document.getElementById('photoUploadText');
const cameraUpload = document.getElementById('cameraUpload'); const cameraUpload = document.getElementById('cameraUpload');
const galleryUpload = document.getElementById('galleryUpload'); const galleryUpload = document.getElementById('galleryUpload');
// 텍스트 업데이트 // 텍스트 업데이트
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`; uploadText.textContent = `사진 추가 (${currentPhotos.length}/5)`;
// 첫 번째 사진 // 모든 사진 미리보기 업데이트 (최대 5장)
if (currentPhotos[0]) { for (let i = 0; i < 5; i++) {
document.getElementById('previewImg1').src = currentPhotos[0]; const photoContainer = document.getElementById(`photo${i + 1}Container`);
photo1Container.classList.remove('hidden'); const previewImg = document.getElementById(`previewImg${i + 1}`);
container.style.display = 'grid';
} else { if (currentPhotos[i]) {
photo1Container.classList.add('hidden'); previewImg.src = currentPhotos[i];
photoContainer.classList.remove('hidden');
container.style.display = 'grid';
} else {
photoContainer.classList.add('hidden');
}
} }
// 두 번째 사진
if (currentPhotos[1]) {
document.getElementById('previewImg2').src = currentPhotos[1];
photo2Container.classList.remove('hidden');
container.style.display = 'grid';
} else {
photo2Container.classList.add('hidden');
}
// 미리보기 컨테이너 표시/숨김 // 미리보기 컨테이너 표시/숨김
if (currentPhotos.length === 0) { if (currentPhotos.length === 0) {
container.style.display = 'none'; container.style.display = 'none';
} }
// 2장이 모두 업로드되면 업로드 버튼 스타일 변경 // 5장이 모두 업로드되면 업로드 버튼 스타일 변경
if (currentPhotos.length >= 2) { if (currentPhotos.length >= 5) {
cameraUpload.classList.add('opacity-50', 'cursor-not-allowed'); cameraUpload.classList.add('opacity-50', 'cursor-not-allowed');
galleryUpload.classList.add('opacity-50', 'cursor-not-allowed'); galleryUpload.classList.add('opacity-50', 'cursor-not-allowed');
uploadText.textContent = '최대 2장 업로드 완료'; uploadText.textContent = '최대 5장 업로드 완료';
uploadText.classList.add('text-green-600', 'font-medium'); uploadText.classList.add('text-green-600', 'font-medium');
} else { } else {
cameraUpload.classList.remove('opacity-50', 'cursor-not-allowed'); cameraUpload.classList.remove('opacity-50', 'cursor-not-allowed');

View File

@@ -710,27 +710,31 @@
incoming_defect: '입고자재 불량', incoming_defect: '입고자재 불량',
inspection_miss: '검사미스' inspection_miss: '검사미스'
}; };
const categoryColors = { const categoryColors = {
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300', material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
design_error: 'bg-blue-100 text-blue-700 border-blue-300', design_error: 'bg-blue-100 text-blue-700 border-blue-300',
incoming_defect: 'bg-red-100 text-red-700 border-red-300', incoming_defect: 'bg-red-100 text-red-700 border-red-300',
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300' inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
}; };
const div = document.createElement('div'); const div = document.createElement('div');
// 검토 완료 상태에 따른 스타일링 // 검토 완료 상태에 따른 스타일링
const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4'; const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4';
const statusClasses = isCompleted const statusClasses = isCompleted
? 'bg-gray-100 opacity-75' ? 'bg-gray-100 opacity-75'
: 'bg-gray-50 hover:bg-gray-100'; : 'bg-gray-50 hover:bg-gray-100';
const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300'; const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300';
div.className = `${baseClasses} ${statusClasses} ${borderColor}`; div.className = `${baseClasses} ${statusClasses} ${borderColor}`;
const dateStr = DateUtils.formatKST(issue.report_date, true); const dateStr = DateUtils.formatKST(issue.report_date, true);
const relativeTime = DateUtils.getRelativeTime(issue.report_date); const relativeTime = DateUtils.getRelativeTime(issue.report_date);
const projectInfo = getProjectInfo(issue.project_id || issue.projectId); const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
// 수정/삭제 권한 확인 (본인이 등록한 부적합만)
const canEdit = issue.reporter_id === currentUser.id;
const canDelete = issue.reporter_id === currentUser.id || currentUser.role === 'admin';
div.innerHTML = ` div.innerHTML = `
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) --> <!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
<div class="flex justify-between items-start p-2 pb-0"> <div class="flex justify-between items-start p-2 pb-0">
@@ -741,49 +745,77 @@
<i class="fas fa-folder-open mr-1"></i>${projectInfo} <i class="fas fa-folder-open mr-1"></i>${projectInfo}
</div> </div>
</div> </div>
<!-- 기존 내용 --> <!-- 기존 내용 -->
<div class="flex gap-3 p-3 pt-1"> <div class="flex gap-3 p-3 pt-1">
<!-- 사진들 --> <!-- 사진들 -->
<div class="flex gap-1 flex-shrink-0"> <div class="flex gap-1 flex-shrink-0 flex-wrap max-w-md">
${issue.photo_path ? ${(() => {
`<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path}')">` : '' const photos = [
} issue.photo_path,
${issue.photo_path2 ? issue.photo_path2,
`<img src="${issue.photo_path2}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path2}')">` : '' issue.photo_path3,
} issue.photo_path4,
${!issue.photo_path && !issue.photo_path2 ? issue.photo_path5
`<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center"> ].filter(p => p);
<i class="fas fa-image text-gray-400"></i>
</div>` : '' if (photos.length === 0) {
} return `
<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
<i class="fas fa-image text-gray-400"></i>
</div>
`;
}
return photos.map(path => `
<img src="${path}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${path}')">
`).join('');
})()}
</div> </div>
<!-- 내용 --> <!-- 내용 -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-2"> <div class="flex items-start justify-between mb-2">
<span class="px-2 py-1 rounded-full text-xs font-medium ${categoryColors[issue.category] || 'bg-gray-100 text-gray-700'}"> <span class="px-2 py-1 rounded-full text-xs font-medium ${categoryColors[issue.category] || 'bg-gray-100 text-gray-700'}">
${categoryNames[issue.category] || issue.category} ${categoryNames[issue.category] || issue.category}
</span> </span>
${issue.work_hours ? ${issue.work_hours ?
`<span class="text-sm text-green-600 font-medium"> `<span class="text-sm text-green-600 font-medium">
<i class="fas fa-clock mr-1"></i>${issue.work_hours}시간 <i class="fas fa-clock mr-1"></i>${issue.work_hours}시간
</span>` : </span>` :
'<span class="text-sm text-gray-400">시간 미입력</span>' '<span class="text-sm text-gray-400">시간 미입력</span>'
} }
</div> </div>
<p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p> <p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p>
<div class="flex items-center gap-4 text-sm text-gray-500"> <div class="flex items-center justify-between">
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span> <div class="flex items-center gap-4 text-sm text-gray-500">
<span><i class="fas fa-calendar mr-1"></i>${dateStr}</span> <span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
<span class="text-xs text-gray-400">${relativeTime}</span> <span><i class="fas fa-calendar mr-1"></i>${dateStr}</span>
<span class="text-xs text-gray-400">${relativeTime}</span>
</div>
<!-- 수정/삭제 버튼 -->
${(canEdit || canDelete) ? `
<div class="flex gap-2">
${canEdit ? `
<button onclick='showEditModal(${JSON.stringify(issue).replace(/'/g, "&apos;")})' class="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
<i class="fas fa-edit mr-1"></i>수정
</button>
` : ''}
${canDelete ? `
<button onclick="confirmDelete(${issue.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
` : ''}
</div> </div>
</div> </div>
</div> </div>
`; `;
return div; return div;
} }
@@ -920,7 +952,151 @@
localStorage.removeItem('currentUser'); localStorage.removeItem('currentUser');
window.location.href = 'index.html'; window.location.href = 'index.html';
} }
// 수정 모달 표시
function showEditModal(issue) {
const categoryNames = {
material_missing: '자재누락',
design_error: '설계미스',
incoming_defect: '입고자재 불량',
inspection_miss: '검사미스'
};
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">부적합 수정</h3>
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form id="editIssueForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
<select id="editCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<select id="editProject" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
${projects.map(p => `
<option value="${p.id}" ${p.id === issue.project_id ? 'selected' : ''}>
${p.job_no} / ${p.project_name}
</option>
`).join('')}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">내용</label>
<textarea id="editDescription" class="w-full px-3 py-2 border border-gray-300 rounded-lg" rows="4" required>${issue.description || ''}</textarea>
</div>
<div class="flex gap-2 pt-4">
<button type="button" onclick="this.closest('.fixed').remove()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
수정
</button>
</div>
</form>
</div>
`;
document.body.appendChild(modal);
// 폼 제출 이벤트 처리
document.getElementById('editIssueForm').addEventListener('submit', async (e) => {
e.preventDefault();
const updateData = {
category: document.getElementById('editCategory').value,
description: document.getElementById('editDescription').value,
project_id: parseInt(document.getElementById('editProject').value)
};
try {
await IssuesAPI.update(issue.id, updateData);
alert('수정되었습니다.');
modal.remove();
// 목록 새로고침
await loadIssues();
} catch (error) {
console.error('수정 실패:', error);
alert('수정에 실패했습니다: ' + error.message);
}
});
}
// 삭제 확인 다이얼로그
function confirmDelete(issueId) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
<div class="text-center mb-4">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
</div>
<h3 class="text-lg font-semibold mb-2">부적합 삭제</h3>
<p class="text-sm text-gray-600">
이 부적합 사항을 삭제하시겠습니까?<br>
삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다.
</p>
</div>
<div class="flex gap-2">
<button onclick="this.closest('.fixed').remove()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button onclick="handleDelete(${issueId})"
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
삭제
</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
// 삭제 처리
async function handleDelete(issueId) {
try {
await IssuesAPI.delete(issueId);
alert('삭제되었습니다.');
// 모달 닫기
const modal = document.querySelector('.fixed');
if (modal) modal.remove();
// 목록 새로고침
await loadIssues();
} catch (error) {
console.error('삭제 실패:', error);
alert('삭제에 실패했습니다: ' + error.message);
}
}
// 네비게이션은 공통 헤더에서 처리됨 // 네비게이션은 공통 헤더에서 처리됨
// API 스크립트 동적 로딩 // API 스크립트 동적 로딩

View File

@@ -977,33 +977,34 @@
<i class="fas fa-camera text-blue-500 mr-2"></i> <i class="fas fa-camera text-blue-500 mr-2"></i>
<span class="text-gray-600 font-medium text-sm">이미지</span> <span class="text-gray-600 font-medium text-sm">이미지</span>
</div> </div>
<div class="flex gap-3 justify-center"> <div class="flex flex-wrap gap-3 justify-center">
${issue.photo_path ? ` ${(() => {
<div class="relative w-40 h-40 rounded-lg border-2 border-blue-200 overflow-hidden cursor-pointer hover:shadow-lg hover:scale-105 transition-all duration-200" onclick="openPhotoModal('${issue.photo_path}')"> const photos = [
<img src="${issue.photo_path}" alt="부적합 사진 1" class="w-full h-full object-cover" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"> issue.photo_path,
<div class="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center" style="display: none;"> issue.photo_path2,
<i class="fas fa-image text-blue-500 text-2xl"></i> issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(p => p);
if (photos.length === 0) {
return `
<div class="w-40 h-40 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-gray-200 flex items-center justify-center">
<i class="fas fa-image text-gray-400 text-2xl opacity-50"></i>
</div>
`;
}
return photos.map((path, idx) => `
<div class="relative w-40 h-40 rounded-lg border-2 border-blue-200 overflow-hidden cursor-pointer hover:shadow-lg hover:scale-105 transition-all duration-200" onclick="openPhotoModal('${path}')">
<img src="${path}" alt="부적합 사진 ${idx + 1}" class="w-full h-full object-cover" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center" style="display: none;">
<i class="fas fa-image text-blue-500 text-2xl"></i>
</div>
<div class="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white"></div>
</div> </div>
<div class="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white"></div> `).join('');
</div> })()}
` : `
<div class="w-40 h-40 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-gray-200 flex items-center justify-center">
<i class="fas fa-image text-gray-400 text-2xl opacity-50"></i>
</div>
`}
${issue.photo_path2 ? `
<div class="relative w-40 h-40 rounded-lg border-2 border-blue-200 overflow-hidden cursor-pointer hover:shadow-lg hover:scale-105 transition-all duration-200" onclick="openPhotoModal('${issue.photo_path2}')">
<img src="${issue.photo_path2}" alt="부적합 사진 2" class="w-full h-full object-cover" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center" style="display: none;">
<i class="fas fa-image text-blue-500 text-2xl"></i>
</div>
<div class="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white"></div>
</div>
` : `
<div class="w-40 h-40 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-gray-200 flex items-center justify-center">
<i class="fas fa-image text-gray-400 text-2xl opacity-50"></i>
</div>
`}
</div> </div>
<!-- 완료 반려 내용 표시 --> <!-- 완료 반려 내용 표시 -->

View File

@@ -795,7 +795,7 @@
const timeAgo = getTimeAgo(reportDate); const timeAgo = getTimeAgo(reportDate);
// 사진 정보 처리 // 사진 정보 처리
const photoCount = [issue.photo_path, issue.photo_path2].filter(Boolean).length; const photoCount = [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean).length;
const photoInfo = photoCount > 0 ? `사진 ${photoCount}` : '사진 없음'; const photoInfo = photoCount > 0 ? `사진 ${photoCount}` : '사진 없음';
return ` return `
@@ -856,8 +856,10 @@
<!-- 사진 미리보기 --> <!-- 사진 미리보기 -->
${photoCount > 0 ? ` ${photoCount > 0 ? `
<div class="photo-gallery"> <div class="photo-gallery">
${issue.photo_path ? `<img src="${issue.photo_path}" class="photo-preview" onclick="openPhotoModal('${issue.photo_path}')" alt="첨부 사진 1">` : ''} ${[issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5]
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="photo-preview" onclick="openPhotoModal('${issue.photo_path2}')" alt="첨부 사진 2">` : ''} .filter(Boolean)
.map((path, idx) => `<img src="${path}" class="photo-preview" onclick="openPhotoModal('${path}')" alt="첨부 사진 ${idx + 1}">`)
.join('')}
</div> </div>
` : ''} ` : ''}

View File

@@ -851,10 +851,27 @@
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">업로드 사진</label> <label class="block text-sm font-medium text-gray-700 mb-2">업로드 사진</label>
<div class="flex gap-2"> ${(() => {
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200 hover:border-blue-400 transition-colors" onclick="openPhotoModal('${issue.photo_path}')" alt="업로드 사진 1">` : '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs border-2 border-dashed border-gray-300">사진 없음</div>'} const photos = [
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200 hover:border-blue-400 transition-colors" onclick="openPhotoModal('${issue.photo_path2}')" alt="업로드 사진 2">` : '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs border-2 border-dashed border-gray-300">사진 없음</div>'} issue.photo_path,
</div> issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(p => p);
if (photos.length === 0) {
return '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs border-2 border-dashed border-gray-300">사진 없음</div>';
}
return `
<div class="flex flex-wrap gap-2">
${photos.map((path, idx) => `
<img src="${path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200 hover:border-blue-400 transition-colors" onclick="openPhotoModal('${path}')" alt="업로드 사진 ${idx + 1}">
`).join('')}
</div>
`;
})()}
</div> </div>
</div> </div>
@@ -903,11 +920,27 @@
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="text-xs text-purple-600 font-medium">완료 사진</label> <label class="text-xs text-purple-600 font-medium">완료 사진</label>
${issue.completion_photo_path ? ` ${(() => {
<div class="mt-1"> const photos = [
<img src="${issue.completion_photo_path}" class="w-24 h-24 object-cover rounded-lg cursor-pointer border-2 border-purple-200 hover:border-purple-400 transition-colors" onclick="openPhotoModal('${issue.completion_photo_path}')" alt="완료 사진"> issue.completion_photo_path,
</div> issue.completion_photo_path2,
` : '<p class="text-xs text-gray-500 mt-1">완료 사진 없음</p>'} issue.completion_photo_path3,
issue.completion_photo_path4,
issue.completion_photo_path5
].filter(p => p);
if (photos.length === 0) {
return '<p class="text-xs text-gray-500 mt-1">완료 사진 없음</p>';
}
return `
<div class="mt-1 flex flex-wrap gap-2">
${photos.map(path => `
<img src="${path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-purple-200 hover:border-purple-400 transition-colors" onclick="openPhotoModal('${path}')" alt="완료 사진">
`).join('')}
</div>
`;
})()}
</div> </div>
<div> <div>
<label class="text-xs text-purple-600 font-medium">완료 코멘트</label> <label class="text-xs text-purple-600 font-medium">완료 코멘트</label>
@@ -1015,20 +1048,27 @@
<!-- 완료 사진 --> <!-- 완료 사진 -->
<div> <div>
<label class="text-xs text-green-600 font-medium">완료 사진</label> <label class="text-xs text-green-600 font-medium">완료 사진</label>
${issue.completion_photo_path ? ${(() => {
(issue.completion_photo_path.toLowerCase().endsWith('.heic') ? const photos = [
`<div class="mt-1 flex items-center space-x-2"> issue.completion_photo_path,
<div class="w-16 h-16 bg-green-100 rounded-lg flex items-center justify-center border border-green-200"> issue.completion_photo_path2,
<i class="fas fa-image text-green-500"></i> issue.completion_photo_path3,
</div> issue.completion_photo_path4,
<a href="${issue.completion_photo_path}" download class="text-xs text-blue-500 hover:text-blue-700 underline">HEIC 다운로드</a> issue.completion_photo_path5
</div>` : ].filter(p => p);
`<div class="mt-1">
<img src="${issue.completion_photo_path}" class="w-16 h-16 object-cover rounded-lg cursor-pointer border-2 border-green-200 hover:border-green-400 transition-colors" onclick="openPhotoModal('${issue.completion_photo_path}')" alt="완료 사진"> if (photos.length === 0) {
</div>` return '<p class="text-xs text-gray-500 mt-1">완료 사진 없음</p>';
) : }
'<p class="text-xs text-gray-500 mt-1">완료 사진 없음</p>'
} return `
<div class="mt-1 flex flex-wrap gap-2">
${photos.map(path => `
<img src="${path}" class="w-16 h-16 object-cover rounded-lg cursor-pointer border-2 border-green-200 hover:border-green-400 transition-colors" onclick="openPhotoModal('${path}')" alt="완료 사진">
`).join('')}
</div>
`;
})()}
</div> </div>
<!-- 완료 코멘트 --> <!-- 완료 코멘트 -->
<div> <div>
@@ -1052,10 +1092,27 @@
<i class="fas fa-camera text-gray-500 mr-2"></i> <i class="fas fa-camera text-gray-500 mr-2"></i>
업로드 사진 업로드 사진
</h4> </h4>
<div class="flex gap-2"> ${(() => {
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200" onclick="openPhotoModal('${issue.photo_path}')" alt="업로드 사진 1">` : '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs">사진 없음</div>'} const photos = [
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200" onclick="openPhotoModal('${issue.photo_path2}')" alt="업로드 사진 2">` : '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs">사진 없음</div>'} issue.photo_path,
</div> issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(p => p);
if (photos.length === 0) {
return '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs">사진 없음</div>';
}
return `
<div class="flex flex-wrap gap-2">
${photos.map((path, idx) => `
<img src="${path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200" onclick="openPhotoModal('${path}')" alt="업로드 사진 ${idx + 1}">
`).join('')}
</div>
`;
})()}
</div> </div>
</div> </div>
`; `;
@@ -1402,10 +1459,27 @@
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">업로드 사진</label> <label class="block text-sm font-medium text-gray-700 mb-1">업로드 사진</label>
<div class="flex gap-2"> ${(() => {
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded cursor-pointer" onclick="openPhotoModal('${issue.photo_path}')" alt="업로드 사진 1">` : '<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">없음</div>'} const photos = [
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-20 h-20 object-cover rounded cursor-pointer" onclick="openPhotoModal('${issue.photo_path2}')" alt="업로드 사진 2">` : '<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">없음</div>'} issue.photo_path,
</div> issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(p => p);
if (photos.length === 0) {
return '<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">없음</div>';
}
return `
<div class="flex flex-wrap gap-2">
${photos.map((path, idx) => `
<img src="${path}" class="w-20 h-20 object-cover rounded cursor-pointer border-2 border-gray-300 hover:border-blue-400 transition-colors" onclick="openPhotoModal('${path}')" alt="업로드 사진 ${idx + 1}">
`).join('')}
</div>
`;
})()}
</div> </div>
</div> </div>
</div> </div>
@@ -1454,13 +1528,37 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">완료 사진</label> <label class="block text-sm font-medium text-gray-700 mb-1">완료 사진 (최대 5장)</label>
<div class="flex items-center gap-3">
${issue.completion_photo_path ? <!-- 기존 완료 사진 표시 -->
`<img src="${issue.completion_photo_path}" class="w-20 h-20 object-cover rounded cursor-pointer" onclick="openPhotoModal('${issue.completion_photo_path}')" alt="완료 사진">` : ${(() => {
'<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">없음</div>' const photos = [
issue.completion_photo_path,
issue.completion_photo_path2,
issue.completion_photo_path3,
issue.completion_photo_path4,
issue.completion_photo_path5
].filter(p => p);
if (photos.length > 0) {
return `
<div class="mb-3">
<p class="text-xs text-gray-600 mb-2">현재 완료 사진 (${photos.length}장)</p>
<div class="flex flex-wrap gap-2">
${photos.map(path => `
<img src="${path}" class="w-16 h-16 object-cover rounded cursor-pointer border-2 border-gray-300 hover:border-blue-400 transition-colors" onclick="openPhotoModal('${path}')" alt="완료 사진">
`).join('')}
</div>
</div>
`;
} }
<input type="file" id="modal_completion_photo" accept="image/*" class="flex-1 text-sm"> return '';
})()}
<!-- 사진 업로드 (최대 5장) -->
<div class="space-y-2">
<input type="file" id="modal_completion_photo" accept="image/*" multiple class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
<p class="text-xs text-gray-500">※ 최대 5장까지 업로드 가능합니다. 새로운 사진을 업로드하면 기존 사진을 모두 교체합니다.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1488,11 +1586,20 @@
} }
}); });
// 완료 사진 처리 // 완료 사진 처리 (최대 5장)
const photoFile = document.getElementById('modal_completion_photo').files[0]; const photoInput = document.getElementById('modal_completion_photo');
if (photoFile) { const photoFiles = photoInput.files;
const base64 = await fileToBase64(photoFile);
updates.completion_photo = base64; if (photoFiles && photoFiles.length > 0) {
const maxPhotos = Math.min(photoFiles.length, 5);
for (let i = 0; i < maxPhotos; i++) {
const base64 = await fileToBase64(photoFiles[i]);
const fieldName = i === 0 ? 'completion_photo' : `completion_photo${i + 1}`;
updates[fieldName] = base64;
}
console.log(`📸 ${maxPhotos}장의 완료 사진 처리 완료`);
} }
console.log('Modal sending updates:', updates); console.log('Modal sending updates:', updates);
@@ -1869,10 +1976,27 @@
<div class="bg-gray-50 p-4 rounded-lg"> <div class="bg-gray-50 p-4 rounded-lg">
<h4 class="font-semibold text-gray-800 mb-3">업로드 사진</h4> <h4 class="font-semibold text-gray-800 mb-3">업로드 사진</h4>
<div class="flex gap-2"> ${(() => {
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200" onclick="openPhotoModal('${issue.photo_path}')" alt="업로드 사진 1">` : '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs">사진 없음</div>'} const photos = [
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200" onclick="openPhotoModal('${issue.photo_path2}')" alt="업로드 사진 2">` : '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs">사진 없음</div>'} issue.photo_path,
</div> issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(p => p);
if (photos.length === 0) {
return '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs">사진 없음</div>';
}
return `
<div class="flex flex-wrap gap-2">
${photos.map((path, idx) => `
<img src="${path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200 hover:border-blue-400 transition-colors" onclick="openPhotoModal('${path}')" alt="업로드 사진 ${idx + 1}">
`).join('')}
</div>
`;
})()}
</div> </div>
</div> </div>
@@ -1923,32 +2047,57 @@
<h4 class="font-semibold text-purple-800 mb-3">완료 신청 정보</h4> <h4 class="font-semibold text-purple-800 mb-3">완료 신청 정보</h4>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">완료 사진</label> <label class="block text-sm font-medium text-gray-700 mb-2">완료 사진 (최대 5장)</label>
<div class="space-y-3"> <div class="space-y-3">
${issue.completion_photo_path ? ` ${(() => {
<div class="flex items-center space-x-3"> const photos = [
<img src="${issue.completion_photo_path}" class="w-24 h-24 object-cover rounded-lg cursor-pointer border-2 border-purple-200 hover:border-purple-400 transition-colors" onclick="openPhotoModal('${issue.completion_photo_path}')" alt="현재 완료 사진"> issue.completion_photo_path,
<div class="flex-1"> issue.completion_photo_path2,
<p class="text-sm text-gray-600 mb-1">현재 완료 사진</p> issue.completion_photo_path3,
<p class="text-xs text-gray-500">클릭하면 크게 볼 수 있습니다</p> issue.completion_photo_path4,
</div> issue.completion_photo_path5
</div> ].filter(p => p);
` : `
<div class="flex items-center justify-center w-24 h-24 bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg"> if (photos.length > 0) {
<div class="text-center"> return `
<i class="fas fa-camera text-gray-400 text-lg mb-1"></i> <div class="mb-3">
<p class="text-xs text-gray-500">사진 없음</p> <p class="text-xs text-gray-600 mb-2">현재 완료 사진 (${photos.length}장)</p>
</div> <div class="flex flex-wrap gap-2">
</div> ${photos.map(path => `
`} <img src="${path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-purple-200 hover:border-purple-400 transition-colors" onclick="openPhotoModal('${path}')" alt="완료 사진">
`).join('')}
</div>
</div>
`;
} else {
return `
<div class="flex items-center justify-center w-24 h-24 bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg mb-3">
<div class="text-center">
<i class="fas fa-camera text-gray-400 text-lg mb-1"></i>
<p class="text-xs text-gray-500">사진 없음</p>
</div>
</div>
`;
}
})()}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input type="file" id="edit-completion-photo-${issue.id}" accept="image/*" class="hidden"> <input type="file" id="edit-completion-photo-${issue.id}" accept="image/*" multiple class="hidden">
<button type="button" onclick="document.getElementById('edit-completion-photo-${issue.id}').click()" class="flex items-center px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors text-sm"> <button type="button" onclick="document.getElementById('edit-completion-photo-${issue.id}').click()" class="flex items-center px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors text-sm">
<i class="fas fa-upload mr-2"></i> <i class="fas fa-upload mr-2"></i>
${issue.completion_photo_path ? '사진 교체' : '사진 업로드'} ${(() => {
const photoCount = [
issue.completion_photo_path,
issue.completion_photo_path2,
issue.completion_photo_path3,
issue.completion_photo_path4,
issue.completion_photo_path5
].filter(p => p).length;
return photoCount > 0 ? '사진 교체' : '사진 업로드';
})()}
</button> </button>
<span id="photo-filename-${issue.id}" class="text-sm text-gray-600"></span> <span id="photo-filename-${issue.id}" class="text-sm text-gray-600"></span>
</div> </div>
<p class="text-xs text-gray-500">※ 최대 5장까지 업로드 가능합니다. 새로운 사진을 업로드하면 기존 사진을 모두 교체합니다.</p>
</div> </div>
</div> </div>
<div> <div>
@@ -1995,8 +2144,9 @@
if (fileInput && filenameSpan) { if (fileInput && filenameSpan) {
fileInput.addEventListener('change', function(e) { fileInput.addEventListener('change', function(e) {
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files.length > 0) {
filenameSpan.textContent = e.target.files[0].name; const fileCount = Math.min(e.target.files.length, 5);
filenameSpan.textContent = `${fileCount}개 파일 선택됨`;
filenameSpan.className = 'text-sm text-green-600 font-medium'; filenameSpan.className = 'text-sm text-green-600 font-medium';
} else { } else {
filenameSpan.textContent = ''; filenameSpan.textContent = '';
@@ -2038,31 +2188,38 @@
// 완료 신청 정보 (완료 대기 상태일 때만) // 완료 신청 정보 (완료 대기 상태일 때만)
const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`); const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`);
const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`); const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`);
let completionComment = null; let completionComment = null;
let completionPhoto = null; const completionPhotos = {}; // 완료 사진들을 저장할 객체
if (completionCommentElement) { if (completionCommentElement) {
completionComment = completionCommentElement.value.trim(); completionComment = completionCommentElement.value.trim();
} }
if (completionPhotoElement && completionPhotoElement.files[0]) { // 완료 사진 처리 (최대 5장)
if (completionPhotoElement && completionPhotoElement.files.length > 0) {
try { try {
const file = completionPhotoElement.files[0]; const files = completionPhotoElement.files;
console.log('🔍 업로드할 파일 정보:', { const maxPhotos = Math.min(files.length, 5);
name: file.name,
size: file.size, console.log(`🔍 총 ${maxPhotos}개의 완료 사진 업로드 시작`);
type: file.type,
lastModified: file.lastModified for (let i = 0; i < maxPhotos; i++) {
}); const file = files[i];
console.log(`🔍 파일 ${i + 1} 정보:`, {
const base64 = await fileToBase64(file); name: file.name,
console.log('🔍 Base64 변환 완료 - 전체 길이:', base64.length); size: file.size,
console.log('🔍 Base64 헤더:', base64.substring(0, 50)); type: file.type
});
completionPhoto = base64.split(',')[1]; // Base64 데이터만 추출
console.log('🔍 헤더 제거 후 길이:', completionPhoto.length); const base64 = await fileToBase64(file);
console.log('🔍 전송할 Base64 시작 부분:', completionPhoto.substring(0, 50)); const base64Data = base64.split(',')[1]; // Base64 데이터만 추출
const fieldName = i === 0 ? 'completion_photo' : `completion_photo${i + 1}`;
completionPhotos[fieldName] = base64Data;
console.log(`✅ 파일 ${i + 1} 변환 완료 (${fieldName})`);
}
} catch (error) { } catch (error) {
console.error('파일 변환 오류:', error); console.error('파일 변환 오류:', error);
alert('완료 사진 업로드 중 오류가 발생했습니다.'); alert('완료 사진 업로드 중 오류가 발생했습니다.');
@@ -2091,8 +2248,9 @@
if (completionComment !== null) { if (completionComment !== null) {
requestBody.completion_comment = completionComment || null; requestBody.completion_comment = completionComment || null;
} }
if (completionPhoto !== null) { // 완료 사진들 추가 (최대 5장)
requestBody.completion_photo = completionPhoto; for (const [key, value] of Object.entries(completionPhotos)) {
requestBody[key] = value;
} }
try { try {

View File

@@ -4,39 +4,47 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일보고서 - 작업보고서</title> <title>일일보고서 - 작업보고서</title>
<!-- Tailwind CSS --> <!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome --> <!-- 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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles --> <!-- Custom Styles -->
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
} }
.report-card { .report-card {
transition: all 0.2s ease; transition: all 0.2s ease;
border-left: 4px solid transparent; border-left: 4px solid transparent;
} }
.report-card:hover { .report-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-left-color: #10b981; border-left-color: #10b981;
} }
.stats-card { .stats-card {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.stats-card:hover { .stats-card:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
.issue-row {
transition: all 0.2s ease;
}
.issue-row:hover {
background-color: #f9fafb;
}
</style> </style>
</head> </head>
<body class="bg-gray-50 min-h-screen"> <body class="bg-gray-50 min-h-screen">
@@ -52,12 +60,12 @@
<i class="fas fa-file-excel text-green-500 mr-3"></i> <i class="fas fa-file-excel text-green-500 mr-3"></i>
일일보고서 일일보고서
</h1> </h1>
<p class="text-gray-600 mt-1">품질팀용 관리함 데이터를 엑셀 형태로 내보내세요</p> <p class="text-gray-600 mt-1">프로젝트별 진행중/완료 항목을 엑셀로 내보내세요</p>
</div> </div>
</div> </div>
</div> </div>
<!-- 프로젝트 선택 및 생성 --> <!-- 프로젝트 선택 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6"> <div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="space-y-6"> <div class="space-y-6">
<!-- 프로젝트 선택 --> <!-- 프로젝트 선택 -->
@@ -70,48 +78,74 @@
</select> </select>
<p class="text-sm text-gray-500 mt-2"> <p class="text-sm text-gray-500 mt-2">
<i class="fas fa-info-circle mr-1"></i> <i class="fas fa-info-circle mr-1"></i>
선택한 프로젝트의 관리함 데이터만 포함됩니다. 진행 중인 항목 + 완료되고 한번도 추출 안된 항목이 포함됩니다.
</p> </p>
</div> </div>
<!-- 생성 버튼 --> <!-- 버튼 -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<button id="generateReportBtn" <button id="previewBtn"
onclick="generateDailyReport()" onclick="loadPreview()"
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" 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">
disabled> <i class="fas fa-eye mr-2"></i>미리보기
<i class="fas fa-download mr-2"></i>일일보고서 생성
</button> </button>
<button id="previewStatsBtn" <button id="generateReportBtn"
onclick="toggleStatsPreview()" onclick="generateDailyReport()"
class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors hidden"> 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-chart-bar mr-2"></i>통계 미리보기 <i class="fas fa-download mr-2"></i>일일보고서 생성
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- 프로젝트 통계 미리보기 --> <!-- 미리보기 섹션 -->
<div id="projectStatsCard" class="bg-white rounded-xl shadow-sm p-6 mb-6 hidden"> <div id="previewSection" class="hidden">
<h2 class="text-xl font-semibold text-gray-900 mb-4"> <!-- 통계 카드 -->
<i class="fas fa-chart-bar text-blue-500 mr-2"></i>프로젝트 현황 미리보기 <div class="bg-white rounded-xl shadow-sm p-6 mb-6">
</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <i class="fas fa-chart-bar text-blue-500 mr-2"></i>추출 항목 통계
<div class="stats-card bg-blue-50 p-4 rounded-lg text-center"> </h2>
<div class="text-3xl font-bold text-blue-600 mb-1" id="reportTotalCount">0</div> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="text-sm text-blue-700 font-medium">총 신고 수량</div> <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="stats-card bg-orange-50 p-4 rounded-lg text-center"> </div>
<div class="text-3xl font-bold text-orange-600 mb-1" id="reportManagementCount">0</div>
<div class="text-sm text-orange-700 font-medium">관리처리 현황</div> <!-- 항목 목록 -->
</div> <div class="bg-white rounded-xl shadow-sm p-6">
<div class="stats-card bg-green-50 p-4 rounded-lg text-center"> <h2 class="text-xl font-semibold text-gray-900 mb-4">
<div class="text-3xl font-bold text-green-600 mb-1" id="reportCompletedCount">0</div> <i class="fas fa-list text-gray-500 mr-2"></i>추출될 항목 목록
<div class="text-sm text-green-700 font-medium">완료 현황</div> </h2>
</div> <div class="overflow-x-auto">
<div class="stats-card bg-red-50 p-4 rounded-lg text-center"> <table class="w-full">
<div class="text-3xl font-bold text-red-600 mb-1" id="reportDelayedCount">0</div> <thead class="bg-gray-50">
<div class="text-sm text-red-700 font-medium">지연 중</div> <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>
</div> </div>
@@ -119,7 +153,7 @@
<!-- 포함 항목 안내 --> <!-- 포함 항목 안내 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6"> <div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4"> <h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list-check text-gray-500 mr-2"></i>보고서 포함 항목 <i class="fas fa-list-check text-gray-500 mr-2"></i>보고서 포함 항목 안내
</h2> </h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <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="report-card bg-blue-50 p-4 rounded-lg">
@@ -127,35 +161,21 @@
<i class="fas fa-check-circle text-blue-500 mr-2"></i> <i class="fas fa-check-circle text-blue-500 mr-2"></i>
<span class="font-medium text-blue-800">진행 중 항목</span> <span class="font-medium text-blue-800">진행 중 항목</span>
</div> </div>
<p class="text-sm text-blue-600">무조건 포함됩니다</p> <p class="text-sm text-blue-600">모든 진행 중인 항목이 포함됩니다 (추출 이력과 무관)</p>
</div> </div>
<div class="report-card bg-green-50 p-4 rounded-lg"> <div class="report-card bg-green-50 p-4 rounded-lg">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<i class="fas fa-check-circle text-green-500 mr-2"></i> <i class="fas fa-check-circle text-green-500 mr-2"></i>
<span class="font-medium text-green-800">완료됨 항목</span> <span class="font-medium text-green-800">완료됨 항목</span>
</div> </div>
<p class="text-sm text-green-600">첫 내보내기에만 포함, 이후 자동 제외</p> <p class="text-sm text-green-600">한번도 추출 안된 완료 항목만 포함, 이후 자동 제외</p>
</div> </div>
<div class="report-card bg-yellow-50 p-4 rounded-lg"> <div class="report-card bg-yellow-50 p-4 rounded-lg">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<i class="fas fa-info-circle text-yellow-500 mr-2"></i> <i class="fas fa-info-circle text-yellow-500 mr-2"></i>
<span class="font-medium text-yellow-800">프로젝트 통계</span> <span class="font-medium text-yellow-800">추출 이력 기록</span>
</div> </div>
<p class="text-sm text-yellow-600">상단에 요약 정보 포함</p> <p class="text-sm text-yellow-600">추출 시 자동으로 이력이 기록됩니다</p>
</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-history text-gray-500 mr-2"></i>최근 생성된 일일보고서
</h2>
<div id="recentReports" class="space-y-3">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-file-excel text-4xl mb-3 opacity-50"></i>
<p>아직 생성된 일일보고서가 없습니다.</p>
<p class="text-sm">프로젝트를 선택하고 보고서를 생성해보세요!</p>
</div> </div>
</div> </div>
</div> </div>
@@ -170,11 +190,12 @@
<script> <script>
let projects = []; let projects = [];
let selectedProjectId = null; let selectedProjectId = null;
let previewData = null;
// 페이지 초기화 // 페이지 초기화
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
console.log('일일보고서 페이지 로드 시작'); console.log('일일보고서 페이지 로드 시작');
// AuthManager 로드 대기 // AuthManager 로드 대기
const checkAuthManager = async () => { const checkAuthManager = async () => {
if (window.authManager) { if (window.authManager) {
@@ -188,7 +209,7 @@
// 프로젝트 목록 로드 // 프로젝트 목록 로드
await loadProjects(); await loadProjects();
// 공통 헤더 초기화 // 공통 헤더 초기화
try { try {
const user = JSON.parse(localStorage.getItem('currentUser') || '{}'); const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
@@ -198,7 +219,7 @@
} catch (headerError) { } catch (headerError) {
console.error('공통 헤더 초기화 오류:', headerError); console.error('공통 헤더 초기화 오류:', headerError);
} }
console.log('일일보고서 페이지 로드 완료'); console.log('일일보고서 페이지 로드 완료');
} catch (error) { } catch (error) {
console.error('페이지 초기화 오류:', error); console.error('페이지 초기화 오류:', error);
@@ -214,7 +235,7 @@
async function loadProjects() { async function loadProjects() {
try { try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/projects/`, { const response = await fetch(`${apiUrl}/projects/`, {
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}` 'Authorization': `Bearer ${localStorage.getItem('access_token')}`
@@ -235,14 +256,14 @@
// 프로젝트 선택 옵션 채우기 // 프로젝트 선택 옵션 채우기
function populateProjectSelect() { function populateProjectSelect() {
const select = document.getElementById('reportProjectSelect'); const select = document.getElementById('reportProjectSelect');
if (!select) { if (!select) {
console.error('reportProjectSelect 요소를 찾을 수 없습니다!'); console.error('reportProjectSelect 요소를 찾을 수 없습니다!');
return; return;
} }
select.innerHTML = '<option value="">프로젝트를 선택하세요</option>'; select.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
projects.forEach(project => { projects.forEach(project => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = project.id; option.value = project.id;
@@ -255,50 +276,136 @@
document.addEventListener('change', async function(e) { document.addEventListener('change', async function(e) {
if (e.target.id === 'reportProjectSelect') { if (e.target.id === 'reportProjectSelect') {
selectedProjectId = e.target.value; selectedProjectId = e.target.value;
const previewBtn = document.getElementById('previewBtn');
const generateBtn = document.getElementById('generateReportBtn'); const generateBtn = document.getElementById('generateReportBtn');
const previewBtn = document.getElementById('previewStatsBtn'); const previewSection = document.getElementById('previewSection');
if (selectedProjectId) { if (selectedProjectId) {
generateBtn.disabled = false;
previewBtn.classList.remove('hidden'); previewBtn.classList.remove('hidden');
await loadProjectStats(selectedProjectId); generateBtn.classList.remove('hidden');
previewSection.classList.add('hidden');
previewData = null;
} else { } else {
generateBtn.disabled = true;
previewBtn.classList.add('hidden'); previewBtn.classList.add('hidden');
document.getElementById('projectStatsCard').classList.add('hidden'); generateBtn.classList.add('hidden');
previewSection.classList.add('hidden');
previewData = null;
} }
} }
}); });
// 프로젝트 통계 로드 // 미리보기 로드
async function loadProjectStats(projectId) { async function loadPreview() {
if (!selectedProjectId) {
alert('프로젝트를 선택해주세요.');
return;
}
try { try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/management/stats?project_id=${projectId}`, { const response = await fetch(`${apiUrl}/reports/daily-preview?project_id=${selectedProjectId}`, {
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}` 'Authorization': `Bearer ${localStorage.getItem('access_token')}`
} }
}); });
if (response.ok) { if (response.ok) {
const stats = await response.json(); previewData = await response.json();
displayPreview(previewData);
document.getElementById('reportTotalCount').textContent = stats.total_count || 0;
document.getElementById('reportManagementCount').textContent = stats.management_count || 0;
document.getElementById('reportCompletedCount').textContent = stats.completed_count || 0;
document.getElementById('reportDelayedCount').textContent = stats.delayed_count || 0;
} else { } else {
console.error('프로젝트 통계 로드 실패:', response.status); alert('미리보기 로드 실패했습니다.');
} }
} catch (error) { } catch (error) {
console.error('프로젝트 통계 로드 오류:', error); console.error('미리보기 로드 오류:', error);
alert('미리보기 로드 중 오류가 발생했습니다.');
} }
} }
// 통계 미리보기 토글 // 미리보기 표시
function toggleStatsPreview() { function displayPreview(data) {
const statsCard = document.getElementById('projectStatsCard'); // 통계 업데이트
statsCard.classList.toggle('hidden'); 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] || '-';
} }
// 일일보고서 생성 // 일일보고서 생성
@@ -308,6 +415,12 @@
return; return;
} }
// 미리보기 데이터가 있고 항목이 0개인 경우
if (previewData && previewData.total_issues === 0) {
alert('추출할 항목이 없습니다.');
return;
}
try { try {
const button = document.getElementById('generateReportBtn'); const button = document.getElementById('generateReportBtn');
const originalText = button.innerHTML; const originalText = button.innerHTML;
@@ -332,20 +445,25 @@
const a = document.createElement('a'); const a = document.createElement('a');
a.style.display = 'none'; a.style.display = 'none';
a.href = url; a.href = url;
// 파일명 생성 (프로젝트명_일일보고서_날짜.xlsx) // 파일명 생성
const project = projects.find(p => p.id == selectedProjectId); const project = projects.find(p => p.id == selectedProjectId);
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
a.download = `${project.name}_일일보고서_${today}.xlsx`; a.download = `${project.project_name}_일일보고서_${today}.xlsx`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
// 성공 메시지 표시 // 성공 메시지
showSuccessMessage('일일보고서가 성공적으로 생성되었습니다!'); showSuccessMessage('일일보고서가 성공적으로 생성되었습니다!');
// 미리보기 새로고침
if (previewData) {
setTimeout(() => loadPreview(), 1000);
}
} else { } else {
const error = await response.text(); const error = await response.text();
console.error('보고서 생성 실패:', error); console.error('보고서 생성 실패:', error);
@@ -371,9 +489,9 @@
<span>${message}</span> <span>${message}</span>
</div> </div>
`; `;
document.body.appendChild(successDiv); document.body.appendChild(successDiv);
setTimeout(() => { setTimeout(() => {
successDiv.remove(); successDiv.remove();
}, 3000); }, 3000);

View File

@@ -190,13 +190,16 @@ const AuthAPI = {
// Issues API // Issues API
const IssuesAPI = { const IssuesAPI = {
create: async (issueData) => { create: async (issueData) => {
// photos 배열 처리 (최대 2장) // photos 배열 처리 (최대 5장)
const dataToSend = { const dataToSend = {
category: issueData.category, category: issueData.category,
description: issueData.description, description: issueData.description,
project_id: issueData.project_id, project_id: issueData.project_id,
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null, photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : 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/', { return apiRequest('/issues/', {

View File

@@ -35,7 +35,7 @@ class CommonHeader {
}, },
{ {
id: 'issues_view', id: 'issues_view',
title: '부적합 조회', title: '신고내용조회',
icon: 'fas fa-search', icon: 'fas fa-search',
url: '/issue-view.html', url: '/issue-view.html',
pageName: 'issues_view', pageName: 'issues_view',