해당 서비스 도커화 성공, 룰 추가, 로그인 오류 수정, 소문자 룰 어느정도 해결

This commit is contained in:
Hyungi Ahn
2025-08-01 15:55:27 +09:00
parent ef06cec8d6
commit 809b2af53e
6418 changed files with 1922672 additions and 69 deletions

View File

@@ -69,7 +69,7 @@ exports.register = async (req, res) => {
// 중복 아이디 확인
const [existing] = await db.query(
'SELECT user_id FROM Users WHERE username = ?',
'SELECT user_id FROM users WHERE username = ?',
[username]
);
@@ -95,7 +95,7 @@ exports.register = async (req, res) => {
// 사용자 등록
const [result] = await db.query(
`INSERT INTO Users (username, password, name, role, access_level, worker_id)
`INSERT INTO users (username, password, name, role, access_level, worker_id)
VALUES (?, ?, ?, ?, ?, ?)`,
[username, hashedPassword, name, role, access_level, worker_id]
);
@@ -126,7 +126,7 @@ exports.deleteUser = async (req, res) => {
// 사용자 존재 확인
const [user] = await db.query(
'SELECT user_id FROM Users WHERE user_id = ?',
'SELECT user_id FROM users WHERE user_id = ?',
[id]
);
@@ -138,7 +138,7 @@ exports.deleteUser = async (req, res) => {
}
// 사용자 삭제
await db.query('DELETE FROM Users WHERE user_id = ?', [id]);
await db.query('DELETE FROM users WHERE user_id = ?', [id]);
console.log('[사용자 삭제 성공] ID:', id);
@@ -165,7 +165,7 @@ exports.getAllUsers = async (req, res) => {
// 비밀번호 제외하고 조회
const [rows] = await db.query(
`SELECT user_id, username, name, role, access_level, worker_id, created_at
FROM Users
FROM users
ORDER BY created_at DESC`
);

View File

@@ -94,8 +94,7 @@ services:
volumes:
db_data:
external: true
name: 7a5a13668b77b18bc1efaf1811d09560aa3be0e722d782e8460cb74f37328d81 # 기존 볼륨명으로 연결
driver: local
# redis_data: # Redis 사용 시 주석 해제
networks:

View File

@@ -6,15 +6,7 @@ const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const app = express();
// ✅ Health check (맨 처음에 등록 - 모든 미들웨어보다 우선)
app.get('/api/health', (req, res) => {
console.log('🟢 Health check 호출됨!');
res.status(200).json({
status: 'healthy',
service: 'Hyungi API',
timestamp: new Date().toISOString()
});
});
// 헬스체크와 개발용 엔드포인트는 CORS 이후에 등록
// ✅ 보안 헤더 설정 (Helmet)
app.use(helmet({
@@ -39,12 +31,77 @@ app.use(helmet({
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(express.json({ limit: '50mb' }));
//개발용
//개발용 CORS 설정 (수정됨)
app.use(cors({
origin: true, // 모든 origin 허용 (개발용)
credentials: true
origin: function (origin, callback) {
// 개발 환경에서는 모든 origin 허용
console.log('🌐 CORS Origin 요청:', origin);
const allowedOrigins = [
'http://localhost:20000', // 웹 UI
'http://localhost:3005', // API 서버
'http://localhost:3000', // 개발 포트
'http://127.0.0.1:20000', // 로컬호스트 대체
];
// origin이 없는 경우 (직접 접근) 허용
if (!origin) {
console.log('✅ Origin 없음 - 허용');
return callback(null, true);
}
// 허용된 origin인지 확인
if (allowedOrigins.includes(origin)) {
console.log('✅ 허용된 Origin:', origin);
return callback(null, true);
}
// 개발 환경에서는 모든 localhost 허용
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
console.log('✅ 로컬호스트 허용:', origin);
return callback(null, true);
}
console.log('❌ 차단된 Origin:', origin);
callback(null, false);
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
}));
// ✅ Health check (CORS 이후에 등록)
app.get('/api/health', (req, res) => {
console.log('🟢 Health check 호출됨!');
res.status(200).json({
status: 'healthy',
service: 'Hyungi API',
timestamp: new Date().toISOString()
});
});
// ✅ 개발용 Ping 엔드포인트
app.get('/api/ping', (req, res) => {
console.log('🏓 Ping 요청 받음!');
res.status(200).json({
message: 'pong',
timestamp: new Date().toISOString()
});
});
// ✅ 서버 상태 엔드포인트
app.get('/api/status', (req, res) => {
console.log('📊 Status 요청 받음!');
res.status(200).json({
status: 'running',
service: 'Hyungi API',
version: '2.1.0',
environment: process.env.NODE_ENV || 'development',
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
});
// ✅ CORS 설정: 허용 origin 명시 (수정된 버전)
//app.use(cors({
// origin: function (origin, callback) {
@@ -106,11 +163,11 @@ const apiLimiter = rateLimit({
legacyHeaders: false,
});
// 로그인 API 속도 제한 (더 엄격하게)
// 로그인 API 속도 제한 (개발 환경에서 완화됨)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: process.env.LOGIN_RATE_LIMIT_MAX_REQUESTS || 5,
message: '너무 많은 로그인 시도입니다. 15분 후에 다시 시도하세요.',
windowMs: 5 * 60 * 1000, // 5분으로 단축
max: process.env.LOGIN_RATE_LIMIT_MAX_REQUESTS || 20, // 5 -> 20으로 증가
message: '너무 많은 로그인 시도입니다. 5분 후에 다시 시도하세요.',
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // 성공한 요청은 카운트하지 않음
@@ -210,7 +267,9 @@ app.use('/api/*', (req, res, next) => {
'/api/auth/login',
'/api/auth/refresh-token',
'/api/auth/check-password-strength',
'/api/health'
'/api/health',
'/api/ping', // 개발용 핑
'/api/status' // 서버 상태
];
// 정확한 경로 매칭 확인

View File

@@ -0,0 +1,56 @@
-- migrations/003_normalize_table_names.sql
-- 모든 테이블명을 snake_case로 변경하여 룰 준수
-- 기존 PascalCase 테이블들을 snake_case로 변경
-- 1. Users -> users
RENAME TABLE Users TO users;
-- 2. CuttingPlan -> cutting_plan
RENAME TABLE CuttingPlan TO cutting_plan;
-- 3. DailyIssueReports -> daily_issue_reports
RENAME TABLE DailyIssueReports TO daily_issue_reports;
-- 4. EquipmentList -> equipment_list
RENAME TABLE EquipmentList TO equipment_list;
-- 5. FactoryInfo -> factory_info
RENAME TABLE FactoryInfo TO factory_info;
-- 6. IssueTypes -> issue_types
RENAME TABLE IssueTypes TO issue_types;
-- 7. PipeSpecs -> pipe_specs
RENAME TABLE PipeSpecs TO pipe_specs;
-- 8. Processes -> processes (이미 소문자이지만 통일성 위해)
-- RENAME TABLE Processes TO processes; -- 이미 소문자면 스킵
-- 9. Projects -> projects
RENAME TABLE Projects TO projects;
-- 10. Tasks -> tasks
RENAME TABLE Tasks TO tasks;
-- 11. WorkReports -> work_reports
RENAME TABLE WorkReports TO work_reports;
-- 12. Workers -> workers
RENAME TABLE Workers TO workers;
-- 이미 snake_case인 테이블들은 그대로 유지:
-- activity_logs
-- daily_work_reports
-- daily_worker_summary
-- error_types
-- login_logs
-- password_change_logs
-- uploaded_documents
-- work_report_audit_log
-- work_status_types
-- work_types
-- worker_groups
-- 변경 완료 로그
SELECT 'Table names normalized to snake_case according to project rules' as migration_status;

View File

@@ -1,19 +1,16 @@
const dbPool = require('../dbPool');
const { getDb } = require('../dbPool');
// 사용자 조회
const findByUsername = async (username) => {
let connection;
try {
connection = await dbPool.getConnection();
const [rows] = await connection.query(
const db = await getDb();
const [rows] = await db.query(
'SELECT * FROM users WHERE username = ?', [username]
);
return rows[0];
} catch (err) {
console.error('DB 오류 - 사용자 조회 실패:', err);
throw err;
} finally {
if (connection) connection.release();
}
};
@@ -22,18 +19,15 @@ const findByUsername = async (username) => {
* @param {number} userId - 사용자 ID
*/
const incrementFailedLoginAttempts = async (userId) => {
let connection;
try {
connection = await dbPool.getConnection();
await connection.execute(
const db = await getDb();
await db.execute(
'UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE user_id = ?',
[userId]
);
} catch (err) {
console.error('DB 오류 - 로그인 실패 횟수 증가 실패:', err);
throw err;
} finally {
if (connection) connection.release();
}
};
@@ -42,18 +36,15 @@ const incrementFailedLoginAttempts = async (userId) => {
* @param {number} userId - 사용자 ID
*/
const lockUserAccount = async (userId) => {
let connection;
try {
connection = await dbPool.getConnection();
await connection.execute(
const db = await getDb();
await db.execute(
'UPDATE users SET locked_until = DATE_ADD(NOW(), INTERVAL 15 MINUTE) WHERE user_id = ?',
[userId]
);
} catch (err) {
console.error('DB 오류 - 계정 잠금 실패:', err);
throw err;
} finally {
if (connection) connection.release();
}
};
@@ -62,18 +53,15 @@ const lockUserAccount = async (userId) => {
* @param {number} userId - 사용자 ID
*/
const resetLoginAttempts = async (userId) => {
let connection;
try {
connection = await dbPool.getConnection();
await connection.execute(
const db = await getDb();
await db.execute(
'UPDATE users SET last_login_at = NOW(), failed_login_attempts = 0, locked_until = NULL WHERE user_id = ?',
[userId]
);
} catch (err) {
console.error('DB 오류 - 로그인 상태 초기화 실패:', err);
throw err;
} finally {
if (connection) connection.release();
}
};

View File

@@ -97,7 +97,7 @@ router.post('/refresh-token', async (req, res) => {
// 사용자 정보 조회
const [users] = await connection.execute(
'SELECT * FROM Users WHERE user_id = ? AND is_active = TRUE',
'SELECT * FROM users WHERE user_id = ? AND is_active = TRUE',
[decoded.user_id]
);
@@ -176,7 +176,7 @@ router.post('/change-password', verifyToken, async (req, res) => {
// 현재 사용자의 비밀번호 조회
const [users] = await connection.execute(
'SELECT password FROM Users WHERE user_id = ?',
'SELECT password FROM users WHERE user_id = ?',
[userId]
);
@@ -283,7 +283,7 @@ router.post('/admin/change-password', verifyToken, async (req, res) => {
// 대상 사용자 확인
const [users] = await connection.execute(
'SELECT username, name FROM Users WHERE user_id = ?',
'SELECT username, name FROM users WHERE user_id = ?',
[userId]
);
@@ -400,7 +400,7 @@ router.get('/me', verifyToken, async (req, res) => {
connection = await mysql.createConnection(dbConfig);
const [rows] = await connection.execute(
'SELECT user_id, username, name, email, access_level, worker_id, last_login_at, created_at FROM Users WHERE user_id = ?',
'SELECT user_id, username, name, email, access_level, worker_id, last_login_at, created_at FROM users WHERE user_id = ?',
[userId]
);
@@ -466,7 +466,7 @@ router.post('/register', verifyToken, async (req, res) => {
// 사용자명 중복 체크
const [existing] = await connection.execute(
'SELECT user_id FROM Users WHERE username = ?',
'SELECT user_id FROM users WHERE username = ?',
[username]
);
@@ -480,7 +480,7 @@ router.post('/register', verifyToken, async (req, res) => {
// 이메일 중복 체크 (이메일이 제공된 경우)
if (email) {
const [existingEmail] = await connection.execute(
'SELECT user_id FROM Users WHERE email = ?',
'SELECT user_id FROM users WHERE email = ?',
[email]
);
@@ -561,7 +561,7 @@ router.get('/users', verifyToken, async (req, res) => {
is_active,
last_login_at,
created_at
FROM Users
FROM users
WHERE 1=1
`;
@@ -638,7 +638,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 사용자 존재 확인
const [existing] = await connection.execute(
'SELECT user_id, username FROM Users WHERE user_id = ?',
'SELECT user_id, username FROM users WHERE user_id = ?',
[userId]
);
@@ -662,7 +662,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 이메일 중복 체크
if (email) {
const [emailCheck] = await connection.execute(
'SELECT user_id FROM Users WHERE email = ? AND user_id != ?',
'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
[email, userId]
);
@@ -732,7 +732,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 업데이트된 사용자 정보 조회
const [updated] = await connection.execute(
'SELECT user_id, username, name, email, access_level, worker_id, is_active FROM Users WHERE user_id = ?',
'SELECT user_id, username, name, email, access_level, worker_id, is_active FROM users WHERE user_id = ?',
[userId]
);
@@ -786,7 +786,7 @@ router.delete('/users/:id', verifyToken, async (req, res) => {
// 사용자 존재 확인
const [existing] = await connection.execute(
'SELECT username FROM Users WHERE user_id = ?',
'SELECT username FROM users WHERE user_id = ?',
[userId]
);

View File

@@ -1,22 +1,19 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userModel = require('../models/userModel');
const dbPool = require('../dbPool');
const { getDb } = require('../dbPool');
// 로그인 이력 기록 (서비스 내부 헬퍼 함수)
const recordLoginHistory = async (userId, success, ipAddress, userAgent, failureReason = null) => {
let connection;
try {
connection = await dbPool.getConnection();
await connection.execute(
const db = await getDb();
await db.execute(
`INSERT INTO login_logs (user_id, login_time, ip_address, user_agent, login_status, failure_reason)
VALUES (?, NOW(), ?, ?, ?, ?)`,
[userId, ipAddress || 'unknown', userAgent || 'unknown', success ? 'success' : 'failed', failureReason]
);
} catch (error) {
console.error('로그인 이력 기록 실패:', error);
} finally {
if (connection) connection.release();
}
};

26
fastapi-bridge/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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()
)

View 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

File diff suppressed because it is too large Load Diff

View 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": "하주현"
}
]
}
]

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

View File

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 369 KiB

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

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

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

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

File diff suppressed because it is too large Load Diff

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

View 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) 제거.
})();

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

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

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

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

View 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 = '등록';
}
}

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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('서버에서 데이터를 가져오는 데 실패했습니다.');
}
}

View 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('인쇄 중 오류가 발생했습니다.');
}
}

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More