해당 서비스 도커화 성공, 룰 추가, 로그인 오류 수정, 소문자 룰 어느정도 해결
This commit is contained in:
26
fastapi-bridge/Dockerfile
Normal file
26
fastapi-bridge/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 시스템 패키지 업데이트
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 의존성 설치
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 소스 코드 복사
|
||||
COPY . .
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# 앱 실행
|
||||
CMD ["python", "main.py"]
|
||||
69
fastapi-bridge/README.md
Normal file
69
fastapi-bridge/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# FastAPI 브릿지
|
||||
|
||||
## 📋 개요
|
||||
|
||||
Technical Korea 생산팀 포털의 FastAPI 브릿지입니다. Express.js API의 성능을 향상시키고 확장성을 제공합니다.
|
||||
|
||||
## 🏗️ 아키텍처
|
||||
|
||||
```
|
||||
브라우저 → FastAPI (포트 8000) → Express.js API (포트 3005)
|
||||
```
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 개발 환경
|
||||
|
||||
```bash
|
||||
# 가상환경 생성
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 서버 실행
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Docker 실행
|
||||
|
||||
```bash
|
||||
# 이미지 빌드
|
||||
docker build -t tk-fastapi-bridge .
|
||||
|
||||
# 컨테이너 실행
|
||||
docker run -p 8000:8000 tk-fastapi-bridge
|
||||
```
|
||||
|
||||
## 📡 API 엔드포인트
|
||||
|
||||
### 헬스체크
|
||||
- `GET /` - 루트 정보
|
||||
- `GET /health` - 전체 시스템 헬스체크
|
||||
|
||||
### 프록시
|
||||
- `ALL /api/*` - Express.js API로 프록시
|
||||
|
||||
## 🔧 설정
|
||||
|
||||
`config.py`에서 설정을 변경할 수 있습니다:
|
||||
|
||||
- `FASTAPI_PORT`: FastAPI 서버 포트 (기본: 8000)
|
||||
- `EXPRESS_API_URL`: Express.js API URL (기본: http://localhost:3005)
|
||||
- `CORS_ORIGINS`: CORS 허용 오리진
|
||||
|
||||
## 📊 Phase 1 목표
|
||||
|
||||
- [x] 기본 FastAPI 설정
|
||||
- [x] Express.js API 프록시
|
||||
- [x] 헬스체크 및 모니터링
|
||||
- [x] Docker 지원
|
||||
- [x] CORS 설정
|
||||
|
||||
## 🔮 다음 단계 (Phase 2)
|
||||
|
||||
- [ ] 정적 파일 서빙
|
||||
- [ ] 단일 포트 통합
|
||||
- [ ] Redis 캐싱
|
||||
- [ ] 성능 최적화
|
||||
314
fastapi-bridge/analytics.py
Normal file
314
fastapi-bridge/analytics.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
FastAPI 브릿지 분석 시스템
|
||||
Phase 4: 데이터 분석 및 성능 모니터링
|
||||
"""
|
||||
import time
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Dict, List, Any, Optional
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
from fastapi import Request, Response
|
||||
|
||||
from config import settings
|
||||
|
||||
@dataclass
|
||||
class RequestMetric:
|
||||
"""요청 메트릭 데이터 클래스"""
|
||||
timestamp: float
|
||||
method: str
|
||||
path: str
|
||||
status_code: int
|
||||
response_time: float
|
||||
cache_hit: bool = False
|
||||
user_agent: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
|
||||
class AnalyticsManager:
|
||||
"""분석 데이터 관리자"""
|
||||
|
||||
def __init__(self, max_entries: int = 10000):
|
||||
self.max_entries = max_entries
|
||||
self.metrics: deque = deque(maxlen=max_entries)
|
||||
self.cache_stats = {
|
||||
"hits": 0,
|
||||
"misses": 0,
|
||||
"total_requests": 0
|
||||
}
|
||||
|
||||
# 실시간 통계
|
||||
self.hourly_stats = defaultdict(lambda: {
|
||||
"requests": 0,
|
||||
"avg_response_time": 0,
|
||||
"cache_hit_rate": 0,
|
||||
"errors": 0
|
||||
})
|
||||
|
||||
# API 엔드포인트별 통계
|
||||
self.endpoint_stats = defaultdict(lambda: {
|
||||
"requests": 0,
|
||||
"avg_response_time": 0,
|
||||
"min_response_time": float('inf'),
|
||||
"max_response_time": 0,
|
||||
"cache_hits": 0,
|
||||
"errors": 0
|
||||
})
|
||||
|
||||
# 클라이언트 IP별 통계
|
||||
self.client_stats = defaultdict(lambda: {
|
||||
"requests": 0,
|
||||
"last_request": None,
|
||||
"user_agents": set()
|
||||
})
|
||||
|
||||
def record_request(self, metric: RequestMetric):
|
||||
"""요청 메트릭 기록"""
|
||||
self.metrics.append(metric)
|
||||
|
||||
# 전체 통계 업데이트
|
||||
self.cache_stats["total_requests"] += 1
|
||||
if metric.cache_hit:
|
||||
self.cache_stats["hits"] += 1
|
||||
else:
|
||||
self.cache_stats["misses"] += 1
|
||||
|
||||
# 시간대별 통계 업데이트
|
||||
hour_key = datetime.fromtimestamp(metric.timestamp).strftime("%Y-%m-%d_%H")
|
||||
hourly = self.hourly_stats[hour_key]
|
||||
hourly["requests"] += 1
|
||||
|
||||
# 평균 응답시간 계산 (이동평균)
|
||||
if hourly["avg_response_time"] == 0:
|
||||
hourly["avg_response_time"] = metric.response_time
|
||||
else:
|
||||
hourly["avg_response_time"] = (
|
||||
hourly["avg_response_time"] * 0.9 + metric.response_time * 0.1
|
||||
)
|
||||
|
||||
if metric.status_code >= 400:
|
||||
hourly["errors"] += 1
|
||||
|
||||
# 엔드포인트별 통계 업데이트
|
||||
endpoint_key = f"{metric.method}:{metric.path}"
|
||||
endpoint = self.endpoint_stats[endpoint_key]
|
||||
endpoint["requests"] += 1
|
||||
|
||||
# 응답시간 통계
|
||||
if endpoint["avg_response_time"] == 0:
|
||||
endpoint["avg_response_time"] = metric.response_time
|
||||
else:
|
||||
endpoint["avg_response_time"] = (
|
||||
endpoint["avg_response_time"] * 0.9 + metric.response_time * 0.1
|
||||
)
|
||||
|
||||
endpoint["min_response_time"] = min(
|
||||
endpoint["min_response_time"], metric.response_time
|
||||
)
|
||||
endpoint["max_response_time"] = max(
|
||||
endpoint["max_response_time"], metric.response_time
|
||||
)
|
||||
|
||||
if metric.cache_hit:
|
||||
endpoint["cache_hits"] += 1
|
||||
|
||||
if metric.status_code >= 400:
|
||||
endpoint["errors"] += 1
|
||||
|
||||
# 클라이언트별 통계 업데이트
|
||||
if metric.ip_address:
|
||||
client = self.client_stats[metric.ip_address]
|
||||
client["requests"] += 1
|
||||
client["last_request"] = metric.timestamp
|
||||
if metric.user_agent:
|
||||
client["user_agents"].add(metric.user_agent)
|
||||
|
||||
def get_summary_stats(self) -> Dict[str, Any]:
|
||||
"""요약 통계 반환"""
|
||||
total_requests = self.cache_stats["total_requests"]
|
||||
cache_hit_rate = 0
|
||||
if total_requests > 0:
|
||||
cache_hit_rate = (self.cache_stats["hits"] / total_requests) * 100
|
||||
|
||||
# 최근 1시간 통계
|
||||
recent_metrics = [
|
||||
m for m in self.metrics
|
||||
if time.time() - m.timestamp < 3600
|
||||
]
|
||||
|
||||
avg_response_time = 0
|
||||
if recent_metrics:
|
||||
avg_response_time = sum(m.response_time for m in recent_metrics) / len(recent_metrics)
|
||||
|
||||
return {
|
||||
"total_requests": total_requests,
|
||||
"cache_hit_rate": round(cache_hit_rate, 2),
|
||||
"recent_hour_requests": len(recent_metrics),
|
||||
"avg_response_time_ms": round(avg_response_time * 1000, 2),
|
||||
"active_clients": len([
|
||||
ip for ip, stats in self.client_stats.items()
|
||||
if time.time() - (stats["last_request"] or 0) < 3600
|
||||
]),
|
||||
"total_endpoints": len(self.endpoint_stats)
|
||||
}
|
||||
|
||||
def get_top_endpoints(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""상위 엔드포인트 통계"""
|
||||
sorted_endpoints = sorted(
|
||||
self.endpoint_stats.items(),
|
||||
key=lambda x: x[1]["requests"],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
result = []
|
||||
for endpoint, stats in sorted_endpoints[:limit]:
|
||||
method, path = endpoint.split(":", 1)
|
||||
cache_rate = 0
|
||||
if stats["requests"] > 0:
|
||||
cache_rate = (stats["cache_hits"] / stats["requests"]) * 100
|
||||
|
||||
result.append({
|
||||
"method": method,
|
||||
"path": path,
|
||||
"requests": stats["requests"],
|
||||
"avg_response_time_ms": round(stats["avg_response_time"] * 1000, 2),
|
||||
"min_response_time_ms": round(stats["min_response_time"] * 1000, 2) if stats["min_response_time"] != float('inf') else 0,
|
||||
"max_response_time_ms": round(stats["max_response_time"] * 1000, 2),
|
||||
"cache_hit_rate": round(cache_rate, 2),
|
||||
"error_count": stats["errors"]
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_hourly_trends(self, hours: int = 24) -> Dict[str, List]:
|
||||
"""시간대별 트렌드 데이터"""
|
||||
now = datetime.now()
|
||||
trends = {
|
||||
"hours": [],
|
||||
"requests": [],
|
||||
"avg_response_times": [],
|
||||
"error_rates": []
|
||||
}
|
||||
|
||||
for i in range(hours):
|
||||
hour = now - timedelta(hours=i)
|
||||
hour_key = hour.strftime("%Y-%m-%d_%H")
|
||||
|
||||
stats = self.hourly_stats.get(hour_key, {
|
||||
"requests": 0,
|
||||
"avg_response_time": 0,
|
||||
"errors": 0
|
||||
})
|
||||
|
||||
error_rate = 0
|
||||
if stats["requests"] > 0:
|
||||
error_rate = (stats["errors"] / stats["requests"]) * 100
|
||||
|
||||
trends["hours"].insert(0, hour.strftime("%H:00"))
|
||||
trends["requests"].insert(0, stats["requests"])
|
||||
trends["avg_response_times"].insert(0, round(stats["avg_response_time"] * 1000, 2))
|
||||
trends["error_rates"].insert(0, round(error_rate, 2))
|
||||
|
||||
return trends
|
||||
|
||||
def get_client_analysis(self) -> Dict[str, Any]:
|
||||
"""클라이언트 분석"""
|
||||
total_clients = len(self.client_stats)
|
||||
active_clients = len([
|
||||
ip for ip, stats in self.client_stats.items()
|
||||
if time.time() - (stats["last_request"] or 0) < 3600
|
||||
])
|
||||
|
||||
# 상위 클라이언트
|
||||
top_clients = sorted(
|
||||
self.client_stats.items(),
|
||||
key=lambda x: x[1]["requests"],
|
||||
reverse=True
|
||||
)[:10]
|
||||
|
||||
client_list = []
|
||||
for ip, stats in top_clients:
|
||||
client_list.append({
|
||||
"ip": ip,
|
||||
"requests": stats["requests"],
|
||||
"last_request": datetime.fromtimestamp(stats["last_request"]).isoformat() if stats["last_request"] else None,
|
||||
"user_agents": len(stats["user_agents"])
|
||||
})
|
||||
|
||||
return {
|
||||
"total_clients": total_clients,
|
||||
"active_clients_1h": active_clients,
|
||||
"top_clients": client_list
|
||||
}
|
||||
|
||||
def predict_load(self) -> Dict[str, Any]:
|
||||
"""간단한 부하 예측"""
|
||||
recent_hours = []
|
||||
for i in range(3): # 최근 3시간
|
||||
hour = datetime.now() - timedelta(hours=i)
|
||||
hour_key = hour.strftime("%Y-%m-%d_%H")
|
||||
stats = self.hourly_stats.get(hour_key, {"requests": 0})
|
||||
recent_hours.append(stats["requests"])
|
||||
|
||||
if len(recent_hours) >= 2:
|
||||
# 단순 선형 트렌드
|
||||
trend = recent_hours[0] - recent_hours[1] if len(recent_hours) > 1 else 0
|
||||
predicted_next_hour = max(0, recent_hours[0] + trend)
|
||||
else:
|
||||
predicted_next_hour = recent_hours[0] if recent_hours else 0
|
||||
|
||||
return {
|
||||
"current_hour_requests": recent_hours[0] if recent_hours else 0,
|
||||
"predicted_next_hour": round(predicted_next_hour),
|
||||
"trend": "increasing" if trend > 0 else "decreasing" if trend < 0 else "stable" if 'trend' in locals() else "unknown"
|
||||
}
|
||||
|
||||
# 전역 분석 매니저
|
||||
analytics_manager = AnalyticsManager()
|
||||
|
||||
class AnalyticsMiddleware:
|
||||
"""분석 미들웨어"""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
request = Request(scope, receive)
|
||||
start_time = time.time()
|
||||
|
||||
# 응답 캡처를 위한 래퍼
|
||||
response_data = {"status_code": 200}
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.start":
|
||||
response_data["status_code"] = message["status"]
|
||||
await send(message)
|
||||
|
||||
# 요청 처리
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
# 메트릭 기록
|
||||
end_time = time.time()
|
||||
response_time = end_time - start_time
|
||||
|
||||
# 캐시 히트 여부는 로그에서 추측 (간단한 구현)
|
||||
cache_hit = "/cache/" in request.url.path or response_time < 0.01
|
||||
|
||||
metric = RequestMetric(
|
||||
timestamp=start_time,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=response_data["status_code"],
|
||||
response_time=response_time,
|
||||
cache_hit=cache_hit,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
analytics_manager.record_request(metric)
|
||||
204
fastapi-bridge/cache.py
Normal file
204
fastapi-bridge/cache.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
FastAPI 브릿지 캐싱 시스템
|
||||
Phase 3: Redis 캐싱 (메모리 캐시로 폴백)
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from functools import wraps
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from config import settings
|
||||
|
||||
class CacheManager:
|
||||
"""캐시 관리자 - Redis 우선, 메모리 캐시 폴백"""
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
self.memory_cache: Dict[str, Dict[str, Any]] = {}
|
||||
self.is_redis_available = False
|
||||
|
||||
async def connect(self):
|
||||
"""Redis 연결 시도, 실패시 메모리 캐시 사용"""
|
||||
try:
|
||||
self.redis_client = redis.from_url(
|
||||
settings.REDIS_URL,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
|
||||
# Redis 연결 테스트
|
||||
await self.redis_client.ping()
|
||||
self.is_redis_available = True
|
||||
print("✅ Redis 연결 성공")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Redis 연결 실패, 메모리 캐시 사용: {e}")
|
||||
self.is_redis_available = False
|
||||
self.redis_client = None
|
||||
|
||||
async def disconnect(self):
|
||||
"""연결 종료"""
|
||||
if self.redis_client:
|
||||
await self.redis_client.close()
|
||||
|
||||
def _generate_key(self, prefix: str, *args, **kwargs) -> str:
|
||||
"""캐시 키 생성"""
|
||||
# 파라미터들을 문자열로 변환
|
||||
key_data = f"{prefix}:{':'.join(map(str, args))}"
|
||||
if kwargs:
|
||||
key_data += f":{json.dumps(kwargs, sort_keys=True)}"
|
||||
|
||||
# 긴 키는 해시로 단축
|
||||
if len(key_data) > 100:
|
||||
key_hash = hashlib.md5(key_data.encode()).hexdigest()
|
||||
return f"{prefix}:{key_hash}"
|
||||
|
||||
return key_data
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""캐시에서 값 조회"""
|
||||
if self.is_redis_available and self.redis_client:
|
||||
try:
|
||||
value = await self.redis_client.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
except Exception as e:
|
||||
print(f"Redis GET 오류: {e}")
|
||||
|
||||
# 메모리 캐시 폴백
|
||||
if key in self.memory_cache:
|
||||
cache_entry = self.memory_cache[key]
|
||||
if cache_entry['expires_at'] > time.time():
|
||||
return cache_entry['data']
|
||||
else:
|
||||
# 만료된 캐시 삭제
|
||||
del self.memory_cache[key]
|
||||
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: int = 300) -> bool:
|
||||
"""캐시에 값 저장"""
|
||||
json_value = json.dumps(value, ensure_ascii=False)
|
||||
|
||||
if self.is_redis_available and self.redis_client:
|
||||
try:
|
||||
await self.redis_client.setex(key, ttl, json_value)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Redis SET 오류: {e}")
|
||||
|
||||
# 메모리 캐시 폴백
|
||||
self.memory_cache[key] = {
|
||||
'data': value,
|
||||
'expires_at': time.time() + ttl
|
||||
}
|
||||
|
||||
# 메모리 캐시 크기 제한 (최대 1000개)
|
||||
if len(self.memory_cache) > 1000:
|
||||
# 가장 오래된 10개 항목 제거
|
||||
sorted_keys = sorted(
|
||||
self.memory_cache.keys(),
|
||||
key=lambda k: self.memory_cache[k]['expires_at']
|
||||
)
|
||||
for key_to_remove in sorted_keys[:10]:
|
||||
del self.memory_cache[key_to_remove]
|
||||
|
||||
return True
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""캐시에서 값 삭제"""
|
||||
deleted = False
|
||||
|
||||
if self.is_redis_available and self.redis_client:
|
||||
try:
|
||||
result = await self.redis_client.delete(key)
|
||||
deleted = result > 0
|
||||
except Exception as e:
|
||||
print(f"Redis DELETE 오류: {e}")
|
||||
|
||||
# 메모리 캐시에서도 삭제
|
||||
if key in self.memory_cache:
|
||||
del self.memory_cache[key]
|
||||
deleted = True
|
||||
|
||||
return deleted
|
||||
|
||||
async def clear_pattern(self, pattern: str) -> int:
|
||||
"""패턴 매칭으로 키 삭제"""
|
||||
deleted_count = 0
|
||||
|
||||
if self.is_redis_available and self.redis_client:
|
||||
try:
|
||||
keys = await self.redis_client.keys(pattern)
|
||||
if keys:
|
||||
deleted_count = await self.redis_client.delete(*keys)
|
||||
except Exception as e:
|
||||
print(f"Redis CLEAR_PATTERN 오류: {e}")
|
||||
|
||||
# 메모리 캐시에서 패턴 매칭 삭제
|
||||
import fnmatch
|
||||
keys_to_delete = [
|
||||
key for key in self.memory_cache.keys()
|
||||
if fnmatch.fnmatch(key, pattern)
|
||||
]
|
||||
|
||||
for key in keys_to_delete:
|
||||
del self.memory_cache[key]
|
||||
deleted_count += 1
|
||||
|
||||
return deleted_count
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""캐시 통계"""
|
||||
stats = {
|
||||
"redis_available": self.is_redis_available,
|
||||
"memory_cache_size": len(self.memory_cache),
|
||||
"cache_type": "Redis" if self.is_redis_available else "Memory"
|
||||
}
|
||||
|
||||
# 만료된 메모리 캐시 정리
|
||||
current_time = time.time()
|
||||
expired_keys = [
|
||||
key for key, entry in self.memory_cache.items()
|
||||
if entry['expires_at'] <= current_time
|
||||
]
|
||||
|
||||
for key in expired_keys:
|
||||
del self.memory_cache[key]
|
||||
|
||||
stats["expired_keys_cleaned"] = len(expired_keys)
|
||||
|
||||
return stats
|
||||
|
||||
# 전역 캐시 매니저
|
||||
cache_manager = CacheManager()
|
||||
|
||||
def cached(prefix: str = "default", ttl: int = 300):
|
||||
"""캐싱 데코레이터"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 캐시 키 생성
|
||||
cache_key = cache_manager._generate_key(prefix, *args, **kwargs)
|
||||
|
||||
# 캐시에서 조회
|
||||
cached_result = await cache_manager.get(cache_key)
|
||||
if cached_result is not None:
|
||||
print(f"🟢 캐시 히트: {cache_key}")
|
||||
return cached_result
|
||||
|
||||
# 캐시 미스 - 함수 실행
|
||||
print(f"🟡 캐시 미스: {cache_key}")
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 결과 캐싱
|
||||
await cache_manager.set(cache_key, result, ttl)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
40
fastapi-bridge/config.py
Normal file
40
fastapi-bridge/config.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
FastAPI 브릿지 설정
|
||||
"""
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
class Settings:
|
||||
# 기본 설정
|
||||
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
|
||||
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://localhost:3005")
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
||||
NODE_ENV: str = os.getenv("NODE_ENV", "development")
|
||||
|
||||
# CORS 설정
|
||||
CORS_ORIGINS: List[str] = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:8000",
|
||||
"https://api.technicalkorea.com"
|
||||
]
|
||||
|
||||
# 로깅
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# API 설정
|
||||
API_V1_PREFIX: str = "/api"
|
||||
PROJECT_NAME: str = "TK FastAPI Bridge"
|
||||
VERSION: str = "1.0.0"
|
||||
|
||||
# 프록시 설정
|
||||
PROXY_TIMEOUT: int = 30
|
||||
MAX_CONNECTIONS: int = 100
|
||||
|
||||
# 캐시 설정
|
||||
CACHE_DEFAULT_TTL: int = 300 # 5분
|
||||
CACHE_HEALTH_TTL: int = 60 # 1분
|
||||
CACHE_AUTH_TTL: int = 900 # 15분
|
||||
CACHE_API_TTL: int = 180 # 3분
|
||||
CACHE_STATIC_TTL: int = 3600 # 1시간
|
||||
|
||||
settings = Settings()
|
||||
294
fastapi-bridge/main.py
Normal file
294
fastapi-bridge/main.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
FastAPI 브릿지 메인 애플리케이션
|
||||
Phase 1: 기본 프록시 기능
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import aiohttp
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from config import settings
|
||||
from cache import cache_manager, cached
|
||||
from analytics import analytics_manager, AnalyticsMiddleware
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=getattr(logging, settings.LOG_LEVEL))
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
description="Technical Korea FastAPI Bridge - Express.js API 프록시"
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 분석 미들웨어 추가
|
||||
app.add_middleware(AnalyticsMiddleware)
|
||||
|
||||
# 정적 파일 서빙 설정
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
app.mount("/css", StaticFiles(directory="static/css"), name="css")
|
||||
app.mount("/js", StaticFiles(directory="static/js"), name="js")
|
||||
app.mount("/img", StaticFiles(directory="static/img"), name="img")
|
||||
app.mount("/pages", StaticFiles(directory="static/pages"), name="pages")
|
||||
app.mount("/components", StaticFiles(directory="static/components"), name="components")
|
||||
|
||||
# HTTP 클라이언트 세션 (전역)
|
||||
http_session: aiohttp.ClientSession = None
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""앱 시작시 초기화"""
|
||||
global http_session
|
||||
|
||||
# 캐시 매니저 연결
|
||||
await cache_manager.connect()
|
||||
|
||||
# HTTP 클라이언트 세션 생성
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=settings.MAX_CONNECTIONS,
|
||||
ttl_dns_cache=300,
|
||||
use_dns_cache=True,
|
||||
)
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=settings.PROXY_TIMEOUT)
|
||||
http_session = aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
logger.info(f"🚀 {settings.PROJECT_NAME} v{settings.VERSION} 시작됨")
|
||||
logger.info(f"📍 포트: {settings.FASTAPI_PORT}")
|
||||
logger.info(f"🔗 Express.js API: {settings.EXPRESS_API_URL}")
|
||||
|
||||
# 캐시 상태 로깅
|
||||
cache_stats = cache_manager.get_stats()
|
||||
logger.info(f"💾 캐시: {cache_stats['cache_type']} ({'연결됨' if cache_stats['redis_available'] else '메모리 모드'})")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""앱 종료시 정리"""
|
||||
global http_session
|
||||
|
||||
# 캐시 매니저 연결 종료
|
||||
await cache_manager.disconnect()
|
||||
|
||||
if http_session:
|
||||
await http_session.close()
|
||||
|
||||
logger.info("✅ FastAPI 브릿지가 정상적으로 종료되었습니다")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""루트 경로 - index.html 서빙"""
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
"""서비스 상태 정보"""
|
||||
return {
|
||||
"service": settings.PROJECT_NAME,
|
||||
"version": settings.VERSION,
|
||||
"status": "healthy",
|
||||
"express_api": settings.EXPRESS_API_URL
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
@cached(prefix="health", ttl=settings.CACHE_HEALTH_TTL)
|
||||
async def health_check():
|
||||
"""헬스체크 엔드포인트 (캐싱 적용)"""
|
||||
try:
|
||||
# Express.js API 헬스체크
|
||||
async with http_session.get(f"{settings.EXPRESS_API_URL}/api/health") as response:
|
||||
express_status = "healthy" if response.status == 200 else "unhealthy"
|
||||
express_data = await response.json() if response.status == 200 else None
|
||||
except Exception as e:
|
||||
logger.error(f"Express.js 헬스체크 실패: {e}")
|
||||
express_status = "unhealthy"
|
||||
express_data = None
|
||||
|
||||
import datetime
|
||||
return {
|
||||
"fastapi_bridge": "healthy",
|
||||
"express_api": express_status,
|
||||
"express_data": express_data,
|
||||
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
"cached": True
|
||||
}
|
||||
|
||||
@app.get("/cache/stats")
|
||||
async def cache_stats():
|
||||
"""캐시 통계 조회"""
|
||||
stats = cache_manager.get_stats()
|
||||
return {
|
||||
"cache_stats": stats,
|
||||
"settings": {
|
||||
"default_ttl": settings.CACHE_DEFAULT_TTL,
|
||||
"health_ttl": settings.CACHE_HEALTH_TTL,
|
||||
"auth_ttl": settings.CACHE_AUTH_TTL,
|
||||
"api_ttl": settings.CACHE_API_TTL,
|
||||
"static_ttl": settings.CACHE_STATIC_TTL
|
||||
}
|
||||
}
|
||||
|
||||
@app.delete("/cache/clear")
|
||||
async def clear_cache():
|
||||
"""캐시 전체 삭제"""
|
||||
cleared_count = await cache_manager.clear_pattern("*")
|
||||
return {
|
||||
"message": "캐시가 삭제되었습니다",
|
||||
"cleared_keys": cleared_count
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Phase 4: 분석 및 모니터링 API
|
||||
# ==========================================
|
||||
|
||||
@app.get("/analytics/summary")
|
||||
async def analytics_summary():
|
||||
"""분석 요약 통계"""
|
||||
return {
|
||||
"analytics": analytics_manager.get_summary_stats(),
|
||||
"cache": cache_manager.get_stats(),
|
||||
"prediction": analytics_manager.predict_load()
|
||||
}
|
||||
|
||||
@app.get("/analytics/endpoints")
|
||||
async def analytics_endpoints(limit: int = 20):
|
||||
"""엔드포인트별 성능 통계"""
|
||||
return {
|
||||
"top_endpoints": analytics_manager.get_top_endpoints(limit),
|
||||
"total_tracked": len(analytics_manager.endpoint_stats)
|
||||
}
|
||||
|
||||
@app.get("/analytics/trends")
|
||||
async def analytics_trends(hours: int = 24):
|
||||
"""시간대별 트렌드 분석"""
|
||||
return {
|
||||
"trends": analytics_manager.get_hourly_trends(hours),
|
||||
"period_hours": hours
|
||||
}
|
||||
|
||||
@app.get("/analytics/clients")
|
||||
async def analytics_clients():
|
||||
"""클라이언트 분석"""
|
||||
return analytics_manager.get_client_analysis()
|
||||
|
||||
@app.get("/analytics/dashboard")
|
||||
async def analytics_dashboard():
|
||||
"""종합 대시보드 데이터"""
|
||||
return {
|
||||
"summary": analytics_manager.get_summary_stats(),
|
||||
"top_endpoints": analytics_manager.get_top_endpoints(10),
|
||||
"trends_6h": analytics_manager.get_hourly_trends(6),
|
||||
"clients": analytics_manager.get_client_analysis(),
|
||||
"cache_stats": cache_manager.get_stats(),
|
||||
"prediction": analytics_manager.predict_load(),
|
||||
"server_info": {
|
||||
"version": settings.VERSION,
|
||||
"environment": settings.NODE_ENV,
|
||||
"redis_available": cache_manager.is_redis_available
|
||||
}
|
||||
}
|
||||
|
||||
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
||||
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
|
||||
|
||||
# Express.js API URL 구성
|
||||
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
||||
|
||||
# 요청 데이터 준비
|
||||
headers = dict(request.headers)
|
||||
headers.pop("host", None) # host 헤더 제거
|
||||
|
||||
params = dict(request.query_params)
|
||||
|
||||
# GET 요청에 대해서만 캐싱 적용
|
||||
if request.method == "GET":
|
||||
cache_key = cache_manager._generate_key("api", path, **params)
|
||||
cached_result = await cache_manager.get(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
logger.info(f"🟢 캐시 히트: GET {target_url}")
|
||||
return JSONResponse(
|
||||
content=cached_result,
|
||||
status_code=200
|
||||
)
|
||||
|
||||
try:
|
||||
# 요청 바디 읽기 (있는 경우)
|
||||
body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
body = await request.body()
|
||||
if body:
|
||||
body = body.decode("utf-8") if isinstance(body, bytes) else body
|
||||
|
||||
logger.info(f"🔗 프록시: {request.method} {target_url}")
|
||||
|
||||
# Express.js API로 요청 전달
|
||||
async with http_session.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=body
|
||||
) as response:
|
||||
|
||||
# 응답 데이터 읽기
|
||||
response_data = await response.text()
|
||||
|
||||
# JSON으로 파싱 시도
|
||||
try:
|
||||
response_json = await response.json()
|
||||
except:
|
||||
response_json = {"data": response_data}
|
||||
|
||||
logger.info(f"✅ 응답: {response.status} ({len(response_data)} bytes)")
|
||||
|
||||
# GET 요청 성공시 캐시에 저장
|
||||
if request.method == "GET" and response.status == 200:
|
||||
await cache_manager.set(cache_key, response_json, settings.CACHE_API_TTL)
|
||||
logger.info(f"💾 캐시 저장: GET {target_url}")
|
||||
|
||||
return JSONResponse(
|
||||
content=response_json,
|
||||
status_code=response.status,
|
||||
headers=dict(response.headers)
|
||||
)
|
||||
|
||||
except aiohttp.ClientTimeout:
|
||||
logger.error(f"⏰ 타임아웃: {target_url}")
|
||||
raise HTTPException(status_code=504, detail="Gateway Timeout")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"🚫 클라이언트 오류: {e}")
|
||||
raise HTTPException(status_code=502, detail="Bad Gateway")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 예상치 못한 오류: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=settings.FASTAPI_PORT,
|
||||
reload=settings.NODE_ENV == "development",
|
||||
log_level=settings.LOG_LEVEL.lower()
|
||||
)
|
||||
6
fastapi-bridge/requirements.txt
Normal file
6
fastapi-bridge/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
aiohttp==3.9.1
|
||||
python-multipart==0.0.6
|
||||
redis==5.0.1
|
||||
python-dotenv==1.0.0
|
||||
1635
fastapi-bridge/static/Minutes/safety.html
Normal file
1635
fastapi-bridge/static/Minutes/safety.html
Normal file
File diff suppressed because it is too large
Load Diff
105
fastapi-bridge/static/Minutes/안전 회의록 백업/2025.06.27.json
Normal file
105
fastapi-bridge/static/Minutes/안전 회의록 백업/2025.06.27.json
Normal file
@@ -0,0 +1,105 @@
|
||||
[
|
||||
{
|
||||
"category": "안전회의",
|
||||
"items": [
|
||||
{
|
||||
"task": "일정 공유",
|
||||
"method": "메일",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "안전회의록 작성 및 지난달 안전회의록 인쇄 및 서명",
|
||||
"method": "-",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "내용 공유",
|
||||
"method": "메일",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "자료 저장 (서버: [공융 드라이브] - [테크니컬코리아] - [07.안전회의])",
|
||||
"method": "서버",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "동절기 전열기 관리",
|
||||
"items": [
|
||||
{
|
||||
"task": "담당자 지정 및 전열기 전원 확인",
|
||||
"method": "-",
|
||||
"frequency": "매주/동절기",
|
||||
"assignee": "하주현"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "소화기 점검",
|
||||
"items": [
|
||||
{
|
||||
"task": "점검일지 작성",
|
||||
"method": "-",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "신민기 (=신상균)"
|
||||
},
|
||||
{
|
||||
"task": "점검일지 자료 저장 (서버: [공융 드라이브] - [테크니컬코리아] - [02.구매물류팀] - [소방안전 박창원,하주현] - [소화기 점검일지])",
|
||||
"method": "서버",
|
||||
"frequency": "1회/매달",
|
||||
"assignee": "하주현"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "건강검진",
|
||||
"items": [
|
||||
{
|
||||
"task": "제휴 병원 (화성디에스) 건강검진 버스 일정 확인",
|
||||
"method": "",
|
||||
"frequency": "1회/매년",
|
||||
"assignee": "이예린"
|
||||
},
|
||||
{
|
||||
"task": "일정 공유",
|
||||
"method": "메일",
|
||||
"frequency": "1회/매년",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "건강검진 실시확인서 (직장제출용) 수집",
|
||||
"method": "메일",
|
||||
"frequency": "1회/매년",
|
||||
"assignee": "하주현"
|
||||
},
|
||||
{
|
||||
"task": "건강검진 실시확인서 (직장제출용) 보관",
|
||||
"method": "",
|
||||
"frequency": "1회/매년",
|
||||
"assignee": "안현기"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "법정의무교육",
|
||||
"items": [
|
||||
{
|
||||
"task": "일정 공유",
|
||||
"method": "메일",
|
||||
"frequency": "1회 / 상,하반기",
|
||||
"assignee": "이예린"
|
||||
},
|
||||
{
|
||||
"task": "교육 미이수자 Follow-up",
|
||||
"method": "메일",
|
||||
"frequency": "1회 / 상,하반기",
|
||||
"assignee": "하주현"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
BIN
fastapi-bridge/static/Minutes/인수인계관련/안전회의록 2025년 5월.xlsx
Normal file
BIN
fastapi-bridge/static/Minutes/인수인계관련/안전회의록 2025년 5월.xlsx
Normal file
Binary file not shown.
BIN
fastapi-bridge/static/Minutes/인수인계관련/하주현 선임 인수인계 (안전) 복사본.xlsx
Normal file
BIN
fastapi-bridge/static/Minutes/인수인계관련/하주현 선임 인수인계 (안전) 복사본.xlsx
Normal file
Binary file not shown.
351
fastapi-bridge/static/components/navbar.html
Normal file
351
fastapi-bridge/static/components/navbar.html
Normal file
@@ -0,0 +1,351 @@
|
||||
<!-- components/navbar.html -->
|
||||
<!-- 프로필 드롭다운이 추가된 개선된 네비게이션바 -->
|
||||
<nav class="navbar">
|
||||
<div class="navbar-brand">
|
||||
<img src="/img/logo.png" alt="로고" class="logo-small">
|
||||
<div class="brand-content">
|
||||
<span class="brand-text">테크니컬코리아</span>
|
||||
<span class="brand-subtitle">생산팀 포털</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-center">
|
||||
<div class="current-time" id="current-time"></div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu">
|
||||
<!-- 프로필 드롭다운 추가 -->
|
||||
<div class="profile-dropdown">
|
||||
<div class="user-info" id="user-info-dropdown">
|
||||
<div class="user-avatar">👤</div>
|
||||
<div class="user-details">
|
||||
<span class="user-name" id="user-name">사용자</span>
|
||||
<span class="user-role" id="user-role">작업자</span>
|
||||
</div>
|
||||
<span class="dropdown-arrow">▼</span>
|
||||
</div>
|
||||
|
||||
<!-- 드롭다운 메뉴 -->
|
||||
<div class="dropdown-menu" id="profile-dropdown-menu">
|
||||
<div class="dropdown-header">
|
||||
<div class="dropdown-user-name" id="dropdown-user-fullname">사용자</div>
|
||||
<div class="dropdown-user-id" id="dropdown-user-id">@username</div>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a href="/pages/profile/my-profile.html" class="dropdown-item">
|
||||
<span class="dropdown-icon">👤</span>
|
||||
내 프로필
|
||||
</a>
|
||||
<a href="/pages/profile/change-password.html" class="dropdown-item">
|
||||
<span class="dropdown-icon">🔐</span>
|
||||
비밀번호 변경
|
||||
</a>
|
||||
<a href="/pages/profile/settings.html" class="dropdown-item">
|
||||
<span class="dropdown-icon">⚙️</span>
|
||||
설정
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item logout-item" id="dropdown-logout">
|
||||
<span class="dropdown-icon">🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-buttons">
|
||||
<button class="nav-btn dashboard-btn" title="대시보드">
|
||||
🏠 대시보드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
padding: 12px 24px;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 프로필 드롭다운 스타일 */
|
||||
.profile-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 25px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.user-info.active {
|
||||
background: rgba(255,255,255,0.25);
|
||||
box-shadow: 0 0 0 3px rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 0.7rem;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info.active .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 드롭다운 메뉴 */
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
min-width: 240px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
padding: 16px 20px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.dropdown-user-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dropdown-user-id {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logout-item {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.logout-item:hover {
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
/* 기존 버튼 스타일 */
|
||||
.navbar-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dashboard-btn {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1024px) {
|
||||
.navbar {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
right: -16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dashboard-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!-- ✅ /components/sections/admin-sections.html -->
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work-reports/work-report-create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work-reports/work-report-manage.html">작업보고서 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/work-reports/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/work-reports/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🔧 시스템 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/factory-upload.html">공장 정보 등록</a></li>
|
||||
<li><a href="/pages/common/factory-view.html">공장 목록 보기</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 이슈 리포트</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/issue-reports/daily-issue-report.html">일일 이슈 보고</a></li>
|
||||
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
|
||||
<li><a href="/pages/analysis/daily_work_analysis.html">작업 정보 페이지</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
136
fastapi-bridge/static/components/sidebar.html
Normal file
136
fastapi-bridge/static/components/sidebar.html
Normal file
@@ -0,0 +1,136 @@
|
||||
<!-- ✅ /components/sidebar.html -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<!-- 일반 작업자 메뉴 -->
|
||||
<div class="menu-section worker-only">
|
||||
<h3 class="menu-title">👷 작업 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/work-reports/create.html">📝 작업 일보 작성</a></li>
|
||||
<li><a href="/pages/common/attendance.html">📋 출근부 확인</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 그룹장 메뉴 -->
|
||||
<div class="menu-section group-leader-only">
|
||||
<h3 class="menu-title">👨🏫 그룹장 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/issue-reports/daily-issue.html">📋 일일 이슈 보고</a></li>
|
||||
<li><a href="/pages/work-reports/team-reports.html">👥 팀 작업 관리</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 지원팀 메뉴 -->
|
||||
<div class="menu-section support-only">
|
||||
<h3 class="menu-title">🧑💼 지원팀 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/work-reports/create.html">📥 작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work-reports/manage.html">🛠 작업보고서 관리</a></li>
|
||||
<li><a href="/pages/common/attendance.html">📊 전체 출근부</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 관리자 메뉴 -->
|
||||
<div class="menu-section admin-only">
|
||||
<h3 class="menu-title">🏢 관리자 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/admin/reports-dashboard.html">📈 리포트 대시보드</a></li>
|
||||
<li><a href="/pages/admin/system-logs.html">📋 시스템 로그</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 관리자 메뉴 -->
|
||||
<div class="menu-section system-only">
|
||||
<h3 class="menu-title">⚙️ 시스템 관리</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 공통 메뉴 (모든 사용자) -->
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">📌 공통 메뉴</h3>
|
||||
<ul class="menu-list">
|
||||
<li><a href="/pages/common/factory-list.html">🏭 공장 정보</a></li>
|
||||
<li><a href="/pages/common/emergency-contacts.html">📞 비상 연락망</a></li>
|
||||
<li><a href="/pages/common/help.html">❓ 도움말</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: #1a237e;
|
||||
color: white;
|
||||
min-height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.menu-section:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 12px 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.menu-list a {
|
||||
display: block;
|
||||
color: rgba(255,255,255,0.9);
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.menu-list a:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.menu-list a:active {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* 모바일 대응 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
fastapi-bridge/static/css/admin.css
Normal file
25
fastapi-bridge/static/css/admin.css
Normal file
@@ -0,0 +1,25 @@
|
||||
body {
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
margin: 0;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.main-layout {
|
||||
display: flex;
|
||||
}
|
||||
#sidebar-container {
|
||||
width: 250px;
|
||||
background: #1a237e;
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#content-container {
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
}
|
||||
h1, h2 {
|
||||
color: #1976d2;
|
||||
}
|
||||
a {
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
}
|
||||
883
fastapi-bridge/static/css/attendance-validation.css
Normal file
883
fastapi-bridge/static/css/attendance-validation.css
Normal file
@@ -0,0 +1,883 @@
|
||||
/* 근태 검증 관리 시스템 - 개선된 스타일 */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 뒤로가기 버튼 */
|
||||
.back-btn {
|
||||
background: rgba(255,255,255,0.95);
|
||||
color: #667eea;
|
||||
border: 3px solid #667eea;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 메인 카드 */
|
||||
.main-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.main-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 캘린더 헤더 */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: between;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 월간 요약 */
|
||||
.summary-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.summary-card.normal {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.summary-card.warning {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.summary-card.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.summary-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-card.normal .summary-number {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.summary-card.warning .summary-number {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.summary-card.error .summary-number {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 요일 헤더 */
|
||||
.weekday-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.weekday.sunday {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.weekday.saturday {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 캘린더 그리드 */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.calendar-day.hover-enabled:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.calendar-day.selected {
|
||||
transform: scale(1.05);
|
||||
z-index: 10;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.calendar-day.loading-state {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
border-color: #3b82f6;
|
||||
animation: loading-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.calendar-day.error-state {
|
||||
background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%);
|
||||
border-color: #ef4444;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.calendar-day.normal {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
border-color: #10b981;
|
||||
color: #064e3b;
|
||||
}
|
||||
|
||||
.calendar-day.needs-review {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border-color: #f59e0b;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.calendar-day.missing {
|
||||
background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%);
|
||||
border-color: #ef4444;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.calendar-day.no-data {
|
||||
background: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-day.no-data::after {
|
||||
content: "클릭하여 확인";
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.calendar-day.no-data.hover-enabled:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-dot.pulse {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.status-dot.normal {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* 범례 */
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.legend-dot.normal {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.legend-dot.warning {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.legend-dot.error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 작업자 카드 */
|
||||
.worker-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.worker-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.worker-card.normal {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.worker-card.needs-review {
|
||||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.worker-card.missing {
|
||||
background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.worker-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.worker-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: #374151;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.worker-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.worker-id {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 1.5rem;
|
||||
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* 데이터 행 */
|
||||
.data-section {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.data-row {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.data-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.difference-positive {
|
||||
color: #dc2626;
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.difference-negative {
|
||||
color: #2563eb;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* 필터 */
|
||||
.filter-container {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background: white;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 20px 20px 0 0;
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
background: #f8fafc;
|
||||
border-radius: 0 0 20px 20px;
|
||||
}
|
||||
|
||||
/* 폼 요소 */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
.message {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
color: #065f46;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%);
|
||||
color: #991b1b;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
color: #92400e;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.message.loading {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
color: #1e40af;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 로딩 스피너 */
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top: 3px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
.fade-in {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 60px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legend {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.calendar-day {
|
||||
min-height: 50px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.summary-number {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.worker-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body, .modal-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
72
fastapi-bridge/static/css/attendance.css
Normal file
72
fastapi-bridge/static/css/attendance.css
Normal file
@@ -0,0 +1,72 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #343a40;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.controls label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.controls select,
|
||||
.controls button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
button#loadAttendance {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button#downloadPdf {
|
||||
background-color: #007BFF;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
#attendanceTableContainer {
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
}
|
||||
th {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
.divider {
|
||||
border-left: 3px solid #333 !important;
|
||||
}
|
||||
tr.separator td {
|
||||
border-bottom: 2px solid #999;
|
||||
padding: 0;
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
.overtime-cell { background: #e9e5ff !important; }
|
||||
.leave { background: #f5e0d6 !important; }
|
||||
.holiday { background: #ffd6d6 !important; }
|
||||
.paid-leave { background: #d6f0ff !important; }
|
||||
.no-data { background: #ddd !important; }
|
||||
.overtime-sum { background: #e9e5ff !important; }
|
||||
90
fastapi-bridge/static/css/daily-issue.css
Normal file
90
fastapi-bridge/static/css/daily-issue.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* /css/daily-issue.css */
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f5f7fa;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 500px;
|
||||
margin: auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.05);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
select, input[type="date"], button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button#submitBtn {
|
||||
margin-top: 30px;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button#submitBtn:hover {
|
||||
background: #125cb1;
|
||||
}
|
||||
|
||||
.multi-select-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.multi-select-box .btn {
|
||||
flex: 1 0 30%;
|
||||
padding: 8px;
|
||||
border: 1px solid #1976d2;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: #1976d2;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.multi-select-box .btn.selected {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.time-range {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.time-range select {
|
||||
flex: 1;
|
||||
}
|
||||
548
fastapi-bridge/static/css/daily-report-viewer.css
Normal file
548
fastapi-bridge/static/css/daily-report-viewer.css
Normal file
@@ -0,0 +1,548 @@
|
||||
/* daily-report-viewer.css */
|
||||
|
||||
/* 전체 레이아웃 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 날짜 선택기 */
|
||||
.date-selector {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.date-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date-input-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-btn, .today-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
background: #f8f9fa;
|
||||
color: #667eea;
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.today-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 로딩 스피너 */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 50px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-spinner p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 에러 메시지 */
|
||||
.error-message {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
border-left: 5px solid #e74c3c;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 데이터 없음 메시지 */
|
||||
.no-data-message {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 50px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.no-data-content {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-data-icon {
|
||||
font-size: 3rem;
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.no-data-content h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 요약 카드 */
|
||||
.report-summary {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.summary-card.error-card {
|
||||
border-left: 5px solid #e74c3c;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.error-card .card-value {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
/* 작업자 리포트 */
|
||||
.workers-report {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 25px;
|
||||
color: #333;
|
||||
border-bottom: 3px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.workers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.worker-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border-left: 5px solid #667eea;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.worker-card:hover {
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.worker-name {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.worker-total-hours {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.work-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.work-entry {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border: 1px solid #e1e5e9;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.work-entry:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.work-entry.error-entry {
|
||||
border-left: 4px solid #e74c3c;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.work-hours {
|
||||
background: #e8f4f8;
|
||||
color: #2c5aa0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.work-entry.error-entry .work-hours {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.entry-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.entry-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.error-type {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 내보내기 섹션 */
|
||||
.export-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.export-section h3 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.excel-btn {
|
||||
background: #217346;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.excel-btn:hover {
|
||||
background: #1a5a37;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
background: #495057;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.print-btn:hover {
|
||||
background: #343a40;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.date-input-group {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.date-input-group > * {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.work-entries {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 인쇄 스타일 */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.date-selector,
|
||||
.export-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.workers-report,
|
||||
.worker-card,
|
||||
.work-entry {
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #ddd !important;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #333 !important;
|
||||
-webkit-text-fill-color: #333 !important;
|
||||
}
|
||||
}
|
||||
756
fastapi-bridge/static/css/daily-work-report.css
Normal file
756
fastapi-bridge/static/css/daily-work-report.css
Normal file
@@ -0,0 +1,756 @@
|
||||
/* daily-work-report.css - 덮어쓰기 방지 완전 스타일 */
|
||||
|
||||
/* 기본 레이아웃 */
|
||||
.daily-work-report-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* 단계 표시 */
|
||||
.steps-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 20px;
|
||||
opacity: 0.5;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.step.active {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.step.completed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #e9ecef 0%, #adb5bd 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
margin-right: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.step.completed .step-number {
|
||||
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 🛡️ 보호 모드 표시기 */
|
||||
.protection-indicator {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 25px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 10px rgba(39, 174, 96, 0.3);
|
||||
z-index: 1000;
|
||||
border: 2px solid #1e8449;
|
||||
animation: pulse-protection 2s infinite;
|
||||
}
|
||||
|
||||
.protection-indicator::before {
|
||||
content: "🛡️";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@keyframes pulse-protection {
|
||||
0%, 100% { box-shadow: 0 2px 10px rgba(39, 174, 96, 0.3); }
|
||||
50% { box-shadow: 0 4px 20px rgba(39, 174, 96, 0.5); }
|
||||
}
|
||||
|
||||
/* 메시지 컨테이너 */
|
||||
.message-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border-color: #1e7e34;
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
color: white;
|
||||
border-color: #bd2130;
|
||||
box-shadow: 0 4px 15px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
|
||||
color: white;
|
||||
border-color: #d68910;
|
||||
box-shadow: 0 4px 15px rgba(243, 156, 18, 0.3);
|
||||
animation: fadeInShake 0.5s ease-out;
|
||||
}
|
||||
|
||||
.message.loading {
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
color: white;
|
||||
border-color: #2471a3;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||
animation: loading-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-shimmer {
|
||||
0% { left: -100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
@keyframes fadeInShake {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 단계별 콘텐츠 */
|
||||
.step-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
border: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
/* 1단계: 날짜 선택 */
|
||||
.date-selection {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 15px;
|
||||
font-size: 18px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
width: 200px;
|
||||
margin: 20px 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 2단계: 작업자 선택 */
|
||||
.worker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.worker-btn {
|
||||
padding: 15px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.worker-btn:hover {
|
||||
border-color: #007bff;
|
||||
background: #f8f9fa;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.2);
|
||||
}
|
||||
|
||||
.worker-btn.selected {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
border-color: #0056b3;
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 🚫 차단된 작업자 버튼 스타일 */
|
||||
.worker-btn.blocked {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%) !important;
|
||||
color: white !important;
|
||||
border: 2px solid #a93226 !important;
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed !important;
|
||||
position: relative;
|
||||
animation: pulse-blocked 2s infinite;
|
||||
}
|
||||
|
||||
.worker-btn.blocked:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.worker-btn.blocked::after {
|
||||
content: "🚫";
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
border: 2px solid #e74c3c;
|
||||
}
|
||||
|
||||
@keyframes pulse-blocked {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* 3단계: 작업 입력 */
|
||||
.work-entries-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.work-entry {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.work-entry:hover {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.work-entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.work-entry-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.remove-work-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.remove-work-btn:hover {
|
||||
background: #c82333;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.work-entry-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.large-select {
|
||||
padding: 12px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.large-select:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 에러 유형 섹션 */
|
||||
.error-type-section {
|
||||
opacity: 0.5;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.error-type-section.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 시간 입력 */
|
||||
.time-input-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.quick-time-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.quick-time-btn {
|
||||
padding: 8px 15px;
|
||||
background: #e9ecef;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.quick-time-btn:hover {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #0056b3;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 총 시간 표시 */
|
||||
.total-hours-display {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* 버튼 스타일 */
|
||||
.btn {
|
||||
padding: 12px 25px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
|
||||
color: white;
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: linear-gradient(135deg, #1e7e34 0%, #155724 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 작업 추가 버튼 */
|
||||
.add-work-container {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.add-work-btn {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
|
||||
color: white;
|
||||
border: 2px solid #138496;
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.add-work-btn:hover {
|
||||
background: linear-gradient(135deg, #138496 0%, #0f6674 100%);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(23, 162, 184, 0.3);
|
||||
}
|
||||
|
||||
/* 저장 버튼 */
|
||||
.submit-container {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border: 2px solid #1e7e34;
|
||||
padding: 18px 40px;
|
||||
border-radius: 30px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: linear-gradient(135deg, #1e7e34 0%, #17a2b8 100%);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px rgba(40, 167, 69, 0.4);
|
||||
}
|
||||
|
||||
/* 오늘 작업자 현황 */
|
||||
.daily-workers-section {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.daily-workers-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #5a6fd8;
|
||||
}
|
||||
|
||||
.daily-workers-header h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 8px 15px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 작업자 현황 그리드 */
|
||||
.worker-status-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.worker-status-item {
|
||||
background: white;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.worker-status-item:hover {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.worker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.worker-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.worker-total-hours {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 개별 작업 항목 */
|
||||
.individual-works-container {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.individual-work-item {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.individual-work-item:hover {
|
||||
background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%);
|
||||
border-color: #adb5bd;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.work-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 액션 버튼들 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.edit-btn, .delete-btn {
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
color: white;
|
||||
border: 2px solid #2471a3;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: linear-gradient(135deg, #2980b9 0%, #2471a3 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 10px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
color: white;
|
||||
border: 2px solid #a93226;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: linear-gradient(135deg, #c0392b 0%, #a93226 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 10px rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
/* 데이터 없음 메시지 */
|
||||
.no-data-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #6c757d;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.no-data-message small {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
/* 로딩 스피너 */
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-size: 16px;
|
||||
color: #007bff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.daily-work-report-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.work-entry-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.worker-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.work-details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.daily-workers-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.protection-indicator {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin: 10px auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.steps-container {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.worker-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
61
fastapi-bridge/static/css/factory.css
Normal file
61
fastapi-bridge/static/css/factory.css
Normal file
@@ -0,0 +1,61 @@
|
||||
body {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
background-color: #f9f9f9;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
form label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
form input[type="text"],
|
||||
form input[type="file"],
|
||||
form textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
form textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
54
fastapi-bridge/static/css/login.css
Normal file
54
fastapi-bridge/static/css/login.css
Normal file
@@ -0,0 +1,54 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: url('/img/login-bg.jpeg') no-repeat center center fixed;
|
||||
background-size: cover;
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
margin: 100px auto;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 200px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
padding: 12px;
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 20px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 10px;
|
||||
color: #ff6b6b;
|
||||
font-weight: bold;
|
||||
}
|
||||
160
fastapi-bridge/static/css/main-layout.css
Normal file
160
fastapi-bridge/static/css/main-layout.css
Normal file
@@ -0,0 +1,160 @@
|
||||
/* ✅ /css/main-layout.css - 공통 레이아웃 스타일 */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 메인 레이아웃 구조 */
|
||||
.main-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#navbar-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#sidebar-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#content-container,
|
||||
#sections-container,
|
||||
#admin-sections,
|
||||
#user-sections {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* 섹션 스타일 */
|
||||
section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #1976d2;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
section li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
section li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
section a {
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 4px 0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
section a:hover {
|
||||
color: #0d47a1;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* 로딩 상태 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '.';
|
||||
animation: dots 1.5s steps(3, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%, 20% { content: '.'; }
|
||||
40% { content: '..'; }
|
||||
60%, 100% { content: '...'; }
|
||||
}
|
||||
|
||||
/* 에러 상태 */
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 16px;
|
||||
padding: 8px 24px;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-state button:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1024px) {
|
||||
#content-container,
|
||||
#sections-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#sidebar-container {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
1005
fastapi-bridge/static/css/management-dashboard.css
Normal file
1005
fastapi-bridge/static/css/management-dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
57
fastapi-bridge/static/css/user.css
Normal file
57
fastapi-bridge/static/css/user.css
Normal file
@@ -0,0 +1,57 @@
|
||||
body {
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #4caf50 0%, #45a049 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 30px;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.quick-menu {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #e8f5e9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
108
fastapi-bridge/static/css/work-report.css
Normal file
108
fastapi-bridge/static/css/work-report.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/* ✅ /css/workreport.css */
|
||||
|
||||
body {
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
margin: 0;
|
||||
padding: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #1976d2;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#calendar {
|
||||
max-width: 500px;
|
||||
margin: 0 auto 30px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#calendar .nav {
|
||||
grid-column: span 7;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#calendar button {
|
||||
padding: 8px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #ccc;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#calendar button:hover {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.selected-date {
|
||||
background-color: #4caf50 !important;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f1f3f4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background-color: #d9534f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background-color: #c9302c;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
display: block;
|
||||
margin: 30px auto;
|
||||
padding: 12px 30px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
839
fastapi-bridge/static/css/work-review.css
Normal file
839
fastapi-bridge/static/css/work-review.css
Normal file
@@ -0,0 +1,839 @@
|
||||
/* work-review.css - 작업 검토 페이지 전용 스타일 (개선된 버전) */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.main-layout-with-navbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.review-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 뒤로가기 버튼 */
|
||||
.back-btn {
|
||||
background: rgba(255,255,255,0.95);
|
||||
color: #667eea;
|
||||
border: 3px solid #667eea;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 컨트롤 패널 */
|
||||
.control-panel {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.month-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-btn, .today-btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-btn:hover, .today-btn:hover {
|
||||
background: #0056b3;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.today-btn:hover {
|
||||
background: #1e7e34;
|
||||
}
|
||||
|
||||
.current-month {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
min-width: 200px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.control-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 사용법 안내 */
|
||||
.usage-guide {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.usage-guide h3 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 2px solid #dee2e6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.guide-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
font-size: 2rem;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.guide-text strong {
|
||||
color: #007bff;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 캘린더 */
|
||||
.calendar-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.calendar-container h3 {
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
border: 3px solid #dee2e6;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
background: #343a40;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
min-height: 80px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.day-cell:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.day-cell.other-month {
|
||||
background: #f8f9fa;
|
||||
color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
border: 3px solid #007bff;
|
||||
background: #e7f3ff;
|
||||
}
|
||||
|
||||
.day-cell.selected {
|
||||
background: #d4edda;
|
||||
border: 3px solid #28a745;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.day-cell.selected .day-number {
|
||||
color: #155724;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 선택된 날짜 정보 패널 */
|
||||
.day-info-panel {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.day-info-placeholder {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.day-info-placeholder h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.day-info-content {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.day-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 3px solid #dee2e6;
|
||||
}
|
||||
|
||||
.day-info-header h3 {
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.day-info-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.review-toggle, .refresh-day-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.review-toggle {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.review-toggle.reviewed {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.review-toggle:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.refresh-day-btn {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.refresh-day-btn:hover {
|
||||
background: #545b62;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 일별 요약 정보 */
|
||||
.day-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-value.normal-work {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.summary-value.overtime {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.summary-value.vacation {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.summary-value.reviewed {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.summary-value.unreviewed {
|
||||
color: #fd7e14;
|
||||
}
|
||||
|
||||
/* 작업자별 상세 섹션 */
|
||||
.workers-detail-container h4 {
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.worker-detail-section {
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.worker-header-detail {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.delete-worker-btn {
|
||||
background: rgba(220, 53, 69, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.delete-worker-btn:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.worker-work-items {
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.work-item-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007bff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.work-item-detail:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.work-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-work-btn, .delete-work-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-work-btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-work-btn:hover {
|
||||
background: #0056b3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.delete-work-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-work-btn:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
.message {
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
animation: slideInDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message.loading {
|
||||
background: #cce5ff;
|
||||
color: #0066cc;
|
||||
border: 2px solid #99d6ff;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 2px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 2px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 2px solid #ffeaa7;
|
||||
}
|
||||
|
||||
/* 수정 모달 스타일 */
|
||||
.edit-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1001;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
animation: slideInUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.edit-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.close-modal-btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.close-modal-btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.edit-modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.edit-form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.edit-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-select, .edit-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.edit-select:focus, .edit-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.edit-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
/* 확인 모달 */
|
||||
.confirm-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1002;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
animation: slideInUp 0.3s ease;
|
||||
}
|
||||
|
||||
.confirm-modal-header {
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.confirm-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.confirm-modal-body {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.confirm-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
/* 버튼 스타일 */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #1e7e34;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.month-navigation {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.guide-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
min-height: 60px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.day-summary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.day-info-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.day-info-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.review-toggle, .refresh-day-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.work-item-detail {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.work-item-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.worker-header-detail {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edit-modal-content {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.edit-modal-footer, .confirm-modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.edit-work-btn, .delete-work-btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
394
fastapi-bridge/static/docs/assets/css/style.css
Normal file
394
fastapi-bridge/static/docs/assets/css/style.css
Normal file
@@ -0,0 +1,394 @@
|
||||
/* 테크니컬코리아 문서 시스템 CSS */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 헤더 스타일 */
|
||||
.header {
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 40px 30px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 메인 콘텐츠 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* 카드 그리드 */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 25px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-10px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: inline-block;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 25px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.card-link:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 검색 카드 특별 스타일 */
|
||||
.search-card {
|
||||
grid-column: 1 / -1;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 15px 20px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 문서 목록 스타일 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
transform: translateX(10px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.document-icon {
|
||||
font-size: 2rem;
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.document-date {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: #218838;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 네비게이션 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 15px 25px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #999;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 30px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(10px);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.footer p:last-child {
|
||||
margin-bottom: 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 로딩 애니메이션 */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 알림 메시지 */
|
||||
.alert {
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-left: 5px solid #667eea;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
border-left-color: #28a745;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
border-left-color: #dc3545;
|
||||
}
|
||||
60
fastapi-bridge/static/docs/assets/js/main.js
Normal file
60
fastapi-bridge/static/docs/assets/js/main.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// 문서 검색 기능
|
||||
function searchDocuments() {
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
if (searchTerm.trim() === '') {
|
||||
alert('검색어를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 간단한 검색 구현 (실제로는 서버 검색 또는 더 복잡한 로직 필요)
|
||||
window.location.href = `search.html?q=${encodeURIComponent(searchTerm)}`;
|
||||
}
|
||||
|
||||
// 문서 필터링 기능 (문서 목록 페이지용)
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const keywords = doc.getAttribute('data-keywords').toLowerCase();
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (keywords.includes(term) || title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'flex';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// QR 코드 생성 (선택사항)
|
||||
function generateQR() {
|
||||
const url = 'http://192.168.0.3:10080';
|
||||
const qrAPI = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(url)}`;
|
||||
|
||||
const qrModal = document.createElement('div');
|
||||
qrModal.innerHTML = `
|
||||
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: flex; justify-content: center; align-items: center; z-index: 1000;">
|
||||
<div style="background: white; padding: 30px; border-radius: 15px; text-align: center;">
|
||||
<h3>QR 코드로 접속</h3>
|
||||
<img src="${qrAPI}" alt="QR Code" style="margin: 20px 0;">
|
||||
<p>${url}</p>
|
||||
<button onclick="this.parentElement.parentElement.remove()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(qrModal);
|
||||
}
|
||||
|
||||
// Enter 키 검색
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchDocuments();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
357
fastapi-bridge/static/docs/hr.html
Normal file
357
fastapi-bridge/static/docs/hr.html
Normal file
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>인사규정 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>인사규정</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>인사규정</h2>
|
||||
<p>Human Resources - 인사관리 규정 및 지침</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- 인사규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-001 인사관리 규정</h3>
|
||||
<p>채용, 승진, 전보, 퇴직 등 인사관리 전반</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 급여규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-002 급여관리 규정</h3>
|
||||
<p>급여체계, 수당, 상여금 지급 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 근무규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-003 근무시간 관리규정</h3>
|
||||
<p>근무시간, 휴게시간, 연장근무 규정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-004.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-004 휴가 및 휴직규정</h3>
|
||||
<p>연차, 병가, 특별휴가, 휴직 관련 규정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 복리후생 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-005.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-005 복리후생 규정</h3>
|
||||
<p>건강보험, 퇴직금, 각종 지원금 규정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 성과평가 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-006.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-006 성과평가 관리규정</h3>
|
||||
<p>성과평가 기준, 절차, 결과 활용 방안</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 교육훈련 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-007.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-007 교육훈련 관리규정</h3>
|
||||
<p>신입사원 교육, 직무교육, 외부교육 지원</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 징계규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-008.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-008 징계 관리규정</h3>
|
||||
<p>징계사유, 절차, 징계양정 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 보안규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-009.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-009 정보보안 및 기밀유지</h3>
|
||||
<p>회사 정보보안 및 기밀유지 의무</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 복무규정 -->
|
||||
<div class="document-item" onclick="location.href='hr/HR-010.html'">
|
||||
<div class="document-content">
|
||||
<h3>HR-010 복무 및 행동강령</h3>
|
||||
<p>직원 복무 기준 및 윤리 행동강령</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>인사규정 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="hrSearchInput" placeholder="인사규정 검색 (예: 급여, 휴가, 교육)">
|
||||
<button onclick="filterDocuments(document.getElementById('hrSearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>인사 문의: hr@technicalkorea.co.kr | 내선: 3456</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'block';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('hrSearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
447
fastapi-bridge/static/docs/hse.html
Normal file
447
fastapi-bridge/static/docs/hse.html
Normal file
@@ -0,0 +1,447 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HSE 관리시스템 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>HSE 관리시스템</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>HSE 관리시스템</h2>
|
||||
<p>Health, Safety & Environment - 안전보건환경 관련 절차서</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- HSE 관리시스템 매뉴얼 (최상위 문서) -->
|
||||
<div class="document-item" onclick="location.href='iso45001_bilingual_manual.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-001 ISO 45001:2018 HSE 관리시스템 매뉴얼</h3>
|
||||
<p>ISO 45001:2018 기반 보건, 안전 및 환경 관리시스템 최상위 문서 (한/영 이중언어)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 조직의 상황 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-410.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-410 조직 상황 이해 및 HSE 관리시스템 운영 절차</h3>
|
||||
<p>조직의 내외부 상황 파악 및 HSE 관리시스템 전반 운영</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 리더십과 근로자 참여 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-510.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-510 리더십 및 정책 수립 절차</h3>
|
||||
<p>최고경영자 리더십 및 HSE 정책 수립·운영 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-520.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-520 조직 편성 및 직무 배정 절차</h3>
|
||||
<p>HSE 관련 조직 구성 및 역할·책임·권한 배정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. 기획 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-610.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-610 기획 및 위험 관리 절차</h3>
|
||||
<p>HSE 관리시스템 기획 및 위험과 기회 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-620.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-620 위험 평가 절차</h3>
|
||||
<p>유해요인 식별 및 위험성 평가 실시 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-630.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-630 HSE 법적 요구사항 관리 절차</h3>
|
||||
<p>HSE 관련 법령 및 기타 요구사항 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-640.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-640 HSE 목표 관리 절차</h3>
|
||||
<p>HSE 목표 설정, 달성 계획 수립 및 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. 지원 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-710.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-710 자원 관리 절차</h3>
|
||||
<p>HSE 관리시스템 운영에 필요한 자원 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-720.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-720 교육 및 훈련 관리 절차</h3>
|
||||
<p>HSE 관련 교육·훈련 계획 수립 및 실시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-730.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-730 인식 및 의사소통 절차</h3>
|
||||
<p>HSE 인식 제고 및 내외부 의사소통 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-740.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-740 문서화된 정보 관리 절차</h3>
|
||||
<p>HSE 문서 및 기록의 작성, 관리, 보관</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. 운영 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-810.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-810 운영 기획 및 관리 절차</h3>
|
||||
<p>HSE 운영 기획, 작업허가, 변경관리, 조달관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-820.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-820 비상 대비 및 대응 절차</h3>
|
||||
<p>비상상황 대비, 대응 계획 및 훈련</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. 성과 평가 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-910.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-910 프로세스 성과 관리 절차</h3>
|
||||
<p>HSE 관리시스템 프로세스 성과 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-920.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-920 HSE 모니터링 및 측정 관리 절차</h3>
|
||||
<p>HSE 성과 모니터링, 측정 및 분석</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-930.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-930 내부 심사 절차</h3>
|
||||
<p>HSE 관리시스템 내부 심사 계획 및 실시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-940.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-940 경영 검토 절차</h3>
|
||||
<p>HSE 관리시스템 경영진 검토</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. 개선 관련 문서 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-1010.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-1010 사건, 부적합 및 시정조치 절차</h3>
|
||||
<p>사건·사고 조사, 부적합 처리 및 시정조치</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-P-1020.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-P-1020 지속적 개선 절차</h3>
|
||||
<p>HSE 관리시스템 지속적 개선 활동</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실무 지침 문서들 -->
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-W-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-W-001 개인보호구 관리 지침</h3>
|
||||
<p>개인보호구 지급, 관리, 점검 실무 지침</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-W-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-W-002 화학물질 관리 지침</h3>
|
||||
<p>화학물질 보관, 사용, 폐기 실무 지침</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-item" onclick="location.href='hse/TK-HSE-W-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>TK-HSE-W-003 응급처치 및 의료관리 지침</h3>
|
||||
<p>응급상황 대응 및 응급처치 실무 지침</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>HSE 문서 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="hseSearchInput" placeholder="HSE 문서 검색 (예: 안전, 화재, 교육)">
|
||||
<button onclick="filterDocuments(document.getElementById('hseSearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>HSE 문의: safety@technicalkorea.co.kr | 내선: 1234</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'flex';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('hseSearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1208
fastapi-bridge/static/docs/hse/TK-HSE-P-410.html
Normal file
1208
fastapi-bridge/static/docs/hse/TK-HSE-P-410.html
Normal file
File diff suppressed because it is too large
Load Diff
1529
fastapi-bridge/static/docs/hse/TK-HSE-P-510.html
Normal file
1529
fastapi-bridge/static/docs/hse/TK-HSE-P-510.html
Normal file
File diff suppressed because it is too large
Load Diff
1826
fastapi-bridge/static/docs/hse/TK-HSE-P-520.html
Normal file
1826
fastapi-bridge/static/docs/hse/TK-HSE-P-520.html
Normal file
File diff suppressed because it is too large
Load Diff
1671
fastapi-bridge/static/docs/hse/TK-HSE-P-610.html
Normal file
1671
fastapi-bridge/static/docs/hse/TK-HSE-P-610.html
Normal file
File diff suppressed because it is too large
Load Diff
1784
fastapi-bridge/static/docs/hse/TK-HSE-P-620.html
Normal file
1784
fastapi-bridge/static/docs/hse/TK-HSE-P-620.html
Normal file
File diff suppressed because it is too large
Load Diff
1894
fastapi-bridge/static/docs/hse/TK-HSE-P-630.html
Normal file
1894
fastapi-bridge/static/docs/hse/TK-HSE-P-630.html
Normal file
File diff suppressed because it is too large
Load Diff
295
fastapi-bridge/static/docs/index.html
Normal file
295
fastapi-bridge/static/docs/index.html
Normal file
@@ -0,0 +1,295 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>테크니컬코리아 문서 시스템</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 10px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 5px 20px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.8;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-link:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<h1>테크니컬코리아 문서 시스템</h1>
|
||||
<p>회사 규정 및 절차서 열람 시스템</p>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="card-grid">
|
||||
<!-- HSE 문서 -->
|
||||
<div class="card">
|
||||
<h3>HSE 관리시스템</h3>
|
||||
<p>ISO 45001:2018 기반 안전보건환경 관련 절차서</p>
|
||||
<a href="hse.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 품질 문서 -->
|
||||
<div class="card">
|
||||
<h3>품질 관리시스템</h3>
|
||||
<p>ISO 9001 기반 품질관리 절차 및 매뉴얼</p>
|
||||
<a href="quality.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 인사 규정 -->
|
||||
<div class="card">
|
||||
<h3>인사 규정</h3>
|
||||
<p>인사관리 규정 및 지침서</p>
|
||||
<a href="hr.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 기술 문서 -->
|
||||
<div class="card">
|
||||
<h3>기술 문서</h3>
|
||||
<p>설계 표준, 용접절차, BOM 시스템 가이드라인</p>
|
||||
<a href="technical.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 경영 방침 -->
|
||||
<div class="card">
|
||||
<h3>경영 방침</h3>
|
||||
<p>회사 방침, 윤리강령 및 정책 문서</p>
|
||||
<a href="policy.html" class="card-link">바로가기</a>
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<div class="card search-card">
|
||||
<h3>문서 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="문서명 또는 키워드 입력">
|
||||
<button onclick="searchDocuments()">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>http://192.168.0.3:10080/docs</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 검색 기능
|
||||
function searchDocuments() {
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
|
||||
if (searchTerm === '') {
|
||||
alert('검색어를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 키워드에 따른 페이지 추천
|
||||
if (searchTerm.includes('iso') || searchTerm.includes('45001') || searchTerm.includes('hse') || searchTerm.includes('안전')) {
|
||||
window.location.href = 'hse.html';
|
||||
} else if (searchTerm.includes('품질') || searchTerm.includes('quality') || searchTerm.includes('9001')) {
|
||||
window.location.href = 'quality.html';
|
||||
} else if (searchTerm.includes('인사') || searchTerm.includes('hr') || searchTerm.includes('급여')) {
|
||||
window.location.href = 'hr.html';
|
||||
} else if (searchTerm.includes('기술') || searchTerm.includes('설계') || searchTerm.includes('용접') || searchTerm.includes('bom')) {
|
||||
window.location.href = 'technical.html';
|
||||
} else if (searchTerm.includes('경영') || searchTerm.includes('정책') || searchTerm.includes('윤리')) {
|
||||
window.location.href = 'policy.html';
|
||||
} else {
|
||||
alert('관련 문서를 찾을 수 없습니다. 다른 키워드로 시도해보세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// Enter 키 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchDocuments();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1469
fastapi-bridge/static/docs/iso45001_bilingual_manual.html
Normal file
1469
fastapi-bridge/static/docs/iso45001_bilingual_manual.html
Normal file
File diff suppressed because it is too large
Load Diff
317
fastapi-bridge/static/docs/policy.html
Normal file
317
fastapi-bridge/static/docs/policy.html
Normal file
@@ -0,0 +1,317 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>경영방침 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>경영방침</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>경영방침</h2>
|
||||
<p>Management Policy - 회사 방침 및 정책 문서</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- 경영방침서 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-001 경영방침서</h3>
|
||||
<p>테크니컬코리아 경영이념 및 기본방침</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 윤리강령 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-002 윤리강령</h3>
|
||||
<p>임직원 윤리행동 기준 및 가이드라인</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정보보안정책 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-003 정보보안 정책</h3>
|
||||
<p>회사 정보자산 보호 및 보안 정책</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 조직도 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-004.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-004 조직도</h3>
|
||||
<p>회사 조직도 및 부서별 역할</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 권한위임규정 -->
|
||||
<div class="document-item" onclick="location.href='policy/MP-005.html'">
|
||||
<div class="document-content">
|
||||
<h3>MP-005 권한위임 규정</h3>
|
||||
<p>의사결정 권한 및 위임 규정</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>경영방침 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="policySearchInput" placeholder="경영방침 검색 (예: 윤리, 보안, 조직)">
|
||||
<button onclick="filterDocuments(document.getElementById('policySearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>경영방침 문의: policy@technicalkorea.co.kr | 내선: 5678</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'block';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('policySearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
373
fastapi-bridge/static/docs/quality.html
Normal file
373
fastapi-bridge/static/docs/quality.html
Normal file
@@ -0,0 +1,373 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>품질 관리시스템 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>품질 관리시스템</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>품질 관리시스템</h2>
|
||||
<p>Quality Management System - ISO 9001 기반 품질관리 절차 및 매뉴얼</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- 품질매뉴얼 -->
|
||||
<div class="document-item" onclick="location.href='quality/QM-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>QM-001 품질매뉴얼</h3>
|
||||
<p>ISO 9001 기반 품질경영시스템 매뉴얼</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-001 문서 및 기록관리 절차</h3>
|
||||
<p>품질문서 작성, 승인, 배포, 보관 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 고객만족 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-002 고객만족 관리절차</h3>
|
||||
<p>고객 요구사항 파악 및 만족도 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설계관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-003 설계 및 개발관리</h3>
|
||||
<p>설계입력, 검토, 검증, 타당성확인 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구매관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-004.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-004 구매 및 외주관리</h3>
|
||||
<p>협력업체 평가, 구매품 검증 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 생산관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-005.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-005 생산 및 서비스 제공</h3>
|
||||
<p>생산공정 관리 및 제품 식별추적성</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검사시험 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-006.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-006 검사 및 시험관리</h3>
|
||||
<p>원자재, 중간품, 최종제품 검사 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 부적합관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-007.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-007 부적합 및 시정조치</h3>
|
||||
<p>부적합품 관리 및 시정예방조치 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 내부심사 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-008.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-008 내부심사 절차</h3>
|
||||
<p>품질경영시스템 내부심사 실시 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 경영검토 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-009.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-009 경영검토 절차</h3>
|
||||
<p>품질경영시스템 경영검토 실시 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 측정장비관리 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-010.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-010 측정장비 관리절차</h3>
|
||||
<p>측정장비 교정, 점검, 관리 절차</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 교육훈련 -->
|
||||
<div class="document-item" onclick="location.href='quality/QP-011.html'">
|
||||
<div class="document-content">
|
||||
<h3>QP-011 교육훈련 관리절차</h3>
|
||||
<p>품질 관련 교육훈련 계획 및 실시</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>품질문서 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="qualitySearchInput" placeholder="품질문서 검색 (예: ISO, 설계, 검사)">
|
||||
<button onclick="filterDocuments(document.getElementById('qualitySearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>품질 문의: quality@technicalkorea.co.kr | 내선: 2345</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'block';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('qualitySearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
333
fastapi-bridge/static/docs/technical.html
Normal file
333
fastapi-bridge/static/docs/technical.html
Normal file
@@ -0,0 +1,333 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>기술문서 - 테크니컬코리아</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border: 2px solid #dc2626;
|
||||
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.1);
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
/* 브레드크럼 */
|
||||
.breadcrumb {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #dc2626;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb span {
|
||||
color: #6b7280;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.page-header {
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
border-bottom: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
color: #dc2626;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 문서 목록 */
|
||||
.document-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(220, 38, 38, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
color: #111827;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 검색 섹션 */
|
||||
.search-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.search-section h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
background: white;
|
||||
border: 1px solid #dc2626;
|
||||
color: #111827;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
border-top: 1px solid #dc2626;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 브레드크럼 네비게이션 -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="index.html">홈</a>
|
||||
<span>></span>
|
||||
<strong>기술문서</strong>
|
||||
</nav>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<h2>기술문서</h2>
|
||||
<p>Technical Documents - 기술 표준 및 가이드라인</p>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="document-list">
|
||||
<!-- 설계표준 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-001.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-001 배관설계 표준</h3>
|
||||
<p>배관 설계 기준 및 표준 사양서</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 용접절차서 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-002.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-002 용접절차서 (WPS)</h3>
|
||||
<p>배관 용접 절차 및 품질 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 재료사양서 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-003.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-003 재료사양서</h3>
|
||||
<p>배관재료 규격 및 선정 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAD 표준 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-004.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-004 CAD 도면 표준</h3>
|
||||
<p>도면 작성 기준 및 CAD 표준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검사기준서 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-005.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-005 배관 검사기준서</h3>
|
||||
<p>배관 제작 및 설치 검사 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 압력시험절차 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-006.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-006 압력시험 절차서</h3>
|
||||
<p>배관계통 압력시험 절차 및 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOM 시스템 매뉴얼 -->
|
||||
<div class="document-item" onclick="location.href='technical/TD-007.html'">
|
||||
<div class="document-content">
|
||||
<h3>TD-007 BOM 시스템 사용자 매뉴얼</h3>
|
||||
<p>자재관리 시스템 사용 가이드</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 섹션 -->
|
||||
<div class="search-section">
|
||||
<h3>기술문서 검색</h3>
|
||||
<div class="search-box">
|
||||
<input type="text" id="techSearchInput" placeholder="기술문서 검색 (예: 설계, 용접, CAD)">
|
||||
<button onclick="filterDocuments(document.getElementById('techSearchInput').value)">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>테크니컬코리아 내부 전용 문서시스템</p>
|
||||
<p>기술 문의: tech@technicalkorea.co.kr | 내선: 4567</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 문서 필터링 기능
|
||||
function filterDocuments(searchTerm) {
|
||||
const documents = document.querySelectorAll('.document-item');
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
documents.forEach(doc => {
|
||||
const title = doc.querySelector('h3').textContent.toLowerCase();
|
||||
const description = doc.querySelector('p').textContent.toLowerCase();
|
||||
|
||||
if (title.includes(term) || description.includes(term)) {
|
||||
doc.style.display = 'block';
|
||||
} else {
|
||||
doc.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('techSearchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
filterDocuments(this.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
fastapi-bridge/static/img/login-bg.jpeg
Normal file
BIN
fastapi-bridge/static/img/login-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
fastapi-bridge/static/img/logo.png
Normal file
BIN
fastapi-bridge/static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
fastapi-bridge/static/img/technicalkorea Logo.jpg
Normal file
BIN
fastapi-bridge/static/img/technicalkorea Logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
28071
fastapi-bridge/static/img/technicalkorea_Logo.ai
Normal file
28071
fastapi-bridge/static/img/technicalkorea_Logo.ai
Normal file
File diff suppressed because it is too large
Load Diff
28071
fastapi-bridge/static/img/technicalkorea_Logo.eps
Normal file
28071
fastapi-bridge/static/img/technicalkorea_Logo.eps
Normal file
File diff suppressed because it is too large
Load Diff
3390
fastapi-bridge/static/img/technicalkorea_Logo.svg
Normal file
3390
fastapi-bridge/static/img/technicalkorea_Logo.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 369 KiB |
80
fastapi-bridge/static/index.html
Normal file
80
fastapi-bridge/static/index.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>(주)테크니컬코리아 생산팀 포털</title>
|
||||
<link rel="stylesheet" href="css/login.css" />
|
||||
<link rel="icon" type="image/png" href="img/favicon.png">
|
||||
<style>
|
||||
/* 문서시스템 버튼 스타일 */
|
||||
.docs-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.docs-button {
|
||||
display: inline-block;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: none;
|
||||
padding: 15px 25px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.docs-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.docs-button {
|
||||
padding: 12px 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<img src="img/logo.png" alt="테크니컬코리아 로고" class="logo" />
|
||||
<h1>(주)테크니컬코리아</h1>
|
||||
<h3>생산팀 포털 로그인</h3>
|
||||
<form id="loginForm">
|
||||
<input type="text" id="username" placeholder="아이디" required autocomplete="username" />
|
||||
<input type="password" id="password" placeholder="비밀번호" required autocomplete="current-password" />
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
<div id="error" class="error-message"></div>
|
||||
|
||||
<!-- 📋 문서관리 시스템 섹션 -->
|
||||
<div class="docs-section">
|
||||
<div class="docs-title">회사 문서시스템</div>
|
||||
<a href="docs/" class="docs-button">
|
||||
문서관리 시스템
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ 모듈로 지정 (import 쓸 수 있도록) -->
|
||||
<script type="module" src="js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
fastapi-bridge/static/js/admin.js
Normal file
34
fastapi-bridge/static/js/admin.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// ✅ /js/admin.js (수정됨 - 중복 로딩 제거)
|
||||
async function initDashboard() {
|
||||
// 로그인 토큰 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ navbar, sidebar는 각각의 모듈에서 처리하도록 변경
|
||||
// load-navbar.js, load-sidebar.js가 자동으로 처리함
|
||||
|
||||
// ✅ 콘텐츠만 직접 로딩 (admin-sections.html이 자동 로딩됨)
|
||||
console.log('관리자 대시보드 초기화 완료');
|
||||
}
|
||||
|
||||
// ✅ 보조 함수 - 필요시 수동 컴포넌트 로딩용
|
||||
async function loadComponent(id, url) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const html = await res.text();
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.innerHTML = html;
|
||||
} else {
|
||||
console.warn(`요소를 찾을 수 없습니다: ${id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`컴포넌트 로딩 실패 (${url}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initDashboard);
|
||||
143
fastapi-bridge/static/js/api-config.js
Normal file
143
fastapi-bridge/static/js/api-config.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// api-config.js - nginx 프록시 대응 API 설정
|
||||
|
||||
function getApiBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
|
||||
console.log('🌐 감지된 환경:', { hostname, protocol, port });
|
||||
|
||||
// 🔗 nginx 프록시를 통한 접근 (권장)
|
||||
// nginx가 /api/ 요청을 백엔드로 프록시하므로 포트 없이 접근
|
||||
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' ||
|
||||
hostname.includes('.local') || hostname.includes('hyungi')) {
|
||||
|
||||
// 현재 웹서버의 도메인/IP를 그대로 사용하되 /api 경로만 추가
|
||||
const baseUrl = port && port !== '80' && port !== '443'
|
||||
? `${protocol}//${hostname}:${port}/api`
|
||||
: `${protocol}//${hostname}/api`;
|
||||
|
||||
console.log('✅ nginx 프록시 사용:', baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// 🚨 백업: 직접 접근 (nginx 프록시 실패시에만)
|
||||
console.warn('⚠️ 직접 API 접근 (백업 모드)');
|
||||
return `${protocol}//${hostname}:8000/api`;
|
||||
}
|
||||
|
||||
export const API = getApiBaseUrl();
|
||||
|
||||
export function ensureAuthenticated() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
alert('로그인이 필요합니다');
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getAuthHeaders() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
// 🔧 개선된 API 호출 함수 (에러 처리 강화)
|
||||
export async function apiCall(url, options = {}) {
|
||||
const defaultOptions = {
|
||||
headers: getAuthHeaders()
|
||||
};
|
||||
|
||||
const finalOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`📡 API 호출: ${url}`);
|
||||
const response = await fetch(url, finalOptions);
|
||||
|
||||
// 인증 만료 처리
|
||||
if (response.status === 401) {
|
||||
console.error('❌ 인증 만료');
|
||||
localStorage.removeItem('token');
|
||||
alert('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// 응답 실패 처리
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorData.message || errorMessage;
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패시 기본 메시지 사용
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(`✅ API 성공: ${url}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ API 오류 (${url}):`, error);
|
||||
|
||||
// 네트워크 오류 vs 서버 오류 구분
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
throw new Error('네트워크 연결 오류입니다. 인터넷 연결을 확인해주세요.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 디버깅 정보
|
||||
console.log('🔗 API Base URL:', API);
|
||||
console.log('🌐 Current Location:', {
|
||||
hostname: window.location.hostname,
|
||||
protocol: window.location.protocol,
|
||||
port: window.location.port,
|
||||
href: window.location.href
|
||||
});
|
||||
|
||||
// 🧪 API 연결 테스트 함수 (개발용)
|
||||
export async function testApiConnection() {
|
||||
try {
|
||||
console.log('🧪 API 연결 테스트 시작...');
|
||||
const response = await fetch(`${API}/health`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('✅ API 연결 성공!');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ API 연결 실패:', response.status);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ API 연결 오류:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 모드에서 자동 테스트
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname.startsWith('192.168.')) {
|
||||
setTimeout(() => {
|
||||
testApiConnection();
|
||||
}, 1000);
|
||||
}
|
||||
115
fastapi-bridge/static/js/api-helper.js
Normal file
115
fastapi-bridge/static/js/api-helper.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// /public/js/api-helper.js
|
||||
|
||||
import { API_BASE_URL } from './api-config.js';
|
||||
import { getToken, clearAuthData } from './auth.js';
|
||||
|
||||
/**
|
||||
* 로그인 API를 호출합니다. (인증이 필요 없는 public 요청)
|
||||
* @param {string} username - 사용자 아이디
|
||||
* @param {string} password - 사용자 비밀번호
|
||||
* @returns {Promise<object>} - API 응답 결과
|
||||
*/
|
||||
export async function login(username, password) {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
// API 에러 응답을 그대로 에러 객체로 던져서 호출부에서 처리하도록 함
|
||||
throw new Error(result.error || '로그인에 실패했습니다.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 인증이 필요한 API 요청을 위한 fetch 래퍼 함수
|
||||
* @param {string} endpoint - /로 시작하는 API 엔드포인트
|
||||
* @param {object} options - fetch 함수에 전달할 옵션
|
||||
* @returns {Promise<Response>} - fetch 응답 객체
|
||||
*/
|
||||
async function authFetch(endpoint, options = {}) {
|
||||
const token = getToken();
|
||||
|
||||
if (!token) {
|
||||
console.error('토큰이 없습니다. 로그인이 필요합니다.');
|
||||
clearAuthData(); // 인증 정보 정리
|
||||
window.location.href = '/index.html'; // 로그인 페이지로 리디렉션
|
||||
// 에러를 던져서 후속 실행을 중단
|
||||
throw new Error('인증 토큰이 없습니다.');
|
||||
}
|
||||
|
||||
const defaultHeaders = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
// 401 Unauthorized 에러 발생 시, 토큰이 유효하지 않다는 의미
|
||||
if (response.status === 401) {
|
||||
console.error('인증 실패. 토큰이 만료되었거나 유효하지 않습니다.');
|
||||
clearAuthData(); // 만료된 인증 정보 정리
|
||||
window.location.href = '/index.html';
|
||||
throw new Error('인증에 실패했습니다.');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 공통 API 요청 함수들
|
||||
|
||||
/**
|
||||
* GET 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
*/
|
||||
export async function apiGet(endpoint) {
|
||||
const response = await authFetch(endpoint);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {object} data - 전송할 데이터
|
||||
*/
|
||||
export async function apiPost(endpoint, data) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {object} data - 전송할 데이터
|
||||
*/
|
||||
export async function apiPut(endpoint, data) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청 헬퍼
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
*/
|
||||
export async function apiDelete(endpoint) {
|
||||
const response = await authFetch(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
1038
fastapi-bridge/static/js/attendance-validation.js
Normal file
1038
fastapi-bridge/static/js/attendance-validation.js
Normal file
File diff suppressed because it is too large
Load Diff
170
fastapi-bridge/static/js/attendance.js
Normal file
170
fastapi-bridge/static/js/attendance.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
const yearSel = document.getElementById('year');
|
||||
const monthSel = document.getElementById('month');
|
||||
const container = document.getElementById('attendanceTableContainer');
|
||||
|
||||
const holidays = [
|
||||
'2025-01-01','2025-01-27','2025-01-28','2025-01-29','2025-01-30','2025-01-31',
|
||||
'2025-03-01','2025-03-03','2025-05-01','2025-05-05','2025-05-06',
|
||||
'2025-06-03','2025-06-06','2025-08-15','2025-10-03','2025-10-09','2025-12-25'
|
||||
];
|
||||
|
||||
const leaveDefaults = {
|
||||
'김두수':16,'임영규':16,'반치원':16,'황인용':16,'표영진':15,
|
||||
'김윤섭':16,'이창호':16,'최광욱':16,'박현수':14,'조윤호':0
|
||||
};
|
||||
|
||||
let workers = [];
|
||||
|
||||
// ✅ 셀렉트 박스 옵션 + 기본 선택 추가
|
||||
function fillSelectOptions() {
|
||||
const currentY = new Date().getFullYear();
|
||||
const currentM = String(new Date().getMonth() + 1).padStart(2, '0');
|
||||
|
||||
for (let y = currentY; y <= currentY + 5; y++) {
|
||||
const selected = y === currentY ? 'selected' : '';
|
||||
yearSel.insertAdjacentHTML('beforeend', `<option value="${y}" ${selected}>${y}</option>`);
|
||||
}
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const selected = mm === currentM ? 'selected' : '';
|
||||
monthSel.insertAdjacentHTML('beforeend', `<option value="${mm}" ${selected}>${m}월</option>`);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 작업자 목록 불러오기
|
||||
async function fetchWorkers() {
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, { headers: getAuthHeaders() });
|
||||
workers = await res.json();
|
||||
workers.sort((a, b) => a.worker_id - b.worker_id);
|
||||
} catch (err) {
|
||||
alert('작업자 불러오기 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 출근부 불러오기 (해당 연도 전체)
|
||||
async function loadAttendance() {
|
||||
const year = yearSel.value;
|
||||
const month = monthSel.value;
|
||||
if (!year || !month) return alert('연도와 월을 선택하세요');
|
||||
|
||||
const lastDay = new Date(+year, +month, 0).getDate();
|
||||
const start = `${year}-01-01`;
|
||||
const end = `${year}-12-31`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports?start=${start}&end=${end}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
renderTable(data, year, month, lastDay);
|
||||
} catch (err) {
|
||||
alert('출근부 로딩 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 테이블 렌더링
|
||||
function renderTable(data, year, month, lastDay) {
|
||||
container.innerHTML = '';
|
||||
const weekdays = ['일','월','화','수','목','금','토'];
|
||||
const tbl = document.createElement('table');
|
||||
|
||||
// ⬆️ 헤더 구성
|
||||
let thead = `<thead><tr><th rowspan="2">작업자</th>`;
|
||||
for (let d = 1; d <= lastDay; d++) thead += `<th>${d}</th>`;
|
||||
thead += `<th class="divider" rowspan="2">잔업합계</th><th rowspan="2">사용연차</th><th rowspan="2">잔여연차</th></tr><tr>`;
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
const dow = new Date(+year, +month - 1, d).getDay();
|
||||
thead += `<th>${weekdays[dow]}</th>`;
|
||||
}
|
||||
thead += '</tr></thead>';
|
||||
tbl.innerHTML = thead;
|
||||
|
||||
// ⬇️ 본문
|
||||
workers.forEach(w => {
|
||||
// ✅ 월간 데이터 (표에 표시용)
|
||||
const recsThisMonth = data.filter(r =>
|
||||
r.worker_id === w.worker_id &&
|
||||
new Date(r.date).getFullYear() === +year &&
|
||||
new Date(r.date).getMonth() + 1 === +month
|
||||
);
|
||||
|
||||
// ✅ 연간 데이터 (연차 계산용)
|
||||
const recsThisYear = data.filter(r =>
|
||||
r.worker_id === w.worker_id &&
|
||||
new Date(r.date).getFullYear() === +year
|
||||
);
|
||||
|
||||
let otSum = 0;
|
||||
let row = `<tr><td>${w.worker_name}</td>`;
|
||||
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
const dd = String(d).padStart(2, '0');
|
||||
const date = `${year}-${month}-${dd}`;
|
||||
|
||||
const rec = recsThisMonth.find(r => {
|
||||
const rDate = new Date(r.date);
|
||||
const yyyy = rDate.getFullYear();
|
||||
const mm = String(rDate.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(rDate.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}` === date;
|
||||
});
|
||||
|
||||
const dow = new Date(+year, +month - 1, d).getDay();
|
||||
const isWe = dow === 0 || dow === 6;
|
||||
const isHo = holidays.includes(date);
|
||||
|
||||
let txt = '', cls = '';
|
||||
if (rec) {
|
||||
const ot = +rec.overtime_hours || 0;
|
||||
if (ot > 0) {
|
||||
txt = ot; cls = 'overtime-cell'; otSum += ot;
|
||||
} else if (rec.work_details) {
|
||||
const d = rec.work_details;
|
||||
if (['연차','반차','반반차','조퇴'].includes(d)) {
|
||||
txt = d; cls = 'leave';
|
||||
} else if (d === '유급') {
|
||||
txt = d; cls = 'paid-leave';
|
||||
} else if (d === '휴무') {
|
||||
txt = d; cls = 'holiday';
|
||||
} else {
|
||||
txt = d;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
txt = (isWe || isHo) ? '휴무' : '';
|
||||
cls = (isWe || isHo) ? 'holiday' : 'no-data';
|
||||
}
|
||||
|
||||
row += `<td class="${cls}">${txt}</td>`;
|
||||
}
|
||||
|
||||
const usedTot = recsThisYear
|
||||
.filter(r => ['연차','반차','반반차','조퇴'].includes(r.work_details))
|
||||
.reduce((s, r) => s + (
|
||||
r.work_details === '연차' ? 1 :
|
||||
r.work_details === '반차' ? 0.5 :
|
||||
r.work_details === '반반차' ? 0.25 : 0.75
|
||||
), 0);
|
||||
|
||||
const remain = (leaveDefaults[w.worker_name] || 0) - usedTot;
|
||||
|
||||
row += `<td class="divider overtime-sum">${otSum.toFixed(1)}</td>`;
|
||||
row += `<td>${usedTot.toFixed(2)}</td><td>${remain.toFixed(2)}</td></tr>`;
|
||||
row += `<tr class="separator"><td colspan="${lastDay + 4}"></td></tr>`;
|
||||
|
||||
tbl.insertAdjacentHTML('beforeend', row);
|
||||
});
|
||||
|
||||
container.appendChild(tbl);
|
||||
}
|
||||
|
||||
// ✅ 초기 로딩
|
||||
fillSelectOptions();
|
||||
fetchWorkers().then(() => {
|
||||
loadAttendance(); // 자동 조회
|
||||
});
|
||||
document.getElementById('loadAttendance').addEventListener('click', loadAttendance);
|
||||
27
fastapi-bridge/static/js/auth-check.js
Normal file
27
fastapi-bridge/static/js/auth-check.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// /js/auth-check.js
|
||||
import { isLoggedIn, getUser, clearAuthData } from './auth.js';
|
||||
|
||||
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
|
||||
(function() {
|
||||
if (!isLoggedIn()) {
|
||||
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData(); // 만약을 위해 한번 더 정리
|
||||
window.location.href = '/index.html';
|
||||
return; // 이후 코드 실행 방지
|
||||
}
|
||||
|
||||
const currentUser = getUser();
|
||||
|
||||
// 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우)
|
||||
if (!currentUser || !currentUser.username || !currentUser.role) {
|
||||
console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ ${currentUser.username}(${currentUser.role})님 인증 성공.`);
|
||||
|
||||
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
|
||||
// 전역 변수 할당(window.currentUser) 제거.
|
||||
})();
|
||||
76
fastapi-bridge/static/js/auth.js
Normal file
76
fastapi-bridge/static/js/auth.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// js/auth.js
|
||||
|
||||
/**
|
||||
* JWT 토큰을 디코딩하여 페이로드(내용)를 반환합니다.
|
||||
* @param {string} token - JWT 토큰
|
||||
* @returns {object|null} - 디코딩된 페이로드 객체 또는 파싱 실패 시 null
|
||||
*/
|
||||
export function parseJwt(token) {
|
||||
try {
|
||||
// 토큰의 두 번째 부분(payload)을 base64 디코딩하고 JSON으로 파싱
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch (e) {
|
||||
console.error("잘못된 토큰입니다.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 인증 토큰을 가져옵니다.
|
||||
* @returns {string|null} - 저장된 토큰 또는 토큰이 없을 경우 null
|
||||
*/
|
||||
export function getToken() {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 사용자 정보를 가져옵니다.
|
||||
* @returns {object|null} - 저장된 사용자 객체 또는 정보가 없을 경우 null
|
||||
*/
|
||||
export function getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
try {
|
||||
return user ? JSON.parse(user) : null;
|
||||
} catch(e) {
|
||||
console.error("사용자 정보를 파싱하는 데 실패했습니다.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 성공 후 토큰과 사용자 정보를 localStorage에 저장합니다.
|
||||
* @param {string} token - 서버에서 받은 JWT 토큰
|
||||
* @param {object} user - 서버에서 받은 사용자 정보 객체
|
||||
*/
|
||||
export function saveAuthData(token, user) {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 시 localStorage에서 인증 정보를 제거합니다.
|
||||
*/
|
||||
export function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 로그인 상태인지 확인합니다.
|
||||
* @returns {boolean} - 로그인 상태이면 true, 아니면 false
|
||||
*/
|
||||
export function isLoggedIn() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 선택 사항: 토큰 만료 여부 확인 로직 추가 가능
|
||||
// const payload = parseJwt(token);
|
||||
// if (payload && payload.exp * 1000 > Date.now()) {
|
||||
// return true;
|
||||
// }
|
||||
// return false;
|
||||
|
||||
return !!token;
|
||||
}
|
||||
59
fastapi-bridge/static/js/calendar.js
Normal file
59
fastapi-bridge/static/js/calendar.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ✅ /js/calendar.js
|
||||
export function renderCalendar(containerId, onDateSelect) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
let currentDate = new Date();
|
||||
let selectedDateStr = '';
|
||||
|
||||
function drawCalendar(date) {
|
||||
container.innerHTML = '';
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const lastDate = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'nav';
|
||||
const prev = document.createElement('button');
|
||||
prev.textContent = '◀';
|
||||
prev.addEventListener('click', () => {
|
||||
currentDate = new Date(year, month - 1, 1);
|
||||
drawCalendar(currentDate);
|
||||
});
|
||||
const title = document.createElement('div');
|
||||
title.innerHTML = `<strong>${year}년 ${month + 1}월</strong>`;
|
||||
const next = document.createElement('button');
|
||||
next.textContent = '▶';
|
||||
next.addEventListener('click', () => {
|
||||
currentDate = new Date(year, month + 1, 1);
|
||||
drawCalendar(currentDate);
|
||||
});
|
||||
nav.append(prev, title, next);
|
||||
container.appendChild(nav);
|
||||
|
||||
['일','월','화','수','목','금','토'].forEach(day => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = `<strong>${day}</strong>`;
|
||||
container.appendChild(el);
|
||||
});
|
||||
|
||||
for (let i = 0; i < firstDay; i++) container.appendChild(document.createElement('div'));
|
||||
|
||||
for (let i = 1; i <= lastDate; i++) {
|
||||
const btn = document.createElement('button');
|
||||
const ymd = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
|
||||
btn.textContent = i;
|
||||
btn.className = (ymd === selectedDateStr) ? 'selected-date' : '';
|
||||
btn.addEventListener('click', () => {
|
||||
selectedDateStr = ymd;
|
||||
drawCalendar(currentDate);
|
||||
onDateSelect(ymd);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
drawCalendar(currentDate);
|
||||
}
|
||||
|
||||
211
fastapi-bridge/static/js/change-password.js
Normal file
211
fastapi-bridge/static/js/change-password.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// js/change-password.js
|
||||
// 개인 비밀번호 변경 페이지 JavaScript
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
// DOM 요소
|
||||
const form = document.getElementById('changePasswordForm');
|
||||
const messageArea = document.getElementById('message-area');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
|
||||
// 비밀번호 토글 기능
|
||||
document.querySelectorAll('.password-toggle').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const input = document.getElementById(targetId);
|
||||
|
||||
if (input) {
|
||||
const isPassword = input.type === 'password';
|
||||
input.type = isPassword ? 'text' : 'password';
|
||||
this.textContent = isPassword ? '👁️🗨️' : '👁️';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 초기화 버튼
|
||||
resetBtn?.addEventListener('click', () => {
|
||||
form.reset();
|
||||
clearMessages();
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
});
|
||||
|
||||
// 메시지 표시 함수
|
||||
function showMessage(type, message) {
|
||||
messageArea.innerHTML = `
|
||||
<div class="message-box ${type}">
|
||||
${type === 'error' ? '❌' : '✅'} ${message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 에러 메시지는 5초 후 자동 제거
|
||||
if (type === 'error') {
|
||||
setTimeout(clearMessages, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messageArea.innerHTML = '';
|
||||
}
|
||||
|
||||
// 비밀번호 강도 체크
|
||||
async function checkPasswordStrength(password) {
|
||||
if (!password) {
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/check-password-strength`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
updatePasswordStrengthUI(result);
|
||||
} catch (error) {
|
||||
console.error('Password strength check error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호 강도 UI 업데이트
|
||||
function updatePasswordStrengthUI(strength) {
|
||||
const container = document.getElementById('passwordStrength');
|
||||
if (!container) return;
|
||||
|
||||
const colors = {
|
||||
0: '#f44336',
|
||||
1: '#ff9800',
|
||||
2: '#ffc107',
|
||||
3: '#4caf50',
|
||||
4: '#2196f3'
|
||||
};
|
||||
|
||||
const strengthText = strength.strengthText || '비밀번호를 입력하세요';
|
||||
const color = colors[strength.strength] || '#ccc';
|
||||
const percentage = (strength.score / strength.maxScore) * 100;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="margin-top: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 0.85rem; color: ${color}; font-weight: 500;">
|
||||
${strengthText}
|
||||
</span>
|
||||
<span style="font-size: 0.8rem; color: #666;">
|
||||
${strength.score}/${strength.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
<div style="height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden;">
|
||||
<div style="width: ${percentage}%; height: 100%; background: ${color}; transition: all 0.3s;"></div>
|
||||
</div>
|
||||
${strength.feedback && strength.feedback.length > 0 ? `
|
||||
<ul style="margin-top: 10px; font-size: 0.8rem; color: #666; padding-left: 20px;">
|
||||
${strength.feedback.map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 비밀번호 입력 이벤트
|
||||
let strengthCheckTimer;
|
||||
document.getElementById('newPassword')?.addEventListener('input', (e) => {
|
||||
clearTimeout(strengthCheckTimer);
|
||||
strengthCheckTimer = setTimeout(() => {
|
||||
checkPasswordStrength(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// 폼 제출
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
clearMessages();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 유효성 검사
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
showMessage('error', '모든 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 버튼 상태 변경
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span>⏳</span><span>처리 중...</span>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showMessage('success', '비밀번호가 성공적으로 변경되었습니다.');
|
||||
form.reset();
|
||||
document.getElementById('passwordStrength').innerHTML = '';
|
||||
|
||||
// 카운트다운 시작
|
||||
let countdown = 3;
|
||||
const countdownInterval = setInterval(() => {
|
||||
showMessage('success',
|
||||
`비밀번호가 변경되었습니다. ${countdown}초 후 로그인 페이지로 이동합니다.`
|
||||
);
|
||||
countdown--;
|
||||
|
||||
if (countdown < 0) {
|
||||
clearInterval(countdownInterval);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} else {
|
||||
const errorMessage = result.error || '비밀번호 변경에 실패했습니다.';
|
||||
showMessage('error', errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 로드 시 현재 사용자 정보 표시
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
console.log('🔐 비밀번호 변경 페이지 로드됨');
|
||||
console.log('👤 현재 사용자:', user.username || 'Unknown');
|
||||
});
|
||||
66
fastapi-bridge/static/js/daily-issue-api.js
Normal file
66
fastapi-bridge/static/js/daily-issue-api.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// /js/daily-issue-api.js
|
||||
import { apiGet, apiPost } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 이슈 보고서 작성을 위해 필요한 초기 데이터(프로젝트, 이슈 유형)를 가져옵니다.
|
||||
* @returns {Promise<{projects: Array, issueTypes: Array}>}
|
||||
*/
|
||||
export async function getInitialData() {
|
||||
try {
|
||||
const [projects, issueTypes] = await Promise.all([
|
||||
apiGet('/projects'),
|
||||
apiGet('/issue-types')
|
||||
]);
|
||||
return { projects, issueTypes };
|
||||
} catch (error) {
|
||||
console.error('이슈 보고서 초기 데이터 로딩 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜에 근무한 작업자 목록을 가져옵니다.
|
||||
* @param {string} date - 조회할 날짜 (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>} - 작업자 목록
|
||||
*/
|
||||
export async function getWorkersByDate(date) {
|
||||
try {
|
||||
// 백엔드에 해당 날짜의 작업자 목록을 요청하는 API가 있다고 가정합니다.
|
||||
// (예: /api/workers?work_date=YYYY-MM-DD)
|
||||
// 현재는 기존 로직을 최대한 활용하여 구현합니다.
|
||||
let workers = [];
|
||||
const reports = await apiGet(`/daily-work-reports?date=${date}`);
|
||||
|
||||
if (reports && reports.length > 0) {
|
||||
const workerMap = new Map();
|
||||
reports.forEach(r => {
|
||||
if (!workerMap.has(r.worker_id)) {
|
||||
workerMap.set(r.worker_id, { worker_id: r.worker_id, worker_name: r.worker_name });
|
||||
}
|
||||
});
|
||||
workers = Array.from(workerMap.values());
|
||||
} else {
|
||||
// 보고서가 없으면 전체 작업자 목록을 가져옵니다.
|
||||
workers = await apiGet('/workers');
|
||||
}
|
||||
return workers.sort((a, b) => a.worker_name.localeCompare(b.worker_name));
|
||||
} catch (error) {
|
||||
console.error(`${date}의 작업자 목록 로딩 실패:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성된 이슈 보고서 데이터를 서버에 전송합니다.
|
||||
* @param {object} issueData - 전송할 이슈 데이터
|
||||
* @returns {Promise<object>} - 서버 응답 결과
|
||||
*/
|
||||
export async function createIssueReport(issueData) {
|
||||
try {
|
||||
const result = await apiPost('/issue-reports', issueData);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('이슈 보고서 생성 요청 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
103
fastapi-bridge/static/js/daily-issue-ui.js
Normal file
103
fastapi-bridge/static/js/daily-issue-ui.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// /js/daily-issue-ui.js
|
||||
|
||||
const DOM = {
|
||||
dateSelect: document.getElementById('dateSelect'),
|
||||
projectSelect: document.getElementById('projectSelect'),
|
||||
issueTypeSelect: document.getElementById('issueTypeSelect'),
|
||||
timeStart: document.getElementById('timeStart'),
|
||||
timeEnd: document.getElementById('timeEnd'),
|
||||
workerList: document.getElementById('workerList'),
|
||||
form: document.getElementById('issueForm'),
|
||||
submitBtn: document.getElementById('submitBtn'),
|
||||
};
|
||||
|
||||
function createOption(value, text) {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = text;
|
||||
return option;
|
||||
}
|
||||
|
||||
export function populateProjects(projects) {
|
||||
DOM.projectSelect.innerHTML = '<option value="">-- 프로젝트 선택 --</option>';
|
||||
if (Array.isArray(projects)) {
|
||||
projects.forEach(p => DOM.projectSelect.appendChild(createOption(p.project_id, p.project_name)));
|
||||
}
|
||||
}
|
||||
|
||||
export function populateIssueTypes(issueTypes) {
|
||||
DOM.issueTypeSelect.innerHTML = '<option value="">-- 이슈 유형 선택 --</option>';
|
||||
if (Array.isArray(issueTypes)) {
|
||||
issueTypes.forEach(t => DOM.issueTypeSelect.appendChild(createOption(t.issue_type_id, `${t.category}:${t.subcategory}`)));
|
||||
}
|
||||
}
|
||||
|
||||
export function populateTimeOptions() {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
for (let m of [0, 30]) {
|
||||
const time = `${String(h).padStart(2, '0')}:${m === 0 ? '00' : '30'}`;
|
||||
DOM.timeStart.appendChild(createOption(time, time));
|
||||
DOM.timeEnd.appendChild(createOption(time, time.replace('00:00', '24:00')));
|
||||
}
|
||||
}
|
||||
DOM.timeEnd.value = "24:00"; // 기본값 설정
|
||||
}
|
||||
|
||||
export function renderWorkerList(workers) {
|
||||
DOM.workerList.innerHTML = '';
|
||||
if (!Array.isArray(workers) || workers.length === 0) {
|
||||
DOM.workerList.textContent = '선택 가능한 작업자가 없습니다.';
|
||||
return;
|
||||
}
|
||||
workers.forEach(worker => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn';
|
||||
btn.textContent = worker.worker_name;
|
||||
btn.dataset.id = worker.worker_id;
|
||||
btn.addEventListener('click', () => btn.classList.toggle('selected'));
|
||||
DOM.workerList.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
export function getFormData() {
|
||||
const selectedWorkers = [...DOM.workerList.querySelectorAll('.btn.selected')].map(b => b.dataset.id);
|
||||
|
||||
if (selectedWorkers.length === 0) {
|
||||
alert('작업자를 한 명 이상 선택해주세요.');
|
||||
return null;
|
||||
}
|
||||
if (DOM.timeEnd.value <= DOM.timeStart.value) {
|
||||
alert('종료 시간은 시작 시간보다 이후여야 합니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const formData = new FormData(DOM.form);
|
||||
const data = {
|
||||
date: formData.get('dateSelect'), // input name 속성이 없어 직접 가져옴
|
||||
project_id: DOM.projectSelect.value,
|
||||
issue_type_id: DOM.issueTypeSelect.value,
|
||||
start_time: DOM.timeStart.value,
|
||||
end_time: DOM.timeEnd.value,
|
||||
worker_ids: selectedWorkers, // worker_id -> worker_ids 로 명확하게 변경
|
||||
};
|
||||
|
||||
for (const key in data) {
|
||||
if (!data[key] || (Array.isArray(data[key]) && data[key].length === 0)) {
|
||||
alert('모든 필수 항목을 입력해주세요.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function setSubmitButtonState(isLoading) {
|
||||
if (isLoading) {
|
||||
DOM.submitBtn.disabled = true;
|
||||
DOM.submitBtn.textContent = '등록 중...';
|
||||
} else {
|
||||
DOM.submitBtn.disabled = false;
|
||||
DOM.submitBtn.textContent = '등록';
|
||||
}
|
||||
}
|
||||
89
fastapi-bridge/static/js/daily-issue.js
Normal file
89
fastapi-bridge/static/js/daily-issue.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// /js/daily-issue.js
|
||||
|
||||
import { getInitialData, getWorkersByDate, createIssueReport } from './daily-issue-api.js';
|
||||
import {
|
||||
populateProjects,
|
||||
populateIssueTypes,
|
||||
populateTimeOptions,
|
||||
renderWorkerList,
|
||||
getFormData,
|
||||
setSubmitButtonState
|
||||
} from './daily-issue-ui.js';
|
||||
|
||||
const dateSelect = document.getElementById('dateSelect');
|
||||
const form = document.getElementById('issueForm');
|
||||
|
||||
/**
|
||||
* 날짜가 변경될 때마다 해당 날짜의 작업자 목록을 다시 불러옵니다.
|
||||
*/
|
||||
async function handleDateChange() {
|
||||
const selectedDate = dateSelect.value;
|
||||
if (!selectedDate) {
|
||||
document.getElementById('workerList').textContent = '날짜를 먼저 선택하세요.';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('workerList').textContent = '작업자 목록을 불러오는 중...';
|
||||
try {
|
||||
const workers = await getWorkersByDate(selectedDate);
|
||||
renderWorkerList(workers);
|
||||
} catch (error) {
|
||||
document.getElementById('workerList').textContent = '작업자 목록 로딩에 실패했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 제출 이벤트를 처리합니다.
|
||||
*/
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const issueData = getFormData();
|
||||
|
||||
if (!issueData) return; // 유효성 검사 실패
|
||||
|
||||
setSubmitButtonState(true);
|
||||
try {
|
||||
const result = await createIssueReport(issueData);
|
||||
if (result.success) {
|
||||
alert('✅ 이슈가 성공적으로 등록되었습니다.');
|
||||
form.reset(); // 폼 초기화
|
||||
dateSelect.value = new Date().toISOString().split('T')[0]; // 날짜 오늘로 리셋
|
||||
handleDateChange(); // 작업자 목록 새로고침
|
||||
} else {
|
||||
throw new Error(result.error || '알 수 없는 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`🚨 등록 실패: ${error.message}`);
|
||||
} finally {
|
||||
setSubmitButtonState(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
async function initializePage() {
|
||||
// 오늘 날짜 기본 설정
|
||||
dateSelect.value = new Date().toISOString().split('T')[0];
|
||||
|
||||
populateTimeOptions();
|
||||
|
||||
// 프로젝트, 이슈유형, 작업자 목록을 병렬로 로드
|
||||
try {
|
||||
const [initialData] = await Promise.all([
|
||||
getInitialData(),
|
||||
handleDateChange() // 초기 작업자 목록 로드
|
||||
]);
|
||||
populateProjects(initialData.projects);
|
||||
populateIssueTypes(initialData.issueTypes);
|
||||
} catch (error) {
|
||||
alert('페이지 초기화 중 오류가 발생했습니다. 새로고침 해주세요.');
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
dateSelect.addEventListener('change', handleDateChange);
|
||||
form.addEventListener('submit', handleSubmit);
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
1009
fastapi-bridge/static/js/daily-report-viewer 복사본.js
Normal file
1009
fastapi-bridge/static/js/daily-report-viewer 복사본.js
Normal file
File diff suppressed because it is too large
Load Diff
81
fastapi-bridge/static/js/daily-report-viewer.js
Normal file
81
fastapi-bridge/static/js/daily-report-viewer.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// /js/daily-report-viewer.js
|
||||
|
||||
import { fetchReportData } from './report-viewer-api.js';
|
||||
import { renderReport, processReportData, showLoading, showError } from './report-viewer-ui.js';
|
||||
import { exportToExcel, printReport } from './report-viewer-export.js';
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
// 전역 상태: 현재 화면에 표시된 데이터
|
||||
let currentProcessedData = null;
|
||||
|
||||
/**
|
||||
* 날짜를 기준으로 보고서를 검색하고 화면에 렌더링합니다.
|
||||
*/
|
||||
async function searchReports() {
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
const selectedDate = dateInput.value;
|
||||
|
||||
if (!selectedDate) {
|
||||
showError('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
currentProcessedData = null; // 새 검색이 시작되면 이전 데이터 초기화
|
||||
|
||||
try {
|
||||
const rawData = await fetchReportData(selectedDate);
|
||||
currentProcessedData = processReportData(rawData, selectedDate);
|
||||
renderReport(currentProcessedData);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
renderReport(null); // 에러 발생 시 데이터 없는 화면 표시
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지의 모든 이벤트 리스너를 설정합니다.
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
document.getElementById('searchBtn')?.addEventListener('click', searchReports);
|
||||
document.getElementById('todayBtn')?.addEventListener('click', () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('reportDate').value = today;
|
||||
searchReports();
|
||||
});
|
||||
|
||||
document.getElementById('reportDate')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') searchReports();
|
||||
});
|
||||
|
||||
document.getElementById('exportExcelBtn')?.addEventListener('click', () => {
|
||||
exportToExcel(currentProcessedData);
|
||||
});
|
||||
|
||||
document.getElementById('printBtn')?.addEventListener('click', printReport);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지가 처음 로드될 때 실행되는 초기화 함수
|
||||
*/
|
||||
function initializePage() {
|
||||
// auth.js를 사용하여 인증 상태 확인
|
||||
const user = getUser();
|
||||
if (!user) {
|
||||
showError('로그인이 필요합니다. 2초 후 로그인 페이지로 이동합니다.');
|
||||
setTimeout(() => window.location.href = '/index.html', 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
setupEventListeners();
|
||||
|
||||
// 페이지 로드 시 오늘 날짜로 자동 검색
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
dateInput.value = new Date().toISOString().split('T')[0];
|
||||
searchReports();
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
897
fastapi-bridge/static/js/daily-work-report.js
Normal file
897
fastapi-bridge/static/js/daily-work-report.js
Normal file
@@ -0,0 +1,897 @@
|
||||
// daily-work-report.js - 통합 API 설정 적용 버전
|
||||
|
||||
// =================================================================
|
||||
// 🌐 통합 API 설정 import
|
||||
// =================================================================
|
||||
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let workTypes = [];
|
||||
let workStatusTypes = [];
|
||||
let errorTypes = [];
|
||||
let workers = [];
|
||||
let projects = [];
|
||||
let selectedWorkers = new Set();
|
||||
let workEntryCounter = 0;
|
||||
let currentStep = 1;
|
||||
let editingWorkId = null; // 수정 중인 작업 ID
|
||||
|
||||
// 한국 시간 기준 오늘 날짜 가져오기
|
||||
function getKoreaToday() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
function getCurrentUser() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return null;
|
||||
|
||||
const payloadBase64 = token.split('.')[1];
|
||||
if (payloadBase64) {
|
||||
const payload = JSON.parse(atob(payloadBase64));
|
||||
console.log('토큰에서 추출한 사용자 정보:', payload);
|
||||
return payload;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('토큰에서 사용자 정보 추출 실패:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
|
||||
if (userInfo) {
|
||||
const parsed = JSON.parse(userInfo);
|
||||
console.log('localStorage에서 가져온 사용자 정보:', parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 메시지 표시
|
||||
function showMessage(message, type = 'info') {
|
||||
const container = document.getElementById('message-container');
|
||||
container.innerHTML = `<div class="message ${type}">${message}</div>`;
|
||||
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
hideMessage();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideMessage() {
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
}
|
||||
|
||||
// 단계 이동
|
||||
function goToStep(stepNumber) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const step = document.getElementById(`step${i}`);
|
||||
if (step) {
|
||||
step.classList.remove('active', 'completed');
|
||||
if (i < stepNumber) {
|
||||
step.classList.add('completed');
|
||||
const stepNum = step.querySelector('.step-number');
|
||||
if (stepNum) stepNum.classList.add('completed');
|
||||
} else if (i === stepNumber) {
|
||||
step.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
currentStep = stepNumber;
|
||||
}
|
||||
|
||||
// 초기 데이터 로드 (통합 API 사용)
|
||||
async function loadData() {
|
||||
try {
|
||||
showMessage('데이터를 불러오는 중...', 'loading');
|
||||
|
||||
console.log('🔗 통합 API 설정을 사용한 기본 데이터 로딩 시작...');
|
||||
await loadWorkers();
|
||||
await loadProjects();
|
||||
await loadWorkTypes();
|
||||
await loadWorkStatusTypes();
|
||||
await loadErrorTypes();
|
||||
|
||||
console.log('로드된 작업자 수:', workers.length);
|
||||
console.log('로드된 프로젝트 수:', projects.length);
|
||||
console.log('작업 유형 수:', workTypes.length);
|
||||
|
||||
populateWorkerGrid();
|
||||
hideMessage();
|
||||
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error);
|
||||
showMessage('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
console.log('Workers API 호출 중... (통합 API 사용)');
|
||||
const data = await apiCall(`${API}/workers`);
|
||||
workers = Array.isArray(data) ? data : (data.workers || []);
|
||||
console.log('✅ Workers 로드 성공:', workers.length);
|
||||
} catch (error) {
|
||||
console.error('작업자 로딩 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
console.log('Projects API 호출 중... (통합 API 사용)');
|
||||
const data = await apiCall(`${API}/projects`);
|
||||
projects = Array.isArray(data) ? data : (data.projects || []);
|
||||
console.log('✅ Projects 로드 성공:', projects.length);
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로딩 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const data = await apiCall(`${API}/daily-work-reports/work-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workTypes = data;
|
||||
console.log('✅ 작업 유형 API 사용 (통합 설정)');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
} catch (error) {
|
||||
console.log('⚠️ 작업 유형 API 사용 불가, 기본값 사용');
|
||||
workTypes = [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkStatusTypes() {
|
||||
try {
|
||||
const data = await apiCall(`${API}/daily-work-reports/work-status-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workStatusTypes = data;
|
||||
console.log('✅ 업무 상태 유형 API 사용 (통합 설정)');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
} catch (error) {
|
||||
console.log('⚠️ 업무 상태 유형 API 사용 불가, 기본값 사용');
|
||||
workStatusTypes = [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadErrorTypes() {
|
||||
try {
|
||||
const data = await apiCall(`${API}/daily-work-reports/error-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
errorTypes = data;
|
||||
console.log('✅ 에러 유형 API 사용 (통합 설정)');
|
||||
return;
|
||||
}
|
||||
throw new Error('API 실패');
|
||||
} catch (error) {
|
||||
console.log('⚠️ 에러 유형 API 사용 불가, 기본값 사용');
|
||||
errorTypes = [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 그리드 생성
|
||||
function populateWorkerGrid() {
|
||||
const grid = document.getElementById('workerGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
workers.forEach(worker => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'worker-btn';
|
||||
btn.textContent = worker.worker_name;
|
||||
btn.dataset.id = worker.worker_id;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
toggleWorkerSelection(worker.worker_id, btn);
|
||||
});
|
||||
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
// 작업자 선택 토글
|
||||
function toggleWorkerSelection(workerId, btnElement) {
|
||||
if (selectedWorkers.has(workerId)) {
|
||||
selectedWorkers.delete(workerId);
|
||||
btnElement.classList.remove('selected');
|
||||
} else {
|
||||
selectedWorkers.add(workerId);
|
||||
btnElement.classList.add('selected');
|
||||
}
|
||||
|
||||
const nextBtn = document.getElementById('nextStep2');
|
||||
nextBtn.disabled = selectedWorkers.size === 0;
|
||||
}
|
||||
|
||||
// 작업 항목 추가
|
||||
function addWorkEntry() {
|
||||
const container = document.getElementById('workEntriesList');
|
||||
workEntryCounter++;
|
||||
|
||||
const entryDiv = document.createElement('div');
|
||||
entryDiv.className = 'work-entry';
|
||||
entryDiv.dataset.id = workEntryCounter;
|
||||
|
||||
entryDiv.innerHTML = `
|
||||
<div class="work-entry-header">
|
||||
<div class="work-entry-title">작업 ${workEntryCounter}</div>
|
||||
<button type="button" class="remove-work-btn" onclick="removeWorkEntry(${workEntryCounter})">×</button>
|
||||
</div>
|
||||
|
||||
<div class="work-entry-row">
|
||||
<div class="form-group">
|
||||
<label>🏗️ 프로젝트</label>
|
||||
<select class="large-select project-select" required>
|
||||
<option value="">프로젝트 선택</option>
|
||||
${projects.map(p => `<option value="${p.project_id}">${p.project_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>⚙️ 작업 유형</label>
|
||||
<select class="large-select work-type-select" required>
|
||||
<option value="">작업 유형 선택</option>
|
||||
${workTypes.map(wt => `<option value="${wt.id}">${wt.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="work-entry-row">
|
||||
<div class="form-group">
|
||||
<label>📊 업무 상태</label>
|
||||
<select class="large-select work-status-select" required>
|
||||
<option value="">업무 상태 선택</option>
|
||||
${workStatusTypes.map(ws => `<option value="${ws.id}">${ws.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group error-type-section">
|
||||
<label>❌ 에러 유형</label>
|
||||
<select class="large-select error-type-select">
|
||||
<option value="">에러 유형 선택</option>
|
||||
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-input-row">
|
||||
<div class="form-group">
|
||||
<label>⏰ 작업 시간</label>
|
||||
<input type="number" class="large-select time-input"
|
||||
placeholder="시간 입력"
|
||||
min="0"
|
||||
max="24"
|
||||
step="0.5"
|
||||
required>
|
||||
<div class="quick-time-buttons">
|
||||
<div class="quick-time-btn" data-hours="1">1시간</div>
|
||||
<div class="quick-time-btn" data-hours="2">2시간</div>
|
||||
<div class="quick-time-btn" data-hours="4">4시간</div>
|
||||
<div class="quick-time-btn" data-hours="8">8시간</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(entryDiv);
|
||||
setupWorkEntryEvents(entryDiv);
|
||||
}
|
||||
|
||||
// 작업 항목 이벤트 설정
|
||||
function setupWorkEntryEvents(entryDiv) {
|
||||
const timeInput = entryDiv.querySelector('.time-input');
|
||||
timeInput.addEventListener('input', updateTotalHours);
|
||||
|
||||
entryDiv.querySelectorAll('.quick-time-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
timeInput.value = btn.dataset.hours;
|
||||
updateTotalHours();
|
||||
});
|
||||
});
|
||||
|
||||
const workStatusSelect = entryDiv.querySelector('.work-status-select');
|
||||
const errorTypeSection = entryDiv.querySelector('.error-type-section');
|
||||
|
||||
workStatusSelect.addEventListener('change', (e) => {
|
||||
if (e.target.value === '2') {
|
||||
errorTypeSection.classList.add('visible');
|
||||
errorTypeSection.querySelector('.error-type-select').required = true;
|
||||
} else {
|
||||
errorTypeSection.classList.remove('visible');
|
||||
errorTypeSection.querySelector('.error-type-select').required = false;
|
||||
errorTypeSection.querySelector('.error-type-select').value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 작업 항목 제거
|
||||
function removeWorkEntry(id) {
|
||||
const entry = document.querySelector(`[data-id="${id}"]`);
|
||||
if (entry) {
|
||||
entry.remove();
|
||||
updateTotalHours();
|
||||
}
|
||||
}
|
||||
|
||||
// 총 시간 업데이트
|
||||
function updateTotalHours() {
|
||||
const timeInputs = document.querySelectorAll('.time-input');
|
||||
let total = 0;
|
||||
|
||||
timeInputs.forEach(input => {
|
||||
const value = parseFloat(input.value) || 0;
|
||||
total += value;
|
||||
});
|
||||
|
||||
const display = document.getElementById('totalHoursDisplay');
|
||||
display.textContent = `총 작업시간: ${total}시간`;
|
||||
|
||||
if (total > 24) {
|
||||
display.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)';
|
||||
display.textContent += ' ⚠️ 24시간 초과';
|
||||
} else {
|
||||
display.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
}
|
||||
}
|
||||
|
||||
// 저장 함수 (통합 API 사용)
|
||||
async function saveWorkReport() {
|
||||
const reportDate = document.getElementById('reportDate').value;
|
||||
|
||||
if (!reportDate || selectedWorkers.size === 0) {
|
||||
showMessage('날짜와 작업자를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = document.querySelectorAll('.work-entry');
|
||||
if (entries.length === 0) {
|
||||
showMessage('최소 하나의 작업을 추가해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newWorkEntries = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const projectId = entry.querySelector('.project-select').value;
|
||||
const workTypeId = entry.querySelector('.work-type-select').value;
|
||||
const workStatusId = entry.querySelector('.work-status-select').value;
|
||||
const errorTypeId = entry.querySelector('.error-type-select').value;
|
||||
const workHours = entry.querySelector('.time-input').value;
|
||||
|
||||
if (!projectId || !workTypeId || !workStatusId || !workHours) {
|
||||
showMessage('모든 작업 항목을 완성해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
newWorkEntries.push({
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
work_status_id: parseInt(workStatusId),
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_hours: parseFloat(workHours)
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '💾 저장 중...';
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
let totalSaved = 0;
|
||||
let totalFailed = 0;
|
||||
const failureDetails = [];
|
||||
|
||||
for (const workerId of selectedWorkers) {
|
||||
const requestData = {
|
||||
report_date: reportDate,
|
||||
worker_id: parseInt(workerId),
|
||||
work_entries: newWorkEntries,
|
||||
created_by: currentUser?.user_id || currentUser?.id
|
||||
};
|
||||
|
||||
console.log('전송 데이터 (통합 API 사용):', requestData);
|
||||
|
||||
try {
|
||||
const result = await apiCall(`${API}/daily-work-reports`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
console.log('✅ 저장 성공 (통합 API):', result);
|
||||
totalSaved++;
|
||||
} catch (error) {
|
||||
console.error('❌ 저장 실패:', error);
|
||||
totalFailed++;
|
||||
|
||||
const workerName = workers.find(w => w.worker_id == workerId)?.worker_name || '알 수 없음';
|
||||
failureDetails.push(`${workerName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSaved > 0 && totalFailed === 0) {
|
||||
showMessage(`✅ ${totalSaved}명의 작업보고서가 성공적으로 저장되었습니다!`, 'success');
|
||||
} else if (totalSaved > 0 && totalFailed > 0) {
|
||||
showMessage(`⚠️ ${totalSaved}명 성공, ${totalFailed}명 실패. 실패: ${failureDetails.join(', ')}`, 'warning');
|
||||
} else {
|
||||
showMessage(`❌ 모든 저장이 실패했습니다. 상세: ${failureDetails.join(', ')}`, 'error');
|
||||
}
|
||||
|
||||
if (totalSaved > 0) {
|
||||
setTimeout(() => {
|
||||
refreshTodayWorkers();
|
||||
resetForm();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
showMessage('저장 중 예기치 못한 오류가 발생했습니다: ' + error.message, 'error');
|
||||
} finally {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '💾 작업보고서 저장';
|
||||
}
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
function resetForm() {
|
||||
goToStep(1);
|
||||
|
||||
selectedWorkers.clear();
|
||||
document.querySelectorAll('.worker-btn.selected').forEach(btn => {
|
||||
btn.classList.remove('selected');
|
||||
});
|
||||
|
||||
const container = document.getElementById('workEntriesList');
|
||||
container.innerHTML = '';
|
||||
|
||||
workEntryCounter = 0;
|
||||
updateTotalHours();
|
||||
|
||||
document.getElementById('nextStep2').disabled = true;
|
||||
}
|
||||
|
||||
// 당일 작업자 현황 로드 (본인 입력분만) - 통합 API 사용
|
||||
async function loadTodayWorkers() {
|
||||
const section = document.getElementById('dailyWorkersSection');
|
||||
const content = document.getElementById('dailyWorkersContent');
|
||||
|
||||
if (!section || !content) {
|
||||
console.log('당일 현황 섹션이 HTML에 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const today = getKoreaToday();
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
content.innerHTML = '<div class="loading-spinner">📊 내가 입력한 오늘의 작업 현황을 불러오는 중... (통합 API)</div>';
|
||||
section.style.display = 'block';
|
||||
|
||||
// 본인이 입력한 데이터만 조회 (통합 API 사용)
|
||||
let queryParams = `date=${today}`;
|
||||
if (currentUser?.user_id) {
|
||||
queryParams += `&created_by=${currentUser.user_id}`;
|
||||
} else if (currentUser?.id) {
|
||||
queryParams += `&created_by=${currentUser.id}`;
|
||||
}
|
||||
|
||||
console.log(`🔒 본인 입력분만 조회 (통합 API): ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
const rawData = await apiCall(`${API}/daily-work-reports?${queryParams}`);
|
||||
console.log('📊 당일 작업 데이터 (통합 API):', rawData);
|
||||
|
||||
let data = [];
|
||||
if (Array.isArray(rawData)) {
|
||||
data = rawData;
|
||||
} else if (rawData?.data) {
|
||||
data = rawData.data;
|
||||
}
|
||||
|
||||
displayMyDailyWorkers(data, today);
|
||||
|
||||
} catch (error) {
|
||||
console.error('당일 작업자 로드 오류:', error);
|
||||
content.innerHTML = `
|
||||
<div class="no-data-message">
|
||||
❌ 오늘의 작업 현황을 불러올 수 없습니다.<br>
|
||||
<small>${error.message}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 본인 입력 작업자 현황 표시 (수정/삭제 기능 포함)
|
||||
function displayMyDailyWorkers(data, date) {
|
||||
const content = document.getElementById('dailyWorkersContent');
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
content.innerHTML = `
|
||||
<div class="no-data-message">
|
||||
📝 내가 오늘(${date}) 입력한 작업이 없습니다.<br>
|
||||
<small>새로운 작업을 추가해보세요!</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업자별로 데이터 그룹화
|
||||
const workerGroups = {};
|
||||
data.forEach(work => {
|
||||
const workerName = work.worker_name || '미지정';
|
||||
if (!workerGroups[workerName]) {
|
||||
workerGroups[workerName] = [];
|
||||
}
|
||||
workerGroups[workerName].push(work);
|
||||
});
|
||||
|
||||
const totalWorkers = Object.keys(workerGroups).length;
|
||||
const totalWorks = data.length;
|
||||
|
||||
const headerHtml = `
|
||||
<div class="daily-workers-header">
|
||||
<h4>📊 내가 입력한 오늘(${date}) 작업 현황 - 총 ${totalWorkers}명, ${totalWorks}개 작업</h4>
|
||||
<button class="refresh-btn" onclick="refreshTodayWorkers()">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const workersHtml = Object.entries(workerGroups).map(([workerName, works]) => {
|
||||
const totalHours = works.reduce((sum, work) => {
|
||||
return sum + parseFloat(work.work_hours || 0);
|
||||
}, 0);
|
||||
|
||||
// 개별 작업 항목들 (수정/삭제 버튼 포함)
|
||||
const individualWorksHtml = works.map((work) => {
|
||||
const projectName = work.project_name || '미지정';
|
||||
const workTypeName = work.work_type_name || '미지정';
|
||||
const workStatusName = work.work_status_name || '미지정';
|
||||
const workHours = work.work_hours || 0;
|
||||
const errorTypeName = work.error_type_name || null;
|
||||
const workId = work.id;
|
||||
|
||||
return `
|
||||
<div class="individual-work-item">
|
||||
<div class="work-details-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">🏗️ 프로젝트</div>
|
||||
<div class="detail-value">${projectName}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">⚙️ 작업종류</div>
|
||||
<div class="detail-value">${workTypeName}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">📊 작업상태</div>
|
||||
<div class="detail-value">${workStatusName}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">⏰ 작업시간</div>
|
||||
<div class="detail-value">${workHours}시간</div>
|
||||
</div>
|
||||
${errorTypeName ? `
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">❌ 에러유형</div>
|
||||
<div class="detail-value">${errorTypeName}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="edit-btn" onclick="editWorkItem('${workId}')">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button class="delete-btn" onclick="deleteWorkItem('${workId}')">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="worker-status-item">
|
||||
<div class="worker-header">
|
||||
<div class="worker-name">👤 ${workerName}</div>
|
||||
<div class="worker-total-hours">총 ${totalHours}시간</div>
|
||||
</div>
|
||||
<div class="individual-works-container">
|
||||
${individualWorksHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
content.innerHTML = headerHtml + '<div class="worker-status-grid">' + workersHtml + '</div>';
|
||||
}
|
||||
|
||||
// 작업 항목 수정 함수 (통합 API 사용)
|
||||
async function editWorkItem(workId) {
|
||||
try {
|
||||
console.log('수정할 작업 ID:', workId);
|
||||
|
||||
// 1. 기존 데이터 조회 (통합 API 사용)
|
||||
showMessage('작업 정보를 불러오는 중... (통합 API)', 'loading');
|
||||
|
||||
const workData = await apiCall(`${API}/daily-work-reports/${workId}`);
|
||||
console.log('수정할 작업 데이터 (통합 API):', workData);
|
||||
|
||||
// 2. 수정 모달 표시
|
||||
showEditModal(workData);
|
||||
hideMessage();
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 정보 조회 오류:', error);
|
||||
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 수정 모달 표시
|
||||
function showEditModal(workData) {
|
||||
editingWorkId = workData.id;
|
||||
|
||||
const modalHtml = `
|
||||
<div class="edit-modal" id="editModal">
|
||||
<div class="edit-modal-content">
|
||||
<div class="edit-modal-header">
|
||||
<h3>✏️ 작업 수정</h3>
|
||||
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="edit-modal-body">
|
||||
<div class="edit-form-group">
|
||||
<label>🏗️ 프로젝트</label>
|
||||
<select class="edit-select" id="editProject">
|
||||
<option value="">프로젝트 선택</option>
|
||||
${projects.map(p => `
|
||||
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
|
||||
${p.project_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⚙️ 작업 유형</label>
|
||||
<select class="edit-select" id="editWorkType">
|
||||
<option value="">작업 유형 선택</option>
|
||||
${workTypes.map(wt => `
|
||||
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
|
||||
${wt.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>📊 업무 상태</label>
|
||||
<select class="edit-select" id="editWorkStatus">
|
||||
<option value="">업무 상태 선택</option>
|
||||
${workStatusTypes.map(ws => `
|
||||
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
|
||||
${ws.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
|
||||
<label>❌ 에러 유형</label>
|
||||
<select class="edit-select" id="editErrorType">
|
||||
<option value="">에러 유형 선택</option>
|
||||
${errorTypes.map(et => `
|
||||
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
|
||||
${et.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⏰ 작업 시간</label>
|
||||
<input type="number" class="edit-input" id="editWorkHours"
|
||||
value="${workData.work_hours}"
|
||||
min="0" max="24" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
|
||||
<button class="btn btn-success" onclick="saveEditedWork()">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 업무 상태 변경 이벤트
|
||||
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
|
||||
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
|
||||
if (e.target.value === '2') {
|
||||
errorTypeGroup.style.display = 'block';
|
||||
} else {
|
||||
errorTypeGroup.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 수정 모달 닫기
|
||||
function closeEditModal() {
|
||||
const modal = document.getElementById('editModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
editingWorkId = null;
|
||||
}
|
||||
|
||||
// 수정된 작업 저장 (통합 API 사용)
|
||||
async function saveEditedWork() {
|
||||
try {
|
||||
const projectId = document.getElementById('editProject').value;
|
||||
const workTypeId = document.getElementById('editWorkType').value;
|
||||
const workStatusId = document.getElementById('editWorkStatus').value;
|
||||
const errorTypeId = document.getElementById('editErrorType').value;
|
||||
const workHours = document.getElementById('editWorkHours').value;
|
||||
|
||||
if (!projectId || !workTypeId || !workStatusId || !workHours) {
|
||||
showMessage('모든 필수 항목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
work_status_id: parseInt(workStatusId),
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_hours: parseFloat(workHours)
|
||||
};
|
||||
|
||||
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/${editingWorkId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
console.log('✅ 수정 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
|
||||
|
||||
closeEditModal();
|
||||
refreshTodayWorkers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 수정 실패:', error);
|
||||
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 항목 삭제 함수 (통합 API 사용)
|
||||
async function deleteWorkItem(workId) {
|
||||
if (!confirm('정말로 이 작업을 삭제하시겠습니까?\n삭제된 작업은 복구할 수 없습니다.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('삭제할 작업 ID:', workId);
|
||||
|
||||
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
|
||||
|
||||
// 개별 항목 삭제 API 호출 (본인 작성분만 삭제 가능) - 통합 API 사용
|
||||
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('✅ 삭제 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
|
||||
|
||||
// 화면 새로고침
|
||||
refreshTodayWorkers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 삭제 실패:', error);
|
||||
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 오늘 현황 새로고침
|
||||
function refreshTodayWorkers() {
|
||||
loadTodayWorkers();
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
document.getElementById('nextStep1').addEventListener('click', () => {
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
if (dateInput && dateInput.value) {
|
||||
goToStep(2);
|
||||
} else {
|
||||
showMessage('날짜를 선택해주세요.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('nextStep2').addEventListener('click', () => {
|
||||
if (selectedWorkers.size > 0) {
|
||||
goToStep(3);
|
||||
addWorkEntry();
|
||||
} else {
|
||||
showMessage('작업자를 선택해주세요.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('addWorkBtn').addEventListener('click', addWorkEntry);
|
||||
document.getElementById('submitBtn').addEventListener('click', saveWorkReport);
|
||||
}
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
localStorage.removeItem('token');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('reportDate').value = getKoreaToday();
|
||||
|
||||
await loadData();
|
||||
setupEventListeners();
|
||||
loadTodayWorkers();
|
||||
|
||||
console.log('✅ 시스템 초기화 완료 (통합 API 설정 적용)');
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
showMessage('초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.removeWorkEntry = removeWorkEntry;
|
||||
window.refreshTodayWorkers = refreshTodayWorkers;
|
||||
window.editWorkItem = editWorkItem;
|
||||
window.deleteWorkItem = deleteWorkItem;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.saveEditedWork = saveEditedWork;
|
||||
49
fastapi-bridge/static/js/factory-upload.js
Normal file
49
fastapi-bridge/static/js/factory-upload.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
// FormData를 사용할 때는 Content-Type을 설정하지 않음 (자동 설정됨)
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`${API}/factoryinfo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || '등록 실패');
|
||||
}
|
||||
|
||||
alert('등록 완료!');
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('등록 실패: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 선택 시 미리보기 (선택사항)
|
||||
const fileInput = document.querySelector('input[name="map_image"]');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
// 미리보기 요소가 있을 경우에만 동작
|
||||
const preview = document.getElementById('file-preview');
|
||||
if (preview) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
preview.innerHTML = `<img src="${e.target.result}" alt="미리보기" style="max-width: 200px; max-height: 200px; border-radius: 8px;">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
38
fastapi-bridge/static/js/factory-view.js
Normal file
38
fastapi-bridge/static/js/factory-view.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
(async () => {
|
||||
const pathParts = location.pathname.split('/');
|
||||
const id = pathParts[pathParts.length - 1];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/factoryinfo/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('조회 실패');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// DOM 요소가 존재하는지 확인 후 설정
|
||||
const nameEl = document.getElementById('factoryName');
|
||||
if (nameEl) nameEl.textContent = data.factory_name;
|
||||
|
||||
const addressEl = document.getElementById('factoryAddress');
|
||||
if (addressEl) addressEl.textContent = '📍 ' + data.address;
|
||||
|
||||
const imageEl = document.getElementById('factoryImage');
|
||||
if (imageEl) imageEl.src = data.map_image_url;
|
||||
|
||||
const descEl = document.getElementById('factoryDescription');
|
||||
if (descEl) descEl.textContent = data.description;
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const container = document.querySelector('.container');
|
||||
if (container) {
|
||||
container.innerHTML = '<p>공장 정보를 불러올 수 없습니다.</p>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
103
fastapi-bridge/static/js/group-leader-dashboard.js
Normal file
103
fastapi-bridge/static/js/group-leader-dashboard.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// /js/group-leader-dashboard.js
|
||||
// 그룹장 전용 대시보드 기능
|
||||
|
||||
console.log('📊 그룹장 대시보드 스크립트 로딩');
|
||||
|
||||
// 팀 현황 새로고침
|
||||
async function refreshTeamStatus() {
|
||||
console.log('🔄 팀 현황 새로고침 시작');
|
||||
|
||||
try {
|
||||
// 로딩 상태 표시
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = '<div style="text-align: center; padding: 20px;">⏳ 로딩 중...</div>';
|
||||
}
|
||||
|
||||
// 실제로는 API 호출
|
||||
// const response = await fetch('/api/team-status', { headers: getAuthHeaders() });
|
||||
// const data = await response.json();
|
||||
|
||||
// 임시 데이터로 업데이트 (실제 API 연동 시 교체)
|
||||
setTimeout(() => {
|
||||
updateTeamStatusUI();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 팀 현황 로딩 실패:', error);
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = '<div style="text-align: center; padding: 20px; color: #f44336;">❌ 로딩 실패</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 팀 현황 UI 업데이트 (임시 데이터)
|
||||
function updateTeamStatusUI() {
|
||||
const teamData = [
|
||||
{ name: '김작업', status: 'present', statusText: '출근' },
|
||||
{ name: '이현장', status: 'present', statusText: '출근' },
|
||||
{ name: '박휴가', status: 'absent', statusText: '휴가' },
|
||||
{ name: '최작업', status: 'present', statusText: '출근' },
|
||||
{ name: '정현장', status: 'present', statusText: '출근' }
|
||||
];
|
||||
|
||||
const teamList = document.getElementById('team-list');
|
||||
if (teamList) {
|
||||
teamList.innerHTML = teamData.map(member => `
|
||||
<div class="team-member ${member.status}">
|
||||
<span class="member-name">${member.name}</span>
|
||||
<span class="member-status">${member.statusText}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
const presentCount = teamData.filter(m => m.status === 'present').length;
|
||||
const absentCount = teamData.filter(m => m.status === 'absent').length;
|
||||
|
||||
const totalEl = document.getElementById('team-total');
|
||||
const presentEl = document.getElementById('team-present');
|
||||
const absentEl = document.getElementById('team-absent');
|
||||
|
||||
if (totalEl) totalEl.textContent = teamData.length;
|
||||
if (presentEl) presentEl.textContent = presentCount;
|
||||
if (absentEl) absentEl.textContent = absentCount;
|
||||
|
||||
console.log('✅ 팀 현황 업데이트 완료');
|
||||
}
|
||||
|
||||
// 환영 메시지 개인화
|
||||
function personalizeWelcome() {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const welcomeMsg = document.getElementById('welcome-message');
|
||||
|
||||
if (user && user.name && welcomeMsg) {
|
||||
welcomeMsg.textContent = `${user.name}님의 실시간 팀 현황 및 작업 모니터링`;
|
||||
console.log('✅ 환영 메시지 개인화 완료');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🚀 그룹장 대시보드 초기화 시작');
|
||||
|
||||
// 사용자 정보 확인
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
console.log('👤 현재 사용자:', user);
|
||||
|
||||
// 권한 확인
|
||||
if (user.access_level !== 'group_leader') {
|
||||
console.warn('⚠️ 그룹장 권한 없음:', user.access_level);
|
||||
// 필요시 다른 페이지로 리다이렉트
|
||||
}
|
||||
|
||||
// 초기화 작업
|
||||
personalizeWelcome();
|
||||
updateTeamStatusUI();
|
||||
|
||||
console.log('✅ 그룹장 대시보드 초기화 완료');
|
||||
});
|
||||
|
||||
// 전역 함수로 내보내기 (HTML에서 사용)
|
||||
window.refreshTeamStatus = refreshTeamStatus;
|
||||
144
fastapi-bridge/static/js/load-navbar.js
Normal file
144
fastapi-bridge/static/js/load-navbar.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// js/load-navbar.js
|
||||
import { getUser, clearAuthData } from './auth.js';
|
||||
|
||||
// 역할 이름을 한글로 변환하는 맵
|
||||
const ROLE_NAMES = {
|
||||
admin: '관리자',
|
||||
system: '시스템 관리자',
|
||||
leader: '그룹장',
|
||||
user: '작업자',
|
||||
support: '지원팀',
|
||||
default: '사용자',
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 메뉴 항목을 필터링합니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {string} userRole - 현재 사용자의 역할
|
||||
*/
|
||||
function filterMenuByRole(doc, userRole) {
|
||||
const selectors = [
|
||||
{ role: 'admin', selector: '.admin-only' },
|
||||
{ role: 'system', selector: '.system-only' },
|
||||
{ role: 'leader', selector: '.leader-only' },
|
||||
];
|
||||
|
||||
selectors.forEach(({ role, selector }) => {
|
||||
// 사용자가 해당 역할을 가지고 있지 않으면 메뉴 항목을 제거
|
||||
if (userRole !== role) {
|
||||
doc.querySelectorAll(selector).forEach(el => el.remove());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 바에 사용자 정보를 채웁니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {object} user - 현재 사용자 객체
|
||||
*/
|
||||
function populateUserInfo(doc, user) {
|
||||
const displayName = user.name || user.username;
|
||||
const roleName = ROLE_NAMES[user.role] || ROLE_NAMES.default;
|
||||
|
||||
// 상단 바 사용자 이름
|
||||
const userNameEl = doc.getElementById('user-name');
|
||||
if (userNameEl) userNameEl.textContent = displayName;
|
||||
|
||||
// 상단 바 사용자 역할
|
||||
const userRoleEl = doc.getElementById('user-role');
|
||||
if (userRoleEl) userRoleEl.textContent = roleName;
|
||||
|
||||
// 드롭다운 메뉴 사용자 이름
|
||||
const dropdownNameEl = doc.getElementById('dropdown-user-fullname');
|
||||
if (dropdownNameEl) dropdownNameEl.textContent = displayName;
|
||||
|
||||
// 드롭다운 메뉴 사용자 아이디
|
||||
const dropdownIdEl = doc.getElementById('dropdown-user-id');
|
||||
if (dropdownIdEl) dropdownIdEl.textContent = `@${user.username}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 바와 관련된 모든 이벤트를 설정합니다.
|
||||
*/
|
||||
function setupNavbarEvents() {
|
||||
const userInfoDropdown = document.getElementById('user-info-dropdown');
|
||||
const profileDropdownMenu = document.getElementById('profile-dropdown-menu');
|
||||
|
||||
// 드롭다운 토글
|
||||
if (userInfoDropdown && profileDropdownMenu) {
|
||||
userInfoDropdown.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
profileDropdownMenu.classList.toggle('show');
|
||||
userInfoDropdown.classList.toggle('active');
|
||||
});
|
||||
}
|
||||
|
||||
// 로그아웃 버튼
|
||||
const logoutButton = document.getElementById('dropdown-logout');
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', () => {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
document.addEventListener('click', (e) => {
|
||||
if (profileDropdownMenu && !userInfoDropdown.contains(e.target) && !profileDropdownMenu.contains(e.target)) {
|
||||
profileDropdownMenu.classList.remove('show');
|
||||
userInfoDropdown.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시간을 업데이트하는 함수
|
||||
*/
|
||||
function updateTime() {
|
||||
const timeElement = document.getElementById('current-time');
|
||||
if (timeElement) {
|
||||
const now = new Date();
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 메인 로직: DOMContentLoaded 시 실행
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const navbarContainer = document.getElementById('navbar-container');
|
||||
if (!navbarContainer) return;
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return; // 사용자가 없으면 아무 작업도 하지 않음
|
||||
|
||||
try {
|
||||
const response = await fetch('/components/navbar.html');
|
||||
const htmlText = await response.text();
|
||||
|
||||
// 1. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 2. DOM에 삽입하기 *전*에 내용 수정
|
||||
filterMenuByRole(doc, currentUser.role);
|
||||
populateUserInfo(doc, currentUser);
|
||||
|
||||
// 3. 수정 완료된 HTML을 실제 DOM에 삽입 (깜빡임 방지)
|
||||
navbarContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
// 4. DOM에 삽입된 후에 이벤트 리스너 설정
|
||||
setupNavbarEvents();
|
||||
|
||||
// 5. 실시간 시간 업데이트 시작
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
|
||||
console.log('✅ 네비게이션 바 로딩 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔴 네비게이션 바 로딩 중 오류 발생:', error);
|
||||
navbarContainer.innerHTML = '<p>네비게이션 바를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
});
|
||||
104
fastapi-bridge/static/js/load-sections.js
Normal file
104
fastapi-bridge/static/js/load-sections.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// /js/load-sections.js
|
||||
import { getUser } from './auth.js';
|
||||
import { apiGet } from './api-helper.js';
|
||||
|
||||
// 역할에 따라 불러올 섹션 HTML 파일을 매핑합니다.
|
||||
const SECTION_MAP = {
|
||||
admin: '/components/sections/admin-sections.html',
|
||||
system: '/components/sections/admin-sections.html', // system도 admin과 동일한 섹션을 사용
|
||||
leader: '/components/sections/leader-sections.html',
|
||||
user: '/components/sections/user-sections.html',
|
||||
default: '/components/sections/user-sections.html', // 역할이 없는 경우 기본값
|
||||
};
|
||||
|
||||
/**
|
||||
* API를 통해 대시보드 통계 데이터를 가져옵니다.
|
||||
* @returns {Promise<object|null>} 통계 데이터 또는 에러 시 null
|
||||
*/
|
||||
async function fetchDashboardStats() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
// 실제 백엔드 엔드포인트는 /api/dashboard/stats 와 같은 형태로 구현될 수 있습니다.
|
||||
const stats = await apiGet(`/workreports?start=${today}&end=${today}`);
|
||||
// 필요한 데이터 형태로 가공 (예시)
|
||||
return {
|
||||
today_reports_count: stats.length,
|
||||
today_workers_count: new Set(stats.map(d => d.worker_id)).size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('대시보드 통계 데이터 로드 실패:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 가상 DOM에 통계 데이터를 채워 넣습니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {object} stats - 통계 데이터
|
||||
*/
|
||||
function populateStatsData(doc, stats) {
|
||||
if (!stats) return;
|
||||
|
||||
const todayStatsEl = doc.getElementById('today-stats');
|
||||
if (todayStatsEl) {
|
||||
todayStatsEl.innerHTML = `
|
||||
<p>📝 오늘 등록된 작업: ${stats.today_reports_count}건</p>
|
||||
<p>👥 참여 작업자: ${stats.today_workers_count}명</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 로직: 페이지에 역할별 섹션을 로드하고 내용을 채웁니다.
|
||||
*/
|
||||
async function initializeSections() {
|
||||
const mainContainer = document.querySelector('main[id$="-sections"]');
|
||||
if (!mainContainer) {
|
||||
console.error('섹션을 담을 메인 컨테이너를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
mainContainer.innerHTML = '<div class="loading">콘텐츠를 불러오는 중...</div>';
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) {
|
||||
mainContainer.innerHTML = '<div class="error-state">사용자 정보를 찾을 수 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionFile = SECTION_MAP[currentUser.role] || SECTION_MAP.default;
|
||||
|
||||
try {
|
||||
// 1. 역할에 맞는 HTML 템플릿과 동적 데이터를 동시에 로드 (Promise.all 활용)
|
||||
const [htmlResponse, statsData] = await Promise.all([
|
||||
fetch(sectionFile),
|
||||
fetchDashboardStats()
|
||||
]);
|
||||
|
||||
if (!htmlResponse.ok) {
|
||||
throw new Error(`섹션 파일(${sectionFile})을 불러오는 데 실패했습니다.`);
|
||||
}
|
||||
const htmlText = await htmlResponse.text();
|
||||
|
||||
// 2. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 3. (필요 시) 역할 기반으로 가상 DOM 필터링 - 현재는 파일 자체가 역할별로 나뉘어 불필요
|
||||
// filterByRole(doc, currentUser.role);
|
||||
|
||||
// 4. 가상 DOM에 동적 데이터 채우기
|
||||
populateStatsData(doc, statsData);
|
||||
|
||||
// 5. 모든 수정이 완료된 HTML을 실제 DOM에 한 번에 삽입
|
||||
mainContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
console.log(`✅ ${currentUser.role} 역할의 섹션 로딩 완료.`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('섹션 로딩 중 오류 발생:', error);
|
||||
mainContainer.innerHTML = `<div class="error-state">콘텐츠 로딩에 실패했습니다: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// DOM이 로드되면 섹션 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializeSections);
|
||||
67
fastapi-bridge/static/js/load-sidebar.js
Normal file
67
fastapi-bridge/static/js/load-sidebar.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// /js/load-sidebar.js
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 사이드바 메뉴 항목을 필터링합니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {string} userRole - 현재 사용자의 역할
|
||||
*/
|
||||
function filterSidebarByRole(doc, userRole) {
|
||||
// 'system' 역할은 모든 메뉴를 볼 수 있으므로 필터링하지 않음
|
||||
if (userRole === 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 역할과 그에 해당하는 클래스 선택자 매핑
|
||||
const roleClassMap = {
|
||||
admin: '.admin-only',
|
||||
leader: '.leader-only',
|
||||
user: '.user-only', // 또는 'worker-only' 등, sidebar.html에 정의된 클래스에 맞춰야 함
|
||||
support: '.support-only'
|
||||
};
|
||||
|
||||
// 모든 역할 기반 선택자를 가져옴
|
||||
const allRoleSelectors = Object.values(roleClassMap).join(', ');
|
||||
const allRoleElements = doc.querySelectorAll(allRoleSelectors);
|
||||
|
||||
allRoleElements.forEach(el => {
|
||||
// 요소가 현재 사용자 역할에 해당하는 클래스를 가지고 있는지 확인
|
||||
const userRoleSelector = roleClassMap[userRole];
|
||||
if (!userRoleSelector || !el.matches(userRoleSelector)) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const sidebarContainer = document.getElementById('sidebar-container');
|
||||
if (!sidebarContainer) return;
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return; // 비로그인 상태면 사이드바를 로드하지 않음
|
||||
|
||||
try {
|
||||
const response = await fetch('/components/sidebar.html');
|
||||
if (!response.ok) {
|
||||
throw new Error(`사이드바 파일을 불러올 수 없습니다: ${response.statusText}`);
|
||||
}
|
||||
const htmlText = await response.text();
|
||||
|
||||
// 1. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
// 2. DOM에 삽입하기 *전*에 역할에 따라 메뉴 필터링
|
||||
filterSidebarByRole(doc, currentUser.role);
|
||||
|
||||
// 3. 수정 완료된 HTML을 실제 DOM에 삽입
|
||||
sidebarContainer.innerHTML = doc.body.innerHTML;
|
||||
|
||||
console.log('✅ 사이드바 로딩 및 필터링 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔴 사이드바 로딩 실패:', error);
|
||||
sidebarContainer.innerHTML = '<p>메뉴 로딩 실패</p>';
|
||||
}
|
||||
});
|
||||
58
fastapi-bridge/static/js/login.js
Normal file
58
fastapi-bridge/static/js/login.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// /js/login.js
|
||||
|
||||
import { login } from './api-helper.js';
|
||||
import { saveAuthData, clearAuthData } from './auth.js';
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorDiv = document.getElementById('error');
|
||||
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
|
||||
// 로딩 상태 시작
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '로그인 중...';
|
||||
errorDiv.style.display = 'none';
|
||||
|
||||
try {
|
||||
// API 헬퍼를 통해 로그인 요청
|
||||
const result = await login(username, password);
|
||||
|
||||
if (result.success && result.token) {
|
||||
// 인증 정보 저장
|
||||
saveAuthData(result.token, result.user);
|
||||
|
||||
// 백엔드가 지정한 URL로 리디렉션
|
||||
const redirectUrl = result.redirectUrl || '/pages/dashboard/user.html'; // 혹시 모를 예외처리
|
||||
|
||||
// 부드러운 화면 전환 효과
|
||||
document.body.style.transition = 'opacity 0.3s ease-out';
|
||||
document.body.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectUrl;
|
||||
}, 300);
|
||||
|
||||
} else {
|
||||
// 이 케이스는 api-helper에서 throw new Error()로 처리되어 catch 블록으로 바로 이동합니다.
|
||||
// 하지만, 만약의 경우를 대비해 방어 코드를 남겨둡니다.
|
||||
clearAuthData();
|
||||
errorDiv.textContent = result.error || '로그인에 실패했습니다.';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그인 오류:', err);
|
||||
clearAuthData();
|
||||
// api-helper에서 보낸 에러 메시지를 표시
|
||||
errorDiv.textContent = err.message || '서버 연결에 실패했습니다.';
|
||||
errorDiv.style.display = 'block';
|
||||
} finally {
|
||||
// 로딩 상태 해제
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
86
fastapi-bridge/static/js/manage-issue.js
Normal file
86
fastapi-bridge/static/js/manage-issue.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { API, getAuthHeaders } from '/js/api-config.js';
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key];
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
const form = document.getElementById('issueTypeForm');
|
||||
form?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
category: document.getElementById('category').value,
|
||||
subcategory: document.getElementById('subcategory').value
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${API}/issue-types`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
form.reset();
|
||||
loadIssueTypes();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadIssueTypes() {
|
||||
const tbody = document.getElementById('issueTypeTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/issue-types`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, ['issue_type_id', 'category', 'subcategory'], async t => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/issue-types/${t.issue_type_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
loadIssueTypes();
|
||||
} else {
|
||||
alert('삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadIssueTypes();
|
||||
});
|
||||
93
fastapi-bridge/static/js/manage-pipespec.js
Normal file
93
fastapi-bridge/static/js/manage-pipespec.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
// 행 생성
|
||||
function createRow(item, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
const label = `${item.material} / ${item.diameter_in} / ${item.schedule}`;
|
||||
tr.innerHTML = `
|
||||
<td>${item.spec_id}</td>
|
||||
<td>${label}</td>
|
||||
<td><button class="btn-delete">삭제</button></td>
|
||||
`;
|
||||
tr.querySelector('.btn-delete').onclick = () => delHandler(item);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// 등록
|
||||
document.getElementById('specForm')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const material = document.getElementById('material').value.trim();
|
||||
const diameter = document.getElementById('diameter_in').value.trim();
|
||||
const schedule = document.getElementById('schedule').value.trim();
|
||||
|
||||
if (!material || !diameter || !schedule) {
|
||||
return alert('모든 항목을 입력하세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/pipespecs`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ material, diameter_in: diameter, schedule })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
e.target.reset();
|
||||
loadSpecs();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '등록 실패'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 불러오기
|
||||
async function loadSpecs() {
|
||||
const tbody = document.getElementById('specTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="3">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/pipespecs`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, async (spec) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/pipespecs/${spec.spec_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
loadSpecs();
|
||||
} else {
|
||||
alert('삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="3">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="3">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', loadSpecs);
|
||||
108
fastapi-bridge/static/js/manage-project.js
Normal file
108
fastapi-bridge/static/js/manage-project.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// /js/manage-project.js
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key];
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
const projectForm = document.getElementById('projectForm');
|
||||
projectForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
job_no: document.getElementById('job_no').value.trim(),
|
||||
project_name: document.getElementById('project_name').value.trim(),
|
||||
contract_date: document.getElementById('contract_date').value,
|
||||
due_date: document.getElementById('due_date').value,
|
||||
delivery_method: document.getElementById('delivery_method').value.trim(),
|
||||
site: document.getElementById('site').value.trim(),
|
||||
pm: document.getElementById('pm').value.trim()
|
||||
};
|
||||
|
||||
if (!body.project_name || !body.job_no) {
|
||||
return alert('필수 항목을 입력하세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/projects`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
projectForm.reset();
|
||||
loadProjects();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
const tbody = document.getElementById('projectTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="9">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/projects`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, [
|
||||
'project_id', 'job_no', 'project_name', 'contract_date',
|
||||
'due_date', 'delivery_method', 'site', 'pm'
|
||||
], async p => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/projects/${p.project_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadProjects();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="9">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="9">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', loadProjects);
|
||||
104
fastapi-bridge/static/js/manage-task.js
Normal file
104
fastapi-bridge/static/js/manage-task.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// /js/manage-task.js
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key];
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
const taskForm = document.getElementById('taskForm');
|
||||
taskForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
category: document.getElementById('category').value.trim(),
|
||||
subcategory: document.getElementById('subcategory').value.trim(),
|
||||
task_name: document.getElementById('task_name').value.trim(),
|
||||
description: document.getElementById('description').value.trim()
|
||||
};
|
||||
|
||||
if (!body.category || !body.task_name) {
|
||||
return alert('필수 항목을 입력하세요');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
taskForm.reset();
|
||||
loadTasks();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadTasks() {
|
||||
const tbody = document.getElementById('taskTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6">불러오는 중...</td></tr>';
|
||||
try {
|
||||
const res = await fetch(`${API}/tasks`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, [
|
||||
'task_id', 'category', 'subcategory', 'task_name', 'description'
|
||||
], async t => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/tasks/${t.task_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadTasks();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="6">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', loadTasks);
|
||||
288
fastapi-bridge/static/js/manage-user.js
Normal file
288
fastapi-bridge/static/js/manage-user.js
Normal file
@@ -0,0 +1,288 @@
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
const accessLabels = {
|
||||
worker: '작업자',
|
||||
group_leader: '그룹장',
|
||||
support_team: '지원팀',
|
||||
admin: '관리자',
|
||||
system: '시스템'
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isSystemUser = currentUser.access_level === 'system';
|
||||
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key] || '-';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// 내 비밀번호 변경
|
||||
const myPasswordForm = document.getElementById('myPasswordForm');
|
||||
myPasswordForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 새 비밀번호 확인
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('❌ 새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 강도 검사
|
||||
if (newPassword.length < 6) {
|
||||
alert('❌ 비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 비밀번호가 변경되었습니다.');
|
||||
myPasswordForm.reset();
|
||||
|
||||
// 3초 후 로그인 페이지로 이동
|
||||
setTimeout(() => {
|
||||
alert('비밀번호가 변경되어 다시 로그인해주세요.');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/index.html';
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('❌ 비밀번호 변경 실패: ' + (result.error || '현재 비밀번호가 올바르지 않습니다.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 시스템 권한자만 볼 수 있는 사용자 비밀번호 변경 섹션
|
||||
if (isSystemUser) {
|
||||
const systemCard = document.getElementById('systemPasswordChangeCard');
|
||||
if (systemCard) {
|
||||
systemCard.style.display = 'block';
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 변경 (시스템 권한자)
|
||||
const userPasswordForm = document.getElementById('userPasswordForm');
|
||||
userPasswordForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const targetUserId = document.getElementById('targetUserId').value;
|
||||
const newPassword = document.getElementById('targetNewPassword').value;
|
||||
|
||||
if (!targetUserId) {
|
||||
alert('❌ 사용자를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
alert('❌ 비밀번호는 최소 6자 이상이어야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('정말로 이 사용자의 비밀번호를 변경하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/admin/change-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
userId: targetUserId,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 사용자 비밀번호가 변경되었습니다.');
|
||||
userPasswordForm.reset();
|
||||
} else {
|
||||
alert('❌ 비밀번호 변경 실패: ' + (result.error || '권한이 없습니다.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Admin password change error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 등록
|
||||
const userForm = document.getElementById('userForm');
|
||||
userForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
username: document.getElementById('username').value.trim(),
|
||||
password: document.getElementById('password').value.trim(),
|
||||
name: document.getElementById('name').value.trim(),
|
||||
access_level: document.getElementById('access_level').value,
|
||||
worker_id: document.getElementById('worker_id').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok && result.success) {
|
||||
showToast('✅ 등록 완료');
|
||||
userForm.reset();
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
alert('🚨 서버 오류: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const tbody = document.getElementById('userTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/users`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
// 시스템 권한자용 사용자 선택 옵션도 업데이트
|
||||
if (isSystemUser) {
|
||||
const targetUserSelect = document.getElementById('targetUserId');
|
||||
if (targetUserSelect) {
|
||||
targetUserSelect.innerHTML = '<option value="">사용자 선택</option>';
|
||||
list.forEach(user => {
|
||||
// 본인은 제외
|
||||
if (user.user_id !== currentUser.user_id) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = user.user_id;
|
||||
opt.textContent = `${user.name} (${user.username})`;
|
||||
targetUserSelect.appendChild(opt);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
list.forEach(item => {
|
||||
item.access_level = accessLabels[item.access_level] || item.access_level;
|
||||
item.worker_id = item.worker_id || '-';
|
||||
const row = createRow(item, [
|
||||
'user_id', 'username', 'name', 'access_level', 'worker_id'
|
||||
], async u => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/auth/users/${u.user_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
showToast('✅ 삭제 완료');
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('🚨 삭제 중 오류 발생');
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="6">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load users error:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="6">로드 실패: ' + error.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkerOptions() {
|
||||
const select = document.getElementById('worker_id');
|
||||
if (!select) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const workers = await res.json();
|
||||
if (Array.isArray(workers)) {
|
||||
workers.forEach(w => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.worker_id;
|
||||
opt.textContent = `${w.worker_name} (${w.worker_id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('작업자 목록 불러오기 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = message;
|
||||
toast.style.position = 'fixed';
|
||||
toast.style.bottom = '30px';
|
||||
toast.style.left = '50%';
|
||||
toast.style.transform = 'translateX(-50%)';
|
||||
toast.style.background = '#323232';
|
||||
toast.style.color = '#fff';
|
||||
toast.style.padding = '10px 20px';
|
||||
toast.style.borderRadius = '6px';
|
||||
toast.style.fontSize = '14px';
|
||||
toast.style.zIndex = 9999;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
loadUsers();
|
||||
loadWorkerOptions();
|
||||
});
|
||||
110
fastapi-bridge/static/js/manage-worker.js
Normal file
110
fastapi-bridge/static/js/manage-worker.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// /js/manage-worker.js
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
// ✅ 테이블 행 생성
|
||||
function createRow(item, cols, delHandler) {
|
||||
const tr = document.createElement('tr');
|
||||
cols.forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key] || '-';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = () => delHandler(item);
|
||||
|
||||
const td = document.createElement('td');
|
||||
td.appendChild(delBtn);
|
||||
tr.appendChild(td);
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ✅ 작업자 등록
|
||||
const workerForm = document.getElementById('workerForm');
|
||||
workerForm?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const body = {
|
||||
worker_name: document.getElementById('workerName').value.trim(),
|
||||
position: document.getElementById('position').value.trim()
|
||||
};
|
||||
|
||||
if (!body.worker_name || !body.position) {
|
||||
return alert('모든 필드를 입력해주세요.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
alert('✅ 등록 완료');
|
||||
workerForm.reset();
|
||||
loadWorkers();
|
||||
} else {
|
||||
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 서버 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ 작업자 목록 불러오기
|
||||
async function loadWorkers() {
|
||||
const tbody = document.getElementById('workerTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workers`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const list = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach(item => {
|
||||
const row = createRow(item, ['worker_id', 'worker_name', 'position'], async w => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/workers/${w.worker_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (delRes.ok) {
|
||||
alert('✅ 삭제 완료');
|
||||
loadWorkers();
|
||||
} else {
|
||||
alert('❌ 삭제 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('🚨 삭제 중 오류: ' + err.message);
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 초기 로딩
|
||||
window.addEventListener('DOMContentLoaded', loadWorkers);
|
||||
954
fastapi-bridge/static/js/management-dashboard.js
Normal file
954
fastapi-bridge/static/js/management-dashboard.js
Normal file
@@ -0,0 +1,954 @@
|
||||
// management-dashboard.js - 관리자 대시보드 전용 스크립트
|
||||
|
||||
// =================================================================
|
||||
// 🌐 통합 API 설정 import
|
||||
// =================================================================
|
||||
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let workData = [];
|
||||
let filteredWorkData = [];
|
||||
let currentDate = '';
|
||||
let currentUser = null;
|
||||
|
||||
// 권한 레벨 매핑
|
||||
const ACCESS_LEVELS = {
|
||||
worker: 1,
|
||||
group_leader: 2,
|
||||
support_team: 3,
|
||||
admin: 4,
|
||||
system: 5
|
||||
};
|
||||
|
||||
// 한국 시간 기준 오늘 날짜 가져오기
|
||||
function getKoreaToday() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
function getCurrentUser() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return null;
|
||||
|
||||
const payloadBase64 = token.split('.')[1];
|
||||
if (payloadBase64) {
|
||||
const payload = JSON.parse(atob(payloadBase64));
|
||||
console.log('토큰에서 추출한 사용자 정보:', payload);
|
||||
return payload;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('토큰에서 사용자 정보 추출 실패:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
|
||||
if (userInfo) {
|
||||
const parsed = JSON.parse(userInfo);
|
||||
console.log('localStorage에서 가져온 사용자 정보:', parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 권한 체크 함수
|
||||
function checkPermission() {
|
||||
currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAccessLevel = currentUser.access_level;
|
||||
const accessLevelValue = ACCESS_LEVELS[userAccessLevel] || 0;
|
||||
|
||||
console.log('사용자 권한 체크:', {
|
||||
username: currentUser.username || currentUser.name,
|
||||
access_level: userAccessLevel,
|
||||
level_value: accessLevelValue,
|
||||
required_level: ACCESS_LEVELS.group_leader
|
||||
});
|
||||
|
||||
if (accessLevelValue < ACCESS_LEVELS.group_leader) {
|
||||
showMessage('그룹장 이상의 권한이 필요합니다. 현재 권한: ' + userAccessLevel, 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 3000);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 메시지 표시
|
||||
function showMessage(message, type = 'info') {
|
||||
const container = document.getElementById('message-container');
|
||||
container.innerHTML = `<div class="message ${type}">${message}</div>`;
|
||||
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
hideMessage();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideMessage() {
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
function showLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'flex';
|
||||
document.getElementById('summarySection').style.display = 'none';
|
||||
document.getElementById('actionBar').style.display = 'none';
|
||||
document.getElementById('workersSection').style.display = 'none';
|
||||
document.getElementById('noDataMessage').style.display = 'none';
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'none';
|
||||
}
|
||||
|
||||
// 작업자 데이터 로드
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
console.log('작업자 데이터 로딩 중... (통합 API)');
|
||||
const data = await apiCall(`${API}/workers`);
|
||||
workers = Array.isArray(data) ? data : (data.workers || []);
|
||||
console.log('✅ 작업자 로드 성공:', workers.length);
|
||||
} catch (error) {
|
||||
console.error('작업자 로딩 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 날짜의 작업 데이터 로드 (개선된 버전)
|
||||
async function loadWorkData(date) {
|
||||
try {
|
||||
console.log(`${date} 날짜의 작업 데이터 로딩 중... (통합 API)`);
|
||||
|
||||
// 1차: view_all=true로 전체 데이터 시도
|
||||
let queryParams = `date=${date}&view_all=true`;
|
||||
console.log(`🔍 1차 시도: ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
let data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
// 데이터가 없으면 다른 방법들 시도
|
||||
if (workData.length === 0) {
|
||||
console.log('⚠️ view_all로 데이터 없음, 다른 방법 시도...');
|
||||
|
||||
// 2차: admin=true로 시도
|
||||
queryParams = `date=${date}&admin=true`;
|
||||
console.log(`🔍 2차 시도: ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
if (workData.length === 0) {
|
||||
// 3차: 날짜 경로 파라미터로 시도
|
||||
console.log(`🔍 3차 시도: ${API}/daily-work-reports/date/${date}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports/date/${date}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
if (workData.length === 0) {
|
||||
// 4차: 기본 파라미터만으로 시도
|
||||
console.log(`🔍 4차 시도: ${API}/daily-work-reports?date=${date}`);
|
||||
|
||||
data = await apiCall(`${API}/daily-work-reports?date=${date}`);
|
||||
workData = Array.isArray(data) ? data : (data.data || []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 최종 작업 데이터 로드 결과: ${workData.length}개`);
|
||||
|
||||
// 디버깅을 위한 상세 로그
|
||||
if (workData.length > 0) {
|
||||
console.log('📊 로드된 데이터 샘플:', workData.slice(0, 3));
|
||||
const uniqueWorkers = [...new Set(workData.map(w => w.worker_name))];
|
||||
console.log('👥 데이터에 포함된 작업자들:', uniqueWorkers);
|
||||
} else {
|
||||
console.log('❌ 해당 날짜에 작업 데이터가 없거나 접근 권한이 없습니다.');
|
||||
}
|
||||
|
||||
return workData;
|
||||
} catch (error) {
|
||||
console.error('작업 데이터 로딩 오류:', error);
|
||||
|
||||
// 에러 시에도 빈 배열 반환하여 앱이 중단되지 않도록
|
||||
workData = [];
|
||||
|
||||
// 구체적인 에러 정보 표시
|
||||
if (error.message.includes('403')) {
|
||||
console.log('🔒 권한 부족으로 인한 접근 제한');
|
||||
throw new Error('해당 날짜의 데이터에 접근할 권한이 없습니다.');
|
||||
} else if (error.message.includes('404')) {
|
||||
console.log('📭 해당 날짜에 데이터 없음');
|
||||
throw new Error('해당 날짜에 입력된 작업 데이터가 없습니다.');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드 데이터 로드
|
||||
async function loadDashboardData() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
|
||||
if (!selectedDate) {
|
||||
showMessage('날짜를 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentDate = selectedDate;
|
||||
showLoading();
|
||||
hideMessage();
|
||||
|
||||
try {
|
||||
// 병렬로 데이터 로드
|
||||
await Promise.all([
|
||||
loadWorkers(),
|
||||
loadWorkData(selectedDate)
|
||||
]);
|
||||
|
||||
// 데이터 분석 및 표시
|
||||
const dashboardData = analyzeDashboardData();
|
||||
displayDashboard(dashboardData);
|
||||
|
||||
hideLoading();
|
||||
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 실패:', error);
|
||||
hideLoading();
|
||||
showMessage('데이터를 불러오는 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
|
||||
// 에러 시 데이터 없음 메시지 표시
|
||||
document.getElementById('noDataMessage').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드 데이터 분석 (개선된 버전)
|
||||
function analyzeDashboardData() {
|
||||
console.log('대시보드 데이터 분석 시작');
|
||||
|
||||
// 작업자별 데이터 그룹화
|
||||
const workerWorkData = {};
|
||||
workData.forEach(work => {
|
||||
const workerId = work.worker_id;
|
||||
if (!workerWorkData[workerId]) {
|
||||
workerWorkData[workerId] = [];
|
||||
}
|
||||
workerWorkData[workerId].push(work);
|
||||
});
|
||||
|
||||
// 전체 통계 계산
|
||||
const totalWorkers = workers.length;
|
||||
const workersWithData = Object.keys(workerWorkData).length;
|
||||
const workersWithoutData = totalWorkers - workersWithData;
|
||||
const totalHours = workData.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
const totalEntries = workData.length;
|
||||
const errorCount = workData.filter(work => work.work_status_id === 2).length;
|
||||
|
||||
// 작업자별 상세 분석 (개선된 버전)
|
||||
const workerAnalysis = workers.map(worker => {
|
||||
const workerWorks = workerWorkData[worker.worker_id] || [];
|
||||
const workerHours = workerWorks.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
|
||||
|
||||
// 작업 유형 분석 (실제 이름으로)
|
||||
const workTypes = [...new Set(workerWorks.map(work => work.work_type_name).filter(Boolean))];
|
||||
|
||||
// 프로젝트 분석
|
||||
const workerProjects = [...new Set(workerWorks.map(work => work.project_name).filter(Boolean))];
|
||||
|
||||
// 기여자 분석
|
||||
const workerContributors = [...new Set(workerWorks.map(work => work.created_by_name).filter(Boolean))];
|
||||
|
||||
// 상태 결정 (더 세밀한 기준)
|
||||
let status = 'missing';
|
||||
if (workerWorks.length > 0) {
|
||||
if (workerHours >= 6) {
|
||||
status = 'completed'; // 6시간 이상을 완료로 간주
|
||||
} else {
|
||||
status = 'partial'; // 1시간 이상이지만 6시간 미만은 부분입력
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 업데이트 시간
|
||||
const lastUpdate = workerWorks.length > 0
|
||||
? new Date(Math.max(...workerWorks.map(work => new Date(work.created_at))))
|
||||
: null;
|
||||
|
||||
return {
|
||||
...worker,
|
||||
status,
|
||||
totalHours: Math.round(workerHours * 10) / 10, // 소수점 1자리로 반올림
|
||||
entryCount: workerWorks.length,
|
||||
workTypes, // 작업 유형 배열 (실제 이름)
|
||||
projects: workerProjects,
|
||||
contributors: workerContributors,
|
||||
lastUpdate,
|
||||
works: workerWorks
|
||||
};
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalWorkers,
|
||||
completedWorkers: workerAnalysis.filter(w => w.status === 'completed').length,
|
||||
missingWorkers: workerAnalysis.filter(w => w.status === 'missing').length,
|
||||
partialWorkers: workerAnalysis.filter(w => w.status === 'partial').length,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
totalEntries,
|
||||
errorCount
|
||||
};
|
||||
|
||||
console.log('대시보드 분석 결과:', { summary, workerAnalysis });
|
||||
|
||||
return {
|
||||
summary,
|
||||
workers: workerAnalysis,
|
||||
date: currentDate
|
||||
};
|
||||
}
|
||||
|
||||
// 대시보드 표시
|
||||
function displayDashboard(data) {
|
||||
displaySummary(data.summary);
|
||||
displayWorkers(data.workers);
|
||||
|
||||
// 섹션 표시
|
||||
document.getElementById('summarySection').style.display = 'block';
|
||||
document.getElementById('actionBar').style.display = 'flex';
|
||||
document.getElementById('workersSection').style.display = 'block';
|
||||
|
||||
// 필터링 설정
|
||||
filteredWorkData = data.workers;
|
||||
setupFiltering();
|
||||
|
||||
console.log('✅ 대시보드 표시 완료');
|
||||
}
|
||||
|
||||
// 요약 섹션 표시
|
||||
function displaySummary(summary) {
|
||||
document.getElementById('totalWorkers').textContent = summary.totalWorkers;
|
||||
document.getElementById('completedWorkers').textContent = summary.completedWorkers;
|
||||
document.getElementById('missingWorkers').textContent = summary.missingWorkers;
|
||||
document.getElementById('totalHours').textContent = summary.totalHours + 'h';
|
||||
document.getElementById('totalEntries').textContent = summary.totalEntries;
|
||||
document.getElementById('errorCount').textContent = summary.errorCount;
|
||||
}
|
||||
|
||||
// 작업자 목록 표시 (테이블 형태로 개선)
|
||||
function displayWorkers(workersData) {
|
||||
const tableBody = document.getElementById('workersTableBody');
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
if (workersData.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9" class="no-data-row">표시할 작업자가 없습니다.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
workersData.forEach(worker => {
|
||||
const row = createWorkerRow(worker);
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// 작업자 테이블 행 생성 (개선된 버전)
|
||||
function createWorkerRow(worker) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
const statusText = {
|
||||
completed: '✅ 완료',
|
||||
missing: '❌ 미입력',
|
||||
partial: '⚠️ 부분입력'
|
||||
};
|
||||
|
||||
const statusClass = {
|
||||
completed: 'completed',
|
||||
missing: 'missing',
|
||||
partial: 'partial'
|
||||
};
|
||||
|
||||
// 작업 유형 태그 생성 (실제 이름으로)
|
||||
const workTypeTags = worker.workTypes && worker.workTypes.length > 0
|
||||
? worker.workTypes.map(type => `<span class="work-type-tag">${type}</span>`).join('')
|
||||
: '<span class="work-type-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 프로젝트 태그 생성
|
||||
const projectTags = worker.projects && worker.projects.length > 0
|
||||
? worker.projects.map(project => `<span class="project-tag">${project}</span>`).join('')
|
||||
: '<span class="project-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 기여자 태그 생성
|
||||
const contributorTags = worker.contributors && worker.contributors.length > 0
|
||||
? worker.contributors.map(contributor => `<span class="contributor-tag">${contributor}</span>`).join('')
|
||||
: '<span class="contributor-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
|
||||
|
||||
// 시간에 따른 스타일 클래스
|
||||
let hoursClass = 'zero';
|
||||
if (worker.totalHours > 0) {
|
||||
hoursClass = worker.totalHours >= 6 ? 'full' : 'partial';
|
||||
}
|
||||
|
||||
// 업데이트 시간 포맷팅 및 스타일
|
||||
let updateTimeText = '없음';
|
||||
let updateClass = '';
|
||||
if (worker.lastUpdate) {
|
||||
const now = new Date();
|
||||
const diff = now - worker.lastUpdate;
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
updateTimeText = formatDateTime(worker.lastUpdate);
|
||||
updateClass = hours < 1 ? 'recent' : hours > 24 ? 'old' : '';
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
<div class="worker-name-cell">
|
||||
👤 ${worker.worker_name}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass[worker.status]}">${statusText[worker.status]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="hours-cell ${hoursClass}">${worker.totalHours}h</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>${worker.entryCount}</strong>개
|
||||
</td>
|
||||
<td>
|
||||
<div class="work-types-container">${workTypeTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="projects-container">${projectTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="contributors-container">${contributorTags}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="update-time ${updateClass}">${updateTimeText}</div>
|
||||
</td>
|
||||
<td>
|
||||
<button class="detail-btn" onclick="showWorkerDetailSafe('${worker.worker_id}')">
|
||||
📋 상세
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// 날짜/시간 포맷팅
|
||||
function formatDateTime(date) {
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 표시 (안전한 버전)
|
||||
function showWorkerDetailSafe(workerId) {
|
||||
// 현재 분석된 데이터에서 해당 작업자 찾기
|
||||
const worker = filteredWorkData.find(w => w.worker_id == workerId);
|
||||
if (!worker) {
|
||||
showMessage('작업자 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showWorkerDetail(worker);
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 표시 (개선된 버전)
|
||||
function showWorkerDetail(worker) {
|
||||
const modal = document.getElementById('workerDetailModal');
|
||||
const modalTitle = document.getElementById('modalWorkerName');
|
||||
const modalBody = document.getElementById('modalWorkerDetails');
|
||||
|
||||
modalTitle.textContent = `👤 ${worker.worker_name} 상세 현황`;
|
||||
|
||||
let detailHtml = `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>📊 기본 정보</h4>
|
||||
<p><strong>작업자명:</strong> ${worker.worker_name}</p>
|
||||
<p><strong>총 작업시간:</strong> ${worker.totalHours}시간</p>
|
||||
<p><strong>작업 항목 수:</strong> ${worker.entryCount}개</p>
|
||||
<p><strong>상태:</strong> ${worker.status === 'completed' ? '✅ 완료' : worker.status === 'missing' ? '❌ 미입력' : '⚠️ 부분입력'}</p>
|
||||
<p><strong>작업 유형:</strong> ${worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join(', ') : '없음'}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (worker.works && worker.works.length > 0) {
|
||||
detailHtml += `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>🔧 작업 내역</h4>
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
`;
|
||||
|
||||
worker.works.forEach((work, index) => {
|
||||
detailHtml += `
|
||||
<div style="border: 1px solid #e9ecef; padding: 15px; margin-bottom: 10px; border-radius: 8px; background: #f8f9fa; position: relative;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
|
||||
<p><strong>작업 ${index + 1}</strong></p>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button onclick="editWorkItem('${work.id}')" style="background: #007bff; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button onclick="deleteWorkItem('${work.id}')" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>프로젝트:</strong> ${work.project_name || '미지정'}</p>
|
||||
<p><strong>작업 유형:</strong> ${work.work_type_name || '미지정'}</p>
|
||||
<p><strong>작업 시간:</strong> ${work.work_hours}시간</p>
|
||||
<p><strong>상태:</strong> ${work.work_status_name || '미지정'}</p>
|
||||
${work.error_type_name ? `<p><strong>에러 유형:</strong> ${work.error_type_name}</p>` : ''}
|
||||
<p><strong>입력자:</strong> ${work.created_by_name || '미지정'}</p>
|
||||
<p><strong>입력 시간:</strong> ${formatDateTime(work.created_at)}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
detailHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
detailHtml += `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>📭 작업 내역</h4>
|
||||
<p style="text-align: center; color: #666; padding: 20px;">입력된 작업이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (worker.contributors && worker.contributors.length > 0) {
|
||||
detailHtml += `
|
||||
<div>
|
||||
<h4>👥 기여자</h4>
|
||||
<p>${worker.contributors.join(', ')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modalBody.innerHTML = detailHtml;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 작업 항목 수정 함수 (통합 API 사용)
|
||||
async function editWorkItem(workId) {
|
||||
try {
|
||||
console.log('수정할 작업 ID:', workId);
|
||||
|
||||
// 현재 작업 데이터에서 해당 작업 찾기
|
||||
let workData = null;
|
||||
for (const worker of filteredWorkData) {
|
||||
if (worker.works) {
|
||||
workData = worker.works.find(work => work.id == workId);
|
||||
if (workData) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!workData) {
|
||||
showMessage('수정할 작업을 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 필요한 마스터 데이터 로드
|
||||
await loadMasterDataForEdit();
|
||||
|
||||
// 수정 모달 표시
|
||||
showEditModal(workData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 정보 조회 오류:', error);
|
||||
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 수정용 마스터 데이터 로드
|
||||
async function loadMasterDataForEdit() {
|
||||
try {
|
||||
if (!window.projects || window.projects.length === 0) {
|
||||
const projectData = await apiCall(`${API}/projects`);
|
||||
window.projects = Array.isArray(projectData) ? projectData : (projectData.projects || []);
|
||||
}
|
||||
|
||||
if (!window.workTypes || window.workTypes.length === 0) {
|
||||
const workTypeData = await apiCall(`${API}/daily-work-reports/work-types`);
|
||||
window.workTypes = Array.isArray(workTypeData) ? workTypeData : [];
|
||||
}
|
||||
|
||||
if (!window.workStatusTypes || window.workStatusTypes.length === 0) {
|
||||
const statusData = await apiCall(`${API}/daily-work-reports/work-status-types`);
|
||||
window.workStatusTypes = Array.isArray(statusData) ? statusData : [];
|
||||
}
|
||||
|
||||
if (!window.errorTypes || window.errorTypes.length === 0) {
|
||||
const errorData = await apiCall(`${API}/daily-work-reports/error-types`);
|
||||
window.errorTypes = Array.isArray(errorData) ? errorData : [];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로드 오류:', error);
|
||||
|
||||
// 기본값 설정
|
||||
window.projects = window.projects || [];
|
||||
window.workTypes = window.workTypes || [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
];
|
||||
window.workStatusTypes = window.workStatusTypes || [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
];
|
||||
window.errorTypes = window.errorTypes || [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 수정 모달 표시
|
||||
function showEditModal(workData) {
|
||||
// 기존 상세 모달 닫기
|
||||
closeWorkerDetailModal();
|
||||
|
||||
const modalHtml = `
|
||||
<div class="edit-modal" id="editModal">
|
||||
<div class="edit-modal-content">
|
||||
<div class="edit-modal-header">
|
||||
<h3>✏️ 작업 수정</h3>
|
||||
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="edit-modal-body">
|
||||
<div class="edit-form-group">
|
||||
<label>🏗️ 프로젝트</label>
|
||||
<select class="edit-select" id="editProject">
|
||||
<option value="">프로젝트 선택</option>
|
||||
${(window.projects || []).map(p => `
|
||||
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
|
||||
${p.project_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⚙️ 작업 유형</label>
|
||||
<select class="edit-select" id="editWorkType">
|
||||
<option value="">작업 유형 선택</option>
|
||||
${(window.workTypes || []).map(wt => `
|
||||
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
|
||||
${wt.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>📊 업무 상태</label>
|
||||
<select class="edit-select" id="editWorkStatus">
|
||||
<option value="">업무 상태 선택</option>
|
||||
${(window.workStatusTypes || []).map(ws => `
|
||||
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
|
||||
${ws.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
|
||||
<label>❌ 에러 유형</label>
|
||||
<select class="edit-select" id="editErrorType">
|
||||
<option value="">에러 유형 선택</option>
|
||||
${(window.errorTypes || []).map(et => `
|
||||
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
|
||||
${et.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⏰ 작업 시간</label>
|
||||
<input type="number" class="edit-input" id="editWorkHours"
|
||||
value="${workData.work_hours}"
|
||||
min="0" max="24" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
|
||||
<button class="btn btn-success" onclick="saveEditedWork('${workData.id}')">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 업무 상태 변경 이벤트
|
||||
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
|
||||
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
|
||||
if (e.target.value === '2') {
|
||||
errorTypeGroup.style.display = 'block';
|
||||
} else {
|
||||
errorTypeGroup.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 수정 모달 닫기
|
||||
function closeEditModal() {
|
||||
const modal = document.getElementById('editModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 수정된 작업 저장 (통합 API 사용)
|
||||
async function saveEditedWork(workId) {
|
||||
try {
|
||||
const projectId = document.getElementById('editProject').value;
|
||||
const workTypeId = document.getElementById('editWorkType').value;
|
||||
const workStatusId = document.getElementById('editWorkStatus').value;
|
||||
const errorTypeId = document.getElementById('editErrorType').value;
|
||||
const workHours = document.getElementById('editWorkHours').value;
|
||||
|
||||
if (!projectId || !workTypeId || !workStatusId || !workHours) {
|
||||
showMessage('모든 필수 항목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
work_status_id: parseInt(workStatusId),
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_hours: parseFloat(workHours)
|
||||
};
|
||||
|
||||
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
console.log('✅ 수정 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
|
||||
|
||||
closeEditModal();
|
||||
closeWorkerDetailModal();
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 수정 실패:', error);
|
||||
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 항목 삭제 함수 (통합 API 사용)
|
||||
async function deleteWorkItem(workId) {
|
||||
if (!confirm('정말로 이 작업을 삭제하시겠습니까?\n삭제된 작업은 복구할 수 없습니다.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('삭제할 작업 ID:', workId);
|
||||
|
||||
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
|
||||
|
||||
// 개별 항목 삭제 API 호출 - 통합 API 사용
|
||||
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('✅ 삭제 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
|
||||
|
||||
closeWorkerDetailModal();
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 삭제 실패:', error);
|
||||
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 상세 모달 닫기
|
||||
function closeWorkerDetailModal() {
|
||||
document.getElementById('workerDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 필터링 설정
|
||||
function setupFiltering() {
|
||||
const showOnlyMissingCheckbox = document.getElementById('showOnlyMissing');
|
||||
|
||||
showOnlyMissingCheckbox.addEventListener('change', (e) => {
|
||||
if (e.target.checked) {
|
||||
// 미입력자만 필터링
|
||||
const missingWorkers = filteredWorkData.filter(worker => worker.status === 'missing');
|
||||
displayWorkers(missingWorkers);
|
||||
} else {
|
||||
// 전체 표시
|
||||
displayWorkers(filteredWorkData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 엑셀 다운로드 (개선된 버전)
|
||||
function exportToExcel() {
|
||||
try {
|
||||
// CSV 형태로 데이터 구성 (개선된 버전)
|
||||
let csvContent = "작업자명,상태,총시간,작업항목수,작업유형,프로젝트,기여자,최근업데이트\n";
|
||||
|
||||
filteredWorkData.forEach(worker => {
|
||||
const statusText = {
|
||||
completed: '완료',
|
||||
missing: '미입력',
|
||||
partial: '부분입력'
|
||||
};
|
||||
|
||||
const workTypes = worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join('; ') : '없음';
|
||||
const projects = worker.projects && worker.projects.length > 0 ? worker.projects.join('; ') : '없음';
|
||||
const contributors = worker.contributors && worker.contributors.length > 0 ? worker.contributors.join('; ') : '없음';
|
||||
const lastUpdate = worker.lastUpdate ? formatDateTime(worker.lastUpdate) : '없음';
|
||||
|
||||
csvContent += `"${worker.worker_name}","${statusText[worker.status]}","${worker.totalHours}","${worker.entryCount}","${workTypes}","${projects}","${contributors}","${lastUpdate}"\n`;
|
||||
});
|
||||
|
||||
// UTF-8 BOM 추가 (한글 깨짐 방지)
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `작업현황_${currentDate}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showMessage('✅ 엑셀 파일이 다운로드되었습니다!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('엑셀 다운로드 오류:', error);
|
||||
showMessage('엑셀 다운로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 새로고침
|
||||
function refreshData() {
|
||||
loadDashboardData();
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
document.getElementById('loadDataBtn').addEventListener('click', loadDashboardData);
|
||||
document.getElementById('refreshBtn').addEventListener('click', refreshData);
|
||||
document.getElementById('exportBtn').addEventListener('click', exportToExcel);
|
||||
|
||||
// 엔터키로 조회
|
||||
document.getElementById('selectedDate').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
loadDashboardData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
try {
|
||||
// 권한 체크
|
||||
if (!checkPermission()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 체크 메시지 숨기기
|
||||
document.getElementById('permission-check-message').style.display = 'none';
|
||||
|
||||
// 오늘 날짜 설정
|
||||
document.getElementById('selectedDate').value = getKoreaToday();
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
console.log('✅ 관리자 대시보드 초기화 완료 (통합 API 설정 적용)');
|
||||
|
||||
// 자동으로 오늘 데이터 로드
|
||||
loadDashboardData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
showMessage('초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 권한 체크 메시지 표시
|
||||
document.getElementById('permission-check-message').style.display = 'block';
|
||||
|
||||
// 토큰 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
localStorage.removeItem('token');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
init();
|
||||
});
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.closeWorkerDetailModal = closeWorkerDetailModal;
|
||||
window.refreshData = refreshData;
|
||||
window.showWorkerDetailSafe = showWorkerDetailSafe;
|
||||
window.showWorkerDetail = showWorkerDetail;
|
||||
window.editWorkItem = editWorkItem;
|
||||
window.deleteWorkItem = deleteWorkItem;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.saveEditedWork = saveEditedWork;
|
||||
122
fastapi-bridge/static/js/my-profile.js
Normal file
122
fastapi-bridge/static/js/my-profile.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// js/my-profile.js
|
||||
// 내 프로필 페이지 JavaScript
|
||||
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
const token = ensureAuthenticated();
|
||||
|
||||
// 권한 레벨 한글 매핑
|
||||
const accessLevelMap = {
|
||||
worker: '작업자',
|
||||
group_leader: '그룹장',
|
||||
support_team: '지원팀',
|
||||
admin: '관리자',
|
||||
system: '시스템 관리자'
|
||||
};
|
||||
|
||||
// 프로필 데이터 로드
|
||||
async function loadProfile() {
|
||||
try {
|
||||
// 먼저 로컬 스토리지에서 기본 정보 표시
|
||||
const storedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
if (storedUser) {
|
||||
updateProfileUI(storedUser);
|
||||
}
|
||||
|
||||
// API에서 최신 정보 가져오기
|
||||
const res = await fetch(`${API}/auth/me`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const userData = await res.json();
|
||||
|
||||
// 로컬 스토리지 업데이트
|
||||
const updatedUser = {
|
||||
...storedUser,
|
||||
...userData
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
|
||||
// UI 업데이트
|
||||
updateProfileUI(userData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('프로필 로딩 실패:', error);
|
||||
showError('프로필 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 프로필 UI 업데이트
|
||||
function updateProfileUI(user) {
|
||||
// 헤더 정보
|
||||
const avatar = document.getElementById('profileAvatar');
|
||||
if (avatar && user.name) {
|
||||
// 이름의 첫 글자를 아바타로 사용
|
||||
const initial = user.name.charAt(0).toUpperCase();
|
||||
if (initial.match(/[A-Z가-힣]/)) {
|
||||
avatar.textContent = initial;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('profileName').textContent = user.name || user.username || '사용자';
|
||||
document.getElementById('profileRole').textContent = accessLevelMap[user.access_level] || user.access_level || '역할 미지정';
|
||||
|
||||
// 기본 정보
|
||||
document.getElementById('userId').textContent = user.user_id || '-';
|
||||
document.getElementById('username').textContent = user.username || '-';
|
||||
document.getElementById('fullName').textContent = user.name || '-';
|
||||
document.getElementById('accessLevel').textContent = accessLevelMap[user.access_level] || user.access_level || '-';
|
||||
document.getElementById('workerId').textContent = user.worker_id || '연결되지 않음';
|
||||
|
||||
// 날짜 포맷팅
|
||||
if (user.created_at) {
|
||||
const createdDate = new Date(user.created_at);
|
||||
document.getElementById('createdAt').textContent = formatDate(createdDate);
|
||||
}
|
||||
|
||||
if (user.last_login_at) {
|
||||
const lastLoginDate = new Date(user.last_login_at);
|
||||
document.getElementById('lastLogin').textContent = formatDateTime(lastLoginDate);
|
||||
} else {
|
||||
document.getElementById('lastLogin').textContent = '첫 로그인';
|
||||
}
|
||||
|
||||
// 이메일
|
||||
document.getElementById('email').textContent = user.email || '등록되지 않음';
|
||||
}
|
||||
|
||||
// 날짜 포맷팅 함수
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(date) {
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 에러 표시
|
||||
function showError(message) {
|
||||
// 간단한 알림으로 처리
|
||||
alert('❌ ' + message);
|
||||
}
|
||||
|
||||
// 페이지 로드 시 실행
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('👤 프로필 페이지 로드됨');
|
||||
loadProfile();
|
||||
});
|
||||
37
fastapi-bridge/static/js/project-analysis-api.js
Normal file
37
fastapi-bridge/static/js/project-analysis-api.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// /js/project-analysis-api.js
|
||||
import { apiGet } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 분석 페이지에 필요한 모든 초기 데이터(마스터 데이터)를 병렬로 가져옵니다.
|
||||
* 이 데이터는 필터 옵션을 채우는 데 사용됩니다.
|
||||
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
|
||||
*/
|
||||
export async function getMasterData() {
|
||||
try {
|
||||
const [workers, projects, tasks] = await Promise.all([
|
||||
apiGet('/workers'),
|
||||
apiGet('/projects'),
|
||||
apiGet('/tasks')
|
||||
]);
|
||||
return { workers, projects, tasks };
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로딩 실패:', error);
|
||||
throw new Error('필터링에 필요한 데이터를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 기간의 모든 분석 데이터를 백엔드에서 직접 가져옵니다.
|
||||
* @param {string} startDate - 시작일 (YYYY-MM-DD)
|
||||
* @param {string} endDate - 종료일 (YYYY-MM-DD)
|
||||
* @returns {Promise<object>} - 요약, 집계, 상세 데이터가 모두 포함된 분석 결과 객체
|
||||
*/
|
||||
export async function getAnalysisReport(startDate, endDate) {
|
||||
try {
|
||||
const analysisData = await apiGet(`/analysis?startDate=${startDate}&endDate=${endDate}`);
|
||||
return analysisData;
|
||||
} catch (error) {
|
||||
console.error('분석 보고서 데이터 로딩 실패:', error);
|
||||
throw new Error(`분석 데이터를 불러오는 데 실패했습니다: ${error.message}`);
|
||||
}
|
||||
}
|
||||
170
fastapi-bridge/static/js/project-analysis-ui.js
Normal file
170
fastapi-bridge/static/js/project-analysis-ui.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// /js/project-analysis-ui.js
|
||||
|
||||
const DOM = {
|
||||
// 기간 설정
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
// 카드 및 필터
|
||||
analysisCard: document.getElementById('analysisCard'),
|
||||
summaryCards: document.getElementById('summaryCards'),
|
||||
projectFilter: document.getElementById('projectFilter'),
|
||||
workerFilter: document.getElementById('workerFilter'),
|
||||
taskFilter: document.getElementById('taskFilter'),
|
||||
// 탭
|
||||
tabButtons: document.querySelectorAll('.tab-button'),
|
||||
tabContents: document.querySelectorAll('.analysis-content'),
|
||||
// 테이블 본문
|
||||
projectTableBody: document.getElementById('projectTableBody'),
|
||||
workerTableBody: document.getElementById('workerTableBody'),
|
||||
taskTableBody: document.getElementById('taskTableBody'),
|
||||
detailTableBody: document.getElementById('detailTableBody'),
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜 input 값을 YYYY-MM-DD 형식의 문자열로 반환
|
||||
* @param {Date} date - 날짜 객체
|
||||
* @returns {string} - 포맷된 날짜 문자열
|
||||
*/
|
||||
const formatDate = (date) => date.toISOString().split('T')[0];
|
||||
|
||||
/**
|
||||
* UI상의 날짜 선택기를 기본값(이번 달)으로 설정합니다.
|
||||
*/
|
||||
export function setDefaultDates() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
DOM.startDate.value = formatDate(firstDay);
|
||||
DOM.endDate.value = formatDate(lastDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 실행 전후의 UI 상태를 관리합니다 (로딩 표시 등)
|
||||
* @param {'loading' | 'data' | 'no-data' | 'error'} state - UI 상태
|
||||
*/
|
||||
export function setUIState(state) {
|
||||
const projectCols = 5;
|
||||
const detailCols = 8;
|
||||
const messages = {
|
||||
loading: '📊 데이터 분석 중...',
|
||||
'no-data': '해당 기간에 분석할 데이터가 없습니다.',
|
||||
error: '오류가 발생했습니다. 다시 시도해주세요.',
|
||||
};
|
||||
|
||||
if (state === 'data') {
|
||||
DOM.analysisCard.style.display = 'block';
|
||||
} else {
|
||||
const message = messages[state];
|
||||
const html = `<tr><td colspan="${projectCols}" class="${state}">${message}</td></tr>`;
|
||||
const detailHtml = `<tr><td colspan="${detailCols}" class="${state}">${message}</td></tr>`;
|
||||
DOM.projectTableBody.innerHTML = html;
|
||||
DOM.workerTableBody.innerHTML = html;
|
||||
DOM.taskTableBody.innerHTML = html;
|
||||
DOM.detailTableBody.innerHTML = detailHtml;
|
||||
DOM.summaryCards.innerHTML = '';
|
||||
DOM.analysisCard.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 마스터 데이터를 기반으로 필터 옵션을 채웁니다.
|
||||
* @param {{workers: Array, projects: Array, tasks: Array}} masterData - 마스터 데이터
|
||||
*/
|
||||
export function updateFilterOptions(masterData) {
|
||||
const createOptions = (items, key, value) => {
|
||||
let html = '<option value="">전체</option>';
|
||||
items.forEach(item => {
|
||||
html += `<option value="${item[key]}">${item[value]}</option>`;
|
||||
});
|
||||
return html;
|
||||
};
|
||||
DOM.projectFilter.innerHTML = createOptions(masterData.projects, 'project_id', 'project_name');
|
||||
DOM.workerFilter.innerHTML = createOptions(masterData.workers, 'worker_id', 'worker_name');
|
||||
DOM.taskFilter.innerHTML = createOptions(masterData.tasks, 'task_id', 'category');
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 카드 데이터를 렌더링합니다.
|
||||
* @param {object} summary - 요약 데이터
|
||||
*/
|
||||
export function renderSummary(summary) {
|
||||
DOM.summaryCards.innerHTML = `
|
||||
<div class="summary-card"><h4>총 투입 시간</h4><div class="value">${(summary.totalHours || 0).toFixed(1)}h</div></div>
|
||||
<div class="summary-card"><h4>참여 프로젝트</h4><div class="value">${summary.totalProjects || 0}개</div></div>
|
||||
<div class="summary-card"><h4>참여 인원</h4><div class="value">${summary.totalWorkers || 0}명</div></div>
|
||||
<div class="summary-card"><h4>작업 분류</h4><div class="value">${summary.totalTasks || 0}개</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 데이터를 받아 테이블을 렌더링하는 범용 함수
|
||||
* @param {HTMLElement} tableBodyEl - 렌더링할 테이블의 tbody 요소
|
||||
* @param {Array} data - 집계된 데이터 배열
|
||||
* @param {function} rowRenderer - 각 행을 렌더링하는 함수
|
||||
*/
|
||||
function renderTable(tableBodyEl, data, rowRenderer) {
|
||||
if (!data || data.length === 0) {
|
||||
tableBodyEl.innerHTML = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tableBodyEl.innerHTML = data.map(rowRenderer).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 데이터를 기반으로 모든 분석 테이블을 렌더링합니다.
|
||||
* @param {object} analysis - 프로젝트/작업자/작업별 집계 데이터
|
||||
*/
|
||||
export function renderAnalysisTables(analysis) {
|
||||
renderTable(DOM.projectTableBody, analysis.byProject, (p, i) => `
|
||||
<tr><td>${i + 1}</td><td class="project-col" title="${p.name}">${p.name}</td><td class="hours-col">${p.hours}h</td>
|
||||
<td>${p.percentage}%</td><td>${p.participants}명</td></tr>`);
|
||||
|
||||
renderTable(DOM.workerTableBody, analysis.byWorker, (w, i) => `
|
||||
<tr><td>${i + 1}</td><td class="worker-col">${w.name}</td><td class="hours-col">${w.hours}h</td>
|
||||
<td>${w.percentage}%</td><td>${w.participants}개</td></tr>`);
|
||||
|
||||
renderTable(DOM.taskTableBody, analysis.byTask, (t, i) => `
|
||||
<tr><td>${i + 1}</td><td class="task-col" title="${t.name}">${t.name}</td><td class="hours-col">${t.hours}h</td>
|
||||
<td>${t.percentage}%</td><td>${t.participants}명</td></tr>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 내역 테이블을 렌더링합니다.
|
||||
* @param {Array} detailData - 필터링된 상세 데이터
|
||||
*/
|
||||
export function renderDetailTable(detailData) {
|
||||
if (!detailData || detailData.length === 0) {
|
||||
DOM.detailTableBody.innerHTML = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
DOM.detailTableBody.innerHTML = detailData.map((item, index) => `
|
||||
<tr><td>${index + 1}</td><td>${formatDate(new Date(item.date))}</td>
|
||||
<td class="project-col" title="${item.project_name}">${item.project_name}</td>
|
||||
<td class="worker-col">${item.worker_name}</td><td class="task-col" title="${item.task_category}">${item.task_category}</td>
|
||||
<td>${item.work_details || '정상근무'}</td>
|
||||
<td class="hours-col">${item.work_hours}h</td>
|
||||
<td title="${item.memo || '-'}">${(item.memo || '-').substring(0, 20)}</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 UI를 제어합니다.
|
||||
* @param {string} tabName - 활성화할 탭의 이름
|
||||
*/
|
||||
export function switchTab(tabName) {
|
||||
DOM.tabButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabName));
|
||||
DOM.tabContents.forEach(content => content.classList.toggle('active', content.id === `${tabName}Tab`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자로부터 현재 필터 값을 가져옵니다.
|
||||
* @returns {{project: string, worker: string, task: string}}
|
||||
*/
|
||||
export function getCurrentFilters() {
|
||||
return {
|
||||
project: DOM.projectFilter.value,
|
||||
worker: DOM.workerFilter.value,
|
||||
task: DOM.taskFilter.value,
|
||||
};
|
||||
}
|
||||
106
fastapi-bridge/static/js/project-analysis.js
Normal file
106
fastapi-bridge/static/js/project-analysis.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// /js/project-analysis.js
|
||||
import { getMasterData, getAnalysisReport } from './project-analysis-api.js';
|
||||
import {
|
||||
setDefaultDates,
|
||||
setUIState,
|
||||
updateFilterOptions,
|
||||
renderSummary,
|
||||
renderAnalysisTables,
|
||||
renderDetailTable,
|
||||
switchTab,
|
||||
} from './project-analysis-ui.js';
|
||||
|
||||
// DOM 요소 참조 (이벤트 리스너 설정용)
|
||||
const DOM = {
|
||||
startDate: document.getElementById('startDate'),
|
||||
endDate: document.getElementById('endDate'),
|
||||
analyzeBtn: document.getElementById('analyzeBtn'),
|
||||
quickMonthBtn: document.getElementById('quickMonth'),
|
||||
quickLastMonthBtn: document.getElementById('quickLastMonth'),
|
||||
// 필터 버튼은 현재 아무 기능도 하지 않으므로 주석 처리 또는 제거 가능
|
||||
// applyFilterBtn: document.getElementById('applyFilter'),
|
||||
tabButtons: document.querySelectorAll('.tab-button'),
|
||||
};
|
||||
|
||||
/**
|
||||
* 분석 실행 버튼 클릭 이벤트 핸들러
|
||||
*/
|
||||
async function handleAnalysis() {
|
||||
const startDate = DOM.startDate.value;
|
||||
const endDate = DOM.endDate.value;
|
||||
|
||||
if (!startDate || !endDate || startDate > endDate) {
|
||||
alert('올바른 분석 기간을 설정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUIState('loading');
|
||||
try {
|
||||
const analysisResult = await getAnalysisReport(startDate, endDate);
|
||||
|
||||
if (!analysisResult.summary.totalHours) {
|
||||
setUIState('no-data');
|
||||
return;
|
||||
}
|
||||
|
||||
renderSummary(analysisResult.summary);
|
||||
renderAnalysisTables(analysisResult);
|
||||
renderDetailTable(analysisResult.details);
|
||||
setUIState('data');
|
||||
|
||||
} catch (error) {
|
||||
console.error('분석 처리 중 오류:', error);
|
||||
setUIState('error');
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 날짜 설정 버튼 핸들러
|
||||
*/
|
||||
function handleQuickDate(monthType) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = monthType === 'this' ? new Date(year, month, 1) : new Date(year, month - 1, 1);
|
||||
const lastDay = monthType === 'this' ? new Date(year, month + 1, 0) : new Date(year, month, 0);
|
||||
|
||||
DOM.startDate.value = firstDay.toISOString().split('T')[0];
|
||||
DOM.endDate.value = lastDay.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
DOM.analyzeBtn.addEventListener('click', handleAnalysis);
|
||||
DOM.quickMonthBtn.addEventListener('click', () => handleQuickDate('this'));
|
||||
DOM.quickLastMonthBtn.addEventListener('click', () => handleQuickDate('last'));
|
||||
|
||||
DOM.tabButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
// 프론트엔드 필터링은 제거되었으므로 관련 이벤트 리스너는 주석 처리합니다.
|
||||
// DOM.applyFilterBtn.addEventListener('click', ...);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
async function initialize() {
|
||||
setDefaultDates();
|
||||
setupEventListeners();
|
||||
|
||||
try {
|
||||
const masterData = await getMasterData();
|
||||
updateFilterOptions(masterData);
|
||||
await handleAnalysis();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
setUIState('error');
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
91
fastapi-bridge/static/js/report-viewer-api.js
Normal file
91
fastapi-bridge/static/js/report-viewer-api.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// /js/report-viewer-api.js
|
||||
import { apiGet } from './api-helper.js';
|
||||
import { getUser } from './auth.js';
|
||||
|
||||
/**
|
||||
* 보고서 조회를 위한 마스터 데이터를 로드합니다. (작업 유형, 상태 등)
|
||||
* 실패 시 기본값을 반환할 수 있도록 개별적으로 처리합니다.
|
||||
* @returns {Promise<object>} - 각 마스터 데이터 배열을 포함하는 객체
|
||||
*/
|
||||
export async function loadMasterData() {
|
||||
const masterData = {
|
||||
workTypes: [],
|
||||
workStatusTypes: [],
|
||||
errorTypes: []
|
||||
};
|
||||
try {
|
||||
// Promise.allSettled를 사용해 일부 API가 실패해도 전체가 중단되지 않도록 함
|
||||
const results = await Promise.allSettled([
|
||||
apiGet('/daily-work-reports/work-types'),
|
||||
apiGet('/daily-work-reports/work-status-types'),
|
||||
apiGet('/daily-work-reports/error-types')
|
||||
]);
|
||||
|
||||
if (results[0].status === 'fulfilled') masterData.workTypes = results[0].value;
|
||||
if (results[1].status === 'fulfilled') masterData.workStatusTypes = results[1].value;
|
||||
if (results[2].status === 'fulfilled') masterData.errorTypes = results[2].value;
|
||||
|
||||
return masterData;
|
||||
} catch (error) {
|
||||
console.error('마스터 데이터 로딩 중 심각한 오류 발생:', error);
|
||||
// 최소한의 기본값이라도 반환
|
||||
return masterData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 사용자의 권한을 확인하여 적절한 API 엔드포인트와 파라미터를 결정합니다.
|
||||
* @param {string} selectedDate - 조회할 날짜
|
||||
* @returns {string} - 호출할 API URL
|
||||
*/
|
||||
function getReportApiUrl(selectedDate) {
|
||||
const user = getUser();
|
||||
|
||||
// 관리자(admin, system)는 모든 데이터를 조회
|
||||
if (user && (user.role === 'admin' || user.role === 'system')) {
|
||||
// 백엔드에서 GET /daily-work-reports?date=YYYY-MM-DD 요청 시
|
||||
// 권한을 확인하고 모든 데이터를 내려준다고 가정
|
||||
return `/daily-work-reports?date=${selectedDate}`;
|
||||
}
|
||||
|
||||
// 그 외 사용자(leader, user)는 본인이 생성한 데이터만 조회
|
||||
// 백엔드에서 동일한 엔드포인트로 요청 시, 권한을 확인하고
|
||||
// 본인 데이터만 필터링해서 내려준다고 가정
|
||||
// (만약 엔드포인트가 다르다면 이 부분을 수정해야 함)
|
||||
return `/daily-work-reports?date=${selectedDate}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 특정 날짜의 작업 보고서 데이터를 서버에서 가져옵니다.
|
||||
* @param {string} selectedDate - 조회할 날짜 (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>} - 작업 보고서 데이터 배열
|
||||
*/
|
||||
export async function fetchReportData(selectedDate) {
|
||||
if (!selectedDate) {
|
||||
throw new Error('조회할 날짜가 선택되지 않았습니다.');
|
||||
}
|
||||
|
||||
const apiUrl = getReportApiUrl(selectedDate);
|
||||
|
||||
try {
|
||||
const rawData = await apiGet(apiUrl);
|
||||
|
||||
// 서버 응답이 { success: true, data: [...] } 형태일 경우와 [...] 형태일 경우 모두 처리
|
||||
if (rawData && rawData.success && Array.isArray(rawData.data)) {
|
||||
return rawData.data;
|
||||
}
|
||||
if (Array.isArray(rawData)) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
// 예상치 못한 형식의 응답
|
||||
console.warn('예상치 못한 형식의 API 응답:', rawData);
|
||||
return [];
|
||||
|
||||
} catch (error) {
|
||||
console.error(`${selectedDate}의 작업 보고서 조회 실패:`, error);
|
||||
throw new Error('서버에서 데이터를 가져오는 데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
72
fastapi-bridge/static/js/report-viewer-export.js
Normal file
72
fastapi-bridge/static/js/report-viewer-export.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// /js/report-viewer-export.js
|
||||
|
||||
/**
|
||||
* 주어진 데이터를 CSV 형식의 문자열로 변환합니다.
|
||||
* @param {object} reportData - 요약 및 작업자별 데이터
|
||||
* @returns {string} - CSV 형식의 문자열
|
||||
*/
|
||||
function convertToCsv(reportData) {
|
||||
let csvContent = "\uFEFF"; // UTF-8 BOM
|
||||
csvContent += "작업자명,프로젝트명,작업유형,작업상태,에러유형,작업시간,입력자\n";
|
||||
|
||||
reportData.workers.forEach(worker => {
|
||||
worker.entries.forEach(entry => {
|
||||
const row = [
|
||||
worker.worker_name,
|
||||
entry.project_name,
|
||||
entry.work_type_name,
|
||||
entry.work_status_name,
|
||||
entry.error_type_name,
|
||||
entry.work_hours,
|
||||
entry.created_by_name
|
||||
].map(field => `"${String(field || '').replace(/"/g, '""')}"`).join(',');
|
||||
csvContent += row + "\n";
|
||||
});
|
||||
});
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가공된 보고서 데이터를 CSV 파일로 다운로드합니다.
|
||||
* @param {object|null} reportData - UI에 표시된 가공된 데이터
|
||||
*/
|
||||
export function exportToExcel(reportData) {
|
||||
if (!reportData || !reportData.workers || reportData.workers.length === 0) {
|
||||
alert('내보낼 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csv = convertToCsv(reportData);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const fileName = `작업보고서_${reportData.summary.date}.csv`;
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', fileName);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Excel 내보내기 실패:', error);
|
||||
alert('Excel 파일을 생성하는 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지의 인쇄 기능을 호출합니다.
|
||||
*/
|
||||
export function printReport() {
|
||||
try {
|
||||
window.print();
|
||||
} catch (error) {
|
||||
console.error('인쇄 실패:', error);
|
||||
alert('인쇄 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
144
fastapi-bridge/static/js/report-viewer-ui.js
Normal file
144
fastapi-bridge/static/js/report-viewer-ui.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// /js/report-viewer-ui.js
|
||||
|
||||
/**
|
||||
* 데이터를 가공하여 UI에 표시하기 좋은 요약 형태로 변환합니다.
|
||||
* @param {Array} rawData - 서버에서 받은 원시 데이터 배열
|
||||
* @param {string} selectedDate - 선택된 날짜
|
||||
* @returns {object} - 요약 정보와 작업자별로 그룹화된 데이터를 포함하는 객체
|
||||
*/
|
||||
export function processReportData(rawData, selectedDate) {
|
||||
if (!Array.isArray(rawData) || rawData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workerGroups = {};
|
||||
let totalHours = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
rawData.forEach(item => {
|
||||
const workerName = item.worker_name || '미지정';
|
||||
const workHours = parseFloat(item.work_hours || 0);
|
||||
totalHours += workHours;
|
||||
if (item.work_status_id === 2) errorCount++; // '에러' 상태 ID가 2라고 가정
|
||||
|
||||
if (!workerGroups[workerName]) {
|
||||
workerGroups[workerName] = {
|
||||
worker_name: workerName,
|
||||
total_hours: 0,
|
||||
entries: []
|
||||
};
|
||||
}
|
||||
workerGroups[workerName].total_hours += workHours;
|
||||
workerGroups[workerName].entries.push(item);
|
||||
});
|
||||
|
||||
return {
|
||||
summary: {
|
||||
date: selectedDate,
|
||||
total_workers: Object.keys(workerGroups).length,
|
||||
total_hours: totalHours,
|
||||
total_entries: rawData.length,
|
||||
error_count: errorCount
|
||||
},
|
||||
workers: Object.values(workerGroups)
|
||||
};
|
||||
}
|
||||
|
||||
function displaySummary(summary) {
|
||||
const elements = {
|
||||
totalWorkers: summary.total_workers,
|
||||
totalHours: `${summary.total_hours}시간`,
|
||||
totalEntries: `${summary.total_entries}개`,
|
||||
errorCount: `${summary.error_count}개`
|
||||
};
|
||||
Object.entries(elements).forEach(([id, value]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
});
|
||||
document.getElementById('reportSummary').style.display = 'block';
|
||||
}
|
||||
|
||||
function createWorkEntryElement(entry) {
|
||||
const entryDiv = document.createElement('div');
|
||||
entryDiv.className = `work-entry ${entry.work_status_id === 2 ? 'error-entry' : ''}`;
|
||||
entryDiv.innerHTML = `
|
||||
<div class="entry-header">
|
||||
<div class="project-name">${entry.project_name || '프로젝트 미지정'}</div>
|
||||
<div class="work-hours">${entry.work_hours || 0}시간</div>
|
||||
</div>
|
||||
<div class="entry-details">
|
||||
<div class="entry-detail">
|
||||
<span class="detail-label">작업 유형:</span>
|
||||
<span class="detail-value">${entry.work_type_name || '-'}</span>
|
||||
</div>
|
||||
${entry.work_status_id === 2 ? `
|
||||
<div class="entry-detail">
|
||||
<span class="detail-label">에러 유형:</span>
|
||||
<span class="detail-value error-type">${entry.error_type_name || '에러'}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
return entryDiv;
|
||||
}
|
||||
|
||||
function displayWorkersDetails(workers) {
|
||||
const workersListEl = document.getElementById('workersList');
|
||||
workersListEl.innerHTML = '';
|
||||
workers.forEach(worker => {
|
||||
const workerCard = document.createElement('div');
|
||||
workerCard.className = 'worker-card';
|
||||
workerCard.innerHTML = `
|
||||
<div class="worker-header">
|
||||
<div class="worker-name">👤 ${worker.worker_name}</div>
|
||||
<div class="worker-total-hours">총 ${worker.total_hours}시간</div>
|
||||
</div>
|
||||
`;
|
||||
const entriesContainer = document.createElement('div');
|
||||
entriesContainer.className = 'work-entries';
|
||||
worker.entries.forEach(entry => entriesContainer.appendChild(createWorkEntryElement(entry)));
|
||||
workerCard.appendChild(entriesContainer);
|
||||
workersListEl.appendChild(workerCard);
|
||||
});
|
||||
document.getElementById('workersReport').style.display = 'block';
|
||||
}
|
||||
|
||||
const hideElement = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* 가공된 데이터를 받아 화면 전체를 렌더링합니다.
|
||||
* @param {object|null} processedData - 가공된 데이터 또는 데이터가 없을 경우 null
|
||||
*/
|
||||
export function renderReport(processedData) {
|
||||
hideElement('loadingSpinner');
|
||||
hideElement('errorMessage');
|
||||
hideElement('noDataMessage');
|
||||
hideElement('reportSummary');
|
||||
hideElement('workersReport');
|
||||
hideElement('exportSection');
|
||||
|
||||
if (!processedData) {
|
||||
document.getElementById('noDataMessage').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
displaySummary(processedData.summary);
|
||||
displayWorkersDetails(processedData.workers);
|
||||
document.getElementById('exportSection').style.display = 'block';
|
||||
}
|
||||
|
||||
export function showLoading(isLoading) {
|
||||
document.getElementById('loadingSpinner').style.display = isLoading ? 'flex' : 'none';
|
||||
if(isLoading) {
|
||||
hideElement('errorMessage');
|
||||
hideElement('noDataMessage');
|
||||
}
|
||||
}
|
||||
|
||||
export function showError(message) {
|
||||
const errorEl = document.getElementById('errorMessage');
|
||||
errorEl.querySelector('.error-text').textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
hideElement('loadingSpinner');
|
||||
}
|
||||
93
fastapi-bridge/static/js/user-dashboard.js
Normal file
93
fastapi-bridge/static/js/user-dashboard.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// /js/user-dashboard.js
|
||||
import { getUser } from './auth.js';
|
||||
import { apiGet } from './api-helper.js'; // 개선된 api-helper를 사용합니다.
|
||||
|
||||
/**
|
||||
* API를 호출하여 오늘의 작업 일정을 불러와 화면에 표시합니다.
|
||||
*/
|
||||
async function loadTodaySchedule() {
|
||||
const scheduleContainer = document.getElementById('today-schedule');
|
||||
scheduleContainer.innerHTML = '<p>📅 오늘의 작업 일정을 불러오는 중...</p>';
|
||||
|
||||
try {
|
||||
// 예시: /api/dashboard/today-schedule 엔드포인트에서 데이터를 가져옵니다.
|
||||
// 실제 엔드포인트는 백엔드 구현에 따라 달라질 수 있습니다.
|
||||
const scheduleData = await apiGet('/dashboard/today-schedule');
|
||||
|
||||
if (scheduleData && scheduleData.length > 0) {
|
||||
const scheduleHtml = scheduleData.map(item => `
|
||||
<div class="schedule-item">
|
||||
<span class="time">${item.time}</span>
|
||||
<span class="task">${item.task_name}</span>
|
||||
<span class="status ${item.status}">${item.status_kor}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
scheduleContainer.innerHTML = scheduleHtml;
|
||||
} else {
|
||||
scheduleContainer.innerHTML = '<p>오늘 예정된 작업이 없습니다.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('오늘의 작업 일정 로드 실패:', error);
|
||||
scheduleContainer.innerHTML = '<p class="error">일정 정보를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API를 호출하여 현재 사용자의 작업 통계를 불러와 화면에 표시합니다.
|
||||
*/
|
||||
async function loadWorkStats() {
|
||||
const statsContainer = document.getElementById('work-stats');
|
||||
statsContainer.innerHTML = '<p>📈 내 작업 현황을 불러오는 중...</p>';
|
||||
|
||||
try {
|
||||
// 예시: /api/dashboard/my-stats 엔드포인트에서 데이터를 가져옵니다.
|
||||
const statsData = await apiGet('/dashboard/my-stats');
|
||||
|
||||
if (statsData) {
|
||||
const statsHtml = `
|
||||
<div class="stat-item">
|
||||
<span>이번 주 작업 시간:</span>
|
||||
<strong>${statsData.weekly_hours || 0} 시간</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>이번 달 작업 시간:</span>
|
||||
<strong>${statsData.monthly_hours || 0} 시간</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>완료한 작업 수:</span>
|
||||
<strong>${statsData.completed_tasks || 0} 건</strong>
|
||||
</div>
|
||||
`;
|
||||
statsContainer.innerHTML = statsHtml;
|
||||
} else {
|
||||
statsContainer.innerHTML = '<p>표시할 통계 정보가 없습니다.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업 통계 로드 실패:', error);
|
||||
statsContainer.innerHTML = '<p class="error">통계 정보를 불러오는 데 실패했습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 환영 메시지를 사용자 이름으로 개인화합니다.
|
||||
*/
|
||||
function personalizeWelcome() {
|
||||
// 전역 변수 대신 auth.js 모듈을 통해 사용자 정보를 가져옵니다.
|
||||
const user = getUser();
|
||||
if (user) {
|
||||
const welcomeEl = document.getElementById('welcome-message');
|
||||
if (welcomeEl) {
|
||||
welcomeEl.textContent = `${user.name || user.username}님, 환영합니다! 오늘 하루도 안전하게 작업하세요.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화 함수
|
||||
function initializeDashboard() {
|
||||
personalizeWelcome();
|
||||
loadTodaySchedule();
|
||||
loadWorkStats();
|
||||
}
|
||||
|
||||
// DOM이 로드되면 대시보드 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializeDashboard);
|
||||
46
fastapi-bridge/static/js/work-report-api.js
Normal file
46
fastapi-bridge/static/js/work-report-api.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// /js/work-report-api.js
|
||||
import { apiGet, apiPost } from './api-helper.js';
|
||||
|
||||
/**
|
||||
* 작업 보고서 작성을 위해 필요한 초기 데이터(작업자, 프로젝트, 태스크)를 가져옵니다.
|
||||
* Promise.all을 사용하여 병렬로 API를 호출합니다.
|
||||
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
|
||||
*/
|
||||
export async function getInitialData() {
|
||||
try {
|
||||
const [workers, projects, tasks] = await Promise.all([
|
||||
apiGet('/workers'),
|
||||
apiGet('/projects'),
|
||||
apiGet('/tasks')
|
||||
]);
|
||||
|
||||
// 데이터 형식 검증
|
||||
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks)) {
|
||||
throw new Error('서버에서 받은 데이터 형식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
// 작업자 목록은 ID 기준으로 정렬
|
||||
workers.sort((a, b) => a.worker_id - b.worker_id);
|
||||
|
||||
return { workers, projects, tasks };
|
||||
} catch (error) {
|
||||
console.error('초기 데이터 로딩 중 오류 발생:', error);
|
||||
// 에러를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성된 작업 보고서 데이터를 서버에 전송합니다.
|
||||
* @param {Array<object>} reportData - 전송할 작업 보고서 데이터 배열
|
||||
* @returns {Promise<object>} - 서버의 응답 결과
|
||||
*/
|
||||
export async function createWorkReport(reportData) {
|
||||
try {
|
||||
const result = await apiPost('/workreports', reportData);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('작업 보고서 생성 요청 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
79
fastapi-bridge/static/js/work-report-create.js
Normal file
79
fastapi-bridge/static/js/work-report-create.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// /js/work-report-create.js
|
||||
import { renderCalendar } from './calendar.js';
|
||||
import { getInitialData, createWorkReport } from './work-report-api.js';
|
||||
import { initializeReportTable, getReportData } from './work-report-ui.js';
|
||||
|
||||
// 전역 상태 변수
|
||||
let selectedDate = '';
|
||||
|
||||
/**
|
||||
* 날짜가 선택되었을 때 실행되는 콜백 함수.
|
||||
* 초기 데이터를 로드하고 테이블을 렌더링합니다.
|
||||
* @param {string} date - 선택된 날짜 (YYYY-MM-DD 형식)
|
||||
*/
|
||||
async function onDateSelect(date) {
|
||||
selectedDate = date;
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">데이터를 불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const initialData = await getInitialData();
|
||||
initializeReportTable(initialData);
|
||||
} catch (error) {
|
||||
alert('데이터를 불러오는 데 실패했습니다: ' + error.message);
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center error">오류 발생! 데이터를 불러올 수 없습니다.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* '전체 등록' 버튼 클릭 시 실행되는 이벤트 핸들러.
|
||||
* 폼 데이터를 서버에 전송합니다.
|
||||
*/
|
||||
async function handleSubmit() {
|
||||
if (!selectedDate) {
|
||||
alert('먼저 달력에서 날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reportData = getReportData();
|
||||
if (!reportData) {
|
||||
// getReportData 내부에서 이미 alert으로 사용자에게 알림
|
||||
return;
|
||||
}
|
||||
|
||||
// 각 항목에 선택된 날짜 추가
|
||||
const payload = reportData.map(item => ({ ...item, date: selectedDate }));
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '등록 중...';
|
||||
|
||||
try {
|
||||
const result = await createWorkReport(payload);
|
||||
if (result.success) {
|
||||
alert('✅ 작업 보고서가 성공적으로 등록되었습니다!');
|
||||
// 성공 후 폼을 다시 로드하거나, 다른 페이지로 이동 등의 로직 추가 가능
|
||||
onDateSelect(selectedDate); // 현재 날짜의 폼을 다시 로드
|
||||
} else {
|
||||
throw new Error(result.error || '알 수 없는 오류로 등록에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('❌ 등록 실패: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '전체 등록';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 초기화 함수
|
||||
*/
|
||||
function initializePage() {
|
||||
renderCalendar('calendar', onDateSelect);
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.addEventListener('click', handleSubmit);
|
||||
}
|
||||
|
||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
210
fastapi-bridge/static/js/work-report-manage.js
Normal file
210
fastapi-bridge/static/js/work-report-manage.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import { renderCalendar } from '/js/calendar.js';
|
||||
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
|
||||
|
||||
// 인증 확인
|
||||
ensureAuthenticated();
|
||||
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const reportBody = document.getElementById('reportBody');
|
||||
let selectedDate = '';
|
||||
|
||||
// 캘린더 렌더링
|
||||
renderCalendar('calendar', (dateStr) => {
|
||||
selectedDate = dateStr;
|
||||
loadReports();
|
||||
});
|
||||
|
||||
// 보고서 로딩
|
||||
async function loadReports() {
|
||||
if (!selectedDate) return;
|
||||
reportBody.innerHTML = '<tr><td colspan="8">불러오는 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const [wRes, pRes, tRes, rRes] = await Promise.all([
|
||||
fetch(`${API}/workers`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API}/projects`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API}/tasks`, { headers: getAuthHeaders() }),
|
||||
fetch(`${API}/workreports?start=${selectedDate}&end=${selectedDate}`, { headers: getAuthHeaders() })
|
||||
]);
|
||||
|
||||
if (![wRes, pRes, tRes, rRes].every(res => res.ok)) throw new Error('불러오기 실패');
|
||||
|
||||
const [workers, projects, tasks, reports] = await Promise.all([
|
||||
wRes.json(), pRes.json(), tRes.json(), rRes.json()
|
||||
]);
|
||||
|
||||
// 배열 체크
|
||||
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks) || !Array.isArray(reports)) {
|
||||
throw new Error('잘못된 데이터 형식');
|
||||
}
|
||||
|
||||
if (!reports.length) {
|
||||
reportBody.innerHTML = '<tr><td colspan="8">등록된 보고서가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const nameMap = Object.fromEntries(workers.map(w => [w.worker_id, w.worker_name]));
|
||||
const projMap = Object.fromEntries(projects.map(p => [p.project_id, p.project_name]));
|
||||
const taskMap = Object.fromEntries(tasks.map(t => [t.task_id, `${t.category}:${t.subcategory}`]));
|
||||
|
||||
reportBody.innerHTML = '';
|
||||
reports.forEach((r, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${i + 1}</td>
|
||||
<td>${nameMap[r.worker_id] || r.worker_id}</td>
|
||||
<td><select data-id="project">
|
||||
${projects.map(p =>
|
||||
`<option value="${p.project_id}" ${p.project_id === r.project_id ? 'selected' : ''}>${p.project_name}</option>`
|
||||
).join('')}</select></td>
|
||||
<td><select data-id="task">
|
||||
${tasks.map(t =>
|
||||
`<option value="${t.task_id}" ${t.task_id === r.task_id ? 'selected' : ''}>${t.category}:${t.subcategory}</option>`
|
||||
).join('')}</select></td>
|
||||
<td><input type="number" min="0" step="0.5" value="${r.overtime_hours || ''}" data-id="overtime"></td>
|
||||
<td><select data-id="work_details">
|
||||
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(opt =>
|
||||
`<option value="${opt}" ${r.work_details === opt ? 'selected' : ''}>${opt}</option>`
|
||||
).join('')}</select></td>
|
||||
<td><input type="text" value="${r.memo || ''}" data-id="memo"></td>
|
||||
<td>
|
||||
<button class="action-btn save-btn">저장</button>
|
||||
<button class="action-btn delete-btn">삭제</button>
|
||||
</td>`;
|
||||
|
||||
// 저장 버튼
|
||||
tr.querySelector('.save-btn').onclick = async () => {
|
||||
// 입력값 검증
|
||||
const projectId = tr.querySelector('[data-id="project"]').value;
|
||||
const taskId = tr.querySelector('[data-id="task"]').value;
|
||||
const overtimeHours = tr.querySelector('[data-id="overtime"]').value;
|
||||
|
||||
if (!projectId || !taskId) {
|
||||
alert('❌ 프로젝트와 작업을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜 형식 처리 - MySQL DATE 형식으로 변환
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return selectedDate;
|
||||
|
||||
// 이미 YYYY-MM-DD 형식인지 확인
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// ISO 형식이나 다른 형식을 YYYY-MM-DD로 변환
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return selectedDate; // 잘못된 날짜면 선택된 날짜 사용
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0]; // YYYY-MM-DD 형식으로 변환
|
||||
};
|
||||
|
||||
const payload = {
|
||||
date: formatDate(r.date), // 날짜 형식 변환
|
||||
worker_id: r.worker_id, // 기존 작업자 ID 유지
|
||||
project_id: Number(projectId),
|
||||
task_id: Number(taskId),
|
||||
overtime_hours: overtimeHours ? Number(overtimeHours) : null,
|
||||
work_details: tr.querySelector('[data-id="work_details"]').value,
|
||||
memo: tr.querySelector('[data-id="memo"]').value.trim() || null
|
||||
};
|
||||
|
||||
// 저장 버튼 상태 변경 (로딩 중)
|
||||
const saveBtn = tr.querySelector('.save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
const originalColor = saveBtn.style.backgroundColor;
|
||||
|
||||
saveBtn.textContent = '저장 중...';
|
||||
saveBtn.style.backgroundColor = '#ffc107';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports/${r.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
// 성공 상태 표시
|
||||
saveBtn.textContent = '✅ 완료';
|
||||
saveBtn.style.backgroundColor = '#28a745';
|
||||
saveBtn.style.color = 'white';
|
||||
|
||||
setTimeout(() => {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.style.color = '';
|
||||
saveBtn.disabled = false;
|
||||
}, 2000);
|
||||
|
||||
// alert 대신 조용한 알림
|
||||
console.log('저장 완료:', result);
|
||||
} else {
|
||||
console.error('저장 실패:', result);
|
||||
alert(`❌ 저장 실패: ${result.error || result.message || '알 수 없는 오류'}`);
|
||||
|
||||
// 실패 시 버튼 복원
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('저장 요청 에러:', err);
|
||||
alert('❌ 저장 요청 실패: ' + err.message);
|
||||
|
||||
// 에러 시 버튼 복원
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.style.backgroundColor = originalColor;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 버튼
|
||||
tr.querySelector('.delete-btn').onclick = async () => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/workreports/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
tr.remove();
|
||||
// 행 번호 다시 매기기
|
||||
updateRowNumbers();
|
||||
alert('✅ 삭제 완료');
|
||||
} else {
|
||||
const result = await res.json();
|
||||
alert(`❌ 삭제 실패: ${result.error || result.message || '알 수 없는 오류'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('삭제 요청 에러:', err);
|
||||
alert('❌ 삭제 요청 실패: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
reportBody.appendChild(tr);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('데이터 로딩 에러:', err);
|
||||
reportBody.innerHTML = '<tr><td colspan="8">❌ 불러오기 실패: ' + err.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// 행 번호 다시 매기기
|
||||
function updateRowNumbers() {
|
||||
reportBody.querySelectorAll('tr').forEach((tr, i) => {
|
||||
const firstTd = tr.querySelector('td:first-child');
|
||||
if (firstTd) firstTd.textContent = i + 1;
|
||||
});
|
||||
}
|
||||
1055
fastapi-bridge/static/js/work-report-review.js
Normal file
1055
fastapi-bridge/static/js/work-report-review.js
Normal file
File diff suppressed because it is too large
Load Diff
141
fastapi-bridge/static/js/work-report-ui.js
Normal file
141
fastapi-bridge/static/js/work-report-ui.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// /js/work-report-ui.js
|
||||
|
||||
const DEFAULT_PROJECT_ID = '13'; // 나중에는 API나 설정에서 받아오는 것이 좋음
|
||||
const DEFAULT_TASK_ID = '15';
|
||||
|
||||
/**
|
||||
* 주어진 데이터를 바탕으로 <select> 요소의 <option>들을 생성합니다.
|
||||
* @param {Array<object>} items - 옵션으로 만들 데이터 배열
|
||||
* @param {string} valueField - <option>의 value 속성에 사용할 필드 이름
|
||||
* @param {string} textField - <option>의 텍스트에 사용할 필드 이름
|
||||
* @returns {string} - 생성된 HTML 옵션 문자열
|
||||
*/
|
||||
function createOptions(items, valueField, textField) {
|
||||
return items.map(item => `<option value="${item[valueField]}">${textField(item)}</option>`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 모든 행 번호를 다시 매깁니다.
|
||||
* @param {HTMLTableSectionElement} tableBody - tbody 요소
|
||||
*/
|
||||
function updateRowNumbers(tableBody) {
|
||||
tableBody.querySelectorAll('tr').forEach((tr, index) => {
|
||||
tr.cells[0].textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 하나의 작업 보고서 행(tr)을 생성합니다.
|
||||
* @param {object} worker - 작업자 정보
|
||||
* @param {Array} projects - 전체 프로젝트 목록
|
||||
* @param {Array} tasks - 전체 태스크 목록
|
||||
* @param {number} index - 행 번호
|
||||
* @returns {HTMLTableRowElement} - 생성된 tr 요소
|
||||
*/
|
||||
function createReportRow(worker, projects, tasks, index) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>
|
||||
<input type="hidden" name="worker_id" value="${worker.worker_id}">
|
||||
${worker.worker_name}
|
||||
</td>
|
||||
<td><select name="project_id">${createOptions(projects, 'project_id', p => p.project_name)}</select></td>
|
||||
<td><select name="task_id">${createOptions(tasks, 'task_id', t => `${t.category}:${t.subcategory}`)}</select></td>
|
||||
<td>
|
||||
<select name="overtime">
|
||||
<option value="">없음</option>
|
||||
${[1, 2, 3, 4].map(n => `<option>${n}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="work_type">
|
||||
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(t => `<option>${t}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="memo" placeholder="메모"></td>
|
||||
<td><button type="button" class="remove-btn">x</button></td>
|
||||
`;
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
const workTypeSelect = tr.querySelector('[name="work_type"]');
|
||||
const projectSelect = tr.querySelector('[name="project_id"]');
|
||||
const taskSelect = tr.querySelector('[name="task_id"]');
|
||||
|
||||
workTypeSelect.addEventListener('change', () => {
|
||||
const isDisabled = ['연차', '휴무', '유급'].includes(workTypeSelect.value);
|
||||
projectSelect.disabled = isDisabled;
|
||||
taskSelect.disabled = isDisabled;
|
||||
if (isDisabled) {
|
||||
projectSelect.value = DEFAULT_PROJECT_ID;
|
||||
taskSelect.value = DEFAULT_TASK_ID;
|
||||
}
|
||||
});
|
||||
|
||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
updateRowNumbers(tr.parentElement);
|
||||
});
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 보고서 테이블을 초기화하고 데이터를 채웁니다.
|
||||
* @param {{workers: Array, projects: Array, tasks: Array}} initialData - 초기 데이터
|
||||
*/
|
||||
export function initializeReportTable(initialData) {
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
if (!tableBody) return;
|
||||
|
||||
tableBody.innerHTML = ''; // 기존 내용 초기화
|
||||
const { workers, projects, tasks } = initialData;
|
||||
|
||||
if (!workers || workers.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">등록할 작업자 정보가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
workers.forEach((worker, index) => {
|
||||
const row = createReportRow(worker, projects, tasks, index);
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에서 폼 데이터를 추출하여 배열로 반환합니다.
|
||||
* @returns {Array<object>|null} - 추출된 데이터 배열 또는 유효성 검사 실패 시 null
|
||||
*/
|
||||
export function getReportData() {
|
||||
const tableBody = document.getElementById('reportBody');
|
||||
const rows = tableBody.querySelectorAll('tr');
|
||||
|
||||
if (rows.length === 0 || (rows.length === 1 && rows[0].cells.length < 2)) {
|
||||
alert('등록할 내용이 없습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const reportData = [];
|
||||
const workerIds = new Set();
|
||||
|
||||
for (const tr of rows) {
|
||||
const workerId = tr.querySelector('[name="worker_id"]').value;
|
||||
if (workerIds.has(workerId)) {
|
||||
alert(`오류: 작업자 '${tr.cells[1].textContent.trim()}'가 중복 등록되었습니다.`);
|
||||
return null;
|
||||
}
|
||||
workerIds.add(workerId);
|
||||
|
||||
reportData.push({
|
||||
worker_id: workerId,
|
||||
project_id: tr.querySelector('[name="project_id"]').value,
|
||||
task_id: tr.querySelector('[name="task_id"]').value,
|
||||
overtime_hours: tr.querySelector('[name="overtime"]').value || 0,
|
||||
work_details: tr.querySelector('[name="work_type"]').value,
|
||||
memo: tr.querySelector('[name="memo"]').value
|
||||
});
|
||||
}
|
||||
|
||||
return reportData;
|
||||
}
|
||||
776
fastapi-bridge/static/js/work-review.js
Normal file
776
fastapi-bridge/static/js/work-review.js
Normal file
@@ -0,0 +1,776 @@
|
||||
// work-review.js - 통합 API 설정 적용 버전
|
||||
|
||||
// =================================================================
|
||||
// 🌐 통합 API 설정 import
|
||||
// =================================================================
|
||||
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let currentDate = new Date();
|
||||
let selectedDate = null;
|
||||
let selectedDateData = null;
|
||||
let basicData = {
|
||||
workTypes: [],
|
||||
workStatusTypes: [],
|
||||
errorTypes: [],
|
||||
projects: []
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
function getCurrentUser() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return null;
|
||||
|
||||
const payloadBase64 = token.split('.')[1];
|
||||
if (payloadBase64) {
|
||||
const payload = JSON.parse(atob(payloadBase64));
|
||||
return payload;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('토큰에서 사용자 정보 추출 실패:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 메시지 표시
|
||||
function showMessage(message, type = 'info') {
|
||||
const container = document.getElementById('message-container');
|
||||
container.innerHTML = `<div class="message ${type}">${message}</div>`;
|
||||
|
||||
if (type !== 'loading') {
|
||||
setTimeout(() => {
|
||||
container.innerHTML = '';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(date) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
// 월 표시 업데이트
|
||||
function updateMonthDisplay() {
|
||||
const monthElement = document.getElementById('currentMonth');
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
monthElement.textContent = `${year}년 ${month}월`;
|
||||
}
|
||||
|
||||
// 근무 유형 분류
|
||||
function classifyWorkType(totalHours) {
|
||||
if (totalHours === 0) return { type: 'vacation', label: '휴무' };
|
||||
if (totalHours === 2) return { type: 'vacation', label: '조퇴' };
|
||||
if (totalHours === 4) return { type: 'vacation', label: '반차' };
|
||||
if (totalHours === 6) return { type: 'vacation', label: '반반차' };
|
||||
if (totalHours === 8) return { type: 'normal-work', label: '정시근무' };
|
||||
if (totalHours > 8) return { type: 'overtime', label: '잔업' };
|
||||
return { type: 'vacation', label: '기타' };
|
||||
}
|
||||
|
||||
// 캘린더 렌더링 (데이터 로드 없이)
|
||||
function renderCalendar() {
|
||||
const calendar = document.getElementById('calendar');
|
||||
|
||||
// 기존 날짜 셀들 제거 (헤더는 유지)
|
||||
const dayHeaders = calendar.querySelectorAll('.day-header');
|
||||
calendar.innerHTML = '';
|
||||
dayHeaders.forEach(header => calendar.appendChild(header));
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
// 해당 월의 첫째 날과 마지막 날
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// 첫째 주의 시작 (일요일부터 시작)
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
// 마지막 주의 끝
|
||||
const endDate = new Date(lastDay);
|
||||
endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()));
|
||||
|
||||
// 오늘 날짜
|
||||
const today = new Date();
|
||||
const todayStr = formatDate(today);
|
||||
|
||||
// 날짜 셀 생성
|
||||
let currentCalendarDate = new Date(startDate);
|
||||
|
||||
while (currentCalendarDate <= endDate) {
|
||||
const dateStr = formatDate(currentCalendarDate);
|
||||
const isCurrentMonth = currentCalendarDate.getMonth() === month;
|
||||
const isToday = dateStr === todayStr;
|
||||
const isSelected = selectedDate === dateStr;
|
||||
|
||||
const dayCell = document.createElement('div');
|
||||
dayCell.className = 'day-cell';
|
||||
|
||||
if (!isCurrentMonth) {
|
||||
dayCell.classList.add('other-month');
|
||||
}
|
||||
|
||||
if (isToday) {
|
||||
dayCell.classList.add('today');
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
dayCell.classList.add('selected');
|
||||
}
|
||||
|
||||
// 날짜 번호
|
||||
const dayNumber = document.createElement('div');
|
||||
dayNumber.className = 'day-number';
|
||||
dayNumber.textContent = currentCalendarDate.getDate();
|
||||
dayCell.appendChild(dayNumber);
|
||||
|
||||
// 클릭 이벤트 - 현재 월의 날짜만 클릭 가능
|
||||
if (isCurrentMonth) {
|
||||
dayCell.style.cursor = 'pointer';
|
||||
dayCell.addEventListener('click', () => {
|
||||
selectedDate = dateStr;
|
||||
loadDayData(dateStr);
|
||||
renderCalendar(); // 선택 상태 업데이트를 위해 재렌더링
|
||||
});
|
||||
}
|
||||
|
||||
calendar.appendChild(dayCell);
|
||||
currentCalendarDate.setDate(currentCalendarDate.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 날짜 데이터 로드 (통합 API 사용)
|
||||
async function loadDayData(dateStr) {
|
||||
try {
|
||||
showMessage(`${dateStr} 데이터를 불러오는 중... (통합 API)`, 'loading');
|
||||
|
||||
const data = await apiCall(`${API}/daily-work-reports?date=${dateStr}`);
|
||||
const dataArray = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
// 데이터 처리
|
||||
processDayData(dateStr, dataArray);
|
||||
renderDayInfo();
|
||||
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('날짜 데이터 로드 실패:', error);
|
||||
showMessage('데이터를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
selectedDateData = null;
|
||||
renderDayInfo();
|
||||
}
|
||||
}
|
||||
|
||||
// 일별 데이터 처리
|
||||
function processDayData(dateStr, works) {
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
totalHours: 0,
|
||||
workers: new Set(),
|
||||
reviewed: Math.random() > 0.3, // 임시: 70% 확률로 검토 완료
|
||||
details: works
|
||||
};
|
||||
|
||||
works.forEach(work => {
|
||||
dayData.totalHours += parseFloat(work.work_hours || 0);
|
||||
dayData.workers.add(work.worker_name || work.worker_id);
|
||||
});
|
||||
|
||||
const workType = classifyWorkType(dayData.totalHours);
|
||||
dayData.workType = workType.type;
|
||||
dayData.workLabel = workType.label;
|
||||
|
||||
selectedDateData = dayData;
|
||||
}
|
||||
|
||||
// 선택된 날짜 정보 렌더링
|
||||
function renderDayInfo() {
|
||||
const dayInfoContainer = document.getElementById('day-info-container');
|
||||
|
||||
if (!selectedDate) {
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-placeholder">
|
||||
<h3>📅 날짜를 선택하세요</h3>
|
||||
<p>캘린더에서 날짜를 클릭하면 해당 날짜의 작업 정보를 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDateData) {
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-placeholder">
|
||||
<h3>📅 ${selectedDate}</h3>
|
||||
<p>해당 날짜에 등록된 작업이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = selectedDateData;
|
||||
|
||||
// 작업자별 상세 정보 생성
|
||||
const workerDetailsHtml = Array.from(data.workers).map(worker => {
|
||||
const workerWorks = data.details.filter(w => (w.worker_name || w.worker_id) === worker);
|
||||
const workerHours = workerWorks.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0);
|
||||
|
||||
const workerWorkItemsHtml = workerWorks.map(work => `
|
||||
<div class="work-item-detail">
|
||||
<div class="work-item-info">
|
||||
<strong>${work.project_name || '프로젝트'}</strong> - ${work.work_hours}시간<br>
|
||||
<small>작업: ${work.work_type_name || '미지정'} | 상태: ${work.work_status_name || '미지정'}</small>
|
||||
${work.error_type_name ? `<br><small style="color: #dc3545;">에러: ${work.error_type_name}</small>` : ''}
|
||||
</div>
|
||||
<div class="work-item-actions">
|
||||
<button class="edit-work-btn" onclick="editWorkItem('${work.id}')">✏️ 수정</button>
|
||||
<button class="delete-work-btn" onclick="deleteWorkItem('${work.id}')">🗑️ 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="worker-detail-section">
|
||||
<div class="worker-header-detail">
|
||||
<strong>👤 ${worker}</strong> - 총 ${workerHours}시간
|
||||
<button class="delete-worker-btn" onclick="deleteWorkerAllWorks('${selectedDate}', '${worker}')">
|
||||
🗑️ 전체삭제
|
||||
</button>
|
||||
</div>
|
||||
<div class="worker-work-items">
|
||||
${workerWorkItemsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
dayInfoContainer.innerHTML = `
|
||||
<div class="day-info-content">
|
||||
<div class="day-info-header">
|
||||
<h3>📅 ${selectedDate} 작업 정보</h3>
|
||||
<div class="day-info-actions">
|
||||
<button class="review-toggle ${data.reviewed ? 'reviewed' : ''}" onclick="toggleReview()">
|
||||
${data.reviewed ? '✅ 검토완료' : '⏳ 검토하기'}
|
||||
</button>
|
||||
<button class="refresh-day-btn" onclick="refreshCurrentDay()">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="day-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">총 작업시간:</span>
|
||||
<span class="summary-value">${data.totalHours}시간</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">근무 유형:</span>
|
||||
<span class="summary-value ${data.workType}">${data.workLabel}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">작업자 수:</span>
|
||||
<span class="summary-value">${data.workers.size}명</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">검토 상태:</span>
|
||||
<span class="summary-value ${data.reviewed ? 'reviewed' : 'unreviewed'}">
|
||||
${data.reviewed ? '✅ 검토완료' : '⏳ 미검토'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workers-detail-container">
|
||||
<h4>👥 작업자별 상세</h4>
|
||||
${workerDetailsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 검토 상태 토글
|
||||
function toggleReview() {
|
||||
if (selectedDateData) {
|
||||
selectedDateData.reviewed = !selectedDateData.reviewed;
|
||||
renderDayInfo();
|
||||
|
||||
// TODO: 실제로는 여기서 API 호출해서 DB에 저장해야 함
|
||||
console.log(`검토 상태 변경: ${selectedDate} - ${selectedDateData.reviewed ? '검토완료' : '미검토'}`);
|
||||
|
||||
showMessage(`검토 상태가 ${selectedDateData.reviewed ? '완료' : '미완료'}로 변경되었습니다.`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 날짜 새로고침
|
||||
function refreshCurrentDay() {
|
||||
if (selectedDate) {
|
||||
loadDayData(selectedDate);
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ 작업 항목 수정 함수 (통합 API 사용)
|
||||
async function editWorkItem(workId) {
|
||||
try {
|
||||
console.log('수정할 작업 ID:', workId);
|
||||
|
||||
if (!selectedDateData) {
|
||||
showMessage('작업 데이터를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const workData = selectedDateData.details.find(work => work.id == workId);
|
||||
if (!workData) {
|
||||
showMessage('수정할 작업 데이터를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본 데이터가 없으면 로드
|
||||
if (basicData.workTypes.length === 0) {
|
||||
showMessage('기본 데이터를 불러오는 중... (통합 API)', 'loading');
|
||||
await loadBasicData();
|
||||
}
|
||||
|
||||
showEditModal(workData);
|
||||
document.getElementById('message-container').innerHTML = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('작업 정보 조회 오류:', error);
|
||||
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ 수정 모달 표시 (개선된 버전)
|
||||
function showEditModal(workData) {
|
||||
const modalHtml = `
|
||||
<div class="edit-modal" id="editModal">
|
||||
<div class="edit-modal-content">
|
||||
<div class="edit-modal-header">
|
||||
<h3>✏️ 작업 수정</h3>
|
||||
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="edit-modal-body">
|
||||
<div class="edit-form-group">
|
||||
<label>🏗️ 프로젝트</label>
|
||||
<select class="edit-select" id="editProject" required>
|
||||
<option value="">프로젝트 선택</option>
|
||||
${basicData.projects.map(p => `
|
||||
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
|
||||
${p.project_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⚙️ 작업 유형</label>
|
||||
<select class="edit-select" id="editWorkType" required>
|
||||
<option value="">작업 유형 선택</option>
|
||||
${basicData.workTypes.map(wt => `
|
||||
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
|
||||
${wt.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>📊 업무 상태</label>
|
||||
<select class="edit-select" id="editWorkStatus" required>
|
||||
<option value="">업무 상태 선택</option>
|
||||
${basicData.workStatusTypes.map(ws => `
|
||||
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
|
||||
${ws.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
|
||||
<label>❌ 에러 유형</label>
|
||||
<select class="edit-select" id="editErrorType">
|
||||
<option value="">에러 유형 선택</option>
|
||||
${basicData.errorTypes.map(et => `
|
||||
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
|
||||
${et.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edit-form-group">
|
||||
<label>⏰ 작업 시간</label>
|
||||
<input type="number" class="edit-input" id="editWorkHours"
|
||||
value="${workData.work_hours}"
|
||||
min="0" max="24" step="0.5" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
|
||||
<button class="btn btn-success" onclick="saveEditedWork('${workData.id}')">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 업무 상태 변경 이벤트
|
||||
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
|
||||
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
|
||||
if (e.target.value === '2') {
|
||||
errorTypeGroup.style.display = 'block';
|
||||
} else {
|
||||
errorTypeGroup.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🛠️ 수정 모달 닫기
|
||||
function closeEditModal() {
|
||||
const modal = document.getElementById('editModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 🛠️ 수정된 작업 저장 (통합 API 사용)
|
||||
async function saveEditedWork(workId) {
|
||||
try {
|
||||
// 입력값 검증
|
||||
const projectId = document.getElementById('editProject').value;
|
||||
const workTypeId = document.getElementById('editWorkType').value;
|
||||
const workStatusId = document.getElementById('editWorkStatus').value;
|
||||
const errorTypeId = document.getElementById('editErrorType').value;
|
||||
const workHours = document.getElementById('editWorkHours').value;
|
||||
|
||||
// 필수값 체크
|
||||
if (!projectId) {
|
||||
showMessage('프로젝트를 선택해주세요.', 'error');
|
||||
document.getElementById('editProject').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workTypeId) {
|
||||
showMessage('작업 유형을 선택해주세요.', 'error');
|
||||
document.getElementById('editWorkType').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workStatusId) {
|
||||
showMessage('업무 상태를 선택해주세요.', 'error');
|
||||
document.getElementById('editWorkStatus').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workHours || workHours <= 0) {
|
||||
showMessage('작업 시간을 올바르게 입력해주세요.', 'error');
|
||||
document.getElementById('editWorkHours').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (workStatusId === '2' && !errorTypeId) {
|
||||
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
|
||||
document.getElementById('editErrorType').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
project_id: parseInt(projectId),
|
||||
work_type_id: parseInt(workTypeId),
|
||||
work_status_id: parseInt(workStatusId),
|
||||
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
|
||||
work_hours: parseFloat(workHours)
|
||||
};
|
||||
|
||||
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
|
||||
|
||||
// 저장 버튼 비활성화
|
||||
const saveBtn = document.querySelector('.btn-success');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = '저장 중...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
console.log('✅ 수정 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
|
||||
|
||||
closeEditModal();
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 수정 실패:', error);
|
||||
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
|
||||
// 버튼 복원
|
||||
const saveBtn = document.querySelector('.btn-success');
|
||||
if (saveBtn) {
|
||||
saveBtn.textContent = '💾 저장';
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 작업 항목 삭제 (통합 API 사용)
|
||||
async function deleteWorkItem(workId) {
|
||||
// 확인 대화상자
|
||||
const confirmDelete = await showConfirmDialog(
|
||||
'작업 삭제 확인',
|
||||
'정말로 이 작업을 삭제하시겠습니까?',
|
||||
'삭제된 작업은 복구할 수 없습니다.'
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
console.log('삭제할 작업 ID:', workId);
|
||||
|
||||
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
|
||||
|
||||
const result = await apiCall(`${API}/daily-work-reports/my-entry/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('✅ 삭제 성공 (통합 API):', result);
|
||||
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
|
||||
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 삭제 실패:', error);
|
||||
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 작업자의 모든 작업 삭제 (통합 API 사용)
|
||||
async function deleteWorkerAllWorks(date, workerName) {
|
||||
// 확인 대화상자
|
||||
const confirmDelete = await showConfirmDialog(
|
||||
'전체 작업 삭제 확인',
|
||||
`정말로 ${workerName}님의 ${date} 모든 작업을 삭제하시겠습니까?`,
|
||||
'삭제된 작업들은 복구할 수 없습니다.'
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
if (!selectedDateData) return;
|
||||
|
||||
const workerWorks = selectedDateData.details.filter(w => (w.worker_name || w.worker_id) === workerName);
|
||||
|
||||
if (workerWorks.length === 0) {
|
||||
showMessage('삭제할 작업이 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage(`${workerName}님의 작업들을 삭제하는 중... (통합 API)`, 'loading');
|
||||
|
||||
// 순차적으로 삭제 (병렬 처리하면 서버 부하 발생 가능)
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const work of workerWorks) {
|
||||
try {
|
||||
await apiCall(`${API}/daily-work-reports/my-entry/${work.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`작업 ${work.id} 삭제 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showMessage(`✅ ${workerName}님의 모든 작업(${successCount}개)이 삭제되었습니다!`, 'success');
|
||||
} else {
|
||||
showMessage(`⚠️ ${successCount}개 삭제 완료, ${failCount}개 삭제 실패`, 'warning');
|
||||
}
|
||||
|
||||
refreshCurrentDay(); // 현재 날짜 데이터 새로고침
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 전체 삭제 실패:', error);
|
||||
showMessage('작업 삭제 중 오류가 발생했습니다: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 확인 대화상자 표시
|
||||
function showConfirmDialog(title, message, warning) {
|
||||
return new Promise((resolve) => {
|
||||
const modalHtml = `
|
||||
<div class="confirm-modal" id="confirmModal">
|
||||
<div class="confirm-modal-content">
|
||||
<div class="confirm-modal-header">
|
||||
<h3>⚠️ ${title}</h3>
|
||||
</div>
|
||||
<div class="confirm-modal-body">
|
||||
<p><strong>${message}</strong></p>
|
||||
<p style="color: #dc3545; font-size: 0.9rem;">${warning}</p>
|
||||
</div>
|
||||
<div class="confirm-modal-footer">
|
||||
<button class="btn btn-secondary" onclick="resolveConfirm(false)">취소</button>
|
||||
<button class="btn btn-danger" onclick="resolveConfirm(true)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 전역 함수로 resolve 함수 노출
|
||||
window.resolveConfirm = (result) => {
|
||||
const modal = document.getElementById('confirmModal');
|
||||
if (modal) modal.remove();
|
||||
delete window.resolveConfirm;
|
||||
resolve(result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 기본 데이터 로드 (통합 API 사용)
|
||||
async function loadBasicData() {
|
||||
try {
|
||||
console.log('🔗 통합 API 설정을 사용한 기본 데이터 로딩...');
|
||||
|
||||
const promises = [
|
||||
// 프로젝트 로드
|
||||
apiCall(`${API}/projects`)
|
||||
.then(data => Array.isArray(data) ? data : (data.projects || []))
|
||||
.catch(() => []),
|
||||
|
||||
// 작업 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/work-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: 'Base'},
|
||||
{id: 2, name: 'Vessel'},
|
||||
{id: 3, name: 'Piping'}
|
||||
]),
|
||||
|
||||
// 업무 상태 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/work-status-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: '정규'},
|
||||
{id: 2, name: '에러'}
|
||||
]),
|
||||
|
||||
// 에러 유형 로드
|
||||
apiCall(`${API}/daily-work-reports/error-types`)
|
||||
.then(data => Array.isArray(data) ? data : [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
])
|
||||
.catch(() => [
|
||||
{id: 1, name: '설계미스'},
|
||||
{id: 2, name: '외주작업 불량'},
|
||||
{id: 3, name: '입고지연'},
|
||||
{id: 4, name: '작업 불량'}
|
||||
])
|
||||
];
|
||||
|
||||
const [projects, workTypes, workStatusTypes, errorTypes] = await Promise.all(promises);
|
||||
|
||||
basicData = {
|
||||
projects,
|
||||
workTypes,
|
||||
workStatusTypes,
|
||||
errorTypes
|
||||
};
|
||||
|
||||
console.log('✅ 기본 데이터 로드 완료 (통합 API):', basicData);
|
||||
} catch (error) {
|
||||
console.error('기본 데이터 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
document.getElementById('prevMonth').addEventListener('click', () => {
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
updateMonthDisplay();
|
||||
selectedDate = null;
|
||||
selectedDateData = null;
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
});
|
||||
|
||||
document.getElementById('nextMonth').addEventListener('click', () => {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
updateMonthDisplay();
|
||||
selectedDate = null;
|
||||
selectedDateData = null;
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
});
|
||||
|
||||
// 오늘 날짜로 이동 버튼 추가
|
||||
document.getElementById('goToday')?.addEventListener('click', () => {
|
||||
const today = new Date();
|
||||
currentDate = new Date(today);
|
||||
updateMonthDisplay();
|
||||
renderCalendar();
|
||||
|
||||
// 오늘 날짜 자동 선택
|
||||
const todayStr = formatDate(today);
|
||||
selectedDate = todayStr;
|
||||
loadDayData(todayStr);
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.toggleReview = toggleReview;
|
||||
window.refreshCurrentDay = refreshCurrentDay;
|
||||
window.editWorkItem = editWorkItem;
|
||||
window.deleteWorkItem = deleteWorkItem;
|
||||
window.deleteWorkerAllWorks = deleteWorkerAllWorks;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.saveEditedWork = saveEditedWork;
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || token === 'undefined') {
|
||||
showMessage('로그인이 필요합니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
updateMonthDisplay();
|
||||
setupEventListeners();
|
||||
renderCalendar();
|
||||
renderDayInfo();
|
||||
|
||||
// 기본 데이터 미리 로드
|
||||
await loadBasicData();
|
||||
|
||||
console.log('✅ 검토 페이지 초기화 완료 (통합 API 설정 적용)');
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
showMessage('초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
BIN
fastapi-bridge/static/logo 2.png
Normal file
BIN
fastapi-bridge/static/logo 2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
fastapi-bridge/static/logo.png
Normal file
BIN
fastapi-bridge/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
44
fastapi-bridge/static/pages/admin/admin dashboard.html
Normal file
44
fastapi-bridge/static/pages/admin/admin dashboard.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work-reports/create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work-reports/manage.html">작업보고서 수정/삭제</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/work-reports/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/work-reports/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🔧 관리콘솔</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/factory-upload.html">공장 정보 등록</a></li>
|
||||
<li><a href="/pages/common/factory-list.html">공장 목록 보기</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 이슈 리포트</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/issue-reports/daily-issue.html">일일 이슈 보고</a></li>
|
||||
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
181
fastapi-bridge/static/pages/admin/attendance-validation.html
Normal file
181
fastapi-bridge/static/pages/admin/attendance-validation.html
Normal file
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>근태 검증 관리 시스템 | (주)테크니컬코리아</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/attendance-validation.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto p-6">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
<a href="javascript:history.back()" class="back-btn">
|
||||
← 뒤로가기
|
||||
</a>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<div class="page-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-4xl">👥</span>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">근태 검증 관리</h1>
|
||||
<p class="text-lg text-white/90 mt-2">작업자별 근무시간을 검증하고 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-white/80 bg-white/20 px-4 py-2 rounded-lg">
|
||||
🔒 Admin 전용
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- 로딩 화면 -->
|
||||
<div id="loadingScreen" class="hidden">
|
||||
<div class="loading-card">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="ml-3 text-gray-600">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<div id="mainContent">
|
||||
<!-- 캘린더 섹션 -->
|
||||
<div class="main-card">
|
||||
<!-- 캘린더 헤더 -->
|
||||
<div class="calendar-header">
|
||||
<button id="prevMonth" class="nav-btn">
|
||||
<span class="text-xl">◀</span>
|
||||
</button>
|
||||
<h2 id="currentMonthYear" class="text-2xl font-bold text-gray-800">
|
||||
2025년 6월
|
||||
</h2>
|
||||
<button id="nextMonth" class="nav-btn">
|
||||
<span class="text-xl">▶</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 월간 요약 정보 -->
|
||||
<div class="summary-section">
|
||||
<h3 class="summary-title">이번 달 요약</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card normal">
|
||||
<div id="normalCount" class="summary-number">0</div>
|
||||
<div class="summary-label">✅ 정상</div>
|
||||
</div>
|
||||
<div class="summary-card warning">
|
||||
<div id="reviewCount" class="summary-number">0</div>
|
||||
<div class="summary-label">⚠️ 검토필요</div>
|
||||
</div>
|
||||
<div class="summary-card error">
|
||||
<div id="missingCount" class="summary-number">0</div>
|
||||
<div class="summary-label">❌ 미입력</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요일 헤더 -->
|
||||
<div class="weekday-header">
|
||||
<div class="weekday sunday">일</div>
|
||||
<div class="weekday">월</div>
|
||||
<div class="weekday">화</div>
|
||||
<div class="weekday">수</div>
|
||||
<div class="weekday">목</div>
|
||||
<div class="weekday">금</div>
|
||||
<div class="weekday saturday">토</div>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 본체 -->
|
||||
<div id="calendarGrid" class="calendar-grid">
|
||||
<!-- 동적으로 생성됨 -->
|
||||
</div>
|
||||
|
||||
<!-- 범례 -->
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot normal"></div>
|
||||
<span>정상</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot warning"></div>
|
||||
<span>검토필요</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot error"></div>
|
||||
<span>미입력</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 성능 상태 표시 (개발용) -->
|
||||
<div id="performanceStatus" class="text-xs text-gray-500 text-center mt-4 hidden">
|
||||
<div class="bg-gray-100 rounded-lg p-2">
|
||||
🔄 순차 호출: <span id="activeReq">0</span>개 처리 중 |
|
||||
📦 캐시: <span id="cacheCount">0</span>개 저장됨 |
|
||||
⏳ 대기: <span id="queueCount">0</span>개 |
|
||||
🚫 429 에러 방지 (2초 딜레이)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 리스트 섹션 -->
|
||||
<div id="workersList" class="main-card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔄</div>
|
||||
<h3 class="empty-title">날짜를 클릭해주세요</h3>
|
||||
<p class="empty-description">
|
||||
캘린더에서 날짜를 클릭하면 해당 날짜의 작업자 검증 내역을 확인할 수 있습니다.<br>
|
||||
<strong>순차 호출 방식</strong>으로 안정적이지만 약 5초의 로딩 시간이 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 모달 -->
|
||||
<div id="editModal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>✏️ 근무시간 수정</h3>
|
||||
<button class="close-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>👤 작업자</label>
|
||||
<input type="text" id="editWorkerName" class="form-input" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>📊 현재 상태</label>
|
||||
<input type="text" id="editWorkerStatus" class="form-input" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>⏰ 근무시간 (시간)</label>
|
||||
<input type="number" id="editWorkHours" class="form-input"
|
||||
min="0" max="24" step="0.5" placeholder="근무시간을 입력하세요">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>📝 수정 사유</label>
|
||||
<textarea id="editReason" class="form-textarea"
|
||||
placeholder="수정 사유를 입력하세요 (선택사항)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeEditModal()">취소</button>
|
||||
<button class="btn-primary" onclick="saveEditedWork()">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script src="/js/attendance-validation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
fastapi-bridge/static/pages/admin/dashboard.html
Normal file
35
fastapi-bridge/static/pages/admin/dashboard.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work/work-report-create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work/work-report-manage.html">작업보고서 수정/삭제</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/attendance/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/attendance/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/attendance/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>관리콘솔</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-all.html">📋 기본정보 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/factory/factory-map-upload.html">공장 지도 업로드</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🗂 기타 관리</h2>
|
||||
<p>프로젝트 및 작업자 관련 기능은 추후 확장 예정</p>
|
||||
</section>
|
||||
23
fastapi-bridge/static/pages/admin/factory-upload.html
Normal file
23
fastapi-bridge/static/pages/admin/factory-upload.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>공장 지도 등록</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-placeholder"></div>
|
||||
<div class="container">
|
||||
<h2>공장 지도 등록</h2>
|
||||
<form id="uploadForm">
|
||||
<input type="text" name="factory_name" placeholder="공장명" required>
|
||||
<input type="text" name="address" placeholder="주소" required>
|
||||
<textarea name="description" placeholder="설명" required></textarea>
|
||||
<input type="file" name="map_image" accept="image/*" required>
|
||||
<button type="submit">등록</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/js/load-navbar.js"></script>
|
||||
<script src="/js/admin/factory-upload.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
667
fastapi-bridge/static/pages/admin/manage-daily-work.html
Normal file
667
fastapi-bridge/static/pages/admin/manage-daily-work.html
Normal file
@@ -0,0 +1,667 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, Clock, Users, AlertTriangle, CheckCircle, Edit3, Filter } from 'lucide-react';
|
||||
|
||||
const AttendanceValidationPage = () => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [attendanceData, setAttendanceData] = useState({});
|
||||
const [selectedDateWorkers, setSelectedDateWorkers] = useState([]);
|
||||
const [filter, setFilter] = useState('all'); // all, needsReview, normal, missing
|
||||
const [monthlyData, setMonthlyData] = useState({ workReports: [], dailyReports: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAuthorized, setIsAuthorized] = useState(true); // TODO: 실제 권한 체크
|
||||
|
||||
// 월이 변경될 때마다 해당 월의 전체 데이터 로드
|
||||
useEffect(() => {
|
||||
loadMonthlyData();
|
||||
}, [currentDate]);
|
||||
|
||||
const loadMonthlyData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
|
||||
console.log(`${year}년 ${month}월 데이터 로딩 중...`);
|
||||
const data = await fetchMonthlyData(year, month);
|
||||
setMonthlyData(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('월간 데이터 로딩 실패:', error);
|
||||
// 실패 시 빈 데이터로 설정
|
||||
setMonthlyData({ workReports: [], dailyReports: [] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 실제 API 호출 함수들
|
||||
const fetchWorkReports = async (date) => {
|
||||
try {
|
||||
const response = await fetch(`/api/workreports/date/${date}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('WorkReports API 호출 오류:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDailyWorkReports = async (date) => {
|
||||
try {
|
||||
const response = await fetch(`/api/daily-work-reports/date/${date}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('DailyWorkReports API 호출 오류:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 월간 데이터 가져오기 (캘린더용)
|
||||
const fetchMonthlyData = async (year, month) => {
|
||||
const start = `${year}-${month.toString().padStart(2, '0')}-01`;
|
||||
const end = `${year}-${month.toString().padStart(2, '0')}-31`;
|
||||
|
||||
try {
|
||||
const [workReports, dailyReports] = await Promise.all([
|
||||
fetch(`/api/workreports?start=${start}&end=${end}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}).then(res => res.json()),
|
||||
fetch(`/api/daily-work-reports/search?start_date=${start}&end_date=${end}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}).then(res => res.json())
|
||||
]);
|
||||
|
||||
return { workReports, dailyReports: dailyReports.reports || [] };
|
||||
} catch (error) {
|
||||
console.error('월간 데이터 가져오기 오류:', error);
|
||||
return { workReports: [], dailyReports: [] };
|
||||
}
|
||||
};
|
||||
|
||||
// 목업 데이터 (개발/테스트용)
|
||||
const mockWorkReports = {
|
||||
'2025-06-16': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
|
||||
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
|
||||
],
|
||||
'2025-06-17': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 2, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'normal' },
|
||||
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'early_leave' },
|
||||
],
|
||||
'2025-06-19': [
|
||||
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
|
||||
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
|
||||
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
|
||||
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'normal' },
|
||||
]
|
||||
};
|
||||
|
||||
const mockDailyReports = {
|
||||
'2025-06-16': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
|
||||
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
|
||||
],
|
||||
'2025-06-17': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 10 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 8 },
|
||||
{ worker_id: 4, worker_name: '정수현', work_hours: 6 },
|
||||
],
|
||||
'2025-06-19': [
|
||||
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
|
||||
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
|
||||
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
|
||||
// 정수현 데이터 누락 - 미입력 상태
|
||||
]
|
||||
};
|
||||
|
||||
// 시간 계산 함수
|
||||
const calculateExpectedHours = (status, overtime_hours = 0) => {
|
||||
const baseHours = {
|
||||
'normal': 8,
|
||||
'half_day': 4,
|
||||
'early_leave': 6,
|
||||
'quarter_day': 2,
|
||||
'vacation': 0,
|
||||
'sick_leave': 0
|
||||
};
|
||||
return (baseHours[status] || 8) + (overtime_hours || 0);
|
||||
};
|
||||
|
||||
// 날짜별 상태 계산 (월간 데이터 기반)
|
||||
const calculateDateStatus = (dateStr) => {
|
||||
const workReports = monthlyData.workReports.filter(wr => wr.date === dateStr);
|
||||
const dailyReports = monthlyData.dailyReports.filter(dr => dr.report_date === dateStr);
|
||||
|
||||
if (workReports.length === 0 && dailyReports.length === 0) {
|
||||
return 'no-data';
|
||||
}
|
||||
|
||||
if (workReports.length === 0 || dailyReports.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
// 작업자별 시간 집계
|
||||
const dailyGrouped = dailyReports.reduce((acc, dr) => {
|
||||
if (!acc[dr.worker_id]) {
|
||||
acc[dr.worker_id] = 0;
|
||||
}
|
||||
acc[dr.worker_id] += parseFloat(dr.work_hours || 0);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 불일치 검사
|
||||
const hasDiscrepancy = workReports.some(wr => {
|
||||
const reportedHours = dailyGrouped[wr.worker_id] || 0;
|
||||
const expectedHours = calculateExpectedHours('normal', wr.overtime_hours || 0);
|
||||
return Math.abs(reportedHours - expectedHours) > 0;
|
||||
});
|
||||
|
||||
return hasDiscrepancy ? 'needs-review' : 'normal';
|
||||
};
|
||||
|
||||
// 권한 체크 (실제 구현 시)
|
||||
const checkPermission = () => {
|
||||
// TODO: 실제 권한 체크 로직
|
||||
const userRole = localStorage.getItem('userRole') || 'user';
|
||||
return userRole === 'admin' || userRole === 'manager';
|
||||
};
|
||||
|
||||
// 권한 없음 UI
|
||||
if (!isAuthorized) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🔒</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">접근 권한이 없습니다</h1>
|
||||
<p className="text-gray-600">이 페이지는 관리자(Admin) 이상만 접근 가능합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 중 UI
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-3 text-gray-600">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 캘린더 생성
|
||||
const generateCalendar = () => {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
const calendar = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
for (let week = 0; week < 6; week++) {
|
||||
const weekDays = [];
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const dateStr = current.toISOString().split('T')[0];
|
||||
const isCurrentMonth = current.getMonth() === month;
|
||||
const status = isCurrentMonth ? calculateDateStatus(dateStr) : 'no-data';
|
||||
|
||||
weekDays.push({
|
||||
date: new Date(current),
|
||||
dateStr,
|
||||
isCurrentMonth,
|
||||
status
|
||||
});
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
calendar.push(weekDays);
|
||||
}
|
||||
return calendar;
|
||||
};
|
||||
|
||||
// 실제 API를 사용한 작업자 데이터 조합
|
||||
const getWorkersForDate = async (dateStr) => {
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const [workReports, dailyReports] = await Promise.all([
|
||||
fetchWorkReports(dateStr),
|
||||
fetchDailyWorkReports(dateStr)
|
||||
]);
|
||||
|
||||
console.log('API 응답:', { workReports, dailyReports });
|
||||
|
||||
const workerMap = new Map();
|
||||
|
||||
// WorkReports 데이터 추가 (생산지원팀 입력)
|
||||
workReports.forEach(wr => {
|
||||
workerMap.set(wr.worker_id, {
|
||||
worker_id: wr.worker_id,
|
||||
worker_name: wr.worker_name,
|
||||
overtime_hours: wr.overtime_hours || 0,
|
||||
status: 'normal', // 실제 테이블 구조에 맞게 수정 필요
|
||||
expected_hours: calculateExpectedHours('normal', wr.overtime_hours),
|
||||
reported_hours: null,
|
||||
hasWorkReport: true,
|
||||
hasDailyReport: false
|
||||
});
|
||||
});
|
||||
|
||||
// DailyReports 데이터 추가 (그룹장 입력) - 작업자별 총 시간 집계
|
||||
const dailyGrouped = dailyReports.reduce((acc, dr) => {
|
||||
if (!acc[dr.worker_id]) {
|
||||
acc[dr.worker_id] = {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
total_work_hours: 0
|
||||
};
|
||||
}
|
||||
acc[dr.worker_id].total_work_hours += parseFloat(dr.work_hours || 0);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Object.values(dailyGrouped).forEach(dr => {
|
||||
if (workerMap.has(dr.worker_id)) {
|
||||
const worker = workerMap.get(dr.worker_id);
|
||||
worker.reported_hours = dr.total_work_hours;
|
||||
worker.hasDailyReport = true;
|
||||
} else {
|
||||
workerMap.set(dr.worker_id, {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
overtime_hours: 0,
|
||||
status: 'normal',
|
||||
expected_hours: 8,
|
||||
reported_hours: dr.total_work_hours,
|
||||
hasWorkReport: false,
|
||||
hasDailyReport: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(workerMap.values()).map(worker => ({
|
||||
...worker,
|
||||
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
|
||||
validationStatus: getValidationStatus(worker)
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('데이터 조합 오류:', error);
|
||||
// 오류 시 목업 데이터 사용
|
||||
return getWorkersForDateMock(dateStr);
|
||||
}
|
||||
};
|
||||
|
||||
// 목업 데이터용 함수 (개발/테스트)
|
||||
const getWorkersForDateMock = (dateStr) => {
|
||||
const workReports = mockWorkReports[dateStr] || [];
|
||||
const dailyReports = mockDailyReports[dateStr] || [];
|
||||
|
||||
const workerMap = new Map();
|
||||
|
||||
// WorkReports 데이터 추가
|
||||
workReports.forEach(wr => {
|
||||
workerMap.set(wr.worker_id, {
|
||||
worker_id: wr.worker_id,
|
||||
worker_name: wr.worker_name,
|
||||
overtime_hours: wr.overtime_hours,
|
||||
status: wr.status,
|
||||
expected_hours: calculateExpectedHours(wr.status, wr.overtime_hours),
|
||||
reported_hours: null,
|
||||
hasWorkReport: true,
|
||||
hasDailyReport: false
|
||||
});
|
||||
});
|
||||
|
||||
// DailyReports 데이터 추가
|
||||
dailyReports.forEach(dr => {
|
||||
if (workerMap.has(dr.worker_id)) {
|
||||
const worker = workerMap.get(dr.worker_id);
|
||||
worker.reported_hours = dr.work_hours;
|
||||
worker.hasDailyReport = true;
|
||||
} else {
|
||||
workerMap.set(dr.worker_id, {
|
||||
worker_id: dr.worker_id,
|
||||
worker_name: dr.worker_name,
|
||||
overtime_hours: 0,
|
||||
status: 'normal',
|
||||
expected_hours: 8,
|
||||
reported_hours: dr.work_hours,
|
||||
hasWorkReport: false,
|
||||
hasDailyReport: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(workerMap.values()).map(worker => ({
|
||||
...worker,
|
||||
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
|
||||
validationStatus: getValidationStatus(worker)
|
||||
}));
|
||||
};
|
||||
|
||||
const getValidationStatus = (worker) => {
|
||||
if (!worker.hasWorkReport || !worker.hasDailyReport) return 'missing';
|
||||
if (Math.abs(worker.difference) > 0) return 'needs-review';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
// 작업자 수정 핸들러
|
||||
const handleEditWorker = (worker) => {
|
||||
// TODO: 수정 모달 또는 인라인 편집 구현
|
||||
const newHours = prompt(
|
||||
`${worker.worker_name}의 근무시간을 수정하세요.\n현재: ${worker.reported_hours || 0}시간`,
|
||||
worker.reported_hours || 0
|
||||
);
|
||||
|
||||
if (newHours !== null && !isNaN(newHours)) {
|
||||
updateWorkerHours(worker.worker_id, parseFloat(newHours));
|
||||
}
|
||||
};
|
||||
|
||||
// 작업자 시간 업데이트 (실제 API 호출)
|
||||
const updateWorkerHours = async (workerId, newHours) => {
|
||||
try {
|
||||
// TODO: 실제 수정 API 호출
|
||||
console.log(`작업자 ${workerId}의 시간을 ${newHours}시간으로 수정`);
|
||||
|
||||
// 임시: 로컬 상태 업데이트
|
||||
setSelectedDateWorkers(prev =>
|
||||
prev.map(worker =>
|
||||
worker.worker_id === workerId
|
||||
? {
|
||||
...worker,
|
||||
reported_hours: newHours,
|
||||
difference: newHours - worker.expected_hours,
|
||||
validationStatus: Math.abs(newHours - worker.expected_hours) > 0 ? 'needs-review' : 'normal'
|
||||
}
|
||||
: worker
|
||||
)
|
||||
);
|
||||
|
||||
alert('수정이 완료되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('수정 실패:', error);
|
||||
alert('수정 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 클릭 핸들러 (실제 API 호출)
|
||||
const handleDateClick = async (dateInfo) => {
|
||||
if (!dateInfo.isCurrentMonth) return;
|
||||
|
||||
setSelectedDate(dateInfo.dateStr);
|
||||
|
||||
try {
|
||||
// 로딩 상태 표시
|
||||
setSelectedDateWorkers([]);
|
||||
|
||||
// 실제 API에서 데이터 가져오기
|
||||
const workers = await getWorkersForDate(dateInfo.dateStr);
|
||||
setSelectedDateWorkers(workers);
|
||||
|
||||
} catch (error) {
|
||||
console.error('날짜별 데이터 로딩 오류:', error);
|
||||
// 오류 시 목업 데이터 사용
|
||||
const workers = getWorkersForDateMock(dateInfo.dateStr);
|
||||
setSelectedDateWorkers(workers);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 작업자 목록
|
||||
const filteredWorkers = selectedDateWorkers.filter(worker => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'needsReview') return worker.validationStatus === 'needs-review';
|
||||
if (filter === 'normal') return worker.validationStatus === 'normal';
|
||||
if (filter === 'missing') return worker.validationStatus === 'missing';
|
||||
return true;
|
||||
});
|
||||
|
||||
// 상태별 아이콘 및 색상
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'normal': return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'needs-review': return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
||||
case 'missing': return <Clock className="w-4 h-4 text-red-500" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'normal': return 'bg-green-100 text-green-800';
|
||||
case 'needs-review': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'missing': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const calendar = generateCalendar();
|
||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">근태 검증 관리</h1>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Admin 전용 페이지
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 캘린더 섹션 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
{/* 캘린더 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1);
|
||||
setCurrentDate(newDate);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-md"
|
||||
disabled={loading}
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{currentDate.getFullYear()}년 {monthNames[currentDate.getMonth()]}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1);
|
||||
setCurrentDate(newDate);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-md"
|
||||
disabled={loading}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 월간 요약 정보 */}
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-green-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'normal').length}
|
||||
</div>
|
||||
<div className="text-gray-600">정상</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-yellow-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'needs-review').length}
|
||||
</div>
|
||||
<div className="text-gray-600">검토필요</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-red-600 font-semibold">
|
||||
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'missing').length}
|
||||
</div>
|
||||
<div className="text-gray-600">미입력</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{dayNames.map(day => (
|
||||
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 캘린더 본체 */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendar.flat().map((dateInfo, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDateClick(dateInfo)}
|
||||
className={`
|
||||
p-2 text-sm rounded-md h-12 relative transition-colors
|
||||
${dateInfo.isCurrentMonth ? 'text-gray-900' : 'text-gray-400'}
|
||||
${selectedDate === dateInfo.dateStr ? 'bg-blue-100 border-2 border-blue-500' : 'hover:bg-gray-50'}
|
||||
${dateInfo.status === 'needs-review' ? 'bg-yellow-50 border border-yellow-200' : ''}
|
||||
${dateInfo.status === 'missing' ? 'bg-red-50 border border-red-200' : ''}
|
||||
${dateInfo.status === 'normal' ? 'bg-green-50 border border-green-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{dateInfo.date.getDate()}</span>
|
||||
{dateInfo.isCurrentMonth && dateInfo.status !== 'no-data' && (
|
||||
<div className="absolute top-1 right-1">
|
||||
{dateInfo.status === 'needs-review' && <div className="w-2 h-2 bg-yellow-500 rounded-full"></div>}
|
||||
{dateInfo.status === 'missing' && <div className="w-2 h-2 bg-red-500 rounded-full"></div>}
|
||||
{dateInfo.status === 'normal' && <div className="w-2 h-2 bg-green-500 rounded-full"></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center justify-center space-x-4 mt-4 text-xs">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span>정상</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<span>검토필요</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<span>미입력</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업자 리스트 섹션 */}
|
||||
<div className="lg:col-span-1">
|
||||
{selectedDate ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
📅 {selectedDate}
|
||||
</h3>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-md px-2 py-1"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="needsReview">검토필요</option>
|
||||
<option value="normal">정상</option>
|
||||
<option value="missing">미입력</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredWorkers.map(worker => (
|
||||
<div key={worker.worker_id} className={`p-3 rounded-lg border ${getStatusColor(worker.validationStatus)}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{worker.worker_name}</span>
|
||||
{getStatusIcon(worker.validationStatus)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>그룹장 입력:</span>
|
||||
<span className="font-mono">
|
||||
{worker.reported_hours !== null ? `${worker.reported_hours}시간` : '미입력'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>시스템 계산:</span>
|
||||
<span className="font-mono">{worker.expected_hours}시간</span>
|
||||
</div>
|
||||
{worker.difference !== 0 && (
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>차이:</span>
|
||||
<span className={`font-mono ${worker.difference > 0 ? 'text-red-600' : 'text-blue-600'}`}>
|
||||
{worker.difference > 0 ? '+' : ''}{worker.difference}시간
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{worker.validationStatus === 'needs-review' && (
|
||||
<button
|
||||
onClick={() => handleEditWorker(worker)}
|
||||
className="mt-2 w-full text-xs bg-blue-600 text-white px-2 py-1 rounded-md hover:bg-blue-700 flex items-center justify-center space-x-1"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>수정</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredWorkers.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
해당 조건의 작업자가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 text-center text-gray-500">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>날짜를 선택하면</p>
|
||||
<p>작업자 검증 내역을 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendanceValidationPage;
|
||||
62
fastapi-bridge/static/pages/admin/manage-issue.html
Normal file
62
fastapi-bridge/static/pages/admin/manage-issue.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>이슈 유형 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>⚙️ 이슈 유형 관리</h1>
|
||||
<p class="subtitle">프로젝트에서 발생하는 이슈 유형을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 이슈 유형 등록</h3>
|
||||
<form id="issueTypeForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="category" placeholder="카테고리" required>
|
||||
<input type="text" id="subcategory" placeholder="서브카테고리" required>
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 이슈 유형</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>카테고리</th>
|
||||
<th>서브카테고리</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="issueTypeTableBody">
|
||||
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-issue.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user