2 Commits

Author SHA1 Message Date
taekyoungc
ec3119799c gov-portal 디자인을 시스템 전체에 적용한다.
- 사이트 업무 페이지: 공통 셸 bag/layout/portal(헤더+대메뉴 클릭+좌측 사이드바 소메뉴)
- 관리자 페이지: admin/layout 을 동일 포털 셸로 재작성(관리자 메뉴 트리, 폴백)
- 메인(/): gov-portal 대시보드, 종량제 실데이터만(재고/주문/승인/활동로그)
- 로그인/회원가입/2차인증/TOTP: 공통 auth/_shell 로 통일, 사이트 공통 로고
- 버튼색 통일: btn-search 등 주요 버튼을 #243a5e(메뉴바 네이비보다 살짝 밝게),
  밝은 파랑 채움 버튼(#2b4c8c/#1e548a)도 동일 색으로
- gov_portal_nav_context() 임의 메뉴 트리 수용, 업무 셸은 실제 bag/* 링크 유지
- Admin\Menu 권한거부 리다이렉트 admin/dashboard(404) → admin 수정
- E2E redesign.spec.js 추가, 기능 무변경

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:47:36 +09:00
taekyoungc
707182ad2d 운영 PHP 8.2와 호환되도록 의존성 잠금을 정정한다.
- config.platform.php=8.2.30 고정 — 로컬(8.3)에서 8.3 전용 버전이 잠기는 문제 방지
- maennchen/zipstream-php 3.2.2(php^8.3) → 3.1.2(php^8.1)로 재잠금
- league/commonmark 유지, 운영 서버 composer install 가능하도록 정정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 01:18:48 +09:00
34 changed files with 1079 additions and 532 deletions

View File

@@ -345,7 +345,7 @@ class Menu extends BaseController
]); ]);
} }
return redirect()->to(base_url('admin/dashboard')) return redirect()->to(base_url('admin'))
->with('error', '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.'); ->with('error', '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.');
} }
} }

View File

@@ -22,7 +22,10 @@ class Auth extends BaseController
return redirect()->to('/'); return redirect()->to('/');
} }
return view('auth/login'); return view('auth/login', [
'pageTitle' => '로그인 - 종량제 시스템',
'cardMax' => 'max-w-md',
]);
} }
public function login() public function login()
@@ -157,6 +160,8 @@ class Auth extends BaseController
return view('auth/login_two_factor', [ return view('auth/login_two_factor', [
'memberId' => $member->mb_id, 'memberId' => $member->mb_id,
'pageTitle' => '2차 인증 - 종량제 시스템',
'cardMax' => 'max-w-md',
]); ]);
} }
@@ -239,6 +244,8 @@ class Auth extends BaseController
'memberId' => $member->mb_id, 'memberId' => $member->mb_id,
'qrDataUri' => $qrDataUri, 'qrDataUri' => $qrDataUri,
'secret' => $secret, 'secret' => $secret,
'pageTitle' => '2차 인증 등록 - 종량제 시스템',
'cardMax' => 'max-w-lg',
]); ]);
} }
@@ -341,6 +348,8 @@ class Auth extends BaseController
return view('auth/register', [ return view('auth/register', [
'localGovernments' => $localGovernments, 'localGovernments' => $localGovernments,
'pageTitle' => '회원가입 - 종량제 시스템',
'cardMax' => 'max-w-md',
]); ]);
} }

View File

@@ -214,7 +214,8 @@ class Bag extends BaseController
private function render(string $title, string $viewFile, array $data = []): string private function render(string $title, string $viewFile, array $data = []): string
{ {
return view('bag/layout/main', [ // 사이트 업무 페이지 공통 셸: gov-portal 디자인(헤더+대메뉴+클릭형 좌측 사이드바).
return view('bag/layout/portal', [
'title' => $title, 'title' => $title,
'content' => view($viewFile, $data), 'content' => view($viewFile, $data),
]); ]);

View File

@@ -61,7 +61,8 @@ abstract class BaseController extends Controller
$path = substr($path, strlen('index.php/')); $path = substr($path, strlen('index.php/'));
} }
if ($path === 'bag' || str_starts_with($path, 'bag/')) { if ($path === 'bag' || str_starts_with($path, 'bag/')) {
return view('bag/layout/main', [ // 사이트 업무 페이지: gov-portal 디자인 셸 적용
return view('bag/layout/portal', [
'title' => $title, 'title' => $title,
'content' => $content, 'content' => $content,
]); ]);

View File

@@ -10,14 +10,141 @@ class Home extends BaseController
public function index() public function index()
{ {
if (session()->get('logged_in')) { if (session()->get('logged_in')) {
// 메인(/) 본문은 「요약(simple)」 대시보드로 노출한다. // 메인(/) — gov-portal 디자인 셸 + 종량제 실데이터 대시보드.
// 종래의 「종합·그래프(blend)」 본문은 /dashboard (또는 /dashboard/blend)로 이동. helper('admin');
return $this->dashboardSimple();
return view('bag/layout/portal', [
'title' => '업무 현황',
'bare' => true,
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
]);
} }
return view('welcome_message'); return view('welcome_message');
} }
/**
* 메인 대시보드용 — 종량제 시스템에 실제 존재하는 데이터만 집계.
*
* @return array<string, mixed>
*/
private function portalDashboardData(): array
{
helper('admin');
$db = \Config\Database::connect();
$lgIdx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
if ($lgIdx === null) {
$raw = session()->get('mb_lg_idx');
$lgIdx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
$inventory = [];
$totalQty = 0;
$orderCount = 0;
$palette = ['#3b82f6', '#10b981', '#f59e0b', '#6366f1', '#ef4444', '#0ea5e9', '#14b8a6', '#a855f7', '#f97316'];
try {
if ($lgIdx !== null && $db->tableExists('bag_inventory')) {
$rows = $db->table('bag_inventory')
->select('bi_bag_name, bi_bag_code, bi_qty')
->where('bi_lg_idx', $lgIdx)
->orderBy('bi_qty', 'DESC')
->get()->getResultArray();
foreach ($rows as $r) {
$inventory[] = [
'name' => (string) ($r['bi_bag_name'] ?? $r['bi_bag_code'] ?? ''),
'qty' => (int) ($r['bi_qty'] ?? 0),
];
$totalQty += (int) ($r['bi_qty'] ?? 0);
}
}
} catch (\Throwable $e) {
$inventory = [];
}
// 재고 구성(상위 품목 비율)
$stockMix = [];
foreach (array_slice($inventory, 0, 6) as $i => $item) {
$stockMix[] = [
'name' => $item['name'],
'value' => $totalQty > 0 ? (int) round($item['qty'] / $totalQty * 100) : 0,
'color' => $palette[$i % count($palette)],
];
}
// 부족 재고(수량 적은 하위 품목) — 최대 재고 대비 비율
$maxQty = $inventory !== [] ? max(array_column($inventory, 'qty')) : 0;
$lowStock = [];
foreach (array_slice(array_reverse($inventory), 0, 5) as $item) {
$lowStock[] = [
'name' => $item['name'],
'qty' => $item['qty'],
'percent' => $maxQty > 0 ? (int) round($item['qty'] / $maxQty * 100) : 0,
];
}
try {
if ($lgIdx !== null && $db->tableExists('shop_order')) {
$orderCount = (int) $db->table('shop_order')
->where('so_lg_idx', $lgIdx)
->where('so_status', 'normal')
->countAllResults();
}
} catch (\Throwable $e) {
$orderCount = 0;
}
$pendingApprovals = 0;
try {
if ($db->tableExists('member_approval_request')) {
$pendingApprovals = (int) $db->table('member_approval_request')
->where('mar_status', 'pending')
->countAllResults();
}
} catch (\Throwable $e) {
$pendingApprovals = 0;
}
// 최근 활동(activity_log) — 실제 변경 이력
$actionLabel = ['create' => '등록', 'update' => '수정', 'delete' => '삭제', 'cancel' => '취소'];
$tableLabel = [
'bag_order' => '발주', 'bag_receiving' => '입고', 'bag_sale' => '판매',
'bag_issue' => '불출', 'bag_inventory' => '재고', 'shop_order' => '주문접수',
'designated_shop' => '지정판매소', 'bag_price' => '단가', 'member' => '회원',
];
$recent = [];
try {
if ($db->tableExists('activity_log')) {
$logs = $db->table('activity_log')
->select('al_action, al_table, al_regdate')
->orderBy('al_idx', 'DESC')->limit(6)->get()->getResultArray();
foreach ($logs as $l) {
$t = (string) ($l['al_regdate'] ?? '');
$recent[] = [
'time' => $t !== '' ? date('m.d H:i', strtotime($t)) : '',
'text' => ($tableLabel[$l['al_table']] ?? (string) $l['al_table'])
. ' ' . ($actionLabel[$l['al_action']] ?? (string) $l['al_action']),
];
}
}
} catch (\Throwable $e) {
$recent = [];
}
return [
'lgLabel' => $this->resolveLgLabel(),
'mbName' => (string) (session()->get('mb_name') ?? '담당자'),
'mbId' => (string) (session()->get('mb_id') ?? ''),
'levelName' => config(\Config\Roles::class)->getLevelName((int) session()->get('mb_level')),
'totalQty' => $totalQty,
'itemCount' => count($inventory),
'orderCount' => $orderCount,
'pendingApprovals' => $pendingApprovals,
'stockMix' => $stockMix,
'lowStock' => $lowStock,
'recentActivity' => $recent,
];
}
/** /**
* 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문 * 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문
*/ */

View File

@@ -687,11 +687,11 @@ if (! function_exists('gov_portal_nav_context')) {
* dashboardAliases: list<string> * dashboardAliases: list<string>
* } * }
*/ */
function gov_portal_nav_context(): array function gov_portal_nav_context(bool $remapLinks = true, ?array $tree = null): array
{ {
helper('url'); helper('url');
$tree = get_site_nav_tree(); $tree = $tree ?? get_site_nav_tree();
$rawPath = current_nav_request_path(); $rawPath = current_nav_request_path();
$variant = gov_portal_active_variant($rawPath); $variant = gov_portal_active_variant($rawPath);
$currentPath = gov_portal_nav_match_path($rawPath); $currentPath = gov_portal_nav_match_path($rawPath);
@@ -702,10 +702,10 @@ if (! function_exists('gov_portal_nav_context')) {
foreach ($tree as $pIdx => $parent) { foreach ($tree as $pIdx => $parent) {
$children = []; $children = [];
foreach ($parent->children ?? [] as $child) { foreach ($parent->children ?? [] as $child) {
$href = gov_portal_menu_href_remap( $href = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath);
menu_link_preferred_href_path($child->mm_link ?? null, $currentPath), if ($remapLinks) {
$variant $href = gov_portal_menu_href_remap($href, $variant);
); }
$children[] = [ $children[] = [
'idx' => (int) ($child->mm_idx ?? 0), 'idx' => (int) ($child->mm_idx ?? 0),
'name' => (string) ($child->mm_name ?? ''), 'name' => (string) ($child->mm_name ?? ''),

View File

@@ -1,181 +1,151 @@
<?php <?php
declare(strict_types=1);
/**
* 관리자 공통 레이아웃 — gov-portal 디자인 적용판.
* 헤더 + 관리자 대메뉴(클릭) + 좌측 사이드바(소메뉴) + 본문($content).
*
* @var string $title
* @var string $content
*/
helper('admin'); helper('admin');
$uriObj = service('request')->getUri();
$n = $uriObj->getTotalSegments();
$uri = $n >= 2 ? $uriObj->getSegment(2) : '';
$seg3 = $n >= 3 ? $uriObj->getSegment(3) : '';
$mbLevel = (int) session()->get('mb_level'); $mbLevel = (int) session()->get('mb_level');
$isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel); $isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel);
$mbName = (string) (session()->get('mb_name') ?? '담당자');
$levelName = config(\Config\Roles::class)->getLevelName($mbLevel);
$effectiveLgIdx = admin_effective_lg_idx(); $effectiveLgIdx = admin_effective_lg_idx();
$effectiveLgName = null; $effectiveLgName = '';
if ($effectiveLgIdx) { if ($effectiveLgIdx) {
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx); $lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx);
$effectiveLgName = $lgRow ? $lgRow->lg_name : null; $effectiveLgName = $lgRow ? (string) $lgRow->lg_name : '';
} }
$userNav = session_user_nav_display();
$currentPath = current_nav_request_path();
$adminNavTree = get_admin_nav_tree();
/** DB 링크(mm_link)만 사용. 짧게 적은 항목(menus 등)은 실제 URI(admin/menus)와 맞춰 후보 비교 */ $adminTree = function_exists('get_admin_nav_tree') ? get_admin_nav_tree() : [];
$adminNavItemIsCurrent = static function (?string $mmLink) use ($currentPath): bool { $gov = gov_portal_nav_context(false, $adminTree);
return menu_link_matches_request($mmLink, $currentPath, []);
};
/** 메뉴가 DB에서 안 쓰일 때만(폴백 상단바) 세그먼트 기반 활성 */ // 관리자 메뉴가 비어 있으면(지자체 미선택 등) 핵심 항목 폴백 노출
$isActive = static function (string $path) use ($uri, $seg3) { $navItems = $gov['navItems'];
if ($path === 'admin' || $path === '') return $uri === ''; if ($navItems === []) {
if ($path === 'users') return $uri === 'users'; $mk = static fn (string $name, string $path): array => [
if ($path === 'login-history') return $uri === 'access' && $seg3 === 'login-history'; 'idx' => 0, 'name' => $name, 'href' => $path, 'url' => base_url($path),
if ($path === 'approvals') return $uri === 'access' && $seg3 === 'approvals'; ];
if ($path === 'roles') return $uri === 'roles'; $navItems = [
if ($path === 'menus') return $uri === 'menus'; ['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
if ($path === 'local-governments') return $uri === 'local-governments'; ['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
if ($path === 'select-local-government') return $uri === 'select-local-government'; $mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('승인 대기', 'admin/access/approvals'),
if ($path === 'designated-shops') return $uri === 'designated-shops'; ]],
return false; ['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
}; $mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
]],
];
if ($isSuperAdmin) {
$navItems[] = ['idx' => 0, 'name' => '지자체', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
$mk('지자체 전환', 'admin/select-local-government'), $mk('지자체 관리', 'admin/local-governments'),
]];
}
}
$navJson = json_encode($navItems, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS);
$navPartial = [
'govNavItems' => $navItems,
'govNavJson' => $navJson,
'govActiveParentIdx' => $gov['activeParentIdx'],
'govCurrentPath' => $gov['currentPath'],
'govDashboardAliases' => $gov['dashboardAliases'],
'govActiveChildHref' => $gov['currentPath'],
];
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko" class="gov-portal-html">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '관리자') ?> - 종량제 시스템</title> <title><?= esc($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"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
extend: { extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] }, fontFamily: { sans: ['Pretendard', '"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: { colors: {
'system-header': '#ffffff', 'system-header': '#ffffff', 'title-bar': '#1a2b4b', 'control-panel': '#f8f9fa',
'title-bar': '#2c3e50', 'btn-search': '#243a5e', 'btn-excel-border': '#28a745', 'btn-excel-text': '#28a745',
'control-panel': '#f8f9fa', 'btn-print-border': '#ced4da', 'btn-exit': '#d9534f',
'btn-search': '#1c4e80',
'btn-excel-border': '#28a745',
'btn-excel-text': '#28a745',
'btn-print-border': '#ced4da',
'btn-exit': '#d9534f',
}, },
fontSize: { 'xxs': '0.65rem' } fontSize: { 'xxs': '0.65rem' }
} }
} }
} }
</script> </script>
<style data-purpose="global-font-scale"> <style>
/* 전체 텍스트 +2px 확대 (요청). 로고(.app-brand)는 16px 로 유지. */ <?php include __DIR__ . '/../home/_dashboard_gov_portal_brand_css.php'; ?>
html { font-size: 18px; } <?php include __DIR__ . '/../home/_dashboard_gov_portal_topnav_css.php'; ?>
.app-brand, .app-brand * { font-size: 16px; } <?php include __DIR__ . '/../home/_dashboard_gov_portal_chrome_css.php'; ?>
</style> .data-table { width: 100%; border-collapse: collapse; font-family: 'Pretendard', 'Malgun Gothic', 'Noto Sans KR', sans-serif; }
<style data-purpose="table-layout">
.data-table { width: 100%; border-collapse: collapse; font-family: 'Malgun Gothic', 'Noto Sans KR', sans-serif; }
.data-table th, .data-table td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; font-size: 13px; } .data-table th, .data-table td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; font-size: 13px; }
.data-table th { background-color: #e9ecef; text-align: center; vertical-align: middle; font-weight: bold; color: #333; } .data-table th { background-color: #e9ecef; text-align: center; vertical-align: middle; font-weight: bold; color: #333; }
.data-table tbody tr:nth-child(even) td { background-color: #f9f9f9; } .data-table tbody tr:nth-child(even) td { background-color: #f9f9f9; }
.data-table tbody tr:nth-child(even) { background-color: #f9f9f9; }
.data-table tbody tr:hover td { background-color: #e6f7ff !important; } .data-table tbody tr:hover td { background-color: #e6f7ff !important; }
.main-content-area { height: calc(100vh - 170px); overflow: auto; }
body { overflow: hidden; }
@media print { @media print {
header, footer, .no-print, nav { display: none !important; } .portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
.main-content-area { height: auto !important; overflow: visible !important; } body.gov-portal-shell { background: #fff; display: block; }
body { overflow: visible !important; } .gov-portal-shell .main.work-main { overflow: visible !important; padding: 0 !important; }
.bg-title-bar { display: none !important; }
.bg-control-panel { break-inside: avoid; }
.print-header { display: block !important; } .print-header { display: block !important; }
} }
</style> </style>
</head> </head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased select-none"> <body class="gov-portal-shell select-none">
<header class="relative bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0 z-50"> <header class="portal-header">
<div class="flex items-center gap-2 shrink-0"> <div class="portal-header-inner">
<?= view('components/header_brand', ['href' => base_url('admin')]) ?> <?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('admin')]) ?>
</div> <?= view('home/_dashboard_gov_portal_topnav_click', $navPartial) ?>
<nav class="hidden md:flex gap-5 text-sm font-medium text-gray-600"> <div class="portal-header-utils" style="display:flex;align-items:center;gap:.5rem;">
<?php if (! empty($adminNavTree)): ?> <span class="user-line">
<?php foreach ($adminNavTree as $navItem): ?> <?php if ($effectiveLgName !== ''): ?><strong><?= esc($effectiveLgName) ?></strong> · <?php endif; ?>
<?php <?= esc($levelName) ?> · <?= esc($mbName) ?>님
$hasChildren = ! empty($navItem->children);
$parentLink = menu_link_preferred_href_path($navItem->mm_link ?? null, $currentPath);
$activeChild = $hasChildren ? menu_active_child_for_parent($navItem, $currentPath, []) : null;
$parentIsCurrent = $adminNavItemIsCurrent($navItem->mm_link ?? null);
if (! $parentIsCurrent && $activeChild !== null) {
$parentIsCurrent = true;
}
?>
<div class="relative group">
<a class="<?= $parentIsCurrent ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>"
href="<?= $parentLink !== '' ? base_url($parentLink) : '#' ?>">
<?= esc($navItem->mm_name) ?>
</a>
<?php if ($hasChildren): ?>
<?php /* 사이트 메뉴와 동일: 호버 끊김 방지 pt-1, 키보드 포커스, z-index */ ?>
<div class="absolute left-0 top-full z-50 hidden pt-1 min-w-[12rem] group-hover:block group-focus-within:block">
<div class="bg-white border border-gray-200 rounded shadow-lg py-1">
<?php foreach ($navItem->children as $child): ?>
<?php
$childLink = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath);
$childIsCurrent = $activeChild !== null
&& (int) ($child->mm_idx ?? 0) === (int) ($activeChild->mm_idx ?? -1);
?>
<?php if ($childLink !== ''): ?>
<a href="<?= base_url($childLink) ?>"
class="block px-3 py-1.5 text-sm hover:bg-blue-50 whitespace-nowrap <?= $childIsCurrent ? 'text-blue-700 font-semibold bg-blue-50' : 'text-gray-700' ?>">
<?= esc($child->mm_name) ?>
</a>
<?php else: ?>
<span class="block px-3 py-1.5 text-sm text-gray-400 cursor-default whitespace-nowrap" title="메뉴 관리에서 링크를 설정해 주세요">
<?= esc($child->mm_name) ?>
</span> </span>
<?php endif; ?> <a href="<?= base_url('/') ?>" title="사이트로"
<?php endforeach; ?> 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-house"></i> 사이트
</a>
<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>
</div> </div>
</header>
<div class="layout">
<?= view('home/_dashboard_gov_portal_sidebar', $navPartial) ?>
<main class="main work-main main-content-area">
<?php if (! empty($title)): ?>
<h1 class="work-titlebar"><i class="fa-solid fa-gear tb-ico"></i><?= esc($title) ?></h1>
<?php endif; ?> <?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 (session()->getFlashdata('errors')): ?>
<div class="work-flash err"><?php foreach (session()->getFlashdata('errors') as $err): ?><div><?= esc($err) ?></div><?php endforeach; ?></div>
<?php endif; ?>
<div class="work-surface">
<?= $content ?>
</div> </div>
<?php endforeach; ?> </main>
<?php else: ?> </div>
<a class="<?= $isActive('') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin') ?>">대시보드</a>
<a class="<?= $isActive('users') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/users') ?>">회원 관리</a> <footer class="portal-footer">
<a class="<?= $isActive('login-history') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/access/login-history') ?>">로그인 이력</a>
<a class="<?= $isActive('approvals') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/access/approvals') ?>">승인 대기</a>
<a class="<?= $isActive('roles') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/roles') ?>">역할</a>
<a class="<?= $isActive('menus') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/menus') ?>">메뉴</a>
<?php if ($isSuperAdmin): ?>
<a class="<?= $isActive('select-local-government') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/select-local-government') ?>">지자체 전환</a>
<a class="<?= $isActive('local-governments') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/local-governments') ?>">지자체</a>
<?php endif; ?>
<a class="<?= $isActive('designated-shops') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('bag/designated-shops') ?>">지정판매소</a>
<?php endif; ?>
</nav>
<?= view('components/header_user_tools', [
'userNav' => $userNav,
'effectiveLgName' => $effectiveLgName,
'showSiteLink' => true,
'showAdminLink' => false,
]) ?>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
<?= esc($title ?? '관리자') ?>
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-green-50 text-green-700 text-sm" role="alert"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $err): ?><p><?= esc($err) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="main-content-area flex-grow bg-white p-4">
<?= $content ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 flex items-center justify-between shrink-0">
<span>종량제 시스템 관리자</span> <span>종량제 시스템 관리자</span>
<span><?= date('Y.m.d (D) g:i:sA') ?></span> <span><?= date('Y.m.d (D) H:i') ?></span>
</footer> </footer>
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
</body> </body>
</html> </html>

70
app/Views/auth/_shell.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* 인증 페이지 공통 셸 — gov-portal 디자인.
* 사용: 자식 뷰 상단에서 $this->extend('auth/_shell'),
* 섹션 'heading'(카드 제목)·'content'(본문) 정의.
* 선택 변수: $subtitle(카드 헤더 소제목), $cardMax(예: 'max-w-lg', 기본 'max-w-md')
*/
$cardMax = $cardMax ?? 'max-w-md';
$subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($pageTitle ?? '종량제 시스템') ?></title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Pretendard', '"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'navy': '#1a2b4b', 'title-bar': '#1a2b4b', 'portal-bg': '#f0f4f8',
'btn-search': '#243a5e', 'btn-exit': '#d9534f',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; letter-spacing: -0.01em; }</style>
</head>
<body class="bg-portal-bg text-gray-700 flex flex-col min-h-screen font-sans antialiased">
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 text-base font-bold tracking-tight hover:opacity-90" title="종량제 시스템">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
</svg>
<span class="whitespace-nowrap">종량제 시스템</span>
</a>
</header>
<main class="flex-grow p-6 flex items-center justify-center">
<section class="w-full <?= esc($cardMax) ?> bg-white border border-[#dde4ec] rounded-2xl shadow-[0_2px_12px_rgba(26,43,75,0.08)] overflow-hidden">
<div class="bg-gradient-to-br from-navy to-[#007bff] text-white px-6 py-5">
<p class="text-xs text-white/70"><?= esc($subtitle) ?></p>
<h1 class="text-lg font-bold mt-0.5"><?= $this->renderSection('heading') ?></h1>
</div>
<div class="p-6">
<?php if (session()->getFlashdata('error')): ?>
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="mb-4 p-3 rounded-lg bg-green-50 text-green-700 text-sm" role="alert"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?= $this->renderSection('content') ?>
</div>
</section>
</main>
<footer class="bg-[#eef2f7] border-t border-[#dde4ec] px-4 py-1.5 text-xs text-gray-500 shrink-0 text-center">종량제 물류시스템</footer>
</body>
</html>

View File

@@ -1,72 +1,21 @@
<!DOCTYPE html> <?= $this->extend('auth/_shell') ?>
<html lang="ko">
<head> <?= $this->section('heading') ?>로그인<?= $this->endSection() ?>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <?= $this->section('content') ?>
<title>로그인 - 종량제 시스템</title> <form action="<?= base_url('login') ?>" method="POST" class="space-y-4">
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff',
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
'btn-exit': '#d9534f',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0">
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 text-base font-semibold text-gray-800 tracking-tight hover:text-blue-600" title="종량제 시스템">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-blue-900 translate-y-[1px]" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
</svg>
<span class="whitespace-nowrap">종량제 시스템</span>
</a>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
로그인
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-green-50 text-green-700 text-sm" role="alert"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center">
<section class="w-full max-w-md bg-white border border-gray-300 rounded shadow-sm p-6">
<form action="<?= base_url('login') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="login_id">아이디</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="login_id">아이디</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="login_id" name="login_id" type="text" placeholder="아이디" value="<?= esc(old('login_id')) ?>" autocomplete="username" autofocus/> <input class="block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]" id="login_id" name="login_id" type="text" placeholder="아이디" value="<?= esc(old('login_id')) ?>" autocomplete="username" autofocus/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="password">비밀번호</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="password">비밀번호</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="password" name="password" type="password" placeholder="비밀번호" autocomplete="current-password"/> <input class="block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]" id="password" name="password" type="password" placeholder="비밀번호" autocomplete="current-password"/>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition border border-transparent">로그인</button> <button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition border border-transparent">로그인</button>
<a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">회원가입</a> <a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">회원가입</a>
</div> </div>
</form> </form>
</section> <?= $this->endSection() ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer>
</body>
</html>

