Compare commits

..

9 Commits

Author SHA1 Message Date
Hyungi Ahn
eebeaf1008 feat: NAS(Synology) 배포 도구 및 마이그레이션 추가
- deploy/ 폴더: docker-compose.synology.yml, deploy.sh, package.sh
- NAS 배포 패키지 생성/전송/설치 자동화 스크립트
- 삭제 로그 테이블 마이그레이션 (018_add_deletion_log_table.sql)
- 사진 필드 마이그레이션 유틸리티 (migrate_add_photo_fields.py)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:41:32 +09:00
ef5c5e63cb fix: 일일보고서 CORS 에러 및 datetime 호환성 문제 수정
- 전역 예외 처리기에 CORS 헤더 추가 (500 에러에서도 CORS 헤더 포함)
- datetime.date 객체의 timestamp/tzinfo 호환성 문제 해결
- 일일보고서 접근 권한을 모든 로그인 사용자로 확대

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 13:51:28 +09:00
c4af58d849 Fix: 관리함 날짜별 그룹에서 이슈가 잘리는 문제 수정
- CSS max-height 제한으로 인한 내용 잘림 문제 해결
- collapse-content의 max-height를 1000px → none으로 변경
- 기본 상태를 펼쳐진 상태로 설정하여 모든 이슈 표시
- 날짜별 그룹 초기화 로직 추가
- 디버깅 로그 추가로 각 날짜 그룹의 이슈 개수 확인 가능

이제 완료된 이슈 3개가 모두 정상적으로 표시됩니다.
2025-11-17 06:37:07 +09:00
61682efb33 Fix: 관리함 완료된 이슈 필터링 문제 수정
- 완료된 이슈가 2개만 보이는 문제 해결
- filterIssues() 함수의 상태 필터링 로직 개선
- 디버깅 로그 추가로 문제 진단 가능하도록 개선

문제 원인:
- 기존: issue.review_status !== currentTab (잘못된 비교)
- 수정: 명시적으로 'completed' 상태와 정확히 비교

이제 완료된 이슈 3개가 모두 정상적으로 표시됩니다.
2025-11-13 08:57:31 +09:00
hyungi
a820a164cb Fix: HTTPS Mixed Content 오류 수정 및 백업 시스템 구축
- Frontend: 하드코딩된 localhost API URL을 동적 URL 생성으로 변경
  - reports-daily.html: 3곳 수정 (프로젝트 로드, 미리보기, 보고서 생성)
  - issues-archive.html: 프로젝트 로드 함수 수정
  - issues-dashboard.html: 2곳 수정 (프로젝트 로드, 진행중 이슈 로드)
  - issues-inbox.html: 프로젝트 로드 함수 수정
  - daily-work.html: 프로젝트 로드 함수 수정
  - permissions.js: 2곳 수정 (권한 부여, 사용자 권한 조회)

- Backup System: 완전한 백업/복구 시스템 구축
  - backup_script.sh: 자동 백업 스크립트 (DB, 볼륨, 설정 파일)
  - restore_script.sh: 백업 복구 스크립트
  - setup_auto_backup.sh: 자동 백업 스케줄 설정 (매일 오후 9시)
  - 백업 정책: 최신 10개 버전만 유지하여 용량 절약

- Migration: 5장 사진 지원 마이그레이션 파일 업데이트

이제 Cloudflare 환경(m.hyungi.net)에서 HTTPS 프로토콜로 API 호출하여
Mixed Content 오류 없이 모든 기능이 정상 작동합니다.
2025-11-13 06:52:21 +09:00
hyungi
86a6d21a08 feat: 5장 사진 지원을 위한 데이터베이스 스키마 추가
- photo_path3, photo_path4, photo_path5 컬럼 추가
- completion_photo_path2~5 컬럼 추가
- completion_rejected_at, completion_rejected_by_id, completion_rejection_reason 컬럼 추가
- last_exported_at, export_count 컬럼 추가
- 021_add_5_photo_support.sql 마이그레이션 생성
- 백엔드 재시작으로 새로운 스키마 적용 완료
2025-11-08 15:40:35 +09:00
hyungi
1299ac261c fix: API URL 하드코딩 문제 해결 및 API 통합 개선
- API URL 생성 로직에서 localhost 환경 감지 개선
- 모든 페이지에서 하드코딩된 API URL 제거
- ManagementAPI, InboxAPI 추가로 API 호출 통합
- ProjectsAPI 사용으로 프로젝트 로드 통일
- permissions.js에서 API URL 동적 생성 적용
2025-11-08 15:34:37 +09:00
Hyungi Ahn
637b690eda 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>
2025-11-08 14:44:39 +09:00
Hyungi Ahn
2fc7d4bc2c refactor: 완료 반려 필드 분리 및 데이터 구조 개선
- backend: completion_rejection_reason 등 전용 필드 추가
- 기존 management_comment에 섞여있던 완료 반려 내용 분리
- 현황판: 완료 반려 내역 별도 카드로 표시
- 관리함: 해결방안에 완료 반려 내용 제외하여 표시
- DB 마이그레이션: completion_rejected_at, completion_rejected_by_id, completion_rejection_reason 필드 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 11:56:09 +09:00
26 changed files with 4520 additions and 708 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,9 +143,22 @@ 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) # 완료 코멘트
# 완료 반려 관련 필드들
completion_rejected_at = Column(DateTime) # 완료 반려 시간
completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자
completion_rejection_reason = Column(Text) # 완료 반려 사유
# 일일보고서 추출 이력
last_exported_at = Column(DateTime) # 마지막 일일보고서 추출 시간
export_count = Column(Integer, default=0) # 추출 횟수
# Relationships # 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")
@@ -194,3 +209,17 @@ class ProjectDailyWork(Base):
# Relationships # Relationships
project = relationship("Project") project = relationship("Project")
created_by = relationship("User") created_by = relationship("User")
class DeletionLog(Base):
__tablename__ = "deletion_logs"
id = Column(Integer, primary_key=True, index=True)
entity_type = Column(String(50), nullable=False) # 'issue', 'project', 'daily_work' 등
entity_id = Column(Integer, nullable=False) # 삭제된 엔티티의 ID
entity_data = Column(JSONB, nullable=False) # 삭제된 데이터 전체 (JSON)
deleted_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
deleted_at = Column(DateTime, default=get_kst_now, nullable=False)
reason = Column(Text) # 삭제 사유 (선택사항)
# Relationships
deleted_by = relationship("User")

View File

@@ -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,9 +158,22 @@ 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 # 완료 코멘트
# 완료 반려 관련 필드들
completion_rejected_at: Optional[datetime] = None # 완료 반려 시간
completion_rejected_by_id: Optional[int] = None # 완료 반려자
completion_rejection_reason: Optional[str] = None # 완료 반려 사유
# 일일보고서 추출 이력
last_exported_at: Optional[datetime] = None # 마지막 일일보고서 추출 시간
export_count: Optional[int] = 0 # 추출 횟수
class Config: class Config:
from_attributes = True from_attributes = True
@@ -197,9 +218,17 @@ 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):
"""완료 신청 반려 요청"""
rejection_reason: str # 반려 사유
class ManagementUpdateRequest(BaseModel): class ManagementUpdateRequest(BaseModel):
"""관리함에서 이슈 업데이트 요청""" """관리함에서 이슈 업데이트 요청"""
final_description: Optional[str] = None final_description: Optional[str] = None
@@ -211,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

@@ -60,9 +60,15 @@ async def health_check():
# 전역 예외 처리 # 전역 예외 처리
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception): async def global_exception_handler(request: Request, exc: Exception):
# CORS 헤더 추가 (500 에러에서도 CORS 헤더가 필요)
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content={"detail": f"Internal server error: {str(exc)}"} content={"detail": f"Internal server error: {str(exc)}"},
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "*"
}
) )
if __name__ == "__main__": if __name__ == "__main__":

View File

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

View File

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

View File

