feat: 작업보고서 시스템 완성
- 일일 공수 입력 기능 - 부적합 사항 등록 (이미지 선택사항) - 날짜별 부적합 조회 (시간순 나열) - 목록 관리 (인라인 편집, 작업시간 확인 버튼) - 보고서 생성 (총 공수/부적합 시간 분리) - JWT 인증 및 권한 관리 - Docker 기반 배포 환경 구성
This commit is contained in:
99
README.md
Normal file
99
README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# M-Project - 작업보고서 시스템
|
||||||
|
|
||||||
|
간단하고 효율적인 부적합 사항 관리 및 공수 계산 시스템
|
||||||
|
|
||||||
|
## 🚀 빠른 시작
|
||||||
|
|
||||||
|
1. 웹 서버 실행:
|
||||||
|
```bash
|
||||||
|
cd M-Project
|
||||||
|
python3 -m http.server 16080
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 브라우저에서 접속:
|
||||||
|
```
|
||||||
|
http://localhost:16080
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 로그인:
|
||||||
|
- 검사자1: `inspector1` / `pass123`
|
||||||
|
- 검사자2: `inspector2` / `pass456`
|
||||||
|
- 관리자: `admin` / `admin123`
|
||||||
|
|
||||||
|
## 📱 주요 기능
|
||||||
|
|
||||||
|
### 1. 부적합 등록 (모바일 최적화)
|
||||||
|
- 사진 촬영 또는 업로드
|
||||||
|
- 위치, 카테고리, 설명 입력
|
||||||
|
- 긴급도 선택 (낮음/보통/높음)
|
||||||
|
|
||||||
|
### 2. 목록 관리
|
||||||
|
- 등록된 부적합 사항 조회
|
||||||
|
- 작업 시간 입력
|
||||||
|
- 상태 변경 (신규→진행중→완료)
|
||||||
|
- 추가 메모 작성
|
||||||
|
|
||||||
|
### 3. 보고서
|
||||||
|
- 작업 기간 및 총 공수 자동 계산
|
||||||
|
- 긴급도별 통계
|
||||||
|
- 부적합 사항 상세 내역
|
||||||
|
- 인쇄 가능한 형식
|
||||||
|
|
||||||
|
## 📁 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
M-Project/
|
||||||
|
├── index.html # 메인 애플리케이션
|
||||||
|
├── Rules.md # 시스템 규칙 및 요구사항
|
||||||
|
└── README.md # 이 파일
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ 기술 스택
|
||||||
|
|
||||||
|
- **Frontend**: HTML5, Tailwind CSS, JavaScript (Vanilla)
|
||||||
|
- **Storage**: LocalStorage (브라우저 로컬 저장)
|
||||||
|
- **Icons**: Font Awesome
|
||||||
|
- **Charts**: Chart.js
|
||||||
|
|
||||||
|
## 📋 데이터 구조
|
||||||
|
|
||||||
|
부적합 사항은 다음과 같은 형식으로 저장됩니다:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: 1234567890, // 타임스탬프
|
||||||
|
photo: "data:image/jpeg;base64...", // Base64 이미지
|
||||||
|
location: "2층 회의실", // 위치
|
||||||
|
category: "safety", // 카테고리
|
||||||
|
description: "안전 장비 미착용", // 설명
|
||||||
|
urgency: "high", // 긴급도
|
||||||
|
status: "new", // 상태
|
||||||
|
reporter: "inspector1", // 보고자 ID
|
||||||
|
reporterName: "검사자1", // 보고자 이름
|
||||||
|
reportDate: "2025-09-17T10:30:00", // 보고 일시
|
||||||
|
workHours: 2.5, // 작업 시간
|
||||||
|
detailNotes: "추가 메모..." // 상세 메모
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚡ 특징
|
||||||
|
|
||||||
|
- **모바일 우선**: 현장에서 스마트폰으로 쉽게 입력
|
||||||
|
- **오프라인 작동**: 인터넷 연결 없이도 사용 가능
|
||||||
|
- **간단한 설치**: 별도의 서버나 데이터베이스 불필요
|
||||||
|
- **즉시 사용**: 웹 서버만 실행하면 바로 사용
|
||||||
|
|
||||||
|
## 🔒 보안 주의사항
|
||||||
|
|
||||||
|
현재 버전은 프로토타입으로:
|
||||||
|
- 사용자 인증이 간단함 (하드코딩된 계정)
|
||||||
|
- 데이터가 브라우저에만 저장됨
|
||||||
|
- 실제 운영 환경에서는 백엔드 서버 구축 필요
|
||||||
|
|
||||||
|
## 📝 라이선스
|
||||||
|
|
||||||
|
내부 사용 목적으로 제작됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
작성일: 2025-09-17
|
||||||
165
Rules.md
Normal file
165
Rules.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# 작업보고서 시스템 규칙 (Rules)
|
||||||
|
|
||||||
|
## 🎯 시스템 목적
|
||||||
|
|
||||||
|
1. **전체 공수 계산**
|
||||||
|
- 작업에 소요된 총 시간을 자동으로 집계
|
||||||
|
- 기간별, 카테고리별 통계 제공
|
||||||
|
- 일일 작업 공수 관리
|
||||||
|
|
||||||
|
2. **발생한 부적합 사항 정리**
|
||||||
|
- 현장에서 핸드폰으로 간편하게 업로드
|
||||||
|
- 사진(선택), 카테고리, 설명을 빠르게 입력
|
||||||
|
- 이미지 없이도 등록 가능
|
||||||
|
|
||||||
|
3. **날짜별 부적합 사항 조회**
|
||||||
|
- 기간별 필터링 (오늘, 이번주, 이번달)
|
||||||
|
- 카테고리별 통계 확인
|
||||||
|
- 날짜별 그룹화된 목록 표시
|
||||||
|
|
||||||
|
4. **상세 내역 확인 및 수정**
|
||||||
|
- 등록된 부적합 사항의 카테고리/설명 수정
|
||||||
|
- 사진 추가/변경 기능
|
||||||
|
- 작업 시간 입력 (자동으로 완료 상태 변경)
|
||||||
|
- 진행 상태는 자동 관리
|
||||||
|
|
||||||
|
5. **최종 보고서 출력**
|
||||||
|
- 전문적인 형식의 보고서 자동 생성
|
||||||
|
- 인쇄 가능한 레이아웃
|
||||||
|
|
||||||
|
6. **일일 공수 관리**
|
||||||
|
- 날짜별 작업 인원 입력
|
||||||
|
- 정규 근무 및 잔업 시간 계산
|
||||||
|
- 총 작업 시간 자동 집계
|
||||||
|
|
||||||
|
## 📱 모바일 입력 최적화
|
||||||
|
|
||||||
|
### 필수 입력 항목 (간단하게)
|
||||||
|
1. **사진** - 카메라 직접 연동
|
||||||
|
2. **카테고리** - 선택형 (자재누락/치수불량/입고자재 불량)
|
||||||
|
3. **간단 설명** - 짧은 텍스트
|
||||||
|
|
||||||
|
### UI/UX 원칙
|
||||||
|
- 큰 버튼과 입력 필드
|
||||||
|
- 최소한의 스크롤
|
||||||
|
- 즉각적인 피드백
|
||||||
|
- 오프라인에서도 작동 (로컬 저장)
|
||||||
|
|
||||||
|
## 📊 보고서 형식
|
||||||
|
|
||||||
|
### 1페이지 - 요약
|
||||||
|
```
|
||||||
|
작업 보고서
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
작업 기간: YYYY-MM-DD ~ YYYY-MM-DD
|
||||||
|
총 공수: XXX 시간
|
||||||
|
|
||||||
|
카테고리별 분석:
|
||||||
|
- 자재누락: X건
|
||||||
|
- 치수불량: X건
|
||||||
|
- 입고자재 불량: X건
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2페이지부터 - 상세 내역
|
||||||
|
```
|
||||||
|
부적합 사항 #1
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
[사진]
|
||||||
|
카테고리: XXX
|
||||||
|
설명: XXX
|
||||||
|
작업시간: X시간
|
||||||
|
추가메모: XXX
|
||||||
|
보고자: XXX
|
||||||
|
보고일시: YYYY-MM-DD HH:MM
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 사용자 권한
|
||||||
|
|
||||||
|
### 일반 사용자 (inspector)
|
||||||
|
- 부적합 사항 등록
|
||||||
|
- 자신이 등록한 항목 수정
|
||||||
|
- 보고서 조회
|
||||||
|
|
||||||
|
### 관리자 (admin)
|
||||||
|
- 모든 부적합 사항 조회/수정
|
||||||
|
- 사용자 관리
|
||||||
|
- 데이터 백업/복원
|
||||||
|
|
||||||
|
## 💾 데이터 구조
|
||||||
|
|
||||||
|
### 부적합 사항 (Issue)
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: timestamp,
|
||||||
|
photo: base64_image,
|
||||||
|
category: enum['material_missing','dimension_defect','incoming_defect'],
|
||||||
|
description: string,
|
||||||
|
status: enum['new','progress','complete'],
|
||||||
|
reporter: user_id,
|
||||||
|
reporterName: string,
|
||||||
|
reportDate: ISO_datetime,
|
||||||
|
workHours: number,
|
||||||
|
detailNotes: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 일일 공수 (Daily Work)
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: timestamp,
|
||||||
|
date: YYYY-MM-DD,
|
||||||
|
workerCount: number, // 작업 인원 수
|
||||||
|
regularHours: number, // 정규 근무 시간 (인원 × 8시간)
|
||||||
|
overtimeWorkers: number, // 잔업 인원 수
|
||||||
|
overtimeHours: number, // 잔업 시간 (1인당)
|
||||||
|
overtimeTotal: number, // 총 잔업 시간
|
||||||
|
totalHours: number, // 전체 작업 시간
|
||||||
|
timestamp: ISO_datetime // 입력 시각
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 향후 개선 사항
|
||||||
|
|
||||||
|
1. **백엔드 연동**
|
||||||
|
- 실제 데이터베이스 사용
|
||||||
|
- 다중 사용자 동시 작업 지원
|
||||||
|
- 데이터 백업 자동화
|
||||||
|
|
||||||
|
2. **추가 기능**
|
||||||
|
- 부적합 사항 알림 기능
|
||||||
|
- 작업자 배정 기능
|
||||||
|
- 사진 여러 장 업로드
|
||||||
|
- GPS 위치 자동 기록
|
||||||
|
- 바코드/QR 코드 스캔
|
||||||
|
|
||||||
|
3. **보고서 확장**
|
||||||
|
- Excel 내보내기
|
||||||
|
- PDF 생성
|
||||||
|
- 이메일 발송
|
||||||
|
- 월간/연간 통계
|
||||||
|
|
||||||
|
## 📝 사용 시나리오
|
||||||
|
|
||||||
|
1. **현장 작업자**
|
||||||
|
- 부적합 사항 발견
|
||||||
|
- 핸드폰으로 사진 촬영
|
||||||
|
- 카테고리 선택과 간단 설명 입력
|
||||||
|
- 등록 완료
|
||||||
|
|
||||||
|
2. **작업 완료 후**
|
||||||
|
- 등록된 부적합 사항 확인
|
||||||
|
- 카테고리/설명 수정 가능
|
||||||
|
- 작업 시간 입력
|
||||||
|
- 상태는 자동으로 '완료'로 변경
|
||||||
|
|
||||||
|
3. **보고서 작성**
|
||||||
|
- 보고서 섹션 접속
|
||||||
|
- 자동 생성된 내용 확인
|
||||||
|
- 필요시 인쇄 또는 공유
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
작성일: 2025-09-17
|
||||||
|
버전: 1.0
|
||||||
24
backend/Dockerfile
Normal file
24
backend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 시스템 패키지 설치
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python 의존성 설치
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 애플리케이션 파일 복사
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# uploads 디렉토리 생성
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
|
|
||||||
|
# 포트 노출
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 실행 명령
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/database/__pycache__/database.cpython-311.pyc
Normal file
BIN
backend/database/__pycache__/database.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/database/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/database/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/database/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
backend/database/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
19
backend/database/database.py
Normal file
19
backend/database/database.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
import os
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://mproject:mproject2024@localhost:5432/mproject")
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
69
backend/database/models.py
Normal file
69
backend/database/models.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, ForeignKey, Enum
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
USER = "user"
|
||||||
|
|
||||||
|
class IssueStatus(str, enum.Enum):
|
||||||
|
NEW = "new"
|
||||||
|
PROGRESS = "progress"
|
||||||
|
COMPLETE = "complete"
|
||||||
|
|
||||||
|
class IssueCategory(str, enum.Enum):
|
||||||
|
MATERIAL_MISSING = "material_missing"
|
||||||
|
DIMENSION_DEFECT = "dimension_defect"
|
||||||
|
INCOMING_DEFECT = "incoming_defect"
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String, nullable=False)
|
||||||
|
full_name = Column(String)
|
||||||
|
role = Column(Enum(UserRole), default=UserRole.USER)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
issues = relationship("Issue", back_populates="reporter")
|
||||||
|
daily_works = relationship("DailyWork", back_populates="created_by")
|
||||||
|
|
||||||
|
class Issue(Base):
|
||||||
|
__tablename__ = "issues"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
photo_path = Column(String)
|
||||||
|
category = Column(Enum(IssueCategory), nullable=False)
|
||||||
|
description = Column(Text, nullable=False)
|
||||||
|
status = Column(Enum(IssueStatus), default=IssueStatus.NEW)
|
||||||
|
reporter_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
report_date = Column(DateTime, default=datetime.utcnow)
|
||||||
|
work_hours = Column(Float, default=0)
|
||||||
|
detail_notes = Column(Text)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
reporter = relationship("User", back_populates="issues")
|
||||||
|
|
||||||
|
class DailyWork(Base):
|
||||||
|
__tablename__ = "daily_works"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
date = Column(DateTime, nullable=False, index=True)
|
||||||
|
worker_count = Column(Integer, nullable=False)
|
||||||
|
regular_hours = Column(Float, nullable=False)
|
||||||
|
overtime_workers = Column(Integer, default=0)
|
||||||
|
overtime_hours = Column(Float, default=0)
|
||||||
|
overtime_total = Column(Float, default=0)
|
||||||
|
total_hours = Column(Float, nullable=False)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_by = relationship("User", back_populates="daily_works")
|
||||||
129
backend/database/schemas.py
Normal file
129
backend/database/schemas.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class UserRole(str, Enum):
|
||||||
|
admin = "admin"
|
||||||
|
user = "user"
|
||||||
|
|
||||||
|
class IssueStatus(str, Enum):
|
||||||
|
new = "new"
|
||||||
|
progress = "progress"
|
||||||
|
complete = "complete"
|
||||||
|
|
||||||
|
class IssueCategory(str, Enum):
|
||||||
|
material_missing = "material_missing"
|
||||||
|
dimension_defect = "dimension_defect"
|
||||||
|
incoming_defect = "incoming_defect"
|
||||||
|
|
||||||
|
# User schemas
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
role: UserRole = UserRole.user
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
role: Optional[UserRole] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
class User(UserBase):
|
||||||
|
id: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Auth schemas
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
user: User
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
# Issue schemas
|
||||||
|
class IssueBase(BaseModel):
|
||||||
|
category: IssueCategory
|
||||||
|
description: str
|
||||||
|
|
||||||
|
class IssueCreate(IssueBase):
|
||||||
|
photo: Optional[str] = None # Base64 encoded image
|
||||||
|
|
||||||
|
class IssueUpdate(BaseModel):
|
||||||
|
category: Optional[IssueCategory] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
work_hours: Optional[float] = None
|
||||||
|
detail_notes: Optional[str] = None
|
||||||
|
status: Optional[IssueStatus] = None
|
||||||
|
photo: Optional[str] = None # Base64 encoded image for update
|
||||||
|
|
||||||
|
class Issue(IssueBase):
|
||||||
|
id: int
|
||||||
|
photo_path: Optional[str] = None
|
||||||
|
status: IssueStatus
|
||||||
|
reporter_id: int
|
||||||
|
reporter: User
|
||||||
|
report_date: datetime
|
||||||
|
work_hours: float
|
||||||
|
detail_notes: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Daily Work schemas
|
||||||
|
class DailyWorkBase(BaseModel):
|
||||||
|
date: datetime
|
||||||
|
worker_count: int = Field(gt=0)
|
||||||
|
overtime_workers: Optional[int] = 0
|
||||||
|
overtime_hours: Optional[float] = 0
|
||||||
|
|
||||||
|
class DailyWorkCreate(DailyWorkBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DailyWorkUpdate(BaseModel):
|
||||||
|
worker_count: Optional[int] = Field(None, gt=0)
|
||||||
|
overtime_workers: Optional[int] = None
|
||||||
|
overtime_hours: Optional[float] = None
|
||||||
|
|
||||||
|
class DailyWork(DailyWorkBase):
|
||||||
|
id: int
|
||||||
|
regular_hours: float
|
||||||
|
overtime_total: float
|
||||||
|
total_hours: float
|
||||||
|
created_by_id: int
|
||||||
|
created_by: User
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Report schemas
|
||||||
|
class ReportRequest(BaseModel):
|
||||||
|
start_date: datetime
|
||||||
|
end_date: datetime
|
||||||
|
|
||||||
|
class CategoryStats(BaseModel):
|
||||||
|
material_missing: int = 0
|
||||||
|
dimension_defect: int = 0
|
||||||
|
incoming_defect: int = 0
|
||||||
|
|
||||||
|
class ReportSummary(BaseModel):
|
||||||
|
start_date: datetime
|
||||||
|
end_date: datetime
|
||||||
|
total_hours: float
|
||||||
|
total_issues: int
|
||||||
|
category_stats: CategoryStats
|
||||||
|
completed_issues: int
|
||||||
|
average_resolution_time: float
|
||||||
62
backend/main.py
Normal file
62
backend/main.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from database.database import engine, get_db
|
||||||
|
from database.models import Base
|
||||||
|
from routers import auth, issues, daily_work, reports
|
||||||
|
from services.auth_service import create_admin_user
|
||||||
|
|
||||||
|
# 데이터베이스 테이블 생성
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# FastAPI 앱 생성
|
||||||
|
app = FastAPI(
|
||||||
|
title="M-Project API",
|
||||||
|
description="작업보고서 시스템 API",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS 설정
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # 프로덕션에서는 구체적인 도메인으로 변경
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 라우터 등록
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(issues.router)
|
||||||
|
app.include_router(daily_work.router)
|
||||||
|
app.include_router(reports.router)
|
||||||
|
|
||||||
|
# 시작 시 관리자 계정 생성
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
db = next(get_db())
|
||||||
|
create_admin_user(db)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# 루트 엔드포인트
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "M-Project API", "version": "1.0.0"}
|
||||||
|
|
||||||
|
# 헬스체크
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
# 전역 예외 처리
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": f"Internal server error: {str(exc)}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
43
backend/migrations/001_init.sql
Normal file
43
backend/migrations/001_init.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- 사용자 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
hashed_password VARCHAR(255) NOT NULL,
|
||||||
|
full_name VARCHAR(100),
|
||||||
|
role VARCHAR(20) DEFAULT 'user',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 이슈 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS issues (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
photo_path VARCHAR(255),
|
||||||
|
category VARCHAR(50) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'new',
|
||||||
|
reporter_id INTEGER REFERENCES users(id),
|
||||||
|
report_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
work_hours FLOAT DEFAULT 0,
|
||||||
|
detail_notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 일일 공수 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_works (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
date DATE NOT NULL UNIQUE,
|
||||||
|
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 REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스 생성
|
||||||
|
CREATE INDEX idx_issues_reporter_id ON issues(reporter_id);
|
||||||
|
CREATE INDEX idx_issues_status ON issues(status);
|
||||||
|
CREATE INDEX idx_issues_report_date ON issues(report_date);
|
||||||
|
CREATE INDEX idx_daily_works_date ON daily_works(date);
|
||||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
alembic==1.12.1
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
pillow==10.1.0
|
||||||
|
reportlab==4.0.7
|
||||||
BIN
backend/routers/__pycache__/auth.cpython-311.pyc
Normal file
BIN
backend/routers/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/daily_work.cpython-311.pyc
Normal file
BIN
backend/routers/__pycache__/daily_work.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/issues.cpython-311.pyc
Normal file
BIN
backend/routers/__pycache__/issues.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/reports.cpython-311.pyc
Normal file
BIN
backend/routers/__pycache__/reports.cpython-311.pyc
Normal file
Binary file not shown.
131
backend/routers/auth.py
Normal file
131
backend/routers/auth.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database.database import get_db
|
||||||
|
from database.models import User, UserRole
|
||||||
|
from database import schemas
|
||||||
|
from services.auth_service import (
|
||||||
|
authenticate_user, create_access_token, verify_token,
|
||||||
|
get_password_hash, verify_password
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
token_data = verify_token(token, credentials_exception)
|
||||||
|
user = db.query(User).filter(User.username == token_data.username).first()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_current_admin(current_user: User = Depends(get_current_user)):
|
||||||
|
if current_user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not enough permissions"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
@router.post("/login", response_model=schemas.Token)
|
||||||
|
async def login(login_data: schemas.LoginRequest, db: Session = Depends(get_db)):
|
||||||
|
user = authenticate_user(db, login_data.username, login_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token = create_access_token(data={"sub": user.username})
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/me", response_model=schemas.User)
|
||||||
|
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
@router.post("/users", response_model=schemas.User)
|
||||||
|
async def create_user(
|
||||||
|
user: schemas.UserCreate,
|
||||||
|
current_admin: User = Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 중복 확인
|
||||||
|
db_user = db.query(User).filter(User.username == user.username).first()
|
||||||
|
if db_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Username already registered")
|
||||||
|
|
||||||
|
# 사용자 생성
|
||||||
|
db_user = User(
|
||||||
|
username=user.username,
|
||||||
|
hashed_password=get_password_hash(user.password),
|
||||||
|
full_name=user.full_name,
|
||||||
|
role=user.role
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.get("/users", response_model=List[schemas.User])
|
||||||
|
async def read_users(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
current_admin: User = Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
users = db.query(User).offset(skip).limit(limit).all()
|
||||||
|
return users
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}", response_model=schemas.User)
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
user_update: schemas.UserUpdate,
|
||||||
|
current_admin: User = Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
db_user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# 업데이트
|
||||||
|
update_data = user_update.dict(exclude_unset=True)
|
||||||
|
if "password" in update_data:
|
||||||
|
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_user, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
current_admin: User = Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
db_user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# 관리자는 삭제 불가
|
||||||
|
if db_user.role == UserRole.ADMIN:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||||
|
|
||||||
|
db.delete(db_user)
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "User deleted successfully"}
|
||||||
163
backend/routers/daily_work.py
Normal file
163
backend/routers/daily_work.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from database.database import get_db
|
||||||
|
from database.models import DailyWork, User, UserRole
|
||||||
|
from database import schemas
|
||||||
|
from routers.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/daily-work", tags=["daily-work"])
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.DailyWork)
|
||||||
|
async def create_daily_work(
|
||||||
|
work: schemas.DailyWorkCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 중복 확인 (같은 날짜)
|
||||||
|
existing = db.query(DailyWork).filter(
|
||||||
|
DailyWork.date == work.date.date()
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Daily work for this date already exists")
|
||||||
|
|
||||||
|
# 계산
|
||||||
|
regular_hours = work.worker_count * 8 # 정규 근무 8시간
|
||||||
|
overtime_total = work.overtime_workers * work.overtime_hours
|
||||||
|
total_hours = regular_hours + overtime_total
|
||||||
|
|
||||||
|
# 생성
|
||||||
|
db_work = DailyWork(
|
||||||
|
date=work.date,
|
||||||
|
worker_count=work.worker_count,
|
||||||
|
regular_hours=regular_hours,
|
||||||
|
overtime_workers=work.overtime_workers,
|
||||||
|
overtime_hours=work.overtime_hours,
|
||||||
|
overtime_total=overtime_total,
|
||||||
|
total_hours=total_hours,
|
||||||
|
created_by_id=current_user.id
|
||||||
|
)
|
||||||
|
db.add(db_work)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_work)
|
||||||
|
return db_work
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.DailyWork])
|
||||||
|
async def read_daily_works(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
start_date: Optional[date] = None,
|
||||||
|
end_date: Optional[date] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
query = db.query(DailyWork)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(DailyWork.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(DailyWork.date <= end_date)
|
||||||
|
|
||||||
|
works = query.order_by(DailyWork.date.desc()).offset(skip).limit(limit).all()
|
||||||
|
return works
|
||||||
|
|
||||||
|
@router.get("/{work_id}", response_model=schemas.DailyWork)
|
||||||
|
async def read_daily_work(
|
||||||
|
work_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||||
|
if not work:
|
||||||
|
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||||
|
return work
|
||||||
|
|
||||||
|
@router.put("/{work_id}", response_model=schemas.DailyWork)
|
||||||
|
async def update_daily_work(
|
||||||
|
work_id: int,
|
||||||
|
work_update: schemas.DailyWorkUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||||
|
if not work:
|
||||||
|
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||||
|
|
||||||
|
# 업데이트
|
||||||
|
update_data = work_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# 재계산 필요한 경우
|
||||||
|
if any(key in update_data for key in ["worker_count", "overtime_workers", "overtime_hours"]):
|
||||||
|
worker_count = update_data.get("worker_count", work.worker_count)
|
||||||
|
overtime_workers = update_data.get("overtime_workers", work.overtime_workers)
|
||||||
|
overtime_hours = update_data.get("overtime_hours", work.overtime_hours)
|
||||||
|
|
||||||
|
regular_hours = worker_count * 8
|
||||||
|
overtime_total = overtime_workers * overtime_hours
|
||||||
|
total_hours = regular_hours + overtime_total
|
||||||
|
|
||||||
|
update_data["regular_hours"] = regular_hours
|
||||||
|
update_data["overtime_total"] = overtime_total
|
||||||
|
update_data["total_hours"] = total_hours
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(work, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(work)
|
||||||
|
return work
|
||||||
|
|
||||||
|
@router.delete("/{work_id}")
|
||||||
|
async def delete_daily_work(
|
||||||
|
work_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||||
|
if not work:
|
||||||
|
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||||
|
|
||||||
|
# 권한 확인 (관리자만 삭제 가능)
|
||||||
|
if current_user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can delete daily work")
|
||||||
|
|
||||||
|
db.delete(work)
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Daily work deleted successfully"}
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def get_daily_work_stats(
|
||||||
|
start_date: Optional[date] = None,
|
||||||
|
end_date: Optional[date] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""일일 공수 통계"""
|
||||||
|
query = db.query(DailyWork)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(DailyWork.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(DailyWork.date <= end_date)
|
||||||
|
|
||||||
|
works = query.all()
|
||||||
|
|
||||||
|
if not works:
|
||||||
|
return {
|
||||||
|
"total_days": 0,
|
||||||
|
"total_hours": 0,
|
||||||
|
"total_overtime": 0,
|
||||||
|
"average_daily_hours": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
total_hours = sum(w.total_hours for w in works)
|
||||||
|
total_overtime = sum(w.overtime_total for w in works)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_days": len(works),
|
||||||
|
"total_hours": total_hours,
|
||||||
|
"total_overtime": total_overtime,
|
||||||
|
"average_daily_hours": total_hours / len(works)
|
||||||
|
}
|
||||||
164
backend/routers/issues.py
Normal file
164
backend/routers/issues.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from database.database import get_db
|
||||||
|
from database.models import Issue, IssueStatus, User, UserRole
|
||||||
|
from database import schemas
|
||||||
|
from routers.auth import get_current_user
|
||||||
|
from services.file_service import save_base64_image, delete_file
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/issues", tags=["issues"])
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.Issue)
|
||||||
|
async def create_issue(
|
||||||
|
issue: schemas.IssueCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 이미지 저장
|
||||||
|
photo_path = None
|
||||||
|
if issue.photo:
|
||||||
|
photo_path = save_base64_image(issue.photo)
|
||||||
|
|
||||||
|
# Issue 생성
|
||||||
|
db_issue = Issue(
|
||||||
|
category=issue.category,
|
||||||
|
description=issue.description,
|
||||||
|
photo_path=photo_path,
|
||||||
|
reporter_id=current_user.id,
|
||||||
|
status=IssueStatus.NEW
|
||||||
|
)
|
||||||
|
db.add(db_issue)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_issue)
|
||||||
|
return db_issue
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.Issue])
|
||||||
|
async def read_issues(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status: Optional[IssueStatus] = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
query = db.query(Issue)
|
||||||
|
|
||||||
|
# 일반 사용자는 자신의 이슈만 조회
|
||||||
|
if current_user.role == UserRole.USER:
|
||||||
|
query = query.filter(Issue.reporter_id == current_user.id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Issue.status == status)
|
||||||
|
|
||||||
|
issues = query.offset(skip).limit(limit).all()
|
||||||
|
return issues
|
||||||
|
|
||||||
|
@router.get("/{issue_id}", response_model=schemas.Issue)
|
||||||
|
async def read_issue(
|
||||||
|
issue_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||||
|
if not issue:
|
||||||
|
raise HTTPException(status_code=404, detail="Issue not found")
|
||||||
|
|
||||||
|
# 권한 확인
|
||||||
|
if current_user.role == UserRole.USER and issue.reporter_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to view this issue")
|
||||||
|
|
||||||
|
return issue
|
||||||
|
|
||||||
|
@router.put("/{issue_id}", response_model=schemas.Issue)
|
||||||
|
async def update_issue(
|
||||||
|
issue_id: int,
|
||||||
|
issue_update: schemas.IssueUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||||
|
if not issue:
|
||||||
|
raise HTTPException(status_code=404, detail="Issue not found")
|
||||||
|
|
||||||
|
# 권한 확인
|
||||||
|
if current_user.role == UserRole.USER and issue.reporter_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to update this issue")
|
||||||
|
|
||||||
|
# 업데이트
|
||||||
|
update_data = issue_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# 사진이 업데이트되는 경우 처리
|
||||||
|
if "photo" in update_data:
|
||||||
|
# 기존 사진 삭제
|
||||||
|
if issue.photo_path:
|
||||||
|
delete_file(issue.photo_path)
|
||||||
|
|
||||||
|
# 새 사진 저장
|
||||||
|
if update_data["photo"]:
|
||||||
|
photo_path = save_base64_image(update_data["photo"])
|
||||||
|
update_data["photo_path"] = photo_path
|
||||||
|
else:
|
||||||
|
update_data["photo_path"] = None
|
||||||
|
|
||||||
|
# photo 필드는 제거 (DB에는 photo_path만 저장)
|
||||||
|
del update_data["photo"]
|
||||||
|
|
||||||
|
# work_hours가 입력되면 자동으로 상태를 complete로 변경
|
||||||
|
if "work_hours" in update_data and update_data["work_hours"] > 0:
|
||||||
|
if issue.status == IssueStatus.NEW:
|
||||||
|
update_data["status"] = IssueStatus.COMPLETE
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(issue, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(issue)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
@router.delete("/{issue_id}")
|
||||||
|
async def delete_issue(
|
||||||
|
issue_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||||
|
if not issue:
|
||||||
|
raise HTTPException(status_code=404, detail="Issue not found")
|
||||||
|
|
||||||
|
# 권한 확인 (관리자만 삭제 가능)
|
||||||
|
if current_user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin can delete issues")
|
||||||
|
|
||||||
|
# 이미지 파일 삭제
|
||||||
|
if issue.photo_path:
|
||||||
|
delete_file(issue.photo_path)
|
||||||
|
|
||||||
|
db.delete(issue)
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Issue deleted successfully"}
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def get_issue_stats(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""이슈 통계 조회"""
|
||||||
|
query = db.query(Issue)
|
||||||
|
|
||||||
|
# 일반 사용자는 자신의 이슈만
|
||||||
|
if current_user.role == UserRole.USER:
|
||||||
|
query = query.filter(Issue.reporter_id == current_user.id)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
new = query.filter(Issue.status == IssueStatus.NEW).count()
|
||||||
|
progress = query.filter(Issue.status == IssueStatus.PROGRESS).count()
|
||||||
|
complete = query.filter(Issue.status == IssueStatus.COMPLETE).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"new": new,
|
||||||
|
"progress": progress,
|
||||||
|
"complete": complete
|
||||||
|
}
|
||||||
130
backend/routers/reports.py
Normal file
130
backend/routers/reports.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database.database import get_db
|
||||||
|
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole
|
||||||
|
from database import schemas
|
||||||
|
from routers.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||||
|
|
||||||
|
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||||
|
async def generate_report_summary(
|
||||||
|
report_request: schemas.ReportRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""보고서 요약 생성"""
|
||||||
|
start_date = report_request.start_date
|
||||||
|
end_date = report_request.end_date
|
||||||
|
|
||||||
|
# 일일 공수 합계
|
||||||
|
daily_works = db.query(DailyWork).filter(
|
||||||
|
DailyWork.date >= start_date.date(),
|
||||||
|
DailyWork.date <= end_date.date()
|
||||||
|
).all()
|
||||||
|
total_hours = sum(w.total_hours for w in daily_works)
|
||||||
|
|
||||||
|
# 이슈 통계
|
||||||
|
issues_query = db.query(Issue).filter(
|
||||||
|
Issue.report_date >= start_date,
|
||||||
|
Issue.report_date <= end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# 일반 사용자는 자신의 이슈만
|
||||||
|
if current_user.role == UserRole.USER:
|
||||||
|
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||||
|
|
||||||
|
issues = issues_query.all()
|
||||||
|
|
||||||
|
# 카테고리별 통계
|
||||||
|
category_stats = schemas.CategoryStats()
|
||||||
|
completed_issues = 0
|
||||||
|
total_resolution_time = 0
|
||||||
|
resolved_count = 0
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
# 카테고리별 카운트
|
||||||
|
if issue.category == IssueCategory.MATERIAL_MISSING:
|
||||||
|
category_stats.material_missing += 1
|
||||||
|
elif issue.category == IssueCategory.DIMENSION_DEFECT:
|
||||||
|
category_stats.dimension_defect += 1
|
||||||
|
elif issue.category == IssueCategory.INCOMING_DEFECT:
|
||||||
|
category_stats.incoming_defect += 1
|
||||||
|
|
||||||
|
# 완료된 이슈
|
||||||
|
if issue.status == IssueStatus.COMPLETE:
|
||||||
|
completed_issues += 1
|
||||||
|
if issue.work_hours > 0:
|
||||||
|
total_resolution_time += issue.work_hours
|
||||||
|
resolved_count += 1
|
||||||
|
|
||||||
|
# 평균 해결 시간
|
||||||
|
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||||
|
|
||||||
|
return schemas.ReportSummary(
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
total_hours=total_hours,
|
||||||
|
total_issues=len(issues),
|
||||||
|
category_stats=category_stats,
|
||||||
|
completed_issues=completed_issues,
|
||||||
|
average_resolution_time=average_resolution_time
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/issues")
|
||||||
|
async def get_report_issues(
|
||||||
|
start_date: datetime,
|
||||||
|
end_date: datetime,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""보고서용 이슈 상세 목록"""
|
||||||
|
query = db.query(Issue).filter(
|
||||||
|
Issue.report_date >= start_date,
|
||||||
|
Issue.report_date <= end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# 일반 사용자는 자신의 이슈만
|
||||||
|
if current_user.role == UserRole.USER:
|
||||||
|
query = query.filter(Issue.reporter_id == current_user.id)
|
||||||
|
|
||||||
|
issues = query.order_by(Issue.report_date).all()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"id": issue.id,
|
||||||
|
"photo_path": issue.photo_path,
|
||||||
|
"category": issue.category,
|
||||||
|
"description": issue.description,
|
||||||
|
"status": issue.status,
|
||||||
|
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||||
|
"report_date": issue.report_date,
|
||||||
|
"work_hours": issue.work_hours,
|
||||||
|
"detail_notes": issue.detail_notes
|
||||||
|
} for issue in issues]
|
||||||
|
|
||||||
|
@router.get("/daily-works")
|
||||||
|
async def get_report_daily_works(
|
||||||
|
start_date: datetime,
|
||||||
|
end_date: datetime,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""보고서용 일일 공수 목록"""
|
||||||
|
works = db.query(DailyWork).filter(
|
||||||
|
DailyWork.date >= start_date.date(),
|
||||||
|
DailyWork.date <= end_date.date()
|
||||||
|
).order_by(DailyWork.date).all()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"date": work.date,
|
||||||
|
"worker_count": work.worker_count,
|
||||||
|
"regular_hours": work.regular_hours,
|
||||||
|
"overtime_workers": work.overtime_workers,
|
||||||
|
"overtime_hours": work.overtime_hours,
|
||||||
|
"overtime_total": work.overtime_total,
|
||||||
|
"total_hours": work.total_hours
|
||||||
|
} for work in works]
|
||||||
BIN
backend/services/__pycache__/auth_service.cpython-311.pyc
Normal file
BIN
backend/services/__pycache__/auth_service.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/file_service.cpython-311.pyc
Normal file
BIN
backend/services/__pycache__/file_service.cpython-311.pyc
Normal file
Binary file not shown.
73
backend/services/auth_service.py
Normal file
73
backend/services/auth_service.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import os
|
||||||
|
|
||||||
|
from database.models import User, UserRole
|
||||||
|
from database.schemas import TokenData
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
|
||||||
|
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7 days
|
||||||
|
|
||||||
|
# 비밀번호 암호화
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
def verify_token(token: str, credentials_exception):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
token_data = TokenData(username=username)
|
||||||
|
return token_data
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
def authenticate_user(db: Session, username: str, password: str):
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return False
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_admin_user(db: Session):
|
||||||
|
"""초기 관리자 계정 생성"""
|
||||||
|
admin_username = os.getenv("ADMIN_USERNAME", "hyungi")
|
||||||
|
admin_password = os.getenv("ADMIN_PASSWORD", "djg3-jj34-X3Q3")
|
||||||
|
|
||||||
|
# 이미 존재하는지 확인
|
||||||
|
existing_admin = db.query(User).filter(User.username == admin_username).first()
|
||||||
|
if not existing_admin:
|
||||||
|
admin_user = User(
|
||||||
|
username=admin_username,
|
||||||
|
hashed_password=get_password_hash(admin_password),
|
||||||
|
full_name="관리자",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(admin_user)
|
||||||
|
db.commit()
|
||||||
|
print(f"관리자 계정 생성됨: {admin_username}")
|
||||||
|
else:
|
||||||
|
print(f"관리자 계정이 이미 존재함: {admin_username}")
|
||||||
61
backend/services/file_service.py
Normal file
61
backend/services/file_service.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
UPLOAD_DIR = "/app/uploads"
|
||||||
|
|
||||||
|
def ensure_upload_dir():
|
||||||
|
"""업로드 디렉토리 생성"""
|
||||||
|
if not os.path.exists(UPLOAD_DIR):
|
||||||
|
os.makedirs(UPLOAD_DIR)
|
||||||
|
|
||||||
|
def save_base64_image(base64_string: str) -> Optional[str]:
|
||||||
|
"""Base64 이미지를 파일로 저장하고 경로 반환"""
|
||||||
|
try:
|
||||||
|
ensure_upload_dir()
|
||||||
|
|
||||||
|
# Base64 헤더 제거
|
||||||
|
if "," in base64_string:
|
||||||
|
base64_string = base64_string.split(",")[1]
|
||||||
|
|
||||||
|
# 디코딩
|
||||||
|
image_data = base64.b64decode(base64_string)
|
||||||
|
|
||||||
|
# 이미지 검증 및 형식 확인
|
||||||
|
image = Image.open(io.BytesIO(image_data))
|
||||||
|
format = image.format.lower() if image.format else 'png'
|
||||||
|
|
||||||
|
# 파일명 생성
|
||||||
|
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.{format}"
|
||||||
|
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||||
|
|
||||||
|
# 이미지 저장 (최대 크기 제한)
|
||||||
|
max_size = (1920, 1920)
|
||||||
|
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
if format == 'png':
|
||||||
|
image.save(filepath, 'PNG', optimize=True)
|
||||||
|
else:
|
||||||
|
image.save(filepath, 'JPEG', quality=85, optimize=True)
|
||||||
|
|
||||||
|
# 웹 경로 반환
|
||||||
|
return f"/uploads/{filename}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"이미지 저장 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_file(filepath: str):
|
||||||
|
"""파일 삭제"""
|
||||||
|
try:
|
||||||
|
if filepath and filepath.startswith("/uploads/"):
|
||||||
|
filename = filepath.replace("/uploads/", "")
|
||||||
|
full_path = os.path.join(UPLOAD_DIR, filename)
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
os.remove(full_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"파일 삭제 실패: {e}")
|
||||||
417
chart.html
Normal file
417
chart.html
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
<!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="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sky-50: #f0f9ff;
|
||||||
|
--sky-100: #e0f2fe;
|
||||||
|
--sky-200: #bae6fd;
|
||||||
|
--sky-300: #7dd3fc;
|
||||||
|
--sky-400: #38bdf8;
|
||||||
|
--sky-500: #0ea5e9;
|
||||||
|
--gray-100: #f3f4f6;
|
||||||
|
--gray-200: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(to bottom, #ffffff, #f0f9ff);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-left: 4px solid var(--sky-400);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<header class="glass sticky top-0 z-50 shadow-sm">
|
||||||
|
<div class="container mx-auto px-4 py-3 flex justify-between items-center">
|
||||||
|
<h1 class="text-xl font-bold text-gray-800">
|
||||||
|
<i class="fas fa-chart-bar text-sky-500 mr-2"></i>차트
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="index.html" class="text-sky-600 hover:text-sky-700">
|
||||||
|
<i class="fas fa-camera text-xl"></i>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-600 text-sm" id="userName"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 메인 컨텐츠 -->
|
||||||
|
<main class="container mx-auto px-4 py-6 max-w-6xl">
|
||||||
|
<!-- 통계 카드 -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">전체 사진</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-800" id="totalCount">0</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-images text-3xl text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">오늘</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-800" id="todayCount">0</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-calendar-day text-3xl text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">이번 주</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-800" id="weekCount">0</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-calendar-week text-3xl text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">가장 많은</p>
|
||||||
|
<p class="text-lg font-bold text-gray-800" id="topCategory">-</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-trophy text-3xl text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 차트 영역 -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- 카테고리별 차트 -->
|
||||||
|
<div class="glass p-6 rounded-xl shadow-lg">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">카테고리별 분포</h3>
|
||||||
|
<canvas id="categoryChart" width="400" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 일별 추이 차트 -->
|
||||||
|
<div class="glass p-6 rounded-xl shadow-lg">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">최근 7일 추이</h3>
|
||||||
|
<canvas id="trendChart" width="400" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 갤러리 -->
|
||||||
|
<div class="glass p-6 rounded-xl shadow-lg">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">전체 갤러리</h3>
|
||||||
|
<select id="categoryFilter" class="px-4 py-2 rounded-lg border border-gray-300 focus:border-sky-400 focus:outline-none">
|
||||||
|
<option value="all">전체 카테고리</option>
|
||||||
|
<option value="food">음식</option>
|
||||||
|
<option value="place">장소</option>
|
||||||
|
<option value="people">사람</option>
|
||||||
|
<option value="object">물건</option>
|
||||||
|
<option value="event">행사</option>
|
||||||
|
<option value="etc">기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gallery" class="grid grid-cols-3 md:grid-cols-6 gap-3">
|
||||||
|
<!-- 갤러리 아이템들이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 이미지 모달 -->
|
||||||
|
<div id="imageModal" class="modal" onclick="closeModal()">
|
||||||
|
<div class="bg-white rounded-xl p-4 max-w-2xl mx-4" onclick="event.stopPropagation()">
|
||||||
|
<img id="modalImage" class="w-full rounded-lg mb-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-lg" id="modalDescription"></p>
|
||||||
|
<p class="text-sm text-gray-600 mt-1" id="modalInfo"></p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allData = [];
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
window.onload = () => {
|
||||||
|
// 로그인 체크
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
currentUser = params.get('user');
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
// 로컬스토리지에서 최근 사용자 확인
|
||||||
|
const recentUser = localStorage.getItem('m-project-recent-user');
|
||||||
|
if (recentUser) {
|
||||||
|
currentUser = recentUser;
|
||||||
|
} else {
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('userName').textContent = currentUser;
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
function loadData() {
|
||||||
|
const savedData = JSON.parse(localStorage.getItem('m-project-data') || '[]');
|
||||||
|
allData = savedData.filter(item => item.user === currentUser);
|
||||||
|
|
||||||
|
updateStats();
|
||||||
|
createCharts();
|
||||||
|
displayGallery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계 업데이트
|
||||||
|
function updateStats() {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
document.getElementById('totalCount').textContent = allData.length;
|
||||||
|
|
||||||
|
// 오늘 카운트
|
||||||
|
const todayCount = allData.filter(item => {
|
||||||
|
const itemDate = new Date(item.timestamp);
|
||||||
|
return itemDate >= today;
|
||||||
|
}).length;
|
||||||
|
document.getElementById('todayCount').textContent = todayCount;
|
||||||
|
|
||||||
|
// 이번 주 카운트
|
||||||
|
const weekCount = allData.filter(item => {
|
||||||
|
const itemDate = new Date(item.timestamp);
|
||||||
|
return itemDate >= weekAgo;
|
||||||
|
}).length;
|
||||||
|
document.getElementById('weekCount').textContent = weekCount;
|
||||||
|
|
||||||
|
// 가장 많은 카테고리
|
||||||
|
const categoryCounts = {};
|
||||||
|
allData.forEach(item => {
|
||||||
|
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryNames = {
|
||||||
|
food: '음식',
|
||||||
|
place: '장소',
|
||||||
|
people: '사람',
|
||||||
|
object: '물건',
|
||||||
|
event: '행사',
|
||||||
|
etc: '기타'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(categoryCounts).length > 0) {
|
||||||
|
const topCat = Object.entries(categoryCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])[0][0];
|
||||||
|
document.getElementById('topCategory').textContent = categoryNames[topCat];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 차트 생성
|
||||||
|
function createCharts() {
|
||||||
|
// 카테고리별 차트
|
||||||
|
const categoryCounts = {};
|
||||||
|
allData.forEach(item => {
|
||||||
|
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryNames = {
|
||||||
|
food: '음식',
|
||||||
|
place: '장소',
|
||||||
|
people: '사람',
|
||||||
|
object: '물건',
|
||||||
|
event: '행사',
|
||||||
|
etc: '기타'
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryCtx = document.getElementById('categoryChart').getContext('2d');
|
||||||
|
new Chart(categoryCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(categoryCounts).map(key => categoryNames[key]),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(categoryCounts),
|
||||||
|
backgroundColor: [
|
||||||
|
'#0ea5e9',
|
||||||
|
'#38bdf8',
|
||||||
|
'#7dd3fc',
|
||||||
|
'#bae6fd',
|
||||||
|
'#e0f2fe',
|
||||||
|
'#f0f9ff'
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 일별 추이 차트
|
||||||
|
const today = new Date();
|
||||||
|
const dates = [];
|
||||||
|
const counts = [];
|
||||||
|
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const nextDate = new Date(date);
|
||||||
|
nextDate.setDate(nextDate.getDate() + 1);
|
||||||
|
|
||||||
|
const count = allData.filter(item => {
|
||||||
|
const itemDate = new Date(item.timestamp);
|
||||||
|
return itemDate >= date && itemDate < nextDate;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
dates.push(date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }));
|
||||||
|
counts.push(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||||
|
new Chart(trendCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: dates,
|
||||||
|
datasets: [{
|
||||||
|
label: '업로드 수',
|
||||||
|
data: counts,
|
||||||
|
borderColor: '#0ea5e9',
|
||||||
|
backgroundColor: 'rgba(14, 165, 233, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 갤러리 표시
|
||||||
|
function displayGallery(filter = 'all') {
|
||||||
|
const gallery = document.getElementById('gallery');
|
||||||
|
gallery.innerHTML = '';
|
||||||
|
|
||||||
|
const filteredData = filter === 'all'
|
||||||
|
? allData
|
||||||
|
: allData.filter(item => item.category === filter);
|
||||||
|
|
||||||
|
// 최신순으로 정렬
|
||||||
|
filteredData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
|
|
||||||
|
filteredData.forEach(item => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'gallery-item';
|
||||||
|
div.innerHTML = `<img src="${item.image}" class="w-full h-24 object-cover rounded-lg shadow-sm">`;
|
||||||
|
div.onclick = () => showModal(item);
|
||||||
|
gallery.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredData.length === 0) {
|
||||||
|
gallery.innerHTML = '<p class="col-span-full text-center text-gray-500 py-8">표시할 이미지가 없습니다.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 변경
|
||||||
|
document.getElementById('categoryFilter').addEventListener('change', (e) => {
|
||||||
|
displayGallery(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모달 표시
|
||||||
|
function showModal(item) {
|
||||||
|
const categoryNames = {
|
||||||
|
food: '음식',
|
||||||
|
place: '장소',
|
||||||
|
people: '사람',
|
||||||
|
object: '물건',
|
||||||
|
event: '행사',
|
||||||
|
etc: '기타'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('modalImage').src = item.image;
|
||||||
|
document.getElementById('modalDescription').textContent = item.description;
|
||||||
|
document.getElementById('modalInfo').textContent =
|
||||||
|
`${categoryNames[item.category]} • ${new Date(item.timestamp).toLocaleString('ko-KR')}`;
|
||||||
|
document.getElementById('imageModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 닫기
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('imageModal').classList.remove('show');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
378
daily-work.html
Normal file
378
daily-work.html
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
<!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;
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success);
|
||||||
|
color: white;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #059669;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||||
|
<div class="container mx-auto px-4 py-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-xl font-bold text-gray-800">
|
||||||
|
<i class="fas fa-calendar-check text-blue-500 mr-2"></i>일일 공수 입력
|
||||||
|
</h1>
|
||||||
|
<a href="index.html" class="text-gray-600 hover:text-gray-800">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>돌아가기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 메인 컨텐츠 -->
|
||||||
|
<main class="container mx-auto px-4 py-6 max-w-2xl">
|
||||||
|
<!-- 입력 카드 -->
|
||||||
|
<div class="work-card p-6 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-6">
|
||||||
|
<i class="fas fa-edit text-blue-500 mr-2"></i>공수 입력
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form id="dailyWorkForm" class="space-y-6">
|
||||||
|
<!-- 날짜 선택 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<i class="fas fa-calendar mr-1"></i>날짜
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="workDate"
|
||||||
|
class="input-field w-full px-4 py-3 rounded-lg text-lg"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</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">
|
||||||
|
<label class="text-sm font-medium text-gray-700">
|
||||||
|
<i class="fas fa-clock mr-1"></i>잔업 여부
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="overtimeToggle"
|
||||||
|
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors"
|
||||||
|
onclick="toggleOvertime()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus mr-2"></i>잔업 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 잔업 입력 영역 -->
|
||||||
|
<div id="overtimeSection" class="hidden space-y-3 bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">잔업 인원</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="overtimeWorkers"
|
||||||
|
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 class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-700 font-medium">예상 총 공수</span>
|
||||||
|
<span class="text-2xl font-bold text-blue-600" id="totalHours">0시간</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 저장 버튼 -->
|
||||||
|
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
|
||||||
|
<i class="fas fa-save mr-2"></i>저장하기
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 최근 입력 내역 -->
|
||||||
|
<div class="work-card p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
<i class="fas fa-history text-gray-500 mr-2"></i>최근 입력 내역
|
||||||
|
</h3>
|
||||||
|
<div id="recentEntries" class="space-y-3">
|
||||||
|
<!-- 최근 입력 내역이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 오늘 날짜로 초기화
|
||||||
|
document.getElementById('workDate').valueAsDate = new Date();
|
||||||
|
|
||||||
|
// 잔업 토글
|
||||||
|
function toggleOvertime() {
|
||||||
|
const section = document.getElementById('overtimeSection');
|
||||||
|
const button = document.getElementById('overtimeToggle');
|
||||||
|
|
||||||
|
if (section.classList.contains('hidden')) {
|
||||||
|
section.classList.remove('hidden');
|
||||||
|
button.innerHTML = '<i class="fas fa-minus mr-2"></i>잔업 취소';
|
||||||
|
button.classList.add('bg-gray-100');
|
||||||
|
} else {
|
||||||
|
section.classList.add('hidden');
|
||||||
|
button.innerHTML = '<i class="fas fa-plus mr-2"></i>잔업 추가';
|
||||||
|
button.classList.remove('bg-gray-100');
|
||||||
|
// 잔업 입력값 초기화
|
||||||
|
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', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const workDate = document.getElementById('workDate').value;
|
||||||
|
const workerCount = parseInt(document.getElementById('workerCount').value);
|
||||||
|
const regularHours = workerCount * 8;
|
||||||
|
|
||||||
|
let overtimeWorkers = 0;
|
||||||
|
let overtimeHours = 0;
|
||||||
|
let overtimeTotal = 0;
|
||||||
|
|
||||||
|
if (!document.getElementById('overtimeSection').classList.contains('hidden')) {
|
||||||
|
overtimeWorkers = parseInt(document.getElementById('overtimeWorkers').value) || 0;
|
||||||
|
overtimeHours = parseFloat(document.getElementById('overtimeHours').value) || 0;
|
||||||
|
overtimeTotal = overtimeWorkers * overtimeHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHours = regularHours + overtimeTotal;
|
||||||
|
|
||||||
|
// 데이터 객체 생성
|
||||||
|
const workData = {
|
||||||
|
id: Date.now(),
|
||||||
|
date: workDate,
|
||||||
|
workerCount: workerCount,
|
||||||
|
regularHours: regularHours,
|
||||||
|
overtimeWorkers: overtimeWorkers,
|
||||||
|
overtimeHours: overtimeHours,
|
||||||
|
overtimeTotal: overtimeTotal,
|
||||||
|
totalHours: totalHours,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로컬 스토리지에 저장
|
||||||
|
saveWorkData(workData);
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
showSuccessMessage();
|
||||||
|
|
||||||
|
// 폼 초기화
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
// 최근 내역 갱신
|
||||||
|
displayRecentEntries();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터 저장
|
||||||
|
function saveWorkData(data) {
|
||||||
|
const savedData = JSON.parse(localStorage.getItem('daily-work-data') || '[]');
|
||||||
|
|
||||||
|
// 같은 날짜 데이터가 있으면 업데이트
|
||||||
|
const existingIndex = savedData.findIndex(item => item.date === data.date);
|
||||||
|
if (existingIndex > -1) {
|
||||||
|
savedData[existingIndex] = data;
|
||||||
|
} else {
|
||||||
|
savedData.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('daily-work-data', JSON.stringify(savedData));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
function showSuccessMessage() {
|
||||||
|
const button = document.querySelector('button[type="submit"]');
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = '<i class="fas fa-check-circle mr-2"></i>저장 완료!';
|
||||||
|
button.classList.remove('btn-primary');
|
||||||
|
button.classList.add('btn-success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
button.classList.remove('btn-success');
|
||||||
|
button.classList.add('btn-primary');
|
||||||
|
}, 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() {
|
||||||
|
const container = document.getElementById('recentEntries');
|
||||||
|
const savedData = JSON.parse(localStorage.getItem('daily-work-data') || '[]');
|
||||||
|
|
||||||
|
// 최근 7개만 표시
|
||||||
|
const recentData = savedData.slice(-7).reverse();
|
||||||
|
|
||||||
|
if (recentData.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center py-4">입력된 내역이 없습니다.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = recentData.map(item => {
|
||||||
|
const date = new Date(item.date);
|
||||||
|
const dateStr = date.toLocaleDateString('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="flex justify-between items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-800">${dateStr}</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
인원: ${item.workerCount}명
|
||||||
|
${item.overtimeTotal > 0 ? `• 잔업: ${item.overtimeWorkers}명 × ${item.overtimeHours}시간` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-lg font-bold text-blue-600">${item.totalHours}시간</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 로드 시 최근 내역 표시
|
||||||
|
displayRecentEntries();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: m-project-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: mproject
|
||||||
|
POSTGRES_PASSWORD: mproject2024
|
||||||
|
POSTGRES_DB: mproject
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./backend/migrations:/docker-entrypoint-initdb.d
|
||||||
|
ports:
|
||||||
|
- "16432:5432"
|
||||||
|
networks:
|
||||||
|
- m-project-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: m-project-backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://mproject:mproject2024@db:5432/mproject
|
||||||
|
SECRET_KEY: your-secret-key-here-change-in-production
|
||||||
|
ALGORITHM: HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: 10080 # 7 days
|
||||||
|
ADMIN_USERNAME: hyungi
|
||||||
|
ADMIN_PASSWORD: djg3-jj34-X3Q3
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- uploads:/app/uploads
|
||||||
|
ports:
|
||||||
|
- "16000:8000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- m-project-network
|
||||||
|
restart: unless-stopped
|
||||||
|
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
build: ./nginx
|
||||||
|
container_name: m-project-nginx
|
||||||
|
ports:
|
||||||
|
- "16080:80"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/usr/share/nginx/html
|
||||||
|
- uploads:/usr/share/nginx/html/uploads
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- m-project-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
uploads:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
m-project-network:
|
||||||
|
driver: bridge
|
||||||
417
frontend/chart.html
Normal file
417
frontend/chart.html
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
<!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="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sky-50: #f0f9ff;
|
||||||
|
--sky-100: #e0f2fe;
|
||||||
|
--sky-200: #bae6fd;
|
||||||
|
--sky-300: #7dd3fc;
|
||||||
|
--sky-400: #38bdf8;
|
||||||
|
--sky-500: #0ea5e9;
|
||||||
|
--gray-100: #f3f4f6;
|
||||||
|
--gray-200: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(to bottom, #ffffff, #f0f9ff);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-left: 4px solid var(--sky-400);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<header class="glass sticky top-0 z-50 shadow-sm">
|
||||||
|
<div class="container mx-auto px-4 py-3 flex justify-between items-center">
|
||||||
|
<h1 class="text-xl font-bold text-gray-800">
|
||||||
|
<i class="fas fa-chart-bar text-sky-500 mr-2"></i>차트
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="index.html" class="text-sky-600 hover:text-sky-700">
|
||||||
|
<i class="fas fa-camera text-xl"></i>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-600 text-sm" id="userName"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 메인 컨텐츠 -->
|
||||||
|
<main class="container mx-auto px-4 py-6 max-w-6xl">
|
||||||
|
<!-- 통계 카드 -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">전체 사진</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-800" id="totalCount">0</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-images text-3xl text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">오늘</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-800" id="todayCount">0</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-calendar-day text-3xl text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">이번 주</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-800" id="weekCount">0</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-calendar-week text-3xl text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">가장 많은</p>
|
||||||
|
<p class="text-lg font-bold text-gray-800" id="topCategory">-</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-trophy text-3xl text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 차트 영역 -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- 카테고리별 차트 -->
|
||||||
|
<div class="glass p-6 rounded-xl shadow-lg">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">카테고리별 분포</h3>
|
||||||
|
<canvas id="categoryChart" width="400" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 일별 추이 차트 -->
|
||||||
|
<div class="glass p-6 rounded-xl shadow-lg">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">최근 7일 추이</h3>
|
||||||
|
<canvas id="trendChart" width="400" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 갤러리 -->
|
||||||
|
<div class="glass p-6 rounded-xl shadow-lg">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">전체 갤러리</h3>
|
||||||
|
<select id="categoryFilter" class="px-4 py-2 rounded-lg border border-gray-300 focus:border-sky-400 focus:outline-none">
|
||||||
|
<option value="all">전체 카테고리</option>
|
||||||
|
<option value="food">음식</option>
|
||||||
|
<option value="place">장소</option>
|
||||||
|
<option value="people">사람</option>
|
||||||
|
<option value="object">물건</option>
|
||||||
|
<option value="event">행사</option>
|
||||||
|
<option value="etc">기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gallery" class="grid grid-cols-3 md:grid-cols-6 gap-3">
|
||||||
|
<!-- 갤러리 아이템들이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 이미지 모달 -->
|
||||||
|
<div id="imageModal" class="modal" onclick="closeModal()">
|
||||||
|
<div class="bg-white rounded-xl p-4 max-w-2xl mx-4" onclick="event.stopPropagation()">
|
||||||
|
<img id="modalImage" class="w-full rounded-lg mb-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-lg" id="modalDescription"></p>
|
||||||
|
<p class="text-sm text-gray-600 mt-1" id="modalInfo"></p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allData = [];
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
window.onload = () => {
|
||||||
|
// 로그인 체크
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
currentUser = params.get('user');
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
// 로컬스토리지에서 최근 사용자 확인
|
||||||
|
const recentUser = localStorage.getItem('m-project-recent-user');
|
||||||
|
if (recentUser) {
|
||||||
|
currentUser = recentUser;
|
||||||
|
} else {
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('userName').textContent = currentUser;
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
function loadData() {
|
||||||
|
const savedData = JSON.parse(localStorage.getItem('m-project-data') || '[]');
|
||||||
|
allData = savedData.filter(item => item.user === currentUser);
|
||||||
|
|
||||||
|
updateStats();
|
||||||
|
createCharts();
|
||||||
|
displayGallery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계 업데이트
|
||||||
|
function updateStats() {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
document.getElementById('totalCount').textContent = allData.length;
|
||||||
|
|
||||||
|
// 오늘 카운트
|
||||||
|
const todayCount = allData.filter(item => {
|
||||||
|
const itemDate = new Date(item.timestamp);
|
||||||
|
return itemDate >= today;
|
||||||
|
}).length;
|
||||||
|
document.getElementById('todayCount').textContent = todayCount;
|
||||||
|
|
||||||
|
// 이번 주 카운트
|
||||||
|
const weekCount = allData.filter(item => {
|
||||||
|
const itemDate = new Date(item.timestamp);
|
||||||
|
return itemDate >= weekAgo;
|
||||||
|
}).length;
|
||||||
|
document.getElementById('weekCount').textContent = weekCount;
|
||||||
|
|
||||||
|
// 가장 많은 카테고리
|
||||||
|
const categoryCounts = {};
|
||||||
|
allData.forEach(item => {
|
||||||
|
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryNames = {
|
||||||
|
food: '음식',
|
||||||
|
place: '장소',
|
||||||
|
people: '사람',
|
||||||
|
object: '물건',
|
||||||
|
event: '행사',
|
||||||
|
etc: '기타'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(categoryCounts).length > 0) {
|
||||||
|
const topCat = Object.entries(categoryCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])[0][0];
|
||||||
|
document.getElementById('topCategory').textContent = categoryNames[topCat];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 차트 생성
|
||||||
|
function createCharts() {
|
||||||
|
// 카테고리별 차트
|
||||||
|
const categoryCounts = {};
|
||||||
|
allData.forEach(item => {
|
||||||
|
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryNames = {
|
||||||
|
food: '음식',
|
||||||
|
place: '장소',
|
||||||
|
people: '사람',
|
||||||
|
object: '물건',
|
||||||
|
event: '행사',
|
||||||
|
etc: '기타'
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryCtx = document.getElementById('categoryChart').getContext('2d');
|
||||||
|
new Chart(categoryCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(categoryCounts).map(key => categoryNames[key]),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(categoryCounts),
|
||||||
|
backgroundColor: [
|
||||||
|
'#0ea5e9',
|
||||||
|
'#38bdf8',
|
||||||
|
'#7dd3fc',
|
||||||
|
'#bae6fd',
|
||||||
|
'#e0f2fe',
|
||||||
|
'#f0f9ff'
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 일별 추이 차트
|
||||||
|
const today = new Date();
|
||||||
|
const dates = [];
|
||||||
|
const counts = [];
|
||||||
|
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const nextDate = new Date(date);
|
||||||
|
nextDate.setDate(nextDate.getDate() + 1);
|
||||||
|
|
||||||
|
const count = allData.filter(item => {
|
||||||
|
const itemDate = new Date(item.timestamp);
|
||||||
|
return itemDate >= date && itemDate < nextDate;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
dates.push(date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }));
|
||||||
|
counts.push(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||||
|
new Chart(trendCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: dates,
|
||||||
|
datasets: [{
|
||||||
|
label: '업로드 수',
|
||||||
|
data: counts,
|
||||||
|
borderColor: '#0ea5e9',
|
||||||
|
backgroundColor: 'rgba(14, 165, 233, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 갤러리 표시
|
||||||
|
function displayGallery(filter = 'all') {
|
||||||
|
const gallery = document.getElementById('gallery');
|
||||||
|
gallery.innerHTML = '';
|
||||||
|
|
||||||
|
const filteredData = filter === 'all'
|
||||||
|
? allData
|
||||||
|
: allData.filter(item => item.category === filter);
|
||||||
|
|
||||||
|
// 최신순으로 정렬
|
||||||
|
filteredData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
|
|
||||||
|
filteredData.forEach(item => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'gallery-item';
|
||||||
|
div.innerHTML = `<img src="${item.image}" class="w-full h-24 object-cover rounded-lg shadow-sm">`;
|
||||||
|
div.onclick = () => showModal(item);
|
||||||
|
gallery.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredData.length === 0) {
|
||||||
|
gallery.innerHTML = '<p class="col-span-full text-center text-gray-500 py-8">표시할 이미지가 없습니다.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 변경
|
||||||
|
document.getElementById('categoryFilter').addEventListener('change', (e) => {
|
||||||
|
displayGallery(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모달 표시
|
||||||
|
function showModal(item) {
|
||||||
|
const categoryNames = {
|
||||||
|
food: '음식',
|
||||||
|
place: '장소',
|
||||||
|
people: '사람',
|
||||||
|
object: '물건',
|
||||||
|
event: '행사',
|
||||||
|
etc: '기타'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('modalImage').src = item.image;
|
||||||
|
document.getElementById('modalDescription').textContent = item.description;
|
||||||
|
document.getElementById('modalInfo').textContent =
|
||||||
|
`${categoryNames[item.category]} • ${new Date(item.timestamp).toLocaleString('ko-KR')}`;
|
||||||
|
document.getElementById('imageModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 닫기
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('imageModal').classList.remove('show');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
458
frontend/daily-work.html
Normal file
458
frontend/daily-work.html
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
<!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;
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success);
|
||||||
|
color: white;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #059669;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #4b5563;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||||
|
<div class="container mx-auto px-4 py-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-xl font-bold text-gray-800">
|
||||||
|
<i class="fas fa-clipboard-list mr-2"></i>작업보고서 시스템
|
||||||
|
</h1>
|
||||||
|
<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>
|
||||||
|
</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 active">
|
||||||
|
<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">
|
||||||
|
<i class="fas fa-search mr-2"></i>부적합 조회
|
||||||
|
</a>
|
||||||
|
<a href="index.html#list" class="nav-link">
|
||||||
|
<i class="fas fa-list mr-2"></i>목록 관리
|
||||||
|
</a>
|
||||||
|
<a href="index.html#summary" class="nav-link">
|
||||||
|
<i class="fas fa-chart-bar mr-2"></i>보고서
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 메인 컨텐츠 -->
|
||||||
|
<main class="container mx-auto px-4 py-6 max-w-2xl">
|
||||||
|
<!-- 입력 카드 -->
|
||||||
|
<div class="work-card p-6 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-6">
|
||||||
|
<i class="fas fa-edit text-blue-500 mr-2"></i>공수 입력
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form id="dailyWorkForm" class="space-y-6">
|
||||||
|
<!-- 날짜 선택 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<i class="fas fa-calendar mr-1"></i>날짜
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="workDate"
|
||||||
|
class="input-field w-full px-4 py-3 rounded-lg text-lg"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</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">
|
||||||
|
<label class="text-sm font-medium text-gray-700">
|
||||||
|
<i class="fas fa-clock mr-1"></i>잔업 여부
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="overtimeToggle"
|
||||||
|
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors"
|
||||||
|
onclick="toggleOvertime()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus mr-2"></i>잔업 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 잔업 입력 영역 -->
|
||||||
|
<div id="overtimeSection" class="hidden space-y-3 bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">잔업 인원</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="overtimeWorkers"
|
||||||
|
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 class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-700 font-medium">예상 총 공수</span>
|
||||||
|
<span class="text-2xl font-bold text-blue-600" id="totalHours">0시간</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 저장 버튼 -->
|
||||||
|
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
|
||||||
|
<i class="fas fa-save mr-2"></i>저장하기
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 최근 입력 내역 -->
|
||||||
|
<div class="work-card p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
<i class="fas fa-history text-gray-500 mr-2"></i>최근 입력 내역
|
||||||
|
</h3>
|
||||||
|
<div id="recentEntries" class="space-y-3">
|
||||||
|
<!-- 최근 입력 내역이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script>
|
||||||
|
let currentUser = null;
|
||||||
|
let dailyWorks = [];
|
||||||
|
|
||||||
|
// 페이지 로드 시 인증 체크
|
||||||
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const user = TokenManager.getUser();
|
||||||
|
if (!user) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentUser = user;
|
||||||
|
|
||||||
|
// 오늘 날짜로 초기화
|
||||||
|
document.getElementById('workDate').valueAsDate = new Date();
|
||||||
|
|
||||||
|
// 최근 내역 로드
|
||||||
|
await loadRecentEntries();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 잔업 토글
|
||||||
|
function toggleOvertime() {
|
||||||
|
const section = document.getElementById('overtimeSection');
|
||||||
|
const button = document.getElementById('overtimeToggle');
|
||||||
|
|
||||||
|
if (section.classList.contains('hidden')) {
|
||||||
|
section.classList.remove('hidden');
|
||||||
|
button.innerHTML = '<i class="fas fa-minus mr-2"></i>잔업 취소';
|
||||||
|
button.classList.add('bg-gray-100');
|
||||||
|
} else {
|
||||||
|
section.classList.add('hidden');
|
||||||
|
button.innerHTML = '<i class="fas fa-plus mr-2"></i>잔업 추가';
|
||||||
|
button.classList.remove('bg-gray-100');
|
||||||
|
// 잔업 입력값 초기화
|
||||||
|
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) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const workDate = document.getElementById('workDate').value;
|
||||||
|
const workerCount = parseInt(document.getElementById('workerCount').value);
|
||||||
|
|
||||||
|
let overtimeWorkers = 0;
|
||||||
|
let overtimeHours = 0;
|
||||||
|
|
||||||
|
if (!document.getElementById('overtimeSection').classList.contains('hidden')) {
|
||||||
|
overtimeWorkers = parseInt(document.getElementById('overtimeWorkers').value) || 0;
|
||||||
|
overtimeHours = parseFloat(document.getElementById('overtimeHours').value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API 호출
|
||||||
|
await DailyWorkAPI.create({
|
||||||
|
date: new Date(workDate).toISOString(),
|
||||||
|
worker_count: workerCount,
|
||||||
|
overtime_workers: overtimeWorkers,
|
||||||
|
overtime_hours: overtimeHours
|
||||||
|
});
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
showSuccessMessage();
|
||||||
|
|
||||||
|
// 폼 초기화
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
// 최근 내역 갱신
|
||||||
|
await loadRecentEntries();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 최근 데이터 로드
|
||||||
|
async function loadRecentEntries() {
|
||||||
|
try {
|
||||||
|
// 최근 7일간 데이터 가져오기
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - 7);
|
||||||
|
|
||||||
|
dailyWorks = await DailyWorkAPI.getAll({
|
||||||
|
start_date: startDate.toISOString().split('T')[0],
|
||||||
|
end_date: endDate.toISOString().split('T')[0],
|
||||||
|
limit: 7
|
||||||
|
});
|
||||||
|
|
||||||
|
displayRecentEntries();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('데이터 로드 실패:', error);
|
||||||
|
dailyWorks = [];
|
||||||
|
displayRecentEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
function showSuccessMessage() {
|
||||||
|
const button = document.querySelector('button[type="submit"]');
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = '<i class="fas fa-check-circle mr-2"></i>저장 완료!';
|
||||||
|
button.classList.remove('btn-primary');
|
||||||
|
button.classList.add('btn-success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
button.classList.remove('btn-success');
|
||||||
|
button.classList.add('btn-primary');
|
||||||
|
}, 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() {
|
||||||
|
const container = document.getElementById('recentEntries');
|
||||||
|
|
||||||
|
if (dailyWorks.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center py-4">입력된 내역이 없습니다.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = dailyWorks.map(item => {
|
||||||
|
const date = new Date(item.date);
|
||||||
|
const dateStr = date.toLocaleDateString('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="flex justify-between items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-800">${dateStr}</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
인원: ${item.worker_count}명
|
||||||
|
${item.overtime_total > 0 ? `• 잔업: ${item.overtime_workers}명 × ${item.overtime_hours}시간` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-lg font-bold text-blue-600">${item.total_hours}시간</p>
|
||||||
|
${currentUser && currentUser.role === 'admin' ? `
|
||||||
|
<button
|
||||||
|
onclick="deleteDailyWork(${item.id})"
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일일 공수 삭제 (관리자만)
|
||||||
|
async function deleteDailyWork(workId) {
|
||||||
|
if (!currentUser || currentUser.role !== 'admin') {
|
||||||
|
alert('관리자만 삭제할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('정말로 이 일일 공수 기록을 삭제하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await DailyWorkAPI.delete(workId);
|
||||||
|
|
||||||
|
// 성공 시 목록 다시 로드
|
||||||
|
await loadRecentEntries();
|
||||||
|
|
||||||
|
alert('삭제되었습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
909
frontend/index.html
Normal file
909
frontend/index.html
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
<!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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-new {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-progress {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-complete {
|
||||||
|
background-color: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: var(--gray-100);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 로그인 화면 -->
|
||||||
|
<div id="loginScreen" class="min-h-screen flex items-center justify-center p-4 bg-gray-50">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<i class="fas fa-clipboard-check text-5xl text-blue-500 mb-4"></i>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800">작업보고서 시스템</h1>
|
||||||
|
<p class="text-gray-600 text-sm mt-2">부적합 사항 관리 및 공수 계산</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">아이디</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="userId"
|
||||||
|
class="input-field w-full px-4 py-2 rounded-lg"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
class="input-field w-full px-4 py-2 rounded-lg"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full py-2 rounded-lg font-medium">
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 메인 화면 -->
|
||||||
|
<div id="mainScreen" class="hidden min-h-screen bg-gray-50">
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||||
|
<div class="container mx-auto px-4 py-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-xl font-bold text-gray-800">
|
||||||
|
<i class="fas fa-clipboard-check text-blue-500 mr-2"></i>작업보고서
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm text-gray-600" id="userDisplay"></span>
|
||||||
|
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
<button class="nav-link active" onclick="showSection('report')">
|
||||||
|
<i class="fas fa-camera-retro mr-2"></i>부적합 등록
|
||||||
|
</button>
|
||||||
|
<a href="issue-view.html" class="nav-link">
|
||||||
|
<i class="fas fa-search mr-2"></i>부적합 조회
|
||||||
|
</a>
|
||||||
|
<button class="nav-link" onclick="showSection('list')">
|
||||||
|
<i class="fas fa-list mr-2"></i>목록 관리
|
||||||
|
</button>
|
||||||
|
<button class="nav-link" onclick="showSection('summary')">
|
||||||
|
<i class="fas fa-chart-bar mr-2"></i>보고서
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 부적합 등록 섹션 (모바일 최적화) -->
|
||||||
|
<section id="reportSection" class="container mx-auto px-4 py-6 max-w-lg">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-500 mr-2"></i>부적합 사항 등록
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form id="reportForm" class="space-y-4">
|
||||||
|
<!-- 사진 업로드 (선택사항) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
사진 <span class="text-gray-500 text-xs">(선택사항)</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
id="photoUpload"
|
||||||
|
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
|
||||||
|
onclick="document.getElementById('photoInput').click()"
|
||||||
|
>
|
||||||
|
<div id="photoPreview" class="hidden mb-4">
|
||||||
|
<img id="previewImg" class="max-h-48 mx-auto rounded-lg">
|
||||||
|
<button type="button" onclick="removePhoto(event)" class="mt-2 px-2 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600">
|
||||||
|
<i class="fas fa-times mr-1"></i>사진 제거
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="photoPlaceholder">
|
||||||
|
<i class="fas fa-camera text-4xl text-gray-400 mb-2"></i>
|
||||||
|
<p class="text-gray-600">사진 촬영 또는 선택</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="photoInput" accept="image/*" capture="camera" class="hidden">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 카테고리 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
|
||||||
|
<select id="category" class="input-field w-full px-4 py-2 rounded-lg" required>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="material_missing">자재누락</option>
|
||||||
|
<option value="dimension_defect">치수불량</option>
|
||||||
|
<option value="incoming_defect">입고자재 불량</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 간단 설명 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">간단 설명</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
rows="3"
|
||||||
|
class="input-field w-full px-4 py-2 rounded-lg resize-none"
|
||||||
|
placeholder="발견된 문제를 간단히 설명하세요"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
|
||||||
|
<i class="fas fa-check mr-2"></i>등록하기
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 목록 관리 섹션 -->
|
||||||
|
<section id="listSection" class="hidden container mx-auto px-4 py-6">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">부적합 사항 목록</h2>
|
||||||
|
<div id="issueList" class="space-y-4">
|
||||||
|
<!-- 목록이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 보고서 섹션 -->
|
||||||
|
<section id="summarySection" class="hidden container mx-auto px-4 py-6">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800">작업 보고서</h2>
|
||||||
|
<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 id="reportContent">
|
||||||
|
<!-- 보고서 내용이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script>
|
||||||
|
let currentUser = null;
|
||||||
|
let currentPhoto = null;
|
||||||
|
let issues = [];
|
||||||
|
|
||||||
|
// 페이지 로드 시 인증 체크
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const user = TokenManager.getUser();
|
||||||
|
if (user) {
|
||||||
|
currentUser = user;
|
||||||
|
document.getElementById('userDisplay').textContent = user.full_name || user.username;
|
||||||
|
document.getElementById('loginScreen').classList.add('hidden');
|
||||||
|
document.getElementById('mainScreen').classList.remove('hidden');
|
||||||
|
|
||||||
|
// 일반 사용자는 보고서 메뉴 숨김
|
||||||
|
if (user.role === 'user') {
|
||||||
|
const reportBtn = document.querySelector('button[onclick="showSection(\'summary\')"]');
|
||||||
|
if (reportBtn) reportBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIssues();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그인
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const userId = document.getElementById('userId').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await AuthAPI.login(userId, password);
|
||||||
|
currentUser = data.user;
|
||||||
|
document.getElementById('userDisplay').textContent = currentUser.full_name || currentUser.username;
|
||||||
|
document.getElementById('loginScreen').classList.add('hidden');
|
||||||
|
document.getElementById('mainScreen').classList.remove('hidden');
|
||||||
|
|
||||||
|
// 일반 사용자는 보고서 메뉴 숨김
|
||||||
|
if (currentUser.role === 'user') {
|
||||||
|
const reportBtn = document.querySelector('button[onclick="showSection(\'summary\')"]');
|
||||||
|
if (reportBtn) reportBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIssues();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || '로그인에 실패했습니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그아웃
|
||||||
|
function logout() {
|
||||||
|
AuthAPI.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 섹션 전환
|
||||||
|
function showSection(section) {
|
||||||
|
// 모든 섹션 숨기기
|
||||||
|
document.querySelectorAll('section').forEach(s => s.classList.add('hidden'));
|
||||||
|
// 선택된 섹션 표시
|
||||||
|
document.getElementById(section + 'Section').classList.remove('hidden');
|
||||||
|
|
||||||
|
// 네비게이션 활성화 상태 변경
|
||||||
|
document.querySelectorAll('.nav-link').forEach(link => link.classList.remove('active'));
|
||||||
|
event.target.closest('.nav-link').classList.add('active');
|
||||||
|
|
||||||
|
// 섹션별 초기화
|
||||||
|
if (section === 'list') {
|
||||||
|
displayIssueList();
|
||||||
|
} else if (section === 'summary') {
|
||||||
|
generateReport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진 업로드
|
||||||
|
document.getElementById('photoInput').addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
currentPhoto = e.target.result;
|
||||||
|
document.getElementById('previewImg').src = currentPhoto;
|
||||||
|
document.getElementById('photoPreview').classList.remove('hidden');
|
||||||
|
document.getElementById('photoPlaceholder').classList.add('hidden');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사진 제거
|
||||||
|
function removePhoto(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
currentPhoto = null;
|
||||||
|
document.getElementById('photoInput').value = '';
|
||||||
|
document.getElementById('photoPreview').classList.add('hidden');
|
||||||
|
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록에서 사진 변경
|
||||||
|
const tempPhotoChanges = new Map();
|
||||||
|
|
||||||
|
function handlePhotoChange(issueId, input) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
// 임시로 저장
|
||||||
|
tempPhotoChanges.set(issueId, e.target.result);
|
||||||
|
|
||||||
|
// 미리보기 업데이트
|
||||||
|
const photoElement = document.getElementById(`photo-${issueId}`);
|
||||||
|
if (photoElement.tagName === 'IMG') {
|
||||||
|
photoElement.src = e.target.result;
|
||||||
|
} else {
|
||||||
|
// div를 img로 교체
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.id = `photo-${issueId}`;
|
||||||
|
img.src = e.target.result;
|
||||||
|
img.className = 'w-32 h-32 object-cover rounded-lg shadow-sm';
|
||||||
|
photoElement.parentNode.replaceChild(img, photoElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수정 상태로 표시
|
||||||
|
markAsModified(issueId);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 사항 등록
|
||||||
|
document.getElementById('reportForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issueData = {
|
||||||
|
photo: currentPhoto,
|
||||||
|
category: document.getElementById('category').value,
|
||||||
|
description: document.getElementById('description').value
|
||||||
|
};
|
||||||
|
|
||||||
|
await IssuesAPI.create(issueData);
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
alert('부적합 사항이 등록되었습니다.');
|
||||||
|
|
||||||
|
// 폼 초기화
|
||||||
|
document.getElementById('reportForm').reset();
|
||||||
|
currentPhoto = null;
|
||||||
|
document.getElementById('photoPreview').classList.add('hidden');
|
||||||
|
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||||
|
|
||||||
|
// 목록 다시 로드
|
||||||
|
await loadIssues();
|
||||||
|
if (document.getElementById('listSection').classList.contains('hidden') === false) {
|
||||||
|
displayIssueList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || '등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
async function loadIssues() {
|
||||||
|
try {
|
||||||
|
issues = await IssuesAPI.getAll();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이슈 로드 실패:', error);
|
||||||
|
issues = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalStorage 관련 함수는 더 이상 사용하지 않음
|
||||||
|
function saveIssues() {
|
||||||
|
// Deprecated - API 사용
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 표시
|
||||||
|
function displayIssueList() {
|
||||||
|
const container = document.getElementById('issueList');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 부적합 사항이 없습니다.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.forEach(issue => {
|
||||||
|
const categoryNames = {
|
||||||
|
material_missing: '자재누락',
|
||||||
|
dimension_defect: '치수불량',
|
||||||
|
incoming_defect: '입고자재 불량'
|
||||||
|
};
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'border rounded-lg p-6 bg-gray-50';
|
||||||
|
div.id = `issue-card-${issue.id}`;
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 사진과 기본 정보 -->
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="relative">
|
||||||
|
${issue.photo_path ?
|
||||||
|
`<img id="photo-${issue.id}" src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg shadow-sm">` :
|
||||||
|
`<div id="photo-${issue.id}" class="w-32 h-32 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||||
|
<i class="fas fa-image text-gray-400 text-3xl"></i>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="document.getElementById('photoEdit-${issue.id}').click()"
|
||||||
|
class="absolute bottom-1 right-1 p-1 bg-blue-500 text-white rounded-full hover:bg-blue-600 text-xs"
|
||||||
|
title="사진 변경"
|
||||||
|
>
|
||||||
|
<i class="fas fa-camera"></i>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="photoEdit-${issue.id}" accept="image/*" class="hidden" onchange="handlePhotoChange(${issue.id}, this)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<!-- 카테고리 선택 -->
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-gray-600">카테고리</label>
|
||||||
|
<select
|
||||||
|
id="category-${issue.id}"
|
||||||
|
class="input-field w-full px-3 py-2 rounded-lg"
|
||||||
|
onchange="markAsModified(${issue.id})"
|
||||||
|
>
|
||||||
|
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
|
||||||
|
<option value="dimension_defect" ${issue.category === 'dimension_defect' ? 'selected' : ''}>치수불량</option>
|
||||||
|
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 설명 -->
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-gray-600">설명</label>
|
||||||
|
<textarea
|
||||||
|
id="description-${issue.id}"
|
||||||
|
class="input-field w-full px-3 py-2 rounded-lg resize-none"
|
||||||
|
rows="2"
|
||||||
|
oninput="markAsModified(${issue.id})"
|
||||||
|
>${issue.description}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 작업 시간 입력 및 버튼 -->
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- 작업 시간 입력 영역 -->
|
||||||
|
<div class="flex items-center gap-2 p-2 bg-blue-50 rounded-lg">
|
||||||
|
<label class="text-sm font-medium text-gray-700">
|
||||||
|
<i class="fas fa-wrench text-blue-500 mr-1"></i>해결 시간:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="workHours-${issue.id}"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
value="${issue.work_hours || ''}"
|
||||||
|
class="input-field px-3 py-1 w-20 rounded bg-white"
|
||||||
|
oninput="onWorkHoursChange(${issue.id})"
|
||||||
|
placeholder="0"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600">시간</span>
|
||||||
|
|
||||||
|
<!-- 시간 입력 확인 버튼 -->
|
||||||
|
<button
|
||||||
|
id="workHours-confirm-${issue.id}"
|
||||||
|
onclick="confirmWorkHours(${issue.id})"
|
||||||
|
class="px-3 py-1 rounded transition-colors text-sm font-medium ${
|
||||||
|
issue.work_hours
|
||||||
|
? 'bg-green-100 text-green-700 cursor-default'
|
||||||
|
: 'bg-blue-500 text-white hover:bg-blue-600'
|
||||||
|
}"
|
||||||
|
${issue.work_hours ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
${issue.work_hours
|
||||||
|
? '<i class="fas fa-check-circle mr-1"></i>완료'
|
||||||
|
: '<i class="fas fa-clock mr-1"></i>확인'
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 전체 저장/취소 버튼 (수정시 표시) -->
|
||||||
|
<div id="save-buttons-${issue.id}" class="hidden flex gap-2 ml-4 border-l pl-4">
|
||||||
|
<button
|
||||||
|
onclick="saveIssueChanges(${issue.id})"
|
||||||
|
class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<i class="fas fa-save mr-1"></i>전체 저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick="cancelIssueChanges(${issue.id})"
|
||||||
|
class="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<i class="fas fa-undo mr-1"></i>되돌리기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${currentUser && currentUser.role === 'admin' ? `
|
||||||
|
<button
|
||||||
|
onclick="deleteIssue(${issue.id})"
|
||||||
|
class="ml-auto px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash mr-1"></i>삭제
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span><i class="fas fa-user mr-1"></i>${issue.reporter.full_name || issue.reporter.username}</span>
|
||||||
|
<span><i class="fas fa-calendar mr-1"></i>${new Date(issue.report_date).toLocaleDateString()}</span>
|
||||||
|
<span class="px-2 py-1 rounded-full text-xs ${
|
||||||
|
issue.status === 'complete' ? 'bg-green-100 text-green-700' :
|
||||||
|
issue.status === 'progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-gray-100 text-gray-700'
|
||||||
|
}">
|
||||||
|
${issue.status === 'complete' ? '완료' : issue.status === 'progress' ? '진행중' : '신규'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수정 상태 표시
|
||||||
|
const modifiedIssues = new Set();
|
||||||
|
|
||||||
|
function markAsModified(issueId) {
|
||||||
|
modifiedIssues.add(issueId);
|
||||||
|
const saveButtons = document.getElementById(`save-buttons-${issueId}`);
|
||||||
|
if (saveButtons) {
|
||||||
|
saveButtons.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카드 배경색 변경으로 수정 중임을 표시
|
||||||
|
const card = document.getElementById(`issue-card-${issueId}`);
|
||||||
|
if (card) {
|
||||||
|
card.classList.add('bg-yellow-50', 'border-yellow-300');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업 시간 변경 시
|
||||||
|
function onWorkHoursChange(issueId) {
|
||||||
|
const input = document.getElementById(`workHours-${issueId}`);
|
||||||
|
const confirmBtn = document.getElementById(`workHours-confirm-${issueId}`);
|
||||||
|
|
||||||
|
if (input && confirmBtn) {
|
||||||
|
const hasValue = input.value && parseFloat(input.value) > 0;
|
||||||
|
const issue = issues.find(i => i.id === issueId);
|
||||||
|
const isChanged = parseFloat(input.value) !== (issue?.work_hours || 0);
|
||||||
|
|
||||||
|
// 버튼 활성화/비활성화
|
||||||
|
confirmBtn.disabled = !hasValue || !isChanged;
|
||||||
|
|
||||||
|
// 버튼 스타일 업데이트
|
||||||
|
if (!hasValue) {
|
||||||
|
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-gray-300 text-gray-500 cursor-not-allowed';
|
||||||
|
confirmBtn.innerHTML = '<i class="fas fa-clock mr-1"></i>확인';
|
||||||
|
} else if (issue?.work_hours && !isChanged) {
|
||||||
|
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-green-100 text-green-700 cursor-default';
|
||||||
|
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-1"></i>완료';
|
||||||
|
} else {
|
||||||
|
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-blue-500 text-white hover:bg-blue-600';
|
||||||
|
confirmBtn.innerHTML = '<i class="fas fa-clock mr-1"></i>확인';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 필드도 수정된 경우를 위해 markAsModified 호출
|
||||||
|
if (isChanged) {
|
||||||
|
markAsModified(issueId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업 시간만 빠르게 저장
|
||||||
|
async function confirmWorkHours(issueId) {
|
||||||
|
const workHours = parseFloat(document.getElementById(`workHours-${issueId}`).value);
|
||||||
|
|
||||||
|
if (!workHours || workHours <= 0) {
|
||||||
|
alert('작업 시간을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 작업 시간만 업데이트
|
||||||
|
await IssuesAPI.update(issueId, {
|
||||||
|
work_hours: workHours
|
||||||
|
});
|
||||||
|
|
||||||
|
// 성공 시 데이터 다시 로드
|
||||||
|
await loadIssues();
|
||||||
|
displayIssueList();
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
showToastMessage(`${workHours}시간 저장 완료!`, 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경사항 저장
|
||||||
|
async function saveIssueChanges(issueId) {
|
||||||
|
try {
|
||||||
|
const category = document.getElementById(`category-${issueId}`).value;
|
||||||
|
const description = document.getElementById(`description-${issueId}`).value.trim();
|
||||||
|
const workHours = parseFloat(document.getElementById(`workHours-${issueId}`).value) || 0;
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!description) {
|
||||||
|
alert('설명은 필수 입력 항목입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업데이트 데이터 준비
|
||||||
|
const updateData = {
|
||||||
|
category: category,
|
||||||
|
description: description,
|
||||||
|
work_hours: workHours
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사진이 변경되었으면 추가
|
||||||
|
if (tempPhotoChanges.has(issueId)) {
|
||||||
|
updateData.photo = tempPhotoChanges.get(issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
await IssuesAPI.update(issueId, updateData);
|
||||||
|
|
||||||
|
// 성공 시 상태 초기화
|
||||||
|
modifiedIssues.delete(issueId);
|
||||||
|
tempPhotoChanges.delete(issueId);
|
||||||
|
|
||||||
|
// 데이터 다시 로드
|
||||||
|
await loadIssues();
|
||||||
|
displayIssueList();
|
||||||
|
|
||||||
|
// 성공 메시지 (작은 토스트 메시지)
|
||||||
|
showToastMessage('저장되었습니다!', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경사항 취소
|
||||||
|
async function cancelIssueChanges(issueId) {
|
||||||
|
// 수정 상태 초기화
|
||||||
|
modifiedIssues.delete(issueId);
|
||||||
|
tempPhotoChanges.delete(issueId);
|
||||||
|
|
||||||
|
// 원래 데이터로 복원
|
||||||
|
await loadIssues();
|
||||||
|
displayIssueList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 토스트 메시지 표시
|
||||||
|
function showToastMessage(message, type = 'success') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `fixed bottom-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${
|
||||||
|
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`;
|
||||||
|
toast.innerHTML = `<i class="fas fa-check-circle mr-2"></i>${message}`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 삭제 함수 (관리자만)
|
||||||
|
async function deleteIssue(issueId) {
|
||||||
|
if (!currentUser || currentUser.role !== 'admin') {
|
||||||
|
alert('관리자만 삭제할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('정말로 이 부적합 사항을 삭제하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await IssuesAPI.delete(issueId);
|
||||||
|
|
||||||
|
// 성공 시 목록 다시 로드
|
||||||
|
await loadIssues();
|
||||||
|
displayIssueList();
|
||||||
|
|
||||||
|
alert('삭제되었습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보고서 생성
|
||||||
|
async function generateReport() {
|
||||||
|
const container = document.getElementById('reportContent');
|
||||||
|
|
||||||
|
// 날짜 범위 계산
|
||||||
|
const dates = issues.map(i => new Date(i.report_date));
|
||||||
|
const startDate = dates.length > 0 ? new Date(Math.min(...dates)) : new Date();
|
||||||
|
const endDate = new Date();
|
||||||
|
|
||||||
|
// 일일 공수 데이터 가져오기
|
||||||
|
let dailyWorkTotal = 0;
|
||||||
|
try {
|
||||||
|
const dailyWorks = await DailyWorkAPI.getAll({
|
||||||
|
start_date: startDate.toISOString().split('T')[0],
|
||||||
|
end_date: endDate.toISOString().split('T')[0]
|
||||||
|
});
|
||||||
|
dailyWorkTotal = dailyWorks.reduce((sum, work) => sum + work.total_hours, 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('일일 공수 데이터 로드 실패:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 사항 해결 시간 계산
|
||||||
|
const issueHours = issues.reduce((sum, issue) => sum + issue.work_hours, 0);
|
||||||
|
const categoryCount = {};
|
||||||
|
|
||||||
|
issues.forEach(issue => {
|
||||||
|
categoryCount[issue.category] = (categoryCount[issue.category] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 부적합 시간 비율 계산
|
||||||
|
const issuePercentage = dailyWorkTotal > 0 ? ((issueHours / dailyWorkTotal) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- 요약 페이지 -->
|
||||||
|
<div class="border-b pb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-center mb-6">작업 보고서</h3>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-gray-700 mb-2">작업 기간</h4>
|
||||||
|
<p class="text-lg">${startDate.toLocaleDateString()} ~ ${endDate.toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-blue-700 mb-2">총 작업 공수</h4>
|
||||||
|
<p class="text-3xl font-bold text-blue-600">${dailyWorkTotal}시간</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">일일 공수 합계</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-red-700 mb-2">부적합 처리 시간</h4>
|
||||||
|
<p class="text-3xl font-bold text-red-600">${issueHours}시간</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">전체 공수의 ${issuePercentage}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 효율성 지표 -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold text-gray-700 mb-3">작업 효율성</h4>
|
||||||
|
<div class="bg-gradient-to-r from-green-50 to-blue-50 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">정상 작업 시간</p>
|
||||||
|
<p class="text-2xl font-bold text-green-600">${dailyWorkTotal - issueHours}시간</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">작업 효율</p>
|
||||||
|
<p class="text-3xl font-bold ${100 - issuePercentage >= 90 ? 'text-green-600' : 100 - issuePercentage >= 80 ? 'text-yellow-600' : 'text-red-600'}">
|
||||||
|
${(100 - issuePercentage).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">부적합 처리</p>
|
||||||
|
<p class="text-2xl font-bold text-red-600">${issueHours}시간</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 카테고리별 분석 -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold text-gray-700 mb-3">부적합 카테고리별 분석</h4>
|
||||||
|
<div class="grid md:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-2xl font-bold text-blue-600">${categoryCount.material_missing || 0}</p>
|
||||||
|
<p class="text-sm text-gray-600">자재누락</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-50 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-2xl font-bold text-orange-600">${categoryCount.dimension_defect || 0}</p>
|
||||||
|
<p class="text-sm text-gray-600">치수불량</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-50 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-2xl font-bold text-purple-600">${categoryCount.incoming_defect || 0}</p>
|
||||||
|
<p class="text-sm text-gray-600">입고자재 불량</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 상세 내역 -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-gray-700 mb-4">부적합 사항 상세</h4>
|
||||||
|
${issues.map(issue => {
|
||||||
|
const categoryNames = {
|
||||||
|
material_missing: '자재누락',
|
||||||
|
dimension_defect: '치수불량',
|
||||||
|
incoming_defect: '입고자재 불량'
|
||||||
|
};
|
||||||
|
return `
|
||||||
|
<div class="border rounded-lg p-4 mb-4 break-inside-avoid">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||||
|
<div class="flex-1">
|
||||||
|
<h5 class="font-semibold text-gray-800 mb-2">${categoryNames[issue.category] || issue.category}</h5>
|
||||||
|
<p class="text-gray-600 mb-2">${issue.description}</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<p><span class="font-medium">상태:</span> ${
|
||||||
|
issue.status === 'new' ? '신규' :
|
||||||
|
issue.status === 'progress' ? '진행중' : '완료'
|
||||||
|
}</p>
|
||||||
|
<p><span class="font-medium">작업시간:</span> ${issue.work_hours}시간</p>
|
||||||
|
<p><span class="font-medium">보고자:</span> ${issue.reporter.full_name || issue.reporter.username}</p>
|
||||||
|
<p><span class="font-medium">보고일:</span> ${new Date(issue.report_date).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
${issue.detail_notes ? `
|
||||||
|
<div class="mt-2 p-2 bg-gray-50 rounded">
|
||||||
|
<p class="text-sm text-gray-600">${issue.detail_notes}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인쇄
|
||||||
|
function printReport() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
315
frontend/issue-view.html
Normal file
315
frontend/issue-view.html
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>부적합 사항 조회 - 작업보고서</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- Custom Styles -->
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-effect {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="glass-effect border-b border-gray-200 sticky top-0 z-50">
|
||||||
|
<div class="container mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-clipboard-list mr-2"></i>작업보고서 시스템
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/daily-work.html" class="text-gray-600 hover:text-gray-800 transition-colors">
|
||||||
|
<i class="fas fa-calendar-day mr-1"></i>일일 공수
|
||||||
|
</a>
|
||||||
|
<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">
|
||||||
|
<i class="fas fa-list mr-1"></i>목록 관리
|
||||||
|
</a>
|
||||||
|
<a href="/index.html#summary" class="text-gray-600 hover:text-gray-800 transition-colors">
|
||||||
|
<i class="fas fa-chart-bar 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<!-- 날짜 선택 섹션 (간소화) -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||||
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800">
|
||||||
|
<i class="fas fa-list-alt text-blue-500 mr-2"></i>부적합 사항 목록
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<button onclick="setDateRange('week')" class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm">
|
||||||
|
이번 주
|
||||||
|
</button>
|
||||||
|
<button onclick="setDateRange('month')" class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm">
|
||||||
|
이번 달
|
||||||
|
</button>
|
||||||
|
<button onclick="setDateRange('all')" class="px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm">
|
||||||
|
전체
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 결과 섹션 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div id="issueResults" class="space-y-3">
|
||||||
|
<!-- 결과가 여기에 표시됩니다 -->
|
||||||
|
<div class="text-gray-500 text-center py-8">
|
||||||
|
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
|
||||||
|
<p>데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script>
|
||||||
|
let currentUser = null;
|
||||||
|
let issues = [];
|
||||||
|
let currentRange = 'week'; // 기본값: 이번 주
|
||||||
|
|
||||||
|
// 페이지 로드 시
|
||||||
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const user = TokenManager.getUser();
|
||||||
|
if (!user) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentUser = user;
|
||||||
|
|
||||||
|
// 기본값: 이번 주 데이터 로드
|
||||||
|
setDateRange('week');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 날짜 범위 설정 및 자동 조회
|
||||||
|
async function setDateRange(range) {
|
||||||
|
currentRange = range;
|
||||||
|
|
||||||
|
// 버튼 스타일 업데이트
|
||||||
|
document.querySelectorAll('button[onclick^="setDateRange"]').forEach(btn => {
|
||||||
|
if (btn.textContent.includes('전체') && range === 'all') {
|
||||||
|
btn.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm';
|
||||||
|
} else if (btn.textContent.includes('오늘') && range === 'today') {
|
||||||
|
btn.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm';
|
||||||
|
} else if (btn.textContent.includes('이번 주') && range === 'week') {
|
||||||
|
btn.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm';
|
||||||
|
} else if (btn.textContent.includes('이번 달') && range === 'month') {
|
||||||
|
btn.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm';
|
||||||
|
} else {
|
||||||
|
btn.className = 'px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadIssues(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 사항 로드
|
||||||
|
async function loadIssues(range) {
|
||||||
|
const container = document.getElementById('issueResults');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-gray-500 text-center py-8">
|
||||||
|
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
|
||||||
|
<p>데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모든 이슈 가져오기
|
||||||
|
const allIssues = await IssuesAPI.getAll();
|
||||||
|
|
||||||
|
// 날짜 필터링
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(23, 59, 59, 999);
|
||||||
|
let startDate = new Date();
|
||||||
|
|
||||||
|
switch(range) {
|
||||||
|
case 'today':
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
startDate.setDate(today.getDate() - 7);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
startDate.setMonth(today.getMonth() - 1);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
startDate = new Date(2020, 0, 1); // 충분히 과거 날짜
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터링 및 정렬 (최신순)
|
||||||
|
issues = allIssues
|
||||||
|
.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.report_date);
|
||||||
|
return issueDate >= startDate && issueDate <= today;
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date));
|
||||||
|
|
||||||
|
// 결과 표시
|
||||||
|
displayResults();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('조회 실패:', error);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-red-500 text-center py-8">
|
||||||
|
<i class="fas fa-exclamation-circle text-3xl mb-3"></i>
|
||||||
|
<p>데이터를 불러오는데 실패했습니다.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 표시 (시간순 나열)
|
||||||
|
function displayResults() {
|
||||||
|
const container = document.getElementById('issueResults');
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-gray-500 text-center py-8">
|
||||||
|
<i class="fas fa-inbox text-4xl mb-3"></i>
|
||||||
|
<p>등록된 부적합 사항이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryNames = {
|
||||||
|
material_missing: '자재누락',
|
||||||
|
dimension_defect: '치수불량',
|
||||||
|
incoming_defect: '입고자재 불량'
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryColors = {
|
||||||
|
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||||
|
dimension_defect: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||||
|
incoming_defect: 'bg-red-100 text-red-700 border-red-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시간순으로 나열
|
||||||
|
container.innerHTML = issues.map(issue => {
|
||||||
|
const date = new Date(issue.report_date);
|
||||||
|
const dateStr = date.toLocaleDateString('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short'
|
||||||
|
});
|
||||||
|
const timeStr = date.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
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'}">
|
||||||
|
<!-- 사진 -->
|
||||||
|
${issue.photo_path ?
|
||||||
|
`<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded shadow-sm flex-shrink-0">` :
|
||||||
|
`<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-image text-gray-400"></i>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- 내용 -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start justify-between mb-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium ${categoryColors[issue.category].split(' ').slice(0, 2).join(' ')}">
|
||||||
|
${categoryNames[issue.category]}
|
||||||
|
</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} ${timeStr}
|
||||||
|
</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>
|
||||||
|
`;
|
||||||
|
}).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 === 'dimension_defect').length}건 |
|
||||||
|
입고자재 불량: ${issues.filter(i => i.category === 'incoming_defect').length}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = summary + container.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
208
frontend/static/js/api.js
Normal file
208
frontend/static/js/api.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// API 기본 설정
|
||||||
|
const API_BASE_URL = '/api';
|
||||||
|
|
||||||
|
// 토큰 관리
|
||||||
|
const TokenManager = {
|
||||||
|
getToken: () => localStorage.getItem('access_token'),
|
||||||
|
setToken: (token) => localStorage.setItem('access_token', token),
|
||||||
|
removeToken: () => localStorage.removeItem('access_token'),
|
||||||
|
|
||||||
|
getUser: () => {
|
||||||
|
const userStr = localStorage.getItem('current_user');
|
||||||
|
return userStr ? JSON.parse(userStr) : null;
|
||||||
|
},
|
||||||
|
setUser: (user) => localStorage.setItem('current_user', JSON.stringify(user)),
|
||||||
|
removeUser: () => localStorage.removeItem('current_user')
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 요청 헬퍼
|
||||||
|
async function apiRequest(endpoint, options = {}) {
|
||||||
|
const token = TokenManager.getToken();
|
||||||
|
|
||||||
|
const defaultHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...defaultHeaders,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 인증 실패 시 로그인 페이지로
|
||||||
|
TokenManager.removeToken();
|
||||||
|
TokenManager.removeUser();
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'API 요청 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API 요청 에러:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
const AuthAPI = {
|
||||||
|
login: async (username, password) => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || '로그인 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
TokenManager.setToken(data.access_token);
|
||||||
|
TokenManager.setUser(data.user);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
TokenManager.removeToken();
|
||||||
|
TokenManager.removeUser();
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
},
|
||||||
|
|
||||||
|
getMe: () => apiRequest('/auth/me'),
|
||||||
|
|
||||||
|
createUser: (userData) => apiRequest('/auth/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
}),
|
||||||
|
|
||||||
|
getUsers: () => apiRequest('/auth/users'),
|
||||||
|
|
||||||
|
updateUser: (userId, userData) => apiRequest(`/auth/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteUser: (userId) => apiRequest(`/auth/users/${userId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Issues API
|
||||||
|
const IssuesAPI = {
|
||||||
|
create: (issueData) => apiRequest('/issues/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(issueData)
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAll: (params = {}) => {
|
||||||
|
const queryString = new URLSearchParams(params).toString();
|
||||||
|
return apiRequest(`/issues/${queryString ? '?' + queryString : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
get: (id) => apiRequest(`/issues/${id}`),
|
||||||
|
|
||||||
|
update: (id, issueData) => apiRequest(`/issues/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(issueData)
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (id) => apiRequest(`/issues/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}),
|
||||||
|
|
||||||
|
getStats: () => apiRequest('/issues/stats/summary')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Daily Work API
|
||||||
|
const DailyWorkAPI = {
|
||||||
|
create: (workData) => apiRequest('/daily-work/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(workData)
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAll: (params = {}) => {
|
||||||
|
const queryString = new URLSearchParams(params).toString();
|
||||||
|
return apiRequest(`/daily-work/${queryString ? '?' + queryString : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
get: (id) => apiRequest(`/daily-work/${id}`),
|
||||||
|
|
||||||
|
update: (id, workData) => apiRequest(`/daily-work/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(workData)
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (id) => apiRequest(`/daily-work/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}),
|
||||||
|
|
||||||
|
getStats: (params = {}) => {
|
||||||
|
const queryString = new URLSearchParams(params).toString();
|
||||||
|
return apiRequest(`/daily-work/stats/summary${queryString ? '?' + queryString : ''}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reports API
|
||||||
|
const ReportsAPI = {
|
||||||
|
getSummary: (startDate, endDate) => apiRequest('/reports/summary', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
getIssues: (startDate, endDate) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate
|
||||||
|
}).toString();
|
||||||
|
return apiRequest(`/reports/issues?${params}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getDailyWorks: (startDate, endDate) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate
|
||||||
|
}).toString();
|
||||||
|
return apiRequest(`/reports/daily-works?${params}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 체크
|
||||||
|
function checkAuth() {
|
||||||
|
const user = TokenManager.getUser();
|
||||||
|
if (!user) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAdminAuth() {
|
||||||
|
const user = checkAuth();
|
||||||
|
if (user && user.role !== 'admin') {
|
||||||
|
alert('관리자 권한이 필요합니다.');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
671
index.html
Normal file
671
index.html
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
<!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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-new {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-progress {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-complete {
|
||||||
|
background-color: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: var(--gray-100);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 로그인 화면 -->
|
||||||
|
<div id="loginScreen" class="min-h-screen flex items-center justify-center p-4 bg-gray-50">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<i class="fas fa-clipboard-check text-5xl text-blue-500 mb-4"></i>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800">작업보고서 시스템</h1>
|
||||||
|
<p class="text-gray-600 text-sm mt-2">부적합 사항 관리 및 공수 계산</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">아이디</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="userId"
|
||||||
|
class="input-field w-full px-4 py-2 rounded-lg"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
class="input-field w-full px-4 py-2 rounded-lg"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full py-2 rounded-lg font-medium">
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 메인 화면 -->
|
||||||
|
<div id="mainScreen" class="hidden min-h-screen bg-gray-50">
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||||
|
<div class="container mx-auto px-4 py-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-xl font-bold text-gray-800">
|
||||||
|
<i class="fas fa-clipboard-check text-blue-500 mr-2"></i>작업보고서
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm text-gray-600" id="userDisplay"></span>
|
||||||
|
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
<button class="nav-link active" onclick="showSection('report')">
|
||||||
|
<i class="fas fa-camera-retro mr-2"></i>부적합 등록
|
||||||
|
</button>
|
||||||
|
<button class="nav-link" onclick="showSection('list')">
|
||||||
|
<i class="fas fa-list mr-2"></i>목록 관리
|
||||||
|
</button>
|
||||||
|
<button class="nav-link" onclick="showSection('summary')">
|
||||||
|
<i class="fas fa-chart-bar mr-2"></i>보고서
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 부적합 등록 섹션 (모바일 최적화) -->
|
||||||
|
<section id="reportSection" class="container mx-auto px-4 py-6 max-w-lg">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-500 mr-2"></i>부적합 사항 등록
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form id="reportForm" class="space-y-4">
|
||||||
|
<!-- 사진 업로드 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">사진</label>
|
||||||
|
<div
|
||||||
|
id="photoUpload"
|
||||||
|
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
|
||||||
|
onclick="document.getElementById('photoInput').click()"
|
||||||
|
>
|
||||||
|
<div id="photoPreview" class="hidden mb-4">
|
||||||
|
<img id="previewImg" class="max-h-48 mx-auto rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div id="photoPlaceholder">
|
||||||
|
<i class="fas fa-camera text-4xl text-gray-400 mb-2"></i>
|
||||||
|
<p class="text-gray-600">사진 촬영 또는 선택</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="photoInput" accept="image/*" capture="camera" class="hidden">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 카테고리 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
|
||||||
|
<select id="category" class="input-field w-full px-4 py-2 rounded-lg" required>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="material_missing">자재누락</option>
|
||||||
|
<option value="dimension_defect">치수불량</option>
|
||||||
|
<option value="incoming_defect">입고자재 불량</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 간단 설명 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">간단 설명</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
rows="3"
|
||||||
|
class="input-field w-full px-4 py-2 rounded-lg resize-none"
|
||||||
|
placeholder="발견된 문제를 간단히 설명하세요"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
|
||||||
|
<i class="fas fa-check mr-2"></i>등록하기
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 목록 관리 섹션 -->
|
||||||
|
<section id="listSection" class="hidden container mx-auto px-4 py-6">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">부적합 사항 목록</h2>
|
||||||
|
<div id="issueList" class="space-y-3">
|
||||||
|
<!-- 목록이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 상세보기 모달 -->
|
||||||
|
<div id="detailModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4" onclick="if(event.target === this) closeDetailModal()">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
|
||||||
|
<div class="sticky top-0 bg-white border-b p-4 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">부적합 사항 상세</h3>
|
||||||
|
<button onclick="closeDetailModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 사진 -->
|
||||||
|
<div id="modalPhoto" class="mb-6"></div>
|
||||||
|
|
||||||
|
<!-- 수정 가능한 정보 -->
|
||||||
|
<div class="mb-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
|
||||||
|
<select id="modalCategory" class="input-field w-full px-4 py-2 rounded-lg">
|
||||||
|
<option value="material_missing">자재누락</option>
|
||||||
|
<option value="dimension_defect">치수불량</option>
|
||||||
|
<option value="incoming_defect">입고자재 불량</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||||
|
<textarea
|
||||||
|
id="modalDescription"
|
||||||
|
rows="3"
|
||||||
|
class="input-field w-full px-4 py-2 rounded-lg resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-500 pt-2">
|
||||||
|
<span><i class="fas fa-user mr-1"></i><span id="modalReporter"></span></span>
|
||||||
|
<span><i class="fas fa-calendar mr-1"></i><span id="modalDate"></span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 작업 시간 입력 -->
|
||||||
|
<div class="border-t pt-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">해결하는데 걸린 총 시간</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="workHours"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
class="input-field flex-1 px-4 py-2 rounded-lg"
|
||||||
|
placeholder="예: 2.5"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600">시간</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="saveIssueDetails()" class="btn-primary w-full py-2 rounded-lg font-medium">
|
||||||
|
<i class="fas fa-save mr-2"></i>저장하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 보고서 섹션 -->
|
||||||
|
<section id="summarySection" class="hidden container mx-auto px-4 py-6">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800">작업 보고서</h2>
|
||||||
|
<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 id="reportContent">
|
||||||
|
<!-- 보고서 내용이 여기에 표시됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 사용자 데이터
|
||||||
|
const users = {
|
||||||
|
'inspector1': { password: 'pass123', name: '검사자1' },
|
||||||
|
'inspector2': { password: 'pass456', name: '검사자2' },
|
||||||
|
'admin': { password: 'admin123', name: '관리자' }
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentUser = null;
|
||||||
|
let currentPhoto = null;
|
||||||
|
let issues = [];
|
||||||
|
|
||||||
|
// 로그인
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const userId = document.getElementById('userId').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
if (users[userId] && users[userId].password === password) {
|
||||||
|
currentUser = { id: userId, ...users[userId] };
|
||||||
|
document.getElementById('userDisplay').textContent = currentUser.name;
|
||||||
|
document.getElementById('loginScreen').classList.add('hidden');
|
||||||
|
document.getElementById('mainScreen').classList.remove('hidden');
|
||||||
|
loadIssues();
|
||||||
|
} else {
|
||||||
|
alert('아이디 또는 비밀번호가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그아웃
|
||||||
|
function logout() {
|
||||||
|
currentUser = null;
|
||||||
|
document.getElementById('loginScreen').classList.remove('hidden');
|
||||||
|
document.getElementById('mainScreen').classList.add('hidden');
|
||||||
|
document.getElementById('loginForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 섹션 전환
|
||||||
|
function showSection(section) {
|
||||||
|
// 모든 섹션 숨기기
|
||||||
|
document.querySelectorAll('section').forEach(s => s.classList.add('hidden'));
|
||||||
|
// 선택된 섹션 표시
|
||||||
|
document.getElementById(section + 'Section').classList.remove('hidden');
|
||||||
|
|
||||||
|
// 네비게이션 활성화 상태 변경
|
||||||
|
document.querySelectorAll('.nav-link').forEach(link => link.classList.remove('active'));
|
||||||
|
event.target.closest('.nav-link').classList.add('active');
|
||||||
|
|
||||||
|
// 섹션별 초기화
|
||||||
|
if (section === 'list') {
|
||||||
|
displayIssueList();
|
||||||
|
} else if (section === 'summary') {
|
||||||
|
generateReport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진 업로드
|
||||||
|
document.getElementById('photoInput').addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
currentPhoto = e.target.result;
|
||||||
|
document.getElementById('previewImg').src = currentPhoto;
|
||||||
|
document.getElementById('photoPreview').classList.remove('hidden');
|
||||||
|
document.getElementById('photoPlaceholder').classList.add('hidden');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 부적합 사항 등록
|
||||||
|
document.getElementById('reportForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const issue = {
|
||||||
|
id: Date.now(),
|
||||||
|
photo: currentPhoto,
|
||||||
|
category: document.getElementById('category').value,
|
||||||
|
description: document.getElementById('description').value,
|
||||||
|
status: 'new',
|
||||||
|
reporter: currentUser.id,
|
||||||
|
reporterName: currentUser.name,
|
||||||
|
reportDate: new Date().toISOString(),
|
||||||
|
workHours: 0,
|
||||||
|
detailNotes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
issues.push(issue);
|
||||||
|
saveIssues();
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
alert('부적합 사항이 등록되었습니다.');
|
||||||
|
|
||||||
|
// 폼 초기화
|
||||||
|
document.getElementById('reportForm').reset();
|
||||||
|
currentPhoto = null;
|
||||||
|
document.getElementById('photoPreview').classList.add('hidden');
|
||||||
|
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터 저장/로드
|
||||||
|
function saveIssues() {
|
||||||
|
localStorage.setItem('work-report-issues', JSON.stringify(issues));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadIssues() {
|
||||||
|
const saved = localStorage.getItem('work-report-issues');
|
||||||
|
if (saved) {
|
||||||
|
issues = JSON.parse(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 표시
|
||||||
|
function displayIssueList() {
|
||||||
|
const container = document.getElementById('issueList');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 부적합 사항이 없습니다.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.forEach(issue => {
|
||||||
|
const categoryNames = {
|
||||||
|
material_missing: '자재누락',
|
||||||
|
dimension_defect: '치수불량',
|
||||||
|
incoming_defect: '입고자재 불량'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadges = {
|
||||||
|
new: 'status-new',
|
||||||
|
progress: 'status-progress',
|
||||||
|
complete: 'status-complete'
|
||||||
|
};
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
${issue.photo ? `<img src="${issue.photo}" class="w-20 h-20 object-cover rounded-lg">` : ''}
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<h3 class="font-medium text-gray-800">${categoryNames[issue.category] || issue.category}</h3>
|
||||||
|
<span class="status-badge ${statusBadges[issue.status]}">${
|
||||||
|
issue.status === 'new' ? '신규' :
|
||||||
|
issue.status === 'progress' ? '진행중' : '완료'
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 mb-2">${issue.description}</p>
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<span class="text-gray-500">
|
||||||
|
<i class="fas fa-calendar mr-1"></i>
|
||||||
|
${new Date(issue.reportDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500">
|
||||||
|
<i class="fas fa-clock mr-1"></i>
|
||||||
|
${issue.workHours}시간
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
div.onclick = () => showIssueDetail(issue.id);
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 편집 중인 이슈 ID
|
||||||
|
let currentEditingIssueId = null;
|
||||||
|
|
||||||
|
// 부적합 사항 상세보기
|
||||||
|
function showIssueDetail(issueId) {
|
||||||
|
const issue = issues.find(i => i.id === issueId);
|
||||||
|
if (!issue) return;
|
||||||
|
|
||||||
|
currentEditingIssueId = issueId;
|
||||||
|
|
||||||
|
const categoryNames = {
|
||||||
|
material_missing: '자재누락',
|
||||||
|
dimension_defect: '치수불량',
|
||||||
|
incoming_defect: '입고자재 불량'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사진 표시
|
||||||
|
const photoContainer = document.getElementById('modalPhoto');
|
||||||
|
if (issue.photo) {
|
||||||
|
photoContainer.innerHTML = `<img src="${issue.photo}" class="w-full rounded-lg shadow-md">`;
|
||||||
|
} else {
|
||||||
|
photoContainer.innerHTML = '<div class="bg-gray-100 rounded-lg p-8 text-center text-gray-500">사진 없음</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정보 표시
|
||||||
|
document.getElementById('modalCategory').value = issue.category;
|
||||||
|
document.getElementById('modalDescription').value = issue.description;
|
||||||
|
document.getElementById('modalReporter').textContent = issue.reporterName;
|
||||||
|
document.getElementById('modalDate').textContent = new Date(issue.reportDate).toLocaleDateString();
|
||||||
|
|
||||||
|
// 작업 시간
|
||||||
|
document.getElementById('workHours').value = issue.workHours || '';
|
||||||
|
|
||||||
|
// 모달 표시
|
||||||
|
document.getElementById('detailModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 닫기
|
||||||
|
function closeDetailModal() {
|
||||||
|
document.getElementById('detailModal').classList.add('hidden');
|
||||||
|
currentEditingIssueId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 정보 저장
|
||||||
|
function saveIssueDetails() {
|
||||||
|
if (!currentEditingIssueId) return;
|
||||||
|
|
||||||
|
const issue = issues.find(i => i.id === currentEditingIssueId);
|
||||||
|
if (!issue) return;
|
||||||
|
|
||||||
|
// 값 가져오기
|
||||||
|
const category = document.getElementById('modalCategory').value;
|
||||||
|
const description = document.getElementById('modalDescription').value.trim();
|
||||||
|
const workHours = parseFloat(document.getElementById('workHours').value) || 0;
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!description) {
|
||||||
|
alert('설명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업데이트
|
||||||
|
issue.category = category;
|
||||||
|
issue.description = description;
|
||||||
|
issue.workHours = workHours;
|
||||||
|
|
||||||
|
// 작업 시간이 입력되었으면 상태를 자동으로 '완료'로 변경
|
||||||
|
if (workHours > 0 && issue.status === 'new') {
|
||||||
|
issue.status = 'complete';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
saveIssues();
|
||||||
|
|
||||||
|
// UI 업데이트
|
||||||
|
displayIssueList();
|
||||||
|
closeDetailModal();
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
alert('저장되었습니다!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보고서 생성
|
||||||
|
function generateReport() {
|
||||||
|
const container = document.getElementById('reportContent');
|
||||||
|
|
||||||
|
// 통계 계산
|
||||||
|
const totalHours = issues.reduce((sum, issue) => sum + issue.workHours, 0);
|
||||||
|
const categoryCount = {};
|
||||||
|
|
||||||
|
issues.forEach(issue => {
|
||||||
|
categoryCount[issue.category] = (categoryCount[issue.category] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 날짜 범위
|
||||||
|
const dates = issues.map(i => new Date(i.reportDate));
|
||||||
|
const startDate = dates.length > 0 ? new Date(Math.min(...dates)) : new Date();
|
||||||
|
const endDate = new Date();
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- 요약 페이지 -->
|
||||||
|
<div class="border-b pb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-center mb-6">작업 보고서</h3>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-gray-700 mb-3">작업 기간</h4>
|
||||||
|
<p class="text-lg">${startDate.toLocaleDateString()} ~ ${endDate.toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-blue-700 mb-3">총 공수</h4>
|
||||||
|
<p class="text-3xl font-bold text-blue-600">${totalHours}시간</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold text-gray-700 mb-3">카테고리별 분석</h4>
|
||||||
|
<div class="grid md:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-2xl font-bold text-blue-600">${categoryCount.material_missing || 0}</p>
|
||||||
|
<p class="text-sm text-gray-600">자재누락</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-50 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-2xl font-bold text-orange-600">${categoryCount.dimension_defect || 0}</p>
|
||||||
|
<p class="text-sm text-gray-600">치수불량</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-50 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-2xl font-bold text-purple-600">${categoryCount.incoming_defect || 0}</p>
|
||||||
|
<p class="text-sm text-gray-600">입고자재 불량</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 상세 내역 -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-gray-700 mb-4">부적합 사항 상세</h4>
|
||||||
|
${issues.map(issue => {
|
||||||
|
const categoryNames = {
|
||||||
|
material_missing: '자재누락',
|
||||||
|
dimension_defect: '치수불량',
|
||||||
|
incoming_defect: '입고자재 불량'
|
||||||
|
};
|
||||||
|
return `
|
||||||
|
<div class="border rounded-lg p-4 mb-4 break-inside-avoid">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
${issue.photo ? `<img src="${issue.photo}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||||
|
<div class="flex-1">
|
||||||
|
<h5 class="font-semibold text-gray-800 mb-2">${categoryNames[issue.category] || issue.category}</h5>
|
||||||
|
<p class="text-gray-600 mb-2">${issue.description}</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<p><span class="font-medium">상태:</span> ${
|
||||||
|
issue.status === 'new' ? '신규' :
|
||||||
|
issue.status === 'progress' ? '진행중' : '완료'
|
||||||
|
}</p>
|
||||||
|
<p><span class="font-medium">작업시간:</span> ${issue.workHours}시간</p>
|
||||||
|
<p><span class="font-medium">보고자:</span> ${issue.reporterName}</p>
|
||||||
|
<p><span class="font-medium">보고일:</span> ${new Date(issue.reportDate).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
${issue.detailNotes ? `
|
||||||
|
<div class="mt-2 p-2 bg-gray-50 rounded">
|
||||||
|
<p class="text-sm text-gray-600">${issue.detailNotes}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인쇄
|
||||||
|
function printReport() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
nginx/Dockerfile
Normal file
7
nginx/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Nginx 설정 파일 복사
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# 포트 노출
|
||||||
|
EXPOSE 80
|
||||||
47
nginx/nginx.conf
Normal file
47
nginx/nginx.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# 로그 설정
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
# 업로드 크기 제한
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# 프론트엔드 파일 서빙
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 프록시
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 업로드된 파일 서빙
|
||||||
|
location /uploads/ {
|
||||||
|
alias /usr/share/nginx/html/uploads/;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user