View File

@@ -1,60 +1,18 @@
<!DOCTYPE html> <?= $this->extend('auth/_shell') ?>
<html lang="ko">
<head> <?= $this->section('heading') ?>2차 인증 (TOTP)<?= $this->endSection() ?>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <?= $this->section('content') ?>
<title>2 인증 - 종량제 시스템</title> <p class="text-sm text-gray-600 mb-4">계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 대해 인증 앱의 6자리 코드를 입력해 주세요.</p>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <form action="<?= base_url('login/two-factor') ?>" method="POST" class="space-y-4">
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff',
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col min-h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0">
<?= view('components/header_brand') ?>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
2차 인증 (TOTP)
</div>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center">
<section class="w-full max-w-md bg-white border border-gray-300 rounded shadow-sm p-6 space-y-4">
<p class="text-sm text-gray-600">계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 대해 인증 앱의 6자리 코드를 입력해 주세요.</p>
<form action="<?= base_url('login/two-factor') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">인증 코드</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">인증 코드</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm tracking-widest focus:ring-blue-500 focus:border-blue-500" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/> <input class="block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm tracking-widest focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition border border-transparent">확인</button> <button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition border border-transparent">확인</button>
<a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">처음으로</a> <a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">처음으로</a>
</div> </div>
</form> </form>
</section> <?= $this->endSection() ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer>
</body>
</html>

View File

@@ -1,76 +1,38 @@
<!DOCTYPE html> <?= $this->extend('auth/_shell') ?>
<html lang="ko">
<head> <?= $this->section('heading') ?>회원가입<?= $this->endSection() ?>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <?= $this->section('content') ?>
<title>회원가입 - 종량제 시스템</title> <?php $inputCls = 'block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]'; ?>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <form action="<?= base_url('register') ?>" method="POST" class="space-y-4">
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff',
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
'btn-exit': '#d9534f',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0">
<?= view('components/header_brand') ?>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
회원가입
</div>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 overflow-auto">
<section class="w-full max-w-md mx-auto bg-white border border-gray-300 rounded shadow-sm p-6">
<form action="<?= base_url('register') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_id">아이디 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_id">아이디 <span class="text-red-500">*</span></label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_id" name="mb_id" type="text" value="<?= esc(old('mb_id')) ?>" autocomplete="username" autofocus/> <input class="<?= $inputCls ?>" id="mb_id" name="mb_id" type="text" value="<?= esc(old('mb_id')) ?>" autocomplete="username" autofocus/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd">비밀번호 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd">비밀번호 <span class="text-red-500">*</span></label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_passwd" name="mb_passwd" type="password" autocomplete="new-password"/> <input class="<?= $inputCls ?>" id="mb_passwd" name="mb_passwd" type="password" autocomplete="new-password"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd_confirm">비밀번호 확인 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd_confirm">비밀번호 확인 <span class="text-red-500">*</span></label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_passwd_confirm" name="mb_passwd_confirm" type="password" autocomplete="new-password"/> <input class="<?= $inputCls ?>" id="mb_passwd_confirm" name="mb_passwd_confirm" type="password" autocomplete="new-password"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_name">이름 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_name">이름 <span class="text-red-500">*</span></label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_name" name="mb_name" type="text" value="<?= esc(old('mb_name')) ?>" autocomplete="name"/> <input class="<?= $inputCls ?>" id="mb_name" name="mb_name" type="text" value="<?= esc(old('mb_name')) ?>" autocomplete="name"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_email">이메일</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_email">이메일</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_email" name="mb_email" type="email" value="<?= esc(old('mb_email')) ?>"/> <input class="<?= $inputCls ?>" id="mb_email" name="mb_email" type="email" value="<?= esc(old('mb_email')) ?>"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_phone">연락처</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_phone">연락처</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_phone" name="mb_phone" type="tel" value="<?= esc(old('mb_phone')) ?>"/> <input class="<?= $inputCls ?>" id="mb_phone" name="mb_phone" type="tel" value="<?= esc(old('mb_phone')) ?>"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_lg_idx">지자체</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_lg_idx">지자체</label>
<select class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_lg_idx" name="mb_lg_idx"> <select class="<?= $inputCls ?>" id="mb_lg_idx" name="mb_lg_idx">
<option value="">선택 안 함</option> <option value="">선택 안 함</option>
<?php if (! empty($localGovernments)): ?> <?php if (! empty($localGovernments)): ?>
<?php foreach ($localGovernments as $lg): ?> <?php foreach ($localGovernments as $lg): ?>
@@ -81,7 +43,7 @@ tailwind.config = {
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_level">사용자 역할 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_level">사용자 역할 <span class="text-red-500">*</span></label>
<select class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_level" name="mb_level"> <select class="<?= $inputCls ?>" id="mb_level" name="mb_level">
<?php foreach (config('Roles')->levelNames as $level => $name): ?> <?php foreach (config('Roles')->levelNames as $level => $name): ?>
<?php if (\Config\Roles::isSuperAdminEquivalent((int) $level)) continue; ?> <?php if (\Config\Roles::isSuperAdminEquivalent((int) $level)) continue; ?>
<option value="<?= $level ?>" <?= old('mb_level', config('Roles')->defaultLevelForSelfRegister) == $level ? 'selected' : '' ?>><?= esc($name) ?></option> <option value="<?= $level ?>" <?= old('mb_level', config('Roles')->defaultLevelForSelfRegister) == $level ? 'selected' : '' ?>><?= esc($name) ?></option>
@@ -90,12 +52,8 @@ tailwind.config = {
<p class="text-xs text-gray-500 mt-1">가입 후 관리자 승인 완료 시 로그인할 수 있습니다.</p> <p class="text-xs text-gray-500 mt-1">가입 후 관리자 승인 완료 시 로그인할 수 있습니다.</p>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition">가입하기</button> <button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition">가입하기</button>
<a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">로그인</a> <a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">로그인</a>
</div> </div>
</form> </form>
</section> <?= $this->endSection() ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer>
</body>
</html>

View File

@@ -1,70 +1,29 @@
<!DOCTYPE html> <?= $this->extend('auth/_shell') ?>
<html lang="ko">
<head> <?= $this->section('heading') ?>2차 인증 앱 등록<?= $this->endSection() ?>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <?= $this->section('content') ?>
<title>2 인증 등록 - 종량제 시스템</title> <p class="text-sm text-gray-600 mb-4">관리자 계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 Google Authenticator, Microsoft Authenticator 등으로 아래 시크릿 또는 QR을 등록한 뒤, 표시되는 6자리 코드를 입력해 주세요.</p>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <?php if (! empty($qrDataUri)): ?>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style> <div class="flex justify-center mb-4">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/> <img src="<?= esc($qrDataUri, 'attr') ?>" alt="TOTP QR 코드" class="border border-gray-200 rounded-lg max-w-[200px] h-auto"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col min-h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center px-4 shrink-0">
<?= view('components/header_brand') ?>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
2차 인증 앱 등록
</div> </div>
<?php if (session()->getFlashdata('error')): ?> <?php else: ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div> <p class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mb-4">QR 이미지를 불러올 수 없습니다. 아래 시크릿을 앱에 직접 입력해 주세요.</p>
<?php endif; ?> <?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?> <div class="mb-4">
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center">
<section class="w-full max-w-lg bg-white border border-gray-300 rounded shadow-sm p-6 space-y-4">
<p class="text-sm text-gray-600">관리자 계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 Google Authenticator, Microsoft Authenticator 등으로 아래 시크릿 또는 QR을 등록한 뒤, 표시되는 6자리 코드를 입력해 주세요.</p>
<?php if (! empty($qrDataUri)): ?>
<div class="flex justify-center">
<img src="<?= esc($qrDataUri, 'attr') ?>" alt="TOTP QR 코드" class="border border-gray-200 rounded max-w-[200px] h-auto"/>
</div>
<?php else: ?>
<p class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">QR 이미지를 불러올 수 없습니다. 아래 시크릿을 앱에 직접 입력해 주세요.</p>
<?php endif; ?>
<div>
<span class="block text-xs font-semibold text-gray-500 mb-1">수동 입력용 시크릿</span> <span class="block text-xs font-semibold text-gray-500 mb-1">수동 입력용 시크릿</span>
<code class="block text-sm bg-gray-100 border border-gray-200 rounded px-3 py-2 break-all select-all"><?= esc($secret) ?></code> <code class="block text-sm bg-gray-100 border border-gray-200 rounded-lg px-3 py-2 break-all select-all"><?= esc($secret) ?></code>
</div> </div>
<form action="<?= base_url('login/totp-setup') ?>" method="POST" class="space-y-4 pt-2 border-t border-gray-200"> <form action="<?= base_url('login/totp-setup') ?>" method="POST" class="space-y-4 pt-2 border-t border-gray-200">
<?= csrf_field() ?> <?= csrf_field() ?>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">확인용 인증 코드</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">확인용 인증 코드</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm tracking-widest focus:ring-blue-500 focus:border-blue-500" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/> <input class="block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm tracking-widest focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition border border-transparent">등록 완료</button> <button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition border border-transparent">등록 완료</button>
<a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">취소</a> <a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">취소</a>
</div> </div>
</form> </form>
</section> <?= $this->endSection() ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer>
</body>
</html>

View File

@@ -23,7 +23,7 @@ if (isset($pager) && $pager !== null) {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" onclick="window.print()" class="no-print rounded border border-gray-300 px-3 py-1 text-sm text-gray-600 hover:bg-gray-50">인쇄</button> <button type="button" onclick="window.print()" class="no-print rounded border border-gray-300 px-3 py-1 text-sm text-gray-600 hover:bg-gray-50">인쇄</button>
<?php if ($canManage): ?> <?php if ($canManage): ?>
<a href="<?= base_url('admin/code-details/' . (int) $kind->ck_idx . '/create') ?>" class="rounded border border-transparent bg-[#1c4e80] px-3 py-1.5 text-sm text-white shadow hover:opacity-90">세부코드 등록</a> <a href="<?= base_url('admin/code-details/' . (int) $kind->ck_idx . '/create') ?>" class="rounded border border-transparent bg-[#243a5e] px-3 py-1.5 text-sm text-white shadow hover:opacity-90">세부코드 등록</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>

View File

@@ -19,7 +19,7 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
<h3 class="text-base font-bold text-gray-700">기본코드 종류</h3> <h3 class="text-base font-bold text-gray-700">기본코드 종류</h3>
<div class="flex flex-wrap items-center gap-2 text-xs sm:text-sm"> <div class="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<?php if ($canManageKinds): ?> <?php if ($canManageKinds): ?>
<a href="<?= base_url('admin/code-kinds/create') ?>" class="inline-flex items-center rounded bg-[#1c4e80] px-3 py-1.5 text-white shadow hover:opacity-90">기본코드 등록</a> <a href="<?= base_url('admin/code-kinds/create') ?>" class="inline-flex items-center rounded bg-[#243a5e] px-3 py-1.5 text-white shadow hover:opacity-90">기본코드 등록</a>
<?php else: ?> <?php else: ?>
<span class="text-gray-500">코드 종류 등록·수정은 super admin·본부 관리자만 가능합니다.</span> <span class="text-gray-500">코드 종류 등록·수정은 super admin·본부 관리자만 가능합니다.</span>
<?php endif; ?> <?php endif; ?>
@@ -81,7 +81,7 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
<?php endif; ?> <?php endif; ?>
</h3> </h3>
<?php if ($canManageDetails && $selectedKind !== null): ?> <?php if ($canManageDetails && $selectedKind !== null): ?>
<a href="<?= base_url('admin/code-details/' . (int) $selectedKind->ck_idx . '/create') ?>" class="inline-flex items-center rounded bg-[#1c4e80] px-3 py-1.5 text-white shadow hover:opacity-90 text-sm">세부코드 등록</a> <a href="<?= base_url('admin/code-details/' . (int) $selectedKind->ck_idx . '/create') ?>" class="inline-flex items-center rounded bg-[#243a5e] px-3 py-1.5 text-white shadow hover:opacity-90 text-sm">세부코드 등록</a>
<?php endif; ?> <?php endif; ?>
</div> </div>

View File

@@ -32,7 +32,7 @@ $userNav = session_user_nav_display();
'system-header': '#ffffff', 'system-header': '#ffffff',
'title-bar': '#2c3e50', 'title-bar': '#2c3e50',
'control-panel': '#f8f9fa', 'control-panel': '#f8f9fa',
'btn-search': '#1c4e80', 'btn-search': '#243a5e',
'btn-excel-border': '#28a745', 'btn-excel-border': '#28a745',
'btn-excel-text': '#28a745', 'btn-excel-text': '#28a745',
'btn-print-border': '#ced4da', 'btn-print-border': '#ced4da',

View File

@@ -110,7 +110,7 @@ $notices = [
<span><i class="fa-regular fa-calendar mr-0.5"></i><?= date('Y-m-d (D) H:i') ?></span> <span><i class="fa-regular fa-calendar mr-0.5"></i><?= date('Y-m-d (D) H:i') ?></span>
<span class="text-gray-300">|</span> <span class="text-gray-300">|</span>
<span><?= esc($lgLabel) ?> · <strong class="text-gray-800"><?= esc($mbName) ?></strong></span> <span><?= esc($lgLabel) ?> · <strong class="text-gray-800"><?= esc($mbName) ?></strong></span>
<button type="button" class="bg-[#2b4c8c] text-white px-2 py-0.5 rounded text-[11px]" onclick="location.reload()"><i class="fa-solid fa-rotate mr-0.5"></i>새로고침</button> <button type="button" class="bg-[#243a5e] text-white px-2 py-0.5 rounded text-[11px]" onclick="location.reload()"><i class="fa-solid fa-rotate mr-0.5"></i>새로고침</button>
</div> </div>
</div> </div>

View File

@@ -91,7 +91,7 @@ $notices = [
<span><i class="fa-regular fa-calendar mr-0.5"></i><?= date('Y-m-d (D) H:i') ?></span> <span><i class="fa-regular fa-calendar mr-0.5"></i><?= date('Y-m-d (D) H:i') ?></span>
<span class="text-gray-300">|</span> <span class="text-gray-300">|</span>
<span><?= esc($lgLabel) ?> · <strong class="text-gray-800"><?= esc($mbName) ?></strong></span> <span><?= esc($lgLabel) ?> · <strong class="text-gray-800"><?= esc($mbName) ?></strong></span>
<button type="button" class="bg-[#2b4c8c] text-white px-2 py-0.5 rounded text-[11px]" onclick="location.reload()"><i class="fa-solid fa-rotate mr-0.5"></i>새로고침</button> <button type="button" class="bg-[#243a5e] text-white px-2 py-0.5 rounded text-[11px]" onclick="location.reload()"><i class="fa-solid fa-rotate mr-0.5"></i>새로고침</button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
/**
* 메인(/) gov-portal 디자인 대시보드 — 종량제 실데이터만 표시.
*
* @var string $lgLabel
* @var string $mbName
* @var string $mbId
* @var string $levelName
* @var int $totalQty
* @var int $itemCount
* @var int $orderCount
* @var int $pendingApprovals
* @var array $stockMix
* @var array $lowStock
* @var array $recentActivity
*/
$stockMix = is_array($stockMix ?? null) ? $stockMix : [];
$lowStock = is_array($lowStock ?? null) ? $lowStock : [];
$recentActivity = is_array($recentActivity ?? null) ? $recentActivity : [];
// 도넛 conic-gradient 누적 계산
$donutStops = [];
$acc = 0;
foreach ($stockMix as $m) {
$start = $acc;
$acc += (int) $m['value'];
$donutStops[] = $m['color'] . ' ' . $start . '% ' . $acc . '%';
}
$donutCss = $donutStops !== [] ? implode(', ', $donutStops) : '#e5e7eb 0% 100%';
?>
<div class="space-y-4">
<!-- 프로필 + KPI -->
<section class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div class="rounded-xl bg-slate-700 text-white p-4 shadow-sm">
<p class="text-xs text-white/70">안녕하세요.</p>
<p class="text-lg font-bold mt-1"><?= esc($mbName) ?>님</p>
<p class="text-[11px] text-white/70 mt-1 leading-relaxed">
<?= esc($levelName) ?><?= $lgLabel !== '' ? ' · ' . esc($lgLabel) : '' ?><br/>
아이디 <?= esc($mbId) ?><br/>최근접속 <?= date('Y.m.d H:i') ?>
</p>
<div class="flex gap-1.5 mt-3">
<a href="<?= base_url('bag/manual') ?>" class="text-[11px] px-2 py-1 rounded bg-white/10 hover:bg-white/20 border border-white/30">매뉴얼</a>
<a href="<?= base_url('logout') ?>" class="text-[11px] px-2 py-1 rounded bg-white/10 hover:bg-white/20 border border-white/30">로그아웃</a>
</div>
</div>
<div class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<p class="text-xs text-gray-500"><i class="fa-solid fa-boxes-stacked text-blue-600 mr-1"></i>봉투 재고 총량</p>
<p class="text-2xl font-bold text-gray-900 mt-1"><?= number_format($totalQty) ?><span class="text-sm font-medium text-gray-400 ml-1">개</span></p>
<p class="text-[11px] text-gray-500 mt-1"><?= esc($lgLabel !== '' ? $lgLabel : '전체') ?> · 품목 <?= (int) $itemCount ?>종</p>
</div>
<div class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<p class="text-xs text-gray-500"><i class="fa-solid fa-receipt text-sky-600 mr-1"></i>주문 접수(정상)</p>
<p class="text-2xl font-bold text-sky-700 mt-1"><?= (int) $orderCount ?><span class="text-sm font-medium text-gray-400 ml-1">건</span></p>
<p class="text-[11px] text-gray-500 mt-1">전화·지정판매소 주문 누계</p>
</div>
<div class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<p class="text-xs text-gray-500"><i class="fa-solid fa-user-check text-violet-600 mr-1"></i>승인 대기</p>
<p class="text-2xl font-bold text-violet-700 mt-1"><?= (int) $pendingApprovals ?><span class="text-sm font-medium text-gray-400 ml-1">명</span></p>
<p class="text-[11px] text-gray-500 mt-1">회원 가입 승인 요청</p>
</div>
</section>
<section class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<?php if ($stockMix !== []): ?>
<!-- 재고 구성 -->
<div class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<h2 class="text-sm font-bold text-gray-900 mb-3"><i class="fa-solid fa-chart-pie text-blue-600 mr-1"></i>재고 구성</h2>
<div class="flex items-center gap-4">
<div class="w-20 h-20 rounded-full shrink-0" style="background: conic-gradient(<?= $donutCss ?>);">
<div class="w-10 h-10 bg-white rounded-full mx-auto" style="margin-top:1.25rem;"></div>
</div>
<ul class="text-[11px] text-gray-600 space-y-1 min-w-0">
<?php foreach ($stockMix as $m): ?>
<li class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full shrink-0" style="background-color: <?= esc($m['color'], 'attr') ?>"></span>
<span class="truncate"><?= esc($m['name']) ?> <?= (int) $m['value'] ?>%</span>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<?php if ($lowStock !== []): ?>
<!-- 부족 재고 -->
<div class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<h2 class="text-sm font-bold text-gray-900 mb-3"><i class="fa-solid fa-box-open text-amber-600 mr-1"></i>재고 적은 품목</h2>
<div class="space-y-2">
<?php foreach ($lowStock as $item): ?>
<div>
<div class="flex justify-between text-[11px] text-gray-600 mb-1">
<span class="truncate"><?= esc($item['name']) ?></span>
<span class="shrink-0 ml-2"><?= number_format((int) $item['qty']) ?>개</span>
</div>
<div class="h-2 rounded bg-gray-100 overflow-hidden">
<div class="h-full rounded bg-amber-500" style="width: <?= (int) $item['percent'] ?>%"></div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if ($recentActivity !== []): ?>
<!-- 최근 활동 (activity_log) -->
<div class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<h2 class="text-sm font-bold text-gray-900 mb-3"><i class="fa-solid fa-clock-rotate-left text-emerald-600 mr-1"></i>최근 처리 내역</h2>
<ul class="space-y-2">
<?php foreach ($recentActivity as $ev): ?>
<li class="flex items-start gap-2 text-[12px]">
<span class="text-[11px] font-semibold text-gray-400 shrink-0 w-20"><?= esc($ev['time']) ?></span>
<span class="text-gray-700"><?= esc($ev['text']) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section>
<!-- 자주 가는 화면 (실제 메뉴 링크) -->
<section class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<h2 class="text-sm font-bold text-gray-900 mb-3"><i class="fa-solid fa-location-arrow text-blue-600 mr-1"></i>자주 가는 화면</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<?php
$links = [
['bag/inventory', '창고 재고 조회', '품목별 현재 재고', 'fa-boxes-stacked', 'emerald'],
['bag/order/create', '발주 등록', '봉투 발주·구매신청', 'fa-cart-shopping', 'sky'],
['bag/flow', '봉투 수불 현황', '입고·출고 내역', 'fa-arrow-right-arrow-left', 'orange'],
['bag/sales', '판매 관리', '판매/반품 내역', 'fa-receipt', 'indigo'],
['bag/reports/daily-summary', '일계표', '일일 판매 요약', 'fa-table-list', 'violet'],
['bag/manual', '사용자 매뉴얼', '업무별 사용 안내', 'fa-book', 'slate'],
];
foreach ($links as [$path, $label, $desc, $icon, $c]):
?>
<a href="<?= base_url($path) ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-gray-200 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-<?= $c ?>-50 text-<?= $c ?>-600 flex items-center justify-center shrink-0">
<i class="fa-solid <?= $icon ?>"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800"><?= esc($label) ?></p>
<p class="text-[11px] text-gray-500 truncate"><?= esc($desc) ?></p>
</div>
</a>
<?php endforeach; ?>
</div>
</section>
</div>

View File

@@ -142,7 +142,7 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button class="bg-[#1e548a] hover:bg-blue-900 text-white px-4 py-1.5 rounded text-sm font-medium flex items-center gap-1 transition-colors"> <button class="bg-[#243a5e] hover:brightness-110 text-white px-4 py-1.5 rounded text-sm font-medium flex items-center gap-1 transition-colors">
<i class="fa-solid fa-magnifying-glass"></i> 검색 <i class="fa-solid fa-magnifying-glass"></i> 검색
</button> </button>
<button class="bg-white border border-[#2e7d32] text-[#2e7d32] hover:bg-green-50 px-4 py-1.5 rounded text-sm font-medium flex items-center gap-1 transition-colors"> <button class="bg-white border border-[#2e7d32] text-[#2e7d32] hover:bg-green-50 px-4 py-1.5 rounded text-sm font-medium flex items-center gap-1 transition-colors">

View File

@@ -29,9 +29,9 @@ tailwind.config = {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] }, fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: { colors: {
'system-header': '#ffffff', 'system-header': '#ffffff',
'title-bar': '#2c3e50', 'title-bar': '#1a2b4b',
'control-panel': '#f8f9fa', 'control-panel': '#f8f9fa',
'btn-search': '#1c4e80', 'btn-search': '#243a5e',
'btn-excel-border': '#28a745', 'btn-excel-border': '#28a745',
'btn-excel-text': '#28a745', 'btn-excel-text': '#28a745',
'btn-print-border': '#ced4da', 'btn-print-border': '#ced4da',

View File

@@ -0,0 +1,169 @@
<?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 : '';
}
?>
<!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 ?? '종량제 시스템') ?></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 th, .data-table td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; font-size: 13px; }
.data-table th { background-color: #e9ecef; text-align: center; vertical-align: middle; font-weight: bold; color: #333; }
.data-table tbody tr:nth-child(even) td { background-color: #f9f9f9; }
.data-table tbody tr:hover td { background-color: #e6f7ff !important; }
@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;">
<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;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)): ?>
<h1 class="work-titlebar"><i class="fa-solid fa-folder-open tb-ico"></i><?= esc($title) ?></h1>
<?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>종량제 시스템</span>
<span><?= date('Y.m.d (D) H:i') ?></span>
</footer>
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<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>
</body>
</html>

View File

@@ -103,7 +103,7 @@ $mbName = session()->get('mb_name') ?? '담당자';
<input type="text" readonly value="<?= date('Y.m.d') ?>" class="border border-gray-300 px-2 py-1 rounded w-28 shadow-sm"> <input type="text" readonly value="<?= date('Y.m.d') ?>" class="border border-gray-300 px-2 py-1 rounded w-28 shadow-sm">
<span class="text-gray-500">|</span> <span class="text-gray-500">|</span>
<span class="text-gray-600">지자체 <strong class="text-gray-800"><?= esc($lgLabel) ?></strong></span> <span class="text-gray-600">지자체 <strong class="text-gray-800"><?= esc($lgLabel) ?></strong></span>
<button type="button" class="bg-[#2b4c8c] hover:bg-blue-800 text-white px-3 py-1 rounded text-xs font-medium shadow flex items-center gap-1"> <button type="button" class="bg-[#243a5e] hover:brightness-110 text-white px-3 py-1 rounded text-xs font-medium shadow flex items-center gap-1">
<i class="fa-solid fa-rotate"></i> 새로고침 <i class="fa-solid fa-rotate"></i> 새로고침
</button> </button>
</div> </div>

View File

@@ -149,7 +149,7 @@ $notices = [
<span><i class="fa-regular fa-calendar mr-0.5"></i><?= date('Y-m-d (D) H:i') ?></span> <span><i class="fa-regular fa-calendar mr-0.5"></i><?= date('Y-m-d (D) H:i') ?></span>
<span class="text-gray-300">|</span> <span class="text-gray-300">|</span>
<span>기준지자체 <strong class="text-gray-800"><?= esc($lgLabel) ?></strong></span> <span>기준지자체 <strong class="text-gray-800"><?= esc($lgLabel) ?></strong></span>
<button type="button" class="bg-[#2b4c8c] text-white px-2 py-0.5 rounded text-[11px]"><i class="fa-solid fa-rotate mr-0.5"></i>새로고침</button> <button type="button" class="bg-[#243a5e] text-white px-2 py-0.5 rounded text-[11px]"><i class="fa-solid fa-rotate mr-0.5"></i>새로고침</button>
</div> </div>
</div> </div>
@@ -369,7 +369,7 @@ $notices = [
<div class="absolute inset-2 rounded-full bg-white flex items-center justify-center text-[10px] font-bold text-gray-700">비중</div> <div class="absolute inset-2 rounded-full bg-white flex items-center justify-center text-[10px] font-bold text-gray-700">비중</div>
</div> </div>
<div class="text-[10px] text-gray-600 space-y-0.5"> <div class="text-[10px] text-gray-600 space-y-0.5">
<div><span class="inline-block w-2 h-2 rounded-full bg-[#2b4c8c] mr-1"></span>일반 40%</div> <div><span class="inline-block w-2 h-2 rounded-full bg-[#243a5e] mr-1"></span>일반 40%</div>
<div><span class="inline-block w-2 h-2 rounded-full bg-emerald-500 mr-1"></span>스티커 30%</div> <div><span class="inline-block w-2 h-2 rounded-full bg-emerald-500 mr-1"></span>스티커 30%</div>
<div><span class="inline-block w-2 h-2 rounded-full bg-amber-500 mr-1"></span>대형/특수 22%</div> <div><span class="inline-block w-2 h-2 rounded-full bg-amber-500 mr-1"></span>대형/특수 22%</div>
</div> </div>

View File

@@ -41,7 +41,7 @@ $lowStock = [
<i class="fa-regular fa-calendar mr-1 text-gray-500"></i><?= date('Y.m.d (D)') ?> <i class="fa-regular fa-calendar mr-1 text-gray-500"></i><?= date('Y.m.d (D)') ?>
</div> </div>
</div> </div>
<button type="button" class="inline-flex items-center justify-center px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium shadow"> <button type="button" class="inline-flex items-center justify-center px-3 py-1.5 rounded bg-btn-search hover:brightness-110 text-white text-xs font-medium shadow">
<i class="fa-solid fa-rotate mr-1"></i> 새로고침 <i class="fa-solid fa-rotate mr-1"></i> 새로고침
</button> </button>
</div> </div>

View File

@@ -30,7 +30,7 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
.manual-prose ol { list-style: decimal; } .manual-prose ol { list-style: decimal; }
.manual-prose li { margin: 0.25rem 0; } .manual-prose li { margin: 0.25rem 0; }
.manual-prose li > ul, .manual-prose li > ol { margin-top: 0.25rem; } .manual-prose li > ul, .manual-prose li > ol { margin-top: 0.25rem; }
.manual-prose a { color: #1c4e80; text-decoration: underline; } .manual-prose a { color: #1a2b4b; text-decoration: underline; }
.manual-prose a:hover { color: #2563eb; } .manual-prose a:hover { color: #2563eb; }
.manual-prose strong { font-weight: 700; color: #111827; } .manual-prose strong { font-weight: 700; color: #111827; }
.manual-prose blockquote { margin: 0.9rem 0; padding: 0.6rem 1rem; border-left: 4px solid #60a5fa; background: #eff6ff; color: #1e3a8a; border-radius: 0 6px 6px 0; } .manual-prose blockquote { margin: 0.9rem 0; padding: 0.6rem 1rem; border-left: 4px solid #60a5fa; background: #eff6ff; color: #1e3a8a; border-radius: 0 6px 6px 0; }
@@ -42,7 +42,7 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
.manual-prose th { background: #e9ecef; font-weight: 700; color: #333; } .manual-prose th { background: #e9ecef; font-weight: 700; color: #333; }
.manual-prose tbody tr:nth-child(even) td { background: #f9fafb; } .manual-prose tbody tr:nth-child(even) td { background: #f9fafb; }
.manual-prose hr { margin: 1.6rem 0; border: 0; border-top: 1px solid #e5e7eb; } .manual-prose hr { margin: 1.6rem 0; border: 0; border-top: 1px solid #e5e7eb; }
.manual-toc a.active { background: #1c4e80; color: #fff; font-weight: 700; } .manual-toc a.active { background: #1a2b4b; color: #fff; font-weight: 700; }
@media print { @media print {
.manual-toc, .manual-actions, .manual-nav { display: none !important; } .manual-toc, .manual-actions, .manual-nav { display: none !important; }
.manual-layout { display: block !important; } .manual-layout { display: block !important; }

View File

@@ -93,8 +93,8 @@ $hasResult = $result !== null && ($result['ok'] ?? false);
cursor: pointer; cursor: pointer;
} }
.num-lookup-btn-primary { .num-lookup-btn-primary {
border-color: #1c4e80; border-color: #243a5e;
background: linear-gradient(180deg, #2b6cb0 0%, #1c4e80 100%); background: linear-gradient(180deg, #2b6cb0 0%, #243a5e 100%);
color: #fff; color: #fff;
} }
.num-lookup-meta { .num-lookup-meta {

View File

@@ -104,7 +104,7 @@
<option>북구</option> <option>북구</option>
</select> </select>
</div> </div>
<button type="button" class="bg-[#2b4c8c] hover:bg-blue-800 text-white px-4 py-1.5 rounded text-sm font-medium shadow flex items-center gap-1"> <button type="button" class="bg-[#243a5e] hover:brightness-110 text-white px-4 py-1.5 rounded text-sm font-medium shadow flex items-center gap-1">
<i class="fa-solid fa-magnifying-glass"></i> 검색 <i class="fa-solid fa-magnifying-glass"></i> 검색
</button> </button>
</div> </div>

View File

@@ -21,7 +21,7 @@ $pager->setSurroundCount(2);
<?php foreach ($pager->links() as $link): ?> <?php foreach ($pager->links() as $link): ?>
<?php if ($link['active']): ?> <?php if ($link['active']): ?>
<span class="px-3 py-1 text-xs border border-blue-600 rounded bg-blue-600 text-white font-bold"><?= $link['title'] ?></span> <span class="px-3 py-1 text-xs border border-btn-search rounded bg-btn-search text-white font-bold"><?= $link['title'] ?></span>
<?php else: ?> <?php else: ?>
<a href="<?= $link['uri'] ?>" class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"><?= $link['title'] ?></a> <a href="<?= $link['uri'] ?>" class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"><?= $link['title'] ?></a>
<?php endif; ?> <?php endif; ?>

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/**
* gov-portal 공통 크롬 CSS (헤더/사이드바/레이아웃 골격).
* 대시보드·업무 셸이 공유한다. 카드/그리드 등 대시보드 전용 스타일은 포함하지 않는다.
*/
?>
:root {
--navy: #1a2b4b;
--navy-deep: #002b4e;
--blue: #0056b3;
--blue-ui: #007bff;
--blue-menu: #4a69bd;
--blue-light: #eef6ff;
--teal: #009688;
--bg: #f0f4f8;
--card: #fff;
--text: #444;
--text-dark: #222;
--muted: #888;
--border: #dde4ec;
--font-scale: 1;
}
.gov-portal-shell * { box-sizing: border-box; }
html.gov-portal-html { font-size: calc(14px * var(--font-scale)); -webkit-text-size-adjust: 100%; }
body.gov-portal-shell {
margin: 0;
font-family: 'Pretendard', 'Malgun Gothic', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.45;
letter-spacing: -0.01em;
color: var(--text);
background: var(--bg);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.gov-portal-shell .layout { display: flex; flex: 1; min-height: 0; }
/* 좌측 사이드바 (대메뉴 클릭 → 소메뉴) */
.gov-portal-shell .sidebar {
width: 188px;
flex-shrink: 0;
background: #fff;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
font-size: 0.8125rem;
}
.gov-portal-shell .my-menu-hd {
background: var(--navy);
color: #fff;
padding: 0.55rem 0.7rem;
font-weight: 700;
font-size: 0.8125rem;
letter-spacing: -0.02em;
}
.gov-portal-shell .my-menu-list { list-style: none; padding: 0.375rem 0.25rem; margin: 0; flex: 1; overflow-y: auto; }
.gov-portal-shell .my-menu-list li { margin: 0.1875rem 0.375rem; }
.gov-portal-shell .my-menu-list a,
.gov-portal-shell .my-menu-list .menu-sub {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.4375rem 0.625rem;
margin: 0;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
text-decoration: none;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.02em;
box-sizing: border-box;
transition: filter 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.gov-portal-shell .my-menu-list a { color: #fff; background: var(--blue-menu); }
.gov-portal-shell .my-menu-list a .menu-ico,
.gov-portal-shell .my-menu-list .menu-sub .menu-ico { font-size: 0.625rem; opacity: .9; width: 0.75rem; text-align: center; flex-shrink: 0; }
.gov-portal-shell .my-menu-list a:hover { filter: brightness(1.06); border-color: rgba(255, 255, 255, 0.35); }
.gov-portal-shell .my-menu-list a.active {
background: #3d5a9e;
font-weight: 700;
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 1px 3px rgba(26, 43, 75, 0.12);
}
.gov-portal-shell .my-menu-list .menu-sub { background: var(--blue-light); color: var(--blue); font-size: 0.75rem; border-color: rgba(0, 86, 179, 0.18); }
.gov-portal-shell .sidebar-blocks { margin-top: auto; }
.gov-portal-shell .sb-teal { background: var(--teal); color: #fff; padding: 0.75rem 0.625rem; font-size: 0.6875rem; line-height: 1.5; letter-spacing: -0.02em; }
.gov-portal-shell .sb-teal i { font-size: 1.125rem; margin-bottom: 0.25rem; display: block; }
.gov-portal-shell .sb-gray { background: #4a5568; color: #fff; padding: 0.625rem; font-size: 0.6875rem; line-height: 1.45; }
.gov-portal-shell .sb-links { padding: 0.625rem; background: #f5f7fa; font-size: 0.6875rem; }
.gov-portal-shell .sb-links a { display: block; color: #555; text-decoration: none; padding: 0.1875rem 0; letter-spacing: -0.02em; font-weight: 600; }
.gov-portal-shell .sb-links a:hover { color: var(--blue-ui); }
/* 본문 영역 */
.gov-portal-shell .main { flex: 1; min-width: 0; overflow-y: auto; }
.gov-portal-shell .main.work-main { padding: 0.875rem 1rem 1.25rem; background: var(--bg); }
.gov-portal-shell .work-titlebar {
display: flex; align-items: center; gap: .5rem;
font-size: 1.05rem; font-weight: 800; color: var(--text-dark);
letter-spacing: -0.03em; margin: 0 0 0.75rem;
}
.gov-portal-shell .work-titlebar .tb-ico { color: var(--blue-ui); }
.gov-portal-shell .work-surface {
background: #fff; border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 1px 3px rgba(26,43,75,.06), 0 2px 8px rgba(26,43,75,.04);
padding: 1rem 1.1rem;
}
.gov-portal-shell .work-flash { margin-bottom: 0.75rem; padding: 0.6rem 0.9rem; border-radius: 8px; font-size: 0.8125rem; }
.gov-portal-shell .work-flash.ok { background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; }
.gov-portal-shell .work-flash.err { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; }
.gov-portal-shell .portal-footer {
background: #eef2f7; border-top: 1px solid var(--border);
padding: 0.3rem 1rem; font-size: 0.6875rem; color: var(--muted);
display: flex; justify-content: space-between; align-items: center;
}
@media (max-width: 1024px) {
.gov-portal-shell .layout { flex-direction: column; }
.gov-portal-shell .sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
.gov-portal-shell .my-menu-list { display: flex; flex-wrap: wrap; }
}

View File

@@ -15,7 +15,7 @@ tailwind.config = {
colors: { colors: {
'title-bar': '#2c3e50', 'title-bar': '#2c3e50',
'control-panel': '#f8f9fa', 'control-panel': '#f8f9fa',
'btn-search': '#1c4e80', 'btn-search': '#243a5e',
'btn-exit': '#d9534f', 'btn-exit': '#d9534f',
} }
} }

View File

@@ -3,48 +3,63 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>종량제 시스템</title> <title>종량제 물류시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
extend: { extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] }, fontFamily: { sans: ['Pretendard', '"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: { colors: {
'system-header': '#ffffff', 'navy': '#1a2b4b',
'title-bar': '#2c3e50', 'title-bar': '#1a2b4b',
'control-panel': '#f8f9fa', 'portal-bg': '#f0f4f8',
'btn-search': '#1c4e80', 'btn-search': '#243a5e',
'btn-exit': '#d9534f',
} }
} }
} }
} }
</script> </script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style> <style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; letter-spacing: -0.01em; }</style>
</head> </head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased"> <body class="bg-portal-bg text-gray-700 flex flex-col h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0"> <header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
<?= view('components/header_brand') ?> <a href="<?= base_url() ?>" class="flex items-center gap-2 text-base font-bold tracking-tight hover:opacity-90">
<nav class="flex gap-4 text-sm font-medium text-gray-600"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
<a class="hover:text-blue-600" href="<?= base_url('login') ?>">로그인</a> <path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
<a class="hover:text-blue-600" href="<?= base_url('register') ?>">회원가입</a> </svg>
<span class="whitespace-nowrap">종량제 시스템</span>
</a>
<nav class="flex gap-3 text-sm font-medium">
<a class="px-3 py-1 rounded hover:bg-white/10" href="<?= base_url('login') ?>">로그인</a>
<a class="px-3 py-1 rounded hover:bg-white/10" href="<?= base_url('register') ?>">회원가입</a>
</nav> </nav>
</header> </header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
종량제 시스템 <main class="flex-grow flex items-center justify-center p-6">
</div> <section class="w-full max-w-xl bg-white border border-[#dde4ec] rounded-2xl shadow-[0_2px_12px_rgba(26,43,75,0.08)] overflow-hidden">
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center"> <div class="bg-gradient-to-br from-navy to-[#007bff] text-white px-8 py-8 text-center">
<section class="text-center max-w-lg"> <div class="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white/15 mb-3">
<p class="text-gray-700 mb-6">로그인 후 이용해 주세요.</p> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-7 w-7 text-white" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
</svg>
</div>
<h1 class="text-xl font-bold">종량제 쓰레기봉투 물류시스템</h1>
<p class="text-sm text-white/75 mt-1">발주 · 입고 · 재고 · 판매 · 수불 통합 관리</p>
</div>
<div class="px-8 py-7 text-center">
<p class="text-gray-600 mb-5 text-sm">서비스 이용을 위해 로그인해 주세요.</p>
<div class="flex gap-2 justify-center"> <div class="flex gap-2 justify-center">
<a href="<?= base_url('login') ?>" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition">로그인</a> <a href="<?= base_url('login') ?>" class="bg-btn-search text-white px-5 py-2.5 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition">
<a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">회원가입</a> <i class="fa-solid fa-right-to-bracket mr-1"></i> 로그인
</a>
<a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-5 py-2.5 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">회원가입</a>
</div>
</div> </div>
</section> </section>
</main> </main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer> <footer class="bg-[#eef2f7] border-t border-[#dde4ec] px-4 py-1.5 text-xs text-gray-500 shrink-0 text-center">종량제 물류시스템</footer>
</body> </body>
</html> </html>

View File

@@ -38,7 +38,10 @@
"config": { "config": {
"optimize-autoloader": true, "optimize-autoloader": true,
"preferred-install": "dist", "preferred-install": "dist",
"sort-packages": true "sort-packages": true,
"platform": {
"php": "8.2.30"
}
}, },
"scripts": { "scripts": {
"test": "phpunit" "test": "phpunit"

63
composer.lock generated
View File

@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "453c36cb480c6356bc20f71f8a3d1603", "content-hash": "7bbe4c2b49ac08e24480b1c9b20aab60",
"packages": [ "packages": [
{ {
"name": "codeigniter4/framework", "name": "codeigniter4/framework",
"version": "v4.7.0", "version": "v4.7.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/codeigniter4/framework.git", "url": "https://github.com/codeigniter4/framework.git",
"reference": "e7753bc03f8b74af428f46b5e2bb74925487c930" "reference": "ab9bf33caa3ccc25b1a4652234d7d0eb1d1937de"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/codeigniter4/framework/zipball/e7753bc03f8b74af428f46b5e2bb74925487c930", "url": "https://api.github.com/repos/codeigniter4/framework/zipball/ab9bf33caa3ccc25b1a4652234d7d0eb1d1937de",
"reference": "e7753bc03f8b74af428f46b5e2bb74925487c930", "reference": "ab9bf33caa3ccc25b1a4652234d7d0eb1d1937de",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -81,7 +81,7 @@
"slack": "https://codeigniterchat.slack.com", "slack": "https://codeigniterchat.slack.com",
"source": "https://github.com/codeigniter4/CodeIgniter4" "source": "https://github.com/codeigniter4/CodeIgniter4"
}, },
"time": "2026-02-01T20:39:35+00:00" "time": "2026-05-22T11:21:33+00:00"
}, },
{ {
"name": "composer/pcre", "name": "composer/pcre",
@@ -489,31 +489,31 @@
}, },
{ {
"name": "maennchen/zipstream-php", "name": "maennchen/zipstream-php",
"version": "3.2.2", "version": "3.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git", "url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-zlib": "*", "ext-zlib": "*",
"php-64bit": "^8.3" "php-64bit": "^8.2"
}, },
"require-dev": { "require-dev": {
"brianium/paratest": "^7.7", "brianium/paratest": "^7.7",
"ext-zip": "*", "ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86", "friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5", "guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6", "mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5", "php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0", "phpunit/phpunit": "^11.0",
"vimeo/psalm": "^6.0" "vimeo/psalm": "^6.0"
}, },
"suggest": { "suggest": {
@@ -555,7 +555,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues", "issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
}, },
"funding": [ "funding": [
{ {
@@ -563,7 +563,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-04-11T18:38:28+00:00" "time": "2025-01-27T12:07:53+00:00"
}, },
{ {
"name": "markbaker/complex", "name": "markbaker/complex",
@@ -832,16 +832,16 @@
}, },
{ {
"name": "phpoffice/phpspreadsheet", "name": "phpoffice/phpspreadsheet",
"version": "2.4.4", "version": "2.4.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "78bf6f0b4945ab31f1935741324ef3f0bf59a6fe" "reference": "0bbef382b7d9c1dbda10c8113d564ff9159a7e79"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/78bf6f0b4945ab31f1935741324ef3f0bf59a6fe", "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/0bbef382b7d9c1dbda10c8113d564ff9159a7e79",
"reference": "78bf6f0b4945ab31f1935741324ef3f0bf59a6fe", "reference": "0bbef382b7d9c1dbda10c8113d564ff9159a7e79",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -932,9 +932,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.4" "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.6"
}, },
"time": "2026-04-10T03:20:38+00:00" "time": "2026-06-07T02:31:12+00:00"
}, },
{ {
"name": "psr/event-dispatcher", "name": "psr/event-dispatcher",
@@ -1170,16 +1170,16 @@
}, },
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
"version": "v3.6.0", "version": "v3.7.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git", "url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1192,7 +1192,7 @@
"name": "symfony/contracts" "name": "symfony/contracts"
}, },
"branch-alias": { "branch-alias": {
"dev-main": "3.6-dev" "dev-main": "3.7-dev"
} }
}, },
"autoload": { "autoload": {
@@ -1217,7 +1217,7 @@
"description": "A generic function and convention to trigger deprecation notices", "description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
}, },
"funding": [ "funding": [
{ {
@@ -1228,12 +1228,16 @@
"url": "https://github.com/fabpot", "url": "https://github.com/fabpot",
"type": "github" "type": "github"
}, },
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{ {
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-09-25T14:21:43+00:00" "time": "2026-04-13T15:52:40+00:00"
}, },
{ {
"name": "symfony/polyfill-php80", "name": "symfony/polyfill-php80",
@@ -3168,5 +3172,8 @@
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": [], "platform-dev": [],
"platform-overrides": {
"php": "8.2.30"
},
"plugin-api-version": "2.3.0" "plugin-api-version": "2.3.0"
} }

74
e2e/redesign.spec.js Normal file
View File

@@ -0,0 +1,74 @@
const { test, expect } = require('@playwright/test');
const { login } = require('./helpers/auth');
/**
* gov-portal 디자인 전면 적용 검증
* - 메인(/) = 포털 대시보드(실데이터 카드)
* - 업무 페이지에 포털 셸(헤더+대메뉴+사이드바) 적용
* - 대메뉴 클릭 → 좌측 사이드바에 소메뉴 표시
*/
test.describe('gov-portal 전면 적용', () => {
test('메인(/)이 포털 대시보드로 렌더', async ({ page }) => {
await login(page, 'user');
await page.goto('/');
await expect(page.locator('header.portal-header')).toBeVisible();
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.getByText('봉투 재고 총량')).toBeVisible();
await expect(page.getByText('승인 대기')).toBeVisible();
// 목업 흔적(가짜 공지/지도)이 없어야 함
await expect(page.getByText('서비스 데스크')).toHaveCount(0);
});
test('업무 페이지에 포털 셸 적용', async ({ page }) => {
await login(page, 'user');
await page.goto('/bag/help');
await expect(page.locator('header.portal-header')).toBeVisible();
await expect(page.locator('#portalSidebarList')).toBeAttached();
await expect(page.locator('#portalTopNavClick')).toBeAttached();
});
test('관리자 계정은 상단에 관리자 링크 노출', async ({ page }) => {
await login(page, 'local');
await page.goto('/');
const adminLink = page.locator('header.portal-header a[href$="/admin"]');
await expect(adminLink).toBeVisible();
await expect(adminLink).toContainText('관리자');
});
test('로그인 페이지가 gov-portal 스타일(Pretendard)로 렌더', async ({ page }) => {
await page.goto('/login');
const hasPretendard = await page.evaluate(() =>
[...document.styleSheets, ...document.querySelectorAll('link')].some(
(n) => (n.href || '').includes('pretendard')));
expect(hasPretendard).toBeTruthy();
await expect(page.locator('input[name="login_id"]')).toBeVisible();
});
test('관리자 페이지에도 포털 셸 적용', async ({ page }) => {
await login(page, 'admin');
await page.goto('/admin/select-local-government');
await expect(page.locator('header.portal-header')).toBeVisible();
await expect(page.locator('#portalSidebarList')).toBeAttached();
await expect(page.locator('#portalTopNavClick')).toBeAttached();
});
test('로그인·메인 로고가 사이트 공통 로고로 통일', async ({ page }) => {
for (const url of ['/login', '/']) {
await page.goto(url);
const brand = page.locator('header a[href]').first();
await expect(brand).toContainText('종량제 시스템');
await expect(brand.locator('svg path')).toHaveCount(1); // 휴지통 SVG
}
});
test('대메뉴 클릭 시 사이드바에 소메뉴 표시', async ({ page }) => {
await login(page, 'user');
await page.goto('/');
const triggers = page.locator('.portal-nav-trigger[data-parent-idx]');
const n = await triggers.count();
test.skip(n === 0, '이 계정에 하위메뉴를 가진 대메뉴가 없음');
await triggers.first().click();
// 사이드바 목록에 항목이 채워짐
await expect(page.locator('#portalSidebarList li').first()).toBeVisible();
});
});