refactor: 네비게이션 헤더 최신 디자인으로 전면 개편 및 로그인 버그 수정
- fix: 로그인 API에서 user.role_name 필드 올바르게 사용 (auth.service.js) - refactor: navbar 컴포넌트를 최신 dashboard-header 스타일로 전환 - refactor: 구버전 work-report-header 제거 (6개 페이지) - refactor: load-navbar.js를 최신 헤더 구조에 맞게 업데이트 - style: 파란색 그라데이션 헤더, 실시간 시계, 향상된 프로필 메뉴 - docs: 2026-01-20 개발 로그 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -35,19 +35,20 @@ const getAllUsers = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
user_id,
|
||||
username,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
role,
|
||||
access_level,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_login
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.name,
|
||||
u.email,
|
||||
u.role_id,
|
||||
r.name as role,
|
||||
u._access_level_old as access_level,
|
||||
u.is_active,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.last_login_at as last_login
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
ORDER BY u.created_at DESC
|
||||
`;
|
||||
|
||||
const [users] = await db.execute(query);
|
||||
|
||||
@@ -23,13 +23,13 @@ exports.up = async function(knex) {
|
||||
|
||||
// 2. 사용자별 페이지 접근 권한 테이블 생성
|
||||
await knex.schema.createTable('user_page_access', function(table) {
|
||||
table.integer('user_id').unsigned().notNullable()
|
||||
table.integer('user_id').notNullable()
|
||||
.references('user_id').inTable('users').onDelete('CASCADE');
|
||||
table.integer('page_id').unsigned().notNullable()
|
||||
.references('id').inTable('pages').onDelete('CASCADE');
|
||||
table.boolean('can_access').defaultTo(true); // 접근 가능 여부
|
||||
table.timestamp('granted_at').defaultTo(knex.fn.now());
|
||||
table.integer('granted_by').unsigned() // 권한을 부여한 Admin의 user_id
|
||||
table.integer('granted_by') // 권한을 부여한 Admin의 user_id
|
||||
.references('user_id').inTable('users').onDelete('SET NULL');
|
||||
table.primary(['user_id', 'page_id']);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 2. 각 작업자에 대해 users 테이블에 계정 생성
|
||||
* 3. username은 이름 기반으로 자동 생성 (예: 홍길동 → hong.gildong)
|
||||
* 4. 초기 비밀번호는 '1234'로 통일 (첫 로그인 시 변경 권장)
|
||||
* 5. 현재 연도 연차 잔액 초기화
|
||||
* 5. 현재 연도 연차 잔액 초기화 (workers.annual_leave 사용)
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
@@ -25,7 +25,7 @@ exports.up = async function(knex) {
|
||||
'workers.worker_name',
|
||||
'workers.email',
|
||||
'workers.status',
|
||||
'workers.base_annual_leave'
|
||||
'workers.annual_leave'
|
||||
);
|
||||
|
||||
console.log(`📊 계정이 없는 작업자: ${workersWithoutAccount.length}명`);
|
||||
@@ -41,7 +41,7 @@ exports.up = async function(knex) {
|
||||
|
||||
// User 역할 ID 조회
|
||||
const userRole = await knex('roles')
|
||||
.where('role_name', 'User')
|
||||
.where('name', 'User')
|
||||
.first();
|
||||
|
||||
if (!userRole) {
|
||||
@@ -77,7 +77,7 @@ exports.up = async function(knex) {
|
||||
await knex('worker_vacation_balance').insert({
|
||||
worker_id: worker.worker_id,
|
||||
year: currentYear,
|
||||
total_annual_leave: worker.base_annual_leave || 15,
|
||||
total_annual_leave: worker.annual_leave || 15,
|
||||
used_annual_leave: 0,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
|
||||
@@ -12,8 +12,8 @@ exports.up = async function(knex) {
|
||||
|
||||
// 1. Guest 역할 추가
|
||||
const [guestRoleId] = await knex('roles').insert({
|
||||
role_name: 'Guest',
|
||||
role_description: '게스트 (계정 없이 특정 기능 접근 가능)',
|
||||
name: 'Guest',
|
||||
description: '게스트 (계정 없이 특정 기능 접근 가능)',
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
@@ -44,7 +44,7 @@ exports.down = async function(knex) {
|
||||
|
||||
// 역할 제거
|
||||
await knex('roles')
|
||||
.where('role_name', 'Guest')
|
||||
.where('name', 'Guest')
|
||||
.delete();
|
||||
|
||||
console.log('✅ 게스트 역할 제거 완료');
|
||||
|
||||
0
api.hyungi.net/deploy.sh
Normal file → Executable file
0
api.hyungi.net/deploy.sh
Normal file → Executable file
@@ -129,8 +129,10 @@ const requireRole = (...roles) => {
|
||||
}
|
||||
|
||||
const userRole = req.user.role;
|
||||
const userRoleLower = userRole ? userRole.toLowerCase() : '';
|
||||
const rolesLower = roles.map(r => r.toLowerCase());
|
||||
|
||||
if (!roles.includes(userRole)) {
|
||||
if (!rolesLower.includes(userRoleLower)) {
|
||||
logger.warn('권한 체크 실패: 역할 불일치', {
|
||||
user_id: req.user.user_id || req.user.id,
|
||||
username: req.user.username,
|
||||
|
||||
@@ -5,7 +5,7 @@ class AttendanceModel {
|
||||
static async getDailyAttendanceRecords(date, workerId = null) {
|
||||
const db = await getDb();
|
||||
let query = `
|
||||
SELECT
|
||||
SELECT
|
||||
dar.*,
|
||||
w.worker_name,
|
||||
w.job_type,
|
||||
@@ -34,6 +34,39 @@ class AttendanceModel {
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 기간별 근태 기록 조회 (월별 조회용)
|
||||
static async getDailyRecords(startDate, endDate, workerId = null) {
|
||||
const db = await getDb();
|
||||
let query = `
|
||||
SELECT
|
||||
dar.*,
|
||||
w.worker_name,
|
||||
w.job_type,
|
||||
wat.type_name as attendance_type_name,
|
||||
wat.type_code as attendance_type_code,
|
||||
vt.type_name as vacation_type_name,
|
||||
vt.type_code as vacation_type_code,
|
||||
vt.deduct_days as vacation_days
|
||||
FROM daily_attendance_records dar
|
||||
LEFT JOIN workers w ON dar.worker_id = w.worker_id
|
||||
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
|
||||
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
|
||||
WHERE dar.record_date BETWEEN ? AND ?
|
||||
`;
|
||||
|
||||
const params = [startDate, endDate];
|
||||
|
||||
if (workerId) {
|
||||
query += ' AND dar.worker_id = ?';
|
||||
params.push(workerId);
|
||||
}
|
||||
|
||||
query += ' ORDER BY dar.record_date ASC';
|
||||
|
||||
const [rows] = await db.execute(query, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 작업 보고서와 근태 기록 동기화 (시간 합산 및 상태 업데이트)
|
||||
static async syncWithWorkReports(workerId, date) {
|
||||
const db = await getDb();
|
||||
|
||||
@@ -5,7 +5,14 @@ const findByUsername = async (username) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
'SELECT user_id, username, password, name, email, role, access_level, worker_id, is_active, last_login_at, password_changed_at, failed_login_attempts, locked_until, created_at, updated_at FROM users WHERE username = ?', [username]
|
||||
`SELECT u.user_id, u.username, u.password, u.name, u.email,
|
||||
u.role_id, r.name as role_name,
|
||||
u._access_level_old as access_level, u.worker_id, u.is_active,
|
||||
u.last_login_at, u.password_changed_at, u.failed_login_attempts,
|
||||
u.locked_until, u.created_at, u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.username = ?`, [username]
|
||||
);
|
||||
return rows[0];
|
||||
} catch (err) {
|
||||
|
||||
@@ -22,7 +22,8 @@ router.use(verifyToken);
|
||||
* 관리자 권한 확인 미들웨어
|
||||
*/
|
||||
const adminOnly = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'admin' || req.user.role === 'system')) {
|
||||
const userRole = req.user?.role?.toLowerCase();
|
||||
if (req.user && (userRole === 'admin' || userRole === 'system' || userRole === 'system admin')) {
|
||||
next();
|
||||
} else {
|
||||
logger.warn('관리자 권한 없는 접근 시도', {
|
||||
@@ -87,8 +88,9 @@ router.get('/me/work-reports', async (req, res) => {
|
||||
router.get('/me/monthly-stats', async (req, res) => {
|
||||
try {
|
||||
const { year, month } = req.query;
|
||||
const db = require('../config/database');
|
||||
const stats = await db.query(
|
||||
const { getDb } = require('../dbPool');
|
||||
const db = await getDb();
|
||||
const [stats] = await db.execute(
|
||||
`SELECT
|
||||
SUM(total_work_hours) as month_hours,
|
||||
COUNT(DISTINCT record_date) as work_days
|
||||
|
||||
@@ -57,7 +57,7 @@ const loginService = async (username, password, ipAddress, userAgent) => {
|
||||
|
||||
|
||||
const token = jwt.sign(
|
||||
{ user_id: user.user_id, username: user.username, role: user.role, access_level: user.access_level, worker_id: user.worker_id, name: user.name || user.username },
|
||||
{ user_id: user.user_id, username: user.username, role: user.role_name, role_id: user.role_id, access_level: user.access_level, worker_id: user.worker_id, name: user.name || user.username },
|
||||
process.env.JWT_SECRET || 'your-secret-key',
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
|
||||
);
|
||||
@@ -80,7 +80,7 @@ const loginService = async (username, password, ipAddress, userAgent) => {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
name: user.name || user.username,
|
||||
role: user.role,
|
||||
role: user.role_name,
|
||||
access_level: user.access_level,
|
||||
worker_id: user.worker_id
|
||||
}
|
||||
|
||||
@@ -1,470 +1,287 @@
|
||||
<!-- 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>
|
||||
<!-- 최신 대시보드 헤더 -->
|
||||
<header class="dashboard-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<img src="/img/logo.png" alt="테크니컬코리아" class="brand-logo">
|
||||
<div class="brand-text">
|
||||
<h1 class="brand-title">테크니컬코리아</h1>
|
||||
<p class="brand-subtitle">생산팀 포털</p>
|
||||
</div>
|
||||
</div>
|
||||
</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-dashboard.html" class="dropdown-item">
|
||||
<span class="dropdown-icon">📊</span>
|
||||
나의 대시보드
|
||||
</a>
|
||||
<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/admin-settings.html" class="dropdown-item admin-only">
|
||||
<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 class="header-center">
|
||||
<div class="current-time" id="currentTime">
|
||||
<span class="time-label">현재 시각</span>
|
||||
<span class="time-value" id="timeValue">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-buttons">
|
||||
<button class="nav-btn dashboard-btn" title="대시보드">
|
||||
🏠 대시보드
|
||||
</button>
|
||||
<button class="nav-btn system-btn" title="시스템 관리자" id="systemBtn" style="display: none;">
|
||||
🔧 시스템
|
||||
</button>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-profile" id="userProfile">
|
||||
<div class="user-avatar">
|
||||
<span class="avatar-text" id="userInitial">사</span>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name" id="userName">사용자</span>
|
||||
<span class="user-role" id="userRole">작업자</span>
|
||||
</div>
|
||||
<div class="profile-menu" id="profileMenu">
|
||||
<a href="/pages/profile/my-dashboard.html" class="menu-item">
|
||||
<span class="menu-icon">📊</span>
|
||||
나의 대시보드
|
||||
</a>
|
||||
<a href="/pages/profile/my-profile.html" class="menu-item">
|
||||
<span class="menu-icon">👤</span>
|
||||
내 프로필
|
||||
</a>
|
||||
<a href="/pages/profile/change-password.html" class="menu-item">
|
||||
<span class="menu-icon">🔐</span>
|
||||
비밀번호 변경
|
||||
</a>
|
||||
<a href="/pages/profile/admin-settings.html" class="menu-item admin-only">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
관리자 설정
|
||||
</a>
|
||||
<button class="menu-item logout-btn" id="logoutBtn">
|
||||
<span class="menu-icon">🚪</span>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||
padding: 0.75rem 1.5rem;
|
||||
/* 최신 대시보드 헤더 스타일 */
|
||||
.dashboard-header {
|
||||
background: linear-gradient(135deg, #1e40af 0%, #1d4ed8 50%, #2563eb 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(0,0,0,0.15);
|
||||
font-family: 'Malgun Gothic', sans-serif;
|
||||
padding: 1rem 1.5rem;
|
||||
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
min-height: 4rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-left .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
height: 2.5rem;
|
||||
width: auto;
|
||||
border-radius: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
.header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.125rem;
|
||||
.brand-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.9;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 1.25rem;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
white-space: nowrap;
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
.header-center .current-time {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
display: block;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.header-right .user-profile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
flex-shrink: 1;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
gap: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
/* 프로필 드롭다운 스타일 */
|
||||
.profile-dropdown {
|
||||
position: relative;
|
||||
.user-profile:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 9999px;
|
||||
background: #90caf9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 1.5625rem;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
min-width: 0;
|
||||
max-width: 15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.user-info.active {
|
||||
background: rgba(255,255,255,0.25);
|
||||
box-shadow: 0 0 0 0.1875rem rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 0.625rem;
|
||||
margin-left: 0.25rem;
|
||||
transition: transform 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info.active .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 드롭다운 메뉴 */
|
||||
.dropdown-menu {
|
||||
.profile-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: white;
|
||||
margin-top: 0.5rem;
|
||||
background: linear-gradient(135deg, #ffffff, #f8fafc);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.5rem 1.5rem rgba(0,0,0,0.15);
|
||||
min-width: 15rem;
|
||||
max-width: 20rem;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
border: 2px solid rgba(59, 130, 246, 0.2);
|
||||
min-width: 220px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-0.625rem);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
.user-profile:hover .profile-menu,
|
||||
.profile-menu:hover {
|
||||
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 {
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
color: #333;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
border: none;
|
||||
background: none;
|
||||
background: transparent;
|
||||
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: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 0.5rem;
|
||||
margin: 0.25rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-btn {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
.menu-item:hover {
|
||||
background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
|
||||
color: #1f2937;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
transform: translateY(-0.0625rem);
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
|
||||
.menu-item:first-child {
|
||||
border-radius: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.system-btn {
|
||||
background: linear-gradient(135deg, #9c27b0 0%, #673ab7 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0.125rem 0.5rem rgba(156,39,176,0.3);
|
||||
.menu-item:last-child {
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.system-btn:hover {
|
||||
background: linear-gradient(135deg, #8e24aa 0%, #5e35b1 100%);
|
||||
transform: translateY(-0.0625rem);
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(156,39,176,0.4);
|
||||
.menu-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.menu-item:hover .menu-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: #dc2626 !important;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: linear-gradient(135deg, #fef2f2, #fee2e2) !important;
|
||||
color: #b91c1c !important;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1200px) {
|
||||
.navbar {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0.625rem 1rem;
|
||||
gap: 0.75rem;
|
||||
.dashboard-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
gap: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.navbar-buttons {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
right: 0;
|
||||
min-width: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.brand-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-buttons .nav-btn {
|
||||
font-size: 0;
|
||||
padding: 0.5rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.navbar-buttons .nav-btn::before {
|
||||
content: attr(title);
|
||||
.brand-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: none;
|
||||
.brand-subtitle {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.navbar {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.navbar-buttons {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
right: -0.5rem;
|
||||
min-width: 10rem;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
583
web-ui/css/my-attendance.css
Normal file
583
web-ui/css/my-attendance.css
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* 나의 출근 현황 페이지 스타일
|
||||
*/
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 통계 카드 섹션 */
|
||||
.stats-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 36px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 탭 컨테이너 */
|
||||
.tab-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #495057;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #007bff;
|
||||
border-bottom-color: #007bff;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 탭 컨텐츠 */
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 테이블 스타일 */
|
||||
#attendanceTable {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
#attendanceTable thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
#attendanceTable th {
|
||||
padding: 16px 12px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
#attendanceTable td {
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f1f3f5;
|
||||
}
|
||||
|
||||
#attendanceTable tbody tr {
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#attendanceTable tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.loading-cell,
|
||||
.empty-cell,
|
||||
.error-cell {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-cell {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.notes-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
color: #6c757d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 상태 배지 */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.normal {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-badge.late {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-badge.early {
|
||||
background: #ffe5b5;
|
||||
color: #a56200;
|
||||
}
|
||||
|
||||
.status-badge.absent {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-badge.vacation {
|
||||
background: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
/* 달력 스타일 */
|
||||
#calendarContainer {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.calendar-header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-nav-btn {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.calendar-nav-btn:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
padding: 12px 8px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.calendar-day.empty {
|
||||
background: #f8f9fa;
|
||||
border-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.calendar-day.has-record {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-day.has-record:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 달력 날짜 상태별 색상 */
|
||||
.calendar-day.normal {
|
||||
background: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.calendar-day.late {
|
||||
background: #fff3cd;
|
||||
border-color: #ffeaa7;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.calendar-day.early {
|
||||
background: #ffe5b5;
|
||||
border-color: #ffd98a;
|
||||
color: #a56200;
|
||||
}
|
||||
|
||||
.calendar-day.absent {
|
||||
background: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.calendar-day.vacation {
|
||||
background: #cce5ff;
|
||||
border-color: #b8daff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.calendar-day-status {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 달력 범례 */
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.legend-dot.normal {
|
||||
background: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
}
|
||||
|
||||
.legend-dot.late {
|
||||
background: #fff3cd;
|
||||
border-color: #ffeaa7;
|
||||
}
|
||||
|
||||
.legend-dot.early {
|
||||
background: #ffe5b5;
|
||||
border-color: #ffd98a;
|
||||
}
|
||||
|
||||
.legend-dot.absent {
|
||||
background: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
.legend-dot.vacation {
|
||||
background: #cce5ff;
|
||||
border-color: #b8daff;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
background: #f8f9fa;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 상세 정보 그리드 */
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-item div {
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* 버튼 스타일 */
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.stats-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-day-status {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#attendanceTable {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#attendanceTable th,
|
||||
#attendanceTable td {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.notes-cell {
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,15 @@ function clearAuthData() {
|
||||
const currentUser = getUser();
|
||||
|
||||
// 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우)
|
||||
if (!currentUser || !currentUser.username || !currentUser.role) {
|
||||
if (!currentUser || !currentUser.username) {
|
||||
console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ ${currentUser.username}(${currentUser.role})님 인증 성공.`);
|
||||
const userRole = currentUser.role || currentUser.access_level || '사용자';
|
||||
console.log(`✅ ${currentUser.username}(${userRole})님 인증 성공.`);
|
||||
|
||||
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
|
||||
// 전역 변수 할당(window.currentUser) 제거.
|
||||
|
||||
@@ -57,39 +57,22 @@ function populateUserInfo(doc, user) {
|
||||
const roleName = ROLE_NAMES[user.role] || ROLE_NAMES.default;
|
||||
|
||||
const elements = {
|
||||
'user-name': displayName,
|
||||
'user-role': roleName,
|
||||
'dropdown-user-fullname': displayName,
|
||||
'dropdown-user-id': `@${user.username}`,
|
||||
'userName': displayName,
|
||||
'userRole': roleName,
|
||||
'userInitial': displayName.charAt(0),
|
||||
};
|
||||
|
||||
for (const id in elements) {
|
||||
const el = doc.getElementById(id);
|
||||
if (el) el.textContent = elements[id];
|
||||
}
|
||||
|
||||
const systemBtn = doc.getElementById('systemBtn');
|
||||
if (systemBtn && user.role === 'system') {
|
||||
systemBtn.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 바와 관련된 모든 이벤트를 설정합니다.
|
||||
*/
|
||||
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');
|
||||
const logoutButton = document.getElementById('logoutBtn');
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', () => {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
@@ -98,34 +81,13 @@ function setupNavbarEvents() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const systemButton = document.getElementById('systemBtn');
|
||||
if (systemButton) {
|
||||
systemButton.addEventListener('click', () => {
|
||||
window.location.href = config.paths.systemDashboard;
|
||||
});
|
||||
}
|
||||
|
||||
const dashboardButton = document.querySelector('.dashboard-btn');
|
||||
if (dashboardButton) {
|
||||
dashboardButton.addEventListener('click', () => {
|
||||
window.location.href = config.paths.groupLeaderDashboard;
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
const now = new Date();
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
|
||||
390
web-ui/js/my-attendance.js
Normal file
390
web-ui/js/my-attendance.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 나의 출근 현황 페이지
|
||||
* 본인의 출근 기록과 근태 현황을 조회하고 표시합니다
|
||||
*/
|
||||
|
||||
// 전역 상태
|
||||
let currentYear = new Date().getFullYear();
|
||||
let currentMonth = new Date().getMonth() + 1;
|
||||
let attendanceData = [];
|
||||
let vacationBalance = null;
|
||||
let monthlyStats = null;
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializePage();
|
||||
});
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
function initializePage() {
|
||||
initializeYearMonthSelects();
|
||||
setupEventListeners();
|
||||
loadAttendanceData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 년도/월 선택 옵션 초기화
|
||||
*/
|
||||
function initializeYearMonthSelects() {
|
||||
const yearSelect = document.getElementById('yearSelect');
|
||||
const monthSelect = document.getElementById('monthSelect');
|
||||
|
||||
// 년도 옵션 (현재 년도 기준 ±2년)
|
||||
const currentYearValue = new Date().getFullYear();
|
||||
for (let year = currentYearValue - 2; year <= currentYearValue + 2; year++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}년`;
|
||||
if (year === currentYear) option.selected = true;
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
|
||||
// 월 옵션
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = month;
|
||||
option.textContent = `${month}월`;
|
||||
if (month === currentMonth) option.selected = true;
|
||||
monthSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// 조회 버튼
|
||||
document.getElementById('loadAttendance').addEventListener('click', () => {
|
||||
currentYear = parseInt(document.getElementById('yearSelect').value);
|
||||
currentMonth = parseInt(document.getElementById('monthSelect').value);
|
||||
loadAttendanceData();
|
||||
});
|
||||
|
||||
// 탭 전환
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const tabName = e.currentTarget.dataset.tab;
|
||||
switchTab(tabName);
|
||||
});
|
||||
});
|
||||
|
||||
// 달력 네비게이션
|
||||
document.getElementById('prevMonth').addEventListener('click', () => {
|
||||
changeMonth(-1);
|
||||
});
|
||||
|
||||
document.getElementById('nextMonth').addEventListener('click', () => {
|
||||
changeMonth(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 출근 데이터 로드
|
||||
*/
|
||||
async function loadAttendanceData() {
|
||||
try {
|
||||
showLoading();
|
||||
|
||||
// 병렬로 데이터 로드
|
||||
const [attendanceRes, vacationRes, statsRes] = await Promise.all([
|
||||
window.apiGet(`/users/me/attendance-records?year=${currentYear}&month=${currentMonth}`),
|
||||
window.apiGet(`/users/me/vacation-balance?year=${currentYear}`),
|
||||
window.apiGet(`/users/me/monthly-stats?year=${currentYear}&month=${currentMonth}`)
|
||||
]);
|
||||
|
||||
attendanceData = attendanceRes.data || attendanceRes || [];
|
||||
vacationBalance = vacationRes.data || vacationRes;
|
||||
monthlyStats = statsRes.data || statsRes;
|
||||
|
||||
// UI 업데이트
|
||||
updateStats();
|
||||
renderTable();
|
||||
renderCalendar();
|
||||
|
||||
} catch (error) {
|
||||
console.error('출근 데이터 로드 실패:', error);
|
||||
showError('출근 데이터를 불러오는데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 업데이트
|
||||
*/
|
||||
function updateStats() {
|
||||
// 총 근무시간 (API는 month_hours 반환)
|
||||
const totalHours = monthlyStats?.month_hours || monthlyStats?.total_work_hours || 0;
|
||||
document.getElementById('totalHours').textContent = `${totalHours}시간`;
|
||||
|
||||
// 근무일수
|
||||
const totalDays = monthlyStats?.work_days || 0;
|
||||
document.getElementById('totalDays').textContent = `${totalDays}일`;
|
||||
|
||||
// 잔여 연차
|
||||
const remaining = vacationBalance?.remaining_annual_leave ||
|
||||
(vacationBalance?.total_annual_leave || 0) - (vacationBalance?.used_annual_leave || 0);
|
||||
document.getElementById('remainingLeave').textContent = `${remaining}일`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 렌더링
|
||||
*/
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('attendanceTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!attendanceData || attendanceData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">출근 기록이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
attendanceData.forEach(record => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = `attendance-row ${getStatusClass(record.attendance_type_code || record.type_code)}`;
|
||||
tr.onclick = () => showDetailModal(record);
|
||||
|
||||
const date = new Date(record.record_date);
|
||||
const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()];
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>${formatDate(record.record_date)}</td>
|
||||
<td>${dayOfWeek}</td>
|
||||
<td>${record.check_in_time || '-'}</td>
|
||||
<td>${record.check_out_time || '-'}</td>
|
||||
<td>${record.total_work_hours ? `${record.total_work_hours}h` : '-'}</td>
|
||||
<td><span class="status-badge ${getStatusClass(record.attendance_type_code || record.type_code)}">${getStatusText(record)}</span></td>
|
||||
<td class="notes-cell">${record.notes || '-'}</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 달력 렌더링
|
||||
*/
|
||||
function renderCalendar() {
|
||||
const calendarTitle = document.getElementById('calendarTitle');
|
||||
const calendarGrid = document.getElementById('calendarGrid');
|
||||
|
||||
calendarTitle.textContent = `${currentYear}년 ${currentMonth}월`;
|
||||
|
||||
// 달력 그리드 초기화
|
||||
calendarGrid.innerHTML = '';
|
||||
|
||||
// 요일 헤더
|
||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
weekdays.forEach(day => {
|
||||
const dayHeader = document.createElement('div');
|
||||
dayHeader.className = 'calendar-day-header';
|
||||
dayHeader.textContent = day;
|
||||
calendarGrid.appendChild(dayHeader);
|
||||
});
|
||||
|
||||
// 해당 월의 첫날과 마지막 날
|
||||
const firstDay = new Date(currentYear, currentMonth - 1, 1);
|
||||
const lastDay = new Date(currentYear, currentMonth, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startDayOfWeek = firstDay.getDay();
|
||||
|
||||
// 출근 데이터를 날짜별로 매핑
|
||||
const attendanceMap = {};
|
||||
if (attendanceData) {
|
||||
attendanceData.forEach(record => {
|
||||
const date = new Date(record.record_date);
|
||||
const day = date.getDate();
|
||||
attendanceMap[day] = record;
|
||||
});
|
||||
}
|
||||
|
||||
// 빈 칸 (이전 달)
|
||||
for (let i = 0; i < startDayOfWeek; i++) {
|
||||
const emptyCell = document.createElement('div');
|
||||
emptyCell.className = 'calendar-day empty';
|
||||
calendarGrid.appendChild(emptyCell);
|
||||
}
|
||||
|
||||
// 날짜 칸
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayCell = document.createElement('div');
|
||||
dayCell.className = 'calendar-day';
|
||||
|
||||
const record = attendanceMap[day];
|
||||
if (record) {
|
||||
dayCell.classList.add('has-record', getStatusClass(record.attendance_type_code || record.type_code));
|
||||
dayCell.onclick = () => showDetailModal(record);
|
||||
}
|
||||
|
||||
dayCell.innerHTML = `
|
||||
<div class="calendar-day-number">${day}</div>
|
||||
${record ? `<div class="calendar-day-status">${getStatusIcon(record)}</div>` : ''}
|
||||
`;
|
||||
|
||||
calendarGrid.appendChild(dayCell);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// 탭 버튼 활성화 토글
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// 탭 컨텐츠 토글
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
if (tabName === 'list') {
|
||||
document.getElementById('listView').classList.add('active');
|
||||
} else if (tabName === 'calendar') {
|
||||
document.getElementById('calendarView').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 변경
|
||||
*/
|
||||
function changeMonth(offset) {
|
||||
currentMonth += offset;
|
||||
|
||||
if (currentMonth < 1) {
|
||||
currentMonth = 12;
|
||||
currentYear--;
|
||||
} else if (currentMonth > 12) {
|
||||
currentMonth = 1;
|
||||
currentYear++;
|
||||
}
|
||||
|
||||
// Select 박스 업데이트
|
||||
document.getElementById('yearSelect').value = currentYear;
|
||||
document.getElementById('monthSelect').value = currentMonth;
|
||||
|
||||
loadAttendanceData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 모달 표시
|
||||
*/
|
||||
function showDetailModal(record) {
|
||||
const modal = document.getElementById('detailModal');
|
||||
const modalBody = document.getElementById('modalBody');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
|
||||
const date = new Date(record.record_date);
|
||||
modalTitle.textContent = `${formatDate(record.record_date)} 출근 상세`;
|
||||
|
||||
modalBody.innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>날짜</label>
|
||||
<div>${formatDate(record.record_date)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>출근 상태</label>
|
||||
<div><span class="status-badge ${getStatusClass(record.attendance_type_code || record.type_code)}">${getStatusText(record)}</span></div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>출근 시간</label>
|
||||
<div>${record.check_in_time || '기록 없음'}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>퇴근 시간</label>
|
||||
<div>${record.check_out_time || '기록 없음'}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>총 근무 시간</label>
|
||||
<div>${record.total_work_hours ? `${record.total_work_hours} 시간` : '계산 불가'}</div>
|
||||
</div>
|
||||
${record.vacation_type_name ? `
|
||||
<div class="detail-item">
|
||||
<label>휴가 유형</label>
|
||||
<div>${record.vacation_type_name}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${record.notes ? `
|
||||
<div class="detail-item full-width">
|
||||
<label>비고</label>
|
||||
<div>${record.notes}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 닫기
|
||||
*/
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('detailModal');
|
||||
if (event.target === modal) {
|
||||
closeDetailModal();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 유틸리티 함수들
|
||||
*/
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${month}/${day}`;
|
||||
}
|
||||
|
||||
function getStatusClass(typeCode) {
|
||||
const typeMap = {
|
||||
'NORMAL': 'normal',
|
||||
'LATE': 'late',
|
||||
'EARLY_LEAVE': 'early',
|
||||
'ABSENT': 'absent',
|
||||
'VACATION': 'vacation'
|
||||
};
|
||||
return typeMap[typeCode] || 'normal';
|
||||
}
|
||||
|
||||
function getStatusText(record) {
|
||||
if (record.vacation_type_name) {
|
||||
return record.vacation_type_name;
|
||||
}
|
||||
return record.attendance_type_name || record.type_name || '정상';
|
||||
}
|
||||
|
||||
function getStatusIcon(record) {
|
||||
const typeCode = record.attendance_type_code || record.type_code;
|
||||
const iconMap = {
|
||||
'NORMAL': '✓',
|
||||
'LATE': '⚠',
|
||||
'EARLY_LEAVE': '⏰',
|
||||
'ABSENT': '✗',
|
||||
'VACATION': '🌴'
|
||||
};
|
||||
return iconMap[typeCode] || '✓';
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
const tbody = document.getElementById('attendanceTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="loading-cell">데이터를 불러오는 중...</td></tr>';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const tbody = document.getElementById('attendanceTableBody');
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="error-cell">${message}</td></tr>`;
|
||||
|
||||
// 통계 초기화
|
||||
document.getElementById('totalHours').textContent = '-';
|
||||
document.getElementById('totalDays').textContent = '-';
|
||||
document.getElementById('remainingLeave').textContent = '-';
|
||||
}
|
||||
@@ -13,13 +13,7 @@
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<header class="work-report-header">
|
||||
<h1>✍️ 일일 작업보고서 작성</h1>
|
||||
<p class="subtitle">단계별로 오늘의 작업 내용을 간편하게 기록하고 관리하세요.</p>
|
||||
</header>
|
||||
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
|
||||
151
web-ui/pages/common/my-attendance.html
Normal file
151
web-ui/pages/common/my-attendance.html
Normal file
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>나의 출근 현황 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/attendance.css">
|
||||
<link rel="stylesheet" href="/css/my-attendance.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📊</span>
|
||||
나의 출근 현황
|
||||
</h1>
|
||||
<p class="page-description">나의 출근 기록과 근태 현황을 확인할 수 있습니다</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<div class="controls">
|
||||
<label for="yearSelect">연도:</label>
|
||||
<select id="yearSelect"></select>
|
||||
|
||||
<label for="monthSelect">월:</label>
|
||||
<select id="monthSelect"></select>
|
||||
|
||||
<button id="loadAttendance" class="btn-primary">조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 섹션 -->
|
||||
<section class="stats-section">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏱️</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="totalHours">-</div>
|
||||
<div class="stat-label">총 근무시간</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="totalDays">-</div>
|
||||
<div class="stat-label">근무일수</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🌴</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="remainingLeave">-</div>
|
||||
<div class="stat-label">잔여 연차</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 섹션 -->
|
||||
<div class="tab-container">
|
||||
<button class="tab-btn active" data-tab="list">
|
||||
<span class="tab-icon">📋</span> 리스트 보기
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="calendar">
|
||||
<span class="tab-icon">📅</span> 달력 보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 뷰 -->
|
||||
<div id="listView" class="tab-content active">
|
||||
<div id="attendanceTableContainer">
|
||||
<table id="attendanceTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>요일</th>
|
||||
<th>출근시간</th>
|
||||
<th>퇴근시간</th>
|
||||
<th>근무시간</th>
|
||||
<th>상태</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attendanceTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-cell">데이터를 불러오는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 달력 뷰 -->
|
||||
<div id="calendarView" class="tab-content">
|
||||
<div id="calendarContainer">
|
||||
<div class="calendar-header">
|
||||
<button id="prevMonth" class="calendar-nav-btn">◀</button>
|
||||
<h3 id="calendarTitle">2026년 1월</h3>
|
||||
<button id="nextMonth" class="calendar-nav-btn">▶</button>
|
||||
</div>
|
||||
<div id="calendarGrid" class="calendar-grid">
|
||||
<!-- 달력이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
<div class="calendar-legend">
|
||||
<span class="legend-item"><span class="legend-dot normal"></span> 정상</span>
|
||||
<span class="legend-item"><span class="legend-dot late"></span> 지각</span>
|
||||
<span class="legend-item"><span class="legend-dot early"></span> 조퇴</span>
|
||||
<span class="legend-item"><span class="legend-dot absent"></span> 결근</span>
|
||||
<span class="legend-item"><span class="legend-dot vacation"></span> 휴가</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일별 상세 모달 -->
|
||||
<div id="detailModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">출근 상세 정보</h2>
|
||||
<button class="modal-close-btn" onclick="closeDetailModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 상세 정보가 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 로딩 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/my-attendance.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,13 +16,7 @@
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<header class="work-report-header">
|
||||
<h1 id="pageTitle">👤 개별 작업 보고서</h1>
|
||||
<p class="subtitle" id="pageSubtitle">작업자의 일일 작업 내용을 입력하고 수정합니다.</p>
|
||||
</header>
|
||||
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<span class="icon">📊</span>
|
||||
<span>일일 이슈 보고</span>
|
||||
</a>
|
||||
<a href="/pages/common/attendance.html" class="menu-item">
|
||||
<a href="/pages/common/my-attendance.html" class="menu-item">
|
||||
<span class="icon">📋</span>
|
||||
<span>출근부 확인</span>
|
||||
</a>
|
||||
|
||||
@@ -11,16 +11,10 @@
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<header class="work-report-header">
|
||||
<h1>🏷️ 코드 관리</h1>
|
||||
<p class="subtitle">작업 상태, 오류 유형, 작업 유형 등 시스템에서 사용하는 코드를 관리합니다</p>
|
||||
</header>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="work-report-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
@@ -37,13 +31,12 @@
|
||||
</h1>
|
||||
<p class="page-description">작업 상태, 오류 유형, 작업 유형 등 시스템에서 사용하는 코드를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" onclick="refreshAllCodes()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
전체 새로고침
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="refreshAllCodes()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
전체 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 코드 유형 탭 -->
|
||||
|
||||
@@ -10,16 +10,10 @@
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<header class="work-report-header">
|
||||
<h1>📁 프로젝트 관리</h1>
|
||||
<p class="subtitle">프로젝트 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
|
||||
</header>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="work-report-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
@@ -36,17 +30,16 @@
|
||||
</h1>
|
||||
<p class="page-description">프로젝트 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openProjectModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 프로젝트 등록
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshProjectList()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openProjectModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 프로젝트 등록
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshProjectList()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
|
||||
@@ -10,16 +10,10 @@
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<header class="work-report-header">
|
||||
<h1>🔧 작업 관리</h1>
|
||||
<p class="subtitle">프로젝트, 작업자, 작업 유형 등 기본 데이터를 관리합니다</p>
|
||||
</header>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="work-report-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
|
||||
@@ -11,16 +11,10 @@
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<header class="work-report-header">
|
||||
<h1>👥 작업자 관리</h1>
|
||||
<p class="subtitle">작업자 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
|
||||
</header>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="work-report-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
@@ -37,17 +31,16 @@
|
||||
</h1>
|
||||
<p class="page-description">작업자 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openWorkerModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 작업자 등록
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshWorkerList()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openWorkerModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 작업자 등록
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshWorkerList()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
|
||||
180
개발 log/2026-01-20.md
Normal file
180
개발 log/2026-01-20.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 개발 로그 - 2026-01-20
|
||||
|
||||
## 타임라인
|
||||
|
||||
### 오전 - 로그인 API 500 에러 수정
|
||||
- **작업**: 수정
|
||||
- **대상**: `api.hyungi.net/services/auth.service.js`
|
||||
- **문제**:
|
||||
- 로그인 페이지에서 500 Internal Server Error 발생
|
||||
- 브라우저 콘솔 에러: "Failed to load resource: the server responded with a status of 500"
|
||||
- **원인**:
|
||||
- `userModel.findByUsername()`은 `role_name` 필드를 반환하는데
|
||||
- `auth.service.js:84`에서 `user.role`로 접근하여 undefined 발생
|
||||
- 응답 객체에 undefined 값이 포함되어 JSON 직렬화 실패
|
||||
- **해결방법**:
|
||||
- `role: user.role` → `role: user.role_name`으로 수정
|
||||
- **변경 내용**:
|
||||
```javascript
|
||||
// 이전
|
||||
user: {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
name: user.name || user.username,
|
||||
role: user.role, // undefined
|
||||
access_level: user.access_level,
|
||||
worker_id: user.worker_id
|
||||
}
|
||||
|
||||
// 이후
|
||||
user: {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
name: user.name || user.username,
|
||||
role: user.role_name, // 올바른 필드 사용
|
||||
access_level: user.access_level,
|
||||
worker_id: user.worker_id
|
||||
}
|
||||
```
|
||||
- **파일**: `api.hyungi.net/services/auth.service.js:83`
|
||||
|
||||
---
|
||||
|
||||
### 오전 - 네비게이션 헤더 전면 개편
|
||||
- **작업**: 대규모 리팩토링
|
||||
- **대상**: navbar 컴포넌트 및 관련 페이지 전체
|
||||
- **배경**:
|
||||
- 구식 navbar 디자인 사용 중
|
||||
- group-leader.html의 최신 dashboard-header 스타일이 표준
|
||||
- 모든 페이지를 최신 디자인으로 통일 필요
|
||||
|
||||
#### 1단계: 구버전 헤더 제거
|
||||
- **작업**: 수정
|
||||
- **대상**: 6개 페이지
|
||||
- **문제**:
|
||||
- `work-report-header` 클래스의 구버전 헤더가 navbar와 중복 표시
|
||||
- 페이지마다 불필요한 제목과 설명 중복
|
||||
- **해결방법**:
|
||||
- 모든 페이지에서 `<header class="work-report-header">` 블록 제거
|
||||
- navbar 컴포넌트만 유지
|
||||
- **제거된 헤더 예시**:
|
||||
```html
|
||||
<!-- 제거됨 -->
|
||||
<header class="work-report-header">
|
||||
<h1>🔧 작업 관리</h1>
|
||||
<p class="subtitle">프로젝트, 작업자, 작업 유형 등 기본 데이터를 관리합니다</p>
|
||||
</header>
|
||||
```
|
||||
- **수정된 파일** (6개):
|
||||
- `web-ui/pages/management/work-management.html`
|
||||
- `web-ui/pages/management/project-management.html`
|
||||
- `web-ui/pages/management/code-management.html`
|
||||
- `web-ui/pages/management/worker-management.html`
|
||||
- `web-ui/pages/common/worker-individual-report.html`
|
||||
- `web-ui/pages/common/daily-work-report.html`
|
||||
|
||||
#### 2단계: navbar 컴포넌트 최신화
|
||||
- **작업**: 전면 재작성
|
||||
- **대상**: `web-ui/components/navbar.html`
|
||||
- **변경 내용**:
|
||||
1. **HTML 구조 변경**:
|
||||
- 기존: `<nav class="navbar">` 구조
|
||||
- 신규: `<header class="dashboard-header">` 구조 (group-leader.html과 동일)
|
||||
2. **레이아웃 3분할**:
|
||||
- `header-left`: 브랜드 로고 + 회사명
|
||||
- `header-center`: 실시간 시계
|
||||
- `header-right`: 사용자 프로필 메뉴
|
||||
3. **스타일 전면 교체**:
|
||||
- 구버전 navbar 스타일 (470줄) 삭제
|
||||
- modern-dashboard.css 기반 최신 스타일 (230줄) 적용
|
||||
- 파란색 그라데이션 배경 (`#1e40af` → `#2563eb`)
|
||||
- 향상된 hover 효과 및 애니메이션
|
||||
4. **프로필 메뉴 개선**:
|
||||
- 드롭다운 메뉴를 user-profile hover 시 자동 표시
|
||||
- 기존의 클릭 기반 → hover 기반으로 변경
|
||||
- 더 부드러운 트랜지션 효과
|
||||
- **파일**: `web-ui/components/navbar.html:1-287` (전체)
|
||||
|
||||
#### 3단계: navbar 로더 스크립트 업데이트
|
||||
- **작업**: 수정
|
||||
- **대상**: `web-ui/js/load-navbar.js`
|
||||
- **변경 내용**:
|
||||
1. **사용자 정보 채우기 함수 수정**:
|
||||
```javascript
|
||||
// 이전
|
||||
const elements = {
|
||||
'user-name': displayName,
|
||||
'user-role': roleName,
|
||||
'dropdown-user-fullname': displayName,
|
||||
'dropdown-user-id': `@${user.username}`,
|
||||
};
|
||||
|
||||
// 이후 (새 ID에 맞춤)
|
||||
const elements = {
|
||||
'userName': displayName,
|
||||
'userRole': roleName,
|
||||
'userInitial': displayName.charAt(0),
|
||||
};
|
||||
```
|
||||
2. **이벤트 리스너 단순화**:
|
||||
- 복잡한 드롭다운 토글 로직 제거
|
||||
- 로그아웃 버튼 이벤트만 유지 (CSS hover로 처리)
|
||||
3. **시계 업데이트 함수 수정**:
|
||||
- 'current-time' → 'timeValue' ID로 변경
|
||||
- **파일**: `web-ui/js/load-navbar.js:55-95`
|
||||
|
||||
---
|
||||
|
||||
## 요약
|
||||
|
||||
### 주요 변경사항
|
||||
|
||||
#### 백엔드 수정
|
||||
1. **로그인 API 버그 수정**:
|
||||
- auth.service.js에서 role_name 필드 올바르게 사용
|
||||
- 500 에러 해결
|
||||
|
||||
#### 프론트엔드 대규모 리팩토링
|
||||
1. **네비게이션 전면 개편**:
|
||||
- navbar 컴포넌트를 최신 dashboard-header 스타일로 전환
|
||||
- 구버전 work-report-header 제거 (6개 페이지)
|
||||
- 모든 페이지에 일관된 최신 헤더 적용
|
||||
|
||||
2. **디자인 통일**:
|
||||
- group-leader.html의 모던 디자인이 전체 표준이 됨
|
||||
- 파란색 그라데이션 헤더
|
||||
- 실시간 시계 표시
|
||||
- 향상된 사용자 프로필 메뉴
|
||||
|
||||
3. **코드 정리**:
|
||||
- 470줄의 구식 CSS → 230줄의 최신 CSS
|
||||
- 불필요한 이벤트 리스너 제거 (hover로 대체)
|
||||
- 더 간결하고 유지보수 쉬운 코드
|
||||
|
||||
### 영향받는 파일
|
||||
- **수정**: 9개 파일
|
||||
- api.hyungi.net/services/auth.service.js
|
||||
- web-ui/components/navbar.html
|
||||
- web-ui/js/load-navbar.js
|
||||
- web-ui/pages/management/work-management.html
|
||||
- web-ui/pages/management/project-management.html
|
||||
- web-ui/pages/management/code-management.html
|
||||
- web-ui/pages/management/worker-management.html
|
||||
- web-ui/pages/common/worker-individual-report.html
|
||||
- web-ui/pages/common/daily-work-report.html
|
||||
|
||||
### 사용자 경험 개선
|
||||
- ✨ 모던하고 일관된 UI/UX
|
||||
- ⏰ 모든 페이지에서 실시간 시계 확인 가능
|
||||
- 🎨 향상된 비주얼 디자인
|
||||
- 📱 반응형 디자인 지원
|
||||
- ⚡ 더 부드러운 애니메이션과 트랜지션
|
||||
|
||||
### 테스트 필요
|
||||
- [x] 로그인 기능 정상 작동 확인
|
||||
- [ ] 모든 페이지 헤더 표시 확인
|
||||
- [ ] 사용자 프로필 메뉴 동작 확인
|
||||
- [ ] 실시간 시계 작동 확인
|
||||
- [ ] 반응형 디자인 (모바일/태블릿) 확인
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user