리브랜딩 - 서비스명 "종량제 시스템" → "GBLS", 헤더 로고에 풀네임(Garbage Bag Logistics System) 병기 (gov-portal·공통 브랜드·로그인/welcome 셸·타이틀·푸터 전반) 매뉴얼 - 신규 페이지 [로그인·회원가입·계정](01_account.md): 가입 항목·관리자 승인·2차 인증 흐름 - [화면 구성·워크스페이스·단축키]에 계정 전환 시 탭 초기화 안내 추가 워크스페이스(탭) - 탭 전환 시 좌측 사이드바 강조 동기화(메뉴 없는 화면은 강조 해제, 경로 폴백 매칭) - 소메뉴 좌측 아이콘(▸/·) 전부 제거 — 활성 메뉴는 배경 강조로만 구분 - 탭을 사용자(mb_idx)별로 격리: 다른 아이디 로그인 시 이전 탭 복원 안 함 - 사이드바 FAQ 링크 제거(자주 묻는 질문은 매뉴얼에 통합) 기본 코드관리 화면 - 업무현황 카드 스타일로 재디자인(가벼운 표·상태/범위 pill·단일 구분선) - render()에 $bare 옵션 추가 → 이미 카드형인 화면은 바깥 래퍼 생략 기타 - .claude/settings.local.json(개인 권한 설정) .gitignore 추가 - e2e: 워크스페이스(동기화·계정격리) + 매뉴얼(계정·단축키·검색) 케이스 보강 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
12 KiB
PHP
263 lines
12 KiB
PHP
<?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-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';
|
||
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
|
||
function persist() {
|
||
try {
|
||
var data = {
|
||
owner: WS_OWNER,
|
||
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';
|
||
// 현재 탭에 맞춰 좌측 사이드바(대메뉴/소메뉴) 강조 동기화
|
||
try { if (window.govPortalNav && tabs[id]) window.govPortalNav.syncByUrl(tabs[id].url); } catch (e) {}
|
||
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 || '탭');
|
||
// iframe 내부에 포커스가 있어도 단축키가 동작하도록 같은 출처 문서에 핸들러 부착
|
||
frame.addEventListener('load', function () {
|
||
try { frame.contentDocument.addEventListener('keydown', handleShortcut); } 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) { activate(id); reloadTab(id); }
|
||
else { activate(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);
|
||
activate(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(); activate(order[i]); }
|
||
} else if (k === 'w' || k === 'W' || k === 'ㅈ') {
|
||
if (activeId) { e.preventDefault(); closeTab(activeId); }
|
||
} else if (k === '[' || k === ']') {
|
||
if (!activeId || order.length < 2) return;
|
||
e.preventDefault();
|
||
var cur = order.indexOf(activeId);
|
||
var nx = k === ']' ? (cur + 1) % order.length : (cur - 1 + order.length) % order.length;
|
||
activate(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') || '';
|
||
// 지자체 선택(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.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); });
|
||
if (saved.active && tabs[saved.active]) activate(saved.active);
|
||
return;
|
||
}
|
||
openTab('<?= base_url('/') ?>', '업무 현황');
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|