메인 대시보드에 지정판매소 지도·메뉴검색을 추가한다.

- 카카오 지도(지도 2/3 + 판매소 목록 1/3, 높이 고정·스크롤), 목록 클릭 시 줌인
- 지오코딩 폴백(정밀→도로명→지번→키워드→행정동)으로 마커 표시
- 메뉴검색: 자동완성 드롭다운 + 기본 "최근 방문 메뉴"(localStorage, 뒤로가기/bfcache 갱신)
- 메뉴검색 박스 녹색(#009688), 지도와 높이 일치
- resolveLgLabel: 선택 지자체 실제 이름 사용, '(데모)' 문구 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-06-08 12:10:54 +09:00
parent ec3119799c
commit abc8a405e1
4 changed files with 422 additions and 4 deletions

View File

@@ -104,6 +104,34 @@ class Home extends BaseController
$pendingApprovals = 0; $pendingApprovals = 0;
} }
// 지도용 — 현재 지자체 지정판매소(이름·주소). 좌표는 클라이언트(카카오 지오코딩)에서 변환.
$mapShops = [];
try {
if ($lgIdx !== null && $db->tableExists('designated_shop')) {
$rows = $db->table('designated_shop')
->select('ds_name, ds_addr, ds_addr_jibun')
->where('ds_lg_idx', $lgIdx)
->where('ds_addr IS NOT NULL')
->where('ds_addr <>', '')
->orderBy('ds_idx', 'ASC')
->limit(40)
->get()->getResultArray();
foreach ($rows as $r) {
$addr = trim((string) ($r['ds_addr'] ?? ''));
if ($addr === '') {
continue;
}
$mapShops[] = [
'name' => (string) ($r['ds_name'] ?? ''),
'addr' => $addr,
'jibun' => trim((string) ($r['ds_addr_jibun'] ?? '')),
];
}
}
} catch (\Throwable $e) {
$mapShops = [];
}
// 최근 활동(activity_log) — 실제 변경 이력 // 최근 활동(activity_log) — 실제 변경 이력
$actionLabel = ['create' => '등록', 'update' => '수정', 'delete' => '삭제', 'cancel' => '취소']; $actionLabel = ['create' => '등록', 'update' => '수정', 'delete' => '삭제', 'cancel' => '취소'];
$tableLabel = [ $tableLabel = [
@@ -142,9 +170,44 @@ class Home extends BaseController
'stockMix' => $stockMix, 'stockMix' => $stockMix,
'lowStock' => $lowStock, 'lowStock' => $lowStock,
'recentActivity' => $recent, 'recentActivity' => $recent,
'mapShops' => $mapShops,
'kakaoJsKey' => config(\Config\Kakao::class)->javascriptKey,
'menuSearchOptions' => (function_exists('gov_portal_nav_context') && function_exists('gov_portal_menu_search_options'))
? gov_portal_menu_search_options(gov_portal_nav_context(false)['navItems'])
: [],
'menuFlat' => $this->buildMenuFlat(),
]; ];
} }
/**
* 메뉴검색 자동완성용 — 사이트 메뉴를 (상위·메뉴명·URL) 평탄 목록으로.
*
* @return list<array{parent:string,name:string,url:string}>
*/
private function buildMenuFlat(): array
{
if (! function_exists('gov_portal_nav_context')) {
return [];
}
$flat = [];
foreach (gov_portal_nav_context(false)['navItems'] as $parent) {
$pName = (string) ($parent['name'] ?? '');
if (! empty($parent['children'])) {
foreach ($parent['children'] as $child) {
$url = (string) ($child['url'] ?? '');
if ($url === '') {
continue;
}
$flat[] = ['parent' => $pName, 'name' => (string) ($child['name'] ?? ''), 'url' => $url];
}
} elseif (! empty($parent['url'])) {
$flat[] = ['parent' => '', 'name' => $pName, 'url' => (string) $parent['url']];
}
}
return $flat;
}
/** /**
* 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문 * 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문
*/ */
@@ -376,9 +439,14 @@ class Home extends BaseController
protected function resolveLgLabel(): string protected function resolveLgLabel(): string
{ {
try { try {
$idx = session()->get('mb_lg_idx'); helper('admin');
if ($idx === null || $idx === '') { $idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
return '로그인 지자체 (미지정)'; if ($idx === null) {
$raw = session()->get('mb_lg_idx');
$idx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
if ($idx === null) {
return '지자체 미지정';
} }
$row = model(LocalGovernmentModel::class)->find((int) $idx); $row = model(LocalGovernmentModel::class)->find((int) $idx);
if ($row && isset($row->lg_name) && $row->lg_name !== '') { if ($row && isset($row->lg_name) && $row->lg_name !== '') {
@@ -388,7 +456,7 @@ class Home extends BaseController
// 테이블 미생성 등 // 테이블 미생성 등
} }
return '북구 (데모)'; return '지자체';
} }
} }

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/**
* 메인 대시보드 카카오 지도 패널 — 지도(좌) + 지정판매소 목록(우, 스크롤).
* 주소→좌표는 카카오 지오코딩(services)으로 클라이언트 변환. 목록 클릭 시 해당 위치로 이동.
*
* @var string $kakaoJsKey
* @var string $lgLabel
* @var array<int,array{name:string,addr:string}> $mapShops
*/
$kakaoJsKey = (string) ($kakaoJsKey ?? '');
$lgLabel = (string) ($lgLabel ?? '');
$mapShops = is_array($mapShops ?? null) ? $mapShops : [];
$mapId = 'mainKakaoMap';
?>
<div class="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-100">
<h2 class="text-sm font-bold text-gray-900"><i class="fa-solid fa-map-location-dot text-[#243a5e] mr-1"></i>지정판매소 위치<?= $lgLabel !== '' ? ' · ' . esc($lgLabel) : '' ?> <span class="text-[11px] font-normal text-gray-400">(<?= count($mapShops) ?>곳)</span></h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_240px]">
<!-- 지도 -->
<div id="<?= $mapId ?>" style="width:100%;height:200px;background:#eef2f7;" role="application" aria-label="<?= esc($lgLabel, 'attr') ?> 지정판매소 지도"></div>
<!-- 판매소 목록 (스크롤) -->
<div class="border-t md:border-t-0 md:border-l border-gray-100 overflow-y-auto" style="height:200px;">
<?php if ($mapShops === []): ?>
<p class="p-3 text-[12px] text-gray-400">표시할 지정판매소가 없습니다.</p>
<?php else: ?>
<ul id="shopList" class="divide-y divide-gray-50">
<?php foreach ($mapShops as $i => $shop): ?>
<li>
<button type="button" data-idx="<?= (int) $i ?>"
class="shop-item w-full text-left px-3 py-2 hover:bg-blue-50/60 transition flex gap-2 items-start">
<span class="shop-dot mt-1 inline-block w-2 h-2 rounded-full bg-gray-300 shrink-0"></span>
<span class="min-w-0">
<span class="block text-[12px] font-semibold text-gray-800 truncate"><?= esc($shop['name'] !== '' ? $shop['name'] : '(이름없음)') ?></span>
<span class="block text-[11px] text-gray-500 truncate"><?= esc($shop['addr']) ?></span>
</span>
</button>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<?php if ($kakaoJsKey === ''): ?>
<div class="px-4 py-2 text-[11px] text-amber-700 bg-amber-50 border-t border-amber-200">카카오맵 키가 설정되지 않아 지도를 표시할 수 없습니다.</div>
<?php endif; ?>
</div>
<?php if ($kakaoJsKey !== ''): ?>
<script>
(function () {
var APP_KEY = <?= json_encode($kakaoJsKey, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var SHOPS = <?= json_encode($mapShops, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
var MAP_ID = <?= json_encode($mapId, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var DEFAULT_CENTER = { lat: 35.8714, lng: 128.6014 };
var markers = {}; // idx -> kakao.maps.Marker
var positions = {}; // idx -> kakao.maps.LatLng
var mapRef = null, infowindow = null, geocoder = null, places = null;
function ensureScript(cb) {
if (typeof kakao !== 'undefined' && kakao.maps && kakao.maps.services) { cb(); return; }
var s = document.createElement('script');
s.charset = 'UTF-8'; s.async = true;
s.src = 'https://dapi.kakao.com/v2/maps/sdk.js?appkey=' + encodeURIComponent(APP_KEY) + '&libraries=services&autoload=false';
s.onload = function () {
if (typeof kakao === 'undefined' || !kakao.maps || typeof kakao.maps.load !== 'function') { return; }
kakao.maps.load(cb);
};
document.head.appendChild(s);
}
// "대구광역시 북구 검단동 칠곡중앙대로 21" → "대구광역시 북구 칠곡중앙대로 21" (행정동 토큰 제거)
function roadVariant(addr) {
var p = String(addr || '').trim().split(/\s+/);
if (p.length < 4) return '';
var roadIdx = -1;
for (var i = 2; i < p.length; i++) { if (/(로|길)$/.test(p[i])) { roadIdx = i; break; } }
if (roadIdx < 3) return '';
var out = [];
p.forEach(function (tok, i) {
if (i >= 2 && i < roadIdx && /(동|가|리)$/.test(tok)) return; // 행정동 제거
out.push(tok);
});
var joined = out.join(' ');
return joined !== addr ? joined : '';
}
// "대구광역시 북구 검단동 ..." → "대구광역시 북구 검단동" (시도 구군 동 — 행정구역 폴백)
function regionVariant(addr) {
var p = String(addr || '').trim().split(/\s+/);
if (p.length < 3) return '';
for (var i = 2; i < p.length; i++) {
if (/(동|가|리)$/.test(p[i])) { return p[0] + ' ' + p[1] + ' ' + p[i]; }
}
return p[0] + ' ' + p[1]; // 동을 못 찾으면 구·군까지
}
// 여러 후보를 순서대로 시도(정밀주소 → 도로명변형 → 지번 → 키워드 → 행정동 → 행정동 키워드)
function geocodeChain(shop, cb) {
var tries = [];
if (shop.addr) tries.push(['addr', shop.addr]);
var rv = roadVariant(shop.addr);
if (rv) tries.push(['addr', rv]);
if (shop.jibun) tries.push(['addr', shop.jibun]);
if (rv) tries.push(['kw', rv]);
else if (shop.addr) tries.push(['kw', shop.addr]);
var region = regionVariant(shop.addr || shop.jibun);
if (region) { tries.push(['addr', region]); tries.push(['kw', region]); }
(function next(i) {
if (i >= tries.length) { cb(null); return; }
var mode = tries[i][0], q = tries[i][1];
if (mode === 'addr') {
geocoder.addressSearch(q, function (result, status) {
if (status === kakao.maps.services.Status.OK && result && result[0]) {
cb(new kakao.maps.LatLng(result[0].y, result[0].x));
} else { next(i + 1); }
});
} else {
places.keywordSearch(q, function (data, status) {
if (status === kakao.maps.services.Status.OK && data && data[0]) {
cb(new kakao.maps.LatLng(data[0].y, data[0].x));
} else { next(i + 1); }
});
}
})(0);
}
function openInfo(idx) {
var name = (SHOPS[idx] && SHOPS[idx].name) || '판매소';
infowindow.setContent('<div style="padding:5px 8px;font-size:12px;font-weight:600;white-space:nowrap;">' + name + '</div>');
if (markers[idx]) infowindow.open(mapRef, markers[idx]);
}
function focusShop(idx) {
var pos = positions[idx];
if (!pos || !mapRef) return;
mapRef.setCenter(pos);
mapRef.setLevel(3); // 줌인
openInfo(idx);
}
function initMap() {
var el = document.getElementById(MAP_ID);
if (!el || typeof kakao === 'undefined' || !kakao.maps) return;
var map = new kakao.maps.Map(el, { center: new kakao.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng), level: 8 });
mapRef = map;
infowindow = new kakao.maps.InfoWindow({ zIndex: 1 });
geocoder = new kakao.maps.services.Geocoder();
places = new kakao.maps.services.Places();
var bounds = new kakao.maps.LatLngBounds();
var placed = 0, pending = SHOPS.length;
function done() {
if (placed > 0) map.setBounds(bounds);
setTimeout(function () { map.relayout(); if (placed > 0) map.setBounds(bounds); }, 150);
}
if (pending === 0) { done(); return; }
SHOPS.forEach(function (shop, idx) {
geocodeChain(shop, function (pos) {
pending--;
if (pos) {
var marker = new kakao.maps.Marker({ position: pos, map: map });
markers[idx] = marker;
positions[idx] = pos;
bounds.extend(pos); placed++;
kakao.maps.event.addListener(marker, 'click', function () { openInfo(idx); });
var dot = document.querySelector('.shop-item[data-idx="' + idx + '"] .shop-dot');
if (dot) { dot.style.background = '#243a5e'; }
}
if (pending === 0) done();
});
});
// 목록 클릭 → 해당 판매소로 줌인
document.querySelectorAll('.shop-item').forEach(function (btn) {
btn.addEventListener('click', function () { focusShop(parseInt(btn.getAttribute('data-idx'), 10)); });
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { ensureScript(initMap); });
} else {
ensureScript(initMap);
}
})();
</script>
<?php endif; ?>

View File

@@ -64,6 +64,147 @@ $donutCss = $donutStops !== [] ? implode(', ', $donutStops) : '#e5e7eb 0% 100%';
</div> </div>
</section> </section>
<!-- 지도(2/3) + 메뉴검색(1/3) -->
<section class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch">
<div class="lg:col-span-2">
<?= view('bag/_dashboard_kakao_map', [
'kakaoJsKey' => $kakaoJsKey ?? '',
'lgLabel' => $lgLabel,
'mapShops' => $mapShops ?? [],
]) ?>
</div>
<div class="lg:col-span-1 flex">
<!-- 메뉴검색 (자동완성) -->
<div class="rounded-xl bg-[#009688] text-white p-4 shadow-sm w-full h-full flex flex-col">
<strong class="text-sm font-bold flex items-center gap-1.5"><i class="fa-solid fa-magnifying-glass"></i> 메뉴검색</strong>
<div class="relative mt-2" id="menuSearchWrap">
<input type="search" id="mainMenuSearch" autocomplete="off" placeholder="메뉴명 입력 (예: 재고, 발주, 통계)"
class="w-full rounded-lg px-3 py-2 pr-9 text-sm text-gray-800 border-0 focus:outline-none focus:ring-2 focus:ring-white/50"/>
<i class="fa-solid fa-magnifying-glass absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none"></i>
<!-- 자동완성 목록 -->
<ul id="mainMenuSearchList" class="hidden absolute left-0 right-0 top-full mt-1 z-30 max-h-60 overflow-y-auto bg-white text-gray-800 rounded-lg shadow-lg border border-gray-200 py-1"></ul>
</div>
<!-- 최근 방문 메뉴 (기본 표시) — 높이 고정·스크롤 -->
<div id="recentMenus" class="mt-3 overflow-y-auto" style="max-height:105px;"></div>
<p class="text-[11px] text-white/70 pt-2 mt-auto shrink-0">메뉴명을 입력하면 자동완성에서 선택하거나, 전체 이름 입력 후 Enter 로 이동합니다.</p>
</div>
</div>
</section>
<script>
(function () {
var FLAT = <?= json_encode($menuFlat ?? [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
var input = document.getElementById('mainMenuSearch');
var list = document.getElementById('mainMenuSearchList');
if (!input || !list) return;
var active = -1, current = [];
function norm(s) { return String(s || '').toLowerCase().replace(/\s+/g, ''); }
function esc(s) { return String(s || '').replace(/</g, '&lt;'); }
function pathOf(u) { try { return (new URL(u, location.origin).pathname || '').replace(/\/+$/, '') || '/'; } catch (e) { return u; } }
// 최근 방문 메뉴 (localStorage 기록을 menuFlat 메뉴로 매핑)
function recentMenuList() {
var arr = [];
try { arr = JSON.parse(localStorage.getItem('jrj_recent_menus') || '[]'); } catch (e) {}
if (!Array.isArray(arr)) return [];
var out = [], seen = {};
arr.forEach(function (x) {
var p = x && x.p;
if (!p) return;
var m = FLAT.find(function (f) { return pathOf(f.url) === p; })
|| FLAT.find(function (f) { var fp = pathOf(f.url); return p.indexOf(fp + '/') === 0; });
if (m && !seen[m.url]) { seen[m.url] = 1; out.push(m); }
});
return out.slice(0, 8);
}
function renderRecent() {
var box = document.getElementById('recentMenus');
if (!box) return;
var r = recentMenuList();
if (!r.length) {
box.innerHTML = '<p class="text-[11px] text-white/55 mt-1">최근 방문한 메뉴가 여기에 표시됩니다.</p>';
return;
}
var html = '<p class="text-[11px] text-white/70 mb-1.5"><i class="fa-regular fa-clock mr-1"></i>최근 방문 메뉴</p>';
r.forEach(function (m) {
html += '<a href="' + m.url + '" class="block text-[12px] px-2 py-1.5 rounded bg-white/12 hover:bg-white/25 mb-1 truncate" title="' + esc(m.name) + '">' + esc(m.name) +
(m.parent ? ' <span class="text-white/55">· ' + esc(m.parent) + '</span>' : '') + '</a>';
});
box.innerHTML = html;
}
function matches(q) {
var nq = norm(q);
if (!nq) return [];
return FLAT.filter(function (m) {
return norm(m.name).indexOf(nq) !== -1 || norm(m.parent + m.name).indexOf(nq) !== -1;
}).slice(0, 10);
}
function render(q) {
current = matches(q); active = -1;
list.innerHTML = '';
if (!current.length) { list.classList.add('hidden'); return; }
current.forEach(function (m, i) {
var li = document.createElement('li');
li.setAttribute('data-url', m.url);
li.setAttribute('data-i', i);
li.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-blue-50 flex flex-col';
li.innerHTML = '<span class="font-semibold text-gray-800">' +
(m.name || '').replace(/</g, '&lt;') + '</span>' +
(m.parent ? '<span class="text-[11px] text-gray-400">' + m.parent.replace(/</g, '&lt;') + '</span>' : '');
list.appendChild(li);
});
list.classList.remove('hidden');
}
function go(url) { if (url) window.location.href = url; }
function highlight() {
Array.prototype.forEach.call(list.children, function (li, i) {
li.classList.toggle('bg-blue-50', i === active);
});
}
input.addEventListener('input', function () { render(input.value); });
input.addEventListener('focus', function () { if (input.value.trim()) render(input.value); });
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); if (current.length) { active = (active + 1) % current.length; highlight(); } return; }
if (e.key === 'ArrowUp') { e.preventDefault(); if (current.length) { active = (active - 1 + current.length) % current.length; highlight(); } return; }
if (e.key === 'Escape') { list.classList.add('hidden'); return; }
if (e.key === 'Enter') {
e.preventDefault();
var q = input.value.trim();
if (active >= 0 && current[active]) { go(current[active].url); return; }
// 전체 이름 정확히 일치 우선
var exact = FLAT.filter(function (m) { return norm(m.name) === norm(q); });
if (exact.length) { go(exact[0].url); return; }
if (current.length) { go(current[0].url); return; }
var any = matches(q);
if (any.length) { go(any[0].url); return; }
alert('일치하는 메뉴가 없습니다.');
}
});
list.addEventListener('mousedown', function (e) {
var li = e.target.closest('li[data-url]');
if (li) { e.preventDefault(); go(li.getAttribute('data-url')); }
});
document.addEventListener('click', function (e) {
if (!document.getElementById('menuSearchWrap').contains(e.target)) { list.classList.add('hidden'); }
});
renderRecent(); // 기본: 최근 방문 메뉴 표시
// 뒤로가기(bfcache 복원)·탭 복귀 시에도 최근 목록 갱신
window.addEventListener('pageshow', renderRecent);
document.addEventListener('visibilitychange', function () { if (!document.hidden) renderRecent(); });
})();
</script>
<section class="grid grid-cols-1 lg:grid-cols-3 gap-4"> <section class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<?php if ($stockMix !== []): ?> <?php if ($stockMix !== []): ?>
<!-- 재고 구성 --> <!-- 재고 구성 -->

View File

@@ -137,6 +137,21 @@ tailwind.config = {
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?> <?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<script> <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>
(() => { (() => {
// 표의 '번호' 컬럼 역순 자동 채번 (기존 사이트 레이아웃 계승) // 표의 '번호' 컬럼 역순 자동 채번 (기존 사이트 레이아웃 계승)
const normalize = (s) => String(s || '').replace(/\s+/g, '').trim(); const normalize = (s) => String(s || '').replace(/\s+/g, '').trim();