Files
jongryangje/app/Views/bag/layout/workspace.php
taekyoungc 912ffdbe23 feat: 워크스페이스 분할 보기(2·4분할) + 구분선 드래그 크기 조절
- 탭바에 분할 레이아웃 버튼 추가: 1분할 / 2분할(좌우) / 2분할(상하) / 4분할
  - iframe reparent 없이 absolute 위치만 재계산해 작업 상태 보존
  - 포커스된 칸에 탭 클릭으로 화면 배치, 칸 헤더(↻ 새로고침 · × 비우기)
  - 칸 안 클릭 시 해당 칸 포커스
- 분할 구분선 드래그로 칸 크기(비율) 조절, 더블클릭 50% 초기화
  - 드래그 중 투명 오버레이로 iframe 위에서도 이벤트 유지
  - 비율 12~88% 제한
- 레이아웃·칸 배치·비율을 세션에 저장/복원(계정별 격리 유지)
- 단축키를 포커스 칸 기준으로 동작하도록 정리
- 매뉴얼: [화면 구성·워크스페이스] 에 분할 보기·크기 조절 절 추가, 개요 안내 보강
- e2e: 분할 보기(2·4분할 전환) 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:19:37 +09:00

480 lines
24 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* 워크스페이스(탭) 셸 — 크롬처럼 메뉴를 탭(iframe)으로 열어두고 작업 상태 유지.
* 헤더 + 대메뉴(클릭) + 좌측 사이드바(소메뉴) + 탭바 + iframe 패널.
* 탭은 세션(이 셸이 열려 있는 동안)만 유지된다.
*/
helper('admin');
$gov = gov_portal_nav_context(false);
$navPartial = [
'govNavItems' => $gov['navItems'],
'govNavJson' => $gov['navJson'],
'govActiveParentIdx' => $gov['activeParentIdx'],
'govCurrentPath' => gov_portal_nav_match_path($gov['currentPath']),
'govDashboardAliases' => $gov['dashboardAliases'],
'govActiveChildHref' => '',
];
$mbLevel = (int) session()->get('mb_level');
$mbName = (string) (session()->get('mb_name') ?? '담당자');
$levelName = config(\Config\Roles::class)->getLevelName($mbLevel);
$isAdmin = ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN || $mbLevel === \Config\Roles::LEVEL_LOCAL_ADMIN);
$effectiveLgIdx = admin_effective_lg_idx();
$lgLabel = '';
if ($effectiveLgIdx) {
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx);
$lgLabel = $lgRow ? (string) $lgRow->lg_name : '';
}
?>
<!DOCTYPE html>
<html lang="ko" class="gov-portal-html">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>워크스페이스 · GBLS</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>
<?php include __DIR__ . '/../../home/_dashboard_gov_portal_brand_css.php'; ?>
<?php include __DIR__ . '/../../home/_dashboard_gov_portal_topnav_css.php'; ?>
<?php include __DIR__ . '/../../home/_dashboard_gov_portal_chrome_css.php'; ?>
html, body { height: 100%; }
body.gov-portal-shell { height: 100vh; overflow: hidden; }
.ws-main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.ws-topbar { display: flex; align-items: stretch; background: #e9eef5; border-bottom: 1px solid var(--border); }
.ws-tabbar { display: flex; align-items: stretch; gap: 2px; padding: 4px 6px 0; overflow-x: auto; min-height: 36px; flex: 1; min-width: 0; }
.ws-tab { display: inline-flex; align-items: center; gap: .4rem; max-width: 200px; padding: .35rem .6rem; background: #f5f7fa; border: 1px solid var(--border); border-bottom: none; border-radius: 7px 7px 0 0; font-size: .78rem; color: #555; cursor: pointer; white-space: nowrap; }
.ws-tab .t-name { overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
.ws-tab.active { background: #fff; color: var(--navy); font-weight: 700; box-shadow: 0 -2px 0 #007bff inset; }
.ws-tab.focused-tab { box-shadow: 0 -2px 0 #243a5e inset; }
.ws-tab .t-refresh, .ws-tab .t-close { width: 16px; height: 16px; line-height: 14px; text-align: center; border-radius: 50%; color: #999; font-size: 12px; }
.ws-tab .t-refresh:hover { background: #dbeafe; color: #1d4ed8; }
.ws-tab .t-close:hover { background: #e2e8f0; color: #333; }
/* 분할 레이아웃 컨트롤 */
.ws-layout { display: flex; align-items: center; gap: 3px; padding: 4px 8px; flex-shrink: 0; border-left: 1px solid var(--border); }
.ws-layout button { width: 30px; height: 26px; border: 1px solid var(--border); background: #f5f7fa; border-radius: 6px; color: #64748b; cursor: pointer; font-size: 12px; display: inline-flex; align-items: center; justify-content: center; }
.ws-layout button:hover { background: #e2e8f0; color: #334155; }
.ws-layout button.active { background: #243a5e; color: #fff; border-color: #243a5e; }
.ws-panels { flex: 1; position: relative; min-height: 0; background: #cbd5e1; }
.ws-frame { position: absolute; left: 0; top: 0; width: 100%; height: 100%; border: 0; display: none; background: #fff; box-sizing: border-box; }
/* 분할 칸 헤더 */
.ws-slot-head { position: absolute; display: none; align-items: center; gap: .3rem; height: 28px; padding: 0 .3rem 0 .6rem; background: #eef2f7; border-bottom: 1px solid var(--border); font-size: .72rem; color: #475569; box-sizing: border-box; cursor: pointer; z-index: 3; }
.ws-slot-head.focused { background: #dbeafe; color: var(--navy); box-shadow: inset 0 2px 0 #007bff; font-weight: 700; }
.ws-slot-head .sh-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-slot-head .sh-btn { width: 18px; height: 18px; line-height: 16px; text-align: center; border-radius: 50%; color: #94a3b8; flex-shrink: 0; font-size: 12px; }
.ws-slot-head .sh-btn:hover { background: #fff; color: #334155; }
.ws-slot-empty { position: absolute; display: none; align-items: center; justify-content: center; background: #f8fafc; color: #94a3b8; font-size: .8rem; box-sizing: border-box; cursor: pointer; z-index: 1; text-align: center; padding: 1rem; }
/* 분할 구분선 드래그 핸들 */
.ws-gutter { position: absolute; display: none; z-index: 6; background: transparent; }
.ws-gutter.v { width: 9px; height: 100%; top: 0; cursor: col-resize; }
.ws-gutter.h { width: 100%; height: 9px; left: 0; cursor: row-resize; }
.ws-gutter:hover { background: rgba(37, 99, 235, .25); }
/* 드래그 중 iframe 위에서도 마우스 이벤트를 받기 위한 오버레이 */
.ws-drag-overlay { position: absolute; inset: 0; z-index: 50; display: none; }
.ws-drag-overlay.v { cursor: col-resize; }
.ws-drag-overlay.h { cursor: row-resize; }
.ws-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; font-size: .9rem; gap: .5rem; z-index: 2; background: #f0f4f8; }
</style>
</head>
<body class="gov-portal-shell">
<header class="portal-header">
<div class="portal-header-inner">
<?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('workspace')]) ?>
<?= view('home/_dashboard_gov_portal_topnav_click', $navPartial) ?>
<div class="portal-header-utils" style="display:flex;align-items:center;gap:.5rem;">
<span class="user-line">
<?php if ($lgLabel !== ''): ?><strong><?= esc($lgLabel) ?></strong> · <?php endif; ?>
<?= esc($levelName) ?> · <?= esc($mbName) ?>님
</span>
<?php if ($isAdmin): ?>
<a href="<?= base_url('admin') ?>" title="관리자 페이지" style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.3);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;"><i class="fa-solid fa-gear"></i> 관리자</a>
<?php endif; ?>
<a href="<?= base_url('logout') ?>" title="로그아웃" style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.22);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;"><i class="fa-solid fa-right-from-bracket"></i> 로그아웃</a>
</div>
</div>
</header>
<div class="layout">
<?= view('home/_dashboard_gov_portal_sidebar', $navPartial) ?>
<main class="ws-main">
<div class="ws-topbar">
<div class="ws-tabbar" id="wsTabBar" role="tablist"></div>
<div class="ws-layout" id="wsLayout">
<button type="button" data-mode="single" title="1분할 (한 화면)"><i class="fa-regular fa-square"></i></button>
<button type="button" data-mode="lr" title="2분할 (좌우)"><i class="fa-solid fa-table-columns"></i></button>
<button type="button" data-mode="tb" title="2분할 (상하)"><i class="fa-solid fa-table-columns fa-rotate-90"></i></button>
<button type="button" data-mode="quad" title="4분할"><i class="fa-solid fa-table-cells-large"></i></button>
</div>
</div>
<div class="ws-panels" id="wsPanels">
<div class="ws-empty" id="wsEmpty">
<i class="fa-regular fa-window-restore" style="font-size:1.6rem;opacity:.5;"></i>
<div>왼쪽 메뉴를 클릭하면 탭으로 열립니다. 여러 화면을 열어두고 전환해도 작업 내용이 유지됩니다.</div>
</div>
</div>
</main>
</div>
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<script>
(function () {
var bar = document.getElementById('wsTabBar');
var panels = document.getElementById('wsPanels');
var empty = document.getElementById('wsEmpty');
var tabs = {}; // id -> {url,title,frame,btn}
var order = [];
var MAX = 12;
// 분할 레이아웃 상태
var layout = 'single'; // single | lr | tb | quad
var slots = [null]; // 칸별로 배치된 tab id (길이 = 칸 수)
var focused = 0; // 포커스된 칸 인덱스
function norm(u) { try { var a = new URL(u, location.origin); return a.pathname + a.search; } catch (e) { return u; } }
function withEmbed(u) {
try { var a = new URL(u, location.origin); a.searchParams.set('embed', '1'); return a.pathname + a.search; }
catch (e) { return u + (u.indexOf('?') >= 0 ? '&' : '?') + 'embed=1'; }
}
// 분할 비율 (좌 컬럼 폭 / 위 행 높이), 드래그로 조절
var vRatio = 0.5, hRatio = 0.5;
var RATIO_MIN = 0.12, RATIO_MAX = 0.88;
function pct(r) { return (r * 100).toFixed(3) + '%'; }
// 레이아웃별 칸 사각형 (2px 간격, 비율 반영) — left/top/width/height CSS 문자열
function rectsFor(mode) {
var Lw = 'calc(' + pct(vRatio) + ' - 1px)'; // 좌 컬럼 폭
var Rl = 'calc(' + pct(vRatio) + ' + 1px)'; // 우 컬럼 시작
var Rw = 'calc(' + pct(1 - vRatio) + ' - 1px)'; // 우 컬럼 폭
var Th = 'calc(' + pct(hRatio) + ' - 1px)'; // 위 행 높이
var Bt = 'calc(' + pct(hRatio) + ' + 1px)'; // 아래 행 시작
var Bh = 'calc(' + pct(1 - hRatio) + ' - 1px)'; // 아래 행 높이
if (mode === 'lr') return [
{ l: '0', t: '0', w: Lw, h: '100%' },
{ l: Rl, t: '0', w: Rw, h: '100%' }
];
if (mode === 'tb') return [
{ l: '0', t: '0', w: '100%', h: Th },
{ l: '0', t: Bt, w: '100%', h: Bh }
];
if (mode === 'quad') return [
{ l: '0', t: '0', w: Lw, h: Th },
{ l: Rl, t: '0', w: Rw, h: Th },
{ l: '0', t: Bt, w: Lw, h: Bh },
{ l: Rl, t: Bt, w: Rw, h: Bh }
];
return [{ l: '0', t: '0', w: '100%', h: '100%' }]; // single
}
var STORE_KEY = 'jrj_ws_tabs';
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
function persist() {
try {
sessionStorage.setItem(STORE_KEY, JSON.stringify({
owner: WS_OWNER,
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
layout: layout,
focused: focused,
vRatio: vRatio,
hRatio: hRatio,
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
}));
} catch (e) {}
}
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
var slotHeads = [], slotEmpties = [];
for (var si = 0; si < 4; si++) {
(function (idx) {
var h = document.createElement('div');
h.className = 'ws-slot-head';
var nm = document.createElement('span'); nm.className = 'sh-name';
var rl = document.createElement('span'); rl.className = 'sh-btn sh-reload'; rl.textContent = '↻'; rl.title = '이 칸 새로고침';
var cl = document.createElement('span'); cl.className = 'sh-btn sh-clear'; cl.textContent = '×'; cl.title = '이 칸 비우기';
h.appendChild(nm); h.appendChild(rl); h.appendChild(cl);
h.addEventListener('click', function (e) {
if (e.target === rl) { var sid = slots[idx]; if (sid) reloadTab(sid); return; }
if (e.target === cl) { slots[idx] = null; render(); return; }
focusSlot(idx);
});
panels.appendChild(h);
var emp = document.createElement('div');
emp.className = 'ws-slot-empty';
emp.textContent = ' 위 탭에서 이 칸에 표시할 화면을 선택하세요';
emp.addEventListener('click', function () { focusSlot(idx); });
panels.appendChild(emp);
slotHeads.push(h); slotEmpties.push(emp);
})(si);
}
// 분할 구분선(드래그 핸들) + 드래그용 투명 오버레이
var vGutter = document.createElement('div'); vGutter.className = 'ws-gutter v';
var hGutter = document.createElement('div'); hGutter.className = 'ws-gutter h';
var dragOverlay = document.createElement('div'); dragOverlay.className = 'ws-drag-overlay';
panels.appendChild(vGutter); panels.appendChild(hGutter); panels.appendChild(dragOverlay);
function clampRatio(r) { return Math.min(RATIO_MAX, Math.max(RATIO_MIN, r)); }
function startDrag(axis) {
dragOverlay.className = 'ws-drag-overlay ' + axis;
dragOverlay.style.display = 'block';
function move(ev) {
var rect = panels.getBoundingClientRect();
if (axis === 'v') vRatio = clampRatio((ev.clientX - rect.left) / rect.width);
else hRatio = clampRatio((ev.clientY - rect.top) / rect.height);
render();
}
function up() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
dragOverlay.style.display = 'none';
persist();
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
}
vGutter.addEventListener('mousedown', function (e) { e.preventDefault(); startDrag('v'); });
hGutter.addEventListener('mousedown', function (e) { e.preventDefault(); startDrag('h'); });
// 더블클릭 시 50%로 초기화
vGutter.addEventListener('dblclick', function () { vRatio = 0.5; render(); persist(); });
hGutter.addEventListener('dblclick', function () { hRatio = 0.5; render(); persist(); });
var layoutBtns = Array.prototype.slice.call(document.querySelectorAll('#wsLayout button'));
// CSS 길이 가감 (calc(0 + 28px) 같은 무효 표현 방지)
function addPx(val, px) { return val === '0' ? (px + 'px') : ('calc(' + val + ' + ' + px + 'px)'); }
function subPx(val, px) { return 'calc(' + val + ' - ' + px + 'px)'; }
function render() {
var rects = rectsFor(layout);
var n = rects.length;
var split = layout !== 'single';
// single 모드에서 포커스 칸이 비면 가장 최근 탭으로 자동 채움
if (!split && !(slots[0] && tabs[slots[0]]) && order.length) {
slots[0] = order[order.length - 1];
}
if (focused >= n) focused = n - 1;
var shown = {};
for (var i = 0; i < 4; i++) {
var head = slotHeads[i], emp = slotEmpties[i];
if (i >= n) { head.style.display = 'none'; emp.style.display = 'none'; continue; }
var r = rects[i];
var bodyTop = split ? addPx(r.t, 28) : r.t;
var bodyH = split ? subPx(r.h, 28) : r.h;
if (split) {
head.style.display = 'flex';
head.style.left = r.l; head.style.top = r.t; head.style.width = r.w;
head.classList.toggle('focused', i === focused);
var sid = slots[i];
head.querySelector('.sh-name').textContent = (sid && tabs[sid]) ? tabs[sid].title : '비어 있음';
} else {
head.style.display = 'none';
}
var id = slots[i];
if (id && tabs[id]) {
shown[id] = true;
var f = tabs[id].frame;
f.style.left = r.l; f.style.top = bodyTop; f.style.width = r.w; f.style.height = bodyH;
f.style.display = 'block';
emp.style.display = 'none';
} else if (split) {
emp.style.display = 'flex';
emp.style.left = r.l; emp.style.top = bodyTop; emp.style.width = r.w; emp.style.height = bodyH;
} else {
emp.style.display = 'none';
}
}
// 어느 칸에도 없는 iframe 은 숨김 / 포커스 칸 프레임에 active 표시
Object.keys(tabs).forEach(function (k) {
if (!shown[k]) tabs[k].frame.style.display = 'none';
tabs[k].frame.classList.toggle('active', k === slots[focused]);
});
// 탭 버튼 강조 (표시 중 = active, 포커스 칸 = focused-tab)
Object.keys(tabs).forEach(function (k) {
var pos = slots.indexOf(k);
tabs[k].btn.classList.toggle('active', pos >= 0 && pos < n);
tabs[k].btn.classList.toggle('focused-tab', split && slots[focused] === k);
});
// 분할 구분선 위치/표시
if (layout === 'lr' || layout === 'quad') { vGutter.style.display = 'block'; vGutter.style.left = 'calc(' + pct(vRatio) + ' - 4px)'; }
else { vGutter.style.display = 'none'; }
if (layout === 'tb' || layout === 'quad') { hGutter.style.display = 'block'; hGutter.style.top = 'calc(' + pct(hRatio) + ' - 4px)'; }
else { hGutter.style.display = 'none'; }
// 레이아웃 버튼 강조
layoutBtns.forEach(function (b) { b.classList.toggle('active', b.getAttribute('data-mode') === layout); });
// 전체 빈 상태
if (empty) empty.style.display = order.length === 0 ? 'flex' : 'none';
// 포커스 칸 화면에 맞춰 좌측 사이드바 동기화
var fid = slots[focused];
try { if (window.govPortalNav) window.govPortalNav.syncByUrl(fid && tabs[fid] ? tabs[fid].url : ''); } catch (e) {}
persist();
}
function focusSlot(i) { focused = i; render(); }
// 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
function placeInFocused(id) {
if (!tabs[id]) return;
if (layout === 'single') { slots = [id]; }
else {
var j = slots.indexOf(id);
if (j >= 0 && j !== focused) { var tmp = slots[focused]; slots[focused] = id; slots[j] = tmp; }
else { slots[focused] = id; }
}
render();
}
function setLayout(mode) {
var n = rectsFor(mode).length;
var keep = [], seen = {};
slots.forEach(function (id) { if (id && tabs[id] && !seen[id]) { seen[id] = true; keep.push(id); } });
var ns = keep.slice(0, n);
for (var i = 0; i < order.length && ns.length < n; i++) { if (ns.indexOf(order[i]) < 0) ns.push(order[i]); }
while (ns.length < n) ns.push(null);
slots = ns;
layout = mode;
if (focused >= n) focused = n - 1;
render();
}
layoutBtns.forEach(function (b) {
b.addEventListener('click', function () { setLayout(b.getAttribute('data-mode')); });
});
function reloadTab(id) {
var t = tabs[id];
if (!t) return;
try { t.frame.contentWindow.location.reload(); }
catch (e) { t.frame.src = t.frame.src; }
}
function closeTab(id) {
if (!tabs[id]) return;
tabs[id].frame.remove();
tabs[id].btn.remove();
delete tabs[id];
order = order.filter(function (x) { return x !== id; });
for (var i = 0; i < slots.length; i++) { if (slots[i] === id) slots[i] = null; }
render();
}
function openTab(url, title, opts) {
var id = norm(url);
if (tabs[id]) { if (!(opts && opts.noFocus)) placeInFocused(id); return; }
if (order.length >= MAX) { closeTab(order[0]); } // 오래된 탭 정리
var frame = document.createElement('iframe');
frame.className = 'ws-frame';
frame.src = withEmbed(url);
frame.setAttribute('title', title || '탭');
// iframe 내부 포커스에서도 단축키 동작 + 칸 클릭 시 그 칸 포커스
frame.addEventListener('load', function () {
try {
var d = frame.contentDocument;
d.addEventListener('keydown', handleShortcut);
d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true);
} catch (e) {}
});
panels.appendChild(frame);
var btn = document.createElement('div');
btn.className = 'ws-tab';
btn.setAttribute('role', 'tab');
btn.title = title || '탭';
var nameSpan = document.createElement('span');
nameSpan.className = 't-name';
nameSpan.textContent = title || '탭';
var refresh = document.createElement('span');
refresh.className = 't-refresh';
refresh.textContent = '↻';
refresh.title = '이 탭 새로고침';
var close = document.createElement('span');
close.className = 't-close';
close.textContent = '×';
close.title = '탭 닫기';
btn.appendChild(nameSpan); btn.appendChild(refresh); btn.appendChild(close);
btn.addEventListener('click', function (e) {
if (e.target === close) { closeTab(id); }
else if (e.target === refresh) { placeInFocused(id); reloadTab(id); }
else { placeInFocused(id); }
});
btn.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
btn.addEventListener('auxclick', function (e) { if (e.button === 1) { e.preventDefault(); closeTab(id); } });
bar.appendChild(btn);
tabs[id] = { url: url, title: title, frame: frame, btn: btn };
order.push(id);
if (!(opts && opts.noFocus)) placeInFocused(id);
}
window.wsOpenTab = openTab;
// 키보드 단축키 (Alt 기반, 포커스 칸 기준)
// Alt+1~9: 번호 탭을 포커스 칸에 · Alt+W: 포커스 칸 탭 닫기 · Alt+[ / Alt+]: 포커스 칸 탭 전환
function handleShortcut(e) {
if (!e.altKey || e.ctrlKey || e.metaKey) return;
var k = e.key;
if (k >= '1' && k <= '9') {
var i = parseInt(k, 10) - 1;
if (order[i]) { e.preventDefault(); placeInFocused(order[i]); }
} else if (k === 'w' || k === 'W' || k === 'ㅈ') {
var fid = slots[focused];
if (fid) { e.preventDefault(); closeTab(fid); }
} else if (k === '[' || k === ']') {
var cur = order.indexOf(slots[focused]);
if (order.length < 2) return;
e.preventDefault();
if (cur < 0) cur = 0;
var nx = k === ']' ? (cur + 1) % order.length : (cur - 1 + order.length) % order.length;
placeInFocused(order[nx]);
}
}
document.addEventListener('keydown', handleShortcut);
// 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
document.querySelector('.sidebar').addEventListener('click', function (e) {
var a = e.target.closest('a[href]');
if (!a) return;
var href = a.getAttribute('href') || '';
if (a.closest('.sb-gray') || a.closest('.sb-teal')) return;
if (/\/logout|\/login/.test(href) || href.charAt(0) === '#' || href === '') return;
e.preventDefault();
openTab(href, (a.textContent || '').trim());
});
// 상단 대메뉴 직접 링크도 포커스 칸에 열기
document.querySelectorAll('.portal-nav-link[href]').forEach(function (lnk) {
lnk.addEventListener('click', function (e) {
var href = lnk.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') return;
e.preventDefault();
openTab(href, (lnk.textContent || '').trim());
});
});
// 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할
(function restore() {
var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
if (saved && saved.tabs && saved.tabs.length) {
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';
if (typeof saved.vRatio === 'number') vRatio = clampRatio(saved.vRatio);
if (typeof saved.hRatio === 'number') hRatio = clampRatio(saved.hRatio);
var n = rectsFor(layout).length;
slots = [];
for (var i = 0; i < n; i++) {
var u = saved.slots && saved.slots[i] ? norm(saved.slots[i]) : null;
slots.push(u && tabs[u] ? u : null);
}
focused = Math.min(Math.max(parseInt(saved.focused, 10) || 0, 0), n - 1);
render();
return;
}
openTab('<?= base_url('/') ?>', '업무 현황');
})();
})();
</script>
</body>
</html>