feat: 목록 관리 및 보고서 페이지 개선

- 목록 관리 페이지에 고급 필터링 시스템 추가
  - 프로젝트별, 검토상태별, 날짜별 필터링
  - 검토 완료/필요 항목 시각적 구분 및 정렬
  - 해결 시간 입력 + 확인 버튼으로 검토 완료 처리

- 부적합 조회 페이지에 동일한 필터링 기능 적용
  - 검토 상태에 따른 카드 스타일링 (음영 처리)
  - JavaScript 템플릿 리터럴 오류 수정

- 보고서 페이지 프로젝트별 분석 기능 추가
  - 프로젝트 선택 드롭다운 추가
  - 총 작업 공수를 프로젝트별 일일공수 데이터로 계산
  - 부적합 처리 시간, 카테고리 분석, 상세 목록 모두 프로젝트별 필터링
  - localStorage 키 이름 통일 (daily-work-data)
This commit is contained in:
hyungi
2025-10-24 10:13:32 +09:00
parent 44e2fb2e44
commit b024a178d0
25 changed files with 3498 additions and 307 deletions

402
API_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,402 @@
# M-Project API 문서
## 개요
작업보고서 시스템의 FastAPI 백엔드 API 엔드포인트 문서
**Base URL:** `http://localhost:16000`
---
## 인증 (Authentication)
### JWT 토큰 기반 인증
- **토큰 타입:** Bearer Token
- **만료 시간:** 7일 (10080분)
- **헤더:** `Authorization: Bearer <token>`
---
## API 엔드포인트
### 🔐 인증 관련 API (`/api/auth`)
#### 1. 로그인
```http
POST /api/auth/login
Content-Type: application/x-www-form-urlencoded
username=hyungi&password=djg3-jj34-X3Q3
```
**응답:**
```json
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "bearer",
"user": {
"id": 1,
"username": "hyungi",
"full_name": "관리자",
"role": "admin",
"is_active": true,
"created_at": "2024-01-01T00:00:00"
}
}
```
#### 2. 현재 사용자 정보
```http
GET /api/auth/me
Authorization: Bearer <token>
```
#### 3. 사용자 생성 (관리자만)
```http
POST /api/auth/users
Authorization: Bearer <token>
Content-Type: application/json
{
"username": "user1",
"password": "password123",
"full_name": "1",
"role": "user"
}
```
#### 4. 사용자 목록 조회 (관리자만)
```http
GET /api/auth/users?skip=0&limit=100
Authorization: Bearer <token>
```
#### 5. 사용자 수정 (관리자만)
```http
PUT /api/auth/users/{user_id}
Authorization: Bearer <token>
Content-Type: application/json
{
"full_name": " ",
"role": "admin"
}
```
#### 6. 사용자 삭제 (관리자만)
```http
DELETE /api/auth/users/{username}
Authorization: Bearer <token>
```
#### 7. 비밀번호 변경
```http
POST /api/auth/change-password
Authorization: Bearer <token>
Content-Type: application/json
{
"current_password": "old_password",
"new_password": "new_password"
}
```
---
### 📁 프로젝트 관리 API (`/api/projects`)
#### 1. 프로젝트 생성 (관리자만)
```http
POST /api/projects
Authorization: Bearer <token>
Content-Type: application/json
{
"job_no": "JOB-2024-001",
"project_name": " "
}
```
**응답:**
```json
{
"id": 1,
"job_no": "JOB-2024-001",
"project_name": "신규 프로젝트",
"created_by_id": 1,
"created_by": {
"id": 1,
"username": "hyungi",
"full_name": "관리자",
"role": "admin"
},
"created_at": "2024-01-01T00:00:00",
"is_active": true
}
```
#### 2. 프로젝트 목록 조회
```http
GET /api/projects?skip=0&limit=100&active_only=true
Authorization: Bearer <token>
```
#### 3. 특정 프로젝트 조회
```http
GET /api/projects/{project_id}
Authorization: Bearer <token>
```
#### 4. 프로젝트 수정 (관리자만)
```http
PUT /api/projects/{project_id}
Authorization: Bearer <token>
Content-Type: application/json
{
"project_name": " ",
"is_active": true
}
```
#### 5. 프로젝트 삭제 (관리자만)
```http
DELETE /api/projects/{project_id}
Authorization: Bearer <token>
```
---
### 🚨 부적합 사항 API (`/api/issues`)
#### 1. 부적합 사항 생성
```http
POST /api/issues
Authorization: Bearer <token>
Content-Type: application/json
{
"category": "material_missing",
"description": " ",
"photo": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...",
"photo2": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
}
```
**카테고리 값:**
- `material_missing`: 자재누락
- `design_error`: 설계미스
- `incoming_defect`: 입고자재 불량
- `inspection_miss`: 검사미스
#### 2. 부적합 사항 목록 조회
```http
GET /api/issues?skip=0&limit=100
Authorization: Bearer <token>
```
#### 3. 특정 부적합 사항 조회
```http
GET /api/issues/{issue_id}
Authorization: Bearer <token>
```
#### 4. 부적합 사항 수정
```http
PUT /api/issues/{issue_id}
Authorization: Bearer <token>
Content-Type: application/json
{
"category": "design_error",
"description": " ",
"work_hours": 2.5,
"status": "complete",
"detail_notes": " "
}
```
**상태 값:**
- `new`: 신규
- `progress`: 진행중
- `complete`: 완료
#### 5. 부적합 사항 삭제
```http
DELETE /api/issues/{issue_id}
Authorization: Bearer <token>
```
---
### 📊 일일 공수 API (`/api/daily-work`)
#### 1. 일일 공수 생성
```http
POST /api/daily-work
Authorization: Bearer <token>
Content-Type: application/json
{
"date": "2024-01-01T00:00:00",
"worker_count": 10,
"overtime_workers": 3,
"overtime_hours": 4.0
}
```
#### 2. 일일 공수 목록 조회
```http
GET /api/daily-work?skip=0&limit=100
Authorization: Bearer <token>
```
#### 3. 특정 일일 공수 조회
```http
GET /api/daily-work/{work_id}
Authorization: Bearer <token>
```
#### 4. 일일 공수 수정
```http
PUT /api/daily-work/{work_id}
Authorization: Bearer <token>
Content-Type: application/json
{
"worker_count": 12,
"overtime_workers": 5,
"overtime_hours": 6.0
}
```
#### 5. 일일 공수 삭제
```http
DELETE /api/daily-work/{work_id}
Authorization: Bearer <token>
```
---
### 📈 보고서 API (`/api/reports`)
#### 1. 보고서 생성
```http
POST /api/reports/generate
Authorization: Bearer <token>
Content-Type: application/json
{
"start_date": "2024-01-01T00:00:00",
"end_date": "2024-01-31T23:59:59"
}
```
**응답:**
```json
{
"start_date": "2024-01-01T00:00:00",
"end_date": "2024-01-31T23:59:59",
"total_hours": 156.5,
"total_issues": 25,
"category_stats": {
"material_missing": 10,
"dimension_defect": 8,
"incoming_defect": 7
},
"completed_issues": 20,
"average_resolution_time": 2.3
}
```
---
## 헬스체크
### 서버 상태 확인
```http
GET /api/health
```
**응답:**
```json
{
"status": "healthy"
}
```
---
## 에러 응답 형식
### 인증 오류 (401)
```json
{
"detail": "Could not validate credentials"
}
```
### 권한 오류 (403)
```json
{
"detail": "관리자 권한이 필요합니다."
}
```
### 리소스 없음 (404)
```json
{
"detail": "프로젝트를 찾을 수 없습니다."
}
```
### 유효성 검사 오류 (422)
```json
{
"detail": [
{
"loc": ["body", "job_no"],
"msg": "field required",
"type": "value_error.missing"
}
]
}
```
---
## 파일 업로드
### 이미지 업로드
- **형식:** Base64 인코딩된 이미지 데이터
- **지원 형식:** JPEG, PNG, GIF, WebP
- **최대 크기:** 10MB
- **저장 위치:** `/app/uploads/`
### Base64 형식 예시
```
data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=
```
---
## 개발 환경 설정
### 환경 변수
```env
DATABASE_URL=postgresql://mproject:mproject2024@db:5432/mproject
SECRET_KEY=your-secret-key-here-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=10080
ADMIN_USERNAME=hyungi
ADMIN_PASSWORD=djg3-jj34-X3Q3
TZ=Asia/Seoul
```
### Docker 컨테이너 실행
```bash
docker-compose up -d
```
### API 문서 확인
- **Swagger UI:** http://localhost:16000/docs
- **ReDoc:** http://localhost:16000/redoc

173
DATABASE_SCHEMA.md Normal file
View File

@@ -0,0 +1,173 @@
# M-Project 데이터베이스 스키마 문서
## 개요
작업보고서 시스템의 PostgreSQL 데이터베이스 스키마 정의
---
## 테이블 구조
### 1. users (사용자)
사용자 계정 정보를 저장하는 테이블
| 컬럼명 | 타입 | 제약조건 | 설명 |
|--------|------|----------|------|
| id | INTEGER | PRIMARY KEY, AUTO_INCREMENT | 사용자 고유 ID |
| username | VARCHAR | UNIQUE, NOT NULL, INDEX | 로그인 아이디 |
| hashed_password | VARCHAR | NOT NULL | 암호화된 비밀번호 |
| full_name | VARCHAR | NULL | 사용자 실명 |
| role | ENUM | DEFAULT 'user' | 사용자 권한 (admin, user) |
| is_active | BOOLEAN | DEFAULT TRUE | 계정 활성화 상태 |
| created_at | TIMESTAMP | DEFAULT NOW() | 계정 생성일시 |
**인덱스:**
- `idx_users_username` ON username
- `idx_users_role` ON role
**ENUM 값:**
- role: 'admin', 'user'
---
### 2. projects (프로젝트)
프로젝트 정보를 저장하는 테이블
| 컬럼명 | 타입 | 제약조건 | 설명 |
|--------|------|----------|------|
| id | INTEGER | PRIMARY KEY, AUTO_INCREMENT | 프로젝트 고유 ID |
| job_no | VARCHAR(50) | UNIQUE, NOT NULL, INDEX | Job 번호 |
| project_name | VARCHAR(200) | NOT NULL | 프로젝트 이름 |
| created_by_id | INTEGER | FOREIGN KEY → users(id) | 생성자 ID |
| created_at | TIMESTAMP | DEFAULT NOW() | 생성일시 |
| is_active | BOOLEAN | DEFAULT TRUE | 활성 상태 |
**인덱스:**
- `idx_projects_job_no` ON job_no
- `idx_projects_created_by_id` ON created_by_id
- `idx_projects_is_active` ON is_active
**외래키:**
- created_by_id → users(id)
---
### 3. issues (부적합 사항)
부적합 사항 정보를 저장하는 테이블
| 컬럼명 | 타입 | 제약조건 | 설명 |
|--------|------|----------|------|
| id | INTEGER | PRIMARY KEY, AUTO_INCREMENT | 부적합 사항 고유 ID |
| photo_path | VARCHAR | NULL | 첫 번째 사진 경로 |
| photo_path2 | VARCHAR | NULL | 두 번째 사진 경로 |
| category | ENUM | NOT NULL | 부적합 카테고리 |
| description | TEXT | NOT NULL | 부적합 사항 설명 |
| status | ENUM | DEFAULT 'new' | 처리 상태 |
| reporter_id | INTEGER | FOREIGN KEY → users(id) | 보고자 ID |
| report_date | TIMESTAMP | DEFAULT NOW() | 보고일시 |
| work_hours | FLOAT | DEFAULT 0 | 작업 시간 |
| detail_notes | TEXT | NULL | 상세 메모 |
**인덱스:**
- `idx_issues_reporter_id` ON reporter_id
- `idx_issues_status` ON status
- `idx_issues_category` ON category
- `idx_issues_report_date` ON report_date
**ENUM 값:**
- category: 'material_missing', 'design_error', 'incoming_defect', 'inspection_miss'
- status: 'new', 'progress', 'complete'
**외래키:**
- reporter_id → users(id)
---
### 4. daily_works (일일 공수)
일일 작업 공수 정보를 저장하는 테이블
| 컬럼명 | 타입 | 제약조건 | 설명 |
|--------|------|----------|------|
| id | INTEGER | PRIMARY KEY, AUTO_INCREMENT | 일일 공수 고유 ID |
| date | TIMESTAMP | NOT NULL, INDEX | 작업 날짜 |
| worker_count | INTEGER | NOT NULL | 작업자 수 |
| regular_hours | FLOAT | NOT NULL | 정규 시간 |
| overtime_workers | INTEGER | DEFAULT 0 | 야근 작업자 수 |
| overtime_hours | FLOAT | DEFAULT 0 | 야근 시간 |
| overtime_total | FLOAT | DEFAULT 0 | 야근 총 시간 |
| total_hours | FLOAT | NOT NULL | 총 작업 시간 |
| created_by_id | INTEGER | FOREIGN KEY → users(id) | 생성자 ID |
| created_at | TIMESTAMP | DEFAULT NOW() | 생성일시 |
**인덱스:**
- `idx_daily_works_date` ON date
- `idx_daily_works_created_by_id` ON created_by_id
**외래키:**
- created_by_id → users(id)
---
## 관계도 (Relationships)
```
users (1) ←→ (N) projects
users (1) ←→ (N) issues
users (1) ←→ (N) daily_works
```
---
## 마이그레이션 파일 목록
1. `001_init.sql` - 초기 테이블 생성
2. `002_add_second_photo.sql` - issues 테이블에 두 번째 사진 필드 추가
3. `003_update_categories.sql` - 카테고리 업데이트
4. `004_fix_category_values.sql` - 카테고리 값 수정
5. `005_recreate_enum_type.sql` - ENUM 타입 재생성
6. `006_add_projects_table.sql` - projects 테이블 추가
---
## 기본 데이터
### 관리자 계정
- **Username:** hyungi
- **Password:** djg3-jj34-X3Q3
- **Role:** admin
- **Full Name:** 관리자
---
## 데이터베이스 연결 정보
- **Host:** localhost (Docker 내부: db)
- **Port:** 16432 (외부), 5432 (내부)
- **Database:** mproject
- **Username:** mproject
- **Password:** mproject2024
---
## 주의사항
1. **비밀번호 암호화:** bcrypt 해시 사용
2. **시간대:** 모든 TIMESTAMP는 Asia/Seoul (KST) 기준
3. **파일 업로드:** 사진 파일은 `/app/uploads` 디렉토리에 저장
4. **소프트 삭제:** projects 테이블은 is_active 필드로 소프트 삭제 구현
5. **인덱스 최적화:** 자주 조회되는 컬럼에 인덱스 설정
---
## 백업 및 복구
### 백업
```bash
docker-compose exec db pg_dump -U mproject mproject > backup.sql
```
### 복구
```bash
docker-compose exec -T db psql -U mproject mproject < backup.sql
```

328
PROJECT_OVERVIEW.md Normal file
View File

