Files
jongryangje/app/Views/bag/layout/workspace.php
taekyoungc 03c2bbc05c feat: 글자크기를 계정별·메뉴별로 저장(재로그인 유지)
기존 전역 localStorage 방식(모든 메뉴 일괄 적용)을 화면 경로(메뉴키) 기준
계정별 DB 저장으로 변경. A−/A+ 조정 시 해당 화면만 조정·저장되고, 다음
방문·재로그인 시 각 화면의 저장값이 서버 주입으로 복원된다.

- member_menu_font_scale 테이블 + 저장 엔드포인트(bag/pref/font-scale)
- member_font_scale_for() 헬퍼로 렌더 시 현재 화면 저장값 주입
- admin/portal/embed 레이아웃 + 워크스페이스(탭 iframe)에 적용
  · 워크스페이스는 포커스된 탭(메뉴)만 조정, 디바운스 저장으로 CSRF 경합 방지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:12:43 +09:00

589 lines
31 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-refresh.spin { animation: ws-spin .6s linear; }
@keyframes ws-spin { to { transform: rotate(360deg); } }
.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; transition: opacity .15s; }
/* 분할 칸 헤더 */
.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; }
.ws-toast { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(10px); background: #243a5e; color: #fff; padding: .6rem 1rem; border-radius: 8px; font-size: .8rem; font-weight: 600; box-shadow: 0 8px 22px rgba(0,0,0,.25); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; z-index: 10000; max-width: 80vw; text-align: center; }
.ws-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</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;">
<div class="ws-fontctl" title="글씨 크기 조절" style="display:inline-flex;align-items:center;gap:2px;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.25);border-radius:6px;padding:1px;">
<button type="button" id="wsFontMinus" title="글씨 작게" style="width:24px;height:22px;border:0;background:transparent;color:#fff;cursor:pointer;font-size:11px;line-height:1;border-radius:5px;">A</button>
<span id="wsFontPct" style="min-width:34px;text-align:center;color:#fff;font-size:.68rem;font-weight:600;">100%</span>
<button type="button" id="wsFontPlus" title="글씨 크게" style="width:24px;height:22px;border:0;background:transparent;color:#fff;cursor:pointer;font-size:14px;line-height:1;border-radius:5px;">A+</button>
</div>
<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>
<button type="button" id="wsCloseAll" title="모든 탭 닫기" style="margin-left:6px;width:auto;padding:0 8px;gap:4px;"><i class="fa-regular fa-rectangle-xmark"></i><span style="font-size:11px;">모두 닫기</span></button>
</div>
</div>
<div class="ws-toast" id="wsToast" role="status" aria-live="polite"></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; // 포커스된 칸 인덱스
var toastEl = document.getElementById('wsToast'), toastTimer = null;
function showToast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 2800);
}
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';
// 칸 제목(sh-name)은 표시하지 않음 — 칸 안 화면에 이미 제목이 있어 중복.
var sp = document.createElement('span'); sp.className = 'sh-spacer'; sp.style.flex = '1';
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(sp); 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);
} 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(); try { updateFontPct(); } catch (e) {} }
// 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
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();
try { updateFontPct(); } catch (e) {}
}
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')); });
});
// 모든 탭 닫기 — 기본 "업무 현황"(대시보드) 탭은 남긴다
var DASH_URL = '<?= base_url('/') ?>';
function closeAllTabs() {
var dashId = norm(DASH_URL);
var others = order.filter(function (id) { return id !== dashId; });
if (!others.length) return; // 업무 현황만 있으면 닫을 것 없음
if (!window.confirm('업무 현황을 제외한 모든 탭(' + others.length + '개)을 닫을까요?')) return;
others.forEach(function (id) { closeTab(id); });
if (!tabs[dashId]) openTab(DASH_URL, '업무 현황'); else placeInFocused(dashId);
showToast('업무 현황만 남기고 모두 닫았습니다.');
}
var closeAllBtn = document.getElementById('wsCloseAll');
if (closeAllBtn) closeAllBtn.addEventListener('click', closeAllTabs);
// 로고 클릭 → 전체 새로고침 대신 "업무 현황" 탭을 열고(1분할) 포커스
var brandLink = document.querySelector('.gov-portal-brand');
if (brandLink) brandLink.addEventListener('click', function (e) {
e.preventDefault();
if (layout !== 'single') setLayout('single');
openTab(DASH_URL, '업무 현황');
});
function reloadTab(id) {
var t = tabs[id];
if (!t) return;
t.frame.style.opacity = '0.35'; // 새로고침 시각 피드백(load 시 복구)
var ic = t.btn && t.btn.querySelector('.t-refresh');
if (ic) { ic.classList.remove('spin'); void ic.offsetWidth; ic.classList.add('spin'); setTimeout(function () { ic.classList.remove('spin'); }, 650); }
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) {
if (!(opts && opts.noFocus)) showToast('탭은 최대 ' + MAX + '개까지 열 수 있습니다. 사용하지 않는 탭을 닫아 주세요.');
return;
}
var frame = document.createElement('iframe');
frame.className = 'ws-frame';
frame.src = withEmbed(url);
frame.setAttribute('title', title || '탭');
// iframe 내부 포커스에서도 단축키 동작 + 칸 클릭 시 그 칸 포커스
frame.addEventListener('load', function () {
frame.style.opacity = ''; // 새로고침 페이드 복구
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) {}
try { readTabScale(id); } 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);
// 글씨 크기 조절 — 포커스된 탭(메뉴) 내용에만 적용하고, 계정·메뉴별로 서버에 저장한다.
// (예전엔 모든 탭·셸에 일괄 적용돼 "모든 메뉴가 함께 바뀌는" 문제가 있었음)
var FONT_MIN = 70, FONT_MAX = 150;
var FONT_SAVE_URL = '<?= site_url('bag/pref/font-scale') ?>';
var FONT_CSRF_NAME = '<?= csrf_token() ?>';
var fontCsrfHash = '<?= csrf_hash() ?>';
function menuKeyOf(id) {
var u = (tabs[id] && tabs[id].url) ? tabs[id].url : id;
try { return new URL(u, location.href).pathname.replace(/^\/+/, '').replace(/^index\.php\//, ''); } catch (e) { return String(u); }
}
function updateFontPct() {
var fid = slots[focused];
var s = (fid && tabs[fid] && tabs[fid].scale) ? tabs[fid].scale : 100;
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
}
// 새 탭 로드 시: embed 레이아웃이 적용한 저장 배율을 읽어 탭 상태로 보관.
function readTabScale(id) {
var s = 100;
try { var z = parseFloat(tabs[id].frame.contentDocument.documentElement.style.zoom || '1'); if (z > 0) s = Math.round(z * 100); } catch (e) {}
if (!(s >= FONT_MIN && s <= FONT_MAX)) s = 100;
if (tabs[id]) tabs[id].scale = s;
if (slots[focused] === id) updateFontPct();
}
var fontSaveTimer = null, fontSavePending = null;
function flushFontSave() {
if (!fontSavePending) return;
var p = fontSavePending; fontSavePending = null;
var b = new URLSearchParams();
b.set(FONT_CSRF_NAME, fontCsrfHash); b.set('menu_key', p.key); b.set('scale', String(p.scale));
fetch(FONT_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) fontCsrfHash = d.csrf; }).catch(function () {});
}
function applyTabFont(id, s, save) {
if (!tabs[id]) return;
s = Math.min(FONT_MAX, Math.max(FONT_MIN, s));
tabs[id].scale = s;
try { tabs[id].frame.contentDocument.documentElement.style.zoom = (s / 100); } catch (e) {}
if (slots[focused] === id) updateFontPct();
if (save) {
var key = menuKeyOf(id);
try { localStorage.setItem('jrj_font_scale::' + key, String(s)); } catch (e) {}
// 연속 클릭 시 CSRF 토큰 회전 경합 방지 → 디바운스로 마지막 값 1회만 저장
fontSavePending = { key: key, scale: s };
clearTimeout(fontSaveTimer);
fontSaveTimer = setTimeout(flushFontSave, 400);
}
}
(function () {
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
function bump(delta) {
var fid = slots[focused];
if (!fid || !tabs[fid]) { showToast('먼저 메뉴 탭을 여세요.'); return; }
applyTabFont(fid, (tabs[fid].scale || 100) + delta, true);
}
if (plus) plus.addEventListener('click', function () { bump(10); });
if (minus) minus.addEventListener('click', function () { bump(-10); });
})();
// 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
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>