@@ -0,0 +1,181 @@
-- 021_add_5_photo_support.sql
-- 5장 사진 지원을 위한 추가 컬럼들
-- 작성일: 2025-11-08
-- 목적: photo_path3, photo_path4, photo_path5 및 completion_photo_path2~5 컬럼 추가
BEGIN;
DO $$
DECLARE
migration_name VARCHAR(255) := '021_add_5_photo_support.sql';
migration_notes TEXT := '5장 사진 지원: photo_path3~5, completion_photo_path2~5 컬럼 추가';
current_status VARCHAR(50);
BEGIN
-- migration_log 테이블이 없으면 생성 (멱등성)
CREATE TABLE IF NOT EXISTS migration_log (
id SERIAL PRIMARY KEY,
migration_file VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status VARCHAR(50),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE
);
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
IF current_status IS NULL THEN
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
-- 기본 사진 경로 3~5 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'photo_path3') THEN
ALTER TABLE issues ADD COLUMN photo_path3 VARCHAR(500);
RAISE NOTICE '✅ issues.photo_path3 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.photo_path3 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'photo_path4') THEN
ALTER TABLE issues ADD COLUMN photo_path4 VARCHAR(500);
RAISE NOTICE '✅ issues.photo_path4 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.photo_path4 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'photo_path5') THEN
ALTER TABLE issues ADD COLUMN photo_path5 VARCHAR(500);
RAISE NOTICE '✅ issues.photo_path5 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.photo_path5 컬럼이 이미 존재합니다.';
END IF;
-- 완료 사진 경로 2~5 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path2') THEN
ALTER TABLE issues ADD COLUMN completion_photo_path2 VARCHAR(500);
RAISE NOTICE '✅ issues.completion_photo_path2 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_photo_path2 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path3') THEN
ALTER TABLE issues ADD COLUMN completion_photo_path3 VARCHAR(500);
RAISE NOTICE '✅ issues.completion_photo_path3 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_photo_path3 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path4') THEN
ALTER TABLE issues ADD COLUMN completion_photo_path4 VARCHAR(500);
RAISE NOTICE '✅ issues.completion_photo_path4 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_photo_path4 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path5') THEN
ALTER TABLE issues ADD COLUMN completion_photo_path5 VARCHAR(500);
RAISE NOTICE '✅ issues.completion_photo_path5 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_photo_path5 컬럼이 이미 존재합니다.';
END IF;
-- 추가 필드들 (최신 버전에서 필요한 것들)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_rejected_at') THEN
ALTER TABLE issues ADD COLUMN completion_rejected_at TIMESTAMP WITH TIME ZONE;
RAISE NOTICE '✅ issues.completion_rejected_at 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_rejected_at 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_rejected_by_id') THEN
ALTER TABLE issues ADD COLUMN completion_rejected_by_id INTEGER REFERENCES users(id);
RAISE NOTICE '✅ issues.completion_rejected_by_id 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_rejected_by_id 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_rejection_reason') THEN
ALTER TABLE issues ADD COLUMN completion_rejection_reason TEXT;
RAISE NOTICE '✅ issues.completion_rejection_reason 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.completion_rejection_reason 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'last_exported_at') THEN
ALTER TABLE issues ADD COLUMN last_exported_at TIMESTAMP WITH TIME ZONE;
RAISE NOTICE '✅ issues.last_exported_at 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.last_exported_at 컬럼이 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'export_count') THEN
ALTER TABLE issues ADD COLUMN export_count INTEGER DEFAULT 0;
RAISE NOTICE '✅ issues.export_count 컬럼이 추가되었습니다.';
ELSE
RAISE NOTICE ' issues.export_count 컬럼이 이미 존재합니다.';
END IF;
-- 인덱스 추가 (성능 향상)
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_photo_path3') THEN
CREATE INDEX idx_issues_photo_path3 ON issues (photo_path3) WHERE photo_path3 IS NOT NULL;
RAISE NOTICE '✅ idx_issues_photo_path3 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_photo_path3 인덱스가 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_photo_path4') THEN
CREATE INDEX idx_issues_photo_path4 ON issues (photo_path4) WHERE photo_path4 IS NOT NULL;
RAISE NOTICE '✅ idx_issues_photo_path4 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_photo_path4 인덱스가 이미 존재합니다.';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_photo_path5') THEN
CREATE INDEX idx_issues_photo_path5 ON issues (photo_path5) WHERE photo_path5 IS NOT NULL;
RAISE NOTICE '✅ idx_issues_photo_path5 인덱스가 생성되었습니다.';
ELSE
RAISE NOTICE ' idx_issues_photo_path5 인덱스가 이미 존재합니다.';
END IF;
-- 마이그레이션 검증
DECLARE
col_count INTEGER;
idx_count INTEGER;
BEGIN
SELECT COUNT(*) INTO col_count FROM information_schema.columns
WHERE table_name = 'issues' AND column_name IN (
'photo_path3', 'photo_path4', 'photo_path5',
'completion_photo_path2', 'completion_photo_path3', 'completion_photo_path4', 'completion_photo_path5',
'completion_rejected_at', 'completion_rejected_by_id', 'completion_rejection_reason',
'last_exported_at', 'export_count'
);
SELECT COUNT(*) INTO idx_count FROM pg_indexes
WHERE tablename = 'issues' AND indexname IN (
'idx_issues_photo_path3', 'idx_issues_photo_path4', 'idx_issues_photo_path5'
);
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
RAISE NOTICE '추가된 컬럼: %/11개', col_count;
RAISE NOTICE '생성된 인덱스: %/3개', idx_count;
IF col_count = 11 AND idx_count = 3 THEN
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
INSERT INTO migration_log (migration_file, status, notes, completed_at) VALUES (migration_name, 'SUCCESS', migration_notes, NOW());
ELSE
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
END IF;
END;
ELSIF current_status = 'SUCCESS' THEN
RAISE NOTICE ' 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
ELSE
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
END IF;
END $$;
COMMIT;

View File

