- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
745 lines
36 KiB
HTML
745 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>GUARDiA — 온콜/당직</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<style>
|
|
.page-wrap { display:flex; flex-direction:column; height:100vh; }
|
|
.topnav { display:flex; align-items:center; gap:16px; padding:0 24px; height:52px; background:var(--card-bg); border-bottom:1px solid var(--border); flex-shrink:0; }
|
|
.topnav-logo { font-weight:700; font-size:16px; color:var(--accent); text-decoration:none; }
|
|
.topnav-links { display:flex; gap:4px; }
|
|
.topnav-link { padding:6px 12px; font-size:13px; color:var(--text-muted); text-decoration:none; border-radius:6px; transition:background .15s,color .15s; }
|
|
.topnav-link:hover,.topnav-link.active { background:var(--sidebar-hover-bg); color:var(--text-primary); }
|
|
.topnav-right { margin-left:auto; display:flex; align-items:center; gap:12px; }
|
|
.page-content { flex:1; overflow-y:auto; padding:24px; }
|
|
.page-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; }
|
|
.page-title { font-size:22px; font-weight:700; color:var(--text-bright); }
|
|
|
|
/* Today Banner */
|
|
.today-oncall-banner {
|
|
background:rgba(99,102,241,.1); border:1px solid rgba(99,102,241,.3);
|
|
border-radius:10px; padding:12px 20px; margin-bottom:20px;
|
|
font-size:14px; color:var(--text-primary); display:flex; align-items:center; gap:8px;
|
|
}
|
|
.today-oncall-banner strong { color:var(--accent); }
|
|
.today-oncall-banner.no-data { background:rgba(107,114,128,.08); border-color:rgba(107,114,128,.2); color:var(--text-muted); }
|
|
|
|
/* Tabs */
|
|
.tabs { display:flex; gap:4px; margin-bottom:20px; border-bottom:1px solid var(--border); }
|
|
.tab-btn { padding:8px 16px; font-size:13px; background:none; border:none; color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .15s; font-weight:500; }
|
|
.tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
|
.tab-content { display:none; }
|
|
.tab-content.active { display:block; }
|
|
|
|
/* Calendar */
|
|
.cal-nav { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
|
.cal-month-label { font-size:18px; font-weight:700; color:var(--text-bright); min-width:160px; text-align:center; }
|
|
.calendar-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
|
.cal-header { background:var(--card-bg); padding:8px; text-align:center; font-size:12px; font-weight:600; color:var(--text-muted); }
|
|
.cal-day { background:var(--main-bg,#0f172a); padding:8px; min-height:84px; cursor:pointer; transition:background .15s; position:relative; }
|
|
.cal-day:hover { background:var(--sidebar-hover-bg); }
|
|
.cal-day.today { background:rgba(99,102,241,.1); }
|
|
.cal-day.today .cal-date { color:var(--accent); font-weight:700; }
|
|
.cal-day.other-month { opacity:.35; pointer-events:none; }
|
|
.cal-date { font-size:12px; font-weight:600; margin-bottom:4px; color:var(--text-primary); }
|
|
.cal-day.sun .cal-date { color:#f87171; }
|
|
.cal-day.sat .cal-date { color:#818cf8; }
|
|
.cal-oncall { font-size:11px; padding:2px 6px; border-radius:4px; margin-bottom:2px; display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
|
|
.cal-oncall:hover { opacity:.8; }
|
|
.shift-ALL_DAY { background:rgba(96,165,250,.2); color:#60a5fa; }
|
|
.shift-DAYTIME { background:rgba(52,211,153,.2); color:#34d399; }
|
|
.shift-NIGHTTIME { background:rgba(167,139,250,.2); color:#a78bfa; }
|
|
.cal-backup { font-size:10px; color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
|
|
/* List Tab */
|
|
.toolbar { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px; align-items:center; }
|
|
.search-box-oc { background:var(--input-bg); border:1px solid var(--border); border-radius:6px; padding:7px 12px; color:var(--text-primary); font-size:13px; min-width:180px; outline:none; }
|
|
.search-box-oc:focus { border-color:var(--accent); }
|
|
.filter-select-oc { background:var(--input-bg); border:1px solid var(--border); border-radius:6px; padding:7px 10px; color:var(--text-primary); font-size:13px; outline:none; cursor:pointer; }
|
|
.sr-table { width:100%; border-collapse:collapse; }
|
|
.sr-table th { background:var(--card-bg); color:var(--text-muted); font-size:11px; font-weight:700; padding:10px 12px; text-align:left; border-bottom:1px solid var(--border); text-transform:uppercase; letter-spacing:.05em; }
|
|
.sr-table td { padding:10px 12px; border-bottom:1px solid var(--border); font-size:13px; color:var(--text-primary); vertical-align:middle; }
|
|
.sr-table tr:hover td { background:var(--sidebar-hover-bg); }
|
|
.badge-shift { display:inline-block; font-size:11px; font-weight:600; padding:2px 8px; border-radius:4px; }
|
|
|
|
/* Bulk section */
|
|
.bulk-section { background:var(--card-bg); border:1px solid var(--border); border-radius:10px; padding:20px; margin-top:24px; }
|
|
.bulk-title { font-size:14px; font-weight:700; color:var(--text-bright); margin-bottom:12px; }
|
|
.bulk-textarea { width:100%; background:var(--input-bg); border:1px solid var(--border); border-radius:6px; padding:10px 12px; color:var(--text-primary); font-size:12px; font-family:monospace; min-height:160px; outline:none; resize:vertical; }
|
|
.bulk-textarea:focus { border-color:var(--accent); }
|
|
|
|
/* Week card */
|
|
.week-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:10px; margin-bottom:20px; }
|
|
.week-card { background:var(--card-bg); border:1px solid var(--border); border-radius:8px; padding:12px 14px; }
|
|
.week-day { font-size:11px; font-weight:700; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; }
|
|
.week-name { font-size:13px; font-weight:600; color:var(--text-bright); }
|
|
.week-shift { font-size:11px; margin-top:2px; }
|
|
.week-card.today-card { border-color:var(--accent); background:rgba(99,102,241,.08); }
|
|
|
|
/* Modal */
|
|
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:100; display:none; align-items:center; justify-content:center; backdrop-filter:blur(2px); }
|
|
.modal-overlay.open { display:flex; }
|
|
.modal-box { background:var(--card-bg); border:1px solid var(--border); border-radius:12px; padding:28px; width:500px; max-width:95vw; max-height:88vh; overflow-y:auto; position:relative; box-shadow:0 8px 40px rgba(0,0,0,.4); }
|
|
.modal-box h2 { font-size:18px; font-weight:700; margin-bottom:18px; color:var(--text-bright); }
|
|
.modal-close { position:absolute; top:14px; right:16px; background:rgba(255,255,255,.06); border:1px solid var(--border); font-size:18px; cursor:pointer; color:var(--text-muted); border-radius:6px; padding:3px 8px; line-height:1; transition:color .15s; }
|
|
.modal-close:hover { color:var(--text-bright); }
|
|
.form-label { display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; color:var(--text-muted); margin-bottom:12px; letter-spacing:.02em; }
|
|
.form-label input, .form-label select, .form-label textarea { background:var(--input-bg); border:1px solid var(--border); border-radius:6px; padding:8px 10px; color:var(--text-primary); font-size:13px; outline:none; font-family:inherit; transition:border-color .15s; }
|
|
.form-label input:focus, .form-label select:focus, .form-label textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(99,102,241,.12); }
|
|
.form-row-2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
|
|
|
/* Buttons */
|
|
.btn-sm { padding:5px 12px; font-size:12px; border-radius:5px; cursor:pointer; border:1px solid transparent; font-weight:600; transition:all .15s; }
|
|
.btn-primary-sm { background:linear-gradient(135deg,var(--accent-dark),#8b5cf6); color:#fff; }
|
|
.btn-primary-sm:hover { opacity:.9; }
|
|
.btn-secondary-sm { background:rgba(255,255,255,.06); border-color:var(--border); color:var(--text-primary); }
|
|
.btn-secondary-sm:hover { background:rgba(255,255,255,.1); }
|
|
.btn-danger-sm { background:rgba(248,113,113,.12); color:#f87171; border-color:rgba(248,113,113,.3); }
|
|
.btn-danger-sm:hover { background:rgba(248,113,113,.22); }
|
|
|
|
/* Toast */
|
|
.toast { position:fixed; bottom:24px; right:24px; background:var(--card-bg); border:1px solid var(--border); border-radius:8px; padding:12px 18px; font-size:13px; color:var(--text-primary); box-shadow:0 4px 20px rgba(0,0,0,.3); z-index:9999; display:none; max-width:360px; }
|
|
.toast.show { display:block; animation:slideUp .2s ease; }
|
|
.toast.success { border-color:rgba(52,211,153,.4); color:#34d399; }
|
|
.toast.error { border-color:rgba(248,113,113,.4); color:#f87171; }
|
|
@keyframes slideUp { from{transform:translateY(12px);opacity:0} to{transform:none;opacity:1} }
|
|
.empty-state { text-align:center; padding:48px; color:var(--text-muted); }
|
|
.empty-icon { font-size:40px; margin-bottom:12px; }
|
|
.loading-dots::after { content:'...'; animation:dots 1.2s steps(4,end) infinite; }
|
|
@keyframes dots { 0%,20%{content:''} 40%{content:'.'} 60%{content:'..'} 80%,100%{content:'...'} }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<script>document.body.dataset.theme = localStorage.getItem("guardia_theme")||"dark";</script>
|
|
|
|
<div class="page-wrap">
|
|
<!-- Top Nav -->
|
|
<nav class="topnav">
|
|
<a href="/" class="topnav-logo">GUARDiA</a>
|
|
<div class="topnav-links">
|
|
<a href="/" class="topnav-link">대시보드</a>
|
|
<a href="/static/agents.html" class="topnav-link">에이전트</a>
|
|
<a href="/static/pm.html" class="topnav-link">PM 점검</a>
|
|
<a href="/static/oncall.html" class="topnav-link active">온콜/당직</a>
|
|
</div>
|
|
<div class="topnav-right">
|
|
<span id="nav-user" style="font-size:13px;color:var(--text-muted)"></span>
|
|
<button class="btn-sm btn-secondary-sm" onclick="logout()">로그아웃</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Page Content -->
|
|
<div class="page-content">
|
|
<div class="page-header">
|
|
<div class="page-title">온콜 / 당직 관리</div>
|
|
<button class="btn-sm btn-secondary-sm" onclick="refreshAll()">새로고침</button>
|
|
</div>
|
|
|
|
<!-- Today Banner -->
|
|
<div class="today-oncall-banner no-data" id="today-banner">
|
|
<span>📞 오늘 당직 정보를 불러오는 중<span class="loading-dots"></span></span>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tabs">
|
|
<button class="tab-btn active" onclick="switchTab('calendar', this)">월간 캘린더</button>
|
|
<button class="tab-btn" onclick="switchTab('list', this)">목록 & 일괄 등록</button>
|
|
</div>
|
|
|
|
<!-- Tab 1: 월간 캘린더 -->
|
|
<div class="tab-content active" id="tab-calendar">
|
|
<!-- 이번 주 카드 -->
|
|
<div id="week-section" style="margin-bottom:20px;display:none">
|
|
<div style="font-size:13px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px">이번 주 당직</div>
|
|
<div class="week-cards" id="week-cards"></div>
|
|
</div>
|
|
|
|
<!-- 월 네비게이션 -->
|
|
<div class="cal-nav">
|
|
<button class="btn-sm btn-secondary-sm" onclick="prevMonth()">◀ 이전</button>
|
|
<div class="cal-month-label" id="cal-month-label">-</div>
|
|
<button class="btn-sm btn-secondary-sm" onclick="nextMonth()">다음 ▶</button>
|
|
<button class="btn-sm btn-primary-sm" onclick="openOncallModal(null)" style="margin-left:auto">+ 당직 등록</button>
|
|
</div>
|
|
|
|
<!-- Calendar Grid -->
|
|
<div class="calendar-grid" id="calendar-grid">
|
|
<div class="cal-header">일</div>
|
|
<div class="cal-header">월</div>
|
|
<div class="cal-header">화</div>
|
|
<div class="cal-header">수</div>
|
|
<div class="cal-header">목</div>
|
|
<div class="cal-header">금</div>
|
|
<div class="cal-header">토</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: 목록 & 일괄 등록 -->
|
|
<div class="tab-content" id="tab-list">
|
|
<div class="toolbar">
|
|
<input class="search-box-oc" id="list-search" type="text" placeholder="담당자 검색..." oninput="filterList()">
|
|
<select class="filter-select-oc" id="list-shift" onchange="filterList()">
|
|
<option value="">모든 시프트</option>
|
|
<option value="ALL_DAY">ALL_DAY</option>
|
|
<option value="DAYTIME">DAYTIME</option>
|
|
<option value="NIGHTTIME">NIGHTTIME</option>
|
|
</select>
|
|
<div style="margin-left:auto">
|
|
<button class="btn-sm btn-primary-sm" onclick="openOncallModal(null)">+ 당직 등록</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x:auto">
|
|
<table class="sr-table">
|
|
<thead>
|
|
<tr>
|
|
<th>날짜</th>
|
|
<th>시프트</th>
|
|
<th>담당자</th>
|
|
<th>백업담당자</th>
|
|
<th>메모</th>
|
|
<th>작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="list-tbody">
|
|
<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-muted)"><span class="loading-dots">로딩중</span></td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 일괄 등록 -->
|
|
<div class="bulk-section">
|
|
<div class="bulk-title">일괄 등록 (JSON)</div>
|
|
<div style="font-size:12px;color:var(--text-muted);margin-bottom:10px">
|
|
JSON 배열 형식으로 입력 (최대 62건). 예시:
|
|
<code style="color:var(--accent)">[{"duty_date":"2026-06-01","shift":"ALL_DAY","user_id":2},{"duty_date":"2026-06-02","shift":"DAYTIME","user_id":3}]</code>
|
|
</div>
|
|
<textarea class="bulk-textarea" id="bulk-json" placeholder='[
|
|
{"duty_date": "2026-07-01", "shift": "ALL_DAY", "user_id": 2},
|
|
{"duty_date": "2026-07-02", "shift": "DAYTIME", "user_id": 3}
|
|
]'></textarea>
|
|
<div style="display:flex;gap:8px;margin-top:10px;justify-content:flex-end">
|
|
<button class="btn-sm btn-secondary-sm" onclick="fillBulkExample()">예시 채우기</button>
|
|
<button class="btn-sm btn-primary-sm" onclick="submitBulk()">일괄 등록</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── 당직 등록/수정 모달 ── -->
|
|
<div class="modal-overlay" id="modal-oncall">
|
|
<div class="modal-box">
|
|
<button class="modal-close" onclick="closeModal('modal-oncall')">✕</button>
|
|
<h2 id="oncall-modal-title">당직 등록</h2>
|
|
<input type="hidden" id="oncall-edit-id">
|
|
<div class="form-row-2">
|
|
<div class="form-label">날짜
|
|
<input type="date" id="oncall-date">
|
|
</div>
|
|
<div class="form-label">시프트
|
|
<select id="oncall-shift">
|
|
<option value="ALL_DAY">ALL_DAY — 종일</option>
|
|
<option value="DAYTIME">DAYTIME — 주간</option>
|
|
<option value="NIGHTTIME">NIGHTTIME — 야간</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row-2">
|
|
<div class="form-label">담당자
|
|
<select id="oncall-user-id">
|
|
<option value="">사용자 선택...</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-label">백업 담당자
|
|
<select id="oncall-backup-id">
|
|
<option value="">없음</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-label">메모
|
|
<textarea id="oncall-notes" rows="3" placeholder="특이사항 메모..."></textarea>
|
|
</div>
|
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px;">
|
|
<button class="btn-sm btn-secondary-sm" onclick="closeModal('modal-oncall')">취소</button>
|
|
<button class="btn-sm btn-danger-sm" id="btn-delete-oncall" style="display:none" onclick="deleteOncall()">삭제</button>
|
|
<button class="btn-sm btn-primary-sm" onclick="saveOncall()">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast -->
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
/* ════════════════════════════════════════
|
|
AUTH
|
|
════════════════════════════════════════ */
|
|
const token = localStorage.getItem('guardia_token');
|
|
if (!token) location.href = '/login';
|
|
|
|
function authHeaders() {
|
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
|
}
|
|
async function apiFetch(url, opts = {}) {
|
|
const res = await fetch(url, { headers: authHeaders(), ...opts });
|
|
if (res.status === 401) { location.href = '/login'; throw new Error('Unauthorized'); }
|
|
return res;
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
STATE
|
|
════════════════════════════════════════ */
|
|
let oncallData = []; // current month
|
|
let oncallFiltered = [];
|
|
let users = [];
|
|
let calYear, calMonth; // 0-based month
|
|
const todayDate = new Date();
|
|
|
|
/* ════════════════════════════════════════
|
|
INIT
|
|
════════════════════════════════════════ */
|
|
(async function init() {
|
|
calYear = todayDate.getFullYear();
|
|
calMonth = todayDate.getMonth(); // 0-based
|
|
try {
|
|
const me = await apiFetch('/api/auth/me').then(r => r.json()).catch(()=>null);
|
|
if (me) document.getElementById('nav-user').textContent = me.display_name || me.username || '';
|
|
} catch(e) {}
|
|
await Promise.all([loadUsers(), loadToday(), loadWeek()]);
|
|
await loadMonth(calYear, calMonth);
|
|
})();
|
|
|
|
function logout() {
|
|
localStorage.removeItem('guardia_token');
|
|
location.href = '/login';
|
|
}
|
|
|
|
function refreshAll() {
|
|
loadToday();
|
|
loadWeek();
|
|
loadMonth(calYear, calMonth);
|
|
showToast('새로고침 완료', 'success');
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
TABS
|
|
════════════════════════════════════════ */
|
|
function switchTab(name, btn) {
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById('tab-' + name).classList.add('active');
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
TODAY BANNER
|
|
════════════════════════════════════════ */
|
|
async function loadToday() {
|
|
const banner = document.getElementById('today-banner');
|
|
try {
|
|
const res = await apiFetch('/api/oncall/today');
|
|
if (!res.ok) throw new Error(res.status);
|
|
const data = await res.json();
|
|
if (!data || (Array.isArray(data) && !data.length)) {
|
|
banner.className = 'today-oncall-banner no-data';
|
|
banner.innerHTML = '📞 오늘 등록된 당직 정보가 없습니다.';
|
|
return;
|
|
}
|
|
const list = Array.isArray(data) ? data : [data];
|
|
banner.className = 'today-oncall-banner';
|
|
banner.innerHTML = list.map(d => `
|
|
📞 오늘 당직: <strong>${escHtml(d.user_name || d.user?.display_name || d.user?.username || '미지정')}</strong>
|
|
(<span class="badge-shift shift-${d.shift||'ALL_DAY'}">${d.shift||'ALL_DAY'}</span>)
|
|
${d.backup_user_name || d.backup_user?.display_name ? ` | 백업: ${escHtml(d.backup_user_name || d.backup_user?.display_name)}` : ''}
|
|
`).join('<span style="margin:0 8px;color:var(--border)">|</span>');
|
|
} catch(e) {
|
|
banner.className = 'today-oncall-banner no-data';
|
|
banner.innerHTML = '📞 오늘 당직 정보를 불러올 수 없습니다.';
|
|
}
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
WEEK CARDS
|
|
════════════════════════════════════════ */
|
|
async function loadWeek() {
|
|
try {
|
|
const res = await apiFetch('/api/oncall/week');
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const weekList = Array.isArray(data) ? data : (data.schedules || data.week || []);
|
|
if (!weekList.length) return;
|
|
document.getElementById('week-section').style.display = 'block';
|
|
const container = document.getElementById('week-cards');
|
|
const days = ['일','월','화','수','목','금','토'];
|
|
container.innerHTML = weekList.map(d => {
|
|
const dt = new Date(d.duty_date || d.date || '');
|
|
const isToday = dt.toDateString() === todayDate.toDateString();
|
|
const dow = isNaN(dt) ? '' : days[dt.getDay()];
|
|
return `
|
|
<div class="week-card ${isToday ? 'today-card' : ''}">
|
|
<div class="week-day">${isNaN(dt) ? '-' : dt.toLocaleDateString('ko-KR',{month:'short',day:'numeric'})} (${dow})</div>
|
|
<div class="week-name">${escHtml(d.user_name || d.user?.display_name || '미지정')}</div>
|
|
<div class="week-shift shift-${d.shift||'ALL_DAY'} badge-shift">${d.shift||'ALL_DAY'}</div>
|
|
${d.backup_user_name || d.backup_user?.display_name ? `<div style="font-size:10px;color:var(--text-muted);margin-top:2px">백업: ${escHtml(d.backup_user_name||d.backup_user?.display_name)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} catch(e) { console.warn('week load err', e); }
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
USERS
|
|
════════════════════════════════════════ */
|
|
async function loadUsers() {
|
|
try {
|
|
// 여러 엔드포인트 시도
|
|
let res = await apiFetch('/api/users');
|
|
if (!res.ok) res = await apiFetch('/api/auth/users');
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
users = data.users || data || [];
|
|
['oncall-user-id','oncall-backup-id'].forEach(selId => {
|
|
const sel = document.getElementById(selId);
|
|
const placeholder = selId === 'oncall-backup-id' ? '<option value="">없음</option>' : '<option value="">사용자 선택...</option>';
|
|
sel.innerHTML = placeholder;
|
|
users.forEach(u => {
|
|
sel.innerHTML += `<option value="${u.id}">${escHtml(u.display_name || u.username || u.name || '-')}</option>`;
|
|
});
|
|
});
|
|
} catch(e) { console.warn('users load err', e); }
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
MONTH LOAD & CALENDAR RENDER
|
|
════════════════════════════════════════ */
|
|
async function loadMonth(year, month) {
|
|
const m1 = month + 1; // API is 1-based
|
|
const label = `${year}년 ${String(m1).padStart(2,'0')}월`;
|
|
document.getElementById('cal-month-label').textContent = label;
|
|
|
|
try {
|
|
const res = await apiFetch(`/api/oncall?year=${year}&month=${m1}`);
|
|
if (!res.ok) throw new Error(res.status);
|
|
const data = await res.json();
|
|
oncallData = data.schedules || data || [];
|
|
} catch(e) {
|
|
oncallData = [];
|
|
console.warn('oncall month load err', e);
|
|
}
|
|
oncallFiltered = [...oncallData];
|
|
renderCalendar(year, month);
|
|
renderList();
|
|
}
|
|
|
|
function renderCalendar(year, month) {
|
|
const grid = document.getElementById('calendar-grid');
|
|
// Clear day cells only (keep headers)
|
|
const headers = grid.querySelectorAll('.cal-header');
|
|
grid.innerHTML = '';
|
|
headers.forEach(h => grid.appendChild(h));
|
|
|
|
const firstDay = new Date(year, month, 1).getDay(); // 0=Sun
|
|
const lastDate = new Date(year, month + 1, 0).getDate();
|
|
const prevLastDate = new Date(year, month, 0).getDate();
|
|
|
|
// Build date → oncall map
|
|
const dateMap = {};
|
|
oncallData.forEach(d => {
|
|
const key = (d.duty_date || d.date || '').substring(0, 10);
|
|
if (!dateMap[key]) dateMap[key] = [];
|
|
dateMap[key].push(d);
|
|
});
|
|
|
|
// Prefix cells (prev month)
|
|
for (let i = 0; i < firstDay; i++) {
|
|
const d = prevLastDate - firstDay + 1 + i;
|
|
grid.appendChild(makeDayCell(year, month - 1, d, true));
|
|
}
|
|
// Current month cells
|
|
for (let d = 1; d <= lastDate; d++) {
|
|
const dateKey = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
|
const events = dateMap[dateKey] || [];
|
|
grid.appendChild(makeDayCell(year, month, d, false, events, dateKey));
|
|
}
|
|
// Suffix cells (next month)
|
|
const totalCells = firstDay + lastDate;
|
|
const remainder = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);
|
|
for (let d = 1; d <= remainder; d++) {
|
|
grid.appendChild(makeDayCell(year, month + 1, d, true));
|
|
}
|
|
}
|
|
|
|
function makeDayCell(year, month, day, otherMonth, events = [], dateKey = '') {
|
|
const cell = document.createElement('div');
|
|
const isToday = !otherMonth &&
|
|
year === todayDate.getFullYear() &&
|
|
month === todayDate.getMonth() &&
|
|
day === todayDate.getDate();
|
|
const dow = new Date(year, month, day).getDay();
|
|
const classes = ['cal-day'];
|
|
if (otherMonth) classes.push('other-month');
|
|
if (isToday) classes.push('today');
|
|
if (dow === 0) classes.push('sun');
|
|
if (dow === 6) classes.push('sat');
|
|
cell.className = classes.join(' ');
|
|
|
|
let html = `<div class="cal-date">${day}</div>`;
|
|
events.slice(0, 3).forEach(ev => {
|
|
const name = ev.user_name || ev.user?.display_name || ev.user?.username || '?';
|
|
const shift = ev.shift || 'ALL_DAY';
|
|
const backup = ev.backup_user_name || ev.backup_user?.display_name || '';
|
|
html += `<span class="cal-oncall shift-${shift}" title="${name} (${shift})" onclick="openOncallModal(null,'${dateKey}');event.stopPropagation()">${escHtml(name)}</span>`;
|
|
if (backup) html += `<div class="cal-backup">백업: ${escHtml(backup)}</div>`;
|
|
});
|
|
if (events.length > 3) {
|
|
html += `<div style="font-size:10px;color:var(--text-muted)">+${events.length - 3}건 더</div>`;
|
|
}
|
|
|
|
cell.innerHTML = html;
|
|
if (!otherMonth) {
|
|
cell.addEventListener('click', () => openOncallModal(null, dateKey));
|
|
}
|
|
return cell;
|
|
}
|
|
|
|
function prevMonth() {
|
|
calMonth--;
|
|
if (calMonth < 0) { calMonth = 11; calYear--; }
|
|
loadMonth(calYear, calMonth);
|
|
}
|
|
function nextMonth() {
|
|
calMonth++;
|
|
if (calMonth > 11) { calMonth = 0; calYear++; }
|
|
loadMonth(calYear, calMonth);
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
LIST RENDER
|
|
════════════════════════════════════════ */
|
|
function renderList() {
|
|
const tbody = document.getElementById('list-tbody');
|
|
if (!oncallFiltered.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="empty-icon">📅</div><div>당직 일정이 없습니다.</div></div></td></tr>';
|
|
return;
|
|
}
|
|
// Sort by date
|
|
const sorted = [...oncallFiltered].sort((a,b) => (a.duty_date||a.date||'').localeCompare(b.duty_date||b.date||''));
|
|
tbody.innerHTML = sorted.map(d => {
|
|
const shift = d.shift || 'ALL_DAY';
|
|
const shiftColors = { ALL_DAY:'#60a5fa', DAYTIME:'#34d399', NIGHTTIME:'#a78bfa' };
|
|
const shiftBg = { ALL_DAY:'rgba(96,165,250,.15)', DAYTIME:'rgba(52,211,153,.15)', NIGHTTIME:'rgba(167,139,250,.15)' };
|
|
return `
|
|
<tr>
|
|
<td>${fmtDateOnly(d.duty_date || d.date)}</td>
|
|
<td><span class="badge-shift" style="background:${shiftBg[shift]||'rgba(107,114,128,.15)'};color:${shiftColors[shift]||'#9ca3af'}">${shift}</span></td>
|
|
<td>${escHtml(d.user_name || d.user?.display_name || d.user?.username || '-')}</td>
|
|
<td>${escHtml(d.backup_user_name || d.backup_user?.display_name || '-')}</td>
|
|
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escAttr(d.notes||'')}">
|
|
${escHtml(d.notes || '-')}
|
|
</td>
|
|
<td style="display:flex;gap:6px">
|
|
<button class="btn-sm btn-secondary-sm" onclick="editOncall(${d.id})">수정</button>
|
|
<button class="btn-sm btn-danger-sm" onclick="confirmDelete(${d.id})">삭제</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function filterList() {
|
|
const q = document.getElementById('list-search').value.toLowerCase();
|
|
const shift = document.getElementById('list-shift').value;
|
|
oncallFiltered = oncallData.filter(d => {
|
|
const name = (d.user_name || d.user?.display_name || d.user?.username || '').toLowerCase();
|
|
const nameMatch = !q || name.includes(q);
|
|
const shiftMatch = !shift || d.shift === shift;
|
|
return nameMatch && shiftMatch;
|
|
});
|
|
renderList();
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
ONCALL MODAL
|
|
════════════════════════════════════════ */
|
|
function openOncallModal(id, prefillDate = null) {
|
|
if (id) {
|
|
// edit mode
|
|
editOncall(id);
|
|
return;
|
|
}
|
|
document.getElementById('oncall-modal-title').textContent = '당직 등록';
|
|
document.getElementById('oncall-edit-id').value = '';
|
|
document.getElementById('btn-delete-oncall').style.display = 'none';
|
|
// Pre-fill date
|
|
const today = todayDate.toISOString().substring(0, 10);
|
|
document.getElementById('oncall-date').value = prefillDate || today;
|
|
document.getElementById('oncall-shift').value = 'ALL_DAY';
|
|
document.getElementById('oncall-user-id').value = '';
|
|
document.getElementById('oncall-backup-id').value = '';
|
|
document.getElementById('oncall-notes').value = '';
|
|
openModal('modal-oncall');
|
|
}
|
|
|
|
function editOncall(id) {
|
|
const d = oncallData.find(x => x.id === id);
|
|
if (!d) return;
|
|
document.getElementById('oncall-modal-title').textContent = '당직 수정';
|
|
document.getElementById('oncall-edit-id').value = d.id;
|
|
document.getElementById('btn-delete-oncall').style.display = '';
|
|
document.getElementById('oncall-date').value = (d.duty_date || d.date || '').substring(0,10);
|
|
document.getElementById('oncall-shift').value = d.shift || 'ALL_DAY';
|
|
document.getElementById('oncall-user-id').value = d.user_id || d.user?.id || '';
|
|
document.getElementById('oncall-backup-id').value = d.backup_user_id || d.backup_user?.id || '';
|
|
document.getElementById('oncall-notes').value = d.notes || '';
|
|
openModal('modal-oncall');
|
|
}
|
|
|
|
async function saveOncall() {
|
|
const editId = document.getElementById('oncall-edit-id').value;
|
|
const duty_date = document.getElementById('oncall-date').value;
|
|
const shift = document.getElementById('oncall-shift').value;
|
|
const user_id = parseInt(document.getElementById('oncall-user-id').value) || null;
|
|
const backup_user_id = parseInt(document.getElementById('oncall-backup-id').value) || null;
|
|
const notes = document.getElementById('oncall-notes').value;
|
|
if (!duty_date) { showToast('날짜를 선택하세요.', 'error'); return; }
|
|
if (!user_id) { showToast('담당자를 선택하세요.', 'error'); return; }
|
|
|
|
const body = { duty_date, shift, user_id, backup_user_id, notes };
|
|
try {
|
|
let res;
|
|
if (editId) {
|
|
res = await apiFetch(`/api/oncall/${editId}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
} else {
|
|
res = await apiFetch('/api/oncall', { method: 'POST', body: JSON.stringify(body) });
|
|
}
|
|
if (res.ok) {
|
|
closeModal('modal-oncall');
|
|
showToast(editId ? '당직이 수정되었습니다.' : '당직이 등록되었습니다.', 'success');
|
|
await loadMonth(calYear, calMonth);
|
|
await loadToday();
|
|
} else {
|
|
const d = await res.json().catch(()=>({}));
|
|
showToast('저장 실패: ' + (d.detail || res.status), 'error');
|
|
}
|
|
} catch(e) { showToast('오류: ' + e.message, 'error'); }
|
|
}
|
|
|
|
async function deleteOncall() {
|
|
const editId = document.getElementById('oncall-edit-id').value;
|
|
if (!editId) return;
|
|
if (!confirm('이 당직 일정을 삭제하시겠습니까?')) return;
|
|
try {
|
|
const res = await apiFetch(`/api/oncall/${editId}`, { method: 'DELETE' });
|
|
if (res.ok || res.status === 204) {
|
|
closeModal('modal-oncall');
|
|
showToast('당직이 삭제되었습니다.', 'success');
|
|
await loadMonth(calYear, calMonth);
|
|
await loadToday();
|
|
} else {
|
|
const d = await res.json().catch(()=>({}));
|
|
showToast('삭제 실패: ' + (d.detail || res.status), 'error');
|
|
}
|
|
} catch(e) { showToast('오류: ' + e.message, 'error'); }
|
|
}
|
|
|
|
async function confirmDelete(id) {
|
|
if (!confirm('이 당직 일정을 삭제하시겠습니까?')) return;
|
|
try {
|
|
const res = await apiFetch(`/api/oncall/${id}`, { method: 'DELETE' });
|
|
if (res.ok || res.status === 204) {
|
|
showToast('당직이 삭제되었습니다.', 'success');
|
|
await loadMonth(calYear, calMonth);
|
|
await loadToday();
|
|
} else {
|
|
const d = await res.json().catch(()=>({}));
|
|
showToast('삭제 실패: ' + (d.detail || res.status), 'error');
|
|
}
|
|
} catch(e) { showToast('오류: ' + e.message, 'error'); }
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
BULK
|
|
════════════════════════════════════════ */
|
|
function fillBulkExample() {
|
|
const year = calYear, m = String(calMonth + 1).padStart(2,'0');
|
|
const example = [
|
|
{ duty_date: `${year}-${m}-01`, shift: 'ALL_DAY', user_id: users[0]?.id || 1 },
|
|
{ duty_date: `${year}-${m}-02`, shift: 'DAYTIME', user_id: users[1]?.id || 2 },
|
|
{ duty_date: `${year}-${m}-03`, shift: 'NIGHTTIME', user_id: users[0]?.id || 1, backup_user_id: users[1]?.id || 2 },
|
|
];
|
|
document.getElementById('bulk-json').value = JSON.stringify(example, null, 2);
|
|
}
|
|
|
|
async function submitBulk() {
|
|
const raw = document.getElementById('bulk-json').value.trim();
|
|
let schedules;
|
|
try {
|
|
schedules = JSON.parse(raw);
|
|
if (!Array.isArray(schedules)) throw new Error('배열이어야 합니다.');
|
|
} catch(e) {
|
|
showToast('JSON 파싱 오류: ' + e.message, 'error');
|
|
return;
|
|
}
|
|
if (schedules.length > 62) {
|
|
showToast('최대 62건까지 일괄 등록 가능합니다.', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
const res = await apiFetch('/api/oncall/bulk', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ schedules })
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
showToast(`${data.created || schedules.length}건이 등록되었습니다.`, 'success');
|
|
document.getElementById('bulk-json').value = '';
|
|
await loadMonth(calYear, calMonth);
|
|
} else {
|
|
const d = await res.json().catch(()=>({}));
|
|
showToast('일괄 등록 실패: ' + (d.detail || res.status), 'error');
|
|
}
|
|
} catch(e) { showToast('오류: ' + e.message, 'error'); }
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
MODAL HELPERS
|
|
════════════════════════════════════════ */
|
|
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
|
document.querySelectorAll('.modal-overlay').forEach(el => {
|
|
el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); });
|
|
});
|
|
|
|
/* ════════════════════════════════════════
|
|
TOAST
|
|
════════════════════════════════════════ */
|
|
let toastTimer;
|
|
function showToast(msg, type = '') {
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.className = 'toast show' + (type ? ' ' + type : '');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => el.classList.remove('show'), 3500);
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
UTILS
|
|
════════════════════════════════════════ */
|
|
function escHtml(s) {
|
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
function escAttr(s) {
|
|
return String(s ?? '').replace(/"/g, '"');
|
|
}
|
|
function fmtDateOnly(d) {
|
|
if (!d) return '-';
|
|
const dt = new Date(d);
|
|
if (isNaN(dt)) return d;
|
|
return dt.toLocaleDateString('ko-KR', { year:'numeric', month:'2-digit', day:'2-digit' });
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|