feat: 작업보고서 시스템 완성

- 일일 공수 입력 기능
- 부적합 사항 등록 (이미지 선택사항)
- 날짜별 부적합 조회 (시간순 나열)
- 목록 관리 (인라인 편집, 작업시간 확인 버튼)
- 보고서 생성 (총 공수/부적합 시간 분리)
- JWT 인증 및 권한 관리
- Docker 기반 배포 환경 구성
This commit is contained in:
hyungi
2025-09-17 10:41:25 +09:00
commit 1339e5dded
36 changed files with 5233 additions and 0 deletions

99
README.md Normal file
View 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
View 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
View 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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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()

View 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
View 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
View 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)

View 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

131
backend/routers/auth.py Normal file
View 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"}

View 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
View 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
View 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]

View 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}")

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
FROM nginx:alpine
# Nginx 설정 파일 복사
COPY nginx.conf /etc/nginx/nginx.conf
# 포트 노출
EXPOSE 80

47
nginx/nginx.conf Normal file
View 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";
}
}
}