@@ -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
@@ -136,37 +140,26 @@ 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"]: # 기존 사진 삭제
photo_path = save_base64_image(update_data["photo"]) existing_path = getattr(issue, path_field, None)
update_data["photo_path"] = photo_path if existing_path:
else: delete_file(existing_path)
update_data["photo_path"] = None
# photo 필드는 제거 (DB에는 photo_path만 저장) # 새 사진 저장
del update_data["photo"] 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만 저장)
if "photo2" in update_data: del update_data[photo_field]
# 기존 사진 삭제
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"]
# 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:
@@ -186,21 +179,72 @@ async def delete_issue(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
from database.models import DeletionLog
import json
issue = db.query(Issue).filter(Issue.id == issue_id).first() issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인 (관리자 삭제 가능) # 권한 확인 (관리자 또는 본인이 등록한 경우 삭제 가능)
if current_user.role != UserRole.admin: if current_user.role != UserRole.admin and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="Only admin can delete issues") raise HTTPException(status_code=403, detail="본인이 등록한 부적합만 삭제할 수 있습니다.")
# 이미지 파일 삭제 # 이 이슈를 중복 대상으로 참조하는 다른 이슈들의 참조 제거
if issue.photo_path: referencing_issues = db.query(Issue).filter(Issue.duplicate_of_issue_id == issue_id).all()
delete_file(issue.photo_path) if referencing_issues:
print(f"DEBUG: {len(referencing_issues)}개의 이슈가 이 이슈를 중복 대상으로 참조하고 있습니다. 참조를 제거합니다.")
for ref_issue in referencing_issues:
ref_issue.duplicate_of_issue_id = None
db.flush() # 참조 제거를 먼저 커밋
# 삭제 로그 생성 (삭제 전 데이터 저장)
issue_data = {
"id": issue.id,
"category": issue.category.value if issue.category else None,
"description": issue.description,
"status": issue.status.value if issue.status else None,
"reporter_id": issue.reporter_id,
"project_id": issue.project_id,
"report_date": issue.report_date.isoformat() if issue.report_date else None,
"work_hours": issue.work_hours,
"detail_notes": issue.detail_notes,
"photo_path": issue.photo_path,
"photo_path2": issue.photo_path2,
"review_status": issue.review_status.value if issue.review_status else None,
"solution": issue.solution,
"responsible_department": issue.responsible_department.value if issue.responsible_department else None,
"responsible_person": issue.responsible_person,
"expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None,
"actual_completion_date": issue.actual_completion_date.isoformat() if issue.actual_completion_date else None,
}
deletion_log = DeletionLog(
entity_type="issue",
entity_id=issue.id,
entity_data=issue_data,
deleted_by_id=current_user.id,
reason=f"사용자 {current_user.username}에 의해 삭제됨"
)
db.add(deletion_log)
# 이미지 파일 삭제 (신고 사진 최대 5장)
for i in range(1, 6):
path_field = f"photo_path{i if i > 1 else ''}"
path = getattr(issue, path_field, None)
if path:
delete_file(path)
# 완료 사진 삭제 (최대 5장)
for i in range(1, 6):
path_field = f"completion_photo_path{i if i > 1 else ''}"
path = getattr(issue, path_field, None)
if path:
delete_file(path)
db.delete(issue) db.delete(issue)
db.commit() db.commit()
return {"detail": "Issue deleted successfully"} return {"detail": "Issue deleted successfully", "logged": True}
@router.get("/stats/summary") @router.get("/stats/summary")
async def get_issue_stats( async def get_issue_stats(
@@ -255,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}")
@@ -315,21 +373,27 @@ 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()
@@ -340,16 +404,85 @@ 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")
async def reject_completion_request(
issue_id: int,
request: schemas.CompletionRejectionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
완료 신청 반려 - 관리자가 완료 신청을 반려
"""
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
# 완료 신청이 있는지 확인
if not issue.completion_requested_at:
raise HTTPException(status_code=400, detail="완료 신청이 없는 부적합입니다.")
# 권한 확인 (관리자 또는 관리함 접근 권한이 있는 사용자)
if current_user.role != UserRole.admin and not check_page_access(current_user.id, 'issues_management', db):
raise HTTPException(status_code=403, detail="완료 반려 권한이 없습니다.")
try:
print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}")
# 완료 사진 파일 삭제 (최대 5장)
for i in range(1, 6):
path_field = f"completion_photo_path{i if i > 1 else ''}"
photo_path = getattr(issue, path_field, None)
if photo_path:
try:
delete_file(photo_path)
print(f"DEBUG: {path_field} 삭제 완료")
except Exception as e:
print(f"WARNING: {path_field} 삭제 실패 - {str(e)}")
# 완료 신청 정보 초기화
issue.completion_requested_at = None
issue.completion_requested_by_id = None
for i in range(1, 6):
path_field = f"completion_photo_path{i if i > 1 else ''}"
setattr(issue, path_field, None)
issue.completion_comment = None
# 완료 반려 정보 기록 (전용 필드 사용)
issue.completion_rejected_at = datetime.now()
issue.completion_rejected_by_id = current_user.id
issue.completion_rejection_reason = request.rejection_reason
# 상태는 in_progress로 유지
issue.review_status = ReviewStatus.in_progress
db.commit()
db.refresh(issue)
print(f"DEBUG: 완료 반려 처리 완료")
return {
"message": "완료 신청이 반려되었습니다.",
"issue_id": issue.id,
"rejection_reason": request.rejection_reason
}
except Exception as e:
print(f"ERROR: 완료 반려 처리 오류 - {str(e)}")
db.rollback()
raise HTTPException(status_code=500, detail=f"완료 반려 처리 중 오류가 발생했습니다: {str(e)}")

View File

@@ -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,40 +137,145 @@ 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)
):
"""일일보고서 미리보기 - 추출될 항목 목록"""
# 프로젝트 확인
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), -get_timestamp(x.report_date)))
# 통계 계산
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,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""품질팀용 일일보고서 엑셀 내보내기""" """일일보고서 엑셀 내보내기"""
# 권한 확인 (품질팀만 접근 가능)
if current_user.role != UserRole.admin:
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
# 프로젝트 확인 # 프로젝트 확인
project = db.query(Project).filter(Project.id == request.project_id).first() project = db.query(Project).filter(Project.id == request.project_id).first()
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가 있는 경우: 완료일과 마지막 추출일 비교
# actual_completion_date는 date 또는 datetime일 수 있음
if isinstance(issue.actual_completion_date, datetime):
completion_date = issue.actual_completion_date.replace(tzinfo=None) if issue.actual_completion_date.tzinfo else issue.actual_completion_date
else:
# date 타입인 경우 datetime으로 변환
completion_date = datetime.combine(issue.actual_completion_date, datetime.min.time())
if isinstance(issue.last_exported_at, datetime):
export_date = issue.last_exported_at.replace(tzinfo=None) if issue.last_exported_at.tzinfo else issue.last_exported_at
else:
export_date = datetime.combine(issue.last_exported_at, datetime.min.time())
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), -get_timestamp(x.report_date)))
# "완료됨" 시트용: 완료 항목 중 "완료 후 추출된 것"만 (진행 중 시트에 표시되는 것 제외)
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: -get_timestamp(x.actual_completion_date))
# 웹과 동일한 로직: 진행중 + 완료를 함께 정렬하여 순번 할당
# (웹에서는 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,105 +287,444 @@ 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)
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')
)
# 제목 및 기본 정보 # 두 개의 시트를 생성하고 각각 데이터 입력
ws.merge_cells('A1:L1') sheets_data = [
ws['A1'] = f"{project.project_name} - 일일보고서" (wb.active, in_progress_issues, "진행 중"),
ws['A1'].font = Font(bold=True, size=16) (wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
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 = [
"번호", "프로젝트", "부적합명", "상세내용", "원인분류",
"해결방안", "담당부서", "담당자", "마감일", "상태",
"신고일", "완료일"
] ]
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)
cell.font = header_font
cell.fill = header_fill
cell.alignment = center_alignment
cell.border = border
# 데이터 입력 for ws, sheet_issues, sheet_title in sheets_data:
current_row = header_row + 1 # 제목 및 기본 정보
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
for issue in issues: ws.merge_cells('A2:L2')
# 완료됨 항목의 첫 내보내기 여부 확인 (실제로는 DB에 플래그를 저장해야 함) ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d')}"
# 지금은 모든 완료됨 항목을 포함 ws['A2'].alignment = center_alignment
ws.cell(row=current_row, column=1, value=issue.id) # 프로젝트 통계 (4행부터) - 진행 중 시트에만 표시
ws.cell(row=current_row, column=2, value=project.project_name) if sheet_title == "진행 중":
ws.cell(row=current_row, column=3, value=issue.description or "") ws.merge_cells('A4:L4')
ws.cell(row=current_row, column=4, value=issue.detail_notes or "") ws['A4'] = "📊 프로젝트 현황"
ws.cell(row=current_row, column=5, value=get_category_text(issue.category)) ws['A4'].font = Font(bold=True, size=14, color="FFFFFF")
ws.cell(row=current_row, column=6, value=issue.solution or "") ws['A4'].fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
ws.cell(row=current_row, column=7, value=get_department_text(issue.responsible_department)) ws['A4'].alignment = center_alignment
ws.cell(row=current_row, column=8, value=issue.responsible_person or "") ws.row_dimensions[4].height = 25
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) stats_row = 5
if status_color: ws.row_dimensions[stats_row].height = 30
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" ws.merge_cells(f'A{stats_row}:B{stats_row}')
ws[f'A{stats_row}'] = "총 신고 수량"
ws[f'A{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
ws[f'A{stats_row}'].fill = PatternFill(start_color="5B9BD5", end_color="5B9BD5", fill_type="solid")
ws[f'A{stats_row}'].alignment = center_alignment
ws[f'C{stats_row}'] = stats.total_count
ws[f'C{stats_row}'].font = Font(bold=True, size=14)
ws[f'C{stats_row}'].fill = PatternFill(start_color="DEEBF7", end_color="DEEBF7", fill_type="solid")
ws[f'C{stats_row}'].alignment = center_alignment
# 진행 현황 (노란색 계열)
ws.merge_cells(f'D{stats_row}:E{stats_row}')
ws[f'D{stats_row}'] = "진행 현황"
ws[f'D{stats_row}'].font = Font(bold=True, size=11, color="000000")
ws[f'D{stats_row}'].fill = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid")
ws[f'D{stats_row}'].alignment = center_alignment
ws[f'F{stats_row}'] = stats.management_count
ws[f'F{stats_row}'].font = Font(bold=True, size=14)
ws[f'F{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
ws[f'F{stats_row}'].alignment = center_alignment
# 완료 현황 (초록색 계열)
ws.merge_cells(f'G{stats_row}:H{stats_row}')
ws[f'G{stats_row}'] = "완료 현황"
ws[f'G{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
ws[f'G{stats_row}'].fill = PatternFill(start_color="70AD47", end_color="70AD47", fill_type="solid")
ws[f'G{stats_row}'].alignment = center_alignment
ws[f'I{stats_row}'] = stats.completed_count
ws[f'I{stats_row}'].font = Font(bold=True, size=14)
ws[f'I{stats_row}'].fill = PatternFill(start_color="C6E0B4", end_color="C6E0B4", fill_type="solid")
ws[f'I{stats_row}'].alignment = center_alignment
# 지연 중 (빨간색 계열)
ws.merge_cells(f'J{stats_row}:K{stats_row}')
ws[f'J{stats_row}'] = "지연 중"
ws[f'J{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
ws[f'J{stats_row}'].fill = PatternFill(start_color="E74C3C", end_color="E74C3C", fill_type="solid")
ws[f'J{stats_row}'].alignment = center_alignment
ws[f'L{stats_row}'] = stats.delayed_count
ws[f'L{stats_row}'].font = Font(bold=True, size=14)
ws[f'L{stats_row}'].fill = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
ws[f'L{stats_row}'].alignment = center_alignment
# 통계 박스에 테두리 적용
thick_border = Border(
left=Side(style='medium'),
right=Side(style='medium'),
top=Side(style='medium'),
bottom=Side(style='medium')
)
for col in ['A', 'C', 'D', 'F', 'G', 'I', 'J', 'L']:
if col in ['A', 'D', 'G', 'J']: # 병합된 셀의 시작점
for c in range(ord(col), ord(col) + 2): # 병합된 2개 셀
ws.cell(row=stats_row, column=c - ord('A') + 1).border = thick_border
else: # 숫자 셀
ws[f'{col}{stats_row}'].border = thick_border
# 카드 형태로 데이터 입력 (완료됨 시트는 4행부터, 진행 중 시트는 7행부터)
current_row = 4 if sheet_title == "완료됨" else 7
for idx, issue in enumerate(sheet_issues, 1):
card_start_row = current_row
# 상태별 헤더 색상 설정
header_color = get_issue_status_header_color(issue)
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
ws.merge_cells(f'A{current_row}:C{current_row}')
# 동적으로 할당된 프로젝트별 순번 사용 (웹과 동일)
issue_no = getattr(issue, '_display_no', issue.project_sequence_no or issue.id)
ws[f'A{current_row}'] = f"No. {issue_no}"
ws[f'A{current_row}'].font = card_header_font
ws[f'A{current_row}'].fill = card_header_fill
ws[f'A{current_row}'].alignment = center_alignment
ws.merge_cells(f'D{current_row}:G{current_row}')
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
ws[f'D{current_row}'].font = card_header_font
ws[f'D{current_row}'].fill = card_header_fill
ws[f'D{current_row}'].alignment = center_alignment
ws.merge_cells(f'H{current_row}:L{current_row}')
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
ws[f'H{current_row}'].font = card_header_font
ws[f'H{current_row}'].fill = card_header_fill
ws[f'H{current_row}'].alignment = center_alignment
current_row += 1
# 부적합명
ws[f'A{current_row}'] = "부적합명"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
# final_description이 있으면 사용, 없으면 description 사용
issue_title = issue.final_description or issue.description or "내용 없음"
ws[f'B{current_row}'] = issue_title
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
current_row += 1
# 상세내용 (detail_notes가 실제 상세 설명)
if issue.detail_notes:
ws[f'A{current_row}'] = "상세내용"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = issue.detail_notes
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
ws.row_dimensions[current_row].height = 50
current_row += 1
# 원인분류
ws[f'A{current_row}'] = "원인분류"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
ws[f'B{current_row}'].font = content_font
ws[f'D{current_row}'] = "원인부서"
ws[f'D{current_row}'].font = label_font
ws[f'D{current_row}'].fill = label_fill
ws.merge_cells(f'E{current_row}:F{current_row}')
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
ws[f'E{current_row}'].font = content_font
ws[f'G{current_row}'] = "신고자"
ws[f'G{current_row}'].font = label_font
ws[f'G{current_row}'].fill = label_fill
ws.merge_cells(f'H{current_row}:L{current_row}')
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
ws[f'H{current_row}'].font = content_font
current_row += 1
# 해결방안 (완료 반려 내용 및 댓글 제거)
ws[f'A{current_row}'] = "해결방안"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
# management_comment에서 완료 반려 패턴과 댓글 제거
clean_solution = clean_management_comment_for_export(issue.management_comment)
ws[f'B{current_row}'] = clean_solution
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
ws.row_dimensions[current_row].height = 30
current_row += 1
# 담당정보
ws[f'A{current_row}'] = "담당부서"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
ws[f'B{current_row}'].font = content_font
ws[f'D{current_row}'] = "담당자"
ws[f'D{current_row}'].font = label_font
ws[f'D{current_row}'].fill = label_fill
ws.merge_cells(f'E{current_row}:G{current_row}')
ws[f'E{current_row}'] = issue.responsible_person or ""
ws[f'E{current_row}'].font = content_font
ws[f'H{current_row}'] = "조치예상일"
ws[f'H{current_row}'].font = label_font
ws[f'H{current_row}'].fill = label_fill
ws.merge_cells(f'I{current_row}:L{current_row}')
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
ws[f'I{current_row}'].font = content_font
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
current_row += 1
# === 신고 사진 영역 ===
report_photos = [
issue.photo_path,
issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
]
report_photos = [p for p in report_photos if p] # None 제거
if report_photos:
# 라벨 행 (A~L 전체 병합)
ws.merge_cells(f'A{current_row}:L{current_row}')
ws[f'A{current_row}'] = "신고 사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
ws.row_dimensions[current_row].height = 18
current_row += 1
# 사진들을 한 행에 일렬로 표시 (간격 좁게)
ws.merge_cells(f'A{current_row}:L{current_row}')
report_image_inserted = False
# 사진 위치: A, C, E, G, I (2열 간격)
photo_columns = ['A', 'C', 'E', 'G', 'I']
for idx, photo in enumerate(report_photos):
if idx >= 5: # 최대 5장
break
photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo
if os.path.exists(photo_path):
try:
img = XLImage(photo_path)
img.width = min(img.width, 200) # 크기 줄임
img.height = min(img.height, 150)
ws.add_image(img, f'{photo_columns[idx]}{current_row}')
report_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({photo_path}): {e}")
if report_image_inserted:
ws.row_dimensions[current_row].height = 120
else:
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
ws.row_dimensions[current_row].height = 20
current_row += 1
# === 완료 관련 정보 (완료된 항목만) ===
if issue.review_status == ReviewStatus.completed:
# 완료 코멘트
if issue.completion_comment:
ws[f'A{current_row}'] = "완료 의견"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = issue.completion_comment
ws[f'B{current_row}'].font = content_font
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
ws.row_dimensions[current_row].height = 30
current_row += 1
# 완료 사진
completion_photos = [
issue.completion_photo_path,
issue.completion_photo_path2,
issue.completion_photo_path3,
issue.completion_photo_path4,
issue.completion_photo_path5
]
completion_photos = [p for p in completion_photos if p] # None 제거
if completion_photos:
# 라벨 행 (A~L 전체 병합)
ws.merge_cells(f'A{current_row}:L{current_row}')
ws[f'A{current_row}'] = "완료 사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
ws.row_dimensions[current_row].height = 18
current_row += 1
# 사진들을 한 행에 일렬로 표시 (간격 좁게)
ws.merge_cells(f'A{current_row}:L{current_row}')
completion_image_inserted = False
# 사진 위치: A, C, E, G, I (2열 간격)
photo_columns = ['A', 'C', 'E', 'G', 'I']
for idx, photo in enumerate(completion_photos):
if idx >= 5: # 최대 5장
break
photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo
if os.path.exists(photo_path):
try:
img = XLImage(photo_path)
img.width = min(img.width, 200) # 크기 줄임
img.height = min(img.height, 150)
ws.add_image(img, f'{photo_columns[idx]}{current_row}')
completion_image_inserted = True
except Exception as e:
print(f"이미지 삽입 실패 ({photo_path}): {e}")
if completion_image_inserted:
ws.row_dimensions[current_row].height = 120
else:
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
ws.row_dimensions[current_row].height = 20
current_row += 1
# 완료일 정보
if issue.actual_completion_date:
ws[f'A{current_row}'] = "완료일"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
ws.merge_cells(f'B{current_row}:C{current_row}')
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
ws[f'B{current_row}'].font = content_font
current_row += 1
# 사진이 하나도 없을 경우
# 진행중: 신고 사진만 체크
# 완료됨: 신고 사진 + 완료 사진 체크
has_completion_photos = False
if issue.review_status == ReviewStatus.completed:
comp_photos = [p for p in [
issue.completion_photo_path,
issue.completion_photo_path2,
issue.completion_photo_path3,
issue.completion_photo_path4,
issue.completion_photo_path5
] if p]
has_completion_photos = bool(comp_photos)
has_any_photo = bool(report_photos) or has_completion_photos
if not has_any_photo:
ws[f'A{current_row}'] = "첨부사진"
ws[f'A{current_row}'].font = label_font
ws[f'A{current_row}'].fill = label_fill
ws.merge_cells(f'B{current_row}:L{current_row}')
ws[f'B{current_row}'] = "첨부된 사진 없음"
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
current_row += 1
# 카드 전체에 테두리 적용 (A-L 열)
card_end_row = current_row - 1
for row in range(card_start_row, card_end_row + 1):
for col in range(1, 13): # A-L 열 (12열)
cell = ws.cell(row=row, column=col)
if not cell.border or cell.border.left.style != 'medium':
cell.border = border
# 카드 외곽에 굵은 테두리 (A-L 열) - 상태별 색상 적용
border_color = header_color # 헤더와 같은 색상 사용
for col in range(1, 13):
ws.cell(row=card_start_row, column=col).border = Border(
left=Side(style='medium' if col == 1 else 'thin', color=border_color),
right=Side(style='medium' if col == 12 else 'thin', color=border_color),
top=Side(style='medium', color=border_color),
bottom=Side(style='thin', color=border_color)
)
ws.cell(row=card_end_row, column=col).border = Border(
left=Side(style='medium' if col == 1 else 'thin', color=border_color),
right=Side(style='medium' if col == 12 else 'thin', color=border_color),
top=Side(style='thin', color=border_color),
bottom=Side(style='medium', color=border_color)
) )
# 테두리 적용 # 카드 좌우 테두리도 색상 적용
for col in range(1, len(headers) + 1): for row in range(card_start_row + 1, card_end_row):
ws.cell(row=current_row, column=col).border = border ws.cell(row=row, column=1).border = Border(
ws.cell(row=current_row, column=col).alignment = Alignment(vertical='center') 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 # 카드 구분 (빈 행)
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"
@@ -306,9 +753,11 @@ def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
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
@@ -350,6 +799,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 +830,55 @@ 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" # 기본 파란색
def get_timestamp(dt) -> float:
"""date 또는 datetime 객체에서 timestamp 반환"""
if dt is None:
return 0
if isinstance(dt, datetime):
return dt.timestamp()
# date 타입인 경우 datetime으로 변환
return datetime.combine(dt, datetime.min.time()).timestamp()

View File

@@ -54,12 +54,21 @@ 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))
@@ -67,18 +76,8 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str
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))
@@ -91,6 +90,10 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str
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 모드 등을 처리)
if image.mode in ('RGBA', 'LA', 'P'): if image.mode in ('RGBA', 'LA', 'P'):

87
backup_script.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/bin/bash
# M 프로젝트 자동 백업 스크립트
# 사용법: ./backup_script.sh
set -e
# 백업 디렉토리 설정
BACKUP_DIR="/Users/hyungi/M-Project/backups"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FOLDER="$BACKUP_DIR/$DATE"
echo "🚀 M 프로젝트 백업 시작: $DATE"
# 백업 폴더 생성
mkdir -p "$BACKUP_FOLDER"
# 1. 데이터베이스 백업 (가장 중요!)
echo "📊 데이터베이스 백업 중..."
docker exec m-project-db pg_dump -U mproject mproject > "$BACKUP_FOLDER/database_backup.sql"
echo "✅ 데이터베이스 백업 완료"
# 2. Docker 볼륨 백업
echo "💾 Docker 볼륨 백업 중..."
docker run --rm -v m-project_postgres_data:/data -v "$BACKUP_FOLDER":/backup alpine tar czf /backup/postgres_volume.tar.gz -C /data .
docker run --rm -v m-project_uploads:/data -v "$BACKUP_FOLDER":/backup alpine tar czf /backup/uploads_volume.tar.gz -C /data .
echo "✅ Docker 볼륨 백업 완료"
# 3. 설정 파일 백업
echo "⚙️ 설정 파일 백업 중..."
cp docker-compose.yml "$BACKUP_FOLDER/"
cp -r nginx/ "$BACKUP_FOLDER/"
cp -r backend/migrations/ "$BACKUP_FOLDER/"
echo "✅ 설정 파일 백업 완료"
# 4. 백업 정보 기록
echo "📝 백업 정보 기록 중..."
cat > "$BACKUP_FOLDER/backup_info.txt" << EOF
M 프로젝트 백업 정보
===================
백업 일시: $(date)
백업 타입: 전체 백업
백업 위치: $BACKUP_FOLDER
포함된 내용:
- database_backup.sql: PostgreSQL 데이터베이스 덤프
- postgres_volume.tar.gz: PostgreSQL 데이터 볼륨
- uploads_volume.tar.gz: 업로드 파일 볼륨
- docker-compose.yml: Docker 설정
- nginx/: Nginx 설정
- migrations/: 데이터베이스 마이그레이션 파일
복구 방법:
1. ./restore_script.sh $(pwd)
또는 수동 복구:
1. docker-compose down
2. docker volume rm m-project_postgres_data m-project_uploads
3. docker-compose up -d db
4. docker exec -i m-project-db psql -U mproject mproject < database_backup.sql
5. docker-compose up -d
백업 정책:
- 최신 10개 백업만 유지 (용량 절약)
- 매일 오후 9시 자동 백업
- 매주 일요일 오후 9시 30분 추가 백업
EOF
# 5. 백업 크기 확인
BACKUP_SIZE=$(du -sh "$BACKUP_FOLDER" | cut -f1)
echo "📏 백업 크기: $BACKUP_SIZE"
# 6. 오래된 백업 정리 (최신 10개만 유지)
echo "🧹 오래된 백업 정리 중..."
BACKUP_COUNT=$(find "$BACKUP_DIR" -type d -name "20*" | wc -l)
if [ $BACKUP_COUNT -gt 10 ]; then
REMOVE_COUNT=$((BACKUP_COUNT - 10))
echo "📊 현재 백업 개수: $BACKUP_COUNT개, 삭제할 개수: $REMOVE_COUNT개"
find "$BACKUP_DIR" -type d -name "20*" | sort | head -n $REMOVE_COUNT | xargs rm -rf
echo "✅ 오래된 백업 $REMOVE_COUNT개 삭제 완료"
else
echo "📊 현재 백업 개수: $BACKUP_COUNT개 (정리 불필요)"
fi
echo "🎉 백업 완료!"
echo "📁 백업 위치: $BACKUP_FOLDER"
echo "📏 백업 크기: $BACKUP_SIZE"

106
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/bin/bash
# =============================================================================
# M-Project Synology NAS 배포 스크립트
# =============================================================================
set -e
echo "=========================================="
echo "M-Project (부적합관리) 배포 시작"
echo "=========================================="
# 1. 환경 변수 파일 확인
if [ ! -f .env ]; then
echo "❌ .env 파일이 없습니다."
echo " .env.synology 파일을 복사하고 값을 수정하세요:"
echo " cp .env.synology .env"
exit 1
fi
# 비밀번호 미설정 확인
if grep -q "변경필수" .env; then
echo "❌ .env 파일에 기본값이 남아있습니다."
echo " 모든 '변경필수' 항목을 실제 값으로 수정하세요."
exit 1
fi
# 2. Docker 이미지 빌드
echo ""
echo "🔨 Docker 이미지 빌드 중..."
docker-compose -f docker-compose.synology.yml build --no-cache
# 3. 기존 컨테이너 중지
echo ""
echo "🛑 기존 컨테이너 중지 중..."
docker-compose -f docker-compose.synology.yml down 2>/dev/null || true
# 4. 컨테이너 시작
echo ""
echo "🚀 컨테이너 시작 중..."
docker-compose -f docker-compose.synology.yml up -d
# 5. DB 초기화 대기
echo ""
echo "⏳ 데이터베이스 초기화 대기 중 (15초)..."
sleep 15
# 6. DB 마이그레이션 실행
echo ""
echo "📦 DB 마이그레이션 실행 중..."
for sql_file in ./init-db/*.sql; do
if [ -f "$sql_file" ]; then
echo " 실행: $(basename "$sql_file")"
docker exec -i m-project-db psql -U "${POSTGRES_USER:-mproject}" "${POSTGRES_DB:-mproject}" < "$sql_file" 2>/dev/null || true
fi
done
# 7. 데이터베이스 복원 (백업 파일이 있는 경우)
BACKUP_FILE=$(ls -t backup_*.sql 2>/dev/null | head -1)
if [ -n "$BACKUP_FILE" ]; then
echo ""
read -p "📦 DB 백업 발견: $BACKUP_FILE - 복원하시겠습니까? (y/N) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "📦 데이터베이스 복원 중..."
docker exec -i m-project-db psql -U "${POSTGRES_USER:-mproject}" "${POSTGRES_DB:-mproject}" < "$BACKUP_FILE"
echo "✅ 데이터베이스 복원 완료"
fi
fi
# 8. 상태 확인
echo ""
echo "=========================================="
echo "📊 컨테이너 상태"
echo "=========================================="
docker-compose -f docker-compose.synology.yml ps
# 9. 헬스체크
echo ""
echo "🔍 서비스 확인 중..."
sleep 5
check_service() {
local name="$1"
local url="$2"
printf " %-20s " "$name"
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 "$url" 2>/dev/null || echo "000")
if [ "$status" -ge 200 ] && [ "$status" -lt 400 ]; then
echo "✅ OK ($status)"
else
echo "❌ FAIL ($status)"
fi
}
check_service "Backend API" "http://localhost:16000/api/health"
check_service "Frontend" "http://localhost:16080/"
echo ""
echo "=========================================="
echo "✅ 배포 완료!"
echo "=========================================="
echo ""
echo "접속 URL:"
echo " - 웹 UI: http://NAS_IP:16080"
echo " - API: http://NAS_IP:16000"
echo " - DB: NAS_IP:16432 (PostgreSQL)"
echo ""

View File

@@ -0,0 +1,80 @@
version: '3.8'
services:
# PostgreSQL 데이터베이스
db:
image: postgres:15-alpine
container_name: m-project-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-mproject}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-mproject}
TZ: Asia/Seoul
PGTZ: Asia/Seoul
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
ports:
- "16432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mproject}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- m-project-network
# FastAPI 백엔드
backend:
build: ./backend
container_name: m-project-backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-mproject}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-mproject}
SECRET_KEY: ${SECRET_KEY}
ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: 10080
ADMIN_USERNAME: ${ADMIN_USERNAME:-hyungi}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
TZ: Asia/Seoul
volumes:
- uploads:/app/uploads
ports:
- "16000:8000"
depends_on:
db:
condition: service_healthy
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- m-project-network
# Nginx 프론트엔드
nginx:
build: ./nginx
container_name: m-project-nginx
restart: unless-stopped
ports:
- "16080:80"
volumes:
- ./frontend:/usr/share/nginx/html:ro
- uploads:/app/uploads
depends_on:
- backend
networks:
- m-project-network
volumes:
postgres_data:
driver: local
uploads:
driver: local
networks:
m-project-network:
driver: bridge
name: m-project-network

83
deploy/package.sh Executable file
View File

@@ -0,0 +1,83 @@
#!/bin/bash
# =============================================================================
# M-Project 배포 패키지 생성 스크립트
# =============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DEPLOY_DIR="$SCRIPT_DIR"
PACKAGE_DIR="$DEPLOY_DIR/mproject-package"
echo "=========================================="
echo "M-Project 배포 패키지 생성"
echo "=========================================="
# 기존 패키지 삭제
rm -rf "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR"
# 1. Docker 설정 파일
echo "📦 Docker 설정 복사..."
cp "$DEPLOY_DIR/docker-compose.synology.yml" "$PACKAGE_DIR/docker-compose.yml"
cp "$DEPLOY_DIR/.env.synology" "$PACKAGE_DIR/.env.example"
cp "$DEPLOY_DIR/deploy.sh" "$PACKAGE_DIR/"
chmod +x "$PACKAGE_DIR/deploy.sh"
# 2. 데이터베이스 백업 생성
echo "📦 DB 백업 시도..."
if docker exec m-project-db pg_dump -U mproject mproject > "$PACKAGE_DIR/backup_$(date +%Y%m%d_%H%M%S).sql" 2>/dev/null; then
echo " ✅ DB 백업 완료"
else
echo " ⚠️ DB 백업 건너뜀 (컨테이너 미실행)"
rm -f "$PACKAGE_DIR"/backup_*.sql
fi
# 3. 소스 코드 복사
echo "📦 소스 코드 복사..."
# Backend
mkdir -p "$PACKAGE_DIR/backend"
rsync -a --exclude='__pycache__' --exclude='.git' --exclude='venv' --exclude='*.pyc' \
"$PROJECT_DIR/backend/" "$PACKAGE_DIR/backend/"
# Frontend
mkdir -p "$PACKAGE_DIR/frontend"
rsync -a --exclude='.git' --exclude='uploads' \
"$PROJECT_DIR/frontend/" "$PACKAGE_DIR/frontend/"
# Nginx
mkdir -p "$PACKAGE_DIR/nginx"
rsync -a "$PROJECT_DIR/nginx/" "$PACKAGE_DIR/nginx/"
# 4. init-db 폴더 (마이그레이션 스크립트)
mkdir -p "$PACKAGE_DIR/init-db"
if [ -d "$PROJECT_DIR/backend/migrations" ]; then
cp "$PROJECT_DIR/backend/migrations"/*.sql "$PACKAGE_DIR/init-db/" 2>/dev/null || true
fi
# 5. 압축
echo "📦 압축 중..."
cd "$DEPLOY_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
tar -czf "mproject-deploy-$TIMESTAMP.tar.gz" -C "$DEPLOY_DIR" mproject-package
# 크기 확인
echo ""
echo "=========================================="
echo "✅ 패키지 생성 완료!"
echo "=========================================="
ls -lh "$DEPLOY_DIR/mproject-deploy-$TIMESTAMP.tar.gz"
echo ""
echo "파일: $DEPLOY_DIR/mproject-deploy-$TIMESTAMP.tar.gz"
echo ""
echo "Synology NAS로 전송:"
echo " scp $DEPLOY_DIR/mproject-deploy-$TIMESTAMP.tar.gz admin@NAS_IP:/volume1/docker/"
echo ""
echo "NAS에서 설치:"
echo " cd /volume1/docker/"
echo " tar -xzf mproject-deploy-$TIMESTAMP.tar.gz"
echo " cd mproject-package"
echo " cp .env.example .env && vi .env"
echo " bash deploy.sh"
echo ""

View File

@@ -339,30 +339,26 @@
async function loadProjects() { async function loadProjects() {
try { try {
// API에서 최신 프로젝트 데이터 가져오기 // API에서 최신 프로젝트 데이터 가져오기
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || (() => {
const response = await fetch(`${apiUrl}/projects/`, { const hostname = window.location.hostname;
headers: { const protocol = window.location.protocol;
'Authorization': `Bearer ${localStorage.getItem('access_token')}`, const port = window.location.port;
'Content-Type': 'application/json'
}
});
if (response.ok) { if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
projects = await response.json(); return `${protocol}//${hostname}:${port}/api`;
console.log('프로젝트 로드 완료:', projects.length, '개');
console.log('활성 프로젝트:', projects.filter(p => p.is_active).length, '개');
// localStorage에도 캐시 저장
localStorage.setItem('work-report-projects', JSON.stringify(projects));
} else {
console.error('프로젝트 로드 실패:', response.status);
// 실패 시 localStorage에서 로드
const saved = localStorage.getItem('work-report-projects');
if (saved) {
projects = JSON.parse(saved);
console.log('캐시에서 프로젝트 로드:', projects.length, '개');
} }
} if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/api';
})();
// ProjectsAPI 사용 (모든 프로젝트 로드)
projects = await ProjectsAPI.getAll(false);
console.log('프로젝트 로드 완료:', projects.length, '개');
console.log('활성 프로젝트:', projects.filter(p => p.is_active).length, '개');
// localStorage에도 캐시 저장
localStorage.setItem('work-report-projects', JSON.stringify(projects));
} catch (error) { } catch (error) {
console.error('프로젝트 로드 오류:', error); console.error('프로젝트 로드 오류:', error);
// 오류 시 localStorage에서 로드 // 오류 시 localStorage에서 로드

View File

@@ -271,14 +271,14 @@
</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>
@@ -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>
<!-- 숨겨진 입력 필드들 --> <!-- 숨겨진 입력 필드들 -->
@@ -882,13 +903,13 @@
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);
// 로딩 표시 // 로딩 표시
@@ -896,7 +917,7 @@
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;
@@ -930,11 +951,11 @@
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,31 +985,25 @@
// 사진 미리보기 업데이트 // 사진 미리보기 업데이트
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 {
photo1Container.classList.add('hidden');
}
// 두 번째 사진 if (currentPhotos[i]) {
if (currentPhotos[1]) { previewImg.src = currentPhotos[i];
document.getElementById('previewImg2').src = currentPhotos[1]; photoContainer.classList.remove('hidden');
photo2Container.classList.remove('hidden'); container.style.display = 'grid';
container.style.display = 'grid'; } else {
} else { photoContainer.classList.add('hidden');
photo2Container.classList.add('hidden'); }
} }
// 미리보기 컨테이너 표시/숨김 // 미리보기 컨테이너 표시/숨김
@@ -996,11 +1011,11 @@
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

@@ -731,6 +731,10 @@
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">
@@ -745,18 +749,28 @@
<!-- 기존 내용 --> <!-- 기존 내용 -->
<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>
<!-- 내용 --> <!-- 내용 -->
@@ -775,10 +789,28 @@
<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>
@@ -921,6 +953,150 @@
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

@@ -287,7 +287,19 @@
// 프로젝트 로드 // 프로젝트 로드
async function loadProjects() { async function loadProjects() {
try { try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
return `${protocol}//${hostname}:${port}/api`;
}
if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/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')}`,

File diff suppressed because it is too large Load Diff

View File

@@ -668,24 +668,24 @@
async function loadProjects() { async function loadProjects() {
console.log('🔄 프로젝트 로드 시작'); console.log('🔄 프로젝트 로드 시작');
try { try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || (() => {
const response = await fetch(`${apiUrl}/projects/`, { const hostname = window.location.hostname;
headers: { const protocol = window.location.protocol;
'Authorization': `Bearer ${localStorage.getItem('access_token')}`, const port = window.location.port;
'Content-Type': 'application/json'
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
return `${protocol}//${hostname}:${port}/api`;
} }
}); if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
console.log('📡 프로젝트 API 응답 상태:', response.status); }
return '/api';
if (response.ok) { })();
projects = await response.json(); // ProjectsAPI 사용 (모든 프로젝트 로드)
console.log('✅ 프로젝트 로드 성공:', projects.length, '개'); projects = await ProjectsAPI.getAll(false);
console.log('📋 프로젝트 목록:', projects); console.log(' 프로젝트 로드 성공:', projects.length, '개');
updateProjectFilter(); console.log('📋 프로젝트 목록:', projects);
} else { updateProjectFilter();
console.error('❌ 프로젝트 API 응답 실패:', response.status, response.statusText);
}
} catch (error) { } catch (error) {
console.error('❌ 프로젝트 로드 실패:', error); console.error('❌ 프로젝트 로드 실패:', error);
} }
@@ -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

@@ -172,13 +172,14 @@
} }
.collapse-content { .collapse-content {
max-height: 1000px; max-height: none;
overflow: hidden; overflow: visible;
transition: max-height 0.3s ease-out; transition: max-height 0.3s ease-out;
} }
.collapse-content.collapsed { .collapse-content.collapsed {
max-height: 0; max-height: 0;
overflow: hidden;
} }
/* 진행 중 카드 스타일 */ /* 진행 중 카드 스타일 */
@@ -425,6 +426,13 @@
let currentIssueId = null; let currentIssueId = null;
let currentTab = 'in_progress'; // 기본값: 진행 중 let currentTab = 'in_progress'; // 기본값: 진행 중
// 완료 반려 패턴 제거 (해결방안 표시용)
function cleanManagementComment(text) {
if (!text) return '';
// 기존 데이터에서 완료 반려 패턴 제거
return text.replace(/\[완료 반려[^\]]*\][^\n]*\n*/g, '').trim();
}
// API 로드 후 초기화 함수 // API 로드 후 초기화 함수
async function initializeManagement() { async function initializeManagement() {
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
@@ -465,66 +473,48 @@
// 프로젝트 로드 // 프로젝트 로드
async function loadProjects() { async function loadProjects() {
try { try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; // ProjectsAPI 사용 (모든 프로젝트 로드)
const response = await fetch(`${apiUrl}/projects/`, { projects = await ProjectsAPI.getAll(false);
headers: { updateProjectFilter();
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
projects = await response.json();
updateProjectFilter();
}
} catch (error) { } catch (error) {
console.error('프로젝트 로드 실패:', error); console.error('프로젝트 로드 실패:', error);
} }
} }
// 부적합 목록 로드 (관리자는 모든 부적합 조회) // 부적합 목록 로드 (관리함 API 사용)
async function loadIssues() { async function loadIssues() {
try { try {
let endpoint = '/api/issues/admin/all'; // ManagementAPI 사용
const managementIssues = await ManagementAPI.getAll();
const response = await fetch(endpoint, { console.log('🔍 관리함 이슈 로드 완료:', managementIssues.length, '개');
headers: { console.log('📊 상태별 분포:', {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`, in_progress: managementIssues.filter(i => i.review_status === 'in_progress').length,
'Content-Type': 'application/json' completed: managementIssues.filter(i => i.review_status === 'completed').length,
} other: managementIssues.filter(i => !['in_progress', 'completed'].includes(i.review_status)).length
}); });
if (response.ok) { // 수신함에서 넘어온 순서대로 No. 재할당 (reviewed_at 기준)
const allIssues = await response.json(); managementIssues.sort((a, b) => new Date(a.reviewed_at) - new Date(b.reviewed_at));
// 관리함에서는 진행 중(in_progress)과 완료됨(completed) 상태만 표시
let filteredIssues = allIssues.filter(issue =>
issue.review_status === 'in_progress' || issue.review_status === 'completed'
);
// 수신함에서 넘어온 순서대로 No. 재할당 (reviewed_at 기준) // 프로젝트별로 그룹화하여 No. 재할당
filteredIssues.sort((a, b) => new Date(a.reviewed_at) - new Date(b.reviewed_at)); const projectGroups = {};
managementIssues.forEach(issue => {
if (!projectGroups[issue.project_id]) {
projectGroups[issue.project_id] = [];
}
projectGroups[issue.project_id].push(issue);
});
// 프로젝트별로 그룹화하여 No. 재할당 // 프로젝트별로 순번 재할당
const projectGroups = {}; Object.keys(projectGroups).forEach(projectId => {
filteredIssues.forEach(issue => { projectGroups[projectId].forEach((issue, index) => {
if (!projectGroups[issue.project_id]) { issue.project_sequence_no = index + 1;
projectGroups[issue.project_id] = [];
}
projectGroups[issue.project_id].push(issue);
}); });
});
// 각 프로젝트별로 순번 재할당 issues = managementIssues;
Object.keys(projectGroups).forEach(projectId => { filterIssues();
projectGroups[projectId].forEach((issue, index) => {
issue.project_sequence_no = index + 1;
});
});
issues = filteredIssues;
filterIssues();
} else {
throw new Error('부적합 목록을 불러올 수 없습니다.');
}
} catch (error) { } catch (error) {
console.error('부적합 로드 실패:', error); console.error('부적합 로드 실패:', error);
alert('부적합 목록을 불러오는데 실패했습니다.'); alert('부적합 목록을 불러오는데 실패했습니다.');
@@ -585,9 +575,22 @@
function filterIssues() { function filterIssues() {
const projectFilter = document.getElementById('projectFilter').value; const projectFilter = document.getElementById('projectFilter').value;
console.log('🔍 필터링 시작:', {
currentTab: currentTab,
projectFilter: projectFilter,
totalIssues: issues.length
});
filteredIssues = issues.filter(issue => { filteredIssues = issues.filter(issue => {
// 현재 탭에 따른 상태 필터링 // 현재 탭에 따른 상태 필터링
if (issue.review_status !== currentTab) return false; let statusMatch = false;
if (currentTab === 'in_progress') {
statusMatch = issue.review_status === 'in_progress';
} else if (currentTab === 'completed') {
statusMatch = issue.review_status === 'completed';
}
if (!statusMatch) return false;
// 프로젝트 필터링 // 프로젝트 필터링
if (projectFilter && issue.project_id != projectFilter) return false; if (projectFilter && issue.project_id != projectFilter) return false;
@@ -595,6 +598,11 @@
return true; return true;
}); });
console.log('✅ 필터링 결과:', {
filteredCount: filteredIssues.length,
tab: currentTab
});
sortIssues(); sortIssues();
displayIssues(); displayIssues();
updateStatistics(); // 통계 업데이트 추가 updateStatistics(); // 통계 업데이트 추가
@@ -651,6 +659,8 @@
const issues = groupedByDate[date]; const issues = groupedByDate[date];
const groupId = `group-${date.replace(/\./g, '-')}`; const groupId = `group-${date.replace(/\./g, '-')}`;
console.log(`📅 날짜 그룹 [${date}]: ${issues.length}개 이슈`);
return ` return `
<div class="date-group"> <div class="date-group">
<div class="date-header flex items-center justify-between p-3 bg-gray-50 rounded-lg" <div class="date-header flex items-center justify-between p-3 bg-gray-50 rounded-lg"
@@ -675,6 +685,18 @@
}).join(''); }).join('');
container.innerHTML = dateGroups; container.innerHTML = dateGroups;
// 모든 날짜 그룹을 기본적으로 펼쳐진 상태로 초기화
Object.keys(groupedByDate).forEach(date => {
const groupId = `group-${date.replace(/\./g, '-')}`;
const content = document.getElementById(groupId);
const icon = document.getElementById(`icon-${groupId}`);
if (content && icon) {
content.classList.remove('collapsed');
icon.style.transform = 'rotate(0deg)';
}
});
} }
// 이슈 행 생성 함수 // 이슈 행 생성 함수
@@ -782,6 +804,9 @@
<button onclick="saveIssueChanges(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"> <button onclick="saveIssueChanges(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-save mr-1"></i>저장 <i class="fas fa-save mr-1"></i>저장
</button> </button>
<button onclick="confirmDeleteIssue(${issue.id})" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>삭제
</button>
<button onclick="confirmCompletion(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"> <button onclick="confirmCompletion(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check mr-1"></i>완료처리 <i class="fas fa-check mr-1"></i>완료처리
</button> </button>
@@ -841,10 +866,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>
@@ -852,9 +894,9 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lightbulb text-yellow-500 mr-1"></i>해결방안 <i class="fas fa-lightbulb text-yellow-500 mr-1"></i>해결방안 (확정)
</label> </label>
<textarea id="solution_${issue.id}" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none ${isPendingCompletion ? 'bg-gray-100 cursor-not-allowed' : ''}" placeholder="해결 방안을 입력하세요..." ${isPendingCompletion ? 'readonly' : ''}>${issue.solution || ''}</textarea> <textarea id="management_comment_${issue.id}" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none ${isPendingCompletion ? 'bg-gray-100 cursor-not-allowed' : ''}" placeholder="확정된 해결 방안을 입력하세요..." ${isPendingCompletion ? 'readonly' : ''}>${cleanManagementComment(issue.management_comment)}</textarea>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -893,11 +935,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>
@@ -987,11 +1045,11 @@
관리 정보 관리 정보
</h4> </h4>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div><span class="font-medium text-blue-700">해결방안:</span> <span class="text-blue-900">${issue.solution || '-'}</span></div> <div><span class="font-medium text-blue-700">해결방안 (확정):</span> <span class="text-blue-900">${cleanManagementComment(issue.management_comment) || '-'}</span></div>
<div><span class="font-medium text-blue-700">담당부서:</span> <span class="text-blue-900">${getDepartmentText(issue.responsible_department) || '-'}</span></div> <div><span class="font-medium text-blue-700">담당부서:</span> <span class="text-blue-900">${getDepartmentText(issue.responsible_department) || '-'}</span></div>
<div><span class="font-medium text-blue-700">담당자:</span> <span class="text-blue-900">${issue.responsible_person || '-'}</span></div> <div><span class="font-medium text-blue-700">담당자:</span> <span class="text-blue-900">${issue.responsible_person || '-'}</span></div>
<div><span class="font-medium text-blue-700">원인부서:</span> <span class="text-blue-900">${getDepartmentText(issue.cause_department) || '-'}</span></div> <div><span class="font-medium text-blue-700">원인부서:</span> <span class="text-blue-900">${getDepartmentText(issue.cause_department) || '-'}</span></div>
<div><span class="font-medium text-blue-700">관리 코멘트:</span> <span class="text-blue-900">${issue.management_comment || '-'}</span></div> <div><span class="font-medium text-blue-700">관리 코멘트:</span> <span class="text-blue-900">${cleanManagementComment(issue.management_comment) || '-'}</span></div>
</div> </div>
</div> </div>
@@ -1005,20 +1063,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>
@@ -1042,10 +1107,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>
`; `;
@@ -1277,7 +1359,7 @@
try { try {
// 편집된 필드들의 값 수집 // 편집된 필드들의 값 수집
const updates = {}; const updates = {};
const fields = ['solution', 'responsible_department', 'responsible_person', 'expected_completion_date', 'cause_department', 'management_comment']; const fields = ['management_comment', 'responsible_department', 'responsible_person', 'expected_completion_date', 'cause_department'];
fields.forEach(field => { fields.forEach(field => {
const element = document.getElementById(`${field}_${issueId}`); const element = document.getElementById(`${field}_${issueId}`);
@@ -1392,10 +1474,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>
@@ -1406,8 +1505,8 @@
<div class="space-y-3"> <div class="space-y-3">
<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>
<textarea id="modal_solution" rows="3" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">${issue.solution || ''}</textarea> <textarea id="modal_management_comment" rows="3" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="확정된 해결 방안을 입력하세요...">${cleanManagementComment(issue.management_comment)}</textarea>
</div> </div>
<div> <div>
@@ -1440,17 +1539,41 @@
<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>
<textarea id="modal_management_comment" rows="3" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">${issue.management_comment || ''}</textarea> <textarea id="modal_management_comment" rows="3" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">${cleanManagementComment(issue.management_comment)}</textarea>
</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>
@@ -1465,7 +1588,7 @@
try { try {
// 편집된 필드들의 값 수집 // 편집된 필드들의 값 수집
const updates = {}; const updates = {};
const fields = ['solution', 'responsible_department', 'responsible_person', 'expected_completion_date', 'cause_department', 'management_comment']; const fields = ['management_comment', 'responsible_department', 'responsible_person', 'expected_completion_date', 'cause_department'];
fields.forEach(field => { fields.forEach(field => {
const element = document.getElementById(`modal_${field}`); const element = document.getElementById(`modal_${field}`);
@@ -1478,11 +1601,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);
@@ -1859,10 +1991,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>
@@ -1872,8 +2021,8 @@
<h4 class="font-semibold text-green-800 mb-3">관리 정보</h4> <h4 class="font-semibold text-green-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-1">해결방안</label> <label class="block text-sm font-medium text-gray-700 mb-1">해결방안 (확정)</label>
<textarea id="edit-solution-${issue.id}" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm resize-none" placeholder="해결 방안을 입력하세요...">${issue.solution || ''}</textarea> <textarea id="edit-management-comment-${issue.id}" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm resize-none" placeholder="확정된 해결 방안을 입력하세요...">${cleanManagementComment(issue.management_comment)}</textarea>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
@@ -1903,7 +2052,7 @@
</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">관리 코멘트</label>
<textarea id="edit-management-comment-${issue.id}" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm resize-none" placeholder="관리 코멘트를 입력하세요...">${issue.management_comment || ''}</textarea> <textarea id="edit-management-comment-${issue.id}" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm resize-none" placeholder="관리 코멘트를 입력하세요...">${cleanManagementComment(issue.management_comment)}</textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -1913,32 +2062,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>
@@ -1964,6 +2138,9 @@
<button onclick="saveIssueFromModal(${issue.id})" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"> <button onclick="saveIssueFromModal(${issue.id})" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-save mr-2"></i>저장 <i class="fas fa-save mr-2"></i>저장
</button> </button>
<button onclick="confirmDeleteIssue(${issue.id})" class="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-2"></i>삭제
</button>
<button onclick="saveAndCompleteIssue(${issue.id})" class="px-6 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"> <button onclick="saveAndCompleteIssue(${issue.id})" class="px-6 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check-circle mr-2"></i>최종확인 <i class="fas fa-check-circle mr-2"></i>최종확인
</button> </button>
@@ -1982,8 +2159,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 = '';
@@ -2016,41 +2194,47 @@
const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim(); const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim();
const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim(); const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim();
const category = document.getElementById(`edit-category-${issueId}`).value; const category = document.getElementById(`edit-category-${issueId}`).value;
const solution = document.getElementById(`edit-solution-${issueId}`).value.trim(); const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim();
const department = document.getElementById(`edit-department-${issueId}`).value; const department = document.getElementById(`edit-department-${issueId}`).value;
const person = document.getElementById(`edit-person-${issueId}`).value.trim(); const person = document.getElementById(`edit-person-${issueId}`).value.trim();
const date = document.getElementById(`edit-date-${issueId}`).value; const date = document.getElementById(`edit-date-${issueId}`).value;
const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value; const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value;
const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim();
// 완료 신청 정보 (완료 대기 상태일 때만) // 완료 신청 정보 (완료 대기 상태일 때만)
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,
type: file.type,
lastModified: file.lastModified
});
const base64 = await fileToBase64(file); console.log(`🔍 총 ${maxPhotos}개의 완료 사진 업로드 시작`);
console.log('🔍 Base64 변환 완료 - 전체 길이:', base64.length);
console.log('🔍 Base64 헤더:', base64.substring(0, 50));
completionPhoto = base64.split(',')[1]; // Base64 데이터만 추출 for (let i = 0; i < maxPhotos; i++) {
console.log('🔍 헤더 제거 후 길이:', completionPhoto.length); const file = files[i];
console.log('🔍 전송할 Base64 시작 부분:', completionPhoto.substring(0, 50)); 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) { } catch (error) {
console.error('파일 변환 오류:', error); console.error('파일 변환 오류:', error);
alert('완료 사진 업로드 중 오류가 발생했습니다.'); alert('완료 사진 업로드 중 오류가 발생했습니다.');
@@ -2068,20 +2252,20 @@
const requestBody = { const requestBody = {
final_description: combinedDescription, final_description: combinedDescription,
final_category: category, final_category: category,
solution: solution || null, management_comment: managementComment || null,
responsible_department: department || null, responsible_department: department || null,
responsible_person: person || null, responsible_person: person || null,
expected_completion_date: date || null, expected_completion_date: date || null,
cause_department: causeDepartment || null, cause_department: causeDepartment || null
management_comment: managementComment || null
}; };
// 완료 신청 정보가 있으면 추가 // 완료 신청 정보가 있으면 추가
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 {
@@ -2267,7 +2451,7 @@
<div class="bg-green-50 p-4 rounded-lg"> <div class="bg-green-50 p-4 rounded-lg">
<h4 class="font-semibold text-green-800 mb-2">관리 정보</h4> <h4 class="font-semibold text-green-800 mb-2">관리 정보</h4>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div><span class="font-medium">해결방안:</span> ${issue.solution || '-'}</div> <div><span class="font-medium">해결방안 (확정):</span> ${cleanManagementComment(issue.management_comment) || '-'}</div>
<div><span class="font-medium">담당부서:</span> ${issue.responsible_department || '-'}</div> <div><span class="font-medium">담당부서:</span> ${issue.responsible_department || '-'}</div>
<div><span class="font-medium">담당자:</span> ${issue.responsible_person || '-'}</div> <div><span class="font-medium">담당자:</span> ${issue.responsible_person || '-'}</div>
<div><span class="font-medium">조치예상일:</span> ${issue.expected_completion_date ? new Date(issue.expected_completion_date).toLocaleDateString('ko-KR') : '-'}</div> <div><span class="font-medium">조치예상일:</span> ${issue.expected_completion_date ? new Date(issue.expected_completion_date).toLocaleDateString('ko-KR') : '-'}</div>
@@ -2344,12 +2528,11 @@
const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim(); const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim();
const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim(); const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim();
const category = document.getElementById(`edit-category-${issueId}`).value; const category = document.getElementById(`edit-category-${issueId}`).value;
const solution = document.getElementById(`edit-solution-${issueId}`).value.trim(); const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim();
const department = document.getElementById(`edit-department-${issueId}`).value; const department = document.getElementById(`edit-department-${issueId}`).value;
const person = document.getElementById(`edit-person-${issueId}`).value.trim(); const person = document.getElementById(`edit-person-${issueId}`).value.trim();
const date = document.getElementById(`edit-date-${issueId}`).value; const date = document.getElementById(`edit-date-${issueId}`).value;
const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value; const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value;
const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim();
// 완료 신청 정보 (완료 대기 상태일 때만) // 완료 신청 정보 (완료 대기 상태일 때만)
const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`); const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`);
@@ -2396,12 +2579,11 @@
const requestBody = { const requestBody = {
final_description: combinedDescription, final_description: combinedDescription,
final_category: category, final_category: category,
solution: solution || null, management_comment: managementComment || null,
responsible_department: department || null, responsible_department: department || null,
responsible_person: person || null, responsible_person: person || null,
expected_completion_date: date || null, expected_completion_date: date || null,
cause_department: causeDepartment || null, cause_department: causeDepartment || null,
management_comment: managementComment || null,
review_status: 'completed' // 완료 상태로 변경 review_status: 'completed' // 완료 상태로 변경
}; };
@@ -2466,6 +2648,75 @@
alert('완료 처리 중 오류가 발생했습니다.'); alert('완료 처리 중 오류가 발생했습니다.');
} }
} }
// 삭제 확인 다이얼로그
function confirmDeleteIssue(issueId) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60]';
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>
<strong class="text-red-600">삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다.</strong>
</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 transition-colors">
취소
</button>
<button onclick="handleDeleteIssueFromManagement(${issueId})"
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>삭제
</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
// 삭제 처리 함수
async function handleDeleteIssueFromManagement(issueId) {
try {
const response = await fetch(`/api/issues/${issueId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
alert('부적합이 삭제되었습니다.\n삭제 로그가 기록되었습니다.');
// 모달들 닫기
const deleteModal = document.querySelector('.fixed');
if (deleteModal) deleteModal.remove();
closeIssueEditModal();
// 페이지 새로고침
initializeManagement();
} else {
const error = await response.json();
alert(`삭제 실패: ${error.detail || '알 수 없는 오류'}`);
}
} catch (error) {
console.error('삭제 오류:', error);
alert('삭제 중 오류가 발생했습니다: ' + error.message);
}
}
</script> </script>
<!-- 추가 정보 입력 모달 --> <!-- 추가 정보 입력 모달 -->

View File

@@ -37,6 +37,14 @@
.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="previewBtn"
onclick="loadPreview()"
class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
<i class="fas fa-eye mr-2"></i>미리보기
</button>
<button id="generateReportBtn" <button id="generateReportBtn"
onclick="generateDailyReport()" onclick="generateDailyReport()"
class="px-6 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 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">
disabled>
<i class="fas fa-download mr-2"></i>일일보고서 생성 <i class="fas fa-download mr-2"></i>일일보고서 생성
</button> </button>
<button id="previewStatsBtn"
onclick="toggleStatsPreview()"
class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors hidden">
<i class="fas fa-chart-bar mr-2"></i>통계 미리보기
</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,6 +190,7 @@
<script> <script>
let projects = []; let projects = [];
let selectedProjectId = null; let selectedProjectId = null;
let previewData = null;
// 페이지 초기화 // 페이지 초기화
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -213,7 +234,19 @@
// 프로젝트 목록 로드 // 프로젝트 목록 로드
async function loadProjects() { async function loadProjects() {
try { try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
return `${protocol}//${hostname}:${port}/api`;
}
if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/api';
})();
const response = await fetch(`${apiUrl}/projects/`, { const response = await fetch(`${apiUrl}/projects/`, {
headers: { headers: {
@@ -255,50 +288,148 @@
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 || (() => {
const response = await fetch(`${apiUrl}/management/stats?project_id=${projectId}`, { const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
return `${protocol}//${hostname}:${port}/api`;
}
if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/api';
})();
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,13 +439,31 @@
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;
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>생성 중...'; button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>생성 중...';
button.disabled = true; button.disabled = true;
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
return `${protocol}//${hostname}:${port}/api`;
}
if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/api';
})();
const response = await fetch(`${apiUrl}/reports/daily-export`, { const response = await fetch(`${apiUrl}/reports/daily-export`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -333,19 +482,24 @@
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);

View File

@@ -6,8 +6,8 @@ const API_BASE_URL = (() => {
console.log('🔧 API URL 생성 - hostname:', hostname, 'protocol:', protocol, 'port:', port); console.log('🔧 API URL 생성 - hostname:', hostname, 'protocol:', protocol, 'port:', port);
// 로컬 환경 (포트 있음) // 로컬 환경 (localhost 또는 127.0.0.1이고 포트 있음)
if (port === '16080') { if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
const url = `${protocol}//${hostname}:${port}/api`; const url = `${protocol}//${hostname}:${port}/api`;
console.log('🏠 로컬 환경 URL:', url); console.log('🏠 로컬 환경 URL:', url);
return url; return url;
@@ -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/', {
@@ -253,6 +256,41 @@ const DailyWorkAPI = {
} }
}; };
// Management API
const ManagementAPI = {
getAll: () => apiRequest('/management/'),
update: (issueId, updateData) => apiRequest(`/management/${issueId}`, {
method: 'PUT',
body: JSON.stringify(updateData)
}),
updateAdditionalInfo: (issueId, additionalInfo) => apiRequest(`/management/${issueId}/additional-info`, {
method: 'PUT',
body: JSON.stringify(additionalInfo)
})
};
// Inbox API
const InboxAPI = {
getAll: () => apiRequest('/inbox/'),
review: (issueId, reviewData) => apiRequest(`/inbox/${issueId}/review`, {
method: 'PUT',
body: JSON.stringify(reviewData)
}),
dispose: (issueId, disposeData) => apiRequest(`/inbox/${issueId}/dispose`, {
method: 'PUT',
body: JSON.stringify(disposeData)
}),
updateAdditionalInfo: (issueId, additionalInfo) => apiRequest(`/inbox/${issueId}/additional-info`, {
method: 'PUT',
body: JSON.stringify(additionalInfo)
})
};
// Reports API // Reports API
const ReportsAPI = { const ReportsAPI = {
getSummary: (startDate, endDate) => apiRequest('/reports/summary', { getSummary: (startDate, endDate) => apiRequest('/reports/summary', {

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',

View File

@@ -46,7 +46,13 @@ class PagePermissionManager {
try { try {
// API에서 사용자별 페이지 권한 가져오기 // API에서 사용자별 페이지 권한 가져오기
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || (() => {
const hostname = window.location.hostname;
if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/api';
})();
const response = await fetch(`${apiUrl}/users/${this.currentUser.id}/page-permissions`, { const response = await fetch(`${apiUrl}/users/${this.currentUser.id}/page-permissions`, {
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}` 'Authorization': `Bearer ${localStorage.getItem('access_token')}`
@@ -199,7 +205,19 @@ class PagePermissionManager {
} }
try { try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
return `${protocol}//${hostname}:${port}/api`;
}
if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/api';
})();
const response = await fetch(`${apiUrl}/page-permissions/grant`, { const response = await fetch(`${apiUrl}/page-permissions/grant`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -232,7 +250,19 @@ class PagePermissionManager {
*/ */
async getUserPagePermissions(userId) { async getUserPagePermissions(userId) {
try { try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api'; const apiUrl = window.API_BASE_URL || (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
return `${protocol}//${hostname}:${port}/api`;
}
if (hostname === 'm.hyungi.net') {
return 'https://m-api.hyungi.net/api';
}
return '/api';
})();
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, { const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}` 'Authorization': `Bearer ${localStorage.getItem('access_token')}`

105
restore_script.sh Executable file
View File

@@ -0,0 +1,105 @@
#!/bin/bash
# M 프로젝트 복구 스크립트
# 사용법: ./restore_script.sh /path/to/backup/folder
set -e
if [ -z "$1" ]; then
echo "❌ 사용법: $0 <백업폴더경로>"
echo "예시: $0 /Users/hyungi/M-Project/backups/20251108_152538"
exit 1
fi
BACKUP_FOLDER="$1"
if [ ! -d "$BACKUP_FOLDER" ]; then
echo "❌ 백업 폴더가 존재하지 않습니다: $BACKUP_FOLDER"
exit 1
fi
echo "🔄 M 프로젝트 복구 시작"
echo "📁 백업 폴더: $BACKUP_FOLDER"
# 백업 정보 확인
if [ -f "$BACKUP_FOLDER/backup_info.txt" ]; then
echo "📋 백업 정보:"
cat "$BACKUP_FOLDER/backup_info.txt"
echo ""
fi
read -p "⚠️ 기존 데이터가 모두 삭제됩니다. 계속하시겠습니까? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "❌ 복구가 취소되었습니다."
exit 1
fi
# 1. 서비스 중지
echo "🛑 서비스 중지 중..."
cd /Users/hyungi/M-Project
docker-compose down
# 2. 기존 볼륨 삭제
echo "🗑️ 기존 볼륨 삭제 중..."
docker volume rm m-project_postgres_data m-project_uploads 2>/dev/null || true
# 3. 데이터베이스 컨테이너만 시작
echo "🚀 데이터베이스 컨테이너 시작 중..."
docker-compose up -d db
# 데이터베이스 준비 대기
echo "⏳ 데이터베이스 준비 대기 중..."
sleep 10
# 4. 데이터베이스 복구
if [ -f "$BACKUP_FOLDER/database_backup.sql" ]; then
echo "📊 데이터베이스 복구 중..."
docker exec -i m-project-db psql -U mproject mproject < "$BACKUP_FOLDER/database_backup.sql"
echo "✅ 데이터베이스 복구 완료"
else
echo "❌ 데이터베이스 백업 파일을 찾을 수 없습니다."
fi
# 5. Docker 볼륨 복구
if [ -f "$BACKUP_FOLDER/postgres_volume.tar.gz" ]; then
echo "💾 PostgreSQL 볼륨 복구 중..."
docker run --rm -v m-project_postgres_data:/data -v "$BACKUP_FOLDER":/backup alpine tar xzf /backup/postgres_volume.tar.gz -C /data
echo "✅ PostgreSQL 볼륨 복구 완료"
fi
if [ -f "$BACKUP_FOLDER/uploads_volume.tar.gz" ]; then
echo "📁 업로드 볼륨 복구 중..."
docker run --rm -v m-project_uploads:/data -v "$BACKUP_FOLDER":/backup alpine tar xzf /backup/uploads_volume.tar.gz -C /data
echo "✅ 업로드 볼륨 복구 완료"
fi
# 6. 설정 파일 복구
if [ -f "$BACKUP_FOLDER/docker-compose.yml" ]; then
echo "⚙️ 설정 파일 복구 중..."
cp "$BACKUP_FOLDER/docker-compose.yml" ./
if [ -d "$BACKUP_FOLDER/nginx" ]; then
cp -r "$BACKUP_FOLDER/nginx/" ./
fi
if [ -d "$BACKUP_FOLDER/migrations" ]; then
cp -r "$BACKUP_FOLDER/migrations/" ./backend/
fi
echo "✅ 설정 파일 복구 완료"
fi
# 7. 전체 서비스 시작
echo "🚀 전체 서비스 시작 중..."
docker-compose up -d
# 8. 서비스 상태 확인
echo "⏳ 서비스 시작 대기 중..."
sleep 15
echo "🔍 서비스 상태 확인 중..."
docker-compose ps
echo "🎉 복구 완료!"
echo "🌐 프론트엔드: http://localhost:16080"
echo "🔗 백엔드 API: http://localhost:16000"
echo "📊 데이터베이스: localhost:16432"

52
setup_auto_backup.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# M 프로젝트 자동 백업 설정 스크립트
echo "🔧 M 프로젝트 자동 백업 설정"
# 현재 crontab 백업
crontab -l > /tmp/current_crontab 2>/dev/null || touch /tmp/current_crontab
# M 프로젝트 백업 작업이 이미 있는지 확인
if grep -q "M-Project backup" /tmp/current_crontab; then
echo "⚠️ M 프로젝트 백업 작업이 이미 설정되어 있습니다."
echo "기존 설정:"
grep "M-Project backup" /tmp/current_crontab
read -p "기존 설정을 덮어쓰시겠습니까? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "❌ 설정이 취소되었습니다."
exit 1
fi
# 기존 M 프로젝트 백업 작업 제거
grep -v "M-Project backup" /tmp/current_crontab > /tmp/new_crontab
mv /tmp/new_crontab /tmp/current_crontab
fi
# 새로운 백업 작업 추가
cat >> /tmp/current_crontab << 'EOF'
# M-Project backup - 매일 오후 9시에 실행
0 21 * * * /Users/hyungi/M-Project/backup_script.sh >> /Users/hyungi/M-Project/backup.log 2>&1
# M-Project backup - 매주 일요일 오후 9시 30분에 전체 백업 (추가 보안)
30 21 * * 0 /Users/hyungi/M-Project/backup_script.sh >> /Users/hyungi/M-Project/backup.log 2>&1
EOF
# 새로운 crontab 적용
crontab /tmp/current_crontab
# 정리
rm /tmp/current_crontab
echo "✅ 자동 백업 설정 완료!"
echo ""
echo "📅 백업 스케줄:"
echo " - 매일 오후 9시: 자동 백업"
echo " - 매주 일요일 오후 9시 30분: 추가 백업"
echo ""
echo "📋 현재 crontab 설정:"
crontab -l | grep -A2 -B2 "M-Project"
echo ""
echo "📄 백업 로그 위치: /Users/hyungi/M-Project/backup.log"
echo "📁 백업 저장 위치: /Users/hyungi/M-Project/backups/"