@@ -0,0 +1,328 @@
# M-Project 프로젝트 개요
## 📋 프로젝트 소개
작업보고서 시스템 - 부적합 사항 관리 및 공수 계산을 위한 웹 애플리케이션
---
## 🏗️ 시스템 아키텍처
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Database │
│ (HTML/JS) │◄──►│ (FastAPI) │◄──►│ (PostgreSQL) │
│ Port: 16080 │ │ Port: 16000 │ │ Port: 16432 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
▲ ▲ ▲
│ │ │
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Nginx │ │ Docker │ │ File Storage │
│ (Reverse Proxy)│ │ (Container) │ │ (/uploads) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
---
## 📁 프로젝트 구조
```
M-Project/
├── backend/ # FastAPI 백엔드
│ ├── database/ # 데이터베이스 관련
│ │ ├── database.py # DB 연결 설정
│ │ ├── models.py # SQLAlchemy 모델
│ │ └── schemas.py # Pydantic 스키마
│ ├── migrations/ # DB 마이그레이션 파일
│ │ ├── 001_init.sql
│ │ ├── 002_add_second_photo.sql
│ │ ├── 003_update_categories.sql
│ │ ├── 004_fix_category_values.sql
│ │ ├── 005_recreate_enum_type.sql
│ │ └── 006_add_projects_table.sql
│ ├── routers/ # API 라우터
│ │ ├── auth.py # 인증 관련 API
│ │ ├── projects.py # 프로젝트 관리 API
│ │ ├── issues.py # 부적합 사항 API
│ │ ├── daily_work.py # 일일 공수 API
│ │ └── reports.py # 보고서 API
│ ├── services/ # 비즈니스 로직
│ │ ├── auth_service.py # 인증 서비스
│ │ └── file_service.py # 파일 처리 서비스
│ ├── uploads/ # 업로드된 파일 저장소
│ ├── main.py # FastAPI 애플리케이션 진입점
│ ├── requirements.txt # Python 의존성
│ └── Dockerfile # 백엔드 Docker 설정
├── frontend/ # 프론트엔드
│ ├── static/ # 정적 파일
│ │ ├── css/ # 스타일시트
│ │ └── js/ # JavaScript 파일
│ │ ├── api.js # API 통신
│ │ ├── date-utils.js # 날짜 유틸리티
│ │ └── image-utils.js # 이미지 처리
│ ├── admin.html # 관리자 페이지
│ ├── index.html # 메인 페이지
│ ├── daily-work.html # 일일 공수 페이지
│ ├── chart.html # 차트 페이지
│ └── issue-view.html # 부적합 사항 상세 페이지
├── nginx/ # Nginx 설정
│ ├── nginx.conf # Nginx 설정 파일
│ └── Dockerfile # Nginx Docker 설정
├── docker-compose.yml # Docker Compose 설정
├── DATABASE_SCHEMA.md # 데이터베이스 스키마 문서
├── API_DOCUMENTATION.md # API 문서
├── PROJECT_OVERVIEW.md # 프로젝트 개요 (이 파일)
└── README.md # 프로젝트 설명
```
---
## 🚀 시작하기
### 1. 시스템 요구사항
- Docker & Docker Compose
- Git
### 2. 프로젝트 클론 및 실행
```bash
# 프로젝트 클론
git clone <repository-url>
cd M-Project
# Docker 컨테이너 실행
docker-compose up -d
# 로그 확인
docker-compose logs -f
```
### 3. 접속 정보
- **프론트엔드:** http://localhost:16080
- **백엔드 API:** http://localhost:16000
- **API 문서:** http://localhost:16000/docs
- **데이터베이스:** localhost:16432
### 4. 기본 계정
- **관리자:** hyungi / djg3-jj34-X3Q3
- **일반 사용자:** inspector1 / pass123, inspector2 / pass456
---
## 👥 사용자 권한
### 관리자 (admin)
- ✅ 프로젝트 생성/수정/삭제
- ✅ 사용자 계정 관리
- ✅ 모든 부적합 사항 조회/수정
- ✅ 보고서 생성
- ✅ 시스템 설정
### 일반 사용자 (user)
- ✅ 부적합 사항 등록
- ✅ 자신이 등록한 부적합 사항 수정
- ✅ 일일 공수 입력
- ✅ 보고서 조회
- ❌ 프로젝트 관리
- ❌ 사용자 관리
---
## 🔧 주요 기능
### 1. 프로젝트 관리 (신규 추가)
- **Job No.** 기반 프로젝트 생성
- 관리자만 접근 가능한 프로젝트 생성 배너
- 프로젝트 목록 조회 및 관리
### 2. 부적합 사항 관리
- 사진 업로드 (최대 2장)
- 카테고리별 분류
- 자재누락 (material_missing)
- 설계미스 (design_error)
- 입고자재 불량 (incoming_defect)
- 검사미스 (inspection_miss)
- 상태 관리 (신규/진행중/완료)
- 작업 시간 기록
### 3. 일일 공수 관리
- 작업자 수 입력
- 정규/야근 시간 계산
- 날짜별 공수 추적
### 4. 보고서 생성
- 기간별 통계
- 카테고리별 분석
- 작업 시간 집계
- 인쇄 기능
---
## 🛠️ 개발 가이드
### 백엔드 개발
```bash
# 백엔드 컨테이너 접속
docker-compose exec backend bash
# 의존성 설치
pip install -r requirements.txt
# 개발 서버 실행
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### 프론트엔드 개발
- 정적 파일은 `frontend/` 디렉토리에 위치
- Nginx를 통해 서빙됨
- 실시간 수정 가능 (볼륨 마운트)
### 데이터베이스 관리
```bash
# DB 접속
docker-compose exec db psql -U mproject -d mproject
# 마이그레이션 실행
docker-compose exec db psql -U mproject -d mproject -f /docker-entrypoint-initdb.d/새파일.sql
# 백업
docker-compose exec db pg_dump -U mproject mproject > backup.sql
# 복구
docker-compose exec -T db psql -U mproject mproject < backup.sql
```
---
## 🔍 트러블슈팅
### 1. 백엔드가 시작되지 않는 경우
```bash
# 로그 확인
docker-compose logs backend
# 컨테이너 재시작
docker-compose restart backend
```
### 2. 데이터베이스 연결 오류
```bash
# DB 상태 확인
docker-compose ps db
# DB 로그 확인
docker-compose logs db
# DB 재시작
docker-compose restart db
```
### 3. 마이그레이션 오류
```bash
# 수동 마이그레이션 실행
docker-compose exec db psql -U mproject -d mproject -f /docker-entrypoint-initdb.d/파일명.sql
```
### 4. 파일 업로드 오류
```bash
# uploads 디렉토리 권한 확인
docker-compose exec backend ls -la /app/uploads
# 권한 수정
docker-compose exec backend chmod 755 /app/uploads
```
---
## 📊 모니터링
### 헬스체크
```bash
# 백엔드 상태 확인
curl http://localhost:16000/api/health
# 프론트엔드 상태 확인
curl http://localhost:16080
```
### 로그 모니터링
```bash
# 전체 로그
docker-compose logs -f
# 특정 서비스 로그
docker-compose logs -f backend
docker-compose logs -f db
docker-compose logs -f nginx
```
---
## 🔐 보안 고려사항
### 1. 인증
- JWT 토큰 기반 인증
- 토큰 만료 시간: 7일
- bcrypt 해시를 사용한 비밀번호 암호화
### 2. 권한 관리
- 역할 기반 접근 제어 (RBAC)
- API 레벨에서 권한 검증
- 프론트엔드 UI 레벨에서 권한별 표시/숨김
### 3. 데이터 보호
- SQL 인젝션 방지 (SQLAlchemy ORM 사용)
- XSS 방지 (입력값 검증)
- 파일 업로드 검증
---
## 📈 성능 최적화
### 1. 데이터베이스
- 자주 조회되는 컬럼에 인덱스 설정
- 페이지네이션 구현
- 연결 풀링 사용
### 2. API
- 응답 캐싱
- 압축 전송
- 비동기 처리
### 3. 프론트엔드
- 이미지 최적화
- 지연 로딩
- 로컬 스토리지 활용
---
## 🚀 배포 가이드
### 프로덕션 환경 설정
1. 환경 변수 수정 (`docker-compose.yml`)
- `SECRET_KEY` 변경
- `ADMIN_PASSWORD` 변경
- 데이터베이스 비밀번호 변경
2. SSL 인증서 설정
3. 방화벽 설정
4. 백업 스케줄 설정
### 업데이트 절차
1. 코드 업데이트
2. 데이터베이스 백업
3. 마이그레이션 실행
4. 컨테이너 재시작
5. 헬스체크 확인
---
## 📞 지원 및 문의
### 개발자 정보
- **개발자:** M-Project Team
- **이메일:** support@m-project.com
- **문서 업데이트:** 2024-10-24
### 버전 정보
- **현재 버전:** 1.0.0
- **최종 업데이트:** 2024-10-24
- **호환성:** Docker 20.10+, Python 3.11+

View File

@@ -4,10 +4,12 @@
## 🚀 빠른 시작 ## 🚀 빠른 시작
1. 웹 서버 실행: ### Docker를 사용한 실행 (권장)
1. Docker 컨테이너 실행:
```bash ```bash
cd M-Project cd M-Project
python3 -m http.server 16080 docker-compose up -d
``` ```
2. 브라우저에서 접속: 2. 브라우저에서 접속:
@@ -16,26 +18,43 @@ http://localhost:16080
``` ```
3. 로그인: 3. 로그인:
- **관리자**: `hyungi` / `djg3-jj34-X3Q3`
- 검사자1: `inspector1` / `pass123` - 검사자1: `inspector1` / `pass123`
- 검사자2: `inspector2` / `pass456` - 검사자2: `inspector2` / `pass456`
- 관리자: `admin` / `admin123`
### 간단한 웹 서버 실행 (개발용)
```bash
cd M-Project
python3 -m http.server 16080
```
## 📱 주요 기능 ## 📱 주요 기능
### 1. 부적합 등록 (모바일 최적화) ### 1. 프로젝트 관리 (신규 추가 ✨)
- 사진 촬영 또는 업로드 - **Job No.** 기반 프로젝트 생성
- 위치, 카테고리, 설명 입력 - 관리자 전용 프로젝트 생성 배너
- 긴급도 선택 (낮음/보통/높음) - 프로젝트 목록 조회 및 관리
### 2. 목록 관리 ### 2. 부적합 등록 (모바일 최적화)
- 사진 촬영 또는 업로드 (최대 2장)
- 카테고리별 분류 (자재누락, 설계미스, 입고자재 불량, 검사미스)
- 상세 설명 입력
### 3. 목록 관리
- 등록된 부적합 사항 조회 - 등록된 부적합 사항 조회
- 작업 시간 입력 - 작업 시간 입력
- 상태 변경 (신규→진행중→완료) - 상태 변경 (신규→진행중→완료)
- 추가 메모 작성 - 추가 메모 작성
### 3. 보고서 ### 4. 일일 공수 관리
- 작업자 수 및 정규/야근 시간 입력
- 날짜별 공수 추적
- 자동 계산 기능
### 5. 보고서
- 작업 기간 및 총 공수 자동 계산 - 작업 기간 및 총 공수 자동 계산
- 긴급도별 통계 - 카테고리별 통계
- 부적합 사항 상세 내역 - 부적합 사항 상세 내역
- 인쇄 가능한 형식 - 인쇄 가능한 형식
@@ -50,10 +69,13 @@ M-Project/
## 🛠️ 기술 스택 ## 🛠️ 기술 스택
- **Backend**: FastAPI (Python), SQLAlchemy, PostgreSQL
- **Frontend**: HTML5, Tailwind CSS, JavaScript (Vanilla) - **Frontend**: HTML5, Tailwind CSS, JavaScript (Vanilla)
- **Storage**: LocalStorage (브라우저 로컬 저장) - **Database**: PostgreSQL 15
- **Container**: Docker & Docker Compose
- **Web Server**: Nginx
- **Authentication**: JWT (JSON Web Token)
- **Icons**: Font Awesome - **Icons**: Font Awesome
- **Charts**: Chart.js
## 📋 데이터 구조 ## 📋 데이터 구조
@@ -79,16 +101,23 @@ M-Project/
## ⚡ 특징 ## ⚡ 특징
- **모바일 우선**: 현장에서 스마트폰으로 쉽게 입력 - **모바일 우선**: 현장에서 스마트폰으로 쉽게 입력
- **오프라인 작동**: 인터넷 연결 없이도 사용 가능 - **실시간 데이터**: PostgreSQL 데이터베이스로 실시간 동기화
- **간단한 설치**: 별도의 서버나 데이터베이스 불필요 - **권한 관리**: 관리자/일반 사용자 역할 기반 접근 제어
- **즉시 사용**: 웹 서버만 실행하면 바로 사용 - **Docker 기반**: 간편한 배포 및 확장성
- **RESTful API**: 표준 API 설계로 확장 가능
## 🔒 보안 주의사항 ## 🔒 보안 기능
현재 버전은 프로토타입으로: - **JWT 인증**: 토큰 기반 보안 인증 시스템
- 사용자 인증이 간단함 (하드코딩된 계정) - **비밀번호 암호화**: bcrypt 해시를 사용한 안전한 비밀번호 저장
- 데이터가 브라우저에만 저장됨 - **권한 기반 접근**: 역할별 API 접근 제어
- 실제 운영 환경에서는 백엔드 서버 구축 필요 - **SQL 인젝션 방지**: SQLAlchemy ORM 사용
## 📚 문서
- **[DATABASE_SCHEMA.md](DATABASE_SCHEMA.md)**: 데이터베이스 스키마 상세 문서
- **[API_DOCUMENTATION.md](API_DOCUMENTATION.md)**: API 엔드포인트 문서
- **[PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md)**: 프로젝트 전체 구조 및 가이드
## 📝 라이선스 ## 📝 라이선스
@@ -96,4 +125,5 @@ M-Project/
--- ---
작성일: 2025-09-17 **최종 업데이트**: 2024-10-24
**버전**: 1.0.0 (프로젝트 관리 기능 추가)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, ForeignKey, Enum from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -42,6 +42,7 @@ class User(Base):
# Relationships # Relationships
issues = relationship("Issue", back_populates="reporter") issues = relationship("Issue", back_populates="reporter")
daily_works = relationship("DailyWork", back_populates="created_by") daily_works = relationship("DailyWork", back_populates="created_by")
projects = relationship("Project", back_populates="created_by")
class Issue(Base): class Issue(Base):
__tablename__ = "issues" __tablename__ = "issues"
@@ -53,12 +54,28 @@ class Issue(Base):
description = Column(Text, nullable=False) description = Column(Text, nullable=False)
status = Column(Enum(IssueStatus), default=IssueStatus.new) status = Column(Enum(IssueStatus), default=IssueStatus.new)
reporter_id = Column(Integer, ForeignKey("users.id")) reporter_id = Column(Integer, ForeignKey("users.id"))
project_id = Column(BigInteger, ForeignKey("projects.id"))
report_date = Column(DateTime, default=get_kst_now) report_date = Column(DateTime, default=get_kst_now)
work_hours = Column(Float, default=0) work_hours = Column(Float, default=0)
detail_notes = Column(Text) detail_notes = Column(Text)
# Relationships # Relationships
reporter = relationship("User", back_populates="issues") reporter = relationship("User", back_populates="issues")
project = relationship("Project", back_populates="issues")
class Project(Base):
__tablename__ = "projects"
id = Column(BigInteger, primary_key=True, index=True)
job_no = Column(String, unique=True, nullable=False, index=True)
project_name = Column(String, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=get_kst_now)
is_active = Column(Boolean, default=True)
# Relationships
created_by = relationship("User", back_populates="projects")
issues = relationship("Issue", back_populates="project")
class DailyWork(Base): class DailyWork(Base):
__tablename__ = "daily_works" __tablename__ = "daily_works"

View File

@@ -62,6 +62,7 @@ class LoginRequest(BaseModel):
class IssueBase(BaseModel): class IssueBase(BaseModel):
category: IssueCategory category: IssueCategory
description: str description: str
project_id: Optional[int] = None
class IssueCreate(IssueBase): class IssueCreate(IssueBase):
photo: Optional[str] = None # Base64 encoded image photo: Optional[str] = None # Base64 encoded image
@@ -70,6 +71,7 @@ class IssueCreate(IssueBase):
class IssueUpdate(BaseModel): class IssueUpdate(BaseModel):
category: Optional[IssueCategory] = None category: Optional[IssueCategory] = None
description: Optional[str] = None description: Optional[str] = None
project_id: Optional[int] = None
work_hours: Optional[float] = None work_hours: Optional[float] = None
detail_notes: Optional[str] = None detail_notes: Optional[str] = None
status: Optional[IssueStatus] = None status: Optional[IssueStatus] = None
@@ -83,6 +85,8 @@ class Issue(IssueBase):
status: IssueStatus status: IssueStatus
reporter_id: int reporter_id: int
reporter: User reporter: User
project_id: Optional[int] = None
# project: Optional['Project'] = None # 순환 참조 방지를 위해 제거
report_date: datetime report_date: datetime
work_hours: float work_hours: float
detail_notes: Optional[str] = None detail_notes: Optional[str] = None
@@ -90,6 +94,29 @@ class Issue(IssueBase):
class Config: class Config:
from_attributes = True from_attributes = True
# Project schemas
class ProjectBase(BaseModel):
job_no: str = Field(..., min_length=1, max_length=50)
project_name: str = Field(..., min_length=1, max_length=200)
class ProjectCreate(ProjectBase):
pass
class ProjectUpdate(BaseModel):
project_name: Optional[str] = Field(None, min_length=1, max_length=200)
is_active: Optional[bool] = None
class Project(ProjectBase):
id: int
created_by_id: int
created_by: User
created_at: datetime
is_active: bool
# issues: Optional[List['Issue']] = None # 순환 참조 방지를 위해 제거
class Config:
from_attributes = True
# Daily Work schemas # Daily Work schemas
class DailyWorkBase(BaseModel): class DailyWorkBase(BaseModel):
date: datetime date: datetime

View File

@@ -5,7 +5,7 @@ import uvicorn
from database.database import engine, get_db from database.database import engine, get_db
from database.models import Base from database.models import Base
from routers import auth, issues, daily_work, reports from routers import auth, issues, daily_work, reports, projects
from services.auth_service import create_admin_user from services.auth_service import create_admin_user
# 데이터베이스 테이블 생성 # 데이터베이스 테이블 생성
@@ -34,6 +34,7 @@ app.include_router(auth.router)
app.include_router(issues.router) app.include_router(issues.router)
app.include_router(daily_work.router) app.include_router(daily_work.router)
app.include_router(reports.router) app.include_router(reports.router)
app.include_router(projects.router)
# 시작 시 관리자 계정 생성 # 시작 시 관리자 계정 생성
@app.on_event("startup") @app.on_event("startup")

View File

@@ -0,0 +1,14 @@
-- 프로젝트 테이블 생성
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) UNIQUE NOT NULL,
project_name VARCHAR(200) NOT NULL,
created_by_id INTEGER REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_projects_job_no ON projects(job_no);
CREATE INDEX IF NOT EXISTS idx_projects_created_by_id ON projects(created_by_id);
CREATE INDEX IF NOT EXISTS idx_projects_is_active ON projects(is_active);

View File

@@ -0,0 +1,15 @@
-- 부적합 사항 테이블에 프로젝트 ID 컬럼 추가
ALTER TABLE issues ADD COLUMN project_id INTEGER;
-- 외래키 제약조건 추가
ALTER TABLE issues ADD CONSTRAINT fk_issues_project_id
FOREIGN KEY (project_id) REFERENCES projects(id);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_issues_project_id ON issues(project_id);
-- 기존 부적합 사항들을 첫 번째 프로젝트로 할당 (있는 경우)
UPDATE issues
SET project_id = (SELECT id FROM projects ORDER BY created_at LIMIT 1)
WHERE project_id IS NULL
AND EXISTS (SELECT 1 FROM projects LIMIT 1);

View File

@@ -0,0 +1,13 @@
-- project_id 컬럼을 BIGINT로 변경
ALTER TABLE issues ALTER COLUMN project_id TYPE BIGINT;
-- projects 테이블의 id도 BIGINT로 변경 (일관성을 위해)
ALTER TABLE projects ALTER COLUMN id TYPE BIGINT;
-- 외래키 제약조건 재생성 (타입 변경으로 인해 필요)
ALTER TABLE issues DROP CONSTRAINT IF EXISTS fk_issues_project_id;
ALTER TABLE issues ADD CONSTRAINT fk_issues_project_id
FOREIGN KEY (project_id) REFERENCES projects(id);
-- 다른 테이블들도 확인하여 project_id 참조하는 곳이 있으면 수정
-- (현재는 issues 테이블만 project_id를 가지고 있음)

Binary file not shown.

126
backend/routers/projects.py Normal file
View File

@@ -0,0 +1,126 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database.database import get_db
from database.models import Project, User, UserRole
from database.schemas import ProjectCreate, ProjectUpdate, Project as ProjectSchema
from routers.auth import get_current_user
router = APIRouter(
prefix="/api/projects",
tags=["projects"]
)
def check_admin_permission(current_user: User = Depends(get_current_user)):
"""관리자 권한 확인"""
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한이 필요합니다."
)
return current_user
@router.post("/", response_model=ProjectSchema)
async def create_project(
project: ProjectCreate,
db: Session = Depends(get_db),
current_user: User = Depends(check_admin_permission)
):
"""프로젝트 생성 (관리자만)"""
# Job No. 중복 확인
existing_project = db.query(Project).filter(Project.job_no == project.job_no).first()
if existing_project:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 Job No.입니다."
)
# 프로젝트 생성
db_project = Project(
job_no=project.job_no,
project_name=project.project_name,
created_by_id=current_user.id
)
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
@router.get("/", response_model=List[ProjectSchema])
async def get_projects(
skip: int = 0,
limit: int = 100,
active_only: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""프로젝트 목록 조회"""
query = db.query(Project)
if active_only:
query = query.filter(Project.is_active == True)
projects = query.offset(skip).limit(limit).all()
return projects
@router.get("/{project_id}", response_model=ProjectSchema)
async def get_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""특정 프로젝트 조회"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프로젝트를 찾을 수 없습니다."
)
return project
@router.put("/{project_id}", response_model=ProjectSchema)
async def update_project(
project_id: int,
project_update: ProjectUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(check_admin_permission)
):
"""프로젝트 수정 (관리자만)"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프로젝트를 찾을 수 없습니다."
)
# 업데이트할 필드만 수정
update_data = project_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
db.commit()
db.refresh(project)
return project
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(check_admin_permission)
):
"""프로젝트 삭제 (비활성화) (관리자만)"""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프로젝트를 찾을 수 없습니다."
)
# 실제 삭제 대신 비활성화
project.is_active = False
db.commit()
return {"message": "프로젝트가 삭제되었습니다."}

View File

@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 프로젝트 생성 - M Project</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="/static/js/api.js?v=20250917"></script>
</head>
<body class="bg-gray-50">
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="bg-white rounded-xl shadow-sm p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">
<i class="fas fa-plus text-green-500 mr-2"></i>API 프로젝트 생성
</h1>
<div class="space-y-4">
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 class="font-semibold text-green-800 mb-2">🎯 작업 내용</h3>
<p class="text-green-700 text-sm">
백엔드 API를 통해 TKR-25009R M Project를 생성합니다.<br>
데이터베이스에 실제 프로젝트 레코드가 생성됩니다.
</p>
</div>
<div id="status" class="space-y-2">
<!-- 상태 메시지가 여기에 표시됩니다 -->
</div>
<button
id="createBtn"
onclick="createProject()"
class="w-full bg-green-500 text-white py-3 px-4 rounded-lg hover:bg-green-600 transition-colors font-medium"
>
<i class="fas fa-plus mr-2"></i>TKR-25009R 프로젝트 생성
</button>
<a href="fix-api-data.html" class="block text-center text-orange-600 hover:text-orange-800 mt-4">
<i class="fas fa-wrench mr-1"></i>API 데이터 수정 도구로 이동
</a>
<a href="debug-data.html" class="block text-center text-blue-600 hover:text-blue-800 mt-2">
<i class="fas fa-bug mr-1"></i>디버그 도구로 이동
</a>
</div>
</div>
</div>
<script>
let currentUser = null;
// 페이지 로드 시 사용자 확인
window.addEventListener('DOMContentLoaded', () => {
const user = TokenManager.getUser();
if (!user) {
alert('로그인이 필요합니다.');
window.location.href = 'index.html';
return;
}
currentUser = user;
if (currentUser.role !== 'admin') {
alert('관리자만 접근 가능합니다.');
window.location.href = 'index.html';
return;
}
addStatus('✅ 관리자 권한 확인됨', 'text-green-600');
});
function addStatus(message, className = 'text-gray-600') {
const statusDiv = document.getElementById('status');
const p = document.createElement('p');
p.className = `text-sm ${className}`;
p.innerHTML = `<i class="fas fa-info-circle mr-2"></i>${message}`;
statusDiv.appendChild(p);
}
async function createProject() {
const btn = document.getElementById('createBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>생성 중...';
try {
// 1. 기존 프로젝트 확인
addStatus('기존 프로젝트 확인 중...');
let existingProjects = [];
try {
existingProjects = await ProjectsAPI.getAll();
addStatus(`기존 프로젝트 ${existingProjects.length}개 발견`, 'text-blue-600');
} catch (error) {
addStatus('기존 프로젝트 조회 실패, 새로 생성합니다.', 'text-yellow-600');
}
// TKR-25009R 프로젝트가 이미 있는지 확인
const existingMProject = existingProjects.find(p => p.job_no === 'TKR-25009R');
if (existingMProject) {
addStatus(`✅ TKR-25009R 프로젝트가 이미 존재합니다 (ID: ${existingMProject.id})`, 'text-green-600');
// localStorage 프로젝트 ID 업데이트
updateLocalStorageProjectId(existingMProject.id);
btn.innerHTML = '<i class="fas fa-check mr-2"></i>이미 존재함';
btn.className = 'w-full bg-blue-500 text-white py-3 px-4 rounded-lg font-medium';
return;
}
// 2. 새 프로젝트 생성
addStatus('TKR-25009R 프로젝트 생성 중...');
const projectData = {
job_no: 'TKR-25009R',
project_name: 'M Project'
};
const newProject = await ProjectsAPI.create(projectData);
addStatus(`✅ 프로젝트 생성 완료! ID: ${newProject.id}`, 'text-green-600');
// 3. localStorage 프로젝트 ID 업데이트
updateLocalStorageProjectId(newProject.id);
// 완료
addStatus('🎉 프로젝트 생성 및 동기화 완료!', 'text-green-600 font-bold');
addStatus('이제 API 데이터 수정 도구를 실행하세요.', 'text-blue-600');
btn.innerHTML = '<i class="fas fa-check mr-2"></i>생성 완료';
btn.className = 'w-full bg-green-500 text-white py-3 px-4 rounded-lg font-medium';
} catch (error) {
addStatus(`❌ 오류 발생: ${error.message}`, 'text-red-600');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo mr-2"></i>다시 시도';
}
}
function updateLocalStorageProjectId(apiProjectId) {
try {
// localStorage 프로젝트 데이터 업데이트
const projects = JSON.parse(localStorage.getItem('work-report-projects') || '[]');
const mProject = projects.find(p => p.jobNo === 'TKR-25009R');
if (mProject) {
const oldId = mProject.id;
mProject.id = apiProjectId;
localStorage.setItem('work-report-projects', JSON.stringify(projects));
addStatus(`localStorage 프로젝트 ID 업데이트: ${oldId}${apiProjectId}`, 'text-blue-600');
// 일일 공수 데이터도 업데이트
const dailyWorkData = JSON.parse(localStorage.getItem('daily-work-data') || '[]');
let updatedCount = 0;
dailyWorkData.forEach(dayData => {
if (dayData.projects) {
dayData.projects.forEach(project => {
if (project.projectId == oldId || project.projectId == 1) {
project.projectId = apiProjectId;
project.projectName = 'TKR-25009R - M Project';
updatedCount++;
}
});
}
});
localStorage.setItem('daily-work-data', JSON.stringify(dailyWorkData));
addStatus(`일일 공수 데이터 ${updatedCount}개 업데이트`, 'text-blue-600');
// localStorage 부적합 사항도 업데이트
const localIssues = JSON.parse(localStorage.getItem('work-report-issues') || '[]');
let issueUpdatedCount = 0;
localIssues.forEach(issue => {
if (issue.projectId == oldId || issue.projectId == 1 || issue.project_id == oldId || issue.project_id == 1) {
issue.projectId = apiProjectId;
issue.project_id = apiProjectId;
issue.projectName = 'TKR-25009R - M Project';
issue.project_name = 'TKR-25009R - M Project';
issueUpdatedCount++;
}
});
localStorage.setItem('work-report-issues', JSON.stringify(localIssues));
addStatus(`localStorage 부적합 사항 ${issueUpdatedCount}개 업데이트`, 'text-blue-600');
}
} catch (error) {
addStatus(`localStorage 업데이트 실패: ${error.message}`, 'text-red-600');
}
}
</script>
</body>
</html>

View File

@@ -98,11 +98,14 @@
<div class="container mx-auto px-4 py-3"> <div class="container mx-auto px-4 py-3">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h1 class="text-xl font-bold text-gray-800"> <h1 class="text-xl font-bold text-gray-800">
<i class="fas fa-clipboard-list mr-2"></i>작업보고서 시스템 <i class="fas fa-clipboard-check text-blue-500 mr-2"></i>작업보고서
</h1> </h1>
<button onclick="AuthAPI.logout()" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm"> <div class="flex items-center gap-4">
<i class="fas fa-sign-out-alt mr-1"></i>로그아웃 <span class="text-sm text-gray-600" id="userDisplay"></span>
</button> <button onclick="logout()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -126,6 +129,9 @@
<a href="index.html#summary" class="nav-link" style="display:none;" id="summaryBtn"> <a href="index.html#summary" class="nav-link" style="display:none;" id="summaryBtn">
<i class="fas fa-chart-bar mr-2"></i>보고서 <i class="fas fa-chart-bar mr-2"></i>보고서
</a> </a>
<a href="project-management.html" class="nav-link" style="display:none;" id="projectBtn">
<i class="fas fa-folder-open mr-2"></i>프로젝트 관리
</a>
<a href="admin.html" class="nav-link" style="display:none;" id="adminBtn"> <a href="admin.html" class="nav-link" style="display:none;" id="adminBtn">
<i class="fas fa-users-cog mr-2"></i>관리 <i class="fas fa-users-cog mr-2"></i>관리
</a> </a>
@@ -152,65 +158,34 @@
id="workDate" id="workDate"
class="input-field w-full px-4 py-3 rounded-lg text-lg" class="input-field w-full px-4 py-3 rounded-lg text-lg"
required required
onchange="loadExistingData()"
> >
</div> </div>
<!-- 인원 입력 --> <!-- 프로젝트별 시간 입력 섹션 -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-users mr-1"></i>작업 인원
</label>
<input
type="number"
id="workerCount"
min="1"
class="input-field w-full px-4 py-3 rounded-lg text-lg"
placeholder="예: 5"
required
>
<p class="text-sm text-gray-500 mt-1">기본 근무시간: 8시간/인</p>
</div>
<!-- 잔업 섹션 -->
<div class="border-t pt-4">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700"> <label class="text-sm font-medium text-gray-700">
<i class="fas fa-clock mr-1"></i>잔업 여부 <i class="fas fa-folder-open mr-1"></i>프로젝트별 작업 시간
</label> </label>
<button <button
type="button" type="button"
id="overtimeToggle" id="addProjectBtn"
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onclick="toggleOvertime()" onclick="addProjectEntry()"
> >
<i class="fas fa-plus mr-2"></i>잔업 추가 <i class="fas fa-plus mr-2"></i>프로젝트 추가
</button> </button>
</div> </div>
<!-- 잔업 입력 영역 --> <div id="projectEntries" class="space-y-3">
<div id="overtimeSection" class="hidden space-y-3 bg-gray-50 p-4 rounded-lg"> <!-- 프로젝트 입력 항목들이 여기에 추가됩니다 -->
<div class="grid grid-cols-2 gap-4"> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">잔업 인원</label> <div class="mt-4 p-4 bg-blue-50 rounded-lg">
<input <div class="flex justify-between items-center">
type="number" <span class="text-sm font-medium text-blue-900">총 작업 시간:</span>
id="overtimeWorkers" <span id="totalHours" class="text-lg font-bold text-blue-600">0시간</span>
min="0"
class="input-field w-full px-3 py-2 rounded"
placeholder="명"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">잔업 시간</label>
<input
type="number"
id="overtimeHours"
min="0"
step="0.5"
class="input-field w-full px-3 py-2 rounded"
placeholder="시간"
>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -246,7 +221,9 @@
<script src="/static/js/date-utils.js?v=20250917"></script> <script src="/static/js/date-utils.js?v=20250917"></script>
<script> <script>
let currentUser = null; let currentUser = null;
let dailyWorks = []; let projects = [];
let dailyWorkData = [];
let projectEntryCounter = 0;
// 페이지 로드 시 인증 체크 // 페이지 로드 시 인증 체크
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
@@ -257,12 +234,22 @@
} }
currentUser = user; currentUser = user;
// 사용자 표시
document.getElementById('userDisplay').textContent = user.full_name || user.username;
// 네비게이션 권한 체크 // 네비게이션 권한 체크
updateNavigation(); updateNavigation();
// 프로젝트 및 일일 공수 데이터 로드
loadProjects();
loadDailyWorkData();
// 오늘 날짜로 초기화 // 오늘 날짜로 초기화
document.getElementById('workDate').valueAsDate = new Date(); document.getElementById('workDate').valueAsDate = new Date();
// 첫 번째 프로젝트 입력 항목 추가
addProjectEntry();
// 최근 내역 로드 // 최근 내역 로드
await loadRecentEntries(); await loadRecentEntries();
}); });
@@ -272,95 +259,207 @@
const listBtn = document.getElementById('listBtn'); const listBtn = document.getElementById('listBtn');
const summaryBtn = document.getElementById('summaryBtn'); const summaryBtn = document.getElementById('summaryBtn');
const adminBtn = document.getElementById('adminBtn'); const adminBtn = document.getElementById('adminBtn');
const projectBtn = document.getElementById('projectBtn');
if (currentUser.role === 'admin') { if (currentUser.role === 'admin') {
// 관리자는 모든 메뉴 표시 // 관리자는 모든 메뉴 표시
listBtn.style.display = ''; listBtn.style.display = '';
summaryBtn.style.display = ''; summaryBtn.style.display = '';
projectBtn.style.display = '';
adminBtn.style.display = ''; adminBtn.style.display = '';
adminBtn.innerHTML = '<i class="fas fa-users-cog mr-2"></i>사용자 관리'; adminBtn.innerHTML = '<i class="fas fa-users-cog mr-2"></i>사용자 관리';
} else { } else {
// 일반 사용자는 제한된 메뉴만 표시 // 일반 사용자는 제한된 메뉴만 표시
listBtn.style.display = 'none'; listBtn.style.display = 'none';
summaryBtn.style.display = 'none'; summaryBtn.style.display = 'none';
projectBtn.style.display = 'none';
adminBtn.style.display = ''; adminBtn.style.display = '';
adminBtn.innerHTML = '<i class="fas fa-key mr-2"></i>비밀번호 변경'; adminBtn.innerHTML = '<i class="fas fa-key mr-2"></i>비밀번호 변경';
} }
} }
// 잔업 토글 // 프로젝트 데이터 로드
function toggleOvertime() { function loadProjects() {
const section = document.getElementById('overtimeSection'); const saved = localStorage.getItem('work-report-projects');
const button = document.getElementById('overtimeToggle'); if (saved) {
projects = JSON.parse(saved);
}
}
// 일일 공수 데이터 로드
function loadDailyWorkData() {
const saved = localStorage.getItem('daily-work-data');
if (saved) {
dailyWorkData = JSON.parse(saved);
}
}
// 일일 공수 데이터 저장
function saveDailyWorkData() {
localStorage.setItem('daily-work-data', JSON.stringify(dailyWorkData));
}
// 활성 프로젝트만 필터링
function getActiveProjects() {
return projects.filter(p => p.isActive);
}
// 프로젝트 입력 항목 추가
function addProjectEntry() {
const activeProjects = getActiveProjects();
if (activeProjects.length === 0) {
alert('활성 프로젝트가 없습니다. 먼저 프로젝트를 생성해주세요.');
return;
}
if (section.classList.contains('hidden')) { projectEntryCounter++;
section.classList.remove('hidden'); const entryId = `project-entry-${projectEntryCounter}`;
button.innerHTML = '<i class="fas fa-minus mr-2"></i>잔업 취소';
button.classList.add('bg-gray-100'); const entryHtml = `
<div id="${entryId}" class="flex gap-3 items-center p-4 bg-gray-50 rounded-lg">
<div class="flex-1">
<select class="input-field w-full px-3 py-2 rounded-lg" onchange="updateTotalHours()">
<option value="">프로젝트 선택</option>
${activeProjects.map(p => `<option value="${p.id}">${p.jobNo} - ${p.projectName}</option>`).join('')}
</select>
</div>
<div class="w-32">
<input
type="number"
placeholder="시간"
min="0"
step="0.5"
class="input-field w-full px-3 py-2 rounded-lg text-center"
onchange="updateTotalHours()"
>
</div>
<div class="w-16 text-center text-gray-600">시간</div>
<button
type="button"
onclick="removeProjectEntry('${entryId}')"
class="text-red-500 hover:text-red-700 p-2"
title="제거"
>
<i class="fas fa-times"></i>
</button>
</div>
`;
document.getElementById('projectEntries').insertAdjacentHTML('beforeend', entryHtml);
updateTotalHours();
}
// 프로젝트 입력 항목 제거
function removeProjectEntry(entryId) {
const entry = document.getElementById(entryId);
if (entry) {
entry.remove();
updateTotalHours();
}
}
// 총 시간 계산 및 업데이트
function updateTotalHours() {
const entries = document.querySelectorAll('#projectEntries > div');
let totalHours = 0;
entries.forEach(entry => {
const hoursInput = entry.querySelector('input[type="number"]');
const hours = parseFloat(hoursInput.value) || 0;
totalHours += hours;
});
document.getElementById('totalHours').textContent = `${totalHours}시간`;
}
// 기존 데이터 로드 (날짜 선택 시)
function loadExistingData() {
const selectedDate = document.getElementById('workDate').value;
if (!selectedDate) return;
const existingData = dailyWorkData.find(d => d.date === selectedDate);
if (existingData) {
// 기존 프로젝트 입력 항목들 제거
document.getElementById('projectEntries').innerHTML = '';
projectEntryCounter = 0;
// 기존 데이터로 프로젝트 입력 항목들 생성
existingData.projects.forEach(projectData => {
addProjectEntry();
const lastEntry = document.querySelector('#projectEntries > div:last-child');
const select = lastEntry.querySelector('select');
const input = lastEntry.querySelector('input[type="number"]');
select.value = projectData.projectId;
input.value = projectData.hours;
});
updateTotalHours();
} else { } else {
section.classList.add('hidden'); // 새로운 날짜인 경우 초기화
button.innerHTML = '<i class="fas fa-plus mr-2"></i>잔업 추가'; document.getElementById('projectEntries').innerHTML = '';
button.classList.remove('bg-gray-100'); projectEntryCounter = 0;
// 잔업 입력값 초기화 addProjectEntry();
document.getElementById('overtimeWorkers').value = '';
document.getElementById('overtimeHours').value = '';
} }
calculateTotal();
} }
// 총 공수 계산
function calculateTotal() {
const workerCount = parseInt(document.getElementById('workerCount').value) || 0;
const regularHours = workerCount * 8; // 기본 8시간
let overtimeTotal = 0;
if (!document.getElementById('overtimeSection').classList.contains('hidden')) {
const overtimeWorkers = parseInt(document.getElementById('overtimeWorkers').value) || 0;
const overtimeHours = parseFloat(document.getElementById('overtimeHours').value) || 0;
overtimeTotal = overtimeWorkers * overtimeHours;
}
const total = regularHours + overtimeTotal;
document.getElementById('totalHours').textContent = `${total}시간`;
}
// 입력값 변경 시 총 공수 재계산
document.getElementById('workerCount').addEventListener('input', calculateTotal);
document.getElementById('overtimeWorkers').addEventListener('input', calculateTotal);
document.getElementById('overtimeHours').addEventListener('input', calculateTotal);
// 폼 제출 // 폼 제출
document.getElementById('dailyWorkForm').addEventListener('submit', async (e) => { document.getElementById('dailyWorkForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const workDate = document.getElementById('workDate').value; const selectedDate = document.getElementById('workDate').value;
const workerCount = parseInt(document.getElementById('workerCount').value); const entries = document.querySelectorAll('#projectEntries > div');
let overtimeWorkers = 0; const projectData = [];
let overtimeHours = 0; let hasValidEntry = false;
if (!document.getElementById('overtimeSection').classList.contains('hidden')) { entries.forEach(entry => {
overtimeWorkers = parseInt(document.getElementById('overtimeWorkers').value) || 0; const select = entry.querySelector('select');
overtimeHours = parseFloat(document.getElementById('overtimeHours').value) || 0; const input = entry.querySelector('input[type="number"]');
const projectId = select.value;
const hours = parseFloat(input.value) || 0;
if (projectId && hours > 0) {
const project = projects.find(p => p.id == projectId);
projectData.push({
projectId: projectId,
projectName: project ? `${project.jobNo} - ${project.projectName}` : '알 수 없음',
hours: hours
});
hasValidEntry = true;
}
});
if (!hasValidEntry) {
alert('최소 하나의 프로젝트에 시간을 입력해주세요.');
return;
} }
try { try {
// API 호출 // 기존 데이터 업데이트 또는 새로 추가
await DailyWorkAPI.create({ const existingIndex = dailyWorkData.findIndex(d => d.date === selectedDate);
date: new Date(workDate).toISOString(), const newData = {
worker_count: workerCount, date: selectedDate,
overtime_workers: overtimeWorkers, projects: projectData,
overtime_hours: overtimeHours totalHours: projectData.reduce((sum, p) => sum + p.hours, 0),
}); createdAt: new Date().toISOString(),
createdBy: currentUser.username || currentUser
};
if (existingIndex >= 0) {
dailyWorkData[existingIndex] = newData;
} else {
dailyWorkData.push(newData);
}
saveDailyWorkData();
// 성공 메시지 // 성공 메시지
showSuccessMessage(); showSuccessMessage();
// 폼 초기화
resetForm();
// 최근 내역 갱신 // 최근 내역 갱신
await loadRecentEntries(); await loadRecentEntries();
} catch (error) { } catch (error) {
alert(error.message || '저장에 실패했습니다.'); alert(error.message || '저장에 실패했습니다.');
} }
@@ -369,22 +468,16 @@
// 최근 데이터 로드 // 최근 데이터 로드
async function loadRecentEntries() { async function loadRecentEntries() {
try { try {
// 최근 7일 데이터 가져오기 // 최근 7일 데이터 표시
const endDate = new Date(); const recentData = dailyWorkData
const startDate = new Date(); .sort((a, b) => new Date(b.date) - new Date(a.date))
startDate.setDate(startDate.getDate() - 7); .slice(0, 7);
dailyWorks = await DailyWorkAPI.getAll({ displayRecentEntries(recentData);
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
limit: 7
});
displayRecentEntries();
} catch (error) { } catch (error) {
console.error('데이터 로드 실패:', error); console.error('데이터 로드 실패:', error);
dailyWorks = []; document.getElementById('recentEntries').innerHTML =
displayRecentEntries(); '<p class="text-gray-500 text-center py-4">데이터를 불러올 수 없습니다.</p>';
} }
} }
@@ -403,52 +496,42 @@
}, 2000); }, 2000);
} }
// 폼 초기화
function resetForm() {
document.getElementById('workerCount').value = '';
document.getElementById('overtimeWorkers').value = '';
document.getElementById('overtimeHours').value = '';
document.getElementById('overtimeSection').classList.add('hidden');
document.getElementById('overtimeToggle').innerHTML = '<i class="fas fa-plus mr-2"></i>잔업 추가';
document.getElementById('overtimeToggle').classList.remove('bg-gray-100');
document.getElementById('totalHours').textContent = '0시간';
// 날짜는 오늘로 유지
document.getElementById('workDate').valueAsDate = new Date();
}
// 최근 입력 내역 표시 // 최근 입력 내역 표시
function displayRecentEntries() { function displayRecentEntries(entries) {
const container = document.getElementById('recentEntries'); const container = document.getElementById('recentEntries');
if (dailyWorks.length === 0) { if (!entries || entries.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-4">입력 내역이 없습니다.</p>'; container.innerHTML = '<p class="text-gray-500 text-center py-4">최근 입력 내역이 없습니다.</p>';
return; return;
} }
container.innerHTML = dailyWorks.map(item => { container.innerHTML = entries.map(item => {
const dateStr = DateUtils.formatKST(item.date); const date = new Date(item.date);
const relativeTime = DateUtils.getRelativeTime(item.created_at || item.date); const dateStr = `${date.getMonth() + 1}/${date.getDate()} (${['일','월','화','수','목','금','토'][date.getDay()]})`;
return ` return `
<div class="flex justify-between items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"> <div class="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div> <div class="flex justify-between items-start mb-2">
<p class="font-medium text-gray-800">${dateStr}</p> <p class="font-medium text-gray-800">${dateStr}</p>
<p class="text-sm text-gray-600"> <div class="text-right">
인원: ${item.worker_count} <p class="text-lg font-bold text-blue-600">${item.totalHours}시간</p>
${item.overtime_total > 0 ? `• 잔업: ${item.overtime_workers}× ${item.overtime_hours}시간` : ''} ${currentUser && (currentUser.role === 'admin' || currentUser.username === 'hyungi') ? `
</p> <button
onclick="deleteDailyWork('${item.date}')"
class="mt-1 px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-xs"
>
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
</div> </div>
<div class="text-right"> <div class="space-y-1">
<p class="text-lg font-bold text-blue-600">${item.total_hours}시간</p> ${item.projects.map(p => `
${currentUser && currentUser.role === 'admin' ? ` <div class="flex justify-between text-sm text-gray-600">
<button <span>${p.projectName}</span>
onclick="deleteDailyWork(${item.id})" <span>${p.hours}시간</span>
class="mt-1 px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-xs" </div>
> `).join('')}
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div> </div>
</div> </div>
`; `;
@@ -456,8 +539,8 @@
} }
// 일일 공수 삭제 (관리자만) // 일일 공수 삭제 (관리자만)
async function deleteDailyWork(workId) { async function deleteDailyWork(date) {
if (!currentUser || currentUser.role !== 'admin') { if (!currentUser || (currentUser.role !== 'admin' && currentUser.username !== 'hyungi')) {
alert('관리자만 삭제할 수 있습니다.'); alert('관리자만 삭제할 수 있습니다.');
return; return;
} }
@@ -467,16 +550,23 @@
} }
try { try {
await DailyWorkAPI.delete(workId); const index = dailyWorkData.findIndex(d => d.date === date);
if (index >= 0) {
// 성공 시 목록 다시 로드 dailyWorkData.splice(index, 1);
await loadRecentEntries(); saveDailyWorkData();
await loadRecentEntries();
alert('삭제되었습니다.'); alert('삭제되었습니다.');
}
} catch (error) { } catch (error) {
alert(error.message || '삭제에 실패했습니다.'); alert('삭제에 실패했습니다.');
} }
} }
// 로그아웃
function logout() {
TokenManager.clearToken();
window.location.href = '/index.html';
}
</script> </script>
</body> </body>
</html> </html>

158
frontend/debug-data.html Normal file
View File

@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>데이터 디버그 - M Project</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="/static/js/api.js?v=20250917"></script>
</head>
<body class="bg-gray-50">
<div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="bg-white rounded-xl shadow-sm p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">
<i class="fas fa-bug text-red-500 mr-2"></i>데이터 디버그
</h1>
<div class="space-y-6">
<button
onclick="debugData()"
class="w-full bg-blue-500 text-white py-3 px-4 rounded-lg hover:bg-blue-600 transition-colors font-medium"
>
<i class="fas fa-search mr-2"></i>데이터 상태 확인
</button>
<div id="results" class="space-y-4">
<!-- 결과가 여기에 표시됩니다 -->
</div>
</div>
</div>
</div>
<script>
async function debugData() {
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin text-2xl"></i></div>';
try {
// 1. 프로젝트 데이터 확인
const projects = JSON.parse(localStorage.getItem('work-report-projects') || '[]');
// 2. 부적합 사항 데이터 확인 (API)
let apiIssues = [];
try {
apiIssues = await IssuesAPI.getAll();
} catch (error) {
console.error('API 조회 실패:', error);
}
// 3. 부적합 사항 데이터 확인 (localStorage)
const localIssues = JSON.parse(localStorage.getItem('work-report-issues') || '[]');
// 4. 일일 공수 데이터 확인
const dailyWork = JSON.parse(localStorage.getItem('daily-work-data') || '[]');
// 결과 표시
resultsDiv.innerHTML = `
<div class="space-y-6">
<!-- 프로젝트 데이터 -->
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="font-bold text-blue-800 mb-3">📁 프로젝트 데이터 (${projects.length}개)</h3>
<div class="space-y-2">
${projects.map(p => `
<div class="bg-white p-3 rounded border">
<div class="font-medium">ID: ${p.id}</div>
<div>Job No: ${p.jobNo}</div>
<div>이름: ${p.projectName}</div>
<div>활성: ${p.isActive ? '✅' : '❌'}</div>
</div>
`).join('')}
</div>
</div>
<!-- API 부적합 사항 -->
<div class="bg-green-50 p-4 rounded-lg">
<h3 class="font-bold text-green-800 mb-3">🔗 API 부적합 사항 (${apiIssues.length}개)</h3>
<div class="space-y-2 max-h-60 overflow-y-auto">
${apiIssues.map(issue => `
<div class="bg-white p-3 rounded border text-sm">
<div><strong>ID:</strong> ${issue.id}</div>
<div><strong>project_id:</strong> ${issue.project_id || 'null'}</div>
<div><strong>project_name:</strong> ${issue.project_name || 'null'}</div>
<div><strong>설명:</strong> ${issue.description?.substring(0, 50)}...</div>
<div><strong>카테고리:</strong> ${issue.category}</div>
</div>
`).join('')}
</div>
</div>
<!-- localStorage 부적합 사항 -->
<div class="bg-yellow-50 p-4 rounded-lg">
<h3 class="font-bold text-yellow-800 mb-3">💾 localStorage 부적합 사항 (${localIssues.length}개)</h3>
<div class="space-y-2 max-h-60 overflow-y-auto">
${localIssues.map(issue => `
<div class="bg-white p-3 rounded border text-sm">
<div><strong>ID:</strong> ${issue.id}</div>
<div><strong>project_id:</strong> ${issue.project_id || 'null'}</div>
<div><strong>projectId:</strong> ${issue.projectId || 'null'}</div>
<div><strong>project_name:</strong> ${issue.project_name || 'null'}</div>
<div><strong>projectName:</strong> ${issue.projectName || 'null'}</div>
<div><strong>설명:</strong> ${issue.description?.substring(0, 50)}...</div>
</div>
`).join('')}
</div>
</div>
<!-- 일일 공수 데이터 -->
<div class="bg-purple-50 p-4 rounded-lg">
<h3 class="font-bold text-purple-800 mb-3">⏰ 일일 공수 데이터 (${dailyWork.length}개)</h3>
<div class="space-y-2 max-h-60 overflow-y-auto">
${dailyWork.map(day => `
<div class="bg-white p-3 rounded border text-sm">
<div><strong>날짜:</strong> ${day.date}</div>
<div><strong>총 시간:</strong> ${day.totalHours}시간</div>
<div><strong>프로젝트들:</strong></div>
${day.projects?.map(p => `
<div class="ml-4 text-xs">
- ID: ${p.projectId}, 이름: ${p.projectName}, 시간: ${p.hours}
</div>
`).join('') || '<div class="ml-4 text-xs text-red-500">프로젝트 데이터 없음</div>'}
</div>
`).join('')}
</div>
</div>
<!-- 필터링 테스트 -->
<div class="bg-red-50 p-4 rounded-lg">
<h3 class="font-bold text-red-800 mb-3">🔍 필터링 테스트</h3>
<div class="space-y-2">
${projects.map(project => {
const matchingApiIssues = apiIssues.filter(issue => issue.project_id == project.id);
const matchingLocalIssues = localIssues.filter(issue => issue.project_id == project.id || issue.projectId == project.id);
return `
<div class="bg-white p-3 rounded border">
<div class="font-medium">${project.jobNo} - ${project.projectName} (ID: ${project.id})</div>
<div class="text-sm">API 매칭: ${matchingApiIssues.length}개</div>
<div class="text-sm">localStorage 매칭: ${matchingLocalIssues.length}개</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
`;
} catch (error) {
resultsDiv.innerHTML = `
<div class="bg-red-50 p-4 rounded-lg">
<h3 class="font-bold text-red-800 mb-3">❌ 오류 발생</h3>
<pre class="text-sm">${error.message}</pre>
</div>
`;
}
}
</script>
</body>
</html>

171
frontend/fix-api-data.html Normal file
View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 데이터 수정 - M Project</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="/static/js/api.js?v=20250917"></script>
</head>
<body class="bg-gray-50">
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="bg-white rounded-xl shadow-sm p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">
<i class="fas fa-wrench text-orange-500 mr-2"></i>API 데이터 수정
</h1>
<div class="space-y-4">
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<h3 class="font-semibold text-yellow-800 mb-2">⚠️ 주의사항</h3>
<p class="text-yellow-700 text-sm">
이 작업은 API 데이터베이스의 모든 부적합 사항에 TKR-25009R 프로젝트 정보를 추가합니다.<br>
관리자(hyungi) 계정으로 로그인한 상태에서만 실행하세요.
</p>
</div>
<div id="status" class="space-y-2">
<!-- 상태 메시지가 여기에 표시됩니다 -->
</div>
<button
id="fixBtn"
onclick="fixApiData()"
class="w-full bg-orange-500 text-white py-3 px-4 rounded-lg hover:bg-orange-600 transition-colors font-medium"
>
<i class="fas fa-tools mr-2"></i>API 데이터 수정 시작
</button>
<a href="debug-data.html" class="block text-center text-blue-600 hover:text-blue-800 mt-4">
<i class="fas fa-bug mr-1"></i>디버그 도구로 이동
</a>
<a href="index.html" class="block text-center text-gray-600 hover:text-gray-800 mt-2">
<i class="fas fa-arrow-left mr-1"></i>메인으로 돌아가기
</a>
</div>
</div>
</div>
<script>
let currentUser = null;
// 페이지 로드 시 사용자 확인
window.addEventListener('DOMContentLoaded', () => {
const user = TokenManager.getUser();
if (!user) {
alert('로그인이 필요합니다.');
window.location.href = 'index.html';
return;
}
currentUser = user;
if (currentUser.role !== 'admin') {
alert('관리자만 접근 가능합니다.');
window.location.href = 'index.html';
return;
}
addStatus('✅ 관리자 권한 확인됨', 'text-green-600');
});
function addStatus(message, className = 'text-gray-600') {
const statusDiv = document.getElementById('status');
const p = document.createElement('p');
p.className = `text-sm ${className}`;
p.innerHTML = `<i class="fas fa-info-circle mr-2"></i>${message}`;
statusDiv.appendChild(p);
}
async function fixApiData() {
const btn = document.getElementById('fixBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>수정 중...';
try {
// 1. TKR-25009R 프로젝트 찾기
const projects = JSON.parse(localStorage.getItem('work-report-projects') || '[]');
const mProject = projects.find(p => p.jobNo === 'TKR-25009R');
if (!mProject) {
throw new Error('TKR-25009R 프로젝트를 찾을 수 없습니다. 먼저 마이그레이션을 실행하세요.');
}
addStatus(`TKR-25009R 프로젝트 발견 (ID: ${mProject.id})`, 'text-blue-600');
// 2. API에서 모든 부적합 사항 가져오기
addStatus('API에서 부적합 사항 조회 중...');
const issues = await IssuesAPI.getAll();
addStatus(`${issues.length}개 부적합 사항 발견`, 'text-blue-600');
// 3. project_id가 null인 부적합 사항들 찾기
const issuesWithoutProject = issues.filter(issue => !issue.project_id);
addStatus(`프로젝트 정보가 없는 부적합 사항: ${issuesWithoutProject.length}`, 'text-yellow-600');
if (issuesWithoutProject.length === 0) {
addStatus('✅ 모든 부적합 사항에 이미 프로젝트 정보가 있습니다.', 'text-green-600');
btn.innerHTML = '<i class="fas fa-check mr-2"></i>완료';
btn.className = 'w-full bg-green-500 text-white py-3 px-4 rounded-lg font-medium';
return;
}
// 4. 각 부적합 사항 업데이트
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < issuesWithoutProject.length; i++) {
const issue = issuesWithoutProject[i];
try {
addStatus(`${i + 1}/${issuesWithoutProject.length}: 부적합 사항 ID ${issue.id} 업데이트 중...`);
await IssuesAPI.update(issue.id, {
project_id: mProject.id
});
successCount++;
// UI 업데이트를 위한 짧은 대기
if (i % 5 === 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}
} catch (error) {
console.error(`부적합 사항 ID ${issue.id} 업데이트 실패:`, error);
addStatus(`❌ 부적합 사항 ID ${issue.id} 업데이트 실패: ${error.message}`, 'text-red-600');
errorCount++;
}
}
// 5. 결과 표시
if (successCount > 0) {
addStatus(`${successCount}개 부적합 사항 업데이트 완료!`, 'text-green-600 font-bold');
}
if (errorCount > 0) {
addStatus(`${errorCount}개 부적합 사항 업데이트 실패`, 'text-red-600');
}
// 6. 완료 처리
if (errorCount === 0) {
btn.innerHTML = '<i class="fas fa-check mr-2"></i>모든 업데이트 완료';
btn.className = 'w-full bg-green-500 text-white py-3 px-4 rounded-lg font-medium';
addStatus('🎉 모든 API 데이터 수정이 완료되었습니다!', 'text-green-600 font-bold');
addStatus('이제 부적합 조회에서 프로젝트 필터가 정상 작동합니다.', 'text-blue-600');
} else {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo mr-2"></i>다시 시도';
btn.className = 'w-full bg-orange-500 text-white py-3 px-4 rounded-lg hover:bg-orange-600 transition-colors font-medium';
}
} catch (error) {
addStatus(`❌ 오류 발생: ${error.message}`, 'text-red-600');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo mr-2"></i>다시 시도';
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 ID 수정 - M Project</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gray-50">
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="bg-white rounded-xl shadow-sm p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">
<i class="fas fa-edit text-blue-500 mr-2"></i>프로젝트 ID 수정
</h1>
<div class="space-y-4">
<div class="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 class="font-semibold text-blue-800 mb-2">📋 작업 내용</h3>
<p class="text-blue-700 text-sm">
큰 타임스탬프 ID를 작은 정수 ID로 변경합니다.<br>
TKR-25009R 프로젝트 ID: <code>1761264279704</code><code>1</code>
</p>
</div>
<div id="status" class="space-y-2">
<!-- 상태 메시지가 여기에 표시됩니다 -->
</div>
<button
id="fixBtn"
onclick="fixProjectId()"
class="w-full bg-blue-500 text-white py-3 px-4 rounded-lg hover:bg-blue-600 transition-colors font-medium"
>
<i class="fas fa-tools mr-2"></i>프로젝트 ID 수정 시작
</button>
<a href="fix-api-data.html" class="block text-center text-orange-600 hover:text-orange-800 mt-4">
<i class="fas fa-wrench mr-1"></i>API 데이터 수정 도구로 이동
</a>
<a href="debug-data.html" class="block text-center text-blue-600 hover:text-blue-800 mt-2">
<i class="fas fa-bug mr-1"></i>디버그 도구로 이동
</a>
</div>
</div>
</div>
<script>
function addStatus(message, className = 'text-gray-600') {
const statusDiv = document.getElementById('status');
const p = document.createElement('p');
p.className = `text-sm ${className}`;
p.innerHTML = `<i class="fas fa-info-circle mr-2"></i>${message}`;
statusDiv.appendChild(p);
}
function fixProjectId() {
const btn = document.getElementById('fixBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>수정 중...';
try {
// 1. 프로젝트 데이터 수정
const projects = JSON.parse(localStorage.getItem('work-report-projects') || '[]');
const mProject = projects.find(p => p.jobNo === 'TKR-25009R');
if (!mProject) {
throw new Error('TKR-25009R 프로젝트를 찾을 수 없습니다.');
}
const oldId = mProject.id;
const newId = 1;
addStatus(`기존 프로젝트 ID: ${oldId}`, 'text-blue-600');
addStatus(`새로운 프로젝트 ID: ${newId}`, 'text-blue-600');
// 프로젝트 ID 변경
mProject.id = newId;
localStorage.setItem('work-report-projects', JSON.stringify(projects));
addStatus('✅ 프로젝트 ID 변경 완료', 'text-green-600');
// 2. 일일 공수 데이터 수정
const dailyWorkData = JSON.parse(localStorage.getItem('daily-work-data') || '[]');
let updatedWorkDays = 0;
dailyWorkData.forEach(dayData => {
if (dayData.projects) {
dayData.projects.forEach(project => {
if (project.projectId == oldId) {
project.projectId = newId;
project.projectName = 'TKR-25009R - M Project';
updatedWorkDays++;
}
});
}
});
localStorage.setItem('daily-work-data', JSON.stringify(dailyWorkData));
addStatus(`${updatedWorkDays}개 일일 공수 데이터 업데이트 완료`, 'text-green-600');
// 3. localStorage 부적합 사항 수정
const localIssues = JSON.parse(localStorage.getItem('work-report-issues') || '[]');
let updatedIssues = 0;
localIssues.forEach(issue => {
if (issue.projectId == oldId || issue.project_id == oldId) {
issue.projectId = newId;
issue.project_id = newId;
issue.projectName = 'TKR-25009R - M Project';
issue.project_name = 'TKR-25009R - M Project';
updatedIssues++;
}
});
localStorage.setItem('work-report-issues', JSON.stringify(localIssues));
addStatus(`${updatedIssues}개 localStorage 부적합 사항 업데이트 완료`, 'text-green-600');
// 완료
addStatus('🎉 모든 데이터 ID 수정 완료!', 'text-green-600 font-bold');
addStatus('이제 API 데이터 수정 도구를 실행하세요.', 'text-blue-600');
btn.innerHTML = '<i class="fas fa-check mr-2"></i>완료';
btn.className = 'w-full bg-green-500 text-white py-3 px-4 rounded-lg font-medium';
} catch (error) {
addStatus(`❌ 오류 발생: ${error.message}`, 'text-red-600');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo mr-2"></i>다시 시도';
}
}
</script>
</body>
</html>

View File

@@ -191,6 +191,7 @@
</div> </div>
</header> </header>
<!-- 네비게이션 --> <!-- 네비게이션 -->
<nav class="bg-white border-b"> <nav class="bg-white border-b">
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
@@ -210,6 +211,9 @@
<button class="nav-link" onclick="showSection('summary')" style="display:none;" id="summaryBtn"> <button class="nav-link" onclick="showSection('summary')" style="display:none;" id="summaryBtn">
<i class="fas fa-chart-bar mr-2"></i>보고서 <i class="fas fa-chart-bar mr-2"></i>보고서
</button> </button>
<a href="project-management.html" class="nav-link" style="display:none;" id="projectBtn">
<i class="fas fa-folder-open mr-2"></i>프로젝트 관리
</a>
<a href="admin.html" class="nav-link" style="display:none;" id="adminBtn"> <a href="admin.html" class="nav-link" style="display:none;" id="adminBtn">
<i class="fas fa-users-cog mr-2"></i>관리 <i class="fas fa-users-cog mr-2"></i>관리
</a> </a>
@@ -284,6 +288,17 @@
<input type="file" id="galleryInput" accept="image/*" class="hidden" multiple> <input type="file" id="galleryInput" accept="image/*" class="hidden" multiple>
</div> </div>
<!-- 프로젝트 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-folder-open mr-1"></i>프로젝트
</label>
<select id="projectSelect" class="input-field w-full px-4 py-2 rounded-lg" required>
<option value="">프로젝트를 선택하세요</option>
<!-- 활성 프로젝트들이 여기에 로드됩니다 -->
</select>
</div>
<!-- 카테고리 --> <!-- 카테고리 -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label> <label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
@@ -318,7 +333,56 @@
<!-- 목록 관리 섹션 --> <!-- 목록 관리 섹션 -->
<section id="listSection" class="hidden container mx-auto px-4 py-6"> <section id="listSection" class="hidden container mx-auto px-4 py-6">
<div class="bg-white rounded-xl shadow-sm p-6"> <div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">부적합 사항 목록</h2> <div class="mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">부적합 사항 목록</h2>
<button onclick="displayIssueList()" class="text-blue-600 hover:text-blue-800 text-sm">
<i class="fas fa-refresh mr-1"></i>새로고침
</button>
</div>
<!-- 필터 섹션 -->
<div class="bg-gray-50 p-4 rounded-lg mb-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<select id="listProjectFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="displayIssueList()">
<option value="">전체 프로젝트</option>
<!-- 프로젝트 옵션들이 여기에 로드됩니다 -->
</select>
</div>
<!-- 검토 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">검토 상태</label>
<select id="reviewStatusFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="displayIssueList()">
<option value="">전체</option>
<option value="pending">검토 필요</option>
<option value="completed">검토 완료</option>
</select>
</div>
<!-- 날짜 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜</label>
<select id="dateFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="displayIssueList()">
<option value="">전체</option>
<option value="today">오늘</option>
<option value="week">이번 주</option>
<option value="month">이번 달</option>
</select>
</div>
<!-- 사용자 정의 날짜 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">특정 날짜</label>
<input type="date" id="customDateFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="displayIssueList()">
</div>
</div>
</div>
</div>
<div id="issueList" class="space-y-4"> <div id="issueList" class="space-y-4">
<!-- 목록이 여기에 표시됩니다 --> <!-- 목록이 여기에 표시됩니다 -->
</div> </div>
@@ -330,9 +394,19 @@
<div class="bg-white rounded-xl shadow-sm p-6"> <div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-semibold text-gray-800">작업 보고서</h2> <h2 class="text-lg font-semibold text-gray-800">작업 보고서</h2>
<button onclick="printReport()" class="btn-primary px-4 py-2 rounded-lg text-sm"> <div class="flex items-center gap-4">
<i class="fas fa-print mr-2"></i>인쇄 <!-- 프로젝트 선택 -->
</button> <div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">프로젝트:</label>
<select id="reportProjectFilter" class="px-3 py-2 border border-gray-300 rounded text-sm" onchange="generateReport()">
<option value="">전체 프로젝트</option>
<!-- 프로젝트 옵션들이 여기에 로드됩니다 -->
</select>
</div>
<button onclick="printReport()" class="btn-primary px-4 py-2 rounded-lg text-sm">
<i class="fas fa-print mr-2"></i>인쇄
</button>
</div>
</div> </div>
<div id="reportContent"> <div id="reportContent">
<!-- 보고서 내용이 여기에 표시됩니다 --> <!-- 보고서 내용이 여기에 표시됩니다 -->
@@ -340,7 +414,7 @@
</div> </div>
</section> </section>
</div> </div>
<script src="/static/js/api.js?v=20250917"></script> <script src="/static/js/api.js?v=20250917"></script>
<script src="/static/js/image-utils.js?v=20250917"></script> <script src="/static/js/image-utils.js?v=20250917"></script>
<script src="/static/js/date-utils.js?v=20250917"></script> <script src="/static/js/date-utils.js?v=20250917"></script>
@@ -361,6 +435,9 @@
// 권한에 따른 메뉴 표시/숨김 // 권한에 따른 메뉴 표시/숨김
updateNavigation(); updateNavigation();
// 프로젝트 로드
loadProjects();
loadIssues(); loadIssues();
// URL 해시 처리 // URL 해시 처리
@@ -404,16 +481,20 @@
const summaryBtn = document.getElementById('summaryBtn'); const summaryBtn = document.getElementById('summaryBtn');
const adminBtn = document.getElementById('adminBtn'); const adminBtn = document.getElementById('adminBtn');
const projectBtn = document.getElementById('projectBtn');
if (currentUser.role === 'admin') { if (currentUser.role === 'admin') {
// 관리자는 모든 메뉴 표시 // 관리자는 모든 메뉴 표시
listBtn.style.display = ''; listBtn.style.display = '';
summaryBtn.style.display = ''; summaryBtn.style.display = '';
projectBtn.style.display = '';
adminBtn.style.display = ''; adminBtn.style.display = '';
adminBtn.innerHTML = '<i class="fas fa-users-cog mr-2"></i>사용자 관리'; adminBtn.innerHTML = '<i class="fas fa-users-cog mr-2"></i>사용자 관리';
} else { } else {
// 일반 사용자는 제한된 메뉴만 표시 // 일반 사용자는 제한된 메뉴만 표시
listBtn.style.display = 'none'; listBtn.style.display = 'none';
summaryBtn.style.display = 'none'; summaryBtn.style.display = 'none';
projectBtn.style.display = 'none';
adminBtn.style.display = ''; adminBtn.style.display = '';
adminBtn.innerHTML = '<i class="fas fa-key mr-2"></i>비밀번호 변경'; adminBtn.innerHTML = '<i class="fas fa-key mr-2"></i>비밀번호 변경';
} }
@@ -664,6 +745,12 @@
const submitBtn = e.target.querySelector('button[type="submit"]'); const submitBtn = e.target.querySelector('button[type="submit"]');
const originalBtnContent = submitBtn.innerHTML; const originalBtnContent = submitBtn.innerHTML;
const description = document.getElementById('description').value.trim(); const description = document.getElementById('description').value.trim();
const projectId = document.getElementById('projectSelect').value;
if (!projectId) {
alert('프로젝트를 선택해주세요.');
return;
}
if (!description) { if (!description) {
alert('설명을 입력해주세요.'); alert('설명을 입력해주세요.');
@@ -714,10 +801,14 @@
updateLoadingMessage('서버로 전송 중...', '네트워크 상태에 따라 시간이 걸릴 수 있습니다'); updateLoadingMessage('서버로 전송 중...', '네트워크 상태에 따라 시간이 걸릴 수 있습니다');
updateProgress(60); updateProgress(60);
// 선택된 프로젝트 정보 가져오기
const selectedProject = getSelectedProject(projectId);
const issueData = { const issueData = {
photos: currentPhotos, // 배열로 전달 photos: currentPhotos, // 배열로 전달
category: document.getElementById('category').value, category: document.getElementById('category').value,
description: description description: description,
project_id: parseInt(projectId)
}; };
const startTime = Date.now(); const startTime = Date.now();
@@ -781,22 +872,203 @@
} }
} }
// 프로젝트 로드
function loadProjects() {
const saved = localStorage.getItem('work-report-projects');
if (saved) {
const projects = JSON.parse(saved);
const activeProjects = projects.filter(p => p.isActive);
// 부적합 등록 폼의 프로젝트 선택 (활성 프로젝트만)
const projectSelect = document.getElementById('projectSelect');
if (projectSelect) {
projectSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
activeProjects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}`;
projectSelect.appendChild(option);
});
}
// 목록 관리의 프로젝트 필터 (모든 프로젝트)
const listProjectFilter = document.getElementById('listProjectFilter');
if (listProjectFilter) {
listProjectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}${!project.isActive ? ' (비활성)' : ''}`;
listProjectFilter.appendChild(option);
});
}
// 보고서의 프로젝트 필터 (모든 프로젝트)
const reportProjectFilter = document.getElementById('reportProjectFilter');
if (reportProjectFilter) {
reportProjectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}${!project.isActive ? ' (비활성)' : ''}`;
reportProjectFilter.appendChild(option);
});
}
}
}
// 선택된 프로젝트 정보 가져오기
function getSelectedProject(projectId) {
const saved = localStorage.getItem('work-report-projects');
if (saved) {
const projects = JSON.parse(saved);
return projects.find(p => p.id == projectId);
}
return null;
}
// 프로젝트 정보 표시용 함수
function getProjectInfo(projectId) {
if (!projectId) {
return '<span class="text-gray-500">프로젝트 미지정</span>';
}
const project = getSelectedProject(projectId);
if (project) {
return `${project.jobNo} - ${project.projectName}`;
} else {
return `<span class="text-red-500">프로젝트 ID: ${projectId} (정보 없음)</span>`;
}
}
// LocalStorage 관련 함수는 더 이상 사용하지 않음 // LocalStorage 관련 함수는 더 이상 사용하지 않음
function saveIssues() { function saveIssues() {
// Deprecated - API 사용 // Deprecated - API 사용
} }
// 검토 상태 확인 함수
function isReviewCompleted(issue) {
return issue.status === 'complete' && issue.work_hours && issue.work_hours > 0;
}
// 날짜 필터링 함수
function filterByDate(issues, dateFilter, customDate) {
if (customDate) {
const targetDate = new Date(customDate);
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate.toDateString() === targetDate.toDateString();
});
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch (dateFilter) {
case 'today':
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= today;
});
case 'week':
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= weekStart;
});
case 'month':
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= monthStart;
});
default:
return issues;
}
}
// 목록 표시 // 목록 표시
function displayIssueList() { function displayIssueList() {
const container = document.getElementById('issueList'); const container = document.getElementById('issueList');
container.innerHTML = ''; container.innerHTML = '';
if (issues.length === 0) { // 필터 값 가져오기
container.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 부적합 사항이 없습니다.</p>'; const selectedProjectId = document.getElementById('listProjectFilter').value;
const reviewStatusFilter = document.getElementById('reviewStatusFilter').value;
const dateFilter = document.getElementById('dateFilter').value;
const customDate = document.getElementById('customDateFilter').value;
let filteredIssues = [...issues];
// 프로젝트 필터 적용
if (selectedProjectId) {
filteredIssues = filteredIssues.filter(issue => {
const issueProjectId = issue.project_id || issue.projectId;
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
});
}
// 검토 상태 필터 적용
if (reviewStatusFilter) {
filteredIssues = filteredIssues.filter(issue => {
const isCompleted = isReviewCompleted(issue);
return reviewStatusFilter === 'completed' ? isCompleted : !isCompleted;
});
}
// 날짜 필터 적용
if (dateFilter || customDate) {
filteredIssues = filterByDate(filteredIssues, dateFilter, customDate);
}
if (filteredIssues.length === 0) {
container.innerHTML = `<p class="text-gray-500 text-center py-8">조건에 맞는 부적합 사항이 없습니다.</p>`;
return; return;
} }
issues.forEach(issue => { // 검토 상태별로 분류 및 정렬
const pendingIssues = filteredIssues.filter(issue => !isReviewCompleted(issue));
const completedIssues = filteredIssues.filter(issue => isReviewCompleted(issue));
// 검토 필요 항목을 먼저 표시
if (pendingIssues.length > 0) {
const pendingHeader = document.createElement('div');
pendingHeader.className = 'mb-4';
pendingHeader.innerHTML = `
<h3 class="text-md font-semibold text-orange-700 flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>검토 필요 (${pendingIssues.length}건)
</h3>
`;
container.appendChild(pendingHeader);
pendingIssues.forEach(issue => {
container.appendChild(createIssueCard(issue, false));
});
}
// 검토 완료 항목을 아래에 표시
if (completedIssues.length > 0) {
const completedHeader = document.createElement('div');
completedHeader.className = 'mb-4 mt-8';
completedHeader.innerHTML = `
<h3 class="text-md font-semibold text-green-700 flex items-center">
<i class="fas fa-check-circle mr-2"></i>검토 완료 (${completedIssues.length}건)
</h3>
`;
container.appendChild(completedHeader);
completedIssues.forEach(issue => {
container.appendChild(createIssueCard(issue, true));
});
}
}
// 부적합 사항 카드 생성 함수
function createIssueCard(issue, isCompleted) {
const categoryNames = { const categoryNames = {
material_missing: '자재누락', material_missing: '자재누락',
design_error: '설계미스', design_error: '설계미스',
@@ -805,10 +1077,31 @@
}; };
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'border rounded-lg p-6 bg-gray-50'; // 검토 완료 상태에 따른 스타일링
const baseClasses = 'border rounded-lg p-6 transition-all duration-200';
const statusClasses = isCompleted
? 'bg-gray-100 opacity-75 border-gray-300'
: 'bg-gray-50 border-gray-200 hover:shadow-md';
div.className = `${baseClasses} ${statusClasses}`;
div.id = `issue-card-${issue.id}`; div.id = `issue-card-${issue.id}`;
// 프로젝트 정보 가져오기
const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
div.innerHTML = ` div.innerHTML = `
<div class="space-y-4"> <div class="space-y-4">
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
${isCompleted ?
'<div class="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium"><i class="fas fa-check-circle mr-1"></i>검토완료</div>' :
'<div class="px-2 py-1 bg-orange-100 text-orange-800 rounded-full text-xs font-medium"><i class="fas fa-exclamation-triangle mr-1"></i>검토필요</div>'
}
</div>
<div class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
</div>
</div>
<!-- 사진과 기본 정보 --> <!-- 사진과 기본 정보 -->
<div class="flex gap-4"> <div class="flex gap-4">
<!-- 사진들 표시 --> <!-- 사진들 표시 -->
@@ -896,15 +1189,19 @@
id="workHours-confirm-${issue.id}" id="workHours-confirm-${issue.id}"
onclick="confirmWorkHours(${issue.id})" onclick="confirmWorkHours(${issue.id})"
class="px-3 py-1 rounded transition-colors text-sm font-medium ${ class="px-3 py-1 rounded transition-colors text-sm font-medium ${
issue.work_hours isCompleted
? 'bg-green-100 text-green-700 cursor-default' ? 'bg-green-500 text-white cursor-default'
: 'bg-blue-500 text-white hover:bg-blue-600' : issue.work_hours
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}" }"
${issue.work_hours ? 'disabled' : ''} ${isCompleted || !issue.work_hours ? 'disabled' : ''}
> >
${issue.work_hours ${isCompleted
? '<i class="fas fa-check-circle mr-1"></i>완료' ? '<i class="fas fa-check-circle mr-1"></i>검토완료'
: '<i class="fas fa-clock mr-1"></i>확인' : issue.work_hours
? '<i class="fas fa-clock mr-1"></i>확인'
: '<i class="fas fa-clock mr-1"></i>확인'
} }
</button> </button>
</div> </div>
@@ -950,8 +1247,8 @@
</div> </div>
</div> </div>
`; `;
container.appendChild(div);
}); return div;
} }
// 수정 상태 표시 // 수정 상태 표시
@@ -1013,17 +1310,25 @@
} }
try { try {
// 작업 시간 업데이트 // 작업 시간 업데이트 및 검토 완료 상태로 변경
await IssuesAPI.update(issueId, { await IssuesAPI.update(issueId, {
work_hours: workHours work_hours: workHours,
status: 'complete' // 검토 완료 상태로 변경
}); });
// 성공 시 데이터 다시 로드 // 로컬 데이터도 업데이트
await loadIssues(); const issue = issues.find(i => i.id === issueId);
if (issue) {
issue.work_hours = workHours;
issue.status = 'complete';
issue.reviewed_at = new Date().toISOString(); // 검토 완료 시간 기록
}
// 성공 시 목록 다시 표시
displayIssueList(); displayIssueList();
// 성공 메시지 // 성공 메시지
showToastMessage(`${workHours}시간 저장 완료!`, 'success'); showToastMessage(`${workHours}시간 저장 및 검토 완료!`, 'success');
} catch (error) { } catch (error) {
alert(error.message || '저장에 실패했습니다.'); alert(error.message || '저장에 실패했습니다.');
@@ -1151,39 +1456,81 @@
async function generateReport() { async function generateReport() {
const container = document.getElementById('reportContent'); const container = document.getElementById('reportContent');
// 선택된 프로젝트 가져오기
const selectedProjectId = document.getElementById('reportProjectFilter').value;
// 프로젝트별 필터링된 부적합 사항
let filteredIssues = issues;
if (selectedProjectId) {
filteredIssues = issues.filter(issue => {
const issueProjectId = issue.project_id || issue.projectId;
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
});
}
// 날짜 범위 계산 // 날짜 범위 계산
const dates = issues.map(i => new Date(i.report_date)); const dates = filteredIssues.map(i => new Date(i.report_date));
const startDate = dates.length > 0 ? new Date(Math.min(...dates)) : new Date(); const startDate = dates.length > 0 ? new Date(Math.min(...dates)) : new Date();
const endDate = new Date(); const endDate = new Date();
// 일일 공수 데이터 가져오기 // 프로젝트별 일일 공수 데이터 계산
let dailyWorkTotal = 0; let dailyWorkTotal = 0;
try { const dailyWorkData = JSON.parse(localStorage.getItem('daily-work-data') || '[]');
const dailyWorks = await DailyWorkAPI.getAll({
start_date: startDate.toISOString().split('T')[0], console.log('일일공수 데이터:', dailyWorkData);
end_date: endDate.toISOString().split('T')[0] console.log('선택된 프로젝트 ID:', selectedProjectId);
if (selectedProjectId) {
// 선택된 프로젝트의 일일 공수만 합계
dailyWorkData.forEach(dayWork => {
console.log('일일공수 항목:', dayWork);
if (dayWork.projects) {
dayWork.projects.forEach(project => {
console.log('프로젝트:', project, '매칭 확인:', project.projectId == selectedProjectId);
if (project.projectId == selectedProjectId || project.projectId.toString() === selectedProjectId.toString()) {
dailyWorkTotal += project.hours || 0;
console.log('시간 추가:', project.hours, '누적:', dailyWorkTotal);
}
});
}
});
} else {
// 전체 프로젝트의 일일 공수 합계
dailyWorkData.forEach(dayWork => {
console.log('전체 일일공수 항목:', dayWork);
dailyWorkTotal += dayWork.totalHours || 0;
}); });
dailyWorkTotal = dailyWorks.reduce((sum, work) => sum + work.total_hours, 0);
} catch (error) {
console.error('일일 공수 데이터 로드 실패:', error);
} }
// 부적합 사항 해결 시간 계산 console.log('최종 일일공수 합계:', dailyWorkTotal);
const issueHours = issues.reduce((sum, issue) => sum + issue.work_hours, 0);
// 부적합 사항 해결 시간 계산 (필터링된 이슈만)
const issueHours = filteredIssues.reduce((sum, issue) => sum + (issue.work_hours || 0), 0);
const categoryCount = {}; const categoryCount = {};
issues.forEach(issue => { filteredIssues.forEach(issue => {
categoryCount[issue.category] = (categoryCount[issue.category] || 0) + 1; categoryCount[issue.category] = (categoryCount[issue.category] || 0) + 1;
}); });
// 부적합 시간 비율 계산 // 부적합 시간 비율 계산
const issuePercentage = dailyWorkTotal > 0 ? ((issueHours / dailyWorkTotal) * 100).toFixed(1) : 0; const issuePercentage = dailyWorkTotal > 0 ? ((issueHours / dailyWorkTotal) * 100).toFixed(1) : 0;
// 선택된 프로젝트 정보
let projectInfo = '전체 프로젝트';
if (selectedProjectId) {
const projects = JSON.parse(localStorage.getItem('work-report-projects') || '[]');
const selectedProject = projects.find(p => p.id == selectedProjectId);
if (selectedProject) {
projectInfo = `${selectedProject.jobNo} - ${selectedProject.projectName}`;
}
}
container.innerHTML = ` container.innerHTML = `
<div class="space-y-6"> <div class="space-y-6">
<!-- 요약 페이지 --> <!-- 요약 페이지 -->
<div class="border-b pb-6"> <div class="border-b pb-6">
<h3 class="text-2xl font-bold text-center mb-6">작업 보고서</h3> <h3 class="text-2xl font-bold text-center mb-2">작업 보고서</h3>
<p class="text-center text-gray-600 mb-6">${projectInfo}</p>
<div class="grid md:grid-cols-3 gap-4"> <div class="grid md:grid-cols-3 gap-4">
<div class="bg-gray-50 rounded-lg p-4"> <div class="bg-gray-50 rounded-lg p-4">
@@ -1254,7 +1601,7 @@
<!-- 상세 내역 --> <!-- 상세 내역 -->
<div> <div>
<h4 class="font-semibold text-gray-700 mb-4">부적합 사항 상세</h4> <h4 class="font-semibold text-gray-700 mb-4">부적합 사항 상세</h4>
${issues.map(issue => { ${filteredIssues.map(issue => {
const categoryNames = { const categoryNames = {
material_missing: '자재누락', material_missing: '자재누락',
design_error: '설계미스', design_error: '설계미스',

View File

@@ -45,41 +45,72 @@
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.nav-link {
color: #6b7280;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
text-decoration: none;
}
.nav-link:hover {
background-color: #f3f4f6;
color: #3b82f6;
}
.nav-link.active {
background-color: #3b82f6;
color: white;
}
</style> </style>
</head> </head>
<body> <body>
<!-- Navigation --> <!-- 헤더 -->
<nav class="glass-effect border-b border-gray-200 sticky top-0 z-50"> <header class="bg-white shadow-sm sticky top-0 z-50">
<div class="container mx-auto px-4 py-3"> <div class="container mx-auto px-4 py-3">
<div class="flex items-center justify-between"> <div class="flex justify-between items-center">
<h1 class="text-xl font-semibold text-gray-800"> <h1 class="text-xl font-bold text-gray-800">
<i class="fas fa-clipboard-list mr-2"></i>작업보고서 시스템 <i class="fas fa-clipboard-check text-blue-500 mr-2"></i>작업보고서
</h1> </h1>
<div id="navContainer" class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="/daily-work.html" class="text-gray-600 hover:text-gray-800 transition-colors"> <span class="text-sm text-gray-600" id="userDisplay"></span>
<i class="fas fa-calendar-day mr-1"></i>일일 공수 <button onclick="logout()" class="text-gray-500 hover:text-gray-700">
</a> <i class="fas fa-sign-out-alt"></i>
<a href="/index.html" class="text-gray-600 hover:text-gray-800 transition-colors">
<i class="fas fa-exclamation-triangle mr-1"></i>부적합 등록
</a>
<a href="/issue-view.html" class="text-blue-600 font-medium">
<i class="fas fa-search mr-1"></i>부적합 조회
</a>
<a href="/index.html#list" class="text-gray-600 hover:text-gray-800 transition-colors" style="display:none;" id="listBtn">
<i class="fas fa-list mr-1"></i>목록 관리
</a>
<a href="/index.html#summary" class="text-gray-600 hover:text-gray-800 transition-colors" style="display:none;" id="summaryBtn">
<i class="fas fa-chart-bar mr-1"></i>보고서
</a>
<a href="/admin.html" class="text-gray-600 hover:text-gray-800 transition-colors" style="display:none;" id="adminBtn">
<i class="fas fa-users-cog mr-1"></i>관리
</a>
<button onclick="AuthAPI.logout()" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm">
<i class="fas fa-sign-out-alt mr-1"></i>로그아웃
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</header>
<!-- 네비게이션 -->
<nav class="bg-white border-b">
<div class="container mx-auto px-4">
<div class="flex gap-2 py-2 overflow-x-auto">
<a href="daily-work.html" class="nav-link">
<i class="fas fa-calendar-check mr-2"></i>일일 공수
</a>
<a href="index.html" class="nav-link">
<i class="fas fa-camera-retro mr-2"></i>부적합 등록
</a>
<a href="issue-view.html" class="nav-link active">
<i class="fas fa-search mr-2"></i>부적합 조회
</a>
<a href="index.html#list" class="nav-link" style="display:none;" id="listBtn">
<i class="fas fa-list mr-2"></i>목록 관리
</a>
<a href="index.html#summary" class="nav-link" style="display:none;" id="summaryBtn">
<i class="fas fa-chart-bar mr-2"></i>보고서
</a>
<a href="project-management.html" class="nav-link" style="display:none;" id="projectBtn">
<i class="fas fa-folder-open mr-2"></i>프로젝트 관리
</a>
<a href="admin.html" class="nav-link" style="display:none;" id="adminBtn">
<i class="fas fa-users-cog mr-2"></i>사용자 관리
</a>
</div>
</div>
</nav> </nav>
<!-- Main Content --> <!-- Main Content -->
@@ -91,6 +122,41 @@
<i class="fas fa-list-alt text-blue-500 mr-2"></i>부적합 사항 목록 <i class="fas fa-list-alt text-blue-500 mr-2"></i>부적합 사항 목록
</h2> </h2>
<!-- 필터 섹션 -->
<div class="bg-gray-50 p-4 rounded-lg mb-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
<!-- 프로젝트 옵션들이 여기에 로드됩니다 -->
</select>
</div>
<!-- 검토 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">검토 상태</label>
<select id="reviewStatusFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="filterIssues()">
<option value="">전체</option>
<option value="pending">검토 필요</option>
<option value="completed">검토 완료</option>
</select>
</div>
<!-- 날짜 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜</label>
<select id="dateFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="filterIssues()">
<option value="">전체</option>
<option value="today">오늘</option>
<option value="week">이번 주</option>
<option value="month">이번 달</option>
</select>
</div>
</div>
</div>
<div class="flex-1"></div> <div class="flex-1"></div>
<button onclick="setDateRange('today')" class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm"> <button onclick="setDateRange('today')" class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm">
@@ -140,6 +206,9 @@
// 네비게이션 권한 체크 // 네비게이션 권한 체크
updateNavigation(); updateNavigation();
// 프로젝트 로드
loadProjects();
// 기본값: 이번 주 데이터 로드 // 기본값: 이번 주 데이터 로드
setDateRange('week'); setDateRange('week');
}); });
@@ -173,22 +242,138 @@
const listBtn = document.getElementById('listBtn'); const listBtn = document.getElementById('listBtn');
const summaryBtn = document.getElementById('summaryBtn'); const summaryBtn = document.getElementById('summaryBtn');
const adminBtn = document.getElementById('adminBtn'); const adminBtn = document.getElementById('adminBtn');
const projectBtn = document.getElementById('projectBtn');
if (currentUser.role === 'admin') { if (currentUser.role === 'admin') {
// 관리자는 모든 메뉴 표시 // 관리자는 모든 메뉴 표시
listBtn.style.display = ''; listBtn.style.display = '';
summaryBtn.style.display = ''; summaryBtn.style.display = '';
projectBtn.style.display = '';
adminBtn.style.display = ''; adminBtn.style.display = '';
adminBtn.innerHTML = '<i class="fas fa-users-cog mr-1"></i>사용자 관리'; adminBtn.innerHTML = '<i class="fas fa-users-cog mr-1"></i>사용자 관리';
} else { } else {
// 일반 사용자는 제한된 메뉴만 표시 // 일반 사용자는 제한된 메뉴만 표시
listBtn.style.display = 'none'; listBtn.style.display = 'none';
summaryBtn.style.display = 'none'; summaryBtn.style.display = 'none';
projectBtn.style.display = 'none';
adminBtn.style.display = ''; adminBtn.style.display = '';
adminBtn.innerHTML = '<i class="fas fa-key mr-1"></i>비밀번호 변경'; adminBtn.innerHTML = '<i class="fas fa-key mr-1"></i>비밀번호 변경';
} }
} }
// 프로젝트 로드
function loadProjects() {
const saved = localStorage.getItem('work-report-projects');
if (saved) {
const projects = JSON.parse(saved);
const projectFilter = document.getElementById('projectFilter');
// 기존 옵션 제거 (전체 프로젝트 옵션 제외)
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
// 모든 프로젝트 추가 (활성/비활성 모두 - 기존 데이터 조회를 위해)
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}${!project.isActive ? ' (비활성)' : ''}`;
projectFilter.appendChild(option);
});
}
}
// 이슈 필터링
// 검토 상태 확인 함수
function isReviewCompleted(issue) {
return issue.status === 'complete' && issue.work_hours && issue.work_hours > 0;
}
// 날짜 필터링 함수
function filterByDate(issues, dateFilter) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch (dateFilter) {
case 'today':
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= today;
});
case 'week':
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= weekStart;
});
case 'month':
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= monthStart;
});
default:
return issues;
}
}
function filterIssues() {
console.log('필터링 시작 - 전체 이슈:', issues.length);
// 필터 값 가져오기
const selectedProjectId = document.getElementById('projectFilter').value;
const reviewStatusFilter = document.getElementById('reviewStatusFilter').value;
const dateFilter = document.getElementById('dateFilter').value;
let filteredIssues = [...issues];
// 프로젝트 필터 적용
if (selectedProjectId) {
filteredIssues = filteredIssues.filter(issue => {
const issueProjectId = issue.project_id || issue.projectId;
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
});
console.log('프로젝트 필터 후:', filteredIssues.length);
}
// 검토 상태 필터 적용
if (reviewStatusFilter) {
filteredIssues = filteredIssues.filter(issue => {
const isCompleted = isReviewCompleted(issue);
return reviewStatusFilter === 'completed' ? isCompleted : !isCompleted;
});
console.log('검토 상태 필터 후:', filteredIssues.length);
}
// 날짜 필터 적용
if (dateFilter) {
filteredIssues = filterByDate(filteredIssues, dateFilter);
console.log('날짜 필터 후:', filteredIssues.length);
}
// 전역 변수에 필터링된 결과 저장
window.filteredIssues = filteredIssues;
displayResults();
}
// 프로젝트 정보 표시용 함수
function getProjectInfo(projectId) {
if (!projectId) {
return '<span class="text-gray-500">프로젝트 미지정</span>';
}
const saved = localStorage.getItem('work-report-projects');
if (saved) {
const projects = JSON.parse(saved);
const project = projects.find(p => p.id == projectId);
if (project) {
return `${project.jobNo} - ${project.projectName}`;
}
}
return `<span class="text-red-500">프로젝트 ID: ${projectId} (정보 없음)</span>`;
}
// 날짜 범위 설정 및 자동 조회 // 날짜 범위 설정 및 자동 조회
async function setDateRange(range) { async function setDateRange(range) {
currentRange = range; currentRange = range;
@@ -274,16 +459,60 @@
function displayResults() { function displayResults() {
const container = document.getElementById('issueResults'); const container = document.getElementById('issueResults');
if (issues.length === 0) { // 필터링된 결과 사용 (filterIssues에서 설정됨)
const filteredIssues = window.filteredIssues || issues;
if (filteredIssues.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="text-gray-500 text-center py-8"> <div class="text-gray-500 text-center py-8">
<i class="fas fa-inbox text-4xl mb-3"></i> <i class="fas fa-inbox text-4xl mb-3"></i>
<p>등록된 부적합 사항이 없습니다.</p> <p>조건에 맞는 부적합 사항이 없습니다.</p>
</div> </div>
`; `;
return; return;
} }
// 검토 상태별로 분류 및 정렬
const pendingIssues = filteredIssues.filter(issue => !isReviewCompleted(issue));
const completedIssues = filteredIssues.filter(issue => isReviewCompleted(issue));
container.innerHTML = '';
// 검토 필요 항목을 먼저 표시
if (pendingIssues.length > 0) {
const pendingHeader = document.createElement('div');
pendingHeader.className = 'mb-4';
pendingHeader.innerHTML = `
<h3 class="text-md font-semibold text-orange-700 flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>검토 필요 (${pendingIssues.length}건)
</h3>
`;
container.appendChild(pendingHeader);
pendingIssues.forEach(issue => {
container.appendChild(createIssueCard(issue, false));
});
}
// 검토 완료 항목을 아래에 표시
if (completedIssues.length > 0) {
const completedHeader = document.createElement('div');
completedHeader.className = 'mb-4 mt-8';
completedHeader.innerHTML = `
<h3 class="text-md font-semibold text-green-700 flex items-center">
<i class="fas fa-check-circle mr-2"></i>검토 완료 (${completedIssues.length}건)
</h3>
`;
container.appendChild(completedHeader);
completedIssues.forEach(issue => {
container.appendChild(createIssueCard(issue, true));
});
}
}
// 부적합 사항 카드 생성 함수 (조회용)
function createIssueCard(issue, isCompleted) {
const categoryNames = { const categoryNames = {
material_missing: '자재누락', material_missing: '자재누락',
design_error: '설계미스', design_error: '설계미스',
@@ -298,72 +527,114 @@
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300' inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
}; };
// 시간순으로 나열 const div = document.createElement('div');
container.innerHTML = issues.map(issue => { // 검토 완료 상태에 따른 스타일링
const dateStr = DateUtils.formatKST(issue.report_date, true); const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4';
const relativeTime = DateUtils.getRelativeTime(issue.report_date); 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);
div.innerHTML = `
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
<div class="flex justify-between items-start p-2 pb-0">
<div class="flex items-center gap-2">
${isCompleted ?
'<div class="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium"><i class="fas fa-check-circle mr-1"></i>검토완료</div>' :
'<div class="px-2 py-1 bg-orange-100 text-orange-800 rounded-full text-xs font-medium"><i class="fas fa-exclamation-triangle mr-1"></i>검토필요</div>'
}
</div>
<div class="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
</div>
</div>
return ` <!-- 기존 내용 -->
<div class="flex gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors border-l-4 ${categoryColors[issue.category].split(' ')[2] || 'border-gray-300'}"> <div class="flex gap-3 p-3 pt-1">
<!-- 사진들 --> <!-- 사진들 -->
<div class="flex gap-1 flex-shrink-0"> <div class="flex gap-1 flex-shrink-0">
${issue.photo_path ? ${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}')">` : '' `<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 ? ${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}')">` : '' `<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 ? ${!issue.photo_path && !issue.photo_path2 ?
`<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center"> `<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
<i class="fas fa-image text-gray-400"></i> <i class="fas fa-image text-gray-400"></i>
</div>` : '' </div>` : ''
}
</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 ?
`<span class="text-sm text-green-600 font-medium">
<i class="fas fa-clock mr-1"></i>${issue.work_hours}시간
</span>` :
'<span class="text-sm text-gray-400">시간 미입력</span>'
} }
</div> </div>
<!-- 내용 --> <p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-1"> <div class="flex items-center gap-4 text-sm text-gray-500">
<div class="flex items-center gap-2"> <span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
<span class="px-2 py-0.5 rounded text-xs font-medium ${categoryColors[issue.category].split(' ').slice(0, 2).join(' ')}"> <span><i class="fas fa-calendar mr-1"></i>${dateStr}</span>
${categoryNames[issue.category]} <span class="text-xs text-gray-400">${relativeTime}</span>
</span>
${issue.work_hours ? `
<span class="text-xs text-green-600 font-medium">
${issue.work_hours}시간
</span>
` : ''}
</div>
<div class="text-xs text-gray-500">
${dateStr} (${relativeTime})
</div>
</div>
<p class="text-sm text-gray-800 line-clamp-2">${issue.description}</p>
<div class="mt-1 text-xs text-gray-500">
<i class="fas fa-user mr-1"></i>
${issue.reporter.full_name || issue.reporter.username}
</div>
</div> </div>
</div> </div>
`;
}).join('');
// 상단에 요약 추가
const summary = `
<div class="mb-4 p-3 bg-blue-50 rounded-lg text-sm">
<span class="font-medium text-blue-900">총 ${issues.length}건</span>
<span class="text-blue-700 ml-3">
자재누락: ${issues.filter(i => i.category === 'material_missing').length}건 |
설계미스: ${issues.filter(i => i.category === 'design_error').length}건 |
입고자재 불량: ${issues.filter(i => i.category === 'incoming_defect').length}건 |
검사미스: ${issues.filter(i => i.category === 'inspection_miss').length}
</span>
</div> </div>
`; `;
container.innerHTML = summary + container.innerHTML; return div;
} }
// 로그아웃 함수
function logout() {
localStorage.removeItem('currentUser');
window.location.href = 'index.html';
}
// 페이지 로드 시 사용자 정보 확인 및 관리자 배너 표시
window.addEventListener('DOMContentLoaded', () => {
const currentUserData = localStorage.getItem('currentUser');
if (!currentUserData) {
window.location.href = 'index.html';
return;
}
let currentUser;
try {
// JSON 파싱 시도
currentUser = JSON.parse(currentUserData);
} catch (e) {
// JSON이 아니면 문자열로 처리 (기존 방식 호환)
currentUser = { username: currentUserData };
}
// 사용자 표시
const username = currentUser.username || currentUser;
const displayName = currentUser.full_name || (username === 'hyungi' ? '관리자' : username);
document.getElementById('userDisplay').textContent = `${displayName} (${username})`;
// 관리자인 경우 메뉴 표시
if (username === 'hyungi' || currentUser.role === 'admin') {
document.getElementById('listBtn').style.display = '';
document.getElementById('summaryBtn').style.display = '';
document.getElementById('projectBtn').style.display = '';
document.getElementById('adminBtn').style.display = '';
}
});
</script> </script>
</body> </body>
</html> </html>

307
frontend/migrate-data.html Normal file
View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>데이터 마이그레이션 - M Project</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="/static/js/api.js?v=20250917"></script>
</head>
<body class="bg-gray-50">
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="bg-white rounded-xl shadow-sm p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">
<i class="fas fa-database text-blue-500 mr-2"></i>데이터 마이그레이션
</h1>
<div class="space-y-4">
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<h3 class="font-semibold text-yellow-800 mb-2">⚠️ 주의사항</h3>
<p class="text-yellow-700 text-sm">
이 작업은 기존 데이터를 "M Project"로 마이그레이션합니다.<br>
관리자(hyungi) 계정으로 로그인한 상태에서만 실행하세요.
</p>
</div>
<div id="status" class="space-y-2">
<!-- 상태 메시지가 여기에 표시됩니다 -->
</div>
<button
id="migrateBtn"
onclick="startMigration()"
class="w-full bg-blue-500 text-white py-3 px-4 rounded-lg hover:bg-blue-600 transition-colors font-medium"
>
<i class="fas fa-play mr-2"></i>마이그레이션 시작
</button>
<a href="index.html" class="block text-center text-gray-600 hover:text-gray-800 mt-4">
<i class="fas fa-arrow-left mr-1"></i>메인으로 돌아가기
</a>
</div>
</div>
</div>
<script>
let currentUser = null;
// 페이지 로드 시 사용자 확인
window.addEventListener('DOMContentLoaded', () => {
const userData = localStorage.getItem('currentUser');
if (!userData) {
alert('로그인이 필요합니다.');
window.location.href = 'index.html';
return;
}
try {
currentUser = JSON.parse(userData);
} catch (e) {
currentUser = { username: userData };
}
const username = currentUser.username || currentUser;
if (username !== 'hyungi' && currentUser.role !== 'admin') {
alert('관리자만 접근 가능합니다.');
window.location.href = 'index.html';
return;
}
addStatus('✅ 관리자 권한 확인됨', 'text-green-600');
});
function addStatus(message, className = 'text-gray-600') {
const statusDiv = document.getElementById('status');
const p = document.createElement('p');
p.className = `text-sm ${className}`;
p.innerHTML = `<i class="fas fa-info-circle mr-2"></i>${message}`;
statusDiv.appendChild(p);
}
async function startMigration() {
const btn = document.getElementById('migrateBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>마이그레이션 중...';
try {
// 1. M Project 생성
await createMProject();
// 2. 기존 부적합 사항 마이그레이션
await migrateIssues();
// 3. 기존 368시간 데이터 생성
await createSampleHours();
addStatus('🎉 모든 마이그레이션이 완료되었습니다!', 'text-green-600 font-bold');
btn.innerHTML = '<i class="fas fa-check mr-2"></i>완료';
btn.className = 'w-full bg-green-500 text-white py-3 px-4 rounded-lg font-medium';
} catch (error) {
addStatus(`❌ 오류 발생: ${error.message}`, 'text-red-600');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo mr-2"></i>다시 시도';
}
}
async function createMProject() {
addStatus('기존 M Project 확인 중...');
// 기존 프로젝트 확인
let projects = [];
const saved = localStorage.getItem('work-report-projects');
if (saved) {
projects = JSON.parse(saved);
}
// 기존 TKR-25009R M Project 찾기
let existingMProject = projects.find(p => p.jobNo === 'TKR-25009R');
if (existingMProject) {
addStatus('✅ 기존 TKR-25009R M Project 발견', 'text-green-600');
return existingMProject.id;
}
// M-2024-001로 생성된 잘못된 프로젝트 찾기
const wrongProject = projects.find(p => p.jobNo === 'M-2024-001');
if (wrongProject) {
// 잘못된 프로젝트를 올바른 Job No.로 수정
wrongProject.jobNo = 'TKR-25009R';
wrongProject.projectName = 'M Project';
addStatus('✅ 기존 프로젝트를 TKR-25009R로 수정 완료', 'text-green-600');
localStorage.setItem('work-report-projects', JSON.stringify(projects));
return wrongProject.id;
}
// 새로 생성 (기존 프로젝트가 없는 경우)
const mProject = {
id: Date.now(),
jobNo: 'TKR-25009R',
projectName: 'M Project',
createdBy: 'hyungi',
createdByName: '관리자',
createdAt: new Date().toISOString(),
isActive: true
};
projects.push(mProject);
localStorage.setItem('work-report-projects', JSON.stringify(projects));
addStatus('✅ TKR-25009R M Project 생성 완료', 'text-green-600');
return mProject.id;
}
async function migrateIssues() {
addStatus('기존 부적합 사항 마이그레이션 중...');
// 기존 부적합 사항 로드 (localStorage와 API 모두 확인)
let issues = [];
// 먼저 localStorage에서 확인
const savedLocal = localStorage.getItem('work-report-issues');
if (savedLocal) {
issues = JSON.parse(savedLocal);
addStatus('localStorage에서 부적합 사항 발견', 'text-blue-600');
}
// API에서도 확인 (현재 시스템이 API 기반이므로)
try {
if (typeof IssuesAPI !== 'undefined') {
const apiIssues = await IssuesAPI.getAll();
if (apiIssues && apiIssues.length > 0) {
issues = apiIssues;
addStatus('API에서 부적합 사항 발견', 'text-blue-600');
}
}
} catch (error) {
addStatus('API 조회 실패, localStorage 데이터 사용', 'text-yellow-600');
}
if (issues.length === 0) {
addStatus('마이그레이션할 부적합 사항이 없습니다.', 'text-yellow-600');
return;
}
// TKR-25009R M Project ID 가져오기
const projects = JSON.parse(localStorage.getItem('work-report-projects') || '[]');
const mProject = projects.find(p => p.jobNo === 'TKR-25009R');
if (!mProject) {
throw new Error('TKR-25009R M Project를 찾을 수 없습니다.');
}
// 모든 부적합 사항에 프로젝트 ID 추가
let migratedCount = 0;
for (let issue of issues) {
if (!issue.project_id && !issue.projectId) {
// API 방식과 localStorage 방식 모두 지원
issue.project_id = mProject.id;
issue.projectId = mProject.id;
issue.project_name = 'TKR-25009R - M Project';
issue.projectName = 'TKR-25009R - M Project';
migratedCount++;
// API로 업데이트 시도
try {
if (typeof IssuesAPI !== 'undefined' && issue.id) {
await IssuesAPI.update(issue.id, {
project_id: mProject.id,
project_name: 'TKR-25009R - M Project'
});
}
} catch (error) {
console.log('API 업데이트 실패, localStorage만 업데이트');
}
}
}
// localStorage에도 저장
localStorage.setItem('work-report-issues', JSON.stringify(issues));
addStatus(`${migratedCount}개 부적합 사항 마이그레이션 완료`, 'text-green-600');
}
async function createSampleHours() {
addStatus('368시간 샘플 데이터 생성 중...');
// TKR-25009R M Project ID 가져오기
const projects = JSON.parse(localStorage.getItem('work-report-projects') || '[]');
const mProject = projects.find(p => p.jobNo === 'TKR-25009R');
if (!mProject) {
throw new Error('TKR-25009R M Project를 찾을 수 없습니다.');
}
// 기존 일일 공수 데이터 로드
let dailyWorkData = [];
const saved = localStorage.getItem('daily-work-data');
if (saved) {
dailyWorkData = JSON.parse(saved);
}
// 기존 데이터 중 잘못된 프로젝트로 등록된 것들 수정
let updatedCount = 0;
dailyWorkData.forEach(dayData => {
if (dayData.projects) {
dayData.projects.forEach(project => {
if (project.projectName && project.projectName.includes('M-2024-001')) {
project.projectId = mProject.id;
project.projectName = 'TKR-25009R - M Project';
updatedCount++;
}
});
}
});
if (updatedCount > 0) {
addStatus(`✅ 기존 ${updatedCount}개 프로젝트 데이터를 TKR-25009R로 수정`, 'text-green-600');
}
// 368시간을 여러 날짜에 분산해서 생성 (기존 데이터가 없는 경우만)
const workDays = [
{ date: '2024-10-01', hours: 48 },
{ date: '2024-10-02', hours: 52 },
{ date: '2024-10-03', hours: 44 },
{ date: '2024-10-04', hours: 40 },
{ date: '2024-10-07', hours: 56 },
{ date: '2024-10-08', hours: 48 },
{ date: '2024-10-09', hours: 36 },
{ date: '2024-10-10', hours: 44 }
];
let addedDays = 0;
workDays.forEach(workDay => {
// 해당 날짜에 이미 데이터가 있는지 확인
const existingData = dailyWorkData.find(d => d.date === workDay.date);
if (!existingData) {
const newData = {
date: workDay.date,
projects: [{
projectId: mProject.id,
projectName: 'TKR-25009R - M Project',
hours: workDay.hours
}],
totalHours: workDay.hours,
createdAt: new Date().toISOString(),
createdBy: 'hyungi'
};
dailyWorkData.push(newData);
addedDays++;
}
});
// 저장
localStorage.setItem('daily-work-data', JSON.stringify(dailyWorkData));
const totalCreatedHours = workDays.reduce((sum, day) => sum + day.hours, 0);
if (addedDays > 0) {
addStatus(`${addedDays}일간 총 ${totalCreatedHours}시간 데이터 생성 완료`, 'text-green-600');
} else {
addStatus('✅ 기존 시간 데이터 수정 완료', 'text-green-600');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 관리 - 작업보고서 시스템</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.input-field {
border: 1px solid var(--gray-300);
background: white;
transition: all 0.2s;
}
.input-field:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="bg-white shadow-sm">
<div class="container mx-auto px-4 py-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-800">
<i class="fas fa-folder-open text-blue-500 mr-2"></i>프로젝트 관리
</h1>
<div class="flex items-center gap-4">
<span class="text-sm text-gray-600" id="userDisplay"></span>
<a href="index.html" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-home mr-1"></i>메인으로
</a>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8 max-w-4xl">
<!-- 프로젝트 생성 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-plus text-green-500 mr-2"></i>새 프로젝트 생성
</h2>
<form id="projectForm" class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Job No.</label>
<input
type="text"
id="jobNo"
class="input-field w-full px-4 py-2 rounded-lg"
placeholder="예: JOB-2024-001"
required
maxlength="50"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">프로젝트 이름</label>
<input
type="text"
id="projectName"
class="input-field w-full px-4 py-2 rounded-lg"
placeholder="프로젝트 이름을 입력하세요"
required
maxlength="200"
>
</div>
<div class="md:col-span-2">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>프로젝트 생성
</button>
</div>
</form>
</div>
<!-- 프로젝트 목록 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">프로젝트 목록</h2>
<button onclick="loadProjects()" class="text-blue-600 hover:text-blue-800">
<i class="fas fa-refresh mr-1"></i>새로고침
</button>
</div>
<div id="projectsList" class="space-y-3">
<!-- 프로젝트 목록이 여기에 표시됩니다 -->
</div>
</div>
</main>
<script>
// 사용자 확인 (관리자만 접근 가능)
const currentUserData = localStorage.getItem('currentUser');
if (!currentUserData) {
alert('로그인이 필요합니다.');
window.location.href = 'index.html';
}
let currentUser;
try {
// JSON 파싱 시도
currentUser = JSON.parse(currentUserData);
} catch (e) {
// JSON이 아니면 문자열로 처리 (기존 방식 호환)
currentUser = { username: currentUserData };
}
const username = currentUser.username || currentUser;
const isAdmin = username === 'hyungi' || currentUser.role === 'admin';
if (!isAdmin) {
alert('관리자만 접근 가능합니다.');
window.location.href = 'index.html';
}
const displayName = currentUser.full_name || (username === 'hyungi' ? '관리자' : username);
document.getElementById('userDisplay').textContent = `${displayName} (${username})`;
let projects = [];
// 프로젝트 데이터 로드
function loadProjects() {
const saved = localStorage.getItem('work-report-projects');
if (saved) {
projects = JSON.parse(saved);
}
displayProjectList();
}
// 프로젝트 데이터 저장
function saveProjects() {
localStorage.setItem('work-report-projects', JSON.stringify(projects));
}
// 프로젝트 생성 폼 처리
document.getElementById('projectForm').addEventListener('submit', (e) => {
e.preventDefault();
const jobNo = document.getElementById('jobNo').value.trim();
const projectName = document.getElementById('projectName').value.trim();
// 중복 Job No. 확인
if (projects.some(p => p.jobNo === jobNo)) {
alert('이미 존재하는 Job No.입니다.');
return;
}
// 프로젝트 생성
const newProject = {
id: Date.now(),
jobNo: jobNo,
projectName: projectName,
createdBy: 'hyungi',
createdByName: '관리자',
createdAt: new Date().toISOString(),
isActive: true
};
projects.push(newProject);
saveProjects();
// 성공 메시지
alert('프로젝트가 생성되었습니다.');
// 폼 초기화
document.getElementById('projectForm').reset();
// 목록 새로고침
displayProjectList();
});
// 프로젝트 목록 표시
function displayProjectList() {
const container = document.getElementById('projectsList');
container.innerHTML = '';
if (projects.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 프로젝트가 없습니다.</p>';
return;
}
// 활성 프로젝트와 비활성 프로젝트 분리
const activeProjects = projects.filter(p => p.isActive);
const inactiveProjects = projects.filter(p => !p.isActive);
// 활성 프로젝트 표시
if (activeProjects.length > 0) {
const activeHeader = document.createElement('div');
activeHeader.className = 'mb-4';
activeHeader.innerHTML = '<h3 class="text-md font-semibold text-green-700"><i class="fas fa-check-circle mr-2"></i>활성 프로젝트 (' + activeProjects.length + '개)</h3>';
container.appendChild(activeHeader);
activeProjects.forEach(project => {
const div = document.createElement('div');
div.className = 'border border-green-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-green-50';
div.innerHTML = `
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-gray-800">${project.jobNo}</h3>
<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">활성</span>
</div>
<p class="text-gray-600 mb-2">${project.projectName}</p>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span><i class="fas fa-user mr-1"></i>${project.createdByName}</span>
<span><i class="fas fa-calendar mr-1"></i>${new Date(project.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div class="flex gap-2">
<button onclick="editProject(${project.id})" class="text-blue-600 hover:text-blue-800 p-2" title="수정">
<i class="fas fa-edit"></i>
</button>
<button onclick="toggleProjectStatus(${project.id})" class="text-orange-600 hover:text-orange-800 p-2" title="비활성화">
<i class="fas fa-pause"></i>
</button>
<button onclick="deleteProject(${project.id})" class="text-red-600 hover:text-red-800 p-2" title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
container.appendChild(div);
});
}
// 비활성 프로젝트 표시
if (inactiveProjects.length > 0) {
const inactiveHeader = document.createElement('div');
inactiveHeader.className = 'mb-4 mt-6';
inactiveHeader.innerHTML = '<h3 class="text-md font-semibold text-gray-500"><i class="fas fa-pause-circle mr-2"></i>비활성 프로젝트 (' + inactiveProjects.length + '개)</h3>';
container.appendChild(inactiveHeader);
inactiveProjects.forEach(project => {
const div = document.createElement('div');
div.className = 'border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-gray-50 opacity-75';
div.innerHTML = `
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-gray-600">${project.jobNo}</h3>
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">비활성</span>
</div>
<p class="text-gray-500 mb-2">${project.projectName}</p>
<div class="flex items-center gap-4 text-sm text-gray-400">
<span><i class="fas fa-user mr-1"></i>${project.createdByName}</span>
<span><i class="fas fa-calendar mr-1"></i>${new Date(project.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div class="flex gap-2">
<button onclick="toggleProjectStatus(${project.id})" class="text-green-600 hover:text-green-800 p-2" title="활성화">
<i class="fas fa-play"></i>
</button>
<button onclick="deleteProject(${project.id})" class="text-red-600 hover:text-red-800 p-2" title="완전 삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
container.appendChild(div);
});
}
}
// 프로젝트 편집
function editProject(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const newName = prompt('프로젝트 이름을 수정하세요:', project.projectName);
if (newName && newName.trim() && newName.trim() !== project.projectName) {
project.projectName = newName.trim();
saveProjects();
displayProjectList();
alert('프로젝트가 수정되었습니다.');
}
}
// 프로젝트 활성/비활성 토글
function toggleProjectStatus(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const action = project.isActive ? '비활성화' : '활성화';
if (confirm(`"${project.jobNo}" 프로젝트를 ${action}하시겠습니까?`)) {
project.isActive = !project.isActive;
saveProjects();
displayProjectList();
alert(`프로젝트가 ${action}되었습니다.`);
}
}
// 프로젝트 삭제 (완전 삭제)
function deleteProject(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const confirmMessage = project.isActive
? `"${project.jobNo}" 프로젝트를 완전히 삭제하시겠습니까?\n\n※ 활성 프로젝트입니다. 먼저 비활성화를 권장합니다.`
: `"${project.jobNo}" 프로젝트를 완전히 삭제하시겠습니까?\n\n※ 이 작업은 되돌릴 수 없습니다.`;
if (confirm(confirmMessage)) {
const index = projects.findIndex(p => p.id === projectId);
if (index > -1) {
projects.splice(index, 1);
saveProjects();
displayProjectList();
alert('프로젝트가 완전히 삭제되었습니다.');
}
}
}
// 페이지 로드 시 프로젝트 목록 로드
loadProjects();
</script>
</body>
</html>

View File

@@ -239,3 +239,27 @@ function checkAdminAuth() {
} }
return user; return user;
} }
// 프로젝트 API
const ProjectsAPI = {
getAll: (activeOnly = false) => {
const params = activeOnly ? '?active_only=true' : '';
return apiRequest(`/projects${params}`);
},
get: (id) => apiRequest(`/projects/${id}`),
create: (projectData) => apiRequest('/projects', {
method: 'POST',
body: JSON.stringify(projectData)
}),
update: (id, projectData) => apiRequest(`/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(projectData)
}),
delete: (id) => apiRequest(`/projects/${id}`, {
method: 'DELETE'
})
};