feat: 사진 업로드 기능 개선 및 카테고리 업데이트

- 사진 2장까지 업로드 지원
- 카메라 촬영 + 갤러리 선택 분리
- 이미지 압축 및 최적화 (ImageUtils)
- iPhone .mpo 파일 JPEG 변환 지원
- 카테고리 변경: 치수불량 → 설계미스, 검사미스 추가
- KST 시간대 설정
- URL 해시 처리로 목록관리 페이지 이동 개선
- 로그인 OAuth2 form-data 형식 수정
- 업로드 속도 개선 및 프로그레스바 추가
This commit is contained in:
hyungi
2025-09-18 07:00:28 +09:00
parent f6bdb68d19
commit 44e2fb2e44
32 changed files with 988 additions and 177 deletions

View File

@@ -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")

View File

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

View File

@@ -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 앱 생성

View File

@@ -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);

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

View File

@@ -0,0 +1,8 @@
-- 카테고리 업데이트 마이그레이션
-- dimension_defect를 design_error로 변경
UPDATE issues
SET category = 'design_error'
WHERE category = 'dimension_defect';
-- PostgreSQL enum 타입 업데이트 (필요한 경우)
-- 기존 enum 타입 확인 후 필요시 재생성

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

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}"

View File

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

View File

@@ -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 = [];

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>
`;

View File

@@ -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();

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

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

View File

@@ -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/ {