Compare commits
11 Commits
5aad973028
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebeaf1008 | ||
| ef5c5e63cb | |||
| c4af58d849 | |||
| 61682efb33 | |||
|
|
a820a164cb | ||
|
|
86a6d21a08 | ||
|
|
1299ac261c | ||
|
|
637b690eda | ||
|
|
2fc7d4bc2c | ||
|
|
d1ed53cbd7 | ||
|
|
58156da987 |
240
DEPLOYMENT_GUIDE_20251028.md
Normal file
240
DEPLOYMENT_GUIDE_20251028.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# 배포 가이드 - 2025.10.28 업데이트
|
||||
|
||||
## 📋 **변경사항 요약**
|
||||
|
||||
### 🎯 **주요 기능 개선**
|
||||
- **보고서 시스템** 구현 (일일/주간/월간 보고서)
|
||||
- **품질팀용 일일보고서 엑셀 내보내기** 기능
|
||||
- **Project 모델 속성명 수정** (name → project_name)
|
||||
- **API URL 절대 경로 사용** 개선
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ **데이터베이스 변경사항**
|
||||
|
||||
### **⚠️ 중요: 스키마 불일치 수정**
|
||||
- `Project` 모델에서 `name` 속성이 `project_name`으로 변경됨
|
||||
- 백엔드 코드에서 `project.name` → `project.project_name` 수정 필요
|
||||
|
||||
### **새로운 의존성**
|
||||
```txt
|
||||
# backend/requirements.txt에 추가됨
|
||||
openpyxl==3.1.2 # 엑셀 파일 생성용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **백엔드 변경사항**
|
||||
|
||||
### **1. 스키마 업데이트**
|
||||
- `backend/database/schemas.py`
|
||||
- `DailyReportRequest` 클래스 추가
|
||||
- `DailyReportStats` 클래스 추가
|
||||
|
||||
### **2. API 엔드포인트 추가**
|
||||
- `backend/routers/reports.py`
|
||||
- `POST /api/reports/daily-export` 엔드포인트 추가
|
||||
- `backend/routers/management.py`
|
||||
- `GET /api/management/stats` 엔드포인트 추가
|
||||
|
||||
### **3. 중요 수정사항**
|
||||
- `backend/routers/reports.py`에서 `project.name` → `project.project_name` 수정 (3곳)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **프론트엔드 변경사항**
|
||||
|
||||
### **1. 새로운 페이지 추가**
|
||||
- `frontend/reports.html` - 보고서 메인 페이지
|
||||
- `frontend/reports-daily.html` - 일일보고서 생성 페이지
|
||||
- `frontend/reports-weekly.html` - 주간보고서 페이지 (준비중)
|
||||
- `frontend/reports-monthly.html` - 월간보고서 페이지 (준비중)
|
||||
|
||||
### **2. 공통 헤더 개선**
|
||||
- `frontend/static/js/components/common-header.js`
|
||||
- 보고서 서브메뉴 추가 (일일/주간/월간)
|
||||
|
||||
### **3. API URL 절대 경로 수정**
|
||||
- 모든 프론트엔드 파일에서 `window.API_BASE_URL` 사용
|
||||
- 상대 경로 `/api/` → 절대 경로 `http://localhost:16080/api` 변경
|
||||
|
||||
### **4. 캐시 무효화**
|
||||
- `frontend/sw.js` 버전 업데이트 (`v1.0.0` → `v1.0.1`)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **배포 절차**
|
||||
|
||||
### **1. 사전 준비**
|
||||
```bash
|
||||
# 1. 현재 데이터베이스 백업
|
||||
docker-compose exec postgres pg_dump -U postgres -d m_project > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# 2. Git 최신 코드 pull
|
||||
git pull origin master
|
||||
```
|
||||
|
||||
### **2. 백엔드 배포**
|
||||
```bash
|
||||
# 1. Docker 컨테이너 중지
|
||||
docker-compose down
|
||||
|
||||
# 2. 이미지 재빌드 (새로운 의존성 포함)
|
||||
docker-compose build backend
|
||||
|
||||
# 3. 컨테이너 시작
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 백엔드 로그 확인
|
||||
docker-compose logs backend --tail=20
|
||||
```
|
||||
|
||||
### **3. 프론트엔드 배포**
|
||||
```bash
|
||||
# 1. Nginx 재시작 (캐시 무효화)
|
||||
docker-compose restart nginx
|
||||
|
||||
# 2. 브라우저 캐시 강제 새로고침 안내
|
||||
# 사용자들에게 Ctrl+Shift+F5 또는 Cmd+Shift+R 안내
|
||||
```
|
||||
|
||||
### **4. 배포 후 검증**
|
||||
```bash
|
||||
# 1. 백엔드 상태 확인
|
||||
docker-compose logs backend --tail=20
|
||||
|
||||
# 2. 새로운 API 엔드포인트 테스트
|
||||
curl -X GET "http://localhost:16080/api/management/stats?project_id=1" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# 3. 엑셀 내보내기 테스트
|
||||
curl -X POST "http://localhost:16080/api/reports/daily-export" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"project_id": 1}' \
|
||||
--output test_report.xlsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ **기능 테스트 체크리스트**
|
||||
|
||||
### **보고서 시스템 테스트**
|
||||
- [ ] 보고서 메인 페이지 접근 확인
|
||||
- [ ] 일일보고서 페이지 로드 확인
|
||||
- [ ] 프로젝트 목록 정상 표시 확인
|
||||
- [ ] 프로젝트 선택 시 통계 표시 확인
|
||||
- [ ] 엑셀 파일 다운로드 정상 작동 확인
|
||||
- [ ] 엑셀 파일 내용 및 형식 확인
|
||||
|
||||
### **기존 기능 회귀 테스트**
|
||||
- [ ] 대시보드 정상 로드 확인
|
||||
- [ ] 관리함 정상 작동 확인
|
||||
- [ ] 수신함 정상 작동 확인
|
||||
- [ ] 일일 공수 정상 작동 확인
|
||||
|
||||
---
|
||||
|
||||
## 🔍 **트러블슈팅**
|
||||
|
||||
### **일반적인 문제들**
|
||||
|
||||
#### **1. Project.name 속성 오류**
|
||||
```bash
|
||||
# 증상: "Project object has no attribute 'name'" 오류
|
||||
# 원인: 백엔드에서 project.name 사용
|
||||
# 해결: 백엔드 재시작 후 확인
|
||||
docker-compose restart backend
|
||||
docker-compose logs backend --tail=20
|
||||
```
|
||||
|
||||
#### **2. 프로젝트 목록 표시 안됨**
|
||||
```bash
|
||||
# 증상: 드롭다운에 프로젝트가 나타나지 않음
|
||||
# 원인: API 경로 문제 또는 캐시 문제
|
||||
# 해결:
|
||||
# 1. 브라우저 강제 새로고침 (Ctrl+Shift+F5)
|
||||
# 2. 개발자 도구에서 네트워크 탭 확인
|
||||
# 3. API 호출 URL이 올바른지 확인
|
||||
```
|
||||
|
||||
#### **3. 엑셀 파일 생성 실패**
|
||||
```bash
|
||||
# 증상: 500 Internal Server Error
|
||||
# 원인: openpyxl 모듈 누락
|
||||
# 해결: 백엔드 이미지 재빌드
|
||||
docker-compose build backend
|
||||
docker-compose up -d backend
|
||||
```
|
||||
|
||||
#### **4. 공통 헤더 로드 실패**
|
||||
```bash
|
||||
# 증상: TypeError: window.commonHeader.init is not a function
|
||||
# 원인: 스크립트 로드 순서 문제
|
||||
# 해결: 페이지 새로고침 또는 캐시 클리어
|
||||
```
|
||||
|
||||
#### **5. API 연결 실패 (CORS 오류)**
|
||||
```bash
|
||||
# 증상: "Fetch API cannot load http://localhost/api/"
|
||||
# 원인: API URL 설정 문제
|
||||
# 해결:
|
||||
# 1. 브라우저 캐시 클리어
|
||||
# 2. 서비스 워커 캐시 무효화 확인
|
||||
# 3. nginx 재시작
|
||||
docker-compose restart nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **성능 모니터링**
|
||||
|
||||
### **모니터링 포인트**
|
||||
```bash
|
||||
# 1. 백엔드 메모리 사용량 확인
|
||||
docker stats m-project-backend
|
||||
|
||||
# 2. 데이터베이스 연결 상태 확인
|
||||
docker-compose exec postgres psql -U postgres -d m_project -c "SELECT count(*) FROM pg_stat_activity;"
|
||||
|
||||
# 3. 엑셀 생성 성능 확인 (대용량 데이터)
|
||||
time curl -X POST "http://localhost:16080/api/reports/daily-export" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"project_id": 1}' \
|
||||
--output performance_test.xlsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 **지원 연락처**
|
||||
- 개발자: [개발자 연락처]
|
||||
- 배포 담당자: [배포 담당자 연락처]
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 주의사항:**
|
||||
1. **반드시 데이터베이스 백업 후 배포 진행**
|
||||
2. **백엔드 이미지 재빌드 필수** (새로운 의존성 포함)
|
||||
3. **브라우저 캐시 무효화 안내** 필요
|
||||
4. **Project.name → project_name 수정사항 확인**
|
||||
5. **배포 후 보고서 기능 전체 테스트 필수**
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **롤백 절차**
|
||||
문제 발생 시 다음 순서로 롤백:
|
||||
|
||||
```bash
|
||||
# 1. 이전 버전으로 코드 롤백
|
||||
git checkout [이전_커밋_해시]
|
||||
|
||||
# 2. 백엔드 이미지 재빌드
|
||||
docker-compose build backend
|
||||
|
||||
# 3. 컨테이너 재시작
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 데이터베이스 롤백 (필요시)
|
||||
# 백업 파일에서 복원
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -95,7 +95,10 @@ class Issue(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
photo_path = Column(String)
|
||||
photo_path2 = Column(String) # 두 번째 사진 경로
|
||||
photo_path2 = Column(String)
|
||||
photo_path3 = Column(String)
|
||||
photo_path4 = Column(String)
|
||||
photo_path5 = Column(String)
|
||||
category = Column(Enum(IssueCategory), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
status = Column(Enum(IssueStatus), default=IssueStatus.new)
|
||||
@@ -120,7 +123,6 @@ class Issue(Base):
|
||||
duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록
|
||||
|
||||
# 관리함에서 사용할 추가 필드들
|
||||
completion_photo_path = Column(String) # 완료 사진 경로
|
||||
solution = Column(Text) # 해결방안 (관리함에서 입력)
|
||||
responsible_department = Column(Enum(DepartmentType)) # 담당부서
|
||||
responsible_person = Column(String(100)) # 담당자
|
||||
@@ -141,9 +143,22 @@ class Issue(Base):
|
||||
# 완료 신청 관련 필드들
|
||||
completion_requested_at = Column(DateTime) # 완료 신청 시간
|
||||
completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자
|
||||
completion_photo_path = Column(String(500)) # 완료 사진 경로
|
||||
completion_photo_path = Column(String(500)) # 완료 사진 1
|
||||
completion_photo_path2 = Column(String(500)) # 완료 사진 2
|
||||
completion_photo_path3 = Column(String(500)) # 완료 사진 3
|
||||
completion_photo_path4 = Column(String(500)) # 완료 사진 4
|
||||
completion_photo_path5 = Column(String(500)) # 완료 사진 5
|
||||
completion_comment = Column(Text) # 완료 코멘트
|
||||
|
||||
|
||||
# 완료 반려 관련 필드들
|
||||
completion_rejected_at = Column(DateTime) # 완료 반려 시간
|
||||
completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자
|
||||
completion_rejection_reason = Column(Text) # 완료 반려 사유
|
||||
|
||||
# 일일보고서 추출 이력
|
||||
last_exported_at = Column(DateTime) # 마지막 일일보고서 추출 시간
|
||||
export_count = Column(Integer, default=0) # 추출 횟수
|
||||
|
||||
# Relationships
|
||||
reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id])
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues")
|
||||
@@ -183,14 +198,28 @@ class DailyWork(Base):
|
||||
|
||||
class ProjectDailyWork(Base):
|
||||
__tablename__ = "project_daily_works"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id"), nullable=False)
|
||||
hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project")
|
||||
created_by = relationship("User")
|
||||
|
||||
class DeletionLog(Base):
|
||||
__tablename__ = "deletion_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_type = Column(String(50), nullable=False) # 'issue', 'project', 'daily_work' 등
|
||||
entity_id = Column(Integer, nullable=False) # 삭제된 엔티티의 ID
|
||||
entity_data = Column(JSONB, nullable=False) # 삭제된 데이터 전체 (JSON)
|
||||
deleted_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
deleted_at = Column(DateTime, default=get_kst_now, nullable=False)
|
||||
reason = Column(Text) # 삭제 사유 (선택사항)
|
||||
|
||||
# Relationships
|
||||
deleted_by = relationship("User")
|
||||
|
||||
@@ -89,7 +89,10 @@ class IssueBase(BaseModel):
|
||||
|
||||
class IssueCreate(IssueBase):
|
||||
photo: Optional[str] = None # Base64 encoded image
|
||||
photo2: Optional[str] = None # Second Base64 encoded image
|
||||
photo2: Optional[str] = None
|
||||
photo3: Optional[str] = None
|
||||
photo4: Optional[str] = None
|
||||
photo5: Optional[str] = None
|
||||
|
||||
class IssueUpdate(BaseModel):
|
||||
category: Optional[IssueCategory] = None
|
||||
@@ -99,12 +102,18 @@ class IssueUpdate(BaseModel):
|
||||
detail_notes: Optional[str] = None
|
||||
status: Optional[IssueStatus] = None
|
||||
photo: Optional[str] = None # Base64 encoded image for update
|
||||
photo2: Optional[str] = None # Second Base64 encoded image for update
|
||||
photo2: Optional[str] = None
|
||||
photo3: Optional[str] = None
|
||||
photo4: Optional[str] = None
|
||||
photo5: Optional[str] = None
|
||||
|
||||
class Issue(IssueBase):
|
||||
id: int
|
||||
photo_path: Optional[str] = None
|
||||
photo_path2: Optional[str] = None # 두 번째 사진 경로
|
||||
photo_path2: Optional[str] = None
|
||||
photo_path3: Optional[str] = None
|
||||
photo_path4: Optional[str] = None
|
||||
photo_path5: Optional[str] = None
|
||||
status: IssueStatus
|
||||
reporter_id: int
|
||||
reporter: User
|
||||
@@ -129,7 +138,6 @@ class Issue(IssueBase):
|
||||
duplicate_reporters: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# 관리함에서 사용할 추가 필드들
|
||||
completion_photo_path: Optional[str] = None # 완료 사진 경로
|
||||
solution: Optional[str] = None # 해결방안
|
||||
responsible_department: Optional[DepartmentType] = None # 담당부서
|
||||
responsible_person: Optional[str] = None # 담당자
|
||||
@@ -150,9 +158,22 @@ class Issue(IssueBase):
|
||||
# 완료 신청 관련 필드들
|
||||
completion_requested_at: Optional[datetime] = None # 완료 신청 시간
|
||||
completion_requested_by_id: Optional[int] = None # 완료 신청자
|
||||
completion_photo_path: Optional[str] = None # 완료 사진 경로
|
||||
completion_photo_path: Optional[str] = None # 완료 사진 1
|
||||
completion_photo_path2: Optional[str] = None # 완료 사진 2
|
||||
completion_photo_path3: Optional[str] = None # 완료 사진 3
|
||||
completion_photo_path4: Optional[str] = None # 완료 사진 4
|
||||
completion_photo_path5: Optional[str] = None # 완료 사진 5
|
||||
completion_comment: Optional[str] = None # 완료 코멘트
|
||||
|
||||
|
||||
# 완료 반려 관련 필드들
|
||||
completion_rejected_at: Optional[datetime] = None # 완료 반려 시간
|
||||
completion_rejected_by_id: Optional[int] = None # 완료 반려자
|
||||
completion_rejection_reason: Optional[str] = None # 완료 반려 사유
|
||||
|
||||
# 일일보고서 추출 이력
|
||||
last_exported_at: Optional[datetime] = None # 마지막 일일보고서 추출 시간
|
||||
export_count: Optional[int] = 0 # 추출 횟수
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -197,9 +218,17 @@ class AdditionalInfoUpdateRequest(BaseModel):
|
||||
|
||||
class CompletionRequestRequest(BaseModel):
|
||||
"""완료 신청 요청"""
|
||||
completion_photo: str # 완료 사진 (Base64)
|
||||
completion_photo: Optional[str] = None # 완료 사진 1 (Base64)
|
||||
completion_photo2: Optional[str] = None # 완료 사진 2 (Base64)
|
||||
completion_photo3: Optional[str] = None # 완료 사진 3 (Base64)
|
||||
completion_photo4: Optional[str] = None # 완료 사진 4 (Base64)
|
||||
completion_photo5: Optional[str] = None # 완료 사진 5 (Base64)
|
||||
completion_comment: Optional[str] = None # 완료 코멘트
|
||||
|
||||
class CompletionRejectionRequest(BaseModel):
|
||||
"""완료 신청 반려 요청"""
|
||||
rejection_reason: str # 반려 사유
|
||||
|
||||
class ManagementUpdateRequest(BaseModel):
|
||||
"""관리함에서 이슈 업데이트 요청"""
|
||||
final_description: Optional[str] = None
|
||||
@@ -211,7 +240,11 @@ class ManagementUpdateRequest(BaseModel):
|
||||
cause_department: Optional[DepartmentType] = None
|
||||
management_comment: Optional[str] = None
|
||||
completion_comment: Optional[str] = None
|
||||
completion_photo: Optional[str] = None # Base64
|
||||
completion_photo: Optional[str] = None # Base64 - 완료 사진 1
|
||||
completion_photo2: Optional[str] = None # Base64 - 완료 사진 2
|
||||
completion_photo3: Optional[str] = None # Base64 - 완료 사진 3
|
||||
completion_photo4: Optional[str] = None # Base64 - 완료 사진 4
|
||||
completion_photo5: Optional[str] = None # Base64 - 완료 사진 5
|
||||
review_status: Optional[ReviewStatus] = None
|
||||
|
||||
class InboxIssue(BaseModel):
|
||||
@@ -324,3 +357,13 @@ class ProjectDailyWork(ProjectDailyWorkBase):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# 일일보고서 관련 스키마
|
||||
class DailyReportRequest(BaseModel):
|
||||
project_id: int
|
||||
|
||||
class DailyReportStats(BaseModel):
|
||||
total_count: int = 0
|
||||
management_count: int = 0 # 관리처리 현황 (진행 중)
|
||||
completed_count: int = 0
|
||||
delayed_count: int = 0
|
||||
|
||||
@@ -60,9 +60,15 @@ async def health_check():
|
||||
# 전역 예외 처리
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
# CORS 헤더 추가 (500 에러에서도 CORS 헤더가 필요)
|
||||
return JSONResponse(
|
||||
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__":
|
||||
|
||||
41
backend/migrate_add_photo_fields.py
Normal file
41
backend/migrate_add_photo_fields.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
데이터베이스 마이그레이션: 사진 필드 추가
|
||||
- 신고 사진 3, 4, 5 추가
|
||||
- 완료 사진 2, 3, 4, 5 추가
|
||||
"""
|
||||
from sqlalchemy import create_engine, text
|
||||
import os
|
||||
|
||||
# 데이터베이스 URL
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/issue_tracker")
|
||||
|
||||
def run_migration():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as conn:
|
||||
print("마이그레이션 시작...")
|
||||
|
||||
try:
|
||||
# 신고 사진 필드 추가
|
||||
print("신고 사진 필드 추가 중...")
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path3 VARCHAR"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path4 VARCHAR"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path5 VARCHAR"))
|
||||
|
||||
# 완료 사진 필드 추가
|
||||
print("완료 사진 필드 추가 중...")
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path2 VARCHAR(500)"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path3 VARCHAR(500)"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path4 VARCHAR(500)"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path5 VARCHAR(500)"))
|
||||
|
||||
conn.commit()
|
||||
print("✅ 마이그레이션 완료!")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"❌ 마이그레이션 실패: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_migration()
|
||||
28
backend/migrations/018_add_deletion_log_table.sql
Normal file
28
backend/migrations/018_add_deletion_log_table.sql
Normal 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 '삭제 사유 (선택사항)';
|
||||
181
backend/migrations/021_add_5_photo_support.sql
Normal file
181
backend/migrations/021_add_5_photo_support.sql
Normal 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;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ pydantic-settings==2.1.0
|
||||
pillow==10.1.0
|
||||
pillow-heif==0.13.0
|
||||
reportlab==4.0.7
|
||||
openpyxl==3.1.2
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -20,22 +20,26 @@ async def create_issue(
|
||||
):
|
||||
print(f"DEBUG: 받은 issue 데이터: {issue}")
|
||||
print(f"DEBUG: project_id: {issue.project_id}")
|
||||
# 이미지 저장
|
||||
photo_path = None
|
||||
photo_path2 = None
|
||||
|
||||
if issue.photo:
|
||||
photo_path = save_base64_image(issue.photo)
|
||||
|
||||
if issue.photo2:
|
||||
photo_path2 = save_base64_image(issue.photo2)
|
||||
|
||||
# 이미지 저장 (최대 5장)
|
||||
photo_paths = {}
|
||||
for i in range(1, 6):
|
||||
photo_field = f"photo{i if i > 1 else ''}"
|
||||
path_field = f"photo_path{i if i > 1 else ''}"
|
||||
photo_data = getattr(issue, photo_field, None)
|
||||
if photo_data:
|
||||
photo_paths[path_field] = save_base64_image(photo_data)
|
||||
else:
|
||||
photo_paths[path_field] = None
|
||||
|
||||
# Issue 생성
|
||||
db_issue = Issue(
|
||||
category=issue.category,
|
||||
description=issue.description,
|
||||
photo_path=photo_path,
|
||||
photo_path2=photo_path2,
|
||||
photo_path=photo_paths.get('photo_path'),
|
||||
photo_path2=photo_paths.get('photo_path2'),
|
||||
photo_path3=photo_paths.get('photo_path3'),
|
||||
photo_path4=photo_paths.get('photo_path4'),
|
||||
photo_path5=photo_paths.get('photo_path5'),
|
||||
reporter_id=current_user.id,
|
||||
project_id=issue.project_id,
|
||||
status=IssueStatus.new
|
||||
@@ -135,38 +139,27 @@ async def update_issue(
|
||||
|
||||
# 업데이트
|
||||
update_data = issue_update.dict(exclude_unset=True)
|
||||
|
||||
# 첫 번째 사진이 업데이트되는 경우 처리
|
||||
if "photo" in update_data:
|
||||
# 기존 사진 삭제
|
||||
if issue.photo_path:
|
||||
delete_file(issue.photo_path)
|
||||
|
||||
# 새 사진 저장
|
||||
if update_data["photo"]:
|
||||
photo_path = save_base64_image(update_data["photo"])
|
||||
update_data["photo_path"] = photo_path
|
||||
else:
|
||||
update_data["photo_path"] = None
|
||||
|
||||
# photo 필드는 제거 (DB에는 photo_path만 저장)
|
||||
del update_data["photo"]
|
||||
|
||||
# 두 번째 사진이 업데이트되는 경우 처리
|
||||
if "photo2" in update_data:
|
||||
# 기존 사진 삭제
|
||||
if issue.photo_path2:
|
||||
delete_file(issue.photo_path2)
|
||||
|
||||
# 새 사진 저장
|
||||
if update_data["photo2"]:
|
||||
photo_path2 = save_base64_image(update_data["photo2"])
|
||||
update_data["photo_path2"] = photo_path2
|
||||
else:
|
||||
update_data["photo_path2"] = None
|
||||
|
||||
# photo2 필드는 제거 (DB에는 photo_path2만 저장)
|
||||
del update_data["photo2"]
|
||||
|
||||
# 사진 업데이트 처리 (최대 5장)
|
||||
for i in range(1, 6):
|
||||
photo_field = f"photo{i if i > 1 else ''}"
|
||||
path_field = f"photo_path{i if i > 1 else ''}"
|
||||
|
||||
if photo_field in update_data:
|
||||
# 기존 사진 삭제
|
||||
existing_path = getattr(issue, path_field, None)
|
||||
if existing_path:
|
||||
delete_file(existing_path)
|
||||
|
||||
# 새 사진 저장
|
||||
if update_data[photo_field]:
|
||||
new_path = save_base64_image(update_data[photo_field])
|
||||
update_data[path_field] = new_path
|
||||
else:
|
||||
update_data[path_field] = None
|
||||
|
||||
# photo 필드는 제거 (DB에는 photo_path만 저장)
|
||||
del update_data[photo_field]
|
||||
|
||||
# work_hours가 입력되면 자동으로 상태를 complete로 변경
|
||||
if "work_hours" in update_data and update_data["work_hours"] > 0:
|
||||
@@ -186,21 +179,72 @@ async def delete_issue(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
from database.models import DeletionLog
|
||||
import json
|
||||
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
# 권한 확인 (관리자만 삭제 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin can delete issues")
|
||||
|
||||
# 이미지 파일 삭제
|
||||
if issue.photo_path:
|
||||
delete_file(issue.photo_path)
|
||||
|
||||
|
||||
# 권한 확인 (관리자 또는 본인이 등록한 경우 삭제 가능)
|
||||
if current_user.role != UserRole.admin and issue.reporter_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="본인이 등록한 부적합만 삭제할 수 있습니다.")
|
||||
|
||||
# 이 이슈를 중복 대상으로 참조하는 다른 이슈들의 참조 제거
|
||||
referencing_issues = db.query(Issue).filter(Issue.duplicate_of_issue_id == issue_id).all()
|
||||
if referencing_issues:
|
||||
print(f"DEBUG: {len(referencing_issues)}개의 이슈가 이 이슈를 중복 대상으로 참조하고 있습니다. 참조를 제거합니다.")
|
||||
for ref_issue in referencing_issues:
|
||||
ref_issue.duplicate_of_issue_id = None
|
||||
db.flush() # 참조 제거를 먼저 커밋
|
||||
|
||||
# 삭제 로그 생성 (삭제 전 데이터 저장)
|
||||
issue_data = {
|
||||
"id": issue.id,
|
||||
"category": issue.category.value if issue.category else None,
|
||||
"description": issue.description,
|
||||
"status": issue.status.value if issue.status else None,
|
||||
"reporter_id": issue.reporter_id,
|
||||
"project_id": issue.project_id,
|
||||
"report_date": issue.report_date.isoformat() if issue.report_date else None,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes,
|
||||
"photo_path": issue.photo_path,
|
||||
"photo_path2": issue.photo_path2,
|
||||
"review_status": issue.review_status.value if issue.review_status else None,
|
||||
"solution": issue.solution,
|
||||
"responsible_department": issue.responsible_department.value if issue.responsible_department else None,
|
||||
"responsible_person": issue.responsible_person,
|
||||
"expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None,
|
||||
"actual_completion_date": issue.actual_completion_date.isoformat() if issue.actual_completion_date else None,
|
||||
}
|
||||
|
||||
deletion_log = DeletionLog(
|
||||
entity_type="issue",
|
||||
entity_id=issue.id,
|
||||
entity_data=issue_data,
|
||||
deleted_by_id=current_user.id,
|
||||
reason=f"사용자 {current_user.username}에 의해 삭제됨"
|
||||
)
|
||||
db.add(deletion_log)
|
||||
|
||||
# 이미지 파일 삭제 (신고 사진 최대 5장)
|
||||
for i in range(1, 6):
|
||||
path_field = f"photo_path{i if i > 1 else ''}"
|
||||
path = getattr(issue, path_field, None)
|
||||
if path:
|
||||
delete_file(path)
|
||||
|
||||
# 완료 사진 삭제 (최대 5장)
|
||||
for i in range(1, 6):
|
||||
path_field = f"completion_photo_path{i if i > 1 else ''}"
|
||||
path = getattr(issue, path_field, None)
|
||||
if path:
|
||||
delete_file(path)
|
||||
|
||||
db.delete(issue)
|
||||
db.commit()
|
||||
return {"detail": "Issue deleted successfully"}
|
||||
return {"detail": "Issue deleted successfully", "logged": True}
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_issue_stats(
|
||||
@@ -255,18 +299,32 @@ async def update_issue_management(
|
||||
update_data = management_update.dict(exclude_unset=True)
|
||||
print(f"DEBUG: Update data dict: {update_data}")
|
||||
|
||||
for field, value in update_data.items():
|
||||
print(f"DEBUG: Processing field {field} = {value}")
|
||||
if field == 'completion_photo' and value:
|
||||
# 완료 사진 업로드 처리
|
||||
# 완료 사진 처리 (최대 5장)
|
||||
completion_photo_fields = []
|
||||
for i in range(1, 6):
|
||||
photo_field = f"completion_photo{i if i > 1 else ''}"
|
||||
path_field = f"completion_photo_path{i if i > 1 else ''}"
|
||||
|
||||
if photo_field in update_data and update_data[photo_field]:
|
||||
completion_photo_fields.append(photo_field)
|
||||
try:
|
||||
completion_photo_path = save_base64_image(value, "completion")
|
||||
setattr(issue, 'completion_photo_path', completion_photo_path)
|
||||
print(f"DEBUG: Saved completion photo: {completion_photo_path}")
|
||||
# 기존 사진 삭제
|
||||
existing_path = getattr(issue, path_field, None)
|
||||
if existing_path:
|
||||
delete_file(existing_path)
|
||||
|
||||
# 새 사진 저장
|
||||
new_path = save_base64_image(update_data[photo_field], "completion")
|
||||
setattr(issue, path_field, new_path)
|
||||
print(f"DEBUG: Saved {photo_field}: {new_path}")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Photo save error: {str(e)}")
|
||||
print(f"DEBUG: Photo save error for {photo_field}: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
|
||||
elif field != 'completion_photo': # completion_photo는 위에서 처리됨
|
||||
|
||||
# 나머지 필드 처리 (완료 사진 제외)
|
||||
for field, value in update_data.items():
|
||||
if field not in completion_photo_fields:
|
||||
print(f"DEBUG: Processing field {field} = {value}")
|
||||
try:
|
||||
setattr(issue, field, value)
|
||||
print(f"DEBUG: Set {field} = {value}")
|
||||
@@ -314,22 +372,28 @@ async def request_completion(
|
||||
|
||||
try:
|
||||
print(f"DEBUG: 완료 신청 시작 - Issue ID: {issue_id}, User: {current_user.username}")
|
||||
|
||||
# 완료 사진 저장
|
||||
completion_photo_path = None
|
||||
if request.completion_photo:
|
||||
print(f"DEBUG: 완료 사진 저장 시작")
|
||||
completion_photo_path = save_base64_image(request.completion_photo, "completion")
|
||||
print(f"DEBUG: 완료 사진 저장 완료 - Path: {completion_photo_path}")
|
||||
|
||||
if not completion_photo_path:
|
||||
raise Exception("완료 사진 저장에 실패했습니다.")
|
||||
|
||||
|
||||
# 완료 사진 저장 (최대 5장)
|
||||
saved_paths = []
|
||||
for i in range(1, 6):
|
||||
photo_field = f"completion_photo{i if i > 1 else ''}"
|
||||
path_field = f"completion_photo_path{i if i > 1 else ''}"
|
||||
photo_data = getattr(request, photo_field, None)
|
||||
|
||||
if photo_data:
|
||||
print(f"DEBUG: {photo_field} 저장 시작")
|
||||
saved_path = save_base64_image(photo_data, "completion")
|
||||
if saved_path:
|
||||
setattr(issue, path_field, saved_path)
|
||||
saved_paths.append(saved_path)
|
||||
print(f"DEBUG: {photo_field} 저장 완료 - Path: {saved_path}")
|
||||
else:
|
||||
raise Exception(f"{photo_field} 저장에 실패했습니다.")
|
||||
|
||||
# 완료 신청 정보 업데이트
|
||||
print(f"DEBUG: DB 업데이트 시작")
|
||||
issue.completion_requested_at = datetime.now()
|
||||
issue.completion_requested_by_id = current_user.id
|
||||
issue.completion_photo_path = completion_photo_path
|
||||
issue.completion_comment = request.completion_comment
|
||||
|
||||
db.commit()
|
||||
@@ -340,16 +404,85 @@ async def request_completion(
|
||||
"message": "완료 신청이 성공적으로 제출되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"completion_requested_at": issue.completion_requested_at,
|
||||
"completion_photo_path": completion_photo_path
|
||||
"completion_photo_paths": saved_paths
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: 완료 신청 처리 오류 - {str(e)}")
|
||||
db.rollback()
|
||||
# 업로드된 파일이 있다면 삭제
|
||||
if 'completion_photo_path' in locals() and completion_photo_path:
|
||||
try:
|
||||
delete_file(completion_photo_path)
|
||||
except:
|
||||
pass
|
||||
if 'saved_paths' in locals():
|
||||
for path in saved_paths:
|
||||
try:
|
||||
delete_file(path)
|
||||
except:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"완료 신청 처리 중 오류가 발생했습니다: {str(e)}")
|
||||
|
||||
@router.post("/{issue_id}/reject-completion")
|
||||
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)}")
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import List
|
||||
from database.database import get_db
|
||||
from database.models import Issue, User, ReviewStatus
|
||||
from database.schemas import (
|
||||
ManagementUpdateRequest, AdditionalInfoUpdateRequest, Issue as IssueSchema
|
||||
ManagementUpdateRequest, AdditionalInfoUpdateRequest, Issue as IssueSchema, DailyReportStats
|
||||
)
|
||||
from routers.auth import get_current_user
|
||||
from routers.page_permissions import check_page_access
|
||||
@@ -172,3 +172,41 @@ async def get_additional_info(
|
||||
"additional_info_updated_at": issue.additional_info_updated_at,
|
||||
"additional_info_updated_by_id": issue.additional_info_updated_by_id
|
||||
}
|
||||
|
||||
@router.get("/stats", response_model=DailyReportStats)
|
||||
async def get_management_stats(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
프로젝트별 관리함 통계 조회 (보고서용)
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 해당 프로젝트의 관리함 이슈들 조회
|
||||
issues = db.query(Issue).filter(
|
||||
Issue.project_id == project_id,
|
||||
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
|
||||
).all()
|
||||
|
||||
# 통계 계산
|
||||
stats = DailyReportStats()
|
||||
today = datetime.now().date()
|
||||
|
||||
for issue in issues:
|
||||
stats.total_count += 1
|
||||
|
||||
if issue.review_status == ReviewStatus.in_progress:
|
||||
stats.management_count += 1
|
||||
|
||||
# 지연 여부 확인
|
||||
if issue.expected_completion_date and issue.expected_completion_date < today:
|
||||
stats.delayed_count += 1
|
||||
|
||||
elif issue.review_status == ReviewStatus.completed:
|
||||
stats.completed_count += 1
|
||||
|
||||
return stats
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func, and_, or_
|
||||
from datetime import datetime, date
|
||||
from typing import List
|
||||
import io
|
||||
import re
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.drawing.image import Image as XLImage
|
||||
import os
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
@@ -48,15 +56,15 @@ async def generate_report_summary(
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.MATERIAL_MISSING:
|
||||
if issue.category == IssueCategory.material_missing:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.DIMENSION_DEFECT:
|
||||
elif issue.category == IssueCategory.design_error:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.INCOMING_DEFECT:
|
||||
elif issue.category == IssueCategory.incoming_defect:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.COMPLETE:
|
||||
if issue.status == IssueStatus.complete:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
@@ -128,3 +136,749 @@ async def get_report_daily_works(
|
||||
"overtime_total": work.overtime_total,
|
||||
"total_hours": work.total_hours
|
||||
} for work in works]
|
||||
|
||||
@router.get("/daily-preview")
|
||||
async def preview_daily_report(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일보고서 미리보기 - 추출될 항목 목록"""
|
||||
|
||||
# 프로젝트 확인
|
||||
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")
|
||||
async def export_daily_report(
|
||||
request: schemas.DailyReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일보고서 엑셀 내보내기"""
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_only = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (모두 조회)
|
||||
all_completed = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.completed
|
||||
).all()
|
||||
|
||||
# 완료 항목 중 "완료 후 추출 안된 것" 필터링
|
||||
# 규칙: 완료 처리 후 1회에 한해서만 "진행 중" 시트에 표시
|
||||
not_exported_after_completion = []
|
||||
for issue in all_completed:
|
||||
if issue.last_exported_at is None:
|
||||
# 한번도 추출 안됨 -> 진행 중 시트에 표시 (완료 후 첫 추출)
|
||||
not_exported_after_completion.append(issue)
|
||||
elif issue.actual_completion_date:
|
||||
# actual_completion_date가 있는 경우: 완료일과 마지막 추출일 비교
|
||||
# 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)
|
||||
|
||||
# 엑셀 파일 생성
|
||||
wb = Workbook()
|
||||
|
||||
# 스타일 정의 (공통)
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
stats_font = Font(bold=True, size=12)
|
||||
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||||
card_header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
label_font = Font(bold=True, size=10)
|
||||
label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
content_font = Font(size=10)
|
||||
thick_border = Border(
|
||||
left=Side(style='medium'),
|
||||
right=Side(style='medium'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 두 개의 시트를 생성하고 각각 데이터 입력
|
||||
sheets_data = [
|
||||
(wb.active, in_progress_issues, "진행 중"),
|
||||
(wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
|
||||
]
|
||||
|
||||
sheets_data[0][0].title = "진행 중"
|
||||
|
||||
for ws, sheet_issues, sheet_title in sheets_data:
|
||||
# 제목 및 기본 정보
|
||||
ws.merge_cells('A1:L1')
|
||||
ws['A1'] = f"{project.project_name} - {sheet_title}"
|
||||
ws['A1'].font = Font(bold=True, size=16)
|
||||
ws['A1'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells('A2:L2')
|
||||
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}"
|
||||
ws['A2'].alignment = center_alignment
|
||||
|
||||
# 프로젝트 통계 (4행부터) - 진행 중 시트에만 표시
|
||||
if sheet_title == "진행 중":
|
||||
ws.merge_cells('A4:L4')
|
||||
ws['A4'] = "📊 프로젝트 현황"
|
||||
ws['A4'].font = Font(bold=True, size=14, color="FFFFFF")
|
||||
ws['A4'].fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
ws['A4'].alignment = center_alignment
|
||||
ws.row_dimensions[4].height = 25
|
||||
|
||||
# 통계 데이터 - 박스 형태로 개선
|
||||
stats_row = 5
|
||||
ws.row_dimensions[stats_row].height = 30
|
||||
|
||||
# 총 신고 수량 (파란색 계열)
|
||||
ws.merge_cells(f'A{stats_row}:B{stats_row}')
|
||||
ws[f'A{stats_row}'] = "총 신고 수량"
|
||||
ws[f'A{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
|
||||
ws[f'A{stats_row}'].fill = PatternFill(start_color="5B9BD5", end_color="5B9BD5", fill_type="solid")
|
||||
ws[f'A{stats_row}'].alignment = center_alignment
|
||||
|
||||
ws[f'C{stats_row}'] = stats.total_count
|
||||
ws[f'C{stats_row}'].font = Font(bold=True, size=14)
|
||||
ws[f'C{stats_row}'].fill = PatternFill(start_color="DEEBF7", end_color="DEEBF7", fill_type="solid")
|
||||
ws[f'C{stats_row}'].alignment = center_alignment
|
||||
|
||||
# 진행 현황 (노란색 계열)
|
||||
ws.merge_cells(f'D{stats_row}:E{stats_row}')
|
||||
ws[f'D{stats_row}'] = "진행 현황"
|
||||
ws[f'D{stats_row}'].font = Font(bold=True, size=11, color="000000")
|
||||
ws[f'D{stats_row}'].fill = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid")
|
||||
ws[f'D{stats_row}'].alignment = center_alignment
|
||||
|
||||
ws[f'F{stats_row}'] = stats.management_count
|
||||
ws[f'F{stats_row}'].font = Font(bold=True, size=14)
|
||||
ws[f'F{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||
ws[f'F{stats_row}'].alignment = center_alignment
|
||||
|
||||
# 완료 현황 (초록색 계열)
|
||||
ws.merge_cells(f'G{stats_row}:H{stats_row}')
|
||||
ws[f'G{stats_row}'] = "완료 현황"
|
||||
ws[f'G{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
|
||||
ws[f'G{stats_row}'].fill = PatternFill(start_color="70AD47", end_color="70AD47", fill_type="solid")
|
||||
ws[f'G{stats_row}'].alignment = center_alignment
|
||||
|
||||
ws[f'I{stats_row}'] = stats.completed_count
|
||||
ws[f'I{stats_row}'].font = Font(bold=True, size=14)
|
||||
ws[f'I{stats_row}'].fill = PatternFill(start_color="C6E0B4", end_color="C6E0B4", fill_type="solid")
|
||||
ws[f'I{stats_row}'].alignment = center_alignment
|
||||
|
||||
# 지연 중 (빨간색 계열)
|
||||
ws.merge_cells(f'J{stats_row}:K{stats_row}')
|
||||
ws[f'J{stats_row}'] = "지연 중"
|
||||
ws[f'J{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
|
||||
ws[f'J{stats_row}'].fill = PatternFill(start_color="E74C3C", end_color="E74C3C", fill_type="solid")
|
||||
ws[f'J{stats_row}'].alignment = center_alignment
|
||||
|
||||
ws[f'L{stats_row}'] = stats.delayed_count
|
||||
ws[f'L{stats_row}'].font = Font(bold=True, size=14)
|
||||
ws[f'L{stats_row}'].fill = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
|
||||
ws[f'L{stats_row}'].alignment = center_alignment
|
||||
|
||||
# 통계 박스에 테두리 적용
|
||||
thick_border = Border(
|
||||
left=Side(style='medium'),
|
||||
right=Side(style='medium'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
for col in ['A', 'C', 'D', 'F', 'G', 'I', 'J', 'L']:
|
||||
if col in ['A', 'D', 'G', 'J']: # 병합된 셀의 시작점
|
||||
for c in range(ord(col), ord(col) + 2): # 병합된 2개 셀
|
||||
ws.cell(row=stats_row, column=c - ord('A') + 1).border = thick_border
|
||||
else: # 숫자 셀
|
||||
ws[f'{col}{stats_row}'].border = thick_border
|
||||
|
||||
# 카드 형태로 데이터 입력 (완료됨 시트는 4행부터, 진행 중 시트는 7행부터)
|
||||
current_row = 4 if sheet_title == "완료됨" else 7
|
||||
|
||||
for idx, issue in enumerate(sheet_issues, 1):
|
||||
card_start_row = current_row
|
||||
|
||||
# 상태별 헤더 색상 설정
|
||||
header_color = get_issue_status_header_color(issue)
|
||||
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
|
||||
|
||||
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
|
||||
ws.merge_cells(f'A{current_row}:C{current_row}')
|
||||
# 동적으로 할당된 프로젝트별 순번 사용 (웹과 동일)
|
||||
issue_no = getattr(issue, '_display_no', issue.project_sequence_no or issue.id)
|
||||
ws[f'A{current_row}'] = f"No. {issue_no}"
|
||||
ws[f'A{current_row}'].font = card_header_font
|
||||
ws[f'A{current_row}'].fill = card_header_fill
|
||||
ws[f'A{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'D{current_row}:G{current_row}')
|
||||
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
|
||||
ws[f'D{current_row}'].font = card_header_font
|
||||
ws[f'D{current_row}'].fill = card_header_fill
|
||||
ws[f'D{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
|
||||
ws[f'H{current_row}'].font = card_header_font
|
||||
ws[f'H{current_row}'].fill = card_header_fill
|
||||
ws[f'H{current_row}'].alignment = center_alignment
|
||||
current_row += 1
|
||||
|
||||
# 부적합명
|
||||
ws[f'A{current_row}'] = "부적합명"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# final_description이 있으면 사용, 없으면 description 사용
|
||||
issue_title = issue.final_description or issue.description or "내용 없음"
|
||||
ws[f'B{current_row}'] = issue_title
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
current_row += 1
|
||||
|
||||
# 상세내용 (detail_notes가 실제 상세 설명)
|
||||
if issue.detail_notes:
|
||||
ws[f'A{current_row}'] = "상세내용"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.detail_notes
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
ws.row_dimensions[current_row].height = 50
|
||||
current_row += 1
|
||||
|
||||
# 원인분류
|
||||
ws[f'A{current_row}'] = "원인분류"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "원인부서"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:F{current_row}')
|
||||
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'G{current_row}'] = "신고자"
|
||||
ws[f'G{current_row}'].font = label_font
|
||||
ws[f'G{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
|
||||
ws[f'H{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 해결방안 (완료 반려 내용 및 댓글 제거)
|
||||
ws[f'A{current_row}'] = "해결방안"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# management_comment에서 완료 반려 패턴과 댓글 제거
|
||||
clean_solution = clean_management_comment_for_export(issue.management_comment)
|
||||
ws[f'B{current_row}'] = clean_solution
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 담당정보
|
||||
ws[f'A{current_row}'] = "담당부서"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "담당자"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:G{current_row}')
|
||||
ws[f'E{current_row}'] = issue.responsible_person or ""
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'H{current_row}'] = "조치예상일"
|
||||
ws[f'H{current_row}'].font = label_font
|
||||
ws[f'H{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'I{current_row}:L{current_row}')
|
||||
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
|
||||
ws[f'I{current_row}'].font = content_font
|
||||
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
|
||||
current_row += 1
|
||||
|
||||
# === 신고 사진 영역 ===
|
||||
report_photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
]
|
||||
report_photos = [p for p in report_photos if p] # None 제거
|
||||
|
||||
if report_photos:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "신고 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진들을 한 행에 일렬로 표시 (간격 좁게)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
report_image_inserted = False
|
||||
|
||||
# 사진 위치: A, C, E, G, I (2열 간격)
|
||||
photo_columns = ['A', 'C', 'E', 'G', 'I']
|
||||
|
||||
for idx, photo in enumerate(report_photos):
|
||||
if idx >= 5: # 최대 5장
|
||||
break
|
||||
photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 200) # 크기 줄임
|
||||
img.height = min(img.height, 150)
|
||||
ws.add_image(img, f'{photo_columns[idx]}{current_row}')
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
if report_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 120
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# === 완료 관련 정보 (완료된 항목만) ===
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
# 완료 코멘트
|
||||
if issue.completion_comment:
|
||||
ws[f'A{current_row}'] = "완료 의견"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.completion_comment
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 완료 사진
|
||||
completion_photos = [
|
||||
issue.completion_photo_path,
|
||||
issue.completion_photo_path2,
|
||||
issue.completion_photo_path3,
|
||||
issue.completion_photo_path4,
|
||||
issue.completion_photo_path5
|
||||
]
|
||||
completion_photos = [p for p in completion_photos if p] # None 제거
|
||||
|
||||
if completion_photos:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "완료 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진들을 한 행에 일렬로 표시 (간격 좁게)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
completion_image_inserted = False
|
||||
|
||||
# 사진 위치: A, C, E, G, I (2열 간격)
|
||||
photo_columns = ['A', 'C', 'E', 'G', 'I']
|
||||
|
||||
for idx, photo in enumerate(completion_photos):
|
||||
if idx >= 5: # 최대 5장
|
||||
break
|
||||
photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 200) # 크기 줄임
|
||||
img.height = min(img.height, 150)
|
||||
ws.add_image(img, f'{photo_columns[idx]}{current_row}')
|
||||
completion_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
if completion_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 120
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# 완료일 정보
|
||||
if issue.actual_completion_date:
|
||||
ws[f'A{current_row}'] = "완료일"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 사진이 하나도 없을 경우
|
||||
# 진행중: 신고 사진만 체크
|
||||
# 완료됨: 신고 사진 + 완료 사진 체크
|
||||
has_completion_photos = False
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
comp_photos = [p for p in [
|
||||
issue.completion_photo_path,
|
||||
issue.completion_photo_path2,
|
||||
issue.completion_photo_path3,
|
||||
issue.completion_photo_path4,
|
||||
issue.completion_photo_path5
|
||||
] if p]
|
||||
has_completion_photos = bool(comp_photos)
|
||||
has_any_photo = bool(report_photos) or has_completion_photos
|
||||
|
||||
if not has_any_photo:
|
||||
ws[f'A{current_row}'] = "첨부사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = "첨부된 사진 없음"
|
||||
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
current_row += 1
|
||||
|
||||
# 카드 전체에 테두리 적용 (A-L 열)
|
||||
card_end_row = current_row - 1
|
||||
for row in range(card_start_row, card_end_row + 1):
|
||||
for col in range(1, 13): # A-L 열 (12열)
|
||||
cell = ws.cell(row=row, column=col)
|
||||
if not cell.border or cell.border.left.style != 'medium':
|
||||
cell.border = border
|
||||
|
||||
# 카드 외곽에 굵은 테두리 (A-L 열) - 상태별 색상 적용
|
||||
border_color = header_color # 헤더와 같은 색상 사용
|
||||
for col in range(1, 13):
|
||||
ws.cell(row=card_start_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin', color=border_color),
|
||||
right=Side(style='medium' if col == 12 else 'thin', color=border_color),
|
||||
top=Side(style='medium', color=border_color),
|
||||
bottom=Side(style='thin', color=border_color)
|
||||
)
|
||||
ws.cell(row=card_end_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin', color=border_color),
|
||||
right=Side(style='medium' if col == 12 else 'thin', color=border_color),
|
||||
top=Side(style='thin', color=border_color),
|
||||
bottom=Side(style='medium', color=border_color)
|
||||
)
|
||||
|
||||
# 카드 좌우 테두리도 색상 적용
|
||||
for row in range(card_start_row + 1, card_end_row):
|
||||
ws.cell(row=row, column=1).border = Border(
|
||||
left=Side(style='medium', color=border_color),
|
||||
right=ws.cell(row=row, column=1).border.right if ws.cell(row=row, column=1).border else Side(style='thin'),
|
||||
top=ws.cell(row=row, column=1).border.top if ws.cell(row=row, column=1).border else Side(style='thin'),
|
||||
bottom=ws.cell(row=row, column=1).border.bottom if ws.cell(row=row, column=1).border else Side(style='thin')
|
||||
)
|
||||
ws.cell(row=row, column=12).border = Border(
|
||||
left=ws.cell(row=row, column=12).border.left if ws.cell(row=row, column=12).border else Side(style='thin'),
|
||||
right=Side(style='medium', color=border_color),
|
||||
top=ws.cell(row=row, column=12).border.top if ws.cell(row=row, column=12).border else Side(style='thin'),
|
||||
bottom=ws.cell(row=row, column=12).border.bottom if ws.cell(row=row, column=12).border else Side(style='thin')
|
||||
)
|
||||
|
||||
# 카드 구분 (빈 행)
|
||||
current_row += 1
|
||||
|
||||
# 열 너비 조정
|
||||
ws.column_dimensions['A'].width = 12 # 레이블 열
|
||||
ws.column_dimensions['B'].width = 15 # 내용 열
|
||||
ws.column_dimensions['C'].width = 15 # 내용 열
|
||||
ws.column_dimensions['D'].width = 15 # 내용 열
|
||||
ws.column_dimensions['E'].width = 15 # 내용 열
|
||||
ws.column_dimensions['F'].width = 15 # 내용 열
|
||||
ws.column_dimensions['G'].width = 15 # 내용 열
|
||||
ws.column_dimensions['H'].width = 15 # 내용 열
|
||||
ws.column_dimensions['I'].width = 15 # 내용 열
|
||||
ws.column_dimensions['J'].width = 15 # 내용 열
|
||||
ws.column_dimensions['K'].width = 15 # 내용 열
|
||||
ws.column_dimensions['L'].width = 15 # 내용 열
|
||||
|
||||
# 엑셀 파일을 메모리에 저장
|
||||
excel_buffer = io.BytesIO()
|
||||
wb.save(excel_buffer)
|
||||
excel_buffer.seek(0)
|
||||
|
||||
# 추출 이력 업데이트
|
||||
export_time = datetime.now()
|
||||
for issue in issues:
|
||||
issue.last_exported_at = export_time
|
||||
issue.export_count = (issue.export_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
# 파일명 생성
|
||||
today = date.today().strftime('%Y%m%d')
|
||||
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
|
||||
|
||||
# 한글 파일명을 위한 URL 인코딩
|
||||
from urllib.parse import quote
|
||||
encoded_filename = quote(filename.encode('utf-8'))
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(excel_buffer.read()),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
|
||||
"""프로젝트 통계 계산"""
|
||||
stats = schemas.DailyReportStats()
|
||||
|
||||
today = date.today()
|
||||
|
||||
for issue in issues:
|
||||
stats.total_count += 1
|
||||
|
||||
if issue.review_status == ReviewStatus.in_progress:
|
||||
stats.management_count += 1
|
||||
|
||||
# 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
|
||||
if issue.expected_completion_date:
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
stats.delayed_count += 1
|
||||
|
||||
elif issue.review_status == ReviewStatus.completed:
|
||||
stats.completed_count += 1
|
||||
|
||||
return stats
|
||||
|
||||
def get_category_text(category: IssueCategory) -> str:
|
||||
"""카테고리 한글 변환"""
|
||||
category_map = {
|
||||
IssueCategory.material_missing: "자재 누락",
|
||||
IssueCategory.design_error: "설계 미스",
|
||||
IssueCategory.incoming_defect: "입고 불량",
|
||||
IssueCategory.inspection_miss: "검사 미스",
|
||||
IssueCategory.etc: "기타"
|
||||
}
|
||||
return category_map.get(category, str(category))
|
||||
|
||||
def get_department_text(department) -> str:
|
||||
"""부서 한글 변환"""
|
||||
if not department:
|
||||
return ""
|
||||
|
||||
department_map = {
|
||||
"production": "생산",
|
||||
"quality": "품질",
|
||||
"purchasing": "구매",
|
||||
"design": "설계",
|
||||
"sales": "영업"
|
||||
}
|
||||
return department_map.get(department, str(department))
|
||||
|
||||
def get_status_text(status: ReviewStatus) -> str:
|
||||
"""상태 한글 변환"""
|
||||
status_map = {
|
||||
ReviewStatus.pending_review: "검토 대기",
|
||||
ReviewStatus.in_progress: "진행 중",
|
||||
ReviewStatus.completed: "완료됨",
|
||||
ReviewStatus.disposed: "폐기됨"
|
||||
}
|
||||
return status_map.get(status, str(status))
|
||||
|
||||
def clean_management_comment_for_export(text: str) -> str:
|
||||
"""엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용)
|
||||
text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text)
|
||||
|
||||
# 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들)
|
||||
lines = text.split('\n')
|
||||
filtered_lines = []
|
||||
for line in lines:
|
||||
# └ 또는 ↳로 시작하는 줄 제외
|
||||
if not re.match(r'^\s*[└↳]', line):
|
||||
filtered_lines.append(line)
|
||||
|
||||
# 3. 빈 줄 정리
|
||||
result = '\n'.join(filtered_lines).strip()
|
||||
# 연속된 빈 줄을 하나로
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
|
||||
return result
|
||||
|
||||
def get_status_color(status: ReviewStatus) -> str:
|
||||
"""상태별 색상 반환"""
|
||||
color_map = {
|
||||
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
|
||||
ReviewStatus.completed: "E2EFDA", # 연한 초록
|
||||
ReviewStatus.disposed: "F2F2F2" # 연한 회색
|
||||
}
|
||||
return color_map.get(status, None)
|
||||
|
||||
|
||||
def get_issue_priority(issue: Issue) -> int:
|
||||
"""이슈 우선순위 반환 (엑셀 정렬용)
|
||||
1: 지연 (빨강)
|
||||
2: 진행중 (노랑)
|
||||
3: 완료됨 (초록)
|
||||
"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return 3
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
# 조치 예상일이 지난 경우 지연
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return 1 # 지연
|
||||
return 2 # 진행중
|
||||
return 2
|
||||
|
||||
def get_issue_status_text(issue: Issue) -> str:
|
||||
"""이슈 상태 텍스트 반환"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return "완료됨"
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return "지연중"
|
||||
return "진행중"
|
||||
return get_status_text(issue.review_status)
|
||||
|
||||
def get_issue_status_header_color(issue: Issue) -> str:
|
||||
"""이슈 상태별 헤더 색상 반환"""
|
||||
priority = get_issue_priority(issue)
|
||||
if priority == 1: # 지연
|
||||
return "E74C3C" # 빨간색
|
||||
elif priority == 2: # 진행중
|
||||
return "FFD966" # 노랑색 (주황색 사이)
|
||||
elif priority == 3: # 완료
|
||||
return "92D050" # 진한 초록색
|
||||
return "4472C4" # 기본 파란색
|
||||
|
||||
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()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -54,31 +54,30 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str
|
||||
print(f"🔍 HEIC 파일 여부: {is_heic}")
|
||||
|
||||
# 이미지 검증 및 형식 확인
|
||||
image = None
|
||||
try:
|
||||
# HEIC 파일인 경우 바로 HEIF 처리 시도
|
||||
# HEIC 파일인 경우 pillow_heif를 직접 사용하여 처리
|
||||
if is_heic and HEIF_SUPPORTED:
|
||||
print("🔄 HEIC 파일 감지, HEIF 처리 시도...")
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
print(f"✅ HEIF 이미지 로드 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}")
|
||||
print("🔄 HEIC 파일 감지, pillow_heif로 직접 처리...")
|
||||
try:
|
||||
import pillow_heif
|
||||
heif_file = pillow_heif.open_heif(io.BytesIO(image_data), convert_hdr_to_8bit=False)
|
||||
image = heif_file.to_pillow()
|
||||
print(f"✅ HEIC -> PIL 변환 성공: 모드: {image.mode}, 크기: {image.size}")
|
||||
except Exception as heic_error:
|
||||
print(f"⚠️ pillow_heif 직접 처리 실패: {heic_error}")
|
||||
# PIL Image.open()으로 재시도
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
print(f"✅ PIL Image.open() 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}")
|
||||
else:
|
||||
# 일반 이미지 처리
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
print(f"🔍 이미지 형식: {image.format}, 모드: {image.mode}, 크기: {image.size}")
|
||||
except Exception as e:
|
||||
print(f"❌ 이미지 열기 실패: {e}")
|
||||
|
||||
# HEIC 파일인 경우 원본 파일로 저장
|
||||
if is_heic:
|
||||
print("🔄 HEIC 파일 - 원본 바이너리 파일로 저장 시도...")
|
||||
filename = f"{prefix}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.heic"
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(image_data)
|
||||
print(f"✅ 원본 HEIC 파일 저장: {filepath}")
|
||||
return f"/uploads/{filename}"
|
||||
|
||||
# HEIC가 아닌 경우에만 HEIF 재시도
|
||||
elif HEIF_SUPPORTED:
|
||||
|
||||
# HEIF 재시도
|
||||
if HEIF_SUPPORTED:
|
||||
print("🔄 HEIF 형식으로 재시도...")
|
||||
try:
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
@@ -90,6 +89,10 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str
|
||||
else:
|
||||
print("❌ HEIF 지원 라이브러리가 설치되지 않거나 처리 불가")
|
||||
raise e
|
||||
|
||||
# 이미지가 성공적으로 로드되지 않은 경우
|
||||
if image is None:
|
||||
raise Exception("이미지 로드 실패")
|
||||
|
||||
# iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환
|
||||
# RGB 모드로 변환 (RGBA, P 모드 등을 처리)
|
||||
|
||||
87
backup_script.sh
Executable file
87
backup_script.sh
Executable 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
106
deploy/deploy.sh
Executable 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 ""
|
||||
80
deploy/docker-compose.synology.yml
Normal file
80
deploy/docker-compose.synology.yml
Normal 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
83
deploy/package.sh
Executable 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 ""
|
||||
@@ -339,29 +339,26 @@
|
||||
async function loadProjects() {
|
||||
try {
|
||||
// API에서 최신 프로젝트 데이터 가져오기
|
||||
const response = await fetch('/api/projects/', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
projects = await response.json();
|
||||
console.log('프로젝트 로드 완료:', projects.length, '개');
|
||||
console.log('활성 프로젝트:', projects.filter(p => p.is_active).length, '개');
|
||||
const apiUrl = window.API_BASE_URL || (() => {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
|
||||
// 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 === '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';
|
||||
})();
|
||||
// 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) {
|
||||
console.error('프로젝트 로드 오류:', error);
|
||||
// 오류 시 localStorage에서 로드
|
||||
|
||||
@@ -271,17 +271,17 @@
|
||||
</div>
|
||||
|
||||
<form id="reportForm" class="space-y-4">
|
||||
<!-- 사진 업로드 (선택사항, 최대 2장) -->
|
||||
<!-- 사진 업로드 (선택사항, 최대 5장) -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700">
|
||||
📸 사진 첨부
|
||||
</label>
|
||||
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
|
||||
선택사항 • 최대 2장
|
||||
선택사항 • 최대 5장
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 사진 미리보기 영역 -->
|
||||
<div id="photoPreviewContainer" class="grid grid-cols-2 gap-2 mb-3" style="display: none;">
|
||||
<!-- 첫 번째 사진 -->
|
||||
@@ -298,6 +298,27 @@
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- 업로드 버튼들 -->
|
||||
@@ -329,7 +350,7 @@
|
||||
|
||||
<!-- 현재 상태 표시 -->
|
||||
<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>
|
||||
|
||||
<!-- 숨겨진 입력 필드들 -->
|
||||
@@ -880,23 +901,23 @@
|
||||
// 사진 업로드 처리 함수
|
||||
async function handlePhotoUpload(files) {
|
||||
const filesArray = Array.from(files);
|
||||
|
||||
|
||||
// 현재 사진 개수 확인
|
||||
if (currentPhotos.length >= 2) {
|
||||
alert('최대 2장까지 업로드 가능합니다.');
|
||||
if (currentPhotos.length >= 5) {
|
||||
alert('최대 5장까지 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 추가 가능한 개수만큼만 처리
|
||||
const availableSlots = 2 - currentPhotos.length;
|
||||
const availableSlots = 5 - currentPhotos.length;
|
||||
const filesToProcess = filesArray.slice(0, availableSlots);
|
||||
|
||||
|
||||
// 로딩 표시
|
||||
showUploadProgress(true);
|
||||
|
||||
|
||||
try {
|
||||
for (const file of filesToProcess) {
|
||||
if (currentPhotos.length >= 2) break;
|
||||
if (currentPhotos.length >= 5) break;
|
||||
|
||||
// 원본 파일 크기 확인
|
||||
const originalSize = file.size;
|
||||
@@ -923,18 +944,18 @@
|
||||
const cameraBtn = document.getElementById('cameraUpload');
|
||||
const galleryBtn = document.getElementById('galleryUpload');
|
||||
const uploadText = document.getElementById('photoUploadText');
|
||||
|
||||
|
||||
if (show) {
|
||||
cameraBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
galleryBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
uploadText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>이미지 처리 중...';
|
||||
uploadText.classList.add('text-blue-600');
|
||||
} else {
|
||||
if (currentPhotos.length < 2) {
|
||||
if (currentPhotos.length < 5) {
|
||||
cameraBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
galleryBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`;
|
||||
uploadText.textContent = `사진 추가 (${currentPhotos.length}/5)`;
|
||||
uploadText.classList.remove('text-blue-600');
|
||||
}
|
||||
}
|
||||
@@ -964,43 +985,37 @@
|
||||
// 사진 미리보기 업데이트
|
||||
function updatePhotoPreview() {
|
||||
const container = document.getElementById('photoPreviewContainer');
|
||||
const photo1Container = document.getElementById('photo1Container');
|
||||
const photo2Container = document.getElementById('photo2Container');
|
||||
const uploadText = document.getElementById('photoUploadText');
|
||||
const cameraUpload = document.getElementById('cameraUpload');
|
||||
const galleryUpload = document.getElementById('galleryUpload');
|
||||
|
||||
|
||||
// 텍스트 업데이트
|
||||
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`;
|
||||
|
||||
// 첫 번째 사진
|
||||
if (currentPhotos[0]) {
|
||||
document.getElementById('previewImg1').src = currentPhotos[0];
|
||||
photo1Container.classList.remove('hidden');
|
||||
container.style.display = 'grid';
|
||||
} else {
|
||||
photo1Container.classList.add('hidden');
|
||||
uploadText.textContent = `사진 추가 (${currentPhotos.length}/5)`;
|
||||
|
||||
// 모든 사진 미리보기 업데이트 (최대 5장)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const photoContainer = document.getElementById(`photo${i + 1}Container`);
|
||||
const previewImg = document.getElementById(`previewImg${i + 1}`);
|
||||
|
||||
if (currentPhotos[i]) {
|
||||
previewImg.src = currentPhotos[i];
|
||||
photoContainer.classList.remove('hidden');
|
||||
container.style.display = 'grid';
|
||||
} else {
|
||||
photoContainer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 두 번째 사진
|
||||
if (currentPhotos[1]) {
|
||||
document.getElementById('previewImg2').src = currentPhotos[1];
|
||||
photo2Container.classList.remove('hidden');
|
||||
container.style.display = 'grid';
|
||||
} else {
|
||||
photo2Container.classList.add('hidden');
|
||||
}
|
||||
|
||||
|
||||
// 미리보기 컨테이너 표시/숨김
|
||||
if (currentPhotos.length === 0) {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
|
||||
// 2장이 모두 업로드되면 업로드 버튼 스타일 변경
|
||||
if (currentPhotos.length >= 2) {
|
||||
|
||||
// 5장이 모두 업로드되면 업로드 버튼 스타일 변경
|
||||
if (currentPhotos.length >= 5) {
|
||||
cameraUpload.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
galleryUpload.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
uploadText.textContent = '최대 2장 업로드 완료';
|
||||
uploadText.textContent = '최대 5장 업로드 완료';
|
||||
uploadText.classList.add('text-green-600', 'font-medium');
|
||||
} else {
|
||||
cameraUpload.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
|
||||
@@ -710,27 +710,31 @@
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
|
||||
const categoryColors = {
|
||||
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
design_error: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
incoming_defect: 'bg-red-100 text-red-700 border-red-300',
|
||||
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
|
||||
};
|
||||
|
||||
|
||||
const div = document.createElement('div');
|
||||
// 검토 완료 상태에 따른 스타일링
|
||||
const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4';
|
||||
const statusClasses = isCompleted
|
||||
? 'bg-gray-100 opacity-75'
|
||||
const statusClasses = isCompleted
|
||||
? 'bg-gray-100 opacity-75'
|
||||
: 'bg-gray-50 hover:bg-gray-100';
|
||||
const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300';
|
||||
div.className = `${baseClasses} ${statusClasses} ${borderColor}`;
|
||||
|
||||
|
||||
const dateStr = DateUtils.formatKST(issue.report_date, true);
|
||||
const relativeTime = DateUtils.getRelativeTime(issue.report_date);
|
||||
const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
|
||||
|
||||
|
||||
// 수정/삭제 권한 확인 (본인이 등록한 부적합만)
|
||||
const canEdit = issue.reporter_id === currentUser.id;
|
||||
const canDelete = issue.reporter_id === currentUser.id || currentUser.role === 'admin';
|
||||
|
||||
div.innerHTML = `
|
||||
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
|
||||
<div class="flex justify-between items-start p-2 pb-0">
|
||||
@@ -741,49 +745,77 @@
|
||||
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 기존 내용 -->
|
||||
<div class="flex gap-3 p-3 pt-1">
|
||||
<!-- 사진들 -->
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
${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}')">` : ''
|
||||
}
|
||||
${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_path && !issue.photo_path2 ?
|
||||
`<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>` : ''
|
||||
}
|
||||
<div class="flex gap-1 flex-shrink-0 flex-wrap max-w-md">
|
||||
${(() => {
|
||||
const photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(p => p);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return `
|
||||
<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 class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium ${categoryColors[issue.category] || 'bg-gray-100 text-gray-700'}">
|
||||
${categoryNames[issue.category] || issue.category}
|
||||
</span>
|
||||
${issue.work_hours ?
|
||||
${issue.work_hours ?
|
||||
`<span class="text-sm text-green-600 font-medium">
|
||||
<i class="fas fa-clock mr-1"></i>${issue.work_hours}시간
|
||||
</span>` :
|
||||
</span>` :
|
||||
'<span class="text-sm text-gray-400">시간 미입력</span>'
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
<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">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
|
||||
<span><i class="fas fa-calendar mr-1"></i>${dateStr}</span>
|
||||
<span class="text-xs text-gray-400">${relativeTime}</span>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</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, "'")})' 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>
|
||||
`;
|
||||
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
@@ -920,7 +952,151 @@
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
|
||||
// 수정 모달 표시
|
||||
function showEditModal(issue) {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
|
||||
modal.innerHTML = `
|
||||
<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 스크립트 동적 로딩
|
||||
|
||||
@@ -287,7 +287,20 @@
|
||||
// 프로젝트 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/projects/', {
|
||||
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/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -668,23 +668,24 @@
|
||||
async function loadProjects() {
|
||||
console.log('🔄 프로젝트 로드 시작');
|
||||
try {
|
||||
const response = await fetch('/api/projects/', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
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`;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📡 프로젝트 API 응답 상태:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
projects = await response.json();
|
||||
console.log('✅ 프로젝트 로드 성공:', projects.length, '개');
|
||||
console.log('📋 프로젝트 목록:', projects);
|
||||
updateProjectFilter();
|
||||
} else {
|
||||
console.error('❌ 프로젝트 API 응답 실패:', response.status, response.statusText);
|
||||
}
|
||||
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);
|
||||
updateProjectFilter();
|
||||
} catch (error) {
|
||||
console.error('❌ 프로젝트 로드 실패:', error);
|
||||
}
|
||||
@@ -794,7 +795,7 @@
|
||||
const timeAgo = getTimeAgo(reportDate);
|
||||
|
||||
// 사진 정보 처리
|
||||
const photoCount = [issue.photo_path, issue.photo_path2].filter(Boolean).length;
|
||||
const photoCount = [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean).length;
|
||||
const photoInfo = photoCount > 0 ? `사진 ${photoCount}장` : '사진 없음';
|
||||
|
||||
return `
|
||||
@@ -855,8 +856,10 @@
|
||||
<!-- 사진 미리보기 -->
|
||||
${photoCount > 0 ? `
|
||||
<div class="photo-gallery">
|
||||
${issue.photo_path ? `<img src="${issue.photo_path}" class="photo-preview" onclick="openPhotoModal('${issue.photo_path}')" alt="첨부 사진 1">` : ''}
|
||||
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="photo-preview" onclick="openPhotoModal('${issue.photo_path2}')" alt="첨부 사진 2">` : ''}
|
||||
${[issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5]
|
||||
.filter(Boolean)
|
||||
.map((path, idx) => `<img src="${path}" class="photo-preview" onclick="openPhotoModal('${path}')" alt="첨부 사진 ${idx + 1}">`)
|
||||
.join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
|
||||
@@ -172,13 +172,14 @@
|
||||
}
|
||||
|
||||
.collapse-content {
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.collapse-content.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 진행 중 카드 스타일 */
|
||||
@@ -425,6 +426,13 @@
|
||||
let currentIssueId = null;
|
||||
let currentTab = 'in_progress'; // 기본값: 진행 중
|
||||
|
||||
// 완료 반려 패턴 제거 (해결방안 표시용)
|
||||
function cleanManagementComment(text) {
|
||||
if (!text) return '';
|
||||
// 기존 데이터에서 완료 반려 패턴 제거
|
||||
return text.replace(/\[완료 반려[^\]]*\][^\n]*\n*/g, '').trim();
|
||||
}
|
||||
|
||||
// API 로드 후 초기화 함수
|
||||
async function initializeManagement() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
@@ -465,65 +473,48 @@
|
||||
// 프로젝트 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/projects/', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
projects = await response.json();
|
||||
updateProjectFilter();
|
||||
}
|
||||
// ProjectsAPI 사용 (모든 프로젝트 로드)
|
||||
projects = await ProjectsAPI.getAll(false);
|
||||
updateProjectFilter();
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 부적합 목록 로드 (관리자는 모든 부적합 조회)
|
||||
// 부적합 목록 로드 (관리함 API 사용)
|
||||
async function loadIssues() {
|
||||
try {
|
||||
let endpoint = '/api/issues/admin/all';
|
||||
// ManagementAPI 사용
|
||||
const managementIssues = await ManagementAPI.getAll();
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
console.log('🔍 관리함 이슈 로드 완료:', managementIssues.length, '개');
|
||||
console.log('📊 상태별 분포:', {
|
||||
in_progress: managementIssues.filter(i => i.review_status === 'in_progress').length,
|
||||
completed: managementIssues.filter(i => i.review_status === 'completed').length,
|
||||
other: managementIssues.filter(i => !['in_progress', 'completed'].includes(i.review_status)).length
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const allIssues = await response.json();
|
||||
// 관리함에서는 진행 중(in_progress)과 완료됨(completed) 상태만 표시
|
||||
let filteredIssues = allIssues.filter(issue =>
|
||||
issue.review_status === 'in_progress' || issue.review_status === 'completed'
|
||||
);
|
||||
|
||||
// 수신함에서 넘어온 순서대로 No. 재할당 (reviewed_at 기준)
|
||||
filteredIssues.sort((a, b) => new Date(a.reviewed_at) - new Date(b.reviewed_at));
|
||||
|
||||
// 프로젝트별로 그룹화하여 No. 재할당
|
||||
const projectGroups = {};
|
||||
filteredIssues.forEach(issue => {
|
||||
if (!projectGroups[issue.project_id]) {
|
||||
projectGroups[issue.project_id] = [];
|
||||
}
|
||||
projectGroups[issue.project_id].push(issue);
|
||||
|
||||
// 수신함에서 넘어온 순서대로 No. 재할당 (reviewed_at 기준)
|
||||
managementIssues.sort((a, b) => new Date(a.reviewed_at) - new Date(b.reviewed_at));
|
||||
|
||||
// 프로젝트별로 그룹화하여 No. 재할당
|
||||
const projectGroups = {};
|
||||
managementIssues.forEach(issue => {
|
||||
if (!projectGroups[issue.project_id]) {
|
||||
projectGroups[issue.project_id] = [];
|
||||
}
|
||||
projectGroups[issue.project_id].push(issue);
|
||||
});
|
||||
|
||||
// 각 프로젝트별로 순번 재할당
|
||||
Object.keys(projectGroups).forEach(projectId => {
|
||||
projectGroups[projectId].forEach((issue, index) => {
|
||||
issue.project_sequence_no = index + 1;
|
||||
});
|
||||
|
||||
// 각 프로젝트별로 순번 재할당
|
||||
Object.keys(projectGroups).forEach(projectId => {
|
||||
projectGroups[projectId].forEach((issue, index) => {
|
||||
issue.project_sequence_no = index + 1;
|
||||
});
|
||||
});
|
||||
|
||||
issues = filteredIssues;
|
||||
filterIssues();
|
||||
} else {
|
||||
throw new Error('부적합 목록을 불러올 수 없습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
issues = managementIssues;
|
||||
filterIssues();
|
||||
} catch (error) {
|
||||
console.error('부적합 로드 실패:', error);
|
||||
alert('부적합 목록을 불러오는데 실패했습니다.');
|
||||
@@ -584,9 +575,22 @@
|
||||
function filterIssues() {
|
||||
const projectFilter = document.getElementById('projectFilter').value;
|
||||
|
||||
console.log('🔍 필터링 시작:', {
|
||||
currentTab: currentTab,
|
||||
projectFilter: projectFilter,
|
||||
totalIssues: issues.length
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -594,6 +598,11 @@
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log('✅ 필터링 결과:', {
|
||||
filteredCount: filteredIssues.length,
|
||||
tab: currentTab
|
||||
});
|
||||
|
||||
sortIssues();
|
||||
displayIssues();
|
||||
updateStatistics(); // 통계 업데이트 추가
|
||||
@@ -650,6 +659,8 @@
|
||||
const issues = groupedByDate[date];
|
||||
const groupId = `group-${date.replace(/\./g, '-')}`;
|
||||
|
||||
console.log(`📅 날짜 그룹 [${date}]: ${issues.length}개 이슈`);
|
||||
|
||||
return `
|
||||
<div class="date-group">
|
||||
<div class="date-header flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
@@ -674,6 +685,18 @@
|
||||
}).join('');
|
||||
|
||||
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)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 이슈 행 생성 함수
|
||||
@@ -781,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">
|
||||
<i class="fas fa-save mr-1"></i>저장
|
||||
</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">
|
||||
<i class="fas fa-check mr-1"></i>완료처리
|
||||
</button>
|
||||
@@ -840,10 +866,27 @@
|
||||
|
||||
<div>
|
||||
<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>'}
|
||||
${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>'}
|
||||
</div>
|
||||
${(() => {
|
||||
const photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(p => p);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return '<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>
|
||||
|
||||
@@ -851,9 +894,9 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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>
|
||||
<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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -892,11 +935,27 @@
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-purple-600 font-medium">완료 사진</label>
|
||||
${issue.completion_photo_path ? `
|
||||
<div class="mt-1">
|
||||
<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="완료 사진">
|
||||
</div>
|
||||
` : '<p class="text-xs text-gray-500 mt-1">완료 사진 없음</p>'}
|
||||
${(() => {
|
||||
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 '<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>
|
||||
<label class="text-xs text-purple-600 font-medium">완료 코멘트</label>
|
||||
@@ -986,11 +1045,11 @@
|
||||
관리 정보
|
||||
</h4>
|
||||
<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">${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">${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>
|
||||
|
||||
@@ -1004,20 +1063,27 @@
|
||||
<!-- 완료 사진 -->
|
||||
<div>
|
||||
<label class="text-xs text-green-600 font-medium">완료 사진</label>
|
||||
${issue.completion_photo_path ?
|
||||
(issue.completion_photo_path.toLowerCase().endsWith('.heic') ?
|
||||
`<div class="mt-1 flex items-center space-x-2">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-lg flex items-center justify-center border border-green-200">
|
||||
<i class="fas fa-image text-green-500"></i>
|
||||
</div>
|
||||
<a href="${issue.completion_photo_path}" download class="text-xs text-blue-500 hover:text-blue-700 underline">HEIC 다운로드</a>
|
||||
</div>` :
|
||||
`<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="완료 사진">
|
||||
</div>`
|
||||
) :
|
||||
'<p class="text-xs text-gray-500 mt-1">완료 사진 없음</p>'
|
||||
}
|
||||
${(() => {
|
||||
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 '<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>
|
||||
@@ -1041,10 +1107,27 @@
|
||||
<i class="fas fa-camera text-gray-500 mr-2"></i>
|
||||
업로드 사진
|
||||
</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>'}
|
||||
${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>'}
|
||||
</div>
|
||||
${(() => {
|
||||
const photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(p => p);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return '<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>
|
||||
`;
|
||||
@@ -1276,7 +1359,7 @@
|
||||
try {
|
||||
// 편집된 필드들의 값 수집
|
||||
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 => {
|
||||
const element = document.getElementById(`${field}_${issueId}`);
|
||||
@@ -1391,10 +1474,27 @@
|
||||
|
||||
<div>
|
||||
<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>'}
|
||||
${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>'}
|
||||
</div>
|
||||
${(() => {
|
||||
const photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(p => p);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return '<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>
|
||||
@@ -1405,8 +1505,8 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<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>
|
||||
<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" placeholder="확정된 해결 방안을 입력하세요...">${cleanManagementComment(issue.management_comment)}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1439,17 +1539,41 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">완료 사진</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>'
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">완료 사진 (최대 5장)</label>
|
||||
|
||||
<!-- 기존 완료 사진 표시 -->
|
||||
${(() => {
|
||||
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>
|
||||
@@ -1464,7 +1588,7 @@
|
||||
try {
|
||||
// 편집된 필드들의 값 수집
|
||||
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 => {
|
||||
const element = document.getElementById(`modal_${field}`);
|
||||
@@ -1477,11 +1601,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 완료 사진 처리
|
||||
const photoFile = document.getElementById('modal_completion_photo').files[0];
|
||||
if (photoFile) {
|
||||
const base64 = await fileToBase64(photoFile);
|
||||
updates.completion_photo = base64;
|
||||
// 완료 사진 처리 (최대 5장)
|
||||
const photoInput = document.getElementById('modal_completion_photo');
|
||||
const photoFiles = photoInput.files;
|
||||
|
||||
if (photoFiles && photoFiles.length > 0) {
|
||||
const maxPhotos = Math.min(photoFiles.length, 5);
|
||||
|
||||
for (let i = 0; i < maxPhotos; i++) {
|
||||
const base64 = await fileToBase64(photoFiles[i]);
|
||||
const fieldName = i === 0 ? 'completion_photo' : `completion_photo${i + 1}`;
|
||||
updates[fieldName] = base64;
|
||||
}
|
||||
|
||||
console.log(`📸 ${maxPhotos}장의 완료 사진 처리 완료`);
|
||||
}
|
||||
|
||||
console.log('Modal sending updates:', updates);
|
||||
@@ -1858,10 +1991,27 @@
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<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>'}
|
||||
${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>'}
|
||||
</div>
|
||||
${(() => {
|
||||
const photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(p => p);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return '<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>
|
||||
|
||||
@@ -1871,8 +2021,8 @@
|
||||
<h4 class="font-semibold text-green-800 mb-3">관리 정보</h4>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">해결방안 (확정)</label>
|
||||
<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 class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -1902,7 +2052,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
@@ -1912,32 +2062,57 @@
|
||||
<h4 class="font-semibold text-purple-800 mb-3">완료 신청 정보</h4>
|
||||
<div class="space-y-3">
|
||||
<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">
|
||||
${issue.completion_photo_path ? `
|
||||
<div class="flex items-center space-x-3">
|
||||
<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="현재 완료 사진">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-600 mb-1">현재 완료 사진</p>
|
||||
<p class="text-xs text-gray-500">클릭하면 크게 볼 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="flex items-center justify-center w-24 h-24 bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg">
|
||||
<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>
|
||||
`}
|
||||
${(() => {
|
||||
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-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">
|
||||
<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">
|
||||
<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>
|
||||
<span id="photo-filename-${issue.id}" class="text-sm text-gray-600"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">※ 최대 5장까지 업로드 가능합니다. 새로운 사진을 업로드하면 기존 사진을 모두 교체합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1963,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">
|
||||
<i class="fas fa-save mr-2"></i>저장
|
||||
</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">
|
||||
<i class="fas fa-check-circle mr-2"></i>최종확인
|
||||
</button>
|
||||
@@ -1981,8 +2159,9 @@
|
||||
|
||||
if (fileInput && filenameSpan) {
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
filenameSpan.textContent = e.target.files[0].name;
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const fileCount = Math.min(e.target.files.length, 5);
|
||||
filenameSpan.textContent = `${fileCount}개 파일 선택됨`;
|
||||
filenameSpan.className = 'text-sm text-green-600 font-medium';
|
||||
} else {
|
||||
filenameSpan.textContent = '';
|
||||
@@ -2015,41 +2194,47 @@
|
||||
const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim();
|
||||
const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim();
|
||||
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 person = document.getElementById(`edit-person-${issueId}`).value.trim();
|
||||
const date = document.getElementById(`edit-date-${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 completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`);
|
||||
|
||||
|
||||
let completionComment = null;
|
||||
let completionPhoto = null;
|
||||
|
||||
const completionPhotos = {}; // 완료 사진들을 저장할 객체
|
||||
|
||||
if (completionCommentElement) {
|
||||
completionComment = completionCommentElement.value.trim();
|
||||
}
|
||||
|
||||
if (completionPhotoElement && completionPhotoElement.files[0]) {
|
||||
|
||||
// 완료 사진 처리 (최대 5장)
|
||||
if (completionPhotoElement && completionPhotoElement.files.length > 0) {
|
||||
try {
|
||||
const file = completionPhotoElement.files[0];
|
||||
console.log('🔍 업로드할 파일 정보:', {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
});
|
||||
|
||||
const base64 = await fileToBase64(file);
|
||||
console.log('🔍 Base64 변환 완료 - 전체 길이:', base64.length);
|
||||
console.log('🔍 Base64 헤더:', base64.substring(0, 50));
|
||||
|
||||
completionPhoto = base64.split(',')[1]; // Base64 데이터만 추출
|
||||
console.log('🔍 헤더 제거 후 길이:', completionPhoto.length);
|
||||
console.log('🔍 전송할 Base64 시작 부분:', completionPhoto.substring(0, 50));
|
||||
const files = completionPhotoElement.files;
|
||||
const maxPhotos = Math.min(files.length, 5);
|
||||
|
||||
console.log(`🔍 총 ${maxPhotos}개의 완료 사진 업로드 시작`);
|
||||
|
||||
for (let i = 0; i < maxPhotos; i++) {
|
||||
const file = files[i];
|
||||
console.log(`🔍 파일 ${i + 1} 정보:`, {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
});
|
||||
|
||||
const base64 = await fileToBase64(file);
|
||||
const base64Data = base64.split(',')[1]; // Base64 데이터만 추출
|
||||
|
||||
const fieldName = i === 0 ? 'completion_photo' : `completion_photo${i + 1}`;
|
||||
completionPhotos[fieldName] = base64Data;
|
||||
|
||||
console.log(`✅ 파일 ${i + 1} 변환 완료 (${fieldName})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파일 변환 오류:', error);
|
||||
alert('완료 사진 업로드 중 오류가 발생했습니다.');
|
||||
@@ -2063,24 +2248,24 @@
|
||||
}
|
||||
|
||||
const combinedDescription = title + (detail ? '\n' + detail : '');
|
||||
|
||||
|
||||
const requestBody = {
|
||||
final_description: combinedDescription,
|
||||
final_category: category,
|
||||
solution: solution || null,
|
||||
management_comment: managementComment || null,
|
||||
responsible_department: department || null,
|
||||
responsible_person: person || null,
|
||||
expected_completion_date: date || null,
|
||||
cause_department: causeDepartment || null,
|
||||
management_comment: managementComment || null
|
||||
cause_department: causeDepartment || null
|
||||
};
|
||||
|
||||
// 완료 신청 정보가 있으면 추가
|
||||
if (completionComment !== null) {
|
||||
requestBody.completion_comment = completionComment || null;
|
||||
}
|
||||
if (completionPhoto !== null) {
|
||||
requestBody.completion_photo = completionPhoto;
|
||||
// 완료 사진들 추가 (최대 5장)
|
||||
for (const [key, value] of Object.entries(completionPhotos)) {
|
||||
requestBody[key] = value;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2266,7 +2451,7 @@
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<h4 class="font-semibold text-green-800 mb-2">관리 정보</h4>
|
||||
<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_person || '-'}</div>
|
||||
<div><span class="font-medium">조치예상일:</span> ${issue.expected_completion_date ? new Date(issue.expected_completion_date).toLocaleDateString('ko-KR') : '-'}</div>
|
||||
@@ -2343,12 +2528,11 @@
|
||||
const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim();
|
||||
const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim();
|
||||
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 person = document.getElementById(`edit-person-${issueId}`).value.trim();
|
||||
const date = document.getElementById(`edit-date-${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}`);
|
||||
@@ -2391,16 +2575,15 @@
|
||||
}
|
||||
|
||||
const combinedDescription = title + (detail ? '\n' + detail : '');
|
||||
|
||||
|
||||
const requestBody = {
|
||||
final_description: combinedDescription,
|
||||
final_category: category,
|
||||
solution: solution || null,
|
||||
management_comment: managementComment || null,
|
||||
responsible_department: department || null,
|
||||
responsible_person: person || null,
|
||||
expected_completion_date: date || null,
|
||||
cause_department: causeDepartment || null,
|
||||
management_comment: managementComment || null,
|
||||
review_status: 'completed' // 완료 상태로 변경
|
||||
};
|
||||
|
||||
@@ -2465,6 +2648,75 @@
|
||||
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>
|
||||
|
||||
<!-- 추가 정보 입력 모달 -->
|
||||
@@ -2548,5 +2800,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
537
frontend/reports-daily.html
Normal file
537
frontend/reports-daily.html
Normal file
@@ -0,0 +1,537 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>일일보고서 - 작업보고서</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
transition: all 0.2s ease;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.report-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.issue-row {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.issue-row:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-file-excel text-green-500 mr-3"></i>
|
||||
일일보고서
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">프로젝트별 진행중/완료 항목을 엑셀로 내보내세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 선택 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="space-y-6">
|
||||
<!-- 프로젝트 선택 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||
<i class="fas fa-folder text-blue-500 mr-2"></i>보고서 생성할 프로젝트 선택
|
||||
</label>
|
||||
<select id="reportProjectSelect" class="w-full max-w-md px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg">
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
</select>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
진행 중인 항목 + 완료되고 한번도 추출 안된 항목이 포함됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button id="previewBtn"
|
||||
onclick="loadPreview()"
|
||||
class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
|
||||
<i class="fas fa-eye mr-2"></i>미리보기
|
||||
</button>
|
||||
<button id="generateReportBtn"
|
||||
onclick="generateDailyReport()"
|
||||
class="px-6 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
|
||||
<i class="fas fa-download mr-2"></i>일일보고서 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 섹션 -->
|
||||
<div id="previewSection" class="hidden">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<i class="fas fa-chart-bar text-blue-500 mr-2"></i>추출 항목 통계
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="stats-card bg-blue-50 p-4 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-blue-600 mb-1" id="previewTotalCount">0</div>
|
||||
<div class="text-sm text-blue-700 font-medium">총 추출 수량</div>
|
||||
</div>
|
||||
<div class="stats-card bg-orange-50 p-4 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-orange-600 mb-1" id="previewInProgressCount">0</div>
|
||||
<div class="text-sm text-orange-700 font-medium">진행 중</div>
|
||||
</div>
|
||||
<div class="stats-card bg-green-50 p-4 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-green-600 mb-1" id="previewCompletedCount">0</div>
|
||||
<div class="text-sm text-green-700 font-medium">완료 (미추출)</div>
|
||||
</div>
|
||||
<div class="stats-card bg-red-50 p-4 rounded-lg text-center">
|
||||
<div class="text-3xl font-bold text-red-600 mb-1" id="previewDelayedCount">0</div>
|
||||
<div class="text-sm text-red-700 font-medium">지연 중</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 항목 목록 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<i class="fas fa-list text-gray-500 mr-2"></i>추출될 항목 목록
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr class="border-b">
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">No</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">부적합명</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">추출이력</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">담당부서</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">신고일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="previewTableBody" class="divide-y divide-gray-200">
|
||||
<!-- 동적으로 채워짐 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 포함 항목 안내 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<i class="fas fa-list-check text-gray-500 mr-2"></i>보고서 포함 항목 안내
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="report-card bg-blue-50 p-4 rounded-lg">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-check-circle text-blue-500 mr-2"></i>
|
||||
<span class="font-medium text-blue-800">진행 중 항목</span>
|
||||
</div>
|
||||
<p class="text-sm text-blue-600">모든 진행 중인 항목이 포함됩니다 (추출 이력과 무관)</p>
|
||||
</div>
|
||||
<div class="report-card bg-green-50 p-4 rounded-lg">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="font-medium text-green-800">완료됨 항목</span>
|
||||
</div>
|
||||
<p class="text-sm text-green-600">한번도 추출 안된 완료 항목만 포함, 이후 자동 제외</p>
|
||||
</div>
|
||||
<div class="report-card bg-yellow-50 p-4 rounded-lg">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-info-circle text-yellow-500 mr-2"></i>
|
||||
<span class="font-medium text-yellow-800">추출 이력 기록</span>
|
||||
</div>
|
||||
<p class="text-sm text-yellow-600">추출 시 자동으로 이력이 기록됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/static/js/core/auth-manager.js"></script>
|
||||
<script src="/static/js/core/permissions.js"></script>
|
||||
<script src="/static/js/components/common-header.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
|
||||
<script>
|
||||
let projects = [];
|
||||
let selectedProjectId = null;
|
||||
let previewData = null;
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('일일보고서 페이지 로드 시작');
|
||||
|
||||
// AuthManager 로드 대기
|
||||
const checkAuthManager = async () => {
|
||||
if (window.authManager) {
|
||||
try {
|
||||
// 인증 확인
|
||||
const isAuthenticated = await window.authManager.checkAuth();
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
await loadProjects();
|
||||
|
||||
// 공통 헤더 초기화
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
|
||||
if (window.commonHeader && user.id) {
|
||||
await window.commonHeader.init(user, 'reports_daily');
|
||||
}
|
||||
} catch (headerError) {
|
||||
console.error('공통 헤더 초기화 오류:', headerError);
|
||||
}
|
||||
|
||||
console.log('일일보고서 페이지 로드 완료');
|
||||
} catch (error) {
|
||||
console.error('페이지 초기화 오류:', error);
|
||||
}
|
||||
} else {
|
||||
setTimeout(checkAuthManager, 100);
|
||||
}
|
||||
};
|
||||
checkAuthManager();
|
||||
});
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || (() => {
|
||||
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/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
projects = await response.json();
|
||||
populateProjectSelect();
|
||||
} else {
|
||||
console.error('프로젝트 로드 실패:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 선택 옵션 채우기
|
||||
function populateProjectSelect() {
|
||||
const select = document.getElementById('reportProjectSelect');
|
||||
|
||||
if (!select) {
|
||||
console.error('reportProjectSelect 요소를 찾을 수 없습니다!');
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
|
||||
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.project_name || project.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// 프로젝트 선택 시 이벤트
|
||||
document.addEventListener('change', async function(e) {
|
||||
if (e.target.id === 'reportProjectSelect') {
|
||||
selectedProjectId = e.target.value;
|
||||
const previewBtn = document.getElementById('previewBtn');
|
||||
const generateBtn = document.getElementById('generateReportBtn');
|
||||
const previewSection = document.getElementById('previewSection');
|
||||
|
||||
if (selectedProjectId) {
|
||||
previewBtn.classList.remove('hidden');
|
||||
generateBtn.classList.remove('hidden');
|
||||
previewSection.classList.add('hidden');
|
||||
previewData = null;
|
||||
} else {
|
||||
previewBtn.classList.add('hidden');
|
||||
generateBtn.classList.add('hidden');
|
||||
previewSection.classList.add('hidden');
|
||||
previewData = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 미리보기 로드
|
||||
async function loadPreview() {
|
||||
if (!selectedProjectId) {
|
||||
alert('프로젝트를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = window.API_BASE_URL || (() => {
|
||||
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: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
previewData = await response.json();
|
||||
displayPreview(previewData);
|
||||
} else {
|
||||
alert('미리보기 로드에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('미리보기 로드 오류:', error);
|
||||
alert('미리보기 로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 미리보기 표시
|
||||
function displayPreview(data) {
|
||||
// 통계 업데이트
|
||||
const inProgressCount = data.issues.filter(i => i.review_status === 'in_progress').length;
|
||||
const completedCount = data.issues.filter(i => i.review_status === 'completed').length;
|
||||
|
||||
document.getElementById('previewTotalCount').textContent = data.total_issues;
|
||||
document.getElementById('previewInProgressCount').textContent = inProgressCount;
|
||||
document.getElementById('previewCompletedCount').textContent = completedCount;
|
||||
document.getElementById('previewDelayedCount').textContent = data.stats.delayed_count;
|
||||
|
||||
// 테이블 업데이트
|
||||
const tbody = document.getElementById('previewTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.issues.forEach(issue => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'issue-row';
|
||||
|
||||
const statusBadge = getStatusBadge(issue);
|
||||
const exportBadge = getExportBadge(issue);
|
||||
const department = getDepartmentText(issue.responsible_department);
|
||||
const reportDate = issue.report_date ? new Date(issue.report_date).toLocaleDateString('ko-KR') : '-';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-3 text-sm text-gray-900">${issue.project_sequence_no || issue.id}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900">${issue.final_description || issue.description || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm">${statusBadge}</td>
|
||||
<td class="px-4 py-3 text-sm">${exportBadge}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900">${department}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">${reportDate}</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// 미리보기 섹션 표시
|
||||
document.getElementById('previewSection').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 상태 배지 (지연/진행중/완료 구분)
|
||||
function getStatusBadge(issue) {
|
||||
// 완료됨
|
||||
if (issue.review_status === 'completed') {
|
||||
return '<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">완료됨</span>';
|
||||
}
|
||||
|
||||
// 진행 중인 경우 지연 여부 확인
|
||||
if (issue.review_status === 'in_progress') {
|
||||
if (issue.expected_completion_date) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const expectedDate = new Date(issue.expected_completion_date);
|
||||
expectedDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (expectedDate < today) {
|
||||
return '<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">지연중</span>';
|
||||
}
|
||||
}
|
||||
return '<span class="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded">진행중</span>';
|
||||
}
|
||||
|
||||
return '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">' + (issue.review_status || '-') + '</span>';
|
||||
}
|
||||
|
||||
// 추출 이력 배지
|
||||
function getExportBadge(issue) {
|
||||
if (issue.last_exported_at) {
|
||||
const exportDate = new Date(issue.last_exported_at).toLocaleDateString('ko-KR');
|
||||
return `<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">추출됨 (${issue.export_count || 1}회)</span>`;
|
||||
} else {
|
||||
return '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">미추출</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// 부서명 변환
|
||||
function getDepartmentText(department) {
|
||||
const map = {
|
||||
'production': '생산',
|
||||
'quality': '품질',
|
||||
'purchasing': '구매',
|
||||
'design': '설계',
|
||||
'sales': '영업'
|
||||
};
|
||||
return map[department] || '-';
|
||||
}
|
||||
|
||||
// 일일보고서 생성
|
||||
async function generateDailyReport() {
|
||||
if (!selectedProjectId) {
|
||||
alert('프로젝트를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 미리보기 데이터가 있고 항목이 0개인 경우
|
||||
if (previewData && previewData.total_issues === 0) {
|
||||
alert('추출할 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const button = document.getElementById('generateReportBtn');
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>생성 중...';
|
||||
button.disabled = true;
|
||||
|
||||
const apiUrl = window.API_BASE_URL || (() => {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project_id: parseInt(selectedProjectId)
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
|
||||
// 파일명 생성
|
||||
const project = projects.find(p => p.id == selectedProjectId);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
a.download = `${project.project_name}_일일보고서_${today}.xlsx`;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
// 성공 메시지
|
||||
showSuccessMessage('일일보고서가 성공적으로 생성되었습니다!');
|
||||
|
||||
// 미리보기 새로고침
|
||||
if (previewData) {
|
||||
setTimeout(() => loadPreview(), 1000);
|
||||
}
|
||||
|
||||
} else {
|
||||
const error = await response.text();
|
||||
console.error('보고서 생성 실패:', error);
|
||||
alert('보고서 생성에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('보고서 생성 오류:', error);
|
||||
alert('보고서 생성 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
const button = document.getElementById('generateReportBtn');
|
||||
button.innerHTML = '<i class="fas fa-download mr-2"></i>일일보고서 생성';
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 성공 메시지 표시
|
||||
function showSuccessMessage(message) {
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
successDiv.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(successDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
successDiv.remove();
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
111
frontend/reports-monthly.html
Normal file
111
frontend/reports-monthly.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>월간보고서 - 작업보고서</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-calendar-alt text-purple-500 mr-3"></i>
|
||||
월간보고서
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합적으로 분석하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 준비중 안내 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-12 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="w-24 h-24 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<i class="fas fa-calendar-alt text-purple-500 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">월간보고서 준비중</h2>
|
||||
<p class="text-gray-600 mb-6">
|
||||
월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합한 보고서 기능을 준비하고 있습니다.
|
||||
</p>
|
||||
<div class="bg-purple-50 p-4 rounded-lg">
|
||||
<h3 class="font-semibold text-purple-800 mb-2">예정 기능</h3>
|
||||
<ul class="text-sm text-purple-700 space-y-1">
|
||||
<li>• 월간 부적합 발생 현황</li>
|
||||
<li>• 월간 처리 완료 현황</li>
|
||||
<li>• 부서별 성과 분석</li>
|
||||
<li>• 월간 트렌드 및 개선사항</li>
|
||||
<li>• 경영진 보고용 요약</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<button onclick="window.history.back()"
|
||||
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/static/js/core/auth-manager.js"></script>
|
||||
<script src="/static/js/core/permissions.js"></script>
|
||||
<script src="/static/js/components/common-header.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
|
||||
<script>
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('월간보고서 페이지 로드 시작');
|
||||
|
||||
// AuthManager 로드 대기
|
||||
const checkAuthManager = async () => {
|
||||
if (window.authManager) {
|
||||
try {
|
||||
// 인증 확인
|
||||
const isAuthenticated = await window.authManager.checkAuth();
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 공통 헤더 초기화
|
||||
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
|
||||
if (window.commonHeader && user.id) {
|
||||
await window.commonHeader.init(user, 'reports_monthly');
|
||||
}
|
||||
|
||||
console.log('월간보고서 페이지 로드 완료');
|
||||
} catch (error) {
|
||||
console.error('페이지 초기화 오류:', error);
|
||||
}
|
||||
} else {
|
||||
setTimeout(checkAuthManager, 100);
|
||||
}
|
||||
};
|
||||
checkAuthManager();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
110
frontend/reports-weekly.html
Normal file
110
frontend/reports-weekly.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>주간보고서 - 작업보고서</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-calendar-week text-blue-500 mr-3"></i>
|
||||
주간보고서
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">주간 단위로 집계된 부적합 현황 및 처리 결과를 확인하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 준비중 안내 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-12 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<i class="fas fa-calendar-week text-blue-500 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">주간보고서 준비중</h2>
|
||||
<p class="text-gray-600 mb-6">
|
||||
주간 단위로 집계된 부적합 현황 및 처리 결과를 정리한 보고서 기능을 준비하고 있습니다.
|
||||
</p>
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">예정 기능</h3>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• 주간 부적합 발생 현황</li>
|
||||
<li>• 주간 처리 완료 현황</li>
|
||||
<li>• 부서별 처리 성과</li>
|
||||
<li>• 주간 트렌드 분석</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<button onclick="window.history.back()"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/static/js/core/auth-manager.js"></script>
|
||||
<script src="/static/js/core/permissions.js"></script>
|
||||
<script src="/static/js/components/common-header.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
|
||||
<script>
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('주간보고서 페이지 로드 시작');
|
||||
|
||||
// AuthManager 로드 대기
|
||||
const checkAuthManager = async () => {
|
||||
if (window.authManager) {
|
||||
try {
|
||||
// 인증 확인
|
||||
const isAuthenticated = await window.authManager.checkAuth();
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 공통 헤더 초기화
|
||||
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
|
||||
if (window.commonHeader && user.id) {
|
||||
await window.commonHeader.init(user, 'reports_weekly');
|
||||
}
|
||||
|
||||
console.log('주간보고서 페이지 로드 완료');
|
||||
} catch (error) {
|
||||
console.error('페이지 초기화 오류:', error);
|
||||
}
|
||||
} else {
|
||||
setTimeout(checkAuthManager, 100);
|
||||
}
|
||||
};
|
||||
checkAuthManager();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
212
frontend/reports.html
Normal file
212
frontend/reports.html
Normal file
@@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>보고서 - 작업보고서</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.report-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.15);
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.report-card.daily-report {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.report-card.daily-report:hover {
|
||||
border-left-color: #059669;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- 공통 헤더 -->
|
||||
<div id="commonHeader"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-chart-bar text-red-500 mr-3"></i>
|
||||
보고서
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">다양한 보고서를 생성하고 관리할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 보고서 카테고리 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<i class="fas fa-list text-gray-500 mr-2"></i>보고서 유형 선택
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- 일일보고서 -->
|
||||
<a href="/reports-daily.html" class="report-card bg-green-50 p-4 rounded-lg hover:bg-green-100 transition-colors">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-file-excel text-green-600"></i>
|
||||
</div>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||
사용 가능
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">일일보고서</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
관리함 데이터를 기반으로 품질팀용 일일보고서를 엑셀 형태로 생성합니다.
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-green-600 font-medium">
|
||||
<i class="fas fa-check-circle mr-1"></i>진행중 항목 포함
|
||||
</span>
|
||||
<i class="fas fa-arrow-right text-gray-400"></i>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 주간보고서 -->
|
||||
<a href="/reports-weekly.html" class="report-card bg-blue-50 p-4 rounded-lg hover:bg-blue-100 transition-colors">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-calendar-week text-blue-600"></i>
|
||||
</div>
|
||||
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||
준비중
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">주간보고서</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
주간 단위로 집계된 부적합 현황 및 처리 결과를 정리한 보고서입니다.
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-blue-600 font-medium">
|
||||
<i class="fas fa-calendar mr-1"></i>주간 집계
|
||||
</span>
|
||||
<i class="fas fa-arrow-right text-gray-400"></i>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 월간보고서 -->
|
||||
<a href="/reports-monthly.html" class="report-card bg-purple-50 p-4 rounded-lg hover:bg-purple-100 transition-colors">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-calendar-alt text-purple-600"></i>
|
||||
</div>
|
||||
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||
준비중
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">월간보고서</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합한 보고서입니다.
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-purple-600 font-medium">
|
||||
<i class="fas fa-chart-line mr-1"></i>월간 분석
|
||||
</span>
|
||||
<i class="fas fa-arrow-right text-gray-400"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 보고서 안내 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<i class="fas fa-info-circle text-blue-500 mr-2"></i>보고서 이용 안내
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold text-gray-800">📊 일일보고서</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 관리함의 진행 중 항목 무조건 포함</li>
|
||||
<li>• 완료됨 항목은 첫 내보내기에만 포함</li>
|
||||
<li>• 프로젝트별 개별 생성</li>
|
||||
<li>• 엑셀 형태로 다운로드</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold text-gray-800">🚀 향후 계획</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 주간보고서: 주간 집계 및 트렌드 분석</li>
|
||||
<li>• 월간보고서: 월간 성과 및 개선사항</li>
|
||||
<li>• 자동 이메일 발송 기능</li>
|
||||
<li>• 대시보드 형태의 실시간 리포트</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/static/js/core/auth-manager.js"></script>
|
||||
<script src="/static/js/core/permissions.js"></script>
|
||||
<script src="/static/js/components/common-header.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
|
||||
<script>
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('보고서 메인 페이지 로드 시작');
|
||||
|
||||
// AuthManager 로드 대기
|
||||
const checkAuthManager = async () => {
|
||||
if (window.authManager) {
|
||||
try {
|
||||
// 인증 확인
|
||||
const isAuthenticated = await window.authManager.checkAuth();
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 공통 헤더 초기화
|
||||
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
|
||||
if (window.commonHeader && user.id) {
|
||||
await window.commonHeader.init(user, 'reports');
|
||||
}
|
||||
|
||||
console.log('보고서 메인 페이지 로드 완료');
|
||||
} catch (error) {
|
||||
console.error('페이지 초기화 오류:', error);
|
||||
}
|
||||
} else {
|
||||
setTimeout(checkAuthManager, 100);
|
||||
}
|
||||
};
|
||||
checkAuthManager();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,8 +6,8 @@ const API_BASE_URL = (() => {
|
||||
|
||||
console.log('🔧 API URL 생성 - hostname:', hostname, 'protocol:', protocol, 'port:', port);
|
||||
|
||||
// 로컬 환경 (포트 있음)
|
||||
if (port === '16080') {
|
||||
// 로컬 환경 (localhost 또는 127.0.0.1이고 포트 있음)
|
||||
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port) {
|
||||
const url = `${protocol}//${hostname}:${port}/api`;
|
||||
console.log('🏠 로컬 환경 URL:', url);
|
||||
return url;
|
||||
@@ -190,13 +190,16 @@ const AuthAPI = {
|
||||
// Issues API
|
||||
const IssuesAPI = {
|
||||
create: async (issueData) => {
|
||||
// photos 배열 처리 (최대 2장)
|
||||
// photos 배열 처리 (최대 5장)
|
||||
const dataToSend = {
|
||||
category: issueData.category,
|
||||
description: issueData.description,
|
||||
project_id: issueData.project_id,
|
||||
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
|
||||
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : null
|
||||
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/', {
|
||||
@@ -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
|
||||
const ReportsAPI = {
|
||||
getSummary: (startDate, endDate) => apiRequest('/reports/summary', {
|
||||
|
||||
@@ -35,7 +35,7 @@ class CommonHeader {
|
||||
},
|
||||
{
|
||||
id: 'issues_view',
|
||||
title: '부적합 조회',
|
||||
title: '신고내용조회',
|
||||
icon: 'fas fa-search',
|
||||
url: '/issue-view.html',
|
||||
pageName: 'issues_view',
|
||||
@@ -93,7 +93,33 @@ class CommonHeader {
|
||||
url: '/reports.html',
|
||||
pageName: 'reports',
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50 hover:bg-red-100'
|
||||
bgColor: 'bg-red-50 hover:bg-red-100',
|
||||
subMenus: [
|
||||
{
|
||||
id: 'reports_daily',
|
||||
title: '일일보고서',
|
||||
icon: 'fas fa-file-excel',
|
||||
url: '/reports-daily.html',
|
||||
pageName: 'reports_daily',
|
||||
color: 'text-green-600'
|
||||
},
|
||||
{
|
||||
id: 'reports_weekly',
|
||||
title: '주간보고서',
|
||||
icon: 'fas fa-calendar-week',
|
||||
url: '/reports-weekly.html',
|
||||
pageName: 'reports_weekly',
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 'reports_monthly',
|
||||
title: '월간보고서',
|
||||
icon: 'fas fa-calendar-alt',
|
||||
url: '/reports-monthly.html',
|
||||
pageName: 'reports_monthly',
|
||||
color: 'text-purple-600'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'projects_manage',
|
||||
|
||||
@@ -46,7 +46,14 @@ class PagePermissionManager {
|
||||
|
||||
try {
|
||||
// API에서 사용자별 페이지 권한 가져오기
|
||||
const response = await fetch(`/api/users/${this.currentUser.id}/page-permissions`, {
|
||||
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`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
@@ -198,7 +205,20 @@ class PagePermissionManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/page-permissions/grant', {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -230,7 +250,20 @@ class PagePermissionManager {
|
||||
*/
|
||||
async getUserPagePermissions(userId) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/page-permissions`, {
|
||||
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`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* M-Project 작업보고서 시스템
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'mproject-v1.0.0';
|
||||
const STATIC_CACHE = 'mproject-static-v1.0.0';
|
||||
const DYNAMIC_CACHE = 'mproject-dynamic-v1.0.0';
|
||||
const CACHE_NAME = 'mproject-v1.0.1';
|
||||
const STATIC_CACHE = 'mproject-static-v1.0.1';
|
||||
const DYNAMIC_CACHE = 'mproject-dynamic-v1.0.1';
|
||||
|
||||
// 캐시할 정적 리소스
|
||||
const STATIC_ASSETS = [
|
||||
|
||||
105
restore_script.sh
Executable file
105
restore_script.sh
Executable 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
52
setup_auto_backup.sh
Executable 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/"
|
||||
Reference in New Issue
Block a user