From 1339e5ddedbf1fbeec0785f3eaed0c57e7f1dd28 Mon Sep 17 00:00:00 2001 From: hyungi Date: Wed, 17 Sep 2025 10:41:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=91=EC=97=85=EB=B3=B4=EA=B3=A0?= =?UTF-8?q?=EC=84=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일일 공수 입력 기능 - 부적합 사항 등록 (이미지 선택사항) - 날짜별 부적합 조회 (시간순 나열) - 목록 관리 (인라인 편집, 작업시간 확인 버튼) - 보고서 생성 (총 공수/부적합 시간 분리) - JWT 인증 및 권한 관리 - Docker 기반 배포 환경 구성 --- README.md | 99 ++ Rules.md | 165 ++++ backend/Dockerfile | 24 + backend/__pycache__/main.cpython-311.pyc | Bin 0 -> 3052 bytes .../__pycache__/database.cpython-311.pyc | Bin 0 -> 1153 bytes .../__pycache__/models.cpython-311.pyc | Bin 0 -> 4542 bytes .../__pycache__/schemas.cpython-311.pyc | Bin 0 -> 8582 bytes backend/database/database.py | 19 + backend/database/models.py | 69 ++ backend/database/schemas.py | 129 +++ backend/main.py | 62 ++ backend/migrations/001_init.sql | 43 + backend/requirements.txt | 12 + .../routers/__pycache__/auth.cpython-311.pyc | Bin 0 -> 7816 bytes .../__pycache__/daily_work.cpython-311.pyc | Bin 0 -> 8794 bytes .../__pycache__/issues.cpython-311.pyc | Bin 0 -> 8225 bytes .../__pycache__/reports.cpython-311.pyc | Bin 0 -> 6792 bytes backend/routers/auth.py | 131 +++ backend/routers/daily_work.py | 163 ++++ backend/routers/issues.py | 164 ++++ backend/routers/reports.py | 130 +++ .../__pycache__/auth_service.cpython-311.pyc | Bin 0 -> 4668 bytes .../__pycache__/file_service.cpython-311.pyc | Bin 0 -> 3514 bytes backend/services/auth_service.py | 73 ++ backend/services/file_service.py | 61 ++ chart.html | 417 ++++++++ daily-work.html | 378 ++++++++ docker-compose.yml | 62 ++ frontend/chart.html | 417 ++++++++ frontend/daily-work.html | 458 +++++++++ frontend/index.html | 909 ++++++++++++++++++ frontend/issue-view.html | 315 ++++++ frontend/static/js/api.js | 208 ++++ index.html | 671 +++++++++++++ nginx/Dockerfile | 7 + nginx/nginx.conf | 47 + 36 files changed, 5233 insertions(+) create mode 100644 README.md create mode 100644 Rules.md create mode 100644 backend/Dockerfile create mode 100644 backend/__pycache__/main.cpython-311.pyc create mode 100644 backend/database/__pycache__/database.cpython-311.pyc create mode 100644 backend/database/__pycache__/models.cpython-311.pyc create mode 100644 backend/database/__pycache__/schemas.cpython-311.pyc create mode 100644 backend/database/database.py create mode 100644 backend/database/models.py create mode 100644 backend/database/schemas.py create mode 100644 backend/main.py create mode 100644 backend/migrations/001_init.sql create mode 100644 backend/requirements.txt create mode 100644 backend/routers/__pycache__/auth.cpython-311.pyc create mode 100644 backend/routers/__pycache__/daily_work.cpython-311.pyc create mode 100644 backend/routers/__pycache__/issues.cpython-311.pyc create mode 100644 backend/routers/__pycache__/reports.cpython-311.pyc create mode 100644 backend/routers/auth.py create mode 100644 backend/routers/daily_work.py create mode 100644 backend/routers/issues.py create mode 100644 backend/routers/reports.py create mode 100644 backend/services/__pycache__/auth_service.cpython-311.pyc create mode 100644 backend/services/__pycache__/file_service.cpython-311.pyc create mode 100644 backend/services/auth_service.py create mode 100644 backend/services/file_service.py create mode 100644 chart.html create mode 100644 daily-work.html create mode 100644 docker-compose.yml create mode 100644 frontend/chart.html create mode 100644 frontend/daily-work.html create mode 100644 frontend/index.html create mode 100644 frontend/issue-view.html create mode 100644 frontend/static/js/api.js create mode 100644 index.html create mode 100644 nginx/Dockerfile create mode 100644 nginx/nginx.conf 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 0000000000000000000000000000000000000000..739167f05e83f7b8f5654b86de2079157ee35b06 GIT binary patch literal 3052 zcmbVO-ESO467Qawow2s^Nr ziJtE2>Yl2eUsYGt?x)%82!eJo{olrmDnkDki==5?=B6Yg^gbepU<=u}hHa@PVX>Ah z*;Z;w#LHI7R%>d+E0$K%;C;#(vWIKK_DF3c+E=Z#ovCFaUbC`xu9mY$Yoqp9ZOqQs z^3nc~HEtJb1&nCs^u7ej#zeyP&vbZecoQRZ6-L}Jk`TpYgrrGkE4vA}ufm91tw@U- zrDWAVQ-$P~F&T|0a9d-WDbR!wH^5Tdm`qmvGtHeYC+odPeo<%icj%1$4xRk3>g-Q& z_s=9hXayNxLz^IQwI@_N0Cr9FmVFE$;nAwX6{G1lxN3SHoW@6pVOr<)b1pl>HAYul=6fKVuQO`+R5yriI(pNi zOq{S}I%FsN+}$rQ_cX2h6@b+|2r#!l{^$1lZ~pMbm*0P}vAuSsvi;7L?SFjo5(%674VUzm4QdcLh-;p2Fu%D{PaG{~1;!gNZvGv}vj{_d@q1Z}kl)K%v?u-s z@A@_5_p%hBSJB{G3_BZrC&8z>Z7?cC-C0CcydbW^p|ALC2;^7}2v5~57w!mii7a^n zVPz*XZ>+4$+lJ}Pt(@oCu6a5=3;tjg5SfMH-4EnjcvjIj!`b&vz1?`X@%Ev253N4- zKV@Qlsil9@@e41MU(=yaD6p7?9Xq1QUtKS0n(g zEHJUp@<2p*+SK!mWy(bP{2Va>hfND2@`o$Tb$xaRuy?`mgiwt0>(U+nIu@jli>@4x zG%lpUR&yS%(()Svh(roSh=iAR5t$h9$O~KrulP;4{8;sT3}O$GO#D)>H)#C~I|>Kg z3&Rs;bP;9tt(!r*5-61jj!1_n;kZm+sn?@424T^}jYW9=3e4>R6?>~ctX{=~!SW&c zNHX!3vI(bN?VSwPq!!FuaPeFv)Q7~pPFT?DPJU(g`= z#bZ7S5dpZBhU@uU5tXKTglnguyur0I8NSFwrt(sEYtHUfxVgH^JSMV_=aaXrTkX8q zVy$`B1SyB-iQyYdQ0wLrhKWBHQJV45WN*#}w^<$=7H4YAZGxLUCjOtWv>+Klvf|~0 z@Vr^4-kd0$dUq8k>=2Y8LmbIRl7eYjT!xh8IfD>=*8-k1ox0T|RFA*K)$TmxI(^y9 zWvJItzC+s_R^4O=nL6?hHK)hBhtw2s;7Bh??TP46)FW zF~%V}68u~b(S5;S57E(Ju!ktO8a-d5yMmwVA*uv}Jwy)#ztuzZ$6&B`RD@G&+3RTX z8k+pzRDdR1Xts@J12hY-wA|`)I8qMB=EF>(qbZtn0|B`u$g14QA*@_SinhwzP z?(ReF!jT@b-R)zSA8DbPHkt|04B(YgZ%tjCx@5NSWE)R*BpHuF(%{h>GLp)@os5tx zT`XNHygs!y)sZlMxPyQQsa&!#ln5Pc;ZhrygxItYn-*ele0N94UB|_1xVV14g{Rvf zBq><^?5Pfd>8olq?Gm9plL&P(NXcBfa7``;GW>dsu0FN)?7I9x^|K>^GT&0>+sZsJ Oaqq}T*$-k-{{IBTF3!UM literal 0 HcmV?d00001 diff --git a/backend/database/__pycache__/database.cpython-311.pyc b/backend/database/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44c2a05b34ef454db063954b3c0fbbf73aca8a66 GIT binary patch literal 1153 zcma)5&1(};5P$oT{YcU#T5V0OMX(?No0M7+f=E-e9#VQRD!7-h$-aEl-E4fjX&Z?U z#6wyN7BB6wr>L}lfJcwwDS;fqf>7|Hw?HqUC+DSUOhCbzH^a>D{oW2U@9l??5jO&P z>&+ljE)ROVHg^-I%{wWXYdN&%{kp=2#U~+d0kXQ;J9?j zkQJFhEW+Yiw-WuPCTL;}svUxziQ)kUgFQ6{P_Q2A1(eeyO~T$IxyHdBSZ{9x_JkTF zhvyJdMm0BI+O9F@BZ-)34!_t`gPGt>Z*eweTu{@*|wbfSJVcK8QYvE=A)H6HgznyowY|%gzvN zz0@P&uu&l98cx}f^I(2%eqm8JQ>vZ?UgXZg?fE`wImkI~wP>3u(=c+jBhxso7p<(r zCv$nt5eT-6B%kWH1_2Lt1MFlQTnsUARd#=b2n~OjQ#;aCNT(|FQNZ}GdZw>H|@~CvmgQ` zu%_h*cBzodXNX7v+5*n66RDe=y);65arh-@x7*q$bOHb(7r>oL_Y6}j>i9mn1}Pnp z)dbh(7=~$~nfmZ*p}G3sRtw#&53d%ID_w1&srv9b_FbuxoznhvJ%}5=bkmovc#q}r z>b2c+L!N2MGZmrbyIL(D6hFJZEZ2RphA-Ci#egJE)WwO07-))t3g6~k%-F8HfAe7B Uh($ literal 0 HcmV?d00001 diff --git a/backend/database/__pycache__/models.cpython-311.pyc b/backend/database/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5d1a32516ec1e1e8bd1ba38075b28a3b9df757e GIT binary patch literal 4542 zcmbtXO>Eo96{aNWm-@FX*^ZNLx86+@tnE6@+Ft)`kXTZzBvux&lP!oAT*jQ(N=Qi= zN=e);P@?F;M+GpjI_$xxRKfb-;~snNi3E5M=CnXhy*WruxwLPFc5KN?P^6^zG@S3v z%$u3_y*K=OBogA_`m6BI&F4Xm`xkvVx_pO)*I&Ch?gvic6qm|rF43jAMYrY=JsL0a znpgB{KGCQ7MZXph16oiFx~Q&O4QYZXu(C%DYY{QR%DfuYqGFVly=qL1xj7`ffT4am z8kZB}Ztgm#`2NNzew0A5hhUj=z2HF)9_JF1OfLX>!4K*UFuf4y2_Mu;F}*P8MNs^O z2gZWuaI8UwMFESQ!qN&j2M%$fOW{Pf;u1ZITjUiF z7|oNwT^->&U&DMuN81L>*c)-YagoKf!Hy%Dx z?LVUtfcwoz^eQ~e%1eZzt~bzXU9LBD;;EpgBv8ZE4UBZ11lFrsO+|H7%KGpC9VkV` zAPC|q<-a9?Z|>Z_ekZ@WN&-u_SH8KGU&~`^tz#7(8;>4|2o&_QAWlpr6f>4Ov0-DG z31itva+Md!dxVl;_T_^EeaV1|`E#59kxqKnNv_@jZ5-b8>hZVgmF-BK{ ziX=SxuRxqwiA>hyQyoX9&l|Vt?W_fD(X|%uLeUrtMJK%#!UvDMid%8-d3q@8W0;>} z3cu&=2_At%l;88ca0AuhaV|0FATMC7pN@FKi6L4GkATA62ns)>8_ljge@|ag)>jOy z>qQ{Qf#nI5d`R7_e`^RxXiy0#!M#vVzYuZ&5tQ&LB%?&Y%cDR$pepfF!s=@;-9)J2 zvL@p#=@CQ_Z@HqNXN9bX_!^b+;|9XN1q(=^(oj|TzKUMD@Fl2t=^}pc7`dU=iI;g9 z31C#KVsJ3PqW9(XM^dd?Ye2|BItk|Fvbyzc6+a@Ob&Q}+k?wDmvO~R0ibxU!02Mib z0()R6%u@s0Wd(bgCDd*Tlh~%LZ$d{kS=XOdu|k3mz(^K-us|EK$dRFqcnqNm2WT-^ z)+KqJ21ZPs2S;h8uTfvGRG%IjC)Y9EI^qMOG++XPLMI@>Fpu^kqAJ*|^<_r<=a!ljqT$s8NJ?w>{k)gZtU@iJgh|g}rf8n6-piTbMO+j_T7k zZ^p;1__!S(hZc!UGu;Y+=A@aJvl4T5V$Qg6;Nzkr+ka@C-@j-^7Olvl9a%IAo#eUZ z7p?DqUNDo(R&v=+E*m%L?GTVovp)fU{}BZ2ri;jk2ksk0p>Bkcw3~_oBT2Xq5}^p| zCEDE!iav~i`RqDHZHIJTXqQm@-R*h^24@SP=KxAQq_Mw$Z2rKZ5U6(KsXO#4Nrh9;NQ`T%OKtc7?US}AT&}jPsFoODrHA|=!F-k z!aGvoMf%EE6{>Ak>s6pqebeC%jkJ*S4pRrFxfe))^mSaW(L;oj%RZsiKAj~@Ob?7i zIEn%i*z=BHc~8YaCFLxokSSwgE+kOD*nAd z4{yTE^Fw_Q##q!3agt=eap>0pq<4^UCHO(UCYYwbE1mY*=A_dQ@K5NmI<16(e-S(f z%Z&>l4*8dLvpmSMqr*i;K1xWuOa;pd4h=K^3k@k(2v4a&!ju&VOY7A}rS1?Vg3$)# zQhdmjcootYJ-f4v1bLi9-_#|A&_UANrm>M}w#FVQv4>FqK94*o8hv%v2 zIm5Drpn^J}{vQx0`SelV|M>K+I-`RRakvGS1$m6;|wR=T#sg?S1 zm}+03+J|r`;wrihxc+kc@+*eCIf~ literal 0 HcmV?d00001 diff --git a/backend/database/__pycache__/schemas.cpython-311.pyc b/backend/database/__pycache__/schemas.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..451dc04430f55d7f13148f815c4a477453163c43 GIT binary patch literal 8582 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 SrADClFyKw9Js3i9Ao(xmpVI08 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..18d8b7f41779d43df98bf11adf25f819e7d8a111 GIT binary patch literal 7816 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 TsfByr4c(M1m39c3X!m~ueLg!7 literal 0 HcmV?d00001 diff --git a/backend/routers/__pycache__/issues.cpython-311.pyc b/backend/routers/__pycache__/issues.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22476d1ba1d902f97acc7daa30453f6f4516dd32 GIT binary patch literal 8225 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 literal 0 HcmV?d00001 diff --git a/backend/routers/__pycache__/reports.cpython-311.pyc b/backend/routers/__pycache__/reports.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c19d0a674f5047356637c507f5174822665a1cd3 GIT binary patch literal 6792 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 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 0000000000000000000000000000000000000000..61217220e0e8ef10f350064ef98cd1e700fed7ce GIT binary patch literal 4668 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 ABme*a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..66d57e2b11bb53ca542390b505b5f9d179cb8b36 GIT binary patch literal 3514 zcmaJ@TWl2989sB{oxORzzQfw`2G}HPj9pw~Ttf($H~~rw!9gu6MXSX#hTZHfXJ)ZM zYh+QVHm)UT>d0O|%=YYpXOi{DnT;lP5Uf}!|!E_t>t%6y!3B1UPwq3@0Xo3|i zw^_eku!^<`TCf4_5U4Tavp*v`qdrRGg`h0Tp{Pjca|t;Vj|C$@bEl)hE8-1%Z!nSQ zO(r7opdgKxCkBEky)SM9v4G?%S(q{g8QP$#d)ZVOt$^-YSzmI?Wg4L{Yv{e)0$c$< zr2iD1>;V1z!kw+(E^Ym4$+I;-zm@s;`Oh<3D-S&RpDpKa|7q06X>?rD*hEmis+q*8 zkR(f*_2T)n=T5v8c28u2UfF*WLGQ zict#;h;%u2fU)Md9F)X?{(vOoQ0$6k!lInSvGFP&A>vE27cZKiT|gF8?ZFigtif9> z#8)YF4Pkc*Wvb^e6hL0P9|B!DOw&j<4lwA4$(njep<0`1O12m>&@0=>$|%LIKwPbr zV{4idEYqeGooTA*RH&3mE_)ExDf=SaV(8S)MxhiVFdyo(6OU(F4ZZ3<#R*oy_Q1XZ z=U}aT7AO?^e-;j*4s4t&5Z7zv*oISCpJKry^cdQUyHofU(RgZ|H|`xZZ5z4 z6~KijpP9*jasTxG&&U5%){4ZQ96B2Gfl*CG$?CaBt9u(Om<6SV)03h2V9vT;IFaCWJu6B()Mf2 zRdEWF2*u>*G&UR$#V|>0nnk{vj9!ifLlMn-L6m|~_!Ph;^Vt)lC;jKfG*${;6E(}o z)VQdp5{*j$aLGO%*AsJVh$NK&q@19}=`<$CTH^_-#)9x2I;Dd%0P$2nBEF+UiF}8r zR4|D-%|%uUl;E*A)e!K>GSVloUo)t{dbp-depi-vtGrv`-31e}JMSG(Y@JYZ?Y@on z-Y4z7>rA%2Uv2MSe@|^coPPH|Ce+fdHhVMCY;(Wb+@H1r$JH`#UG7^N%7nA-gR1*r zw!TlT?@MzzU(bVf#dZ8%^L+b;Ywr`+-mJ^3y1a_Z`|wcO1eUFx^O0Z1=3^Tz2cEPX zSdC>{-cVcKP#ObJAG2Ru|6%{yt{y+Hyf>y?7=u)oWo97QN9R1|z zee3F}Y~vxd@lcwFpK0=>`5f=K>zs8i^7p;!o<4QYP?kTU@<$Z@$Wxo^-o@EB({JYN zuDfSu&)l2JG_N(jERWxe5{K!hh%`$@%q5~ikW6=OmWl zrixoxQP!__#WCcrS{$ZdGLxxdU!hVvPz>*`$*+|RxkC@|k_D8jdI2@bxi1wRH5s|l z7$dk!>KW&I^8jkH)67k#I2WO+P?JlNd|gCHPyW^)@|h*v2^3|fCq@wGLei#0t656 zk6*a!~mNhMnqW*5cI$&K-5okofrPTD*(=M%(@;ZTnY$oNXIa z+Xi)JuC;Tc^|dFhudUkFFJxN>)z-oEsm-?COXlC$KeONWueW8}hSav9^yy8fYi{!H z&Dooap{(Y^)`i6PSqAy#&Pp#jl7`0%5_4N6x8WX7NHpMWaxNdUCD>ix< zb!9$M>bjt2`TZ)tU-@5Ek0rPtc9&ymm>YJYM^5YT0P|>9!|*}o(LoMqUtQ7NuYe

SA_bASGaeq`E^fxfSVGQQhO){LyG!NJhkC zcmxCxm`M6NG;nRDs2sA)=uZwgX7neAI+WU)LvE$E=8$bhe{!f 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"; + } + } +}