Files
jongryangje/app/Views/bag/layout/portal.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

319 lines
17 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);
/**
* 사이트 업무 페이지 공통 셸 — gov-portal 디자인 적용판.
* 헤더 + 대메뉴(클릭) + 좌측 사이드바(소메뉴) + 본문($content).
* 본문은 기존 Tailwind 마크업을 그대로 쓰므로 Tailwind CDN·config·data-table 스타일을 함께 로드한다.
*
* @var string $title
* @var string $content (이스케이프 없이 출력되는 본문 HTML)
* @var bool $bare true면 work-surface 카드/제목바 없이 본문을 그대로 출력(대시보드용)
*/
helper('admin');
$bare = ! empty($bare);
$gov = gov_portal_nav_context(false); // 업무 셸: 실제 bag/* 링크 유지(코드관리 포털 remap 안 함)
$govActiveChildHref = gov_portal_nav_match_path($gov['currentPath']);
$navPartial = [
'govNavItems' => $gov['navItems'],
'govNavJson' => $gov['navJson'],
'govActiveParentIdx' => $gov['activeParentIdx'],
'govCurrentPath' => gov_portal_nav_match_path($gov['currentPath']),
'govDashboardAliases' => $gov['dashboardAliases'],
'govActiveChildHref' => $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 : '';
}
$helpUrl = function_exists('manual_help_url_for_path') ? manual_help_url_for_path() : '';
?>
<!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><?= esc($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"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Pretendard', '"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff',
'title-bar': '#1a2b4b',
'control-panel': '#f8f9fa',
'btn-search': '#243a5e',
'btn-excel-border': '#28a745',
'btn-excel-text': '#28a745',
'btn-print-border': '#ced4da',
'btn-exit': '#d9534f',
},
fontSize: { 'xxs': '0.65rem' }
}
}
}
</script>
<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'; ?>
/* 업무 본문 표/유틸 (기존 사이트 레이아웃에서 계승) */
.data-table { width: 100%; border-collapse: collapse; font-family: 'Pretendard', 'Malgun Gothic', 'Noto Sans KR', sans-serif; }
.data-table { font-size: 13px; }
.data-table th, .data-table td { text-align: left; padding: 0.55rem 0.5rem; white-space: nowrap; border: 0; border-bottom: 1px solid #e5e7eb; }
.data-table thead th { font-size: 0.6875rem; font-weight: 600; color: #6b7280; background: transparent; vertical-align: middle; }
.data-table tbody td { color: #374151; }
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover td { background-color: #f9fafb; }
@media print {
.portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
body.gov-portal-shell { background: #fff; display: block; }
.gov-portal-shell .main.work-main { overflow: visible !important; padding: 0 !important; }
.print-header { display: block !important; }
}
</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('/')]) ?>
<?= 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>
<a href="<?= base_url('workspace') ?>" 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;white-space:nowrap;">
<i class="fa-regular fa-window-restore"></i> 워크스페이스
</a>
<?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;white-space:nowrap;">
<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;white-space:nowrap;">
<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="main work-main main-content-area">
<?php if (! $bare && ! empty($title)): ?>
<div style="display:flex;align-items:center;justify-content:space-between;gap:.5rem;">
<h1 class="work-titlebar" style="margin-bottom:0;"><i class="fa-solid fa-folder-open tb-ico"></i><?= esc($title) ?></h1>
<?php if ($helpUrl !== ''): ?>
<a href="<?= esc($helpUrl, 'attr') ?>" rel="noopener" class="no-print portal-help" style="display:inline-flex;align-items:center;gap:.3rem;padding:.3rem .6rem;border-radius:6px;background:#fff;border:1px solid var(--border);color:#1a2b4b;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
<i class="fa-regular fa-circle-question"></i> 이 화면 설명
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="work-flash ok"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="work-flash err"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if ($bare): ?>
<?= $content ?>
<?php else: ?>
<div class="work-surface">
<?= $content ?>
</div>
<?php endif; ?>
</main>
</div>
<footer class="portal-footer">
<span>GBLS</span>
<span><?= date('Y.m.d (D) H:i') ?></span>
</footer>
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<script>
(function () {
// 방문한 업무 메뉴 경로 기록 (메인 메뉴검색의 "최근 방문 메뉴"용)
try {
var p = (location.pathname || '').replace(/\/+$/, '') || '/';
if (p === '/' || /\/login|\/logout|\/register/.test(p)) return;
var KEY = 'jrj_recent_menus';
var arr = JSON.parse(localStorage.getItem(KEY) || '[]');
if (!Array.isArray(arr)) arr = [];
arr = arr.filter(function (x) { return x && x.p && x.p !== p; });
arr.unshift({ p: p, t: Date.now() });
localStorage.setItem(KEY, JSON.stringify(arr.slice(0, 12)));
} catch (e) {}
})();
</script>
<script>
(function () {
// bfcache(뒤로가기/탭 복귀) 복원 시 열린 채 남은 전체화면 모달·팝업으로 인해
// 회색 레이어가 화면을 덮고 클릭이 막히는 문제 방지 — 복원 시 강제로 닫는다.
function closeStuckOverlays() {
document.querySelectorAll('.fixed.inset-0[id$="-modal"], .fixed.inset-0[id$="-popup"]').forEach(function (el) {
el.classList.add('hidden');
el.setAttribute('aria-hidden', 'true');
});
document.body.style.overflow = '';
}
window.addEventListener('pageshow', function (e) { if (e.persisted) closeStuckOverlays(); });
window.addEventListener('pagehide', closeStuckOverlays);
})();
</script>
<script>
(() => {
// 표의 '번호' 컬럼 역순 자동 채번 (기존 사이트 레이아웃 계승)
const normalize = (s) => String(s || '').replace(/\s+/g, '').trim();
const renumberTable = (table) => {
const headRow = table.querySelector('thead tr');
if (!headRow) return;
const headers = Array.from(headRow.querySelectorAll('th'));
const numberCol = headers.findIndex((th) => normalize(th.textContent) === '번호');
if (numberCol < 0) return;
const body = table.querySelector('tbody');
if (!body) return;
const rows = Array.from(body.querySelectorAll(':scope > tr')).filter((tr) => {
const cells = tr.querySelectorAll('td');
if (cells.length === 0) return false;
if (cells.length === 1 && Number(cells[0].getAttribute('colspan') || '1') > 1) return false;
return true;
});
let no = rows.length;
rows.forEach((tr) => {
const cells = tr.querySelectorAll('td');
if (cells[numberCol]) cells[numberCol].textContent = String(no--);
});
};
const run = () => document.querySelectorAll('table').forEach(renumberTable);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true });
else run();
})();
</script>
<!-- 화면 설명 드로어(팝업) -->
<style>
.help-drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(920px, 92vw); background: #fff; box-shadow: -8px 0 26px rgba(0,0,0,.18); z-index: 9999; display: none; flex-direction: column; }
.help-drawer.open { display: flex; }
.help-drawer-head { display: flex; align-items: center; justify-content: space-between; padding: .5rem .75rem; background: #1a2b4b; color: #fff; font-size: .8rem; font-weight: 700; flex-shrink: 0; }
.help-drawer-head .hd-btn { color: #fff; background: rgba(255,255,255,.14); border: 0; width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none; margin-left: 4px; }
.help-drawer-head .hd-btn:hover { background: rgba(255,255,255,.28); }
.help-drawer iframe { flex: 1; width: 100%; border: 0; }
.help-drawer-grip { position: absolute; left: -4px; top: 0; bottom: 0; width: 8px; cursor: col-resize; }
</style>
<div id="helpDrawer" class="help-drawer no-print" aria-hidden="true">
<div class="help-drawer-grip" id="helpDrawerGrip"></div>
<div class="help-drawer-head">
<span><i class="fa-regular fa-circle-question"></i> 화면 설명</span>
<div><button type="button" id="helpDrawerClose" class="hd-btn" title="닫기">&times;</button></div>
</div>
<iframe id="helpDrawerFrame" title="화면 설명"></iframe>
</div>
<script>
(function () {
var drawer = document.getElementById('helpDrawer'), dFrame = document.getElementById('helpDrawerFrame');
function openHelp(url) {
var u = url; try { var x = new URL(url, location.href); x.searchParams.set('embed', '1'); u = x.href; } catch (e) {}
if (dFrame.getAttribute('data-src') !== u) {
dFrame.src = u; dFrame.setAttribute('data-src', u);
} else {
// 같은 URL → 재로드 안 함. 매뉴얼에 다시 강조하라고 알림(껐다 켜도 강조되도록)
try { dFrame.contentWindow.postMessage({ type: 'manual-hl' }, location.origin); } catch (e) {}
}
drawer.classList.add('open');
}
function closeHelp() { drawer.classList.remove('open'); }
document.addEventListener('click', function (e) {
var h = e.target.closest ? e.target.closest('a.portal-help') : null;
if (!h) return;
e.preventDefault();
openHelp(h.getAttribute('href'));
});
document.getElementById('helpDrawerClose').addEventListener('click', closeHelp);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeHelp(); });
var grip = document.getElementById('helpDrawerGrip'), dragging = false;
// 드래그 중 iframe 이 마우스 이벤트를 가로채면(특히 좁힐 때) 멈추므로, 화면 전체 투명 오버레이로 이벤트를 가로챈다.
var ov = document.createElement('div');
ov.style.cssText = 'position:fixed;inset:0;z-index:10000;cursor:col-resize;display:none;';
document.body.appendChild(ov);
grip.addEventListener('mousedown', function (e) { e.preventDefault(); dragging = true; ov.style.display = 'block'; document.body.style.userSelect = 'none'; });
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; });
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
// 글씨 크기 조절 — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
var MENU_KEY = <?= json_encode($__fmk) ?>;
var scale = <?= (int) $__fsc ?>;
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
var csrfHash = <?= json_encode(csrf_hash()) ?>;
var scaleSelectors = ['.sidebar', '.work-main'];
var saveTimer = null;
function persist(s) {
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
if (!MENU_KEY) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
fetch(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) csrfHash = d.csrf; }).catch(function () {});
}, 400);
}
function applyScale(s, save) {
s = Math.min(150, Math.max(70, s));
scale = s;
var z = s / 100;
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
var topnav = document.querySelector('.portal-top-nav');
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
if (save) persist(s);
}
applyScale(scale, false);
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
})();
</script>
<script>
// 네이티브 날짜 입력의 월 표기를 한글(예: 6월)로 — 요소 lang 지정(브라우저 영어 로케일 대응)
(function () {
function fixLang() {
document.querySelectorAll('input[type=date],input[type=month],input[type=datetime-local],input[type=week],input[type=time]')
.forEach(function (el) { el.setAttribute('lang', 'ko'); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fixLang); else fixLang();
})();
</script>
<?= view('components/feedback_widget') ?>
<script>
/* 인쇄 시 활동 로그 기록(beacon) */
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
</script>
</body>
</html>