feat: 사진 업로드 기능 개선 및 카테고리 업데이트
- 사진 2장까지 업로드 지원 - 카메라 촬영 + 갤러리 선택 분리 - 이미지 압축 및 최적화 (ImageUtils) - iPhone .mpo 파일 JPEG 변환 지원 - 카테고리 변경: 치수불량 → 설계미스, 검사미스 추가 - KST 시간대 설정 - URL 해시 처리로 목록관리 페이지 이동 개선 - 로그인 OAuth2 form-data 형식 수정 - 업로드 속도 개선 및 프로그레스바 추가
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,24 +1,32 @@
|
||||
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
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import enum
|
||||
|
||||
# 한국 시간대 설정
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
def get_kst_now():
|
||||
"""현재 한국 시간 반환"""
|
||||
return datetime.now(KST)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
class IssueStatus(str, enum.Enum):
|
||||
NEW = "new"
|
||||
PROGRESS = "progress"
|
||||
COMPLETE = "complete"
|
||||
new = "new"
|
||||
progress = "progress"
|
||||
complete = "complete"
|
||||
|
||||
class IssueCategory(str, enum.Enum):
|
||||
MATERIAL_MISSING = "material_missing"
|
||||
DIMENSION_DEFECT = "dimension_defect"
|
||||
INCOMING_DEFECT = "incoming_defect"
|
||||
material_missing = "material_missing"
|
||||
design_error = "design_error" # 설계미스 (기존 dimension_defect 대체)
|
||||
incoming_defect = "incoming_defect"
|
||||
inspection_miss = "inspection_miss" # 검사미스 (신규 추가)
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
@@ -27,9 +35,9 @@ class User(Base):
|
||||
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)
|
||||
role = Column(Enum(UserRole), default=UserRole.user)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
issues = relationship("Issue", back_populates="reporter")
|
||||
@@ -40,11 +48,12 @@ class Issue(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
photo_path = Column(String)
|
||||
photo_path2 = Column(String) # 두 번째 사진 경로
|
||||
category = Column(Enum(IssueCategory), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
status = Column(Enum(IssueStatus), default=IssueStatus.NEW)
|
||||
status = Column(Enum(IssueStatus), default=IssueStatus.new)
|
||||
reporter_id = Column(Integer, ForeignKey("users.id"))
|
||||
report_date = Column(DateTime, default=datetime.utcnow)
|
||||
report_date = Column(DateTime, default=get_kst_now)
|
||||
work_hours = Column(Float, default=0)
|
||||
detail_notes = Column(Text)
|
||||
|
||||
@@ -63,7 +72,7 @@ class DailyWork(Base):
|
||||
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)
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="daily_works")
|
||||
|
||||
@@ -14,8 +14,9 @@ class IssueStatus(str, Enum):
|
||||
|
||||
class IssueCategory(str, Enum):
|
||||
material_missing = "material_missing"
|
||||
dimension_defect = "dimension_defect"
|
||||
design_error = "design_error" # 설계미스 (기존 dimension_defect 대체)
|
||||
incoming_defect = "incoming_defect"
|
||||
inspection_miss = "inspection_miss" # 검사미스 (신규 추가)
|
||||
|
||||
# User schemas
|
||||
class UserBase(BaseModel):
|
||||
@@ -64,6 +65,7 @@ class IssueBase(BaseModel):
|
||||
|
||||
class IssueCreate(IssueBase):
|
||||
photo: Optional[str] = None # Base64 encoded image
|
||||
photo2: Optional[str] = None # Second Base64 encoded image
|
||||
|
||||
class IssueUpdate(BaseModel):
|
||||
category: Optional[IssueCategory] = None
|
||||
@@ -72,10 +74,12 @@ class IssueUpdate(BaseModel):
|
||||
detail_notes: Optional[str] = None
|
||||
status: Optional[IssueStatus] = None
|
||||
photo: Optional[str] = None # Base64 encoded image for update
|
||||
photo2: Optional[str] = None # Second Base64 encoded image for update
|
||||
|
||||
class Issue(IssueBase):
|
||||
id: int
|
||||
photo_path: Optional[str] = None
|
||||
photo_path2: Optional[str] = None # 두 번째 사진 경로
|
||||
status: IssueStatus
|
||||
reporter_id: int
|
||||
reporter: User
|
||||
|
||||
@@ -9,6 +9,8 @@ from routers import auth, issues, daily_work, reports
|
||||
from services.auth_service import create_admin_user
|
||||
|
||||
# 데이터베이스 테이블 생성
|
||||
# 메타데이터 캐시 클리어
|
||||
Base.metadata.clear()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# FastAPI 앱 생성
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
-- 초기 데이터베이스 설정
|
||||
|
||||
-- Enum 타입 생성 (4개 카테고리)
|
||||
CREATE TYPE userRole AS ENUM ('admin', 'user');
|
||||
CREATE TYPE issueStatus AS ENUM ('new', 'progress', 'complete');
|
||||
CREATE TYPE issueCategory AS ENUM (
|
||||
'material_missing', -- 자재누락
|
||||
'design_error', -- 설계미스
|
||||
'incoming_defect', -- 입고자재 불량
|
||||
'inspection_miss' -- 검사미스
|
||||
);
|
||||
|
||||
-- 사용자 테이블
|
||||
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',
|
||||
role userRole DEFAULT 'user',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -12,20 +24,21 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
-- 이슈 테이블
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
photo_path VARCHAR(255),
|
||||
category VARCHAR(50) NOT NULL,
|
||||
photo_path VARCHAR(500),
|
||||
photo_path2 VARCHAR(500), -- 두 번째 사진
|
||||
category issueCategory NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'new',
|
||||
status issueStatus 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,
|
||||
date DATE NOT NULL,
|
||||
worker_count INTEGER NOT NULL,
|
||||
regular_hours FLOAT NOT NULL,
|
||||
overtime_workers INTEGER DEFAULT 0,
|
||||
@@ -33,11 +46,13 @@ CREATE TABLE IF NOT EXISTS daily_works (
|
||||
overtime_total FLOAT DEFAULT 0,
|
||||
total_hours FLOAT NOT NULL,
|
||||
created_by_id INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(date)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
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_issues_category ON issues(category);
|
||||
CREATE INDEX idx_daily_works_date ON daily_works(date);
|
||||
CREATE INDEX idx_daily_works_created_by_id ON daily_works(created_by_id);
|
||||
5
backend/migrations/002_add_second_photo.sql
Normal file
5
backend/migrations/002_add_second_photo.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 두 번째 사진 경로 추가
|
||||
ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path2 VARCHAR(500);
|
||||
|
||||
-- 인덱스 추가 (선택사항)
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_photo_path2 ON issues(photo_path2);
|
||||
8
backend/migrations/003_update_categories.sql
Normal file
8
backend/migrations/003_update_categories.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- 카테고리 업데이트 마이그레이션
|
||||
-- dimension_defect를 design_error로 변경
|
||||
UPDATE issues
|
||||
SET category = 'design_error'
|
||||
WHERE category = 'dimension_defect';
|
||||
|
||||
-- PostgreSQL enum 타입 업데이트 (필요한 경우)
|
||||
-- 기존 enum 타입 확인 후 필요시 재생성
|
||||
9
backend/migrations/004_fix_category_values.sql
Normal file
9
backend/migrations/004_fix_category_values.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- 카테고리 값 정규화 (대문자를 소문자로 변경)
|
||||
-- 기존 DIMENSION_DEFECT를 design_error로 변경
|
||||
UPDATE issues SET category = 'design_error' WHERE category IN ('DIMENSION_DEFECT', 'dimension_defect');
|
||||
UPDATE issues SET category = 'material_missing' WHERE category = 'MATERIAL_MISSING';
|
||||
UPDATE issues SET category = 'incoming_defect' WHERE category = 'INCOMING_DEFECT';
|
||||
UPDATE issues SET category = 'inspection_miss' WHERE category = 'INSPECTION_MISS';
|
||||
|
||||
-- 카테고리 값 확인
|
||||
SELECT category, COUNT(*) FROM issues GROUP BY category;
|
||||
20
backend/migrations/005_recreate_enum_type.sql
Normal file
20
backend/migrations/005_recreate_enum_type.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- PostgreSQL enum 타입 재생성
|
||||
-- 카테고리 컬럼을 임시로 텍스트로 변경
|
||||
ALTER TABLE issues ALTER COLUMN category TYPE VARCHAR(50);
|
||||
|
||||
-- 기존 enum 타입 삭제
|
||||
DROP TYPE IF EXISTS issuecategory CASCADE;
|
||||
|
||||
-- 새로운 enum 타입 생성 (4개 카테고리)
|
||||
CREATE TYPE issuecategory AS ENUM (
|
||||
'material_missing', -- 자재누락
|
||||
'design_error', -- 설계미스 (기존 dimension_defect 대체)
|
||||
'incoming_defect', -- 입고자재 불량
|
||||
'inspection_miss' -- 검사미스 (신규)
|
||||
);
|
||||
|
||||
-- 카테고리 컬럼을 새 enum 타입으로 변경
|
||||
ALTER TABLE issues ALTER COLUMN category TYPE issuecategory USING category::issuecategory;
|
||||
|
||||
-- 확인
|
||||
SELECT enumlabel FROM pg_enum WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'issuecategory');
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -29,7 +29,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
||||
return user
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
@@ -37,8 +37,8 @@ async def get_current_admin(current_user: User = Depends(get_current_user)):
|
||||
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)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import DailyWork, User, UserRole
|
||||
from database.models import DailyWork, User, UserRole, KST
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
@@ -120,7 +120,7 @@ async def delete_daily_work(
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
|
||||
# 권한 확인 (관리자만 삭제 가능)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin can delete daily work")
|
||||
|
||||
db.delete(work)
|
||||
|
||||
@@ -19,16 +19,22 @@ async def create_issue(
|
||||
):
|
||||
# 이미지 저장
|
||||
photo_path = None
|
||||
photo_path2 = None
|
||||
|
||||
if issue.photo:
|
||||
photo_path = save_base64_image(issue.photo)
|
||||
|
||||
if issue.photo2:
|
||||
photo_path2 = save_base64_image(issue.photo2)
|
||||
|
||||
# Issue 생성
|
||||
db_issue = Issue(
|
||||
category=issue.category,
|
||||
description=issue.description,
|
||||
photo_path=photo_path,
|
||||
photo_path2=photo_path2,
|
||||
reporter_id=current_user.id,
|
||||
status=IssueStatus.NEW
|
||||
status=IssueStatus.new
|
||||
)
|
||||
db.add(db_issue)
|
||||
db.commit()
|
||||
@@ -45,9 +51,8 @@ async def read_issues(
|
||||
):
|
||||
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)
|
||||
@@ -65,9 +70,7 @@ async def read_issue(
|
||||
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
|
||||
|
||||
@@ -83,13 +86,13 @@ async def update_issue(
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
# 권한 확인
|
||||
if current_user.role == UserRole.USER and issue.reporter_id != current_user.id:
|
||||
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:
|
||||
@@ -105,10 +108,26 @@ async def update_issue(
|
||||
# photo 필드는 제거 (DB에는 photo_path만 저장)
|
||||
del update_data["photo"]
|
||||
|
||||
# 두 번째 사진이 업데이트되는 경우 처리
|
||||
if "photo2" in update_data:
|
||||
# 기존 사진 삭제
|
||||
if issue.photo_path2:
|
||||
delete_file(issue.photo_path2)
|
||||
|
||||
# 새 사진 저장
|
||||
if update_data["photo2"]:
|
||||
photo_path2 = save_base64_image(update_data["photo2"])
|
||||
update_data["photo_path2"] = photo_path2
|
||||
else:
|
||||
update_data["photo_path2"] = None
|
||||
|
||||
# photo2 필드는 제거 (DB에는 photo_path2만 저장)
|
||||
del update_data["photo2"]
|
||||
|
||||
# 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
|
||||
if issue.status == IssueStatus.new:
|
||||
update_data["status"] = IssueStatus.complete
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(issue, field, value)
|
||||
@@ -128,7 +147,7 @@ async def delete_issue(
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
# 권한 확인 (관리자만 삭제 가능)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin can delete issues")
|
||||
|
||||
# 이미지 파일 삭제
|
||||
@@ -148,7 +167,7 @@ async def get_issue_stats(
|
||||
query = db.query(Issue)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.USER:
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
total = query.count()
|
||||
|
||||
@@ -35,7 +35,7 @@ async def generate_report_summary(
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.USER:
|
||||
if current_user.role == UserRole.user:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
@@ -89,7 +89,7 @@ async def get_report_issues(
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.USER:
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date).all()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -63,7 +63,7 @@ def create_admin_user(db: Session):
|
||||
username=admin_username,
|
||||
hashed_password=get_password_hash(admin_password),
|
||||
full_name="관리자",
|
||||
role=UserRole.ADMIN,
|
||||
role=UserRole.admin,
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin_user)
|
||||
|
||||
@@ -27,20 +27,29 @@ def save_base64_image(base64_string: str) -> Optional[str]:
|
||||
|
||||
# 이미지 검증 및 형식 확인
|
||||
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}"
|
||||
# iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환
|
||||
# RGB 모드로 변환 (RGBA, P 모드 등을 처리)
|
||||
if image.mode in ('RGBA', 'LA', 'P'):
|
||||
# 투명도가 있는 이미지는 흰 배경과 합성
|
||||
background = Image.new('RGB', image.size, (255, 255, 255))
|
||||
if image.mode == 'P':
|
||||
image = image.convert('RGBA')
|
||||
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
|
||||
image = background
|
||||
elif image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# 파일명 생성 (강제로 .jpg)
|
||||
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.jpg"
|
||||
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)
|
||||
# 항상 JPEG로 저장
|
||||
image.save(filepath, 'JPEG', quality=85, optimize=True)
|
||||
|
||||
# 웹 경로 반환
|
||||
return f"/uploads/{filename}"
|
||||
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
POSTGRES_USER: mproject
|
||||
POSTGRES_PASSWORD: mproject2024
|
||||
POSTGRES_DB: mproject
|
||||
TZ: Asia/Seoul
|
||||
PGTZ: Asia/Seoul
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backend/migrations:/docker-entrypoint-initdb.d
|
||||
@@ -27,6 +29,7 @@ services:
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: 10080 # 7 days
|
||||
ADMIN_USERNAME: hyungi
|
||||
ADMIN_PASSWORD: djg3-jj34-X3Q3
|
||||
TZ: Asia/Seoul
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- uploads:/app/uploads
|
||||
|
||||
@@ -225,7 +225,8 @@
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/api.js?v=20250917"></script>
|
||||
<script src="/static/js/date-utils.js?v=20250917"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let users = [];
|
||||
|
||||
@@ -242,7 +242,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/api.js?v=20250917"></script>
|
||||
<script src="/static/js/date-utils.js?v=20250917"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let dailyWorks = [];
|
||||
@@ -426,12 +427,8 @@
|
||||
}
|
||||
|
||||
container.innerHTML = dailyWorks.map(item => {
|
||||
const date = new Date(item.date);
|
||||
const dateStr = date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
});
|
||||
const dateStr = DateUtils.formatKST(item.date);
|
||||
const relativeTime = DateUtils.getRelativeTime(item.created_at || item.date);
|
||||
|
||||
return `
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
|
||||
@@ -91,9 +91,50 @@
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 로딩 오버레이 -->
|
||||
<div id="loadingOverlay" class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-spinner fa-spin text-5xl text-blue-500"></i>
|
||||
</div>
|
||||
<p class="text-gray-700 font-medium text-lg">처리 중입니다...</p>
|
||||
<p class="text-gray-500 text-sm mt-2">잠시만 기다려주세요</p>
|
||||
<div class="mt-4 w-full max-w-xs">
|
||||
<div class="bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||
<div class="bg-blue-500 h-full rounded-full transition-all duration-300 upload-progress" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 화면 -->
|
||||
<div id="loginScreen" class="min-h-screen flex items-center justify-center p-4 bg-gray-50">
|
||||
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
|
||||
@@ -184,28 +225,63 @@
|
||||
</h2>
|
||||
|
||||
<form id="reportForm" class="space-y-4">
|
||||
<!-- 사진 업로드 (선택사항) -->
|
||||
<!-- 사진 업로드 (선택사항, 최대 2장) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
사진 <span class="text-gray-500 text-xs">(선택사항)</span>
|
||||
사진 <span class="text-gray-500 text-xs">(선택사항, 최대 2장)</span>
|
||||
</label>
|
||||
<div
|
||||
id="photoUpload"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
|
||||
onclick="document.getElementById('photoInput').click()"
|
||||
>
|
||||
<div id="photoPreview" class="hidden mb-4">
|
||||
<img id="previewImg" class="max-h-48 mx-auto rounded-lg">
|
||||
<button type="button" onclick="removePhoto(event)" class="mt-2 px-2 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600">
|
||||
<i class="fas fa-times mr-1"></i>사진 제거
|
||||
|
||||
<!-- 사진 미리보기 영역 -->
|
||||
<div id="photoPreviewContainer" class="grid grid-cols-2 gap-3 mb-3" style="display: none;">
|
||||
<!-- 첫 번째 사진 -->
|
||||
<div id="photo1Container" class="relative hidden">
|
||||
<img id="previewImg1" class="w-full h-32 object-cover rounded-lg">
|
||||
<button type="button" onclick="removePhoto(0)" class="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full text-xs hover:bg-red-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="photoPlaceholder">
|
||||
<i class="fas fa-camera text-4xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-600">사진 촬영 또는 선택</p>
|
||||
<!-- 두 번째 사진 -->
|
||||
<div id="photo2Container" class="relative hidden">
|
||||
<img id="previewImg2" class="w-full h-32 object-cover rounded-lg">
|
||||
<button type="button" onclick="removePhoto(1)" class="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full text-xs hover:bg-red-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" capture="camera" class="hidden">
|
||||
|
||||
<!-- 업로드 버튼들 -->
|
||||
<div class="flex gap-3">
|
||||
<!-- 카메라 촬영 버튼 -->
|
||||
<div
|
||||
id="cameraUpload"
|
||||
class="flex-1 border-2 border-dashed border-blue-300 rounded-lg p-5 text-center cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all"
|
||||
onclick="openCamera()"
|
||||
>
|
||||
<i class="fas fa-camera text-4xl text-blue-500 mb-2"></i>
|
||||
<p class="text-gray-700 font-medium text-sm">📷 카메라</p>
|
||||
<p class="text-gray-500 text-xs mt-1">즉시 촬영</p>
|
||||
</div>
|
||||
|
||||
<!-- 갤러리 선택 버튼 -->
|
||||
<div
|
||||
id="galleryUpload"
|
||||
class="flex-1 border-2 border-dashed border-green-300 rounded-lg p-5 text-center cursor-pointer hover:border-green-500 hover:bg-green-50 transition-all"
|
||||
onclick="openGallery()"
|
||||
>
|
||||
<i class="fas fa-images text-4xl text-green-500 mb-2"></i>
|
||||
<p class="text-gray-700 font-medium text-sm">🖼️ 갤러리</p>
|
||||
<p class="text-gray-500 text-xs mt-1">사진 선택</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 현재 상태 표시 -->
|
||||
<div class="text-center mt-2">
|
||||
<p class="text-sm text-gray-500" id="photoUploadText">사진 추가 (0/2)</p>
|
||||
</div>
|
||||
|
||||
<!-- 숨겨진 입력 필드들 -->
|
||||
<input type="file" id="cameraInput" accept="image/*" capture="camera" class="hidden" multiple>
|
||||
<input type="file" id="galleryInput" accept="image/*" class="hidden" multiple>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리 -->
|
||||
@@ -214,8 +290,9 @@
|
||||
<select id="category" class="input-field w-full px-4 py-2 rounded-lg" required>
|
||||
<option value="">선택하세요</option>
|
||||
<option value="material_missing">자재누락</option>
|
||||
<option value="dimension_defect">치수불량</option>
|
||||
<option value="design_error">설계미스</option>
|
||||
<option value="incoming_defect">입고자재 불량</option>
|
||||
<option value="inspection_miss">검사미스</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -264,10 +341,12 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/api.js?v=20250917"></script>
|
||||
<script src="/static/js/image-utils.js?v=20250917"></script>
|
||||
<script src="/static/js/date-utils.js?v=20250917"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let currentPhoto = null;
|
||||
let currentPhotos = [];
|
||||
let issues = [];
|
||||
|
||||
// 페이지 로드 시 인증 체크
|
||||
@@ -283,6 +362,9 @@
|
||||
updateNavigation();
|
||||
|
||||
loadIssues();
|
||||
|
||||
// URL 해시 처리
|
||||
handleUrlHash();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -303,6 +385,9 @@
|
||||
updateNavigation();
|
||||
|
||||
loadIssues();
|
||||
|
||||
// URL 해시 처리
|
||||
handleUrlHash();
|
||||
} catch (error) {
|
||||
alert(error.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
@@ -335,6 +420,17 @@
|
||||
}
|
||||
|
||||
// 섹션 전환
|
||||
// URL 해시 처리
|
||||
function handleUrlHash() {
|
||||
const hash = window.location.hash.substring(1); // # 제거
|
||||
if (hash === 'list' || hash === 'summary') {
|
||||
showSection(hash);
|
||||
}
|
||||
}
|
||||
|
||||
// 해시 변경 감지
|
||||
window.addEventListener('hashchange', handleUrlHash);
|
||||
|
||||
function showSection(section) {
|
||||
// 모든 섹션 숨기기
|
||||
document.querySelectorAll('section').forEach(s => s.classList.add('hidden'));
|
||||
@@ -343,38 +439,191 @@
|
||||
|
||||
// 네비게이션 활성화 상태 변경
|
||||
document.querySelectorAll('.nav-link').forEach(link => link.classList.remove('active'));
|
||||
|
||||
// event가 있는 경우에만 활성화 처리
|
||||
if (typeof event !== 'undefined' && event.target && event.target.closest) {
|
||||
event.target.closest('.nav-link').classList.add('active');
|
||||
} else {
|
||||
// URL 해시로 접근한 경우 해당 버튼 찾아서 활성화
|
||||
const targetButton = document.querySelector(`[onclick="showSection('${section}')"]`);
|
||||
if (targetButton) {
|
||||
targetButton.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// 섹션별 초기화
|
||||
if (section === 'list') {
|
||||
// 데이터가 없으면 먼저 로드
|
||||
if (issues.length === 0) {
|
||||
loadIssues().then(() => displayIssueList());
|
||||
} else {
|
||||
displayIssueList();
|
||||
}
|
||||
} else if (section === 'summary') {
|
||||
// 데이터가 없으면 먼저 로드
|
||||
if (issues.length === 0) {
|
||||
loadIssues().then(() => generateReport());
|
||||
} else {
|
||||
generateReport();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 업로드
|
||||
document.getElementById('photoInput').addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
currentPhoto = e.target.result;
|
||||
document.getElementById('previewImg').src = currentPhoto;
|
||||
document.getElementById('photoPreview').classList.remove('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.add('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
// 카메라 열기
|
||||
function openCamera() {
|
||||
if (currentPhotos.length >= 2) {
|
||||
alert('최대 2장까지 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
document.getElementById('cameraInput').click();
|
||||
}
|
||||
|
||||
// 갤러리 열기
|
||||
function openGallery() {
|
||||
if (currentPhotos.length >= 2) {
|
||||
alert('최대 2장까지 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
document.getElementById('galleryInput').click();
|
||||
}
|
||||
|
||||
// 사진 업로드 처리 함수
|
||||
async function handlePhotoUpload(files) {
|
||||
const filesArray = Array.from(files);
|
||||
|
||||
// 현재 사진 개수 확인
|
||||
if (currentPhotos.length >= 2) {
|
||||
alert('최대 2장까지 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 추가 가능한 개수만큼만 처리
|
||||
const availableSlots = 2 - currentPhotos.length;
|
||||
const filesToProcess = filesArray.slice(0, availableSlots);
|
||||
|
||||
// 로딩 표시
|
||||
showUploadProgress(true);
|
||||
|
||||
try {
|
||||
for (const file of filesToProcess) {
|
||||
if (currentPhotos.length >= 2) break;
|
||||
|
||||
// 원본 파일 크기 확인
|
||||
const originalSize = file.size;
|
||||
console.log(`원본 이미지 크기: ${ImageUtils.formatFileSize(originalSize)}`);
|
||||
|
||||
// 이미지 압축
|
||||
const compressedImage = await ImageUtils.compressImage(file, {
|
||||
maxWidth: 1280,
|
||||
maxHeight: 1280,
|
||||
quality: 0.75
|
||||
});
|
||||
|
||||
// 압축된 크기 확인
|
||||
const compressedSize = ImageUtils.getBase64Size(compressedImage);
|
||||
console.log(`압축된 이미지 크기: ${ImageUtils.formatFileSize(compressedSize)}`);
|
||||
console.log(`압축률: ${Math.round((1 - compressedSize/originalSize) * 100)}%`);
|
||||
|
||||
currentPhotos.push(compressedImage);
|
||||
updatePhotoPreview();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('이미지 압축 실패:', error);
|
||||
alert('이미지 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
showUploadProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 업로드 진행 상태 표시
|
||||
function showUploadProgress(show) {
|
||||
const cameraBtn = document.getElementById('cameraUpload');
|
||||
const galleryBtn = document.getElementById('galleryUpload');
|
||||
const uploadText = document.getElementById('photoUploadText');
|
||||
|
||||
if (show) {
|
||||
cameraBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
galleryBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
uploadText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>이미지 처리 중...';
|
||||
uploadText.classList.add('text-blue-600');
|
||||
} else {
|
||||
if (currentPhotos.length < 2) {
|
||||
cameraBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
galleryBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`;
|
||||
uploadText.classList.remove('text-blue-600');
|
||||
}
|
||||
}
|
||||
|
||||
// 카메라 입력 이벤트
|
||||
document.getElementById('cameraInput').addEventListener('change', (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handlePhotoUpload(e.target.files);
|
||||
}
|
||||
e.target.value = ''; // 입력 초기화
|
||||
});
|
||||
|
||||
// 갤러리 입력 이벤트
|
||||
document.getElementById('galleryInput').addEventListener('change', (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handlePhotoUpload(e.target.files);
|
||||
}
|
||||
e.target.value = ''; // 입력 초기화
|
||||
});
|
||||
|
||||
// 사진 제거
|
||||
function removePhoto(event) {
|
||||
event.stopPropagation();
|
||||
currentPhoto = null;
|
||||
document.getElementById('photoInput').value = '';
|
||||
document.getElementById('photoPreview').classList.add('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||
function removePhoto(index) {
|
||||
currentPhotos.splice(index, 1);
|
||||
updatePhotoPreview();
|
||||
}
|
||||
|
||||
// 사진 미리보기 업데이트
|
||||
function updatePhotoPreview() {
|
||||
const container = document.getElementById('photoPreviewContainer');
|
||||
const photo1Container = document.getElementById('photo1Container');
|
||||
const photo2Container = document.getElementById('photo2Container');
|
||||
const uploadText = document.getElementById('photoUploadText');
|
||||
const cameraUpload = document.getElementById('cameraUpload');
|
||||
const galleryUpload = document.getElementById('galleryUpload');
|
||||
|
||||
// 텍스트 업데이트
|
||||
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`;
|
||||
|
||||
// 첫 번째 사진
|
||||
if (currentPhotos[0]) {
|
||||
document.getElementById('previewImg1').src = currentPhotos[0];
|
||||
photo1Container.classList.remove('hidden');
|
||||
container.style.display = 'grid';
|
||||
} else {
|
||||
photo1Container.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 두 번째 사진
|
||||
if (currentPhotos[1]) {
|
||||
document.getElementById('previewImg2').src = currentPhotos[1];
|
||||
photo2Container.classList.remove('hidden');
|
||||
container.style.display = 'grid';
|
||||
} else {
|
||||
photo2Container.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 미리보기 컨테이너 표시/숨김
|
||||
if (currentPhotos.length === 0) {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
|
||||
// 2장이 모두 업로드되면 업로드 버튼 스타일 변경
|
||||
if (currentPhotos.length >= 2) {
|
||||
cameraUpload.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
galleryUpload.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
uploadText.textContent = '최대 2장 업로드 완료';
|
||||
uploadText.classList.add('text-green-600', 'font-medium');
|
||||
} else {
|
||||
cameraUpload.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
galleryUpload.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
uploadText.classList.remove('text-green-600', 'font-medium');
|
||||
}
|
||||
}
|
||||
|
||||
// 목록에서 사진 변경
|
||||
@@ -412,23 +661,83 @@
|
||||
document.getElementById('reportForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
const originalBtnContent = submitBtn.innerHTML;
|
||||
const description = document.getElementById('description').value.trim();
|
||||
|
||||
if (!description) {
|
||||
alert('설명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 오버레이 표시 및 상태 업데이트
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
const loadingText = loadingOverlay.querySelector('p.text-gray-700');
|
||||
const loadingSubtext = loadingOverlay.querySelector('p.text-gray-500');
|
||||
|
||||
loadingOverlay.classList.add('active');
|
||||
|
||||
// 단계별 메시지 표시
|
||||
const updateLoadingMessage = (message, subMessage = '') => {
|
||||
if (loadingText) loadingText.textContent = message;
|
||||
if (loadingSubtext) loadingSubtext.textContent = subMessage;
|
||||
};
|
||||
|
||||
// 로딩 상태 표시
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = `
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
등록 중...
|
||||
`;
|
||||
submitBtn.classList.add('opacity-75', 'cursor-not-allowed');
|
||||
|
||||
// 전체 폼 비활성화
|
||||
const formElements = e.target.querySelectorAll('input, textarea, select, button');
|
||||
formElements.forEach(el => el.disabled = true);
|
||||
|
||||
try {
|
||||
const issueData = {
|
||||
photo: currentPhoto,
|
||||
category: document.getElementById('category').value,
|
||||
description: document.getElementById('description').value
|
||||
// 프로그레스바 업데이트 함수
|
||||
const updateProgress = (percent) => {
|
||||
const progressBar = loadingOverlay.querySelector('.upload-progress');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${percent}%`;
|
||||
}
|
||||
};
|
||||
|
||||
// 이미지가 있는 경우 압축 상태 표시
|
||||
if (currentPhotos.length > 0) {
|
||||
updateLoadingMessage('이미지 최적화 중...', `${currentPhotos.length}장 처리 중`);
|
||||
updateProgress(30);
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // UI 업데이트를 위한 짧은 대기
|
||||
}
|
||||
|
||||
updateLoadingMessage('서버로 전송 중...', '네트워크 상태에 따라 시간이 걸릴 수 있습니다');
|
||||
updateProgress(60);
|
||||
|
||||
const issueData = {
|
||||
photos: currentPhotos, // 배열로 전달
|
||||
category: document.getElementById('category').value,
|
||||
description: description
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
await IssuesAPI.create(issueData);
|
||||
const uploadTime = Date.now() - startTime;
|
||||
|
||||
// 성공 메시지
|
||||
alert('부적합 사항이 등록되었습니다.');
|
||||
updateProgress(90);
|
||||
updateLoadingMessage('완료 처리 중...', '거의 다 되었습니다');
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('reportForm').reset();
|
||||
currentPhoto = null;
|
||||
document.getElementById('photoPreview').classList.add('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||
console.log(`업로드 완료: ${uploadTime}ms`);
|
||||
|
||||
updateProgress(100);
|
||||
|
||||
// 성공 메시지 (토스트)
|
||||
showToastMessage('부적합 사항이 등록되었습니다!', 'success');
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('reportForm').reset();
|
||||
currentPhotos = [];
|
||||
updatePhotoPreview();
|
||||
|
||||
// 목록 다시 로드
|
||||
await loadIssues();
|
||||
@@ -437,6 +746,28 @@
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message || '등록에 실패했습니다.');
|
||||
} finally {
|
||||
// 로딩 오버레이 숨기기
|
||||
setTimeout(() => {
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
// 프로그레스바 초기화
|
||||
const progressBar = document.querySelector('.upload-progress');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = '0%';
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// 버튼 및 폼 원상 복구
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalBtnContent;
|
||||
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
|
||||
|
||||
// 폼 요소 다시 활성화
|
||||
formElements.forEach(el => {
|
||||
if (el !== submitBtn) {
|
||||
el.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -468,8 +799,9 @@
|
||||
issues.forEach(issue => {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
dimension_defect: '치수불량',
|
||||
incoming_defect: '입고자재 불량'
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
const div = document.createElement('div');
|
||||
@@ -479,22 +811,34 @@
|
||||
<div class="space-y-4">
|
||||
<!-- 사진과 기본 정보 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="relative">
|
||||
${issue.photo_path ?
|
||||
`<img id="photo-${issue.id}" src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg shadow-sm">` :
|
||||
`<div id="photo-${issue.id}" class="w-32 h-32 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400 text-3xl"></i>
|
||||
<!-- 사진들 표시 -->
|
||||
<div class="flex gap-2">
|
||||
${issue.photo_path || issue.photo_path2 ?
|
||||
`${issue.photo_path ?
|
||||
`<div class="relative">
|
||||
<img id="photo-${issue.id}" src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path}')">
|
||||
</div>` : ''
|
||||
}
|
||||
${issue.photo_path2 ?
|
||||
`<div class="relative">
|
||||
<img id="photo2-${issue.id}" src="${issue.photo_path2}" class="w-32 h-32 object-cover rounded-lg shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path2}')">
|
||||
</div>` : ''
|
||||
}` :
|
||||
`<div class="relative">
|
||||
<div id="photo-${issue.id}" class="w-32 h-32 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400 text-3xl"></i>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('photoEdit-${issue.id}').click()"
|
||||
class="absolute bottom-1 right-1 p-1 bg-blue-500 text-white rounded-full hover:bg-blue-600 text-xs"
|
||||
title="사진 추가"
|
||||
>
|
||||
<i class="fas fa-camera"></i>
|
||||
</button>
|
||||
</div>`
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('photoEdit-${issue.id}').click()"
|
||||
class="absolute bottom-1 right-1 p-1 bg-blue-500 text-white rounded-full hover:bg-blue-600 text-xs"
|
||||
title="사진 변경"
|
||||
>
|
||||
<i class="fas fa-camera"></i>
|
||||
</button>
|
||||
<input type="file" id="photoEdit-${issue.id}" accept="image/*" class="hidden" onchange="handlePhotoChange(${issue.id}, this)">
|
||||
<input type="file" id="photoEdit-${issue.id}" accept="image/*" class="hidden" onchange="handlePhotoChange(${issue.id}, this)" multiple>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-3">
|
||||
@@ -507,8 +851,9 @@
|
||||
onchange="markAsModified(${issue.id})"
|
||||
>
|
||||
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
|
||||
<option value="dimension_defect" ${issue.category === 'dimension_defect' ? 'selected' : ''}>치수불량</option>
|
||||
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
|
||||
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
|
||||
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -592,14 +937,14 @@
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter.full_name || issue.reporter.username}</span>
|
||||
<span><i class="fas fa-calendar mr-1"></i>${new Date(issue.report_date).toLocaleDateString()}</span>
|
||||
<span><i class="fas fa-calendar mr-1"></i>${DateUtils.formatKST(issue.report_date)}</span>
|
||||
<span class="px-2 py-1 rounded-full text-xs ${
|
||||
issue.status === 'complete' ? 'bg-green-100 text-green-700' :
|
||||
issue.status === 'progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}">
|
||||
${issue.status === 'complete' ? '완료' : issue.status === 'progress' ? '진행중' : '신규'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,7 +991,7 @@
|
||||
} else if (issue?.work_hours && !isChanged) {
|
||||
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-green-100 text-green-700 cursor-default';
|
||||
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-1"></i>완료';
|
||||
} else {
|
||||
} else {
|
||||
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-blue-500 text-white hover:bg-blue-600';
|
||||
confirmBtn.innerHTML = '<i class="fas fa-clock mr-1"></i>확인';
|
||||
}
|
||||
@@ -691,13 +1036,13 @@
|
||||
const category = document.getElementById(`category-${issueId}`).value;
|
||||
const description = document.getElementById(`description-${issueId}`).value.trim();
|
||||
const workHours = parseFloat(document.getElementById(`workHours-${issueId}`).value) || 0;
|
||||
|
||||
// 유효성 검사
|
||||
if (!description) {
|
||||
|
||||
// 유효성 검사
|
||||
if (!description) {
|
||||
alert('설명은 필수 입력 항목입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 업데이트 데이터 준비
|
||||
const updateData = {
|
||||
category: category,
|
||||
@@ -719,7 +1064,7 @@
|
||||
|
||||
// 데이터 다시 로드
|
||||
await loadIssues();
|
||||
displayIssueList();
|
||||
displayIssueList();
|
||||
|
||||
// 성공 메시지 (작은 토스트 메시지)
|
||||
showToastMessage('저장되었습니다!', 'success');
|
||||
@@ -740,6 +1085,30 @@
|
||||
displayIssueList();
|
||||
}
|
||||
|
||||
// 이미지 모달 표시
|
||||
function showImageModal(imagePath) {
|
||||
if (!imagePath) return;
|
||||
|
||||
// 모달 HTML 생성
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50';
|
||||
modal.onclick = () => modal.remove();
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="relative max-w-4xl max-h-[90vh]">
|
||||
<img src="${imagePath}" class="max-w-full max-h-[90vh] object-contain rounded-lg">
|
||||
<button
|
||||
onclick="this.parentElement.parentElement.remove()"
|
||||
class="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full hover:bg-red-600"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
function showToastMessage(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
@@ -861,18 +1230,22 @@
|
||||
<!-- 카테고리별 분석 -->
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold text-gray-700 mb-3">부적합 카테고리별 분석</h4>
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">${categoryCount.material_missing || 0}</p>
|
||||
<div class="grid md:grid-cols-4 gap-4">
|
||||
<div class="bg-yellow-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-yellow-600">${categoryCount.material_missing || 0}</p>
|
||||
<p class="text-sm text-gray-600">자재누락</p>
|
||||
</div>
|
||||
<div class="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-orange-600">${categoryCount.dimension_defect || 0}</p>
|
||||
<p class="text-sm text-gray-600">치수불량</p>
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">${categoryCount.design_error || 0}</p>
|
||||
<p class="text-sm text-gray-600">설계미스</p>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-red-600">${categoryCount.incoming_defect || 0}</p>
|
||||
<p class="text-sm text-gray-600">입고자재 불량</p>
|
||||
</div>
|
||||
<div class="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-purple-600">${categoryCount.incoming_defect || 0}</p>
|
||||
<p class="text-sm text-gray-600">입고자재 불량</p>
|
||||
<p class="text-2xl font-bold text-purple-600">${categoryCount.inspection_miss || 0}</p>
|
||||
<p class="text-sm text-gray-600">검사미스</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -884,13 +1257,17 @@
|
||||
${issues.map(issue => {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
dimension_defect: '치수불량',
|
||||
incoming_defect: '입고자재 불량'
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
return `
|
||||
<div class="border rounded-lg p-4 mb-4 break-inside-avoid">
|
||||
<div class="flex gap-4">
|
||||
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||
<div class="flex gap-2">
|
||||
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h5 class="font-semibold text-gray-800 mb-2">${categoryNames[issue.category] || issue.category}</h5>
|
||||
<p class="text-gray-600 mb-2">${issue.description}</p>
|
||||
@@ -901,7 +1278,7 @@
|
||||
}</p>
|
||||
<p><span class="font-medium">작업시간:</span> ${issue.work_hours}시간</p>
|
||||
<p><span class="font-medium">보고자:</span> ${issue.reporter.full_name || issue.reporter.username}</p>
|
||||
<p><span class="font-medium">보고일:</span> ${new Date(issue.report_date).toLocaleDateString()}</p>
|
||||
<p><span class="font-medium">보고일:</span> ${DateUtils.formatKST(issue.report_date, true)}</p>
|
||||
</div>
|
||||
${issue.detail_notes ? `
|
||||
<div class="mt-2 p-2 bg-gray-50 rounded">
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/api.js?v=20250917"></script>
|
||||
<script src="/static/js/date-utils.js?v=20250917"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let issues = [];
|
||||
@@ -143,6 +144,30 @@
|
||||
setDateRange('week');
|
||||
});
|
||||
|
||||
// 이미지 모달 표시
|
||||
function showImageModal(imagePath) {
|
||||
if (!imagePath) return;
|
||||
|
||||
// 모달 HTML 생성
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50';
|
||||
modal.onclick = () => modal.remove();
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="relative max-w-4xl max-h-[90vh]">
|
||||
<img src="${imagePath}" class="max-w-full max-h-[90vh] object-contain rounded-lg">
|
||||
<button
|
||||
onclick="this.parentElement.parentElement.remove()"
|
||||
class="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full hover:bg-red-600"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 네비게이션 권한 업데이트
|
||||
function updateNavigation() {
|
||||
const listBtn = document.getElementById('listBtn');
|
||||
@@ -261,38 +286,39 @@
|
||||
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
dimension_defect: '치수불량',
|
||||
incoming_defect: '입고자재 불량'
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
const categoryColors = {
|
||||
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
dimension_defect: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
incoming_defect: 'bg-red-100 text-red-700 border-red-300'
|
||||
design_error: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
incoming_defect: 'bg-red-100 text-red-700 border-red-300',
|
||||
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
|
||||
};
|
||||
|
||||
// 시간순으로 나열
|
||||
container.innerHTML = issues.map(issue => {
|
||||
const date = new Date(issue.report_date);
|
||||
const dateStr = date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const dateStr = DateUtils.formatKST(issue.report_date, true);
|
||||
const relativeTime = DateUtils.getRelativeTime(issue.report_date);
|
||||
|
||||
return `
|
||||
<div class="flex gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors border-l-4 ${categoryColors[issue.category].split(' ')[2] || 'border-gray-300'}">
|
||||
<!-- 사진 -->
|
||||
${issue.photo_path ?
|
||||
`<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded shadow-sm flex-shrink-0">` :
|
||||
`<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
</div>`
|
||||
}
|
||||
<!-- 사진들 -->
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
${issue.photo_path ?
|
||||
`<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path}')">` : ''
|
||||
}
|
||||
${issue.photo_path2 ?
|
||||
`<img src="${issue.photo_path2}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path2}')">` : ''
|
||||
}
|
||||
${!issue.photo_path && !issue.photo_path2 ?
|
||||
`<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
</div>` : ''
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -308,7 +334,7 @@
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
${dateStr} ${timeStr}
|
||||
${dateStr} (${relativeTime})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -329,8 +355,9 @@
|
||||
<span class="font-medium text-blue-900">총 ${issues.length}건</span>
|
||||
<span class="text-blue-700 ml-3">
|
||||
자재누락: ${issues.filter(i => i.category === 'material_missing').length}건 |
|
||||
치수불량: ${issues.filter(i => i.category === 'dimension_defect').length}건 |
|
||||
입고자재 불량: ${issues.filter(i => i.category === 'incoming_defect').length}건
|
||||
설계미스: ${issues.filter(i => i.category === 'design_error').length}건 |
|
||||
입고자재 불량: ${issues.filter(i => i.category === 'incoming_defect').length}건 |
|
||||
검사미스: ${issues.filter(i => i.category === 'inspection_miss').length}건
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -48,6 +48,17 @@ async function apiRequest(endpoint, options = {}) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('API Error Response:', error);
|
||||
console.error('Error details:', JSON.stringify(error, null, 2));
|
||||
|
||||
// 422 에러의 경우 validation 에러 메시지 추출
|
||||
if (response.status === 422 && error.detail && Array.isArray(error.detail)) {
|
||||
const validationErrors = error.detail.map(err =>
|
||||
`${err.loc ? err.loc.join('.') : 'field'}: ${err.msg}`
|
||||
).join(', ');
|
||||
throw new Error(`입력값 검증 오류: ${validationErrors}`);
|
||||
}
|
||||
|
||||
throw new Error(error.detail || 'API 요청 실패');
|
||||
}
|
||||
|
||||
@@ -61,12 +72,16 @@ async function apiRequest(endpoint, options = {}) {
|
||||
// Auth API
|
||||
const AuthAPI = {
|
||||
login: async (username, password) => {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
body: formData.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -115,10 +130,20 @@ const AuthAPI = {
|
||||
|
||||
// Issues API
|
||||
const IssuesAPI = {
|
||||
create: (issueData) => apiRequest('/issues/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(issueData)
|
||||
}),
|
||||
create: async (issueData) => {
|
||||
// photos 배열 처리 (최대 2장)
|
||||
const dataToSend = {
|
||||
category: issueData.category,
|
||||
description: issueData.description,
|
||||
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
|
||||
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : null
|
||||
};
|
||||
|
||||
return apiRequest('/issues/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dataToSend)
|
||||
});
|
||||
},
|
||||
|
||||
getAll: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
|
||||
139
frontend/static/js/date-utils.js
Normal file
139
frontend/static/js/date-utils.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 날짜 관련 유틸리티 함수들
|
||||
* 한국 표준시(KST) 기준으로 처리
|
||||
*/
|
||||
|
||||
const DateUtils = {
|
||||
/**
|
||||
* UTC 시간을 KST로 변환
|
||||
* @param {string|Date} dateInput - UTC 날짜 문자열 또는 Date 객체
|
||||
* @returns {Date} KST 시간대의 Date 객체
|
||||
*/
|
||||
toKST(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
// UTC 시간에 9시간 추가 (KST = UTC+9)
|
||||
return new Date(date.getTime() + (date.getTimezoneOffset() * 60000) + (9 * 3600000));
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 KST 시간 가져오기
|
||||
* @returns {Date} 현재 KST 시간
|
||||
*/
|
||||
nowKST() {
|
||||
const now = new Date();
|
||||
return this.toKST(now);
|
||||
},
|
||||
|
||||
/**
|
||||
* KST 날짜를 한국식 문자열로 포맷
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @param {boolean} includeTime - 시간 포함 여부
|
||||
* @returns {string} 포맷된 날짜 문자열
|
||||
*/
|
||||
formatKST(dateInput, includeTime = false) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'Asia/Seoul'
|
||||
};
|
||||
|
||||
if (includeTime) {
|
||||
options.hour = '2-digit';
|
||||
options.minute = '2-digit';
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 상대적 시간 표시 (예: 3분 전, 2시간 전)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {string} 상대적 시간 문자열
|
||||
*/
|
||||
getRelativeTime(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return '방금 전';
|
||||
if (diffMin < 60) return `${diffMin}분 전`;
|
||||
if (diffHour < 24) return `${diffHour}시간 전`;
|
||||
if (diffDay < 7) return `${diffDay}일 전`;
|
||||
|
||||
return this.formatKST(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* 오늘 날짜인지 확인 (KST 기준)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isToday(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const today = new Date();
|
||||
|
||||
return date.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' }) ===
|
||||
today.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
},
|
||||
|
||||
/**
|
||||
* 이번 주인지 확인 (KST 기준, 월요일 시작)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isThisWeek(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const now = new Date();
|
||||
|
||||
// 주의 시작일 (월요일) 계산
|
||||
const startOfWeek = new Date(now);
|
||||
const day = startOfWeek.getDay();
|
||||
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
|
||||
startOfWeek.setDate(diff);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
// 주의 끝일 (일요일) 계산
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
return date >= startOfWeek && date <= endOfWeek;
|
||||
},
|
||||
|
||||
/**
|
||||
* ISO 문자열을 로컬 date input 값으로 변환
|
||||
* @param {string} isoString - ISO 날짜 문자열
|
||||
* @returns {string} YYYY-MM-DD 형식
|
||||
*/
|
||||
toDateInputValue(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 날짜 차이 계산 (일 단위)
|
||||
* @param {string|Date} date1 - 첫 번째 날짜
|
||||
* @param {string|Date} date2 - 두 번째 날짜
|
||||
* @returns {number} 일 수 차이
|
||||
*/
|
||||
getDaysDiff(date1, date2) {
|
||||
const d1 = typeof date1 === 'string' ? new Date(date1) : date1;
|
||||
const d2 = typeof date2 === 'string' ? new Date(date2) : date2;
|
||||
const diffMs = Math.abs(d2 - d1);
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.DateUtils = DateUtils;
|
||||
134
frontend/static/js/image-utils.js
Normal file
134
frontend/static/js/image-utils.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 이미지 압축 및 최적화 유틸리티
|
||||
*/
|
||||
|
||||
const ImageUtils = {
|
||||
/**
|
||||
* 이미지를 압축하고 리사이즈
|
||||
* @param {File|Blob|String} source - 이미지 파일, Blob 또는 base64 문자열
|
||||
* @param {Object} options - 압축 옵션
|
||||
* @returns {Promise<String>} - 압축된 base64 이미지
|
||||
*/
|
||||
async compressImage(source, options = {}) {
|
||||
const {
|
||||
maxWidth = 1024, // 최대 너비
|
||||
maxHeight = 1024, // 최대 높이
|
||||
quality = 0.7, // JPEG 품질 (0-1)
|
||||
format = 'jpeg' // 출력 형식
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let img = new Image();
|
||||
|
||||
// 이미지 로드 완료 시
|
||||
img.onload = () => {
|
||||
// Canvas 생성
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 리사이즈 계산
|
||||
let { width, height } = this.calculateDimensions(
|
||||
img.width,
|
||||
img.height,
|
||||
maxWidth,
|
||||
maxHeight
|
||||
);
|
||||
|
||||
// Canvas 크기 설정
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// 압축된 이미지를 base64로 변환
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('이미지 압축 실패'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
}, `image/${format}`, quality);
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error('이미지 로드 실패'));
|
||||
|
||||
// 소스 타입에 따라 처리
|
||||
if (typeof source === 'string') {
|
||||
// Base64 문자열인 경우
|
||||
img.src = source;
|
||||
} else if (source instanceof File || source instanceof Blob) {
|
||||
// File 또는 Blob인 경우
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
img.src = reader.result;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(source);
|
||||
} else {
|
||||
reject(new Error('지원하지 않는 이미지 형식'));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 크기 계산 (비율 유지)
|
||||
*/
|
||||
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
|
||||
// 원본 크기가 제한 내에 있으면 그대로 반환
|
||||
if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
|
||||
return { width: originalWidth, height: originalHeight };
|
||||
}
|
||||
|
||||
// 비율 계산
|
||||
const widthRatio = maxWidth / originalWidth;
|
||||
const heightRatio = maxHeight / originalHeight;
|
||||
const ratio = Math.min(widthRatio, heightRatio);
|
||||
|
||||
return {
|
||||
width: Math.round(originalWidth * ratio),
|
||||
height: Math.round(originalHeight * ratio)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 파일 크기를 사람이 읽을 수 있는 형식으로 변환
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
/**
|
||||
* Base64 문자열의 크기 계산
|
||||
*/
|
||||
getBase64Size(base64String) {
|
||||
const base64Length = base64String.length - (base64String.indexOf(',') + 1);
|
||||
const padding = (base64String.charAt(base64String.length - 2) === '=') ? 2 :
|
||||
((base64String.charAt(base64String.length - 1) === '=') ? 1 : 0);
|
||||
return (base64Length * 0.75) - padding;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 미리보기 생성 (썸네일)
|
||||
*/
|
||||
async createThumbnail(source, size = 150) {
|
||||
return this.compressImage(source, {
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
quality: 0.8
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.ImageUtils = ImageUtils;
|
||||
@@ -23,6 +23,14 @@ http {
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# JS/CSS 파일 캐시 제어
|
||||
location ~* \.(js|css)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
add_header Pragma "no-cache";
|
||||
}
|
||||
|
||||
# API 프록시
|
||||
location /api/ {
|
||||
|
||||
Reference in New Issue
Block a user