지정판매소 현황·바코드 출력 기능을 전용 화면으로 확장

지정판매소 신규/취소 현황을 사용자 지자체 기준으로 고정 조회하도록 정리하고, 동별 요약과 컬럼 설명 툴팁을 추가했습니다.
또한 지정판매소 바코드 출력 메뉴를 전용 URL로 분리하고 선택 인쇄/출력 레이아웃을 GBMS 형태에 맞춰 구현했습니다.
This commit is contained in:
taekyoungc
2026-04-14 00:14:53 +09:00
parent 72578f200c
commit 734a55833b
13 changed files with 2160 additions and 167 deletions

View File

@@ -83,7 +83,13 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('designated-shops/export', 'Admin\DesignatedShop::export');
$routes->get('designated-shops/map', 'Admin\DesignatedShop::map');
$routes->get('designated-shops/status/export', 'Admin\DesignatedShop::statusExport');
$routes->get('designated-shops/status', 'Admin\DesignatedShop::status');
$routes->get('designated-shops/barcode', 'Admin\DesignatedShop::barcode');
$routes->post('designated-shops/barcode/print', 'Admin\DesignatedShop::barcodePrint');
$routes->get('designated-shops/district-new-cancel/export', 'Admin\DesignatedShop::districtNewCancelExport');
$routes->get('designated-shops/district-new-cancel', 'Admin\DesignatedShop::districtNewCancel');
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 바코드 출력']) ?>
<style>
.ds-bc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.ds-bc-table th, .ds-bc-table td { border: 1px solid #ccc; padding: 4px 6px; }
.ds-bc-table th { background: #e9ecef; color: #2d3748; }
.ds-bc-table td { background: #fff; }
.ds-bc-table td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-table td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-check { width: 14px; height: 14px; }
</style>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 바코드 출력</span>
<div class="flex items-center gap-2">
<button type="button" id="ds-bc-print-btn" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">인쇄</button>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form id="ds-bc-filter-form" method="get" action="<?= mgmt_url('designated-shops/barcode') ?>" class="flex flex-wrap items-end gap-3">
<div class="min-w-[12rem]">
<label class="block text-xs text-gray-600 mb-0.5">군·구</label>
<div class="border border-gray-300 rounded px-3 py-1 text-sm bg-gray-50 text-gray-800 font-medium pointer-events-none select-none">
<?= esc($fixedGugunLabel ?? '현재 지자체') ?>
</div>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">읍·면·동</label>
<select name="ds_zone_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="">전체</option>
<?php foreach (($zones ?? []) as $z): ?>
<?php $zc = trim((string) ($z->zone_code ?? '')); ?>
<option value="<?= esc($zc) ?>" <?= ($zoneFilter ?? '') === $zc ? 'selected' : '' ?>><?= esc($zc) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">조회순서</label>
<select name="order_by" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="shop_no" <?= ($orderBy ?? 'shop_no') === 'shop_no' ? 'selected' : '' ?>>판매소 코드</option>
<option value="name" <?= ($orderBy ?? '') === 'name' ? 'selected' : '' ?>>판매소명</option>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<section class="mx-2 mt-2 mb-2">
<form id="ds-bc-print-form" method="post" action="<?= mgmt_url('designated-shops/barcode/print') ?>" target="ds-bc-print-frame">
<?= csrf_field() ?>
<input type="hidden" name="zone_label" value="<?= esc(($zoneFilter ?? '') !== '' ? (string) $zoneFilter : '전체') ?>">
<div class="mb-1 text-xs text-gray-600">
<label class="inline-flex items-center gap-1 cursor-pointer"><input type="checkbox" id="ds-bc-check-all" class="ds-bc-check"> 전체선택</label>
<span class="ml-3">선택 건수: <strong id="ds-bc-selected-count">0</strong></span>
</div>
<div class="overflow-auto border border-gray-300 bg-white">
<table class="ds-bc-table">
<thead>
<tr>
<th class="w-14">출력</th>
<th class="w-36">판매소 코드</th>
<th>판매소명</th>
<th class="w-24">대표자명</th>
<th class="w-32">사업자번호</th>
<th>사업장 주소</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($list ?? []) as $row): ?>
<?php
$st = (int) ($row->ds_state ?? 1);
$stLabel = $st === 1 ? '사용' : '정지';
?>
<tr>
<td class="text-center"><input class="ds-bc-row-check ds-bc-check" type="checkbox" name="ds_idx[]" value="<?= (int) $row->ds_idx ?>"></td>
<td class="text-center text-blue-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td>
<td class="name-cell text-blue-700" title="<?= esc((string) ($row->ds_name ?? '')) ?>"><?= esc((string) ($row->ds_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_biz_no ?? '')) ?></td>
<td class="addr-cell" title="<?= esc((string) ($row->ds_addr ?? '')) ?>"><?= esc((string) ($row->ds_addr ?? '')) ?></td>
<td class="<?= $st === 1 ? 'text-blue-700' : 'text-red-600' ?>"><?= esc($stLabel) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-8">조회된 지정판매소가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</form>
<iframe name="ds-bc-print-frame" class="hidden" style="display:none;width:0;height:0;border:0;" aria-hidden="true"></iframe>
</section>
<?php if (isset($pager)): ?>
<div class="mt-2 mb-2 mx-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?>
<script>
(function () {
var all = document.getElementById('ds-bc-check-all');
var countEl = document.getElementById('ds-bc-selected-count');
var printBtn = document.getElementById('ds-bc-print-btn');
var printForm = document.getElementById('ds-bc-print-form');
var rows = Array.prototype.slice.call(document.querySelectorAll('.ds-bc-row-check'));
if (!all || !countEl || !rows.length) return;
function refreshCount() {
var n = rows.filter(function (el) { return el.checked; }).length;
countEl.textContent = String(n);
all.checked = n > 0 && n === rows.length;
all.indeterminate = n > 0 && n < rows.length;
}
all.addEventListener('change', function () {
rows.forEach(function (el) { el.checked = all.checked; });
refreshCount();
});
rows.forEach(function (el) { el.addEventListener('change', refreshCount); });
if (printBtn && printForm) {
printBtn.addEventListener('click', function () {
var selected = rows.filter(function (el) { return el.checked; }).length;
if (selected < 1) {
window.alert('출력할 지정판매소를 1개 이상 선택해 주세요.');
return;
}
printForm.action = "<?= esc(mgmt_url('designated-shops/barcode/print')) ?>?autoprint=1";
printForm.submit();
});
}
refreshCount();
})();
</script>

View File

@@ -0,0 +1,94 @@
<?php
$rows = $rows ?? [];
$zoneLabel = trim((string) ($zoneLabel ?? '전체'));
$printedAt = trim((string) ($printedAt ?? date('Y.m.d')));
$chunks = array_chunk($rows, 12);
$totalPages = count($chunks);
?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>지정판매소 바코드</title>
<style>
body { margin: 0; font-family: Arial, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; color: #222; background: #fff; }
.page { width: 210mm; min-height: 297mm; margin: 0 auto; padding: 14mm 12mm 12mm; box-sizing: border-box; }
.title { text-align: center; font-size: 42px; letter-spacing: 1px; font-weight: 500; margin: 0 0 14px; }
.meta { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555; padding-bottom: 4px; font-size: 13px; margin-bottom: 8px; }
.meta .center { font-weight: 700; }
.cards { display: flex; flex-wrap: wrap; align-content: flex-start; }
.card { width: 33.3333%; padding: 0 8px 12px; box-sizing: border-box; }
.barcode-wrap { min-height: 40px; }
.barcode-svg { width: 100%; max-width: 270px; height: 22px; }
.code-text { text-align: center; margin-top: 1px; font-size: 16px; letter-spacing: 0.35px; }
.name-text { text-align: center; margin-top: 5px; font-size: 14px; line-height: 1.2; word-break: keep-all; }
@media print {
@page { size: A4; margin: 0; }
.page { page-break-after: always; }
.page:last-child { page-break-after: auto; }
}
</style>
</head>
<body>
<?php if ($rows === []): ?>
<div class="page">
<h1 class="title">지정판매소 바코드</h1>
<p style="text-align:center; margin-top:30px; color:#666;">출력할 지정판매소가 없습니다.</p>
</div>
<?php else: ?>
<?php foreach ($chunks as $pageIndex => $pageRows): ?>
<section class="page">
<h1 class="title">지정판매소 바코드</h1>
<div class="meta">
<span>출 력 일 자: <?= esc($printedAt) ?></span>
<span class="center"><?= esc($zoneLabel) ?></span>
<span>페&nbsp;&nbsp;이&nbsp;&nbsp;지: <?= (int) ($pageIndex + 1) ?> / <?= (int) $totalPages ?></span>
</div>
<div class="cards">
<?php foreach ($pageRows as $row): ?>
<?php
$code = trim((string) ($row->ds_shop_no ?? ''));
$nm = trim((string) ($row->ds_name ?? ''));
$rep = trim((string) ($row->ds_rep_name ?? ''));
$label = trim($nm . ($rep !== '' ? ('-' . $rep) : ''));
?>
<div class="card">
<div class="barcode-wrap">
<svg class="barcode-svg" data-barcode="<?= esc($code, 'attr') ?>"></svg>
</div>
<div class="code-text"><?= esc($code) ?></div>
<div class="name-text"><?= esc($label) ?></div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
<?php endif; ?>
<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var svgs = document.querySelectorAll('svg[data-barcode]');
svgs.forEach(function (svg) {
var code = (svg.getAttribute('data-barcode') || '').trim();
if (!code) return;
try {
JsBarcode(svg, code, {
format: 'CODE128',
displayValue: false,
margin: 0,
height: 16,
width: 1.28
});
} catch (e) {
svg.outerHTML = '<div style="font-size:12px;color:#b91c1c;">바코드 생성 실패: ' + code + '</div>';
}
});
if (window.location.search.indexOf('autoprint=1') >= 0) {
setTimeout(function () { window.print(); }, 200);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,210 @@
<?php
$ry = (int) ($reportYear ?? (int) date('Y'));
$lg = $currentLg ?? null;
$lgSido = $lg !== null ? trim((string) ($lg->lg_sido ?? '')) : '';
$lgGugun = $lg !== null ? trim((string) ($lg->lg_gugun ?? '')) : '';
$lgName = $lg !== null ? trim((string) ($lg->lg_name ?? '')) : '';
$scopeLabel = $lgSido !== '' && $lgGugun !== ''
? $lgSido . ' ' . $lgGugun
: ($lgName !== '' ? $lgName : '—');
$exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . http_build_query(['year' => $ry]);
?>
<?= view('components/print_header', ['printTitle' => '지정 판매소 신규/취소 현황 (' . $ry . '년)']) ?>
<style>
.gbms-dnc-wrap { max-width: 100%; }
.gbms-dnc-table { border-collapse: collapse; width: 100%; font-size: 13px; }
.gbms-dnc-table th,
.gbms-dnc-table td {
border: 1px solid #7a8aa0;
padding: 6px 10px;
text-align: center;
}
.gbms-dnc-table thead th {
background: linear-gradient(180deg, #e8eef6 0%, #d4dee9 100%);
font-weight: 700;
color: #1a2a3a;
}
.gbms-dnc-table thead th.gbms-sub {
background: #dce6f0;
font-weight: 600;
}
.gbms-dnc-table tbody td.text-left { text-align: left; }
.gbms-dnc-table tbody tr.gbms-total td {
font-weight: 700;
border: 2px solid #c62828;
background: #fff8f8;
}
.gbms-dnc-caption {
font-size: 13px;
font-weight: 700;
margin: 8px 0 6px;
color: #1a2a3a;
}
.gbms-unit-pill {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #0d47a1;
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 2px;
}
.gbms-tip {
position: relative;
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
}
.gbms-help {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 2px;
border: 1px solid #5c6f85;
border-radius: 999px;
background: #fef9c3;
color: #111827;
font-size: 11px;
font-weight: 700;
line-height: 1;
font-family: Arial, sans-serif;
user-select: none;
cursor: help;
}
.gbms-help::after {
content: attr(data-tip);
position: absolute;
left: 50%;
top: calc(100% + 6px);
transform: translateX(-50%);
display: none;
min-width: 12rem;
max-width: 14rem;
padding: 6px 8px;
border: 1px solid #8fa0b3;
border-radius: 4px;
background: #1f2937;
color: #fff;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: left;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
z-index: 30;
}
.gbms-help:hover::after,
.gbms-help:focus::after {
display: block;
}
@media print {
.gbms-dnc-table { font-size: 11px; }
.gbms-help { display: none !important; }
}
</style>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-800">[지정 판매소 신규/취소 현황]</span>
<div class="flex items-center gap-2">
<a href="<?= esc($exportUrl) ?>" class="border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록</a>
</div>
</div>
</section>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('designated-shops/district-new-cancel') ?>" class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-xs text-gray-600 mb-0.5">조회년도</label>
<select name="year" class="border border-gray-400 rounded px-2 py-1.5 text-sm min-w-[7rem] bg-white">
<?php foreach (($yearChoices ?? []) as $y): ?>
<option value="<?= (int) $y ?>" <?= $ry === (int) $y ? 'selected' : '' ?>><?= (int) $y ?>년</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex-1 min-w-[12rem]">
<span class="block text-xs text-gray-600 mb-0.5">군·구 (소속 지자체)</span>
<div class="border border-gray-300 rounded px-3 py-1.5 text-sm bg-gray-50 text-gray-800 font-medium">
<?= esc($scopeLabel) ?>
</div>
</div>
<span class="gbms-unit-pill self-end mb-0.5">단위: 판매소</span>
<button type="submit" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm font-medium shadow-sm hover:opacity-90">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2">
종전·현행은 각각 <?= $ry - 1 ?>-12-31·<?= $ry ?>-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 <?= $ry ?>년 달력연도 기준입니다. 구·군 행은 효과 지자체의 기본코드(구 코드) 순서로 표시됩니다.
</p>
</section>
<div class="mx-2 mt-3 mb-4 gbms-dnc-wrap">
<div class="gbms-dnc-caption">지정 판매소 신규/취소 현황 조회 내역</div>
<div class="overflow-x-auto border border-gray-400 bg-white">
<table class="gbms-dnc-table">
<thead>
<tr>
<th rowspan="2" class="min-w-[6rem]">군·구</th>
<th rowspan="2">
<span class="gbms-tip">
종전
<span class="gbms-help" tabindex="0" aria-label="종전 설명" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span>
</span>
<br><span class="text-xs font-normal">(전년도말)</span>
</th>
<th colspan="2">사용</th>
<th rowspan="2">
<span class="gbms-tip">
현행
<span class="gbms-help" tabindex="0" aria-label="현행 설명" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span>
</span>
<br><span class="text-xs font-normal">(금년도말)</span>
</th>
</tr>
<tr>
<th class="gbms-sub">
<span class="gbms-tip">
지정
<span class="gbms-help" tabindex="0" aria-label="지정 설명" data-tip="조회년도 내 지정일이 속한 신규 지정 건수">?</span>
</span>
</th>
<th class="gbms-sub">
<span class="gbms-tip">
취소
<span class="gbms-help" tabindex="0" aria-label="취소 설명" data-tip="조회년도 내 폐업/해지 전환일이 속한 건수">?</span>
</span>
</th>
</tr>
</thead>
<tbody>
<?php foreach (($districtRows ?? []) as $row): ?>
<tr>
<td class="text-left font-medium"><?= esc($row->region_label) ?></td>
<td><?= number_format((int) $row->prev_end) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format((int) $row->curr_end) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($districtRows)): ?>
<tr>
<td colspan="5" class="text-center text-gray-500 py-8">표시할 구·군 또는 지정판매소 데이터가 없습니다.</td>
</tr>
<?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="gbms-total">
<td class="text-left"><?= esc($districtTotal->region_label) ?></td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -1,80 +1,387 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
$ry = (int) ($reportYear ?? (int) date('Y'));
$exportUrl = mgmt_url('designated-shops/status/export') . '?' . http_build_query([
'year' => $ry,
]);
$fixedGugunLabel = trim((string) ($fixedGugunLabel ?? ''));
$regionColLabel = '군·구';
$sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
?>
<style>
.ds-status-x-scroll {
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
max-width: 100%;
}
@media print {
.ds-status-x-scroll { overflow: visible !important; border: none; }
}
.ds-status-x-scroll .ds-status-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
}
.ds-status-x-scroll .ds-status-table th,
.ds-status-x-scroll .ds-status-table td {
white-space: nowrap;
padding: 6px 10px;
font-size: 12px;
}
.ds-status-x-scroll .ds-status-table thead th {
background: #e9ecef;
border: 1px solid #ccc;
}
.ds-status-x-scroll .ds-status-table tbody td {
border: 1px solid #ccc;
}
.ds-status-x-scroll th.sticky-num,
.ds-status-x-scroll td.sticky-num {
position: sticky;
left: 0;
z-index: 3;
min-width: 3rem;
max-width: 3rem;
width: 3rem;
box-sizing: border-box;
background: #e9ecef;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
}
.ds-status-x-scroll td.sticky-num {
background: #fff;
text-align: center;
}
.ds-status-x-scroll tr.sum-row td.sticky-num {
background: #f3f4f6;
}
.ds-status-x-scroll th.sticky-region,
.ds-status-x-scroll td.sticky-region {
position: sticky;
left: 3rem;
z-index: 2;
background: #e9ecef;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
max-width: 16rem;
text-align: left;
}
.ds-status-x-scroll td.sticky-region {
background: #fff;
overflow: hidden;
text-overflow: ellipsis;
}
.ds-status-x-scroll tr.sum-row td.sticky-region {
background: #f3f4f6;
}
.ds-help {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
}
.ds-help-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 2px;
border: 1px solid #64748b;
border-radius: 999px;
background: #fef9c3;
color: #111827;
font-size: 11px;
font-weight: 700;
line-height: 1;
font-family: Arial, sans-serif;
cursor: help;
user-select: none;
}
.ds-floating-tip {
position: fixed;
left: 0;
top: 0;
display: none;
max-width: min(22rem, calc(100vw - 16px));
padding: 6px 8px;
border: 1px solid #8fa0b3;
border-radius: 4px;
background: #1f2937;
color: #fff;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: left;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
z-index: 9999;
pointer-events: none;
}
</style>
<?= view('components/print_header', ['printTitle' => '지정판매소 신규·취소 현황 (' . $ry . '년)']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 현황 (신규/취소)</span>
<div class="flex items-center gap-2">
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($exportUrl) ?>" class="border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a>
</div>
</div>
</section>
<!-- 전체 현황 요약 -->
<div class="flex gap-4 mt-2 mb-2">
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">활성 판매소</div>
<div class="text-2xl font-bold text-green-600"><?= number_format($totalActive) ?></div>
</div>
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">비활성/취소 판매소</div>
<div class="text-2xl font-bold text-red-600"><?= number_format($totalInactive) ?></div>
</div>
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">전체</div>
<div class="text-2xl font-bold text-gray-700"><?= number_format($totalActive + $totalInactive) ?></div>
</div>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('designated-shops/status') ?>" class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs text-gray-600 mb-0.5">연도</label>
<select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[6rem]">
<?php foreach (($yearChoices ?? []) as $y): ?>
<option value="<?= (int) $y ?>" <?= $ry === (int) $y ? 'selected' : '' ?>><?= (int) $y ?>년</option>
<?php endforeach; ?>
</select>
</div>
<div class="min-w-[12rem]">
<label class="block text-xs text-gray-600 mb-0.5">군·구</label>
<div class="border border-gray-300 rounded px-3 py-1 text-sm bg-gray-50 text-gray-800 font-medium pointer-events-none select-none">
<?= esc($fixedGugunLabel !== '' ? $fixedGugunLabel : '현재 지자체 기준') ?>
</div>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2">
종전·현행은 각각 <?= $ry - 1 ?>-12-31·<?= $ry ?>-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 <?= $ry ?>년 달력연도 기준입니다. 군·구는 현재 로그인 사용자의 지자체 기준으로 고정 표시됩니다.
</p>
</section>
<!-- 인쇄 시에도 보이는 본표 -->
<div class="mx-2 mt-2 mb-2 ds-status-x-scroll">
<table class="ds-status-table data-table">
<thead>
<tr>
<th class="sticky-num text-center w-12">순번</th>
<th class="sticky-region"><?= esc($regionColLabel) ?></th>
<th class="text-left">
<span class="ds-help">구코드 <span class="ds-help-badge" tabindex="0" data-tip="지정판매소에 저장된 구·군 코드 값">?</span></span>
</th>
<th class="text-right">
<span class="ds-help">종전 <span class="ds-help-badge" tabindex="0" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(전년도말)
</th>
<th class="text-right">
<span class="ds-help">지정 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 지정일이 속한 신규 지정 건수">?</span></span>(<?= $ry ?>년)
</th>
<th class="text-right">
<span class="ds-help">취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 폐업/해지 전환일이 속한 건수">?</span></span>(<?= $ry ?>년)
</th>
<th class="text-right">
<span class="ds-help">현행 <span class="ds-help-badge" tabindex="0" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(금년도말)
</th>
<th class="text-right">
<span class="ds-help">증감 <span class="ds-help-badge" tabindex="0" data-tip="현행에서 종전을 뺀 값 (현행−종전)">?</span></span>
<br/><span class="font-normal text-xs">(현행−종전)</span>
</th>
<th class="text-right">
<span class="ds-help">지정−취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 지정 건수에서 취소 건수를 뺀 값">?</span></span>
<br/><span class="font-normal text-xs">(<?= $ry ?>년)</span>
</th>
<th class="text-right">
<span class="ds-help">현행비중 <span class="ds-help-badge" tabindex="0" data-tip="전체 현행 합계 대비 해당 행 현행 건수의 비율(%)">?</span></span>
<br/><span class="font-normal text-xs">(%)</span>
</th>
<th class="text-right">
<span class="ds-help">전년대비 <span class="ds-help-badge ds-help-right" tabindex="0" data-tip="((현행−종전) / 종전) × 100, 종전이 0이면 표시 안함">?</span></span>
<br/><span class="font-normal text-xs">증감률(%)</span>
</th>
</tr>
</thead>
<tbody class="text-right">
<?php $rowNo = 0; ?>
<?php foreach (($districtRows ?? []) as $row): ?>
<?php
$rowNo++;
$curr = (int) $row->curr_end;
$prev = (int) $row->prev_end;
$pctShare = $sumCurrForPct > 0 ? round(($curr / $sumCurrForPct) * 100, 1) : 0.0;
$yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : null;
?>
<tr>
<td class="sticky-num"><?= $rowNo ?></td>
<td class="sticky-region" title="<?= esc($row->region_label) ?>"><?= esc($row->region_label) ?></td>
<td class="text-left text-xs"><?= esc((string) ($row->gugun_code ?? '')) ?></td>
<td><?= number_format($prev) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format($curr) ?></td>
<td><?= number_format((int) ($row->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($row->delta_des_cancel ?? 0)) ?></td>
<td><?= $pctShare ?></td>
<td><?= $yoyPct !== null ? $yoyPct : '—' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($districtRows)): ?>
<tr><td colspan="11" class="text-center text-gray-400 py-6">조건에 맞는 데이터가 없습니다.</td></tr>
<?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="font-bold bg-gray-50 sum-row">
<td class="sticky-num">—</td>
<td class="sticky-region"><?= esc($districtTotal->region_label) ?></td>
<td class="text-left">—</td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
<td><?= number_format((int) ($districtTotal->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($districtTotal->delta_des_cancel ?? 0)) ?></td>
<td>100</td>
<td>
<?php
$tPrev = (int) $districtTotal->prev_end;
$tCurr = (int) $districtTotal->curr_end;
echo $tPrev > 0 ? round((($tCurr - $tPrev) / $tPrev) * 100, 1) : '—';
?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<!-- 연도별 신규등록 -->
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 신규등록 건수</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>연도</th>
<th>신규등록 건수</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($newByYear as $row): ?>
<tr>
<td class="text-center"><?= esc($row->yr) ?>년</td>
<td><?= number_format((int) $row->cnt) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($newByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php $zoneRows = $zoneSummaryRows ?? []; ?>
<section class="mx-2 mb-3 no-print">
<div class="text-xs font-semibold text-gray-700 mb-1">동별 현행 요약 (<?= esc($fixedGugunLabel ?? '군·구') ?>)</div>
<?php if (! empty($zoneRows)): ?>
<div class="flex flex-wrap gap-1 mb-2">
<?php foreach ($zoneRows as $z): ?>
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded border border-gray-300 bg-gray-50 text-gray-700">
<?= esc((string) $z->zone_label) ?> <?= number_format((int) $z->curr_end) ?>
</span>
<?php endforeach; ?>
</div>
<div class="border border-gray-300 bg-white overflow-auto max-h-56">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="text-left">동</th>
<th class="text-right">종전</th>
<th class="text-right">지정</th>
<th class="text-right">취소</th>
<th class="text-right">현행</th>
<th class="text-right">증감</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($zoneRows as $z): ?>
<tr>
<td class="text-left"><?= esc((string) $z->zone_label) ?></td>
<td><?= number_format((int) $z->prev_end) ?></td>
<td><?= number_format((int) $z->designated_y) ?></td>
<td><?= number_format((int) $z->cancelled_y) ?></td>
<td><?= number_format((int) $z->curr_end) ?></td>
<td><?= number_format((int) $z->delta_curr_prev) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p class="text-xs text-gray-500">동별 집계 데이터가 없습니다.</p>
<?php endif; ?>
</section>
<!-- 연도별 취소/비활성 -->
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 취소/비활성 건수</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>연도</th>
<th>취소/비활성 건수</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($cancelByYear as $row): ?>
<tr>
<td class="text-center"><?= esc($row->yr) ?>년</td>
<td><?= number_format((int) $row->cnt) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($cancelByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
<div id="ds-floating-tip" class="ds-floating-tip no-print" aria-hidden="true"></div>
<script>
(function () {
var tipEl = document.getElementById('ds-floating-tip');
if (!tipEl) return;
var badges = Array.prototype.slice.call(document.querySelectorAll('.ds-help-badge'));
if (!badges.length) return;
function placeTip(target) {
var text = String(target.getAttribute('data-tip') || '').trim();
if (!text) return;
tipEl.textContent = text;
tipEl.style.display = 'block';
tipEl.setAttribute('aria-hidden', 'false');
var rect = target.getBoundingClientRect();
var tipRect = tipEl.getBoundingClientRect();
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
var gap = 8;
var left = rect.left + (rect.width / 2) - (tipRect.width / 2);
var top = rect.bottom + gap;
if (left < gap) left = gap;
if (left + tipRect.width > vw - gap) left = vw - gap - tipRect.width;
if (top + tipRect.height > vh - gap) top = rect.top - gap - tipRect.height;
if (top < gap) top = gap;
tipEl.style.left = Math.round(left) + 'px';
tipEl.style.top = Math.round(top) + 'px';
}
function hideTip() {
tipEl.style.display = 'none';
tipEl.setAttribute('aria-hidden', 'true');
tipEl.textContent = '';
}
badges.forEach(function (badge) {
badge.addEventListener('mouseenter', function () { placeTip(badge); });
badge.addEventListener('focus', function () { placeTip(badge); });
badge.addEventListener('mouseleave', hideTip);
badge.addEventListener('blur', hideTip);
});
window.addEventListener('scroll', hideTip, true);
window.addEventListener('resize', hideTip);
})();
</script>
<details class="mx-2 mb-4 no-print text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-800">연도별 요약 (참고)</summary>
<div class="flex gap-4 mt-2">
<div class="border border-gray-300 p-2 flex-1">
<div class="text-xs font-bold text-gray-700 mb-1">활성 / 비활성 / 전체</div>
<div class="text-sm">활성 <?= number_format((int) ($totalActive ?? 0)) ?> · 비활성 <?= number_format((int) ($totalInactive ?? 0)) ?> · 합 <?= number_format((int) ($totalActive ?? 0) + (int) ($totalInactive ?? 0)) ?></div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 신규등록 (지정일)</h3>
<div class="border border-gray-300 overflow-auto max-h-48">
<table class="w-full data-table text-xs">
<thead><tr><th>연도</th><th>건수</th></tr></thead>
<tbody class="text-right">
<?php foreach (($newByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($newByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 취소/비활성 (등록일 기준)</h3>
<div class="border border-gray-300 overflow-auto max-h-48">
<table class="w-full data-table text-xs">
<thead><tr><th>연도</th><th>건수</th></tr></thead>
<tbody class="text-right">
<?php foreach (($cancelByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($cancelByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</details>