Files
jongryangje/app/Views/bag/layout/workspace.php
taekyoungc 1a443de02e feat: 매뉴얼 검색·소메뉴 아이콘 개선·워크스페이스 탭 세션 유지
- 매뉴얼: 전체 검색 박스(slug별 hit 카운트·스니펫)와 본문 하이라이트 추가
  - ManualRenderer::plainText()/search(), Bag::manualSearch(), bag/manual/search 라우트
- 사이드바 소메뉴 선택 아이콘 변경: 닫기처럼 보이던 × → ▸, + → · (정적/동적 일관)
- 워크스페이스: 탭 목록을 sessionStorage에 저장·복원
  - 관리자 페이지 이동 후 복귀·새로고침해도 열어둔 탭 유지(세션 범위)
  - 복원으로 무의미해진 beforeunload 새로고침 경고 제거
- e2e: 관리자 이동 후 탭 복원 케이스 추가

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

226 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
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>워크스페이스 · 종량제 시스템</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-tabbar { display: flex; align-items: stretch; gap: 2px; background: #e9eef5; border-bottom: 1px solid var(--border); padding: 4px 6px 0; overflow-x: auto; min-height: 36px; }
.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 .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-panels { flex: 1; position: relative; min-height: 0; background: #f0f4f8; }
.ws-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: none; background: #f0f4f8; }
.ws-frame.active { display: block; }
.ws-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; font-size: .9rem; gap: .5rem; }
</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-tabbar" id="wsTabBar" role="tablist"></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 activeId = null;
var MAX = 12;
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 STORE_KEY = 'jrj_ws_tabs';
function persist() {
try {
var data = {
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
active: activeId
};
sessionStorage.setItem(STORE_KEY, JSON.stringify(data));
} catch (e) {}
}
function activate(id) {
if (!tabs[id]) return;
activeId = id;
Object.keys(tabs).forEach(function (k) {
tabs[k].frame.classList.toggle('active', k === id);
tabs[k].btn.classList.toggle('active', k === id);
});
if (empty) empty.style.display = 'none';
persist();
}
function reloadTab(id) {
var t = tabs[id];
if (!t) return;
try { t.frame.contentWindow.location.reload(); }
catch (e) { t.frame.src = t.frame.src; } // 교차출처 등 예외 시 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; });
if (activeId === id) {
var next = order[order.length - 1];
if (next) activate(next); else { activeId = null; if (empty) empty.style.display = 'flex'; }
}
persist();
}
function openTab(url, title) {
var id = norm(url);
if (tabs[id]) { activate(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 || '탭');
panels.appendChild(frame);
var btn = document.createElement('div');
btn.className = 'ws-tab';
btn.setAttribute('role', 'tab');
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) { activate(id); reloadTab(id); }
else { activate(id); }
});
bar.appendChild(btn);
tabs[id] = { url: url, title: title, frame: frame, btn: btn };
order.push(id);
activate(id);
}
window.wsOpenTab = openTab;
// 좌측 사이드바 소메뉴 클릭 → 탭으로 열기 (전체 페이지 이동 대신)
document.querySelector('.sidebar').addEventListener('click', function (e) {
var a = e.target.closest('a[href]');
if (!a) return;
var href = a.getAttribute('href') || '';
// 지자체 선택(sb-gray)·모바일앱(sb-teal)은 그대로 이동, 나머지(소메뉴·하단 링크)는 탭으로
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());
});
});
// 첫 화면: 세션에 저장된 탭이 있으면 복원(관리자 페이지 등 전체 이동 후 복귀·새로고침 대응),
// 없으면 대시보드 탭 자동 열기
(function restore() {
var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
if (saved && saved.tabs && saved.tabs.length) {
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title); });
if (saved.active && tabs[saved.active]) activate(saved.active);
return;
}
openTab('<?= base_url('/') ?>', '업무 현황');
})();
})();
</script>
</body>
</html>