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