commit 1339e5ddedbf1fbeec0785f3eaed0c57e7f1dd28 Author: hyungi Date: Wed Sep 17 10:41:25 2025 +0900 feat: 작업보고서 시스템 완성 - 일일 공수 입력 기능 - 부적합 사항 등록 (이미지 선택사항) - 날짜별 부적합 조회 (시간순 나열) - 목록 관리 (인라인 편집, 작업시간 확인 버튼) - 보고서 생성 (총 공수/부적합 시간 분리) - JWT 인증 및 권한 관리 - Docker 기반 배포 환경 구성 diff --git a/README.md b/README.md new file mode 100644 index 0000000..49741c0 --- /dev/null +++ b/README.md @@ -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 diff --git a/Rules.md b/Rules.md new file mode 100644 index 0000000..6bf4823 --- /dev/null +++ b/Rules.md @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8eb9b91 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..739167f Binary files /dev/null and b/backend/__pycache__/main.cpython-311.pyc differ diff --git a/backend/database/__pycache__/database.cpython-311.pyc b/backend/database/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000..44c2a05 Binary files /dev/null and b/backend/database/__pycache__/database.cpython-311.pyc differ diff --git a/backend/database/__pycache__/models.cpython-311.pyc b/backend/database/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..d5d1a32 Binary files /dev/null and b/backend/database/__pycache__/models.cpython-311.pyc differ diff --git a/backend/database/__pycache__/schemas.cpython-311.pyc b/backend/database/__pycache__/schemas.cpython-311.pyc new file mode 100644 index 0000000..451dc04 Binary files /dev/null and b/backend/database/__pycache__/schemas.cpython-311.pyc differ diff --git a/backend/database/database.py b/backend/database/database.py new file mode 100644 index 0000000..fbddc46 --- /dev/null +++ b/backend/database/database.py @@ -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() diff --git a/backend/database/models.py b/backend/database/models.py new file mode 100644 index 0000000..b4f24f6 --- /dev/null +++ b/backend/database/models.py @@ -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") diff --git a/backend/database/schemas.py b/backend/database/schemas.py new file mode 100644 index 0000000..9e6d908 --- /dev/null +++ b/backend/database/schemas.py @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..e333629 --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..9352b69 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -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); diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..426fa5c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/__pycache__/auth.cpython-311.pyc b/backend/routers/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000..18d8b7f Binary files /dev/null and b/backend/routers/__pycache__/auth.cpython-311.pyc differ diff --git a/backend/routers/__pycache__/daily_work.cpython-311.pyc b/backend/routers/__pycache__/daily_work.cpython-311.pyc new file mode 100644 index 0000000..5375946 Binary files /dev/null and b/backend/routers/__pycache__/daily_work.cpython-311.pyc differ diff --git a/backend/routers/__pycache__/issues.cpython-311.pyc b/backend/routers/__pycache__/issues.cpython-311.pyc new file mode 100644 index 0000000..22476d1 Binary files /dev/null and b/backend/routers/__pycache__/issues.cpython-311.pyc differ diff --git a/backend/routers/__pycache__/reports.cpython-311.pyc b/backend/routers/__pycache__/reports.cpython-311.pyc new file mode 100644 index 0000000..c19d0a6 Binary files /dev/null and b/backend/routers/__pycache__/reports.cpython-311.pyc differ diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..a45294e --- /dev/null +++ b/backend/routers/auth.py @@ -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"} diff --git a/backend/routers/daily_work.py b/backend/routers/daily_work.py new file mode 100644 index 0000000..fac3e59 --- /dev/null +++ b/backend/routers/daily_work.py @@ -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) + } diff --git a/backend/routers/issues.py b/backend/routers/issues.py new file mode 100644 index 0000000..a544da0 --- /dev/null +++ b/backend/routers/issues.py @@ -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 + } diff --git a/backend/routers/reports.py b/backend/routers/reports.py new file mode 100644 index 0000000..7e5d5f5 --- /dev/null +++ b/backend/routers/reports.py @@ -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] diff --git a/backend/services/__pycache__/auth_service.cpython-311.pyc b/backend/services/__pycache__/auth_service.cpython-311.pyc new file mode 100644 index 0000000..6121722 Binary files /dev/null and b/backend/services/__pycache__/auth_service.cpython-311.pyc differ diff --git a/backend/services/__pycache__/file_service.cpython-311.pyc b/backend/services/__pycache__/file_service.cpython-311.pyc new file mode 100644 index 0000000..66d57e2 Binary files /dev/null and b/backend/services/__pycache__/file_service.cpython-311.pyc differ diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py new file mode 100644 index 0000000..fb762ab --- /dev/null +++ b/backend/services/auth_service.py @@ -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}") diff --git a/backend/services/file_service.py b/backend/services/file_service.py new file mode 100644 index 0000000..06e4776 --- /dev/null +++ b/backend/services/file_service.py @@ -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}") diff --git a/chart.html b/chart.html new file mode 100644 index 0000000..ef7d45e --- /dev/null +++ b/chart.html @@ -0,0 +1,417 @@ + + + + + + M-Project - 차트 + + + + + + + +
+
+

+ 차트 +

+
+ + + + +
+
+
+ + +
+ +
+
+
+
+

전체 사진

+

0

+
+ +
+
+ +
+
+
+

오늘

+

0

+
+ +
+
+ +
+
+
+

이번 주

+

0

+
+ +
+
+ +
+
+
+

가장 많은

+

-

+
+ +
+
+
+ + +
+ +
+

카테고리별 분포

+ +
+ + +
+

최근 7일 추이

+ +
+
+ + +
+
+

전체 갤러리

+ +
+ + +
+
+ + + + + + + diff --git a/daily-work.html b/daily-work.html new file mode 100644 index 0000000..983eb09 --- /dev/null +++ b/daily-work.html @@ -0,0 +1,378 @@ + + + + + + 일일 공수 입력 + + + + + +
+ +
+
+
+

+ 일일 공수 입력 +

+ + 돌아가기 + +
+
+
+ + +
+ +
+

+ 공수 입력 +

+ +
+ +
+ + +
+ + +
+ + +

기본 근무시간: 8시간/인

+
+ + +
+
+ + +
+ + + +
+ + +
+
+ 예상 총 공수 + 0시간 +
+
+ + + +
+
+ + +
+

+ 최근 입력 내역 +

+
+ +
+
+
+
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9328391 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/chart.html b/frontend/chart.html new file mode 100644 index 0000000..ef7d45e --- /dev/null +++ b/frontend/chart.html @@ -0,0 +1,417 @@ + + + + + + M-Project - 차트 + + + + + + + +
+
+

+ 차트 +

+
+ + + + +
+
+
+ + +
+ +
+
+
+
+

전체 사진

+

0

+
+ +
+
+ +
+
+
+

오늘

+

0

+
+ +
+
+ +
+
+
+

이번 주

+

0

+
+ +
+
+ +
+
+
+

가장 많은

+

-

+
+ +
+
+
+ + +
+ +
+

카테고리별 분포

+ +
+ + +
+

최근 7일 추이

+ +
+
+ + +
+
+

전체 갤러리

+ +
+ + +
+
+ + + + + + + diff --git a/frontend/daily-work.html b/frontend/daily-work.html new file mode 100644 index 0000000..e79ae4f --- /dev/null +++ b/frontend/daily-work.html @@ -0,0 +1,458 @@ + + + + + + 일일 공수 입력 + + + + + +
+ +
+
+
+

+ 작업보고서 시스템 +

+ +
+
+
+ + + + + +
+ +
+

+ 공수 입력 +

+ +
+ +
+ + +
+ + +
+ + +

기본 근무시간: 8시간/인

+
+ + +
+
+ + +
+ + + +
+ + +
+
+ 예상 총 공수 + 0시간 +
+
+ + + +
+
+ + +
+

+ 최근 입력 내역 +

+
+ +
+
+
+
+ + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c5542d7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,909 @@ + + + + + + 작업보고서 시스템 + + + + + + +
+
+
+ +

작업보고서 시스템

+

부적합 사항 관리 및 공수 계산

+
+ +
+
+ + +
+ +
+ + +
+ + +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/frontend/issue-view.html b/frontend/issue-view.html new file mode 100644 index 0000000..2bd316b --- /dev/null +++ b/frontend/issue-view.html @@ -0,0 +1,315 @@ + + + + + + 부적합 사항 조회 - 작업보고서 + + + + + + + + + + + + + + + +
+ +
+
+

+ 부적합 사항 목록 +

+ +
+ + + + + +
+
+ + +
+
+ +
+ +

데이터를 불러오는 중...

+
+
+
+
+ + + + + + diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js new file mode 100644 index 0000000..a12bb74 --- /dev/null +++ b/frontend/static/js/api.js @@ -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; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..e5fb7bb --- /dev/null +++ b/index.html @@ -0,0 +1,671 @@ + + + + + + 작업보고서 시스템 + + + + + + +
+
+
+ +

작업보고서 시스템

+

부적합 사항 관리 및 공수 계산

+
+ +
+
+ + +
+ +
+ + +
+ + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..8967826 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,7 @@ +FROM nginx:alpine + +# Nginx 설정 파일 복사 +COPY nginx.conf /etc/nginx/nginx.conf + +# 포트 노출 +EXPOSE 80 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..3737dcf --- /dev/null +++ b/nginx/nginx.conf @@ -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"; + } + } +}