From 637b690eda33c1e53b9b18cc3118ab9fafb28cbd Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 8 Nov 2025 14:44:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=205=EC=9E=A5=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=B0=8F=20=EC=97=91=EC=85=80=20=EB=82=B4?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신고 및 완료 사진 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 --- backend/database/models.py | 16 +- backend/database/schemas.py | 38 +- backend/routers/issues.py | 199 ++--- backend/routers/reports.py | 737 +++++++++++++++--- backend/services/file_service.py | 37 +- frontend/index.html | 97 ++- frontend/issue-view.html | 236 +++++- frontend/issues-dashboard.html | 53 +- frontend/issues-inbox.html | 8 +- frontend/issues-management.html | 336 +++++--- frontend/reports-daily.html | 312 +++++--- frontend/static/js/api.js | 7 +- .../static/js/components/common-header.js | 2 +- 13 files changed, 1563 insertions(+), 515 deletions(-) diff --git a/backend/database/models.py b/backend/database/models.py index a7f1276..87a1f19 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -95,7 +95,10 @@ class Issue(Base): id = Column(Integer, primary_key=True, index=True) 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) description = Column(Text, nullable=False) status = Column(Enum(IssueStatus), default=IssueStatus.new) @@ -120,7 +123,6 @@ class Issue(Base): duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록 # 관리함에서 사용할 추가 필드들 - completion_photo_path = Column(String) # 완료 사진 경로 solution = Column(Text) # 해결방안 (관리함에서 입력) responsible_department = Column(Enum(DepartmentType)) # 담당부서 responsible_person = Column(String(100)) # 담당자 @@ -141,7 +143,11 @@ class Issue(Base): # 완료 신청 관련 필드들 completion_requested_at = Column(DateTime) # 완료 신청 시간 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) # 완료 코멘트 # 완료 반려 관련 필드들 @@ -149,6 +155,10 @@ class Issue(Base): completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자 completion_rejection_reason = Column(Text) # 완료 반려 사유 + # 일일보고서 추출 이력 + last_exported_at = Column(DateTime) # 마지막 일일보고서 추출 시간 + export_count = Column(Integer, default=0) # 추출 횟수 + # Relationships reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id]) reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues") diff --git a/backend/database/schemas.py b/backend/database/schemas.py index aea4838..ec1aa79 100644 --- a/backend/database/schemas.py +++ b/backend/database/schemas.py @@ -89,7 +89,10 @@ class IssueBase(BaseModel): class IssueCreate(IssueBase): 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): category: Optional[IssueCategory] = None @@ -99,12 +102,18 @@ class IssueUpdate(BaseModel): detail_notes: Optional[str] = None status: Optional[IssueStatus] = None 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): id: int 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 reporter_id: int reporter: User @@ -129,7 +138,6 @@ class Issue(IssueBase): duplicate_reporters: Optional[List[Dict[str, Any]]] = None # 관리함에서 사용할 추가 필드들 - completion_photo_path: Optional[str] = None # 완료 사진 경로 solution: Optional[str] = None # 해결방안 responsible_department: Optional[DepartmentType] = None # 담당부서 responsible_person: Optional[str] = None # 담당자 @@ -150,7 +158,11 @@ class Issue(IssueBase): # 완료 신청 관련 필드들 completion_requested_at: Optional[datetime] = 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 # 완료 코멘트 # 완료 반려 관련 필드들 @@ -158,6 +170,10 @@ class Issue(IssueBase): completion_rejected_by_id: Optional[int] = None # 완료 반려자 completion_rejection_reason: Optional[str] = None # 완료 반려 사유 + # 일일보고서 추출 이력 + last_exported_at: Optional[datetime] = None # 마지막 일일보고서 추출 시간 + export_count: Optional[int] = 0 # 추출 횟수 + class Config: from_attributes = True @@ -202,7 +218,11 @@ class AdditionalInfoUpdateRequest(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 # 완료 코멘트 class CompletionRejectionRequest(BaseModel): @@ -220,7 +240,11 @@ class ManagementUpdateRequest(BaseModel): cause_department: Optional[DepartmentType] = None management_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 class InboxIssue(BaseModel): diff --git a/backend/routers/issues.py b/backend/routers/issues.py index a73e964..8bdde62 100644 --- a/backend/routers/issues.py +++ b/backend/routers/issues.py @@ -20,22 +20,26 @@ async def create_issue( ): print(f"DEBUG: 받은 issue 데이터: {issue}") print(f"DEBUG: project_id: {issue.project_id}") - # 이미지 저장 - photo_path = None - photo_path2 = None - - if issue.photo: - photo_path = save_base64_image(issue.photo) - - if issue.photo2: - photo_path2 = save_base64_image(issue.photo2) - + # 이미지 저장 (최대 5장) + photo_paths = {} + for i in range(1, 6): + photo_field = f"photo{i if i > 1 else ''}" + path_field = f"photo_path{i if i > 1 else ''}" + photo_data = getattr(issue, photo_field, None) + if photo_data: + photo_paths[path_field] = save_base64_image(photo_data) + else: + photo_paths[path_field] = None + # Issue 생성 db_issue = Issue( category=issue.category, description=issue.description, - photo_path=photo_path, - photo_path2=photo_path2, + photo_path=photo_paths.get('photo_path'), + photo_path2=photo_paths.get('photo_path2'), + photo_path3=photo_paths.get('photo_path3'), + photo_path4=photo_paths.get('photo_path4'), + photo_path5=photo_paths.get('photo_path5'), reporter_id=current_user.id, project_id=issue.project_id, status=IssueStatus.new @@ -135,38 +139,27 @@ async def update_issue( # 업데이트 update_data = issue_update.dict(exclude_unset=True) - - # 첫 번째 사진이 업데이트되는 경우 처리 - if "photo" in update_data: - # 기존 사진 삭제 - if issue.photo_path: - delete_file(issue.photo_path) - - # 새 사진 저장 - if update_data["photo"]: - photo_path = save_base64_image(update_data["photo"]) - update_data["photo_path"] = photo_path - else: - update_data["photo_path"] = None - - # photo 필드는 제거 (DB에는 photo_path만 저장) - del update_data["photo"] - - # 두 번째 사진이 업데이트되는 경우 처리 - if "photo2" in update_data: - # 기존 사진 삭제 - if issue.photo_path2: - 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"] + + # 사진 업데이트 처리 (최대 5장) + for i in range(1, 6): + photo_field = f"photo{i if i > 1 else ''}" + path_field = f"photo_path{i if i > 1 else ''}" + + if photo_field in update_data: + # 기존 사진 삭제 + existing_path = getattr(issue, path_field, None) + if existing_path: + delete_file(existing_path) + + # 새 사진 저장 + if update_data[photo_field]: + new_path = save_base64_image(update_data[photo_field]) + update_data[path_field] = new_path + else: + update_data[path_field] = None + + # photo 필드는 제거 (DB에는 photo_path만 저장) + del update_data[photo_field] # work_hours가 입력되면 자동으로 상태를 complete로 변경 if "work_hours" in update_data and update_data["work_hours"] > 0: @@ -235,13 +228,19 @@ async def delete_issue( ) db.add(deletion_log) - # 이미지 파일 삭제 - if issue.photo_path: - delete_file(issue.photo_path) - if issue.photo_path2: - delete_file(issue.photo_path2) - if issue.completion_photo_path: - delete_file(issue.completion_photo_path) + # 이미지 파일 삭제 (신고 사진 최대 5장) + for i in range(1, 6): + path_field = f"photo_path{i if i > 1 else ''}" + path = getattr(issue, path_field, None) + if path: + delete_file(path) + + # 완료 사진 삭제 (최대 5장) + for i in range(1, 6): + path_field = f"completion_photo_path{i if i > 1 else ''}" + path = getattr(issue, path_field, None) + if path: + delete_file(path) db.delete(issue) db.commit() @@ -300,18 +299,32 @@ async def update_issue_management( update_data = management_update.dict(exclude_unset=True) print(f"DEBUG: Update data dict: {update_data}") - for field, value in update_data.items(): - print(f"DEBUG: Processing field {field} = {value}") - if field == 'completion_photo' and value: - # 완료 사진 업로드 처리 + # 완료 사진 처리 (최대 5장) + completion_photo_fields = [] + for i in range(1, 6): + photo_field = f"completion_photo{i if i > 1 else ''}" + path_field = f"completion_photo_path{i if i > 1 else ''}" + + if photo_field in update_data and update_data[photo_field]: + completion_photo_fields.append(photo_field) try: - completion_photo_path = save_base64_image(value, "completion") - setattr(issue, 'completion_photo_path', completion_photo_path) - print(f"DEBUG: Saved completion photo: {completion_photo_path}") + # 기존 사진 삭제 + existing_path = getattr(issue, path_field, None) + if existing_path: + delete_file(existing_path) + + # 새 사진 저장 + new_path = save_base64_image(update_data[photo_field], "completion") + setattr(issue, path_field, new_path) + print(f"DEBUG: Saved {photo_field}: {new_path}") except Exception as e: - print(f"DEBUG: Photo save error: {str(e)}") + print(f"DEBUG: Photo save error for {photo_field}: {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: setattr(issue, field, value) print(f"DEBUG: Set {field} = {value}") @@ -359,22 +372,28 @@ async def request_completion( try: print(f"DEBUG: 완료 신청 시작 - Issue ID: {issue_id}, User: {current_user.username}") - - # 완료 사진 저장 - completion_photo_path = None - if request.completion_photo: - print(f"DEBUG: 완료 사진 저장 시작") - completion_photo_path = save_base64_image(request.completion_photo, "completion") - print(f"DEBUG: 완료 사진 저장 완료 - Path: {completion_photo_path}") - - if not completion_photo_path: - raise Exception("완료 사진 저장에 실패했습니다.") - + + # 완료 사진 저장 (최대 5장) + saved_paths = [] + for i in range(1, 6): + photo_field = f"completion_photo{i if i > 1 else ''}" + path_field = f"completion_photo_path{i if i > 1 else ''}" + photo_data = getattr(request, photo_field, None) + + if photo_data: + print(f"DEBUG: {photo_field} 저장 시작") + saved_path = save_base64_image(photo_data, "completion") + if saved_path: + setattr(issue, path_field, saved_path) + saved_paths.append(saved_path) + print(f"DEBUG: {photo_field} 저장 완료 - Path: {saved_path}") + else: + raise Exception(f"{photo_field} 저장에 실패했습니다.") + # 완료 신청 정보 업데이트 print(f"DEBUG: DB 업데이트 시작") issue.completion_requested_at = datetime.now() issue.completion_requested_by_id = current_user.id - issue.completion_photo_path = completion_photo_path issue.completion_comment = request.completion_comment db.commit() @@ -385,18 +404,19 @@ async def request_completion( "message": "완료 신청이 성공적으로 제출되었습니다.", "issue_id": issue.id, "completion_requested_at": issue.completion_requested_at, - "completion_photo_path": completion_photo_path + "completion_photo_paths": saved_paths } - + except Exception as e: print(f"ERROR: 완료 신청 처리 오류 - {str(e)}") db.rollback() # 업로드된 파일이 있다면 삭제 - if 'completion_photo_path' in locals() and completion_photo_path: - try: - delete_file(completion_photo_path) - except: - pass + if 'saved_paths' in locals(): + for path in saved_paths: + try: + delete_file(path) + except: + pass raise HTTPException(status_code=500, detail=f"완료 신청 처리 중 오류가 발생했습니다: {str(e)}") @router.post("/{issue_id}/reject-completion") @@ -425,18 +445,23 @@ async def reject_completion_request( try: print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}") - # 완료 사진 파일 삭제 - if issue.completion_photo_path: - try: - delete_file(issue.completion_photo_path) - print(f"DEBUG: 완료 사진 삭제 완료") - except Exception as e: - print(f"WARNING: 완료 사진 삭제 실패 - {str(e)}") + # 완료 사진 파일 삭제 (최대 5장) + for i in range(1, 6): + path_field = f"completion_photo_path{i if i > 1 else ''}" + photo_path = getattr(issue, path_field, None) + if photo_path: + try: + delete_file(photo_path) + print(f"DEBUG: {path_field} 삭제 완료") + except Exception as e: + print(f"WARNING: {path_field} 삭제 실패 - {str(e)}") # 완료 신청 정보 초기화 issue.completion_requested_at = None issue.completion_requested_by_id = None - 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 # 완료 반려 정보 기록 (전용 필드 사용) diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 98410ac..30a95e6 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -5,9 +5,12 @@ from sqlalchemy import func, and_, or_ from datetime import datetime, date from typing import List import io +import re from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter +from openpyxl.drawing.image import Image as XLImage +import os from database.database import get_db from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus @@ -134,6 +137,53 @@ async def get_report_daily_works( "total_hours": work.total_hours } for work in works] +@router.get("/daily-preview") +async def preview_daily_report( + project_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """일일보고서 미리보기 - 추출될 항목 목록""" + + # 권한 확인 + if current_user.role != UserRole.admin: + raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다") + + # 프로젝트 확인 + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다") + + # 추출될 항목 조회 (진행 중 + 미추출 완료 항목) + issues_query = db.query(Issue).filter( + Issue.project_id == project_id, + or_( + Issue.review_status == ReviewStatus.in_progress, + and_( + Issue.review_status == ReviewStatus.completed, + Issue.last_exported_at == None + ) + ) + ) + + issues = issues_query.all() + + # 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순 + issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0)) + + # 통계 계산 + stats = calculate_project_stats(issues) + + # 이슈 리스트를 schema로 변환 + issues_data = [schemas.Issue.from_orm(issue) for issue in issues] + + return { + "project": schemas.Project.from_orm(project), + "stats": stats, + "issues": issues_data, + "total_issues": len(issues) + } + @router.post("/daily-export") async def export_daily_report( request: schemas.DailyReportRequest, @@ -151,23 +201,79 @@ async def export_daily_report( if not project: 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.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed]) - ).order_by(Issue.report_date.desc()) - - issues = issues_query.all() + Issue.review_status == ReviewStatus.in_progress + ).all() + + # 2. 완료된 항목 (모두 조회) + all_completed = db.query(Issue).filter( + Issue.project_id == request.project_id, + Issue.review_status == ReviewStatus.completed + ).all() + + # 완료 항목 중 "완료 후 추출 안된 것" 필터링 + # 규칙: 완료 처리 후 1회에 한해서만 "진행 중" 시트에 표시 + not_exported_after_completion = [] + for issue in all_completed: + if issue.last_exported_at is None: + # 한번도 추출 안됨 -> 진행 중 시트에 표시 (완료 후 첫 추출) + not_exported_after_completion.append(issue) + elif issue.actual_completion_date: + # actual_completion_date가 있는 경우: 완료일과 마지막 추출일 비교 + completion_date = issue.actual_completion_date.replace(tzinfo=None) if issue.actual_completion_date.tzinfo else issue.actual_completion_date + export_date = issue.last_exported_at.replace(tzinfo=None) if issue.last_exported_at.tzinfo else issue.last_exported_at + if completion_date > export_date: + # 완료일이 마지막 추출일보다 나중 -> 완료 후 아직 추출 안됨 -> 진행 중 시트에 표시 + not_exported_after_completion.append(issue) + # else: 완료일이 마지막 추출일보다 이전 -> 이미 완료 후 추출됨 -> 완료됨 시트로 + # else: actual_completion_date가 없고 last_exported_at가 있음 + # -> 이미 한번 이상 추출됨 -> 완료됨 시트로 + + # "진행 중" 시트용: 진행 중 + 완료되고 아직 추출 안된 것 + in_progress_issues = in_progress_only + not_exported_after_completion + + # 진행 중 시트 정렬: 지연중 -> 진행중 -> 완료됨 순서 + in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0)) + + # "완료됨" 시트용: 완료 항목 중 "완료 후 추출된 것"만 (진행 중 시트에 표시되는 것 제외) + not_exported_ids = {issue.id for issue in not_exported_after_completion} + completed_issues = [issue for issue in all_completed if issue.id not in not_exported_ids] + + # 완료됨 시트도 정렬 (완료일 최신순) + completed_issues = sorted(completed_issues, key=lambda x: -x.actual_completion_date.timestamp() if x.actual_completion_date else 0) + + # 웹과 동일한 로직: 진행중 + 완료를 함께 정렬하여 순번 할당 + # (웹에서는 in_progress와 completed를 함께 가져와서 전체를 reviewed_at 순으로 정렬 후 순번 매김) + all_management_issues = in_progress_only + all_completed + + # reviewed_at 기준으로 정렬 (웹과 동일) + all_management_issues = sorted(all_management_issues, key=lambda x: x.reviewed_at if x.reviewed_at else datetime.min) + + # 프로젝트별로 그룹화하여 순번 할당 (웹과 동일한 로직) + project_groups = {} + for issue in all_management_issues: + if issue.project_id not in project_groups: + project_groups[issue.project_id] = [] + project_groups[issue.project_id].append(issue) + + # 각 프로젝트별로 순번 재할당 (웹과 동일) + for project_id, project_issues in project_groups.items(): + for idx, issue in enumerate(project_issues, 1): + issue._display_no = idx + + # 전체 이슈 (통계 계산용 및 추출 이력 업데이트용) + issues = list(set(in_progress_only + all_completed)) # 중복 제거 # 통계 계산 stats = calculate_project_stats(issues) # 엑셀 파일 생성 wb = Workbook() - ws = wb.active - ws.title = "일일보고서" - - # 스타일 정의 + + # 스타일 정의 (공통) header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") stats_font = Font(bold=True, size=12) @@ -179,113 +285,452 @@ async def export_daily_report( bottom=Side(style='thin') ) center_alignment = Alignment(horizontal='center', vertical='center') - - # 제목 및 기본 정보 - ws.merge_cells('A1:L1') - ws['A1'] = f"{project.project_name} - 일일보고서" - ws['A1'].font = Font(bold=True, size=16) - ws['A1'].alignment = center_alignment - - ws.merge_cells('A2:L2') - ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}" - ws['A2'].alignment = center_alignment - - # 프로젝트 통계 (4행부터) - ws.merge_cells('A4:L4') - ws['A4'] = "프로젝트 현황" - ws['A4'].font = stats_font - ws['A4'].fill = stats_fill - ws['A4'].alignment = center_alignment - - # 통계 데이터 - stats_row = 5 - ws[f'A{stats_row}'] = "총 신고 수량" - ws[f'B{stats_row}'] = stats.total_count - ws[f'D{stats_row}'] = "관리처리 현황" - ws[f'E{stats_row}'] = stats.management_count - ws[f'G{stats_row}'] = "완료 현황" - ws[f'H{stats_row}'] = stats.completed_count - ws[f'J{stats_row}'] = "지연 중" - ws[f'K{stats_row}'] = stats.delayed_count - - # 통계 스타일 적용 - for col in ['A', 'D', 'G', 'J']: - ws[f'{col}{stats_row}'].font = Font(bold=True) - ws[f'{col}{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") - - # 데이터 테이블 헤더 (7행부터) - headers = [ - "번호", "프로젝트", "부적합명", "상세내용", "원인분류", - "해결방안", "담당부서", "담당자", "마감일", "상태", - "신고일", "완료일" + card_header_font = Font(bold=True, color="FFFFFF", size=11) + label_font = Font(bold=True, size=10) + label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid") + content_font = Font(size=10) + thick_border = Border( + left=Side(style='medium'), + right=Side(style='medium'), + top=Side(style='medium'), + bottom=Side(style='medium') + ) + + # 두 개의 시트를 생성하고 각각 데이터 입력 + sheets_data = [ + (wb.active, in_progress_issues, "진행 중"), + (wb.create_sheet(title="완료됨"), completed_issues, "완료됨") ] - - header_row = 7 - for col, header in enumerate(headers, 1): - cell = ws.cell(row=header_row, column=col, value=header) - cell.font = header_font - cell.fill = header_fill - cell.alignment = center_alignment - cell.border = border - - # 데이터 입력 - current_row = header_row + 1 - - for issue in issues: - # 완료됨 항목의 첫 내보내기 여부 확인 (실제로는 DB에 플래그를 저장해야 함) - # 지금은 모든 완료됨 항목을 포함 - - ws.cell(row=current_row, column=1, value=issue.id) - ws.cell(row=current_row, column=2, value=project.project_name) - ws.cell(row=current_row, column=3, value=issue.description or "") - ws.cell(row=current_row, column=4, value=issue.detail_notes or "") - ws.cell(row=current_row, column=5, value=get_category_text(issue.category)) - ws.cell(row=current_row, column=6, value=issue.solution or "") - 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 "") - ws.cell(row=current_row, column=10, value=get_status_text(issue.review_status)) - 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 "") - - # 상태별 색상 적용 - status_color = get_status_color(issue.review_status) - if status_color: - for col in range(1, len(headers) + 1): - ws.cell(row=current_row, column=col).fill = PatternFill( - start_color=status_color, end_color=status_color, fill_type="solid" + + sheets_data[0][0].title = "진행 중" + + for ws, sheet_issues, sheet_title in sheets_data: + # 제목 및 기본 정보 + ws.merge_cells('A1:L1') + ws['A1'] = f"{project.project_name} - {sheet_title}" + ws['A1'].font = Font(bold=True, size=16) + ws['A1'].alignment = center_alignment + + ws.merge_cells('A2:L2') + ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}" + ws['A2'].alignment = center_alignment + + # 프로젝트 통계 (4행부터) - 진행 중 시트에만 표시 + if sheet_title == "진행 중": + ws.merge_cells('A4:L4') + ws['A4'] = "📊 프로젝트 현황" + ws['A4'].font = Font(bold=True, size=14, color="FFFFFF") + ws['A4'].fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + ws['A4'].alignment = center_alignment + ws.row_dimensions[4].height = 25 + + # 통계 데이터 - 박스 형태로 개선 + stats_row = 5 + ws.row_dimensions[stats_row].height = 30 + + # 총 신고 수량 (파란색 계열) + ws.merge_cells(f'A{stats_row}:B{stats_row}') + ws[f'A{stats_row}'] = "총 신고 수량" + ws[f'A{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF") + ws[f'A{stats_row}'].fill = PatternFill(start_color="5B9BD5", end_color="5B9BD5", fill_type="solid") + ws[f'A{stats_row}'].alignment = center_alignment + + ws[f'C{stats_row}'] = stats.total_count + ws[f'C{stats_row}'].font = Font(bold=True, size=14) + ws[f'C{stats_row}'].fill = PatternFill(start_color="DEEBF7", end_color="DEEBF7", fill_type="solid") + ws[f'C{stats_row}'].alignment = center_alignment + + # 진행 현황 (노란색 계열) + ws.merge_cells(f'D{stats_row}:E{stats_row}') + ws[f'D{stats_row}'] = "진행 현황" + ws[f'D{stats_row}'].font = Font(bold=True, size=11, color="000000") + ws[f'D{stats_row}'].fill = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid") + ws[f'D{stats_row}'].alignment = center_alignment + + ws[f'F{stats_row}'] = stats.management_count + ws[f'F{stats_row}'].font = Font(bold=True, size=14) + ws[f'F{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") + ws[f'F{stats_row}'].alignment = center_alignment + + # 완료 현황 (초록색 계열) + ws.merge_cells(f'G{stats_row}:H{stats_row}') + ws[f'G{stats_row}'] = "완료 현황" + ws[f'G{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF") + ws[f'G{stats_row}'].fill = PatternFill(start_color="70AD47", end_color="70AD47", fill_type="solid") + ws[f'G{stats_row}'].alignment = center_alignment + + ws[f'I{stats_row}'] = stats.completed_count + ws[f'I{stats_row}'].font = Font(bold=True, size=14) + ws[f'I{stats_row}'].fill = PatternFill(start_color="C6E0B4", end_color="C6E0B4", fill_type="solid") + ws[f'I{stats_row}'].alignment = center_alignment + + # 지연 중 (빨간색 계열) + ws.merge_cells(f'J{stats_row}:K{stats_row}') + ws[f'J{stats_row}'] = "지연 중" + ws[f'J{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF") + ws[f'J{stats_row}'].fill = PatternFill(start_color="E74C3C", end_color="E74C3C", fill_type="solid") + ws[f'J{stats_row}'].alignment = center_alignment + + ws[f'L{stats_row}'] = stats.delayed_count + ws[f'L{stats_row}'].font = Font(bold=True, size=14) + ws[f'L{stats_row}'].fill = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") + ws[f'L{stats_row}'].alignment = center_alignment + + # 통계 박스에 테두리 적용 + thick_border = Border( + left=Side(style='medium'), + right=Side(style='medium'), + top=Side(style='medium'), + bottom=Side(style='medium') + ) + + for col in ['A', 'C', 'D', 'F', 'G', 'I', 'J', 'L']: + if col in ['A', 'D', 'G', 'J']: # 병합된 셀의 시작점 + for c in range(ord(col), ord(col) + 2): # 병합된 2개 셀 + ws.cell(row=stats_row, column=c - ord('A') + 1).border = thick_border + else: # 숫자 셀 + ws[f'{col}{stats_row}'].border = thick_border + + # 카드 형태로 데이터 입력 (완료됨 시트는 4행부터, 진행 중 시트는 7행부터) + current_row = 4 if sheet_title == "완료됨" else 7 + + for idx, issue in enumerate(sheet_issues, 1): + card_start_row = current_row + + # 상태별 헤더 색상 설정 + header_color = get_issue_status_header_color(issue) + card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid") + + # 카드 헤더 (No, 상태, 신고일) - L열까지 확장 + ws.merge_cells(f'A{current_row}:C{current_row}') + # 동적으로 할당된 프로젝트별 순번 사용 (웹과 동일) + issue_no = getattr(issue, '_display_no', issue.project_sequence_no or issue.id) + ws[f'A{current_row}'] = f"No. {issue_no}" + ws[f'A{current_row}'].font = card_header_font + ws[f'A{current_row}'].fill = card_header_fill + ws[f'A{current_row}'].alignment = center_alignment + + ws.merge_cells(f'D{current_row}:G{current_row}') + ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}" + ws[f'D{current_row}'].font = card_header_font + ws[f'D{current_row}'].fill = card_header_fill + ws[f'D{current_row}'].alignment = center_alignment + + ws.merge_cells(f'H{current_row}:L{current_row}') + ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}" + ws[f'H{current_row}'].font = card_header_font + ws[f'H{current_row}'].fill = card_header_fill + ws[f'H{current_row}'].alignment = center_alignment + current_row += 1 + + # 부적합명 + ws[f'A{current_row}'] = "부적합명" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:L{current_row}') + + # final_description이 있으면 사용, 없으면 description 사용 + issue_title = issue.final_description or issue.description or "내용 없음" + ws[f'B{current_row}'] = issue_title + ws[f'B{current_row}'].font = content_font + ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top') + current_row += 1 + + # 상세내용 (detail_notes가 실제 상세 설명) + if issue.detail_notes: + ws[f'A{current_row}'] = "상세내용" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:L{current_row}') + ws[f'B{current_row}'] = issue.detail_notes + ws[f'B{current_row}'].font = content_font + ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top') + ws.row_dimensions[current_row].height = 50 + current_row += 1 + + # 원인분류 + ws[f'A{current_row}'] = "원인분류" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:C{current_row}') + ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category) + ws[f'B{current_row}'].font = content_font + + ws[f'D{current_row}'] = "원인부서" + ws[f'D{current_row}'].font = label_font + ws[f'D{current_row}'].fill = label_fill + ws.merge_cells(f'E{current_row}:F{current_row}') + ws[f'E{current_row}'] = get_department_text(issue.cause_department) + ws[f'E{current_row}'].font = content_font + + ws[f'G{current_row}'] = "신고자" + ws[f'G{current_row}'].font = label_font + ws[f'G{current_row}'].fill = label_fill + ws.merge_cells(f'H{current_row}:L{current_row}') + ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-" + ws[f'H{current_row}'].font = content_font + current_row += 1 + + # 해결방안 (완료 반려 내용 및 댓글 제거) + ws[f'A{current_row}'] = "해결방안" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:L{current_row}') + + # management_comment에서 완료 반려 패턴과 댓글 제거 + clean_solution = clean_management_comment_for_export(issue.management_comment) + ws[f'B{current_row}'] = clean_solution + ws[f'B{current_row}'].font = content_font + ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬 + ws.row_dimensions[current_row].height = 30 + current_row += 1 + + # 담당정보 + ws[f'A{current_row}'] = "담당부서" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:C{current_row}') + ws[f'B{current_row}'] = get_department_text(issue.responsible_department) + ws[f'B{current_row}'].font = content_font + + ws[f'D{current_row}'] = "담당자" + ws[f'D{current_row}'].font = label_font + ws[f'D{current_row}'].fill = label_fill + ws.merge_cells(f'E{current_row}:G{current_row}') + ws[f'E{current_row}'] = issue.responsible_person or "" + ws[f'E{current_row}'].font = content_font + + ws[f'H{current_row}'] = "조치예상일" + ws[f'H{current_row}'].font = label_font + ws[f'H{current_row}'].fill = label_fill + ws.merge_cells(f'I{current_row}:L{current_row}') + ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else "" + ws[f'I{current_row}'].font = content_font + ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가 + current_row += 1 + + # === 신고 사진 영역 === + report_photos = [ + issue.photo_path, + issue.photo_path2, + issue.photo_path3, + issue.photo_path4, + issue.photo_path5 + ] + report_photos = [p for p in report_photos if p] # None 제거 + + if report_photos: + # 라벨 행 (A~L 전체 병합) + ws.merge_cells(f'A{current_row}:L{current_row}') + ws[f'A{current_row}'] = "신고 사진" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색 + ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center') + ws.row_dimensions[current_row].height = 18 + current_row += 1 + + # 사진들을 한 행에 일렬로 표시 (간격 좁게) + ws.merge_cells(f'A{current_row}:L{current_row}') + report_image_inserted = False + + # 사진 위치: A, C, E, G, I (2열 간격) + photo_columns = ['A', 'C', 'E', 'G', 'I'] + + for idx, photo in enumerate(report_photos): + if idx >= 5: # 최대 5장 + break + photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo + if os.path.exists(photo_path): + try: + img = XLImage(photo_path) + img.width = min(img.width, 200) # 크기 줄임 + img.height = min(img.height, 150) + ws.add_image(img, f'{photo_columns[idx]}{current_row}') + report_image_inserted = True + except Exception as e: + print(f"이미지 삽입 실패 ({photo_path}): {e}") + + if report_image_inserted: + ws.row_dimensions[current_row].height = 120 + else: + ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음" + ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999") + ws.row_dimensions[current_row].height = 20 + current_row += 1 + + # === 완료 관련 정보 (완료된 항목만) === + if issue.review_status == ReviewStatus.completed: + # 완료 코멘트 + if issue.completion_comment: + ws[f'A{current_row}'] = "완료 의견" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") + ws.merge_cells(f'B{current_row}:L{current_row}') + ws[f'B{current_row}'] = issue.completion_comment + ws[f'B{current_row}'].font = content_font + ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬 + ws.row_dimensions[current_row].height = 30 + current_row += 1 + + # 완료 사진 + completion_photos = [ + issue.completion_photo_path, + issue.completion_photo_path2, + issue.completion_photo_path3, + issue.completion_photo_path4, + issue.completion_photo_path5 + ] + completion_photos = [p for p in completion_photos if p] # None 제거 + + if completion_photos: + # 라벨 행 (A~L 전체 병합) + ws.merge_cells(f'A{current_row}:L{current_row}') + ws[f'A{current_row}'] = "완료 사진" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색 + ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center') + ws.row_dimensions[current_row].height = 18 + current_row += 1 + + # 사진들을 한 행에 일렬로 표시 (간격 좁게) + ws.merge_cells(f'A{current_row}:L{current_row}') + completion_image_inserted = False + + # 사진 위치: A, C, E, G, I (2열 간격) + photo_columns = ['A', 'C', 'E', 'G', 'I'] + + for idx, photo in enumerate(completion_photos): + if idx >= 5: # 최대 5장 + break + photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo + if os.path.exists(photo_path): + try: + img = XLImage(photo_path) + img.width = min(img.width, 200) # 크기 줄임 + img.height = min(img.height, 150) + ws.add_image(img, f'{photo_columns[idx]}{current_row}') + completion_image_inserted = True + except Exception as e: + print(f"이미지 삽입 실패 ({photo_path}): {e}") + + if completion_image_inserted: + ws.row_dimensions[current_row].height = 120 + else: + ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음" + ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999") + ws.row_dimensions[current_row].height = 20 + current_row += 1 + + # 완료일 정보 + if issue.actual_completion_date: + ws[f'A{current_row}'] = "완료일" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") + ws.merge_cells(f'B{current_row}:C{current_row}') + ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d') + ws[f'B{current_row}'].font = content_font + current_row += 1 + + # 사진이 하나도 없을 경우 + # 진행중: 신고 사진만 체크 + # 완료됨: 신고 사진 + 완료 사진 체크 + has_completion_photos = False + if issue.review_status == ReviewStatus.completed: + comp_photos = [p for p in [ + issue.completion_photo_path, + issue.completion_photo_path2, + issue.completion_photo_path3, + issue.completion_photo_path4, + issue.completion_photo_path5 + ] if p] + has_completion_photos = bool(comp_photos) + has_any_photo = bool(report_photos) or has_completion_photos + + if not has_any_photo: + ws[f'A{current_row}'] = "첨부사진" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:L{current_row}') + ws[f'B{current_row}'] = "첨부된 사진 없음" + ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999") + current_row += 1 + + # 카드 전체에 테두리 적용 (A-L 열) + card_end_row = current_row - 1 + for row in range(card_start_row, card_end_row + 1): + for col in range(1, 13): # A-L 열 (12열) + cell = ws.cell(row=row, column=col) + if not cell.border or cell.border.left.style != 'medium': + cell.border = border + + # 카드 외곽에 굵은 테두리 (A-L 열) - 상태별 색상 적용 + border_color = header_color # 헤더와 같은 색상 사용 + for col in range(1, 13): + ws.cell(row=card_start_row, column=col).border = Border( + left=Side(style='medium' if col == 1 else 'thin', color=border_color), + right=Side(style='medium' if col == 12 else 'thin', color=border_color), + top=Side(style='medium', color=border_color), + bottom=Side(style='thin', color=border_color) ) - - # 테두리 적용 - for col in range(1, len(headers) + 1): - ws.cell(row=current_row, column=col).border = border - ws.cell(row=current_row, column=col).alignment = Alignment(vertical='center') - - current_row += 1 + ws.cell(row=card_end_row, column=col).border = Border( + left=Side(style='medium' if col == 1 else 'thin', color=border_color), + right=Side(style='medium' if col == 12 else 'thin', color=border_color), + top=Side(style='thin', color=border_color), + bottom=Side(style='medium', color=border_color) + ) + + # 카드 좌우 테두리도 색상 적용 + for row in range(card_start_row + 1, card_end_row): + ws.cell(row=row, column=1).border = Border( + left=Side(style='medium', color=border_color), + right=ws.cell(row=row, column=1).border.right if ws.cell(row=row, column=1).border else Side(style='thin'), + top=ws.cell(row=row, column=1).border.top if ws.cell(row=row, column=1).border else Side(style='thin'), + bottom=ws.cell(row=row, column=1).border.bottom if ws.cell(row=row, column=1).border else Side(style='thin') + ) + ws.cell(row=row, column=12).border = Border( + left=ws.cell(row=row, column=12).border.left if ws.cell(row=row, column=12).border else Side(style='thin'), + right=Side(style='medium', color=border_color), + top=ws.cell(row=row, column=12).border.top if ws.cell(row=row, column=12).border else Side(style='thin'), + bottom=ws.cell(row=row, column=12).border.bottom if ws.cell(row=row, column=12).border else Side(style='thin') + ) + + # 카드 구분 (빈 행) + current_row += 1 - # 열 너비 자동 조정 - for col in range(1, len(headers) + 1): - column_letter = get_column_letter(col) - ws.column_dimensions[column_letter].width = 15 - - # 특정 열 너비 조정 - ws.column_dimensions['C'].width = 20 # 부적합명 - ws.column_dimensions['D'].width = 30 # 상세내용 - ws.column_dimensions['F'].width = 25 # 해결방안 + # 열 너비 조정 + ws.column_dimensions['A'].width = 12 # 레이블 열 + ws.column_dimensions['B'].width = 15 # 내용 열 + ws.column_dimensions['C'].width = 15 # 내용 열 + ws.column_dimensions['D'].width = 15 # 내용 열 + ws.column_dimensions['E'].width = 15 # 내용 열 + ws.column_dimensions['F'].width = 15 # 내용 열 + ws.column_dimensions['G'].width = 15 # 내용 열 + ws.column_dimensions['H'].width = 15 # 내용 열 + ws.column_dimensions['I'].width = 15 # 내용 열 + ws.column_dimensions['J'].width = 15 # 내용 열 + ws.column_dimensions['K'].width = 15 # 내용 열 + ws.column_dimensions['L'].width = 15 # 내용 열 # 엑셀 파일을 메모리에 저장 excel_buffer = io.BytesIO() wb.save(excel_buffer) excel_buffer.seek(0) - + + # 추출 이력 업데이트 + export_time = datetime.now() + for issue in issues: + issue.last_exported_at = export_time + issue.export_count = (issue.export_count or 0) + 1 + db.commit() + # 파일명 생성 today = date.today().strftime('%Y%m%d') filename = f"{project.project_name}_일일보고서_{today}.xlsx" - + # 한글 파일명을 위한 URL 인코딩 from urllib.parse import quote encoded_filename = quote(filename.encode('utf-8')) - + return StreamingResponse( io.BytesIO(excel_buffer.read()), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -297,22 +742,24 @@ async def export_daily_report( def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats: """프로젝트 통계 계산""" stats = schemas.DailyReportStats() - + today = date.today() - + for issue in issues: stats.total_count += 1 - + if issue.review_status == ReviewStatus.in_progress: stats.management_count += 1 - - # 지연 여부 확인 - if issue.expected_completion_date and issue.expected_completion_date < today: - stats.delayed_count += 1 - + + # 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우) + if issue.expected_completion_date: + expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date + if expected_date < today: + stats.delayed_count += 1 + elif issue.review_status == ReviewStatus.completed: stats.completed_count += 1 - + return stats def get_category_text(category: IssueCategory) -> str: @@ -350,6 +797,29 @@ def get_status_text(status: ReviewStatus) -> str: } return status_map.get(status, str(status)) +def clean_management_comment_for_export(text: str) -> str: + """엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)""" + if not text: + return "" + + # 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용) + text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text) + + # 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들) + lines = text.split('\n') + filtered_lines = [] + for line in lines: + # └ 또는 ↳로 시작하는 줄 제외 + if not re.match(r'^\s*[└↳]', line): + filtered_lines.append(line) + + # 3. 빈 줄 정리 + result = '\n'.join(filtered_lines).strip() + # 연속된 빈 줄을 하나로 + result = re.sub(r'\n{3,}', '\n\n', result) + + return result + def get_status_color(status: ReviewStatus) -> str: """상태별 색상 반환""" color_map = { @@ -358,3 +828,46 @@ def get_status_color(status: ReviewStatus) -> str: ReviewStatus.disposed: "F2F2F2" # 연한 회색 } return color_map.get(status, None) + + +def get_issue_priority(issue: Issue) -> int: + """이슈 우선순위 반환 (엑셀 정렬용) + 1: 지연 (빨강) + 2: 진행중 (노랑) + 3: 완료됨 (초록) + """ + if issue.review_status == ReviewStatus.completed: + return 3 + elif issue.review_status == ReviewStatus.in_progress: + # 조치 예상일이 지난 경우 지연 + if issue.expected_completion_date: + today = date.today() + expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date + if expected_date < today: + return 1 # 지연 + return 2 # 진행중 + return 2 + +def get_issue_status_text(issue: Issue) -> str: + """이슈 상태 텍스트 반환""" + if issue.review_status == ReviewStatus.completed: + return "완료됨" + elif issue.review_status == ReviewStatus.in_progress: + if issue.expected_completion_date: + today = date.today() + expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date + if expected_date < today: + return "지연중" + return "진행중" + return get_status_text(issue.review_status) + +def get_issue_status_header_color(issue: Issue) -> str: + """이슈 상태별 헤더 색상 반환""" + priority = get_issue_priority(issue) + if priority == 1: # 지연 + return "E74C3C" # 빨간색 + elif priority == 2: # 진행중 + return "FFD966" # 노랑색 (주황색 사이) + elif priority == 3: # 완료 + return "92D050" # 진한 초록색 + return "4472C4" # 기본 파란색 diff --git a/backend/services/file_service.py b/backend/services/file_service.py index 838e504..a10d134 100644 --- a/backend/services/file_service.py +++ b/backend/services/file_service.py @@ -54,31 +54,30 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str print(f"🔍 HEIC 파일 여부: {is_heic}") # 이미지 검증 및 형식 확인 + image = None try: - # HEIC 파일인 경우 바로 HEIF 처리 시도 + # HEIC 파일인 경우 pillow_heif를 직접 사용하여 처리 if is_heic and HEIF_SUPPORTED: - print("🔄 HEIC 파일 감지, HEIF 처리 시도...") - image = Image.open(io.BytesIO(image_data)) - print(f"✅ HEIF 이미지 로드 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}") + print("🔄 HEIC 파일 감지, pillow_heif로 직접 처리...") + try: + import pillow_heif + heif_file = pillow_heif.open_heif(io.BytesIO(image_data), convert_hdr_to_8bit=False) + image = heif_file.to_pillow() + print(f"✅ HEIC -> PIL 변환 성공: 모드: {image.mode}, 크기: {image.size}") + except Exception as heic_error: + print(f"⚠️ pillow_heif 직접 처리 실패: {heic_error}") + # PIL Image.open()으로 재시도 + image = Image.open(io.BytesIO(image_data)) + print(f"✅ PIL Image.open() 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}") else: # 일반 이미지 처리 image = Image.open(io.BytesIO(image_data)) print(f"🔍 이미지 형식: {image.format}, 모드: {image.mode}, 크기: {image.size}") except Exception as e: print(f"❌ 이미지 열기 실패: {e}") - - # HEIC 파일인 경우 원본 파일로 저장 - if is_heic: - 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: + + # HEIF 재시도 + if HEIF_SUPPORTED: print("🔄 HEIF 형식으로 재시도...") try: image = Image.open(io.BytesIO(image_data)) @@ -90,6 +89,10 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str else: print("❌ HEIF 지원 라이브러리가 설치되지 않거나 처리 불가") raise e + + # 이미지가 성공적으로 로드되지 않은 경우 + if image is None: + raise Exception("이미지 로드 실패") # iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환 # RGB 모드로 변환 (RGBA, P 모드 등을 처리) diff --git a/frontend/index.html b/frontend/index.html index 8f4140c..fe07364 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -271,17 +271,17 @@
- +
- 선택사항 • 최대 2장 + 선택사항 • 최대 5장
- + + + + + + +
@@ -329,7 +350,7 @@
-

사진 추가 (0/2)

+

사진 추가 (0/5)

@@ -880,23 +901,23 @@ // 사진 업로드 처리 함수 async function handlePhotoUpload(files) { const filesArray = Array.from(files); - + // 현재 사진 개수 확인 - if (currentPhotos.length >= 2) { - alert('최대 2장까지 업로드 가능합니다.'); + if (currentPhotos.length >= 5) { + alert('최대 5장까지 업로드 가능합니다.'); return; } - + // 추가 가능한 개수만큼만 처리 - const availableSlots = 2 - currentPhotos.length; + const availableSlots = 5 - currentPhotos.length; const filesToProcess = filesArray.slice(0, availableSlots); - + // 로딩 표시 showUploadProgress(true); - + try { for (const file of filesToProcess) { - if (currentPhotos.length >= 2) break; + if (currentPhotos.length >= 5) break; // 원본 파일 크기 확인 const originalSize = file.size; @@ -923,18 +944,18 @@ const cameraBtn = document.getElementById('cameraUpload'); const galleryBtn = document.getElementById('galleryUpload'); const uploadText = document.getElementById('photoUploadText'); - + if (show) { cameraBtn.classList.add('opacity-50', 'cursor-not-allowed'); galleryBtn.classList.add('opacity-50', 'cursor-not-allowed'); uploadText.innerHTML = '이미지 처리 중...'; uploadText.classList.add('text-blue-600'); } else { - if (currentPhotos.length < 2) { + if (currentPhotos.length < 5) { cameraBtn.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'); } } @@ -964,43 +985,37 @@ // 사진 미리보기 업데이트 function updatePhotoPreview() { const container = document.getElementById('photoPreviewContainer'); - const photo1Container = document.getElementById('photo1Container'); - const photo2Container = document.getElementById('photo2Container'); const uploadText = document.getElementById('photoUploadText'); const cameraUpload = document.getElementById('cameraUpload'); const galleryUpload = document.getElementById('galleryUpload'); - + // 텍스트 업데이트 - uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`; - - // 첫 번째 사진 - if (currentPhotos[0]) { - document.getElementById('previewImg1').src = currentPhotos[0]; - photo1Container.classList.remove('hidden'); - container.style.display = 'grid'; - } else { - photo1Container.classList.add('hidden'); + uploadText.textContent = `사진 추가 (${currentPhotos.length}/5)`; + + // 모든 사진 미리보기 업데이트 (최대 5장) + for (let i = 0; i < 5; i++) { + const photoContainer = document.getElementById(`photo${i + 1}Container`); + const previewImg = document.getElementById(`previewImg${i + 1}`); + + if (currentPhotos[i]) { + 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) { container.style.display = 'none'; } - - // 2장이 모두 업로드되면 업로드 버튼 스타일 변경 - if (currentPhotos.length >= 2) { + + // 5장이 모두 업로드되면 업로드 버튼 스타일 변경 + if (currentPhotos.length >= 5) { cameraUpload.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'); } else { cameraUpload.classList.remove('opacity-50', 'cursor-not-allowed'); diff --git a/frontend/issue-view.html b/frontend/issue-view.html index f7f8837..9abd773 100644 --- a/frontend/issue-view.html +++ b/frontend/issue-view.html @@ -710,27 +710,31 @@ incoming_defect: '입고자재 불량', inspection_miss: '검사미스' }; - + const categoryColors = { material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300', design_error: 'bg-blue-100 text-blue-700 border-blue-300', incoming_defect: 'bg-red-100 text-red-700 border-red-300', inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300' }; - + const div = document.createElement('div'); // 검토 완료 상태에 따른 스타일링 const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4'; - const statusClasses = isCompleted - ? 'bg-gray-100 opacity-75' + const statusClasses = isCompleted + ? 'bg-gray-100 opacity-75' : 'bg-gray-50 hover:bg-gray-100'; const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300'; div.className = `${baseClasses} ${statusClasses} ${borderColor}`; - + const dateStr = DateUtils.formatKST(issue.report_date, true); const relativeTime = DateUtils.getRelativeTime(issue.report_date); 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 = `
@@ -741,49 +745,77 @@ ${projectInfo}
- +
-
- ${issue.photo_path ? - `` : '' - } - ${issue.photo_path2 ? - `` : '' - } - ${!issue.photo_path && !issue.photo_path2 ? - `
- -
` : '' - } +
+ ${(() => { + const photos = [ + issue.photo_path, + issue.photo_path2, + issue.photo_path3, + issue.photo_path4, + issue.photo_path5 + ].filter(p => p); + + if (photos.length === 0) { + return ` +
+ +
+ `; + } + + return photos.map(path => ` + + `).join(''); + })()}
- +
${categoryNames[issue.category] || issue.category} - ${issue.work_hours ? + ${issue.work_hours ? ` ${issue.work_hours}시간 - ` : + ` : '시간 미입력' }
- +

${issue.description}

- -
- ${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'} - ${dateStr} - ${relativeTime} + +
+
+ ${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'} + ${dateStr} + ${relativeTime} +
+ + + ${(canEdit || canDelete) ? ` +
+ ${canEdit ? ` + + ` : ''} + ${canDelete ? ` + + ` : ''} +
+ ` : ''}
`; - + return div; } @@ -920,7 +952,151 @@ localStorage.removeItem('currentUser'); 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 = ` +
+
+

부적합 수정

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ `; + + 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 = ` +
+
+
+ +
+

부적합 삭제

+

+ 이 부적합 사항을 삭제하시겠습니까?
+ 삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다. +

+
+ +
+ + +
+
+ `; + + 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 스크립트 동적 로딩 diff --git a/frontend/issues-dashboard.html b/frontend/issues-dashboard.html index a0b36ea..c61b0b4 100644 --- a/frontend/issues-dashboard.html +++ b/frontend/issues-dashboard.html @@ -977,33 +977,34 @@ 이미지
-
- ${issue.photo_path ? ` -
- 부적합 사진 1 - diff --git a/frontend/issues-inbox.html b/frontend/issues-inbox.html index 34f4e8e..8e105ce 100644 --- a/frontend/issues-inbox.html +++ b/frontend/issues-inbox.html @@ -795,7 +795,7 @@ 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}장` : '사진 없음'; return ` @@ -856,8 +856,10 @@ ${photoCount > 0 ? ` ` : ''} diff --git a/frontend/issues-management.html b/frontend/issues-management.html index 244e7bb..203a327 100644 --- a/frontend/issues-management.html +++ b/frontend/issues-management.html @@ -851,10 +851,27 @@
-
- ${issue.photo_path ? `업로드 사진 1` : '
사진 없음
'} - ${issue.photo_path2 ? `업로드 사진 2` : '
사진 없음
'} -
+ ${(() => { + const photos = [ + issue.photo_path, + issue.photo_path2, + issue.photo_path3, + issue.photo_path4, + issue.photo_path5 + ].filter(p => p); + + if (photos.length === 0) { + return '
사진 없음
'; + } + + return ` +
+ ${photos.map((path, idx) => ` + 업로드 사진 ${idx + 1} + `).join('')} +
+ `; + })()}
@@ -903,11 +920,27 @@
- ${issue.completion_photo_path ? ` -
- 완료 사진 -
- ` : '

완료 사진 없음

'} + ${(() => { + 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 '

완료 사진 없음

'; + } + + return ` +
+ ${photos.map(path => ` + 완료 사진 + `).join('')} +
+ `; + })()}
@@ -1015,20 +1048,27 @@
- ${issue.completion_photo_path ? - (issue.completion_photo_path.toLowerCase().endsWith('.heic') ? - `
-
- -
- HEIC 다운로드 -
` : - `
- 완료 사진 -
` - ) : - '

완료 사진 없음

' - } + ${(() => { + 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 '

완료 사진 없음

'; + } + + return ` +
+ ${photos.map(path => ` + 완료 사진 + `).join('')} +
+ `; + })()}
@@ -1052,10 +1092,27 @@ 업로드 사진 -
- ${issue.photo_path ? `업로드 사진 1` : '
사진 없음
'} - ${issue.photo_path2 ? `업로드 사진 2` : '
사진 없음
'} -
+ ${(() => { + const photos = [ + issue.photo_path, + issue.photo_path2, + issue.photo_path3, + issue.photo_path4, + issue.photo_path5 + ].filter(p => p); + + if (photos.length === 0) { + return '
사진 없음
'; + } + + return ` +
+ ${photos.map((path, idx) => ` + 업로드 사진 ${idx + 1} + `).join('')} +
+ `; + })()}
`; @@ -1402,10 +1459,27 @@
-
- ${issue.photo_path ? `업로드 사진 1` : '
없음
'} - ${issue.photo_path2 ? `업로드 사진 2` : '
없음
'} -
+ ${(() => { + const photos = [ + issue.photo_path, + issue.photo_path2, + issue.photo_path3, + issue.photo_path4, + issue.photo_path5 + ].filter(p => p); + + if (photos.length === 0) { + return '
없음
'; + } + + return ` +
+ ${photos.map((path, idx) => ` + 업로드 사진 ${idx + 1} + `).join('')} +
+ `; + })()}
@@ -1454,13 +1528,37 @@
- -
- ${issue.completion_photo_path ? - `완료 사진` : - '
없음
' + + + + ${(() => { + 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 ` +
+

현재 완료 사진 (${photos.length}장)

+
+ ${photos.map(path => ` + 완료 사진 + `).join('')} +
+
+ `; } - + return ''; + })()} + + +
+ +

※ 최대 5장까지 업로드 가능합니다. 새로운 사진을 업로드하면 기존 사진을 모두 교체합니다.

@@ -1488,11 +1586,20 @@ } }); - // 완료 사진 처리 - const photoFile = document.getElementById('modal_completion_photo').files[0]; - if (photoFile) { - const base64 = await fileToBase64(photoFile); - updates.completion_photo = base64; + // 완료 사진 처리 (최대 5장) + const photoInput = document.getElementById('modal_completion_photo'); + const photoFiles = photoInput.files; + + 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); @@ -1869,10 +1976,27 @@

업로드 사진

-
- ${issue.photo_path ? `업로드 사진 1` : '
사진 없음
'} - ${issue.photo_path2 ? `업로드 사진 2` : '
사진 없음
'} -
+ ${(() => { + const photos = [ + issue.photo_path, + issue.photo_path2, + issue.photo_path3, + issue.photo_path4, + issue.photo_path5 + ].filter(p => p); + + if (photos.length === 0) { + return '
사진 없음
'; + } + + return ` +
+ ${photos.map((path, idx) => ` + 업로드 사진 ${idx + 1} + `).join('')} +
+ `; + })()}
@@ -1923,32 +2047,57 @@

완료 신청 정보

- +
- ${issue.completion_photo_path ? ` -
- 현재 완료 사진 -
-

현재 완료 사진

-

클릭하면 크게 볼 수 있습니다

-
-
- ` : ` -
-
- -

사진 없음

-
-
- `} + ${(() => { + 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 ` +
+

현재 완료 사진 (${photos.length}장)

+
+ ${photos.map(path => ` + 완료 사진 + `).join('')} +
+
+ `; + } else { + return ` +
+
+ +

사진 없음

+
+
+ `; + } + })()}
- +
+

※ 최대 5장까지 업로드 가능합니다. 새로운 사진을 업로드하면 기존 사진을 모두 교체합니다.

@@ -1995,8 +2144,9 @@ if (fileInput && filenameSpan) { fileInput.addEventListener('change', function(e) { - if (e.target.files && e.target.files[0]) { - filenameSpan.textContent = e.target.files[0].name; + if (e.target.files && e.target.files.length > 0) { + const fileCount = Math.min(e.target.files.length, 5); + filenameSpan.textContent = `${fileCount}개 파일 선택됨`; filenameSpan.className = 'text-sm text-green-600 font-medium'; } else { filenameSpan.textContent = ''; @@ -2038,31 +2188,38 @@ // 완료 신청 정보 (완료 대기 상태일 때만) const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`); const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`); - + let completionComment = null; - let completionPhoto = null; - + const completionPhotos = {}; // 완료 사진들을 저장할 객체 + if (completionCommentElement) { completionComment = completionCommentElement.value.trim(); } - - if (completionPhotoElement && completionPhotoElement.files[0]) { + + // 완료 사진 처리 (최대 5장) + if (completionPhotoElement && completionPhotoElement.files.length > 0) { try { - const file = completionPhotoElement.files[0]; - console.log('🔍 업로드할 파일 정보:', { - name: file.name, - size: file.size, - type: file.type, - lastModified: file.lastModified - }); - - const base64 = await fileToBase64(file); - console.log('🔍 Base64 변환 완료 - 전체 길이:', base64.length); - console.log('🔍 Base64 헤더:', base64.substring(0, 50)); - - completionPhoto = base64.split(',')[1]; // Base64 데이터만 추출 - console.log('🔍 헤더 제거 후 길이:', completionPhoto.length); - console.log('🔍 전송할 Base64 시작 부분:', completionPhoto.substring(0, 50)); + const files = completionPhotoElement.files; + const maxPhotos = Math.min(files.length, 5); + + console.log(`🔍 총 ${maxPhotos}개의 완료 사진 업로드 시작`); + + for (let i = 0; i < maxPhotos; i++) { + const file = files[i]; + console.log(`🔍 파일 ${i + 1} 정보:`, { + name: file.name, + size: file.size, + type: file.type + }); + + const base64 = await fileToBase64(file); + 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) { console.error('파일 변환 오류:', error); alert('완료 사진 업로드 중 오류가 발생했습니다.'); @@ -2091,8 +2248,9 @@ if (completionComment !== null) { requestBody.completion_comment = completionComment || null; } - if (completionPhoto !== null) { - requestBody.completion_photo = completionPhoto; + // 완료 사진들 추가 (최대 5장) + for (const [key, value] of Object.entries(completionPhotos)) { + requestBody[key] = value; } try { diff --git a/frontend/reports-daily.html b/frontend/reports-daily.html index af91ffb..a90449d 100644 --- a/frontend/reports-daily.html +++ b/frontend/reports-daily.html @@ -4,39 +4,47 @@ 일일보고서 - 작업보고서 - + - + - + @@ -52,12 +60,12 @@ 일일보고서 -

품질팀용 관리함 데이터를 엑셀 형태로 내보내세요

+

프로젝트별 진행중/완료 항목을 엑셀로 내보내세요

- +
@@ -70,48 +78,74 @@

- 선택한 프로젝트의 관리함 데이터만 포함됩니다. + 진행 중인 항목 + 완료되고 한번도 추출 안된 항목이 포함됩니다.

- +
- -
- -