9 Commits

Author SHA1 Message Date
taekyoungc
912ffdbe23 feat: 워크스페이스 분할 보기(2·4분할) + 구분선 드래그 크기 조절
- 탭바에 분할 레이아웃 버튼 추가: 1분할 / 2분할(좌우) / 2분할(상하) / 4분할
  - iframe reparent 없이 absolute 위치만 재계산해 작업 상태 보존
  - 포커스된 칸에 탭 클릭으로 화면 배치, 칸 헤더(↻ 새로고침 · × 비우기)
  - 칸 안 클릭 시 해당 칸 포커스
- 분할 구분선 드래그로 칸 크기(비율) 조절, 더블클릭 50% 초기화
  - 드래그 중 투명 오버레이로 iframe 위에서도 이벤트 유지
  - 비율 12~88% 제한
- 레이아웃·칸 배치·비율을 세션에 저장/복원(계정별 격리 유지)
- 단축키를 포커스 칸 기준으로 동작하도록 정리
- 매뉴얼: [화면 구성·워크스페이스] 에 분할 보기·크기 조절 절 추가, 개요 안내 보강
- e2e: 분할 보기(2·4분할 전환) 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:19:37 +09:00
taekyoungc
4d9343e980 feat: GBLS 리브랜딩 + 매뉴얼 보강 + 워크스페이스/코드관리 UX 개선
리브랜딩
- 서비스명 "종량제 시스템" → "GBLS", 헤더 로고에 풀네임(Garbage Bag Logistics System) 병기
  (gov-portal·공통 브랜드·로그인/welcome 셸·타이틀·푸터 전반)

매뉴얼
- 신규 페이지 [로그인·회원가입·계정](01_account.md): 가입 항목·관리자 승인·2차 인증 흐름
- [화면 구성·워크스페이스·단축키]에 계정 전환 시 탭 초기화 안내 추가

워크스페이스(탭)
- 탭 전환 시 좌측 사이드바 강조 동기화(메뉴 없는 화면은 강조 해제, 경로 폴백 매칭)
- 소메뉴 좌측 아이콘(▸/·) 전부 제거 — 활성 메뉴는 배경 강조로만 구분
- 탭을 사용자(mb_idx)별로 격리: 다른 아이디 로그인 시 이전 탭 복원 안 함
- 사이드바 FAQ 링크 제거(자주 묻는 질문은 매뉴얼에 통합)

기본 코드관리 화면
- 업무현황 카드 스타일로 재디자인(가벼운 표·상태/범위 pill·단일 구분선)
- render()에 $bare 옵션 추가 → 이미 카드형인 화면은 바깥 래퍼 생략

기타
- .claude/settings.local.json(개인 권한 설정) .gitignore 추가
- e2e: 워크스페이스(동기화·계정격리) + 매뉴얼(계정·단축키·검색) 케이스 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:43:24 +09:00
taekyoungc
7e32f579e8 feat: 워크스페이스 편의 개선 + 매뉴얼에 화면구성·단축키 페이지 추가
워크스페이스(탭)
- 탭 전환 시 좌측 사이드바(대메뉴/소메뉴) 강조 자동 동기화
  - nav 스크립트에 window.govPortalNav.syncByUrl() 공개, renderSidebar(overrideHref) 확장
- 키보드 단축키(Alt 기반): Alt+1~9 탭 이동, Alt+W 닫기, Alt+[ / Alt+] 이전·다음
  - iframe 내부 포커스에서도 동작하도록 같은 출처 문서에 핸들러 부착
- 탭 가운데(휠) 클릭으로 닫기, 잘린 탭 제목 전체 툴팁

매뉴얼
- 신규 페이지 [화면 구성·워크스페이스·단축키] (05_workspace.md, 목차 2번째)
  - 화면 구성, 탭 사용법·유지 범위, 단축키 표, 이동/도움말 안내
- 개요 페이지에서 새 페이지로 안내

e2e: 워크스페이스(사이드바 동기화·가운데클릭) + 매뉴얼(새 페이지·단축키·검색) 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:09:25 +09:00
taekyoungc
1a443de02e feat: 매뉴얼 검색·소메뉴 아이콘 개선·워크스페이스 탭 세션 유지
- 매뉴얼: 전체 검색 박스(slug별 hit 카운트·스니펫)와 본문 하이라이트 추가
  - ManualRenderer::plainText()/search(), Bag::manualSearch(), bag/manual/search 라우트
- 사이드바 소메뉴 선택 아이콘 변경: 닫기처럼 보이던 × → ▸, + → · (정적/동적 일관)
- 워크스페이스: 탭 목록을 sessionStorage에 저장·복원
  - 관리자 페이지 이동 후 복귀·새로고침해도 열어둔 탭 유지(세션 범위)
  - 복원으로 무의미해진 beforeunload 새로고침 경고 제거
- e2e: 관리자 이동 후 탭 복원 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:52:53 +09:00
taekyoungc
e8d58b5837 화면별 매뉴얼·이 화면 설명 버튼·탭 새로고침/경고·최근방문 기록 보강.
- 매뉴얼: 화면(소메뉴)별 용어·버튼·필드 설명으로 확장 + 기본정보 페이지 신규,
  개요에 용어 사전 추가 (종량제 지식 없는 사용자 대상)
- "이 화면 설명" 버튼: 화면 경로→매뉴얼 매핑(Config\Manual::screenHelp,
  manual_help_url_for_path). 워크스페이스 탭은 새 탭으로, 직접 페이지는 새 창으로
- 워크스페이스: 개별 탭 새로고침(↻) 버튼, 탭 2개 이상일 때만 새로고침 경고,
  사이드바 하단 링크(매뉴얼 등)도 탭으로 열기
- 임베드: 탭 내 링크/폼 embed 유지(중첩 헤더 방지), 매뉴얼 리다이렉트 embed 유지
- 사이드바 하단: 종합그래프 → 사용자 매뉴얼 링크
- 최근 방문 메뉴: embed 페이지에도 방문 기록, 대시보드는 storage 이벤트로 실시간 갱신
- E2E qa_sweep 추가(주요 화면 콘솔/오버레이/매뉴얼/도움말 매핑 점검)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:04:41 +09:00
taekyoungc
c15e01bfa7 워크스페이스(탭) 도입 — 로그인 후 기본 화면을 탭 작업공간으로.
- /workspace: 헤더+사이드바+탭바+iframe 패널. 메뉴 클릭=탭 열기,
  전환해도 폼·스크롤·조회결과 등 작업 상태 유지(세션 동안)
- 로그인 후 / = 워크스페이스(첫 탭=대시보드). iframe 내부는 임베드 렌더
- 임베드 레이아웃(bag/layout/embed): 헤더·사이드바 없이 본문만
- 임베드 판정: ?embed=1 또는 Sec-Fetch-Dest=iframe (iframe 내 링크·폼·
  리다이렉트까지 중첩 크롬 없이 처리)
- iframe 안 세션만료 시 상위 창을 로그인으로 전환(auth/_shell)
- 포털 헤더에 워크스페이스 진입 링크, E2E(workspace.spec) 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:32:53 +09:00
taekyoungc
600a79788e 카카오 지오코딩 호출을 try/catch로 감싼다.
- 외부(카카오) SDK 예외가 미처리 예외로 전파되어 콘솔 오염·
  DevTools "예외에서 일시중지"로 화면이 멈추는 것을 방지
- geocodeChain 호출 및 마커 생성 콜백을 방어적으로 보호

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:53:02 +09:00
taekyoungc
6b1c118651 bfcache 복원 시 모달이 열린 채 남아 화면을 덮는 문제를 고친다.
- 전체화면 모달/팝업(.fixed.inset-0[id$=-modal|-popup])이 열린 상태로
  bfcache(뒤로가기/탭 복귀)에 저장·복원되면 회색 레이어가 클릭을 막던 문제
- pagehide(이탈 시)·pageshow(복원 시) 에서 해당 오버레이를 강제로 닫고
  body 스크롤 잠금 해제
- portal·admin·main 레이아웃에 공통 적용(모든 페이지 커버)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:26:19 +09:00
taekyoungc
abc8a405e1 메인 대시보드에 지정판매소 지도·메뉴검색을 추가한다.
- 카카오 지도(지도 2/3 + 판매소 목록 1/3, 높이 고정·스크롤), 목록 클릭 시 줌인
- 지오코딩 폴백(정밀→도로명→지번→키워드→행정동)으로 마커 표시
- 메뉴검색: 자동완성 드롭다운 + 기본 "최근 방문 메뉴"(localStorage, 뒤로가기/bfcache 갱신)
- 메뉴검색 박스 녹색(#009688), 지도와 높이 일치
- resolveLgLabel: 선택 지자체 실제 이름 사용, '(데모)' 문구 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:10:54 +09:00
47 changed files with 2333 additions and 276 deletions

3
.gitignore vendored
View File

@@ -176,3 +176,6 @@ blob-report/
/phpunit*.xml
# 최상위 개발 문서/스크린샷 폴더만 제외 (app/Docs 등 하위 docs 경로는 추적).
/docs/
# Claude Code 개인 권한 설정(비밀 포함) — 커밋 금지
.claude/settings.local.json

View File

@@ -23,12 +23,51 @@ class Manual extends BaseConfig
*/
public array $pages = [
'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'],
'account' => ['title' => '로그인·회원가입·계정', 'file' => '01_account.md'],
'workspace' => ['title' => '화면 구성·워크스페이스·단축키', 'file' => '05_workspace.md'],
'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'],
'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'],
'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'],
'sales' => ['title' => '판매·불출', 'file' => '40_sales_issue.md'],
'reports' => ['title' => '판매현황·수불·통계', 'file' => '50_reports.md'],
'sales' => ['title' => '판매·반품·불출·주문', 'file' => '40_sales_issue.md'],
'reports' => ['title' => '현황·리포트·수불', 'file' => '50_reports.md'],
'basic' => ['title' => '기본정보(판매소·단가·코드)', 'file' => '60_basic_info.md'],
'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'],
'faq' => ['title' => '자주 묻는 질문·문의', 'file' => '99_faq.md'],
];
/**
* 화면 경로(접두) → 그 화면을 설명하는 매뉴얼 slug.
* "이 화면 설명" 버튼이 현재 경로로 알맞은 매뉴얼 페이지를 연다.
* 더 긴(구체적) 접두가 우선하도록 길이 내림차순으로 매칭한다.
*
* @var array<string, string>
*/
public array $screenHelp = [
'bag/order/phone' => 'sales',
'bag/order' => 'order',
'bag/bag-orders' => 'order',
'bag/receiving' => 'order',
'bag/bag-receivings' => 'order',
'bag/inventory' => 'inventory',
'bag/sale' => 'sales',
'bag/sales' => 'sales',
'bag/issue' => 'sales',
'bag/bag-issues' => 'sales',
'bag/bag-sales' => 'sales',
'bag/shop-orders' => 'sales',
'bag/flow' => 'reports',
'bag/reports' => 'reports',
'bag/analytics' => 'reports',
'bag/designated-shops' => 'basic',
'bag/bag-prices' => 'basic',
'bag/prices' => 'basic',
'bag/packaging-units' => 'basic',
'bag/code-kinds' => 'basic',
'bag/code-details' => 'basic',
'bag/managers' => 'basic',
'bag/companies' => 'basic',
'bag/sales-agencies' => 'basic',
'bag/free-recipients' => 'basic',
'bag/number-lookup' => 'codes',
];
}

View File

@@ -6,6 +6,7 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes
*/
$routes->get('/', 'Home::index');
$routes->get('workspace', 'Home::workspace');
$routes->get('dashboard', 'Home::dashboard');
$routes->get('dashboard/simple', 'Home::dashboardSimple');
$routes->get('dashboard/compact', 'Home::dashboardCompact');
@@ -59,6 +60,7 @@ $routes->get('bag/help', 'Bag::help');
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용
$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void {
$routes->get('manual', 'Bag::manual');
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
});

View File

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

View File

@@ -212,12 +212,15 @@ class Bag extends BaseController
return $row ? trim((string) ($row->mg_name ?? '')) : '';
}
private function render(string $title, string $viewFile, array $data = []): string
private function render(string $title, string $viewFile, array $data = [], bool $bare = false): string
{
// 사이트 업무 페이지 공통 셸: gov-portal 디자인(헤더+대메뉴+클릭형 좌측 사이드바).
return view('bag/layout/portal', [
// /workspace 탭(iframe) 안에서는 임베드 레이아웃(헤더·사이드바 없이 본문만).
$layout = $this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal';
return view($layout, [
'title' => $title,
'content' => view($viewFile, $data),
'bare' => $bare, // true면 바깥 카드 래퍼 없이 본문을 그대로(이미 카드형 화면용)
]);
}
@@ -589,7 +592,7 @@ class Bag extends BaseController
'selectedKind' => $selectedKind,
'detailList' => $detailList,
'rowCanEdit' => $rowCanEdit,
]);
], true); // 본문이 이미 카드 2개라 바깥 래퍼 생략
}
/**
@@ -3578,8 +3581,23 @@ SQL);
public function manual(): \CodeIgniter\HTTP\RedirectResponse
{
$first = (new \App\Libraries\ManualRenderer())->firstSlug();
$url = site_url('bag/manual/' . $first);
if ($this->isEmbeddedRequest()) {
$url .= '?embed=1'; // 워크스페이스 탭 안에서는 임베드 유지(중첩 헤더 방지)
}
return redirect()->to(site_url('bag/manual/' . $first));
return redirect()->to($url);
}
/**
* 사용자 매뉴얼 전체 검색 (JSON). q 와 일치하는 페이지·스니펫 목록.
*/
public function manualSearch(): \CodeIgniter\HTTP\ResponseInterface
{
$q = (string) ($this->request->getGet('q') ?? '');
$results = (new \App\Libraries\ManualRenderer())->search($q);
return $this->response->setJSON(['q' => $q, 'results' => $results]);
}
/**

View File

@@ -48,6 +48,20 @@ abstract class BaseController extends Controller
*
* @param array<string, mixed> $contentData
*/
/**
* 워크스페이스 탭(iframe) 안에서 열린 요청인지. ?embed=1 또는 Sec-Fetch-Dest=iframe.
* iframe 내 링크 이동·폼 전송·리다이렉트까지 모두 임베드로 처리되도록 헤더로도 판정한다.
*/
protected function isEmbeddedRequest(): bool
{
if ($this->request->getGet('embed') !== null) {
return true;
}
$dest = strtolower(trim((string) $this->request->getHeaderLine('Sec-Fetch-Dest')));
return $dest === 'iframe' || $dest === 'frame';
}
protected function renderWorkPage(string $title, string $contentView, array $contentData = []): string
{
$content = view($contentView, $contentData);
@@ -61,8 +75,8 @@ abstract class BaseController extends Controller
$path = substr($path, strlen('index.php/'));
}
if ($path === 'bag' || str_starts_with($path, 'bag/')) {
// 사이트 업무 페이지: gov-portal 디자인 셸 적용
return view('bag/layout/portal', [
// /workspace 탭(iframe) 안에서는 임베드 레이아웃, 아니면 gov-portal
return view($this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal', [
'title' => $title,
'content' => $content,
]);

View File

@@ -10,21 +10,39 @@ class Home extends BaseController
public function index()
{
if (session()->get('logged_in')) {
// 메인(/) — gov-portal 디자인 셸 + 종량제 실데이터 대시보드.
helper('admin');
return view('bag/layout/portal', [
// 워크스페이스 탭(iframe) 안: 대시보드 본문을 임베드로.
if ($this->isEmbeddedRequest()) {
return view('bag/layout/embed', [
'title' => '업무 현황',
'bare' => true,
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
]);
}
// 로그인 후 기본 화면 = 워크스페이스(탭). 메뉴를 탭으로 열어 작업 상태 유지.
return view('bag/layout/workspace');
}
return view('welcome_message');
}
/**
* 메인 대시보드용 — 종량제 시스템에 실제 존재하는 데이터만 집계.
* 워크스페이스 — 메뉴를 탭(iframe)으로 열어두고 작업 상태를 유지하는 화면.
*/
public function workspace()
{
if (! session()->get('logged_in')) {
return redirect()->to(base_url('login'));
}
helper('admin');
return view('bag/layout/workspace');
}
/**
* 메인 대시보드용 — GBLS에 실제 존재하는 데이터만 집계.
*
* @return array<string, mixed>
*/
@@ -104,6 +122,34 @@ class Home extends BaseController
$pendingApprovals = 0;
}
// 지도용 — 현재 지자체 지정판매소(이름·주소). 좌표는 클라이언트(카카오 지오코딩)에서 변환.
$mapShops = [];
try {
if ($lgIdx !== null && $db->tableExists('designated_shop')) {
$rows = $db->table('designated_shop')
->select('ds_name, ds_addr, ds_addr_jibun')
->where('ds_lg_idx', $lgIdx)
->where('ds_addr IS NOT NULL')
->where('ds_addr <>', '')
->orderBy('ds_idx', 'ASC')
->limit(40)
->get()->getResultArray();
foreach ($rows as $r) {
$addr = trim((string) ($r['ds_addr'] ?? ''));
if ($addr === '') {
continue;
}
$mapShops[] = [
'name' => (string) ($r['ds_name'] ?? ''),
'addr' => $addr,
'jibun' => trim((string) ($r['ds_addr_jibun'] ?? '')),
];
}
}
} catch (\Throwable $e) {
$mapShops = [];
}
// 최근 활동(activity_log) — 실제 변경 이력
$actionLabel = ['create' => '등록', 'update' => '수정', 'delete' => '삭제', 'cancel' => '취소'];
$tableLabel = [
@@ -142,9 +188,44 @@ class Home extends BaseController
'stockMix' => $stockMix,
'lowStock' => $lowStock,
'recentActivity' => $recent,
'mapShops' => $mapShops,
'kakaoJsKey' => config(\Config\Kakao::class)->javascriptKey,
'menuSearchOptions' => (function_exists('gov_portal_nav_context') && function_exists('gov_portal_menu_search_options'))
? gov_portal_menu_search_options(gov_portal_nav_context(false)['navItems'])
: [],
'menuFlat' => $this->buildMenuFlat(),
];
}
/**
* 메뉴검색 자동완성용 — 사이트 메뉴를 (상위·메뉴명·URL) 평탄 목록으로.
*
* @return list<array{parent:string,name:string,url:string}>
*/
private function buildMenuFlat(): array
{
if (! function_exists('gov_portal_nav_context')) {
return [];
}
$flat = [];
foreach (gov_portal_nav_context(false)['navItems'] as $parent) {
$pName = (string) ($parent['name'] ?? '');
if (! empty($parent['children'])) {
foreach ($parent['children'] as $child) {
$url = (string) ($child['url'] ?? '');
if ($url === '') {
continue;
}
$flat[] = ['parent' => $pName, 'name' => (string) ($child['name'] ?? ''), 'url' => $url];
}
} elseif (! empty($parent['url'])) {
$flat[] = ['parent' => '', 'name' => $pName, 'url' => (string) $parent['url']];
}
}
return $flat;
}
/**
* 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문
*/
@@ -376,9 +457,14 @@ class Home extends BaseController
protected function resolveLgLabel(): string
{
try {
$idx = session()->get('mb_lg_idx');
if ($idx === null || $idx === '') {
return '로그인 지자체 (미지정)';
helper('admin');
$idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
if ($idx === null) {
$raw = session()->get('mb_lg_idx');
$idx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
if ($idx === null) {
return '지자체 미지정';
}
$row = model(LocalGovernmentModel::class)->find((int) $idx);
if ($row && isset($row->lg_name) && $row->lg_name !== '') {
@@ -388,7 +474,7 @@ class Home extends BaseController
// 테이블 미생성 등
}
return '북구 (데모)';
return '지자체';
}
}

View File

@@ -1,49 +1,50 @@
# 시작하기 · 시스템 개요
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. 이 문서는 실제 업무를 처리하는 담당자(지자체 관리자·지정판매소)를 위한 **빠른 시작 안내서**입니다.
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. **이 시스템을 처음 쓰는 분**도 화면을 이해하고 업무를 처리할 수 있도록, 화면마다 쓰이는 용어와 버튼의 의미를 설명합니다.
## 1. 시스템은 무엇을 하나요?
## 1. 시스템은 무엇을 하나요?
지자체 종량제 쓰레기봉투의 **발주 → 입고 → 재고 → 판매/불출 → 정산·통계** 전 과정을 관리합니다.
지자체가 주민에게 파는 **종량제 쓰레기봉투**가 ① 제작업체에 **주문(발주)** 되고 → ② 창고로 **들어오고(입고)** → ③ **재고**로 보관되다가 → ④ 동네 가게(지정판매소)에 **팔리거나(판매)** 무료 대상자에게 **나눠지는(불출)** 전 과정을 컴퓨터로 관리합니다. 마지막엔 ⑤ 얼마나 팔렸는지 **집계·정산(현황/리포트)** 합니다.
- 봉투를 제작업체에 **발주**하고, 들어온 물량을 **입고** 처리합니다.
- 현재 **재고**를 확인하고, 실사로 실제 수량과 맞춥니다.
- 지정판매소에 **판매**하거나 무료 대상자에게 **불출**합니다.
- 일계표·기간별·연간 등 **판매현황**과 **수불·통계**를 조회합니다.
## 2. 꼭 알아야 할 기본 용어 (용어 사전)
## 2. 로그인과 화면 구성
| 용어 | 쉬운 설명 |
|---|---|
| **발주** | 봉투를 제작업체에 "이만큼 만들어 주세요"라고 **주문**하는 것 |
| **입고** | 주문한 봉투가 창고에 **도착해 들여놓는** 것 |
| **재고** | 지금 창고에 **남아 있는 봉투 수량** |
| **불출** | 봉투를 창고에서 **꺼내 내보내는** 것 (주로 무료 배부) |
| **수불(受拂)** | **들어오고(수입)·나가는(불출)** 움직임을 적은 장부 |
| **지정판매소** | 봉투를 파는 **동네 가게**(편의점·마트 등) |
| **대행소(판매대행소)** | 봉투 **배송·유통을 대행**하는 업체 |
| **실사** | 컴퓨터 기록과 **실제 창고 수량이 맞는지 직접 세어 확인**하는 것 |
| **박스 / 팩 / 낱장** | 포장 단위. **박스 > 팩 > 낱장(봉투 1장)**. 1박스 = 여러 팩, 1팩 = 여러 낱장 |
| **LOT(로트)** | 한 번의 발주 묶음에 부여되는 **추적용 일련번호** |
| **바코드(봉투번호)** | 박스·팩·낱장마다 붙는 **고유 번호**(스캔용) |
| **무료용 / 공공용** | 주민 무료 배부용 / 공공기관 사용용 봉투 구분 |
1. 발급받은 아이디·비밀번호로 로그인합니다.
2. 관리자(지자체/슈퍼)는 보안을 위해 **2차 인증(OTP)** 이 적용될 수 있습니다.
3. 로그인하면 상단에 **대메뉴**가 보이고, 각 대메뉴에 마우스를 올리면 **소메뉴**가 펼쳐집니다.
4. 화면 상단의 제목 바에 현재 보고 있는 화면 이름이 표시됩니다.
> 박스·팩·낱장·LOT·바코드의 자세한 규칙은 좌측 목차 **[봉투·LOT·바코드 코드체계]** 참고.
> 슈퍼 관리자는 작업할 **지자체를 먼저 선택**해야 업무 화면이 열립니다.
## 3. 화면 구성과 사용법
## 3. 사용자 역할(권한)
- 로그인하면 **워크스페이스**(탭 작업공간)가 열립니다. 상단에 대분류 메뉴, **대분류를 클릭하면 왼쪽에 소메뉴**가 펼쳐집니다.
- 메뉴를 클릭하면 **탭으로 열립니다.** 여러 화면을 열어두고 전환해도 입력하던 내용이 유지됩니다.
- 각 화면에서 **"이 화면 설명"(❓) 버튼**을 누르면, 그 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
시스템은 4단계 역할로 접근 권한을 구분합니다.
> 탭 사용법, **분할 보기(2·4분할)·구분선 드래그로 크기 조절**, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요.
| 레벨 | 역할 | 할 수 있는 일 |
|---|---|---|
| 1 | 일반 사용자 | 기본 조회 |
| 2 | 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
| 3 | 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
| 4 | 슈퍼 관리자 | 전체 시스템 관리 + 기본코드 등 마스터 관리 (지자체 선택 후 작업) |
## 4. 사용자 역할(권한)
### 역할별 접근 한눈에 보기
| 역할 | 할 수 있는 일 |
|---|---|
| 일반 사용자 | 기본 조회 |
| 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
| 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
| 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) |
| 기능군 | 일반 | 판매소 | 지자체관리자 | 슈퍼 |
|---|:--:|:--:|:--:|:--:|
| 기본정보 조회(코드·단가·포장) | △ | ○ | ○ | ○ |
| 기본코드·마스터 편집 | ✕ | ✕ | △(지자체분) | ○ |
| 발주·입고·재고·불출 | ✕ | ✕ | ○ | ○ |
| 판매·반품 등록 | ✕ | ○ | ○ | ○ |
| 판매현황·수불·통계 | ✕ | △(자기분) | ○ | ○ |
## 5. 화면별 설명은 어디에?
(○ 사용 가능 · △ 제한적 · ✕ 불가)
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.
- **발주·입고** / **재고·실사** / **판매·반품·불출·주문** / **현황·리포트·수불** / **기본정보(판매소·단가·코드)**
## 4. 다음 단계
- 전체 업무가 어떻게 이어지는지 먼저 보려면 **[핵심 업무 흐름]** 으로 이동하세요.
- 특정 작업 방법은 좌측 목차에서 해당 항목(발주·입고 / 재고·실사 / 판매·불출 / 판매현황·수불·통계)을 선택하세요.
각 화면 설명은 **그 화면 고유의 용어·입력 항목·버튼·작업 순서**만 담았습니다.

View File

@@ -0,0 +1,61 @@
# 로그인 · 회원가입 · 계정
시스템을 쓰려면 **계정(아이디)** 이 필요합니다. 이 페이지는 회원가입부터 로그인, 2차 인증, 로그아웃까지의 과정을 설명합니다.
## 1. 회원가입
로그인 화면 아래쪽 **[회원가입]** 을 눌러 신청합니다. 입력 항목은 다음과 같습니다.
| 항목 | 필수 | 설명 |
|---|:---:|---|
| **아이디** | 필수 | 로그인에 쓰는 ID. **4자 이상**, 이미 쓰는 아이디는 사용할 수 없습니다. |
| **비밀번호** | 필수 | **4자 이상**. 안전을 위해 영문·숫자·기호를 섞는 것을 권장합니다. |
| **비밀번호 확인** | 필수 | 위 비밀번호와 똑같이 한 번 더 입력(오타 방지). |
| **이름** | 필수 | 담당자 이름. |
| **이메일** | 선택 | 안내용. 입력 시 형식 검사를 합니다. |
| **연락처** | 선택 | 전화번호. |
| **지자체** | 선택 | 소속 지자체. 해당되면 선택합니다. |
| **사용자 역할** | 필수 | 신청할 권한(아래 표 참고). 실제 권한은 **관리자 승인 시 확정**됩니다. |
> 이메일·연락처 같은 개인정보는 **암호화되어 저장**됩니다.
### 신청할 수 있는 역할
| 역할 | 주로 하는 일 |
|---|---|
| **일반 사용자** | 기본 조회 |
| **지정판매소** | 봉투 판매·반품, 자기 판매 현황 조회 |
| **지자체 관리자** | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
### 가입 후 — 관리자 승인 대기
회원가입을 제출하면 **바로 로그인되지 않고 "승인 대기" 상태**가 됩니다.
- **관리자가 승인하면** 그때부터 로그인할 수 있습니다.
- 승인 전에 로그인하면 *"관리자 승인 후 로그인 가능합니다."* 안내가 나옵니다.
- 승인이 **반려**되면 *"승인이 반려되었습니다. 관리자에게 문의해 주세요."* 가 나옵니다 — 담당 관리자에게 문의하세요.
## 2. 로그인
로그인 화면에서 **아이디**와 **비밀번호**를 입력합니다.
- 성공하면 **워크스페이스**(탭 작업공간)로 들어갑니다.
- 아이디·비밀번호가 틀리면 안내 메시지가 나옵니다. (승인 대기/반려 상태도 위와 같이 안내됩니다.)
### 2차 인증(OTP)
보안 설정에 따라 비밀번호 확인 뒤 **2차 인증** 단계가 나올 수 있습니다.
- **이미 OTP를 등록한 경우** — 스마트폰 인증 앱(예: Google Authenticator)에 표시되는 **6자리 숫자**를 입력합니다.
- **처음 사용하는 경우** — 화면의 **QR코드 또는 설정 키**를 인증 앱에 등록한 뒤, 앱에 나온 6자리 숫자로 설정을 완료합니다. 이후 로그인부터 OTP를 입력하게 됩니다.
> OTP 숫자는 일정 시간마다 바뀌므로, **현재 표시된 숫자**를 입력해야 합니다. 휴대폰 시간이 자동(네트워크 시간)으로 맞춰져 있어야 정확합니다.
## 3. 로그아웃
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
## 4. 비밀번호·계정 문제
- **비밀번호를 바꾸거나 분실**한 경우, 계정·권한 변경은 **담당 관리자**가 처리합니다. 관리자에게 문의하세요.
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.

View File

@@ -0,0 +1,92 @@
# 화면 구성 · 워크스페이스 · 단축키
이 시스템은 **여러 작업 화면을 탭으로 열어 두고 오가며** 일할 수 있도록 만들어졌습니다(웹 브라우저의 탭과 비슷합니다). 이 페이지는 화면이 어떻게 구성되는지, 탭을 어떻게 쓰는지, 그리고 빠르게 쓰는 단축키를 설명합니다.
## 1. 전체 화면 구성
로그인하면 **워크스페이스**(탭 작업공간)가 기본으로 열립니다. 화면은 크게 네 부분입니다.
| 영역 | 위치 | 설명 |
|---|---|---|
| **상단 헤더** | 맨 위 | 로고, **대분류 메뉴**, 오른쪽에 소속 지자체·접속자·관리자·로그아웃 |
| **왼쪽 사이드바** | 좌측 | 현재 선택한 대분류의 **소메뉴 목록**. 아래에는 매뉴얼·FAQ 링크 |
| **탭바** | 본문 위 | 지금 열어 둔 작업 화면들의 **탭 목록** |
| **본문** | 가운데 | 현재 탭의 실제 작업 화면 |
> **대분류를 클릭하면** 왼쪽 사이드바에 그 안의 소메뉴가 펼쳐집니다. 현재 위치한 메뉴는 왼쪽에서 **▸** 로 표시됩니다.
## 2. 탭(워크스페이스) 사용법
- **메뉴를 클릭하면 탭으로 열립니다.** 같은 메뉴를 다시 누르면 새 탭을 또 만들지 않고 **이미 열린 탭으로 이동**합니다.
- **탭을 전환해도 작업 내용이 유지됩니다.** A 화면에 무언가 입력하다가 B 화면을 잠깐 보고 와도, A 탭의 입력은 그대로 남아 있습니다.
- **탭 ↔ 왼쪽 메뉴 연동:** 탭을 전환하면 왼쪽 사이드바의 강조 위치도 그 탭의 메뉴로 **자동으로 따라갑니다.**
- 탭이 많아지면 가로로 스크롤되며, **최대 12개**까지 열 수 있습니다. 12개를 넘기면 가장 오래된 탭이 자동으로 닫힙니다.
### 탭의 버튼
| 버튼 | 동작 |
|---|---|
| **↻** (탭 위) | 그 **탭만** 새로고침합니다. 다른 탭은 그대로 둡니다. |
| **×** (탭 위) | 그 탭을 닫습니다. |
| 탭을 **가운데(휠) 클릭** | 마우스 휠을 누르면 그 탭이 닫힙니다(브라우저와 동일). |
| 탭에 **마우스를 올리면** | 이름이 길어 잘렸을 때 **전체 제목**이 말풍선으로 보입니다. |
### 탭이 유지되는 범위
- **브라우저를 새로고침**하거나 **관리자 페이지에 갔다가 돌아와도** 열어 두었던 탭이 **다시 복원**됩니다.
- 단, **브라우저 탭(창)을 완전히 닫으면** 작업공간은 초기화됩니다. (이 유지는 "이번 접속 동안"만 적용됩니다.)
- **다른 아이디로 로그인하면** 이전 사용자의 탭은 복원되지 않고 **기본 화면으로 초기화**됩니다. (계정별로 분리됩니다.)
- 복원되는 것은 **열려 있던 화면 목록**입니다. 관리자 페이지를 거치는 등 작업공간을 완전히 벗어났던 경우, 각 화면은 새로 불러와지므로 **입력 중이던 폼 내용까지 그대로 살아나지는 않습니다.**
## 3. 분할 보기 (여러 화면 한눈에)
여러 화면을 **동시에 펼쳐 놓고** 비교하거나 함께 작업할 수 있습니다. 탭바 오른쪽의 **분할 버튼**으로 화면을 나눕니다.
| 버튼 | 모양 | 설명 |
|---|---|---|
| **1분할** | □ | 한 화면만 크게 (기본) |
| **2분할 (좌우)** | ◫ | 화면을 왼쪽/오른쪽 두 칸으로 |
| **2분할 (상하)** | ⬓ | 화면을 위/아래 두 칸으로 |
| **4분할** | ⊞ | 2×2 네 칸으로 |
### 칸에 화면 배치하기
- 분할하면 열어 둔 화면들이 칸에 자동으로 채워집니다. 빈 칸에는 안내 문구가 표시됩니다.
- **칸을 클릭하면 그 칸이 "선택"**(파란 테두리)됩니다. 이 상태에서 **위 탭바의 탭을 클릭**하면 그 화면이 **선택된 칸**에 들어갑니다.
- 각 칸 위의 **헤더**에는 화면 이름과 함께 **↻(이 칸 새로고침)·×(이 칸 비우기)** 버튼이 있습니다.
### 칸 크기 조절
- 칸 사이의 **구분선에 마우스를 올리면 ↔/↕ 커서**가 나타납니다. **드래그**하면 칸 크기(비율)를 자유롭게 조절할 수 있습니다.
- 구분선을 **더블클릭**하면 **50:50으로 초기화**됩니다.
- 조절한 분할 모양·크기도 새로고침·관리자 왕복 후 **그대로 복원**됩니다.
> 분할 상태에서도 각 화면의 작업 내용은 그대로 유지됩니다. 마음껏 나눴다 합쳤다 해도 입력하던 내용이 사라지지 않습니다.
## 4. 키보드 단축키
자주 쓰는 동작은 키보드로 더 빠르게 할 수 있습니다. 브라우저 기본 단축키와 겹치지 않도록 **Alt 키**를 함께 누르는 방식입니다.
동작은 **현재 선택된 칸**을 기준으로 적용됩니다(1분할이면 그 한 화면).
| 단축키 | 동작 |
|---|---|
| **Alt + 1 ~ 9** | **n번째 탭**을 선택된 칸에 표시 |
| **Alt + W** | 선택된 칸의 **탭 닫기** |
| **Alt + ]** | 선택된 칸을 **다음 탭**으로 |
| **Alt + [** | 선택된 칸을 **이전 탭**으로 |
> macOS 에서도 동일하게 **Option(⌥)** 키가 Alt 역할을 합니다 (예: ⌥ + 1).
>
> 참고: `Ctrl/⌘ + W`, `Ctrl/⌘ + 숫자`, `Ctrl + Tab` 은 **브라우저 자체가 먼저 가로채기** 때문에 이 시스템에서 다른 용도로 바꿀 수 없어, 위와 같이 Alt 조합을 사용합니다.
## 5. 그 밖의 이동
- **관리자** 버튼(헤더 오른쪽, 관리자 권한일 때) — 메뉴·코드·판매소 등 **관리자 설정 화면**으로 이동합니다. 갔다가 워크스페이스로 돌아오면 열어 두었던 탭이 복원됩니다.
- **지자체 선택**(왼쪽 사이드바 아래) — 슈퍼 관리자가 **작업할 지자체를 바꿀 때** 사용합니다.
- **로그아웃**(헤더 오른쪽) — 시스템에서 나갑니다.
## 6. 도움말 보는 법
- 각 작업 화면의 **"이 화면 설명"(❓) 버튼** — 지금 보고 있는 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
- 이 매뉴얼 왼쪽 위 **검색창** — 모든 매뉴얼 페이지에서 단어를 찾아, 결과를 누르면 해당 페이지의 그 단어 위치로 이동해 **노란색으로 표시**해 줍니다.

View File

@@ -1,41 +1,66 @@
# 발주 · 입고
제작업체에 봉투를 주문(발주)하고, 도착한 물량을 시스템에 등록(입고)하는 단계입니다. **지자체 관리자** 이상이 사용합니다.
봉투를 제작업체에 **주문(발주)** 하고, 도착한 봉투를 창고에 **들여놓는(입고)** 단계입니다.
## 발주
---
### 발주 등록
## 발주 등록 · *발주 입고 관리 발주 등록*
**발주 입고 관리 발주 등록**
봉투를 **얼마나 주문할지** 입력해 발주서를 만드는 화면입니다. 저장하면 추적용 **LOT 번호**가 자동으로 붙습니다.
1. 봉투 **품목**(종류·용량)과 **수량**, 납품 관련 정보를 입력합니다.
2. 박스/낱장 수량과 금액·총계가 자동으로 계산됩니다.
3. 저장하면 발주 건이 생성되고, 추적용 **LOT 번호**가 자동 부여됩니다.
**이 화면의 용어**
- **발주가능봉투**: 조달청(나라장터)에 등록되어 주문할 수 있는 봉투 종류.
- **입고처**: 들어온 봉투를 받을 창고/장소.
- **조달수수료**: 발주 금액에 붙는 수수료율(%).
- **Box당 팩 / 팩당 낱장 / 1박스 총 낱장**: 포장 환산 정보(참고용 표).
> 발주 내용은 무결성 보호를 위해 버전·해시로 관리됩니다. 수정(재발주) 시 기존 LOT는 유지됩니다.
**입력 항목**: 발주월, 발주일, 협회, 제작업체, 입고처, **봉투 품목별 수량(박스 단위)**.
### 발주 변경 · 현황
**버튼**: `발주`(저장) · `변경 저장`(수정 시) · `취소`.
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 발주 변경 | 발주 변경 | 기존 발주 수정·재발주 |
| 발주 현황 | 발주 현황 | 발주 목록을 기간·상태로 조회, 엑셀 내보내기 |
| 발주 상세 | (현황에서 행 선택) | 개별 발주 상세 확인, 취소 처리 |
**작업 순서**
1. 발주월·발주일, 제작업체·입고처를 고릅니다.
2. 아래 봉투 종류별로 **주문할 박스 수량**을 입력합니다(금액·총 낱장은 자동 계산).
3. `발주` 를 누르면 발주가 생성되고 LOT 번호가 부여됩니다.
## 입고
---
발주분이 실제 도착하면 입고로 등록합니다. 입고 시 **박스·팩·낱장 바코드**가 생성되어 재고에 반영됩니다.
## 발주 현황 · *발주 입고 관리 발주 현황*
| 방식 | 메뉴 | 언제 사용 |
|---|---|---|
| 스캐너 입고 | 발주 입고[스캐너] | 바코드를 스캔하며 입고 |
| 일괄 입고 | 일괄입고 | 다량을 한 번에 입고 |
| 입고 현황 | 입고 현황 | 입고 기록 조회, 엑셀 내보내기 |
낸 발주를 **조회·관리**하는 목록 화면입니다.
### 입고 처리 순서
**필터**: 발주기간(월~월) · 제작업체 · 품명 · **입고구분(전체/입고완료/미입고)**.
1. 입고할 발주 건(LOT)을 선택합니다.
2. 도착 수량(박스/낱장)을 확인·입력합니다.
3. 저장하면 재고가 증가하고, 단위별 바코드가 부여됩니다.
**표 컬럼**: 발주일자 · 제작업체 · 품명 · **발주수량 · 입고수량 · 미입고수량** · 발주금액 · 입고처 · 비고.
- **미입고수량** = 발주했지만 아직 안 들어온 수량.
> 입고가 끝나면 **재고 관리**에서 수량이 정상 반영됐는지 확인하세요.
**버튼**: `엑셀저장` · `인쇄` · `발주등록`. (목록에서 개별 발주의 상세·취소 가능)
---
## 입고 처리 · *발주 입고 관리 입고[스캐너] / 일괄입고*
도착한 봉투를 시스템에 **들여놓는** 화면입니다. 입고하면 박스·팩·낱장 **바코드가 생성**되고 재고가 늘어납니다.
**이 화면의 용어**
- **인계자(제작업체)** / **인수자(대행소)**: 봉투를 넘기는 쪽 / 받는 쪽.
- **입고량(매)**: 실제로 들어온 **낱장 수**("매" = 장).
- **LOT NO / 발주 NO**: 어떤 발주분인지 식별하는 번호.
**입고[스캐너]**: 발주 건을 보고 행마다 **입고량(매)** 을 직접 입력 → `입고처리`.
**일괄입고**: 여러 발주 건을 **체크박스로 골라** 한 번에 입고. 미입고량은 파란색으로 강조됩니다.
**작업 순서**
1. 제작업체·인수자·인계자·입고일을 고릅니다.
2. 들어온 만큼 **입고량(매)** 을 입력(또는 일괄 선택)합니다.
3. `입고처리` → 재고 반영. **재고 관리**에서 수량이 늘었는지 확인하세요.
---
## 입고 현황 · *발주 입고 관리 입고 현황*
입고 기록을 기간별로 조회합니다.
**필터**: 입고기간 · 제작업체 · 품명 · 입고구분(전체/완료/미완료).
**표 컬럼**: 입고일자 · 품명 · 입고수량 · 발주일자 · 발주수량 · 발주번호 · 제작업체 · **입고여부(완료/미완료)** · 입고처 · 비고.
**버튼**: `엑셀저장` · `인쇄`.

View File

@@ -1,39 +1,39 @@
# 재고 · 실사
현재 보유 봉투 수량을 확인하고, 정기적으로 실사를 통해 실제 수량 맞추는 단계입니다.
지금 창고에 **남은 봉투**를 확인하고, 컴퓨터 기록과 **실제 수량 맞추는(실사)** 단계입니다.
## 재고 현황
---
**재고 관리 재고 현황**
## 재고 현황 · *재고 관리 재고 현황*
- 품목별·상태별 현재 재고를 조회합니다.
- 지자체·봉투 종류 등으로 필터링할 수 있습니다.
- **엑셀 내보내기**로 목록을 저장할 수 있습니다.
품목별로 **현재 남은 수량**을 봅니다.
| 항목 | 설명 |
|---|---|
| 품목 | 봉투 종류·용량 |
| 재고 수량 | 입고 (판매 + 불출 + 파기) |
| 상태 | 재고/판매 등 단위별 상태 |
**이 화면의 용어**
- **시군구재고**: 지자체(시·군·구) **창고**에 있는 재고.
- **대행소재고**: 배송 **대행소**가 보유 중인 재고.
- **계**: 둘을 합친 총 재고.
## 실사 (재고 조사)
**필터**: 기준일자 · 대행소(전체/선택).
**표 컬럼**: 품목구분 · 봉투/스티커종류 · **계 · 시군구재고 · 대행소재고**.
**버튼**: `조회` · `엑셀저장` · `인쇄` · `실사선별조회`.
장부상 재고와 실제 창고 수량을 맞추는 작업입니다. 다음 순서로 진행합니다.
---
```
실사 선별 ─→ 실사 등록(작업) ─→ 적용
```
## 실사 (재고 확인) · *재고 관리 실사 선별 조회 / 실사 선별 관리*
| 단계 | 메뉴 | 하는 일 |
|---|---|---|
| ① 선별 | 실사 선별 조회 | 실사 대상 범위를 골라 선별 |
| ② 작업 | 실사 선별 관리 | 실제 수량을 입력·기록 |
| ③ 상세·적용 | 실사 상세 | 실사 결과를 확인하고 재고에 **적용** |
**실사**는 시스템에 적힌 수량(전산재고)과 **창고에서 직접 센 수량(실사재고)** 을 비교해 차이를 바로잡는 작업입니다.
### 실사 진행 순서
**이 화면의 용어**
- **전산재고**: 시스템 기록상 수량.
- **실사재고**: 현장에서 직접 센 수량(직접 입력).
- **차이**: 실사 전산. (양수 = 더 많음, 음수 = 부족)
- **박스 / 팩 / 낱장**: 셀 단위. 팩코드·낱장(시작~끝) 구간으로 표시됩니다.
1. **선별**: 실사할 품목·구간을 선택해 실사 건을 만듭니다.
2. **등록**: 실제 센 수량을 입력합니다. 장부 수량과의 차이가 표시됩니다.
3. **적용**: 검토 후 적용하면 차이가 재고에 반영됩니다.
**작업 순서**
1. **실사 선별**: 실사할 기간·품목을 골라 대상 목록을 만듭니다(팝업에서 작업일자·품목 선택).
2. **실사재고 입력**: 팩/박스별로 실제 센 수량을 입력하면 **차이**가 자동 표시됩니다.
3. **저장(적용)**: 검토 후 적용하면 차이가 재고에 반영됩니다.
> 적용 전까지는 재고에 영향을 주지 않으므로, 입력 도중 중단해도 안전합니다.
**주요 표 컬럼**: 팩코드 · 포장량 · 재고(전산) · **실사재고(입력)** · 차이 · 낱장(시작) · 낱장(끝).
> 적용 전까지는 재고에 영향을 주지 않으므로, 세는 도중 중단해도 안전합니다.

View File

@@ -1,46 +1,83 @@
# 판매 · 불출
# 판매 · 반품 · 불출 · 주문
재고를 외부로 내보내는 두 가지 경로입니다. **판매**는 지정판매소 대상 유상 거래, **불출**무료 대상자에 대한 무상 지급입니다.
재고를 외부로 내보내는 단계입니다. **판매**(가게에 유상 공급)·**불출**(무료 배부)·**주문 접수**(전화 등).
## 판매 (지정판매소)
---
**판매 관리** 메뉴에서 처리합니다.
## 지정판매소 판매 · *판매 관리 지정 판매소 판매*
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 판매 등록 | 지정 판매소 판매 | 판매소를 선택해 판매 거래 기록 |
| 스캔 판매 | 지정 판매소 판매[스캔] | 바코드를 스캔하며 판매(판매소 현장용) |
| 판매 취소 | 지정 판매소 판매 취소 | 기존 판매 거래 취소 |
| 반품 | 지정 판매소 반품 | 판매분 반품 등록 |
| 반품 취소 | 지정 판매소 반품 취소 | 반품 취소 처리 |
동네 가게(지정판매소)에 봉투를 **판매**하고, 어떤 봉투를 줬는지 **바코드로 기록**합니다.
### 판매 등록 순서
**이 화면의 용어**
- **판매소코드/상호/대표자**: 판매하는 가게 정보(검색해서 선택).
- **봉투코드(스캔)**: 내보내는 봉투의 바코드. 스캔/입력하면 어떤 LOT·포장단위인지 식별됩니다.
- **포장단위(Box/Pack/Sheet)**: 박스/팩/낱장.
1. 판매할 **지정판매소**를 선택합니다.
2. 봉투 **품목·수량**을 입력(또는 바코드 스캔)합니다.
3. 저장하면 재고가 감소하고 판매 내역이 기록됩니다.
**입력/순서**
1. 위에서 **판매소를 검색·선택**합니다(코드·상호·전화·주소로 검색).
2. 판매할 봉투 종류·수량을 고르거나 **봉투코드를 스캔**합니다.
3. `판매저장` → 재고가 줄고 판매 내역이 기록됩니다.
### 전화 접수(주문)
**표 컬럼**: (판매내역) 봉투종류·접수량·판매량·단가·판매금액 / (상세) 봉투종류·봉투코드·수량·포장단위.
| 작업 | 메뉴 |
|---|---|
| 전화 접수(신규) | 전화 접수 |
| 전화 접수 관리 | 전화 접수 관리(수정·취소) |
---
## 불출 (무료 대상자)
## 지정 판매소 반품 / 판매·반품 취소 · *판매 관리*
**불출 관리** 메뉴에서 무료 대상자에게 봉투를 무상 지급합니다.
- **반품**: 가게가 안 팔린 봉투를 **되돌려 받는** 것. 스캔/선택 후 저장하면 재고가 다시 늘어납니다.
- **판매 취소 / 반품 취소**: 잘못 처리한 건을 되돌립니다.
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 불출 처리 | 무료용 불출 처리 | 무료 배분 등록(재고 감소) |
| 불출 취소 | 무료용 불출 취소 | 불출 취소(재고 복원) |
| 불출 현황 | 무료 불출 현황 | 기간별 불출 기록 조회 |
---
### 불출 처리 순서
## 판매/반품 현황 · *판매 관리* 또는 *판매 현황*
1. 무료 **대상처**와 봉투 **품목·수량**을 선택합니다.
2. 저장하면 재고가 감소하고 불출 내역이 기록됩니다.
3. 잘못 처리한 경우 **무료용 불출 취소**로 되돌리면 재고가 복원됩니다.
기간별 판매·반품 내역을 봅니다.
> 판매·불출 모두 재고를 감소시키므로, 처리 후 **재고 현황**에서 반영 결과를 확인하세요.
**필터**: 조회기간.
**표 컬럼**: 판매소 · 판매일 · 봉투코드 · 봉투명 · 수량 · 단가 · 금액 · **구분(판매/반품/취소)**.
**버튼**: `조회` · `초기화` · `주문등록` · `판매등록`.
---
## 전화 주문 접수 · *판매 관리 전화 접수*
가게가 전화로 주문한 내용을 **접수**합니다(실제 출고/판매는 이후 단계).
**이 화면의 용어**
- **접수일 / 배달일**: 주문 받은 날 / 가져다줄 날(보통 다음날 자동).
- **결제구분**: 이체 / 가상계좌.
- **1박스·1팩(낱장/판매가)**: 포장별 수량·가격 참고값.
**입력/순서**
1. **판매소 검색·선택**(코드·사업자번호·상호·전화·주소).
2. 결제구분을 고르고, 봉투 **품목·주문수량·포장단위(박스/팩/낱장)** 를 입력(`행추가`로 여러 품목).
3. `등록` → 주문 접수 완료.
> **주문 접수(간편)**: 판매소·배달일·결제방법과 봉투별 수량만 입력하는 간단 버전.
---
## 무료용 불출 처리 · *불출 관리 무료용 불출 처리*
무료 대상자(동사무소 등)에게 봉투를 **무상으로 내보내는(불출)** 화면입니다.
**이 화면의 용어**
- **불출구분(무료용/공공용)**: 주민 무료 배부용 / 공공기관용.
- **불출처**: 봉투를 최종 전달할 곳(동사무소·구청·기타).
- **재고(낱장) / 환산(낱장)**: 현재 재고 / 입력 수량을 낱장으로 환산한 값.
**입력/순서**
1. 불출년도·분기, 불출구분, 불출일, **불출처(동)** 를 고릅니다.
2. **바코드 스캔** 또는 `행추가`로 봉투 종류·수량·포장단위를 입력합니다.
3. `저장` → 재고가 줄고 불출 내역이 기록됩니다.
**표 컬럼**: 봉투코드 · 봉투종류 · 수량 · 포장 · 재고(낱장) · 환산(낱장).
---
## 무료용 불출 취소 · *불출 관리 무료용 불출 취소*
잘못 불출한 건을 **되돌려 재고를 복원**합니다.
**필터**: 불출월 · 불출처 · 불출구분 · 봉투종류.
**순서**: 불출 목록에서 건을 고르고, 품목 내역에서 **취소할 항목을 체크 → 취소수량 입력** 후 처리.

View File

@@ -1,42 +1,63 @@
# 판매현황 · 수불 · 통계
# 현황 · 리포트 · 수불
판매·재고 흐름을 집계해 보여주는 조회·리포트 화면 모음입니다. 대부분 **기간을 지정해 조회**하고 인쇄·엑셀로 내보낼 수 있습니다.
입고·판매·불출 기록을 **모아 보여주는** 조회 화면들입니다. 대부분 **기간을 지정해 조회**하고 `엑셀저장`·`인쇄`로 내보낼 수 있습니다.
## 판매 현황
---
| 메뉴 | 내용 |
|---|---|
| 지정 판매소 일/기간 판매대장 | 판매소별 일자·기간 거래 장부 |
| 일계표 | 하루 전체 판매 요약(일계 + 월 누계) |
| 기간별 판매현황 | 지정 기간의 판매 집계(일집계/기간집계) |
| 년 판매 현황 | 연간 판매 통계(월별/분기별) |
| 지정 판매소별 판매현황 | 판매소별 수량·금액 비교 |
| 홈텍스 처리 | 세금계산서용 데이터(엑셀) 생성 |
## 기간별 봉투 수불 현황 · *봉투 수불 관리 기간별 봉투 수불 현황*
## 봉투 수불 관리
**수불(受拂)** = 들어오고 나간 움직임. 기간 동안 봉투가 얼마나 들어오고(입고) 나갔는지(판매·불출 등)를 한 표로 봅니다.
입고·판매·불출·반품·파기를 한데 모아 **수불(수입·불출)** 흐름을 봅니다.
**이 화면의 용어**
- **전일재고**: 조회 시작일 **전날**의 재고.
- **입고**: 입고량 + 반품 + 기타.
- **출고**: 판매 + 무료불출 + 반품 + 기타.
- **잔량**: 전일재고 + 입고 출고.
| 메뉴 | 내용 |
|---|---|
| 기간별 봉투 수불 현황 | 기간 내 재고/입고/판매/불출 종합(엑셀 가능) |
| 기타 입출고 | 손상·기증·폐기 등 기타 입출고 등록·조회 |
| 반품/파기 현황 | 반품 및 파기 내역 |
| LOT 수불 조회 | 봉투번호(바코드)·LOT 단위 이력 추적 |
| 쓰레기 봉투 수급 계획 | 공급·수요 계획 |
**필터**: 조회기간 · 봉투형식 · 봉투구분 · 대행소 · **집계방식(일자별/기간별)**.
**표 컬럼**: 일자 · 품목 · 전일재고 · 입고(소계) · 출고(소계) · 잔량.
### LOT 수불 조회 사용법
---
1. **봉투번호(바코드)** 또는 LOT 번호를 입력합니다.
2. 조회하면 해당 단위의 입고·판매·반품 이력이 표시됩니다.
3. 입력할 코드 형식이 헷갈리면 **도움말 번호알기**로 먼저 확인하세요.
## 일계표 · *판매 현황 일계표*
## 통계 분석
하루치 판매를 **일계(당일)****누계(월 누적)** 로 집계합니다.
| 메뉴 | 내용 |
|---|---|
| 전년 대비 판매 분석 | 작년 동기 대비 비교(차트) |
| 월별 판매 추이 분석 | 월별 추이 시각화 |
| 계절별 판매 추이 분석 | 계절 패턴 분석 |
**용어**: **일계** = 그날 합계, **누계(월)** = 월초~당일 누적, **징수액** = 판매금액 수수료.
**필터**: 조회일자 · 대행소 · 구분.
**표**: 봉투종류별 — 일계(수량·판매금액·수수료·징수액) / 누계(월) 동일 항목.
> 리포트 화면은 조회 조건(기간·품목·판매소)을 바꿔가며 반복 조회할 수 있습니다. 결과는 인쇄 버튼 또는 엑셀 내보내기로 저장하세요.
---
## 지정 판매소별 판매현황 · *판매 현황 판매소별 판매현황*
판매소마다 **얼마나 팔았는지**(수량 또는 금액)를 월별로 비교합니다.
**필터**: 기간 · 읍면동 · 봉투종류 · **지표(수량/금액)**.
**표**: 판매소명 · 판매소코드 · 월별 값 · 합계.
---
## LOT 수불 조회 · *봉투 수불 관리 LOT 수불 조회*
특정 **봉투번호(바코드)** 또는 **LOT**의 입고·판매·반품 **이력**을 추적합니다.
**입력**: 봉투번호(바코드/팩코드/박스코드/낱장코드).
**표**: 일자 · 품목 · 포장단위 · **구분(입고/판매/반품)** · 수량 · LOT번호.
> 입력할 코드 형식이 헷갈리면 좌측 **[봉투·LOT·바코드 코드체계]** 또는 도움말의 **번호알기**를 참고하세요.
---
## 반품/파기 현황 · *봉투 수불 관리 반품/파기 현황*
**용어**: **반품** = 판매소가 되돌린 봉투(출고 탭) / **파기** = 반품분의 폐기(입고 탭).
**필터**: 조회기간 · 입출고구분.
**표**: 일자 · 판매소명 · 봉투종류 · 수량 · 구분(반품/파기).
---
## 그 밖의 현황
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.
- **홈택스 처리**: 세금계산서용 데이터(엑셀) 생성.
- **통계 분석(전년대비·월별·계절 추이)**: 판매 추세를 그래프로.

View File

@@ -0,0 +1,69 @@
# 기본정보 (판매소 · 단가 · 코드)
업무의 **기준이 되는 정보**를 관리하는 화면들입니다. 발주·판매가 이 값을 사용하므로 먼저 정확히 등록되어 있어야 합니다.
---
## 지정판매소 관리 · *기본정보관리 지정 판매소 관리/조회*
봉투를 파는 **가게(지정판매소)** 를 등록·조회합니다.
**이 화면의 용어**
- **판매소번호**: 가게 고유 번호(지역코드 + 일련번호).
- **도로명주소 / 지번주소**: 두 가지 주소 체계(지도 표시·검색에 사용).
- **은행/계좌, 가상계좌**: 봉투 대금 결제용 계좌.
**목록 표 컬럼**: 번호 · 판매소번호 · 상호명 · 대표자명 · 지역/읍면동 · 전화번호 · 주소.
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
---
## 단가 관리 · *기본정보관리 단가 관리*
봉투 **가격**을 기간별로 관리합니다.
**이 화면의 용어**
- **발주단가**: 제작업체에 주는 가격(살 때).
- **도매단가**: 대행소·판매소에 넘기는 도매 가격.
- **판매단가**: 최종 소비자 판매가.
- **수수료율**: 판매수수료율(%).
- **적용시작/종료**: 그 단가가 유효한 기간.
**필터**: 봉투구분 · 봉투코드 · 조회기간.
**표 컬럼**: 봉투코드 · 봉투명 · 발주단가 · 도매단가 · 판매단가 · 수수료율 · 적용시작 · 적용종료 · 상태.
> 조회 전용이며, 등록·수정은 `단가관리(CRUD)` 화면에서 합니다(이력 보존).
---
## 포장 단위 관리 · *기본정보관리 포장 단위 관리*
봉투 1박스·1팩에 **몇 장이 들어가는지** 정의합니다. 이 값으로 박스↔낱장이 환산됩니다.
**이 화면의 용어**
- **박스당 팩수**: 1박스 안의 팩 개수.
- **팩당 낱장수**: 1팩 안의 낱장(봉투) 수.
- **1박스 총 낱장** = 박스당 팩수 × 팩당 낱장수.
**표 컬럼**: 봉투코드 · 봉투명 · 박스당팩수 · 팩당낱장수 · 1박스총낱장 · 적용시작/종료 · 상태(사용/만료).
---
## 기본코드 관리 · *기본정보관리 기본 코드 관리*
시스템 곳곳의 **선택 항목(드롭다운)** 값을 관리합니다. 왼쪽에 **코드 종류**, 오른쪽에 그 종류의 **세부코드**가 나옵니다.
**이 화면의 용어**
- **코드 종류**: 분류(예: 봉투구분, 동코드, 결제구분, 불출구분).
- **세부코드**: 그 분류의 실제 값(예: 봉투구분 → 봉투/스티커).
**자주 쓰는 코드 종류**
- **봉투구분**(봉투/스티커) · **동코드**(지역 동) · **결제구분**(이체/가상계좌) · **불출구분**(무료용/공공용).
**표 컬럼**: 코드 · 코드명 · (세부코드 개수) · 상태(사용/미사용) · 작업(수정/삭제 — 관리자).
> 등록·수정·삭제는 슈퍼 관리자만 가능합니다. 조회는 누구나 가능합니다.
---
## 그 밖의 기본정보
- **판매 대행소 / 담당자 / 업체(제작·협회·회수) / 무료용 대상자 관리**: 각각 거래처·담당자·대상처를 등록·조회하는 목록 화면입니다(등록·수정은 관리자).

View File

@@ -936,3 +936,32 @@ if (! function_exists('gov_portal_nav_partial_vars')) {
return $out;
}
}
if (! function_exists('manual_help_url_for_path')) {
/**
* 현재(또는 주어진) 화면 경로에 대응하는 매뉴얼 URL. 매칭 없으면 ''.
* Config\Manual::$screenHelp 의 가장 긴(구체적) 접두를 우선 매칭한다.
*/
function manual_help_url_for_path(?string $path = null): string
{
helper('url');
$path = strtolower(trim($path ?? current_nav_request_path(), '/'));
if ($path === '') {
return '';
}
$map = config(\Config\Manual::class)->screenHelp ?? [];
$bestSlug = '';
$bestLen = -1;
foreach ($map as $prefix => $slug) {
$p = strtolower((string) $prefix);
if ($path === $p || str_starts_with($path . '/', $p . '/')) {
if (strlen($p) > $bestLen) {
$bestLen = strlen($p);
$bestSlug = (string) $slug;
}
}
}
return $bestSlug !== '' ? base_url('bag/manual/' . $bestSlug) : '';
}
}

View File

@@ -58,6 +58,61 @@ class ManualRenderer
return $this->config->pages[$slug] ?? null;
}
/**
* 페이지 본문을 일반 텍스트로(검색용). 미존재 시 ''.
*/
public function plainText(string $slug): string
{
$html = $this->render($slug);
if ($html === null || $html === '') {
return '';
}
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/u', ' ', $text));
}
/**
* 모든 매뉴얼 페이지 본문에서 질의어를 찾아 결과 반환(일치 많은 순).
*
* @return list<array{slug:string,title:string,snippet:string,hits:int}>
*/
public function search(string $q): array
{
$q = trim($q);
if ($q === '' || mb_strlen($q) < 1) {
return [];
}
$needle = mb_strtolower($q);
$out = [];
foreach ($this->pages() as $slug => $page) {
$text = $this->plainText((string) $slug);
if ($text === '') {
continue;
}
$hay = mb_strtolower($text);
$pos = mb_strpos($hay, $needle);
if ($pos === false) {
continue;
}
$hits = mb_substr_count($hay, $needle);
$start = max(0, $pos - 30);
$snippet = mb_substr($text, $start, 100);
if ($start > 0) {
$snippet = '…' . $snippet;
}
$out[] = [
'slug' => (string) $slug,
'title' => (string) $page['title'],
'snippet' => trim($snippet),
'hits' => $hits,
];
}
usort($out, static fn ($a, $b): int => $b['hits'] <=> $a['hits']);
return $out;
}
/**
* slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
*/

View File

@@ -61,7 +61,7 @@ $navPartial = [
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '관리자') ?> - 종량제 시스템</title>
<title><?= esc($title ?? '관리자') ?> - GBLS</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
@@ -142,10 +142,23 @@ tailwind.config = {
</div>
<footer class="portal-footer">
<span>종량제 시스템 관리자</span>
<span>GBLS 관리자</span>
<span><?= date('Y.m.d (D) H:i') ?></span>
</footer>
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<script>
(function () {
// bfcache 복원 시 열린 채 남은 모달/팝업으로 회색 레이어가 클릭을 막는 문제 방지
function closeStuckOverlays() {
document.querySelectorAll('.fixed.inset-0[id$="-modal"], .fixed.inset-0[id$="-popup"]').forEach(function (el) {
el.classList.add('hidden'); el.setAttribute('aria-hidden', 'true');
});
document.body.style.overflow = '';
}
window.addEventListener('pageshow', function (e) { if (e.persisted) closeStuckOverlays(); });
window.addEventListener('pagehide', closeStuckOverlays);
})();
</script>
</body>
</html>

View File

@@ -14,7 +14,7 @@ $subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($pageTitle ?? '종량제 시스템') ?></title>
<title><?= esc($pageTitle ?? 'GBLS') ?></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"/>
@@ -33,13 +33,20 @@ tailwind.config = {
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; letter-spacing: -0.01em; }</style>
</head>
<script>
// iframe(워크스페이스 탭) 안에서 세션 만료로 로그인이 열리면 상위 창 전체를 로그인으로 전환
if (window.top !== window.self) { try { window.top.location.href = <?= json_encode(base_url('login'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; } catch (e) {} }
</script>
<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="종량제 시스템">
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
<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>
<span class="leading-none flex flex-col">
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
<span class="text-[0.56rem] font-medium text-white/65 tracking-tight whitespace-nowrap">Garbage Bag Logistics System</span>
</span>
</a>
</header>

View File

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

View File

@@ -12,32 +12,40 @@ $showKindActions = $canManageKinds;
$selectedKindId = (int) ($selectedKind->ck_idx ?? 0);
$colCount = 6 + ($showKindActions ? 1 : 0);
$detailColCount = 7 + ($canManageDetails ? 1 : 0);
/** 상태 배지 (업무현황 스타일의 가벼운 pill) */
$stateBadge = static function (int $state): string {
return $state === 1
? '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">사용</span>'
: '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">미사용</span>';
};
?>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<section>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2 border-b pb-1">
<h3 class="text-base font-bold text-gray-700">기본코드 종류</h3>
<div class="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<!-- 기본코드 종류 -->
<section class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<h2 class="text-sm font-bold text-gray-900"><i class="fa-solid fa-layer-group text-blue-600 mr-1"></i>기본코드 종류</h2>
<?php if ($canManageKinds): ?>
<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>
<a href="<?= base_url('admin/code-kinds/create') ?>" class="inline-flex items-center rounded-lg bg-[#243a5e] px-3 py-1.5 text-white text-xs font-semibold shadow-sm hover:opacity-90">기본코드 등록</a>
<?php else: ?>
<span class="text-gray-500">코드 종류 등록·수정은 super admin·본부 관리자만 가능합니다.</span>
<span class="text-gray-400 text-[11px]">코드 종류 등록·수정은 super admin·본부 관리자만 가능합니다.</span>
<?php endif; ?>
</div>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="data-table w-full">
<thead><tr>
<th class="w-14">번호</th>
<th class="w-24">코드</th>
<th>코드명</th>
<th class="w-24">세부코드</th>
<th class="w-20">상태</th>
<th class="w-40">등록일</th>
<div class="overflow-auto">
<table class="w-full text-[13px]">
<thead>
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="py-2.5 px-2 w-12 text-center">번호</th>
<th class="py-2.5 px-2 w-20">코드</th>
<th class="py-2.5 px-2">코드명</th>
<th class="py-2.5 px-2 w-20 text-center">세부코드</th>
<th class="py-2.5 px-2 w-16 text-center">상태</th>
<th class="py-2.5 px-2 w-32">등록일</th>
<?php if ($showKindActions): ?>
<th class="w-36">작업</th>
<th class="py-2.5 px-2 w-28 text-center">작업</th>
<?php endif; ?>
</tr></thead>
</tr>
</thead>
<tbody>
<?php if (! empty($codeKinds)): ?>
<?php $i = 0; foreach ($codeKinds as $row): $i++; ?>
@@ -45,16 +53,16 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
$isSelected = (int) $row->ck_idx === $selectedKindId;
$detailUrl = base_url('bag/code-kinds?ck_idx=' . (int) $row->ck_idx);
?>
<tr class="<?= $isSelected ? 'bg-blue-50' : '' ?> cursor-pointer hover:bg-blue-50"
<tr class="border-b border-gray-200 last:border-0 cursor-pointer hover:bg-blue-50/60 <?= $isSelected ? 'bg-blue-50' : '' ?>"
onclick="window.location.href='<?= esc($detailUrl, 'attr') ?>'">
<td class="text-center"><?= (string) $i ?></td>
<td class="text-center font-mono"><?= esc($row->ck_code) ?></td>
<td><?= esc($row->ck_name) ?></td>
<td class="text-center"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td>
<td class="text-center"><?= (int) ($row->ck_state ?? 0) === 1 ? '사용' : '미사용' ?></td>
<td class="text-left"><?= esc($row->ck_regdate ?? '') ?></td>
<td class="py-2.5 px-2 text-center text-gray-500"><?= (string) $i ?></td>
<td class="py-2.5 px-2 text-center font-mono text-gray-700"><?= esc($row->ck_code) ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->ck_name) ?></td>
<td class="py-2.5 px-2 text-center text-gray-600"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td>
<td class="py-2.5 px-2 text-center"><?= $stateBadge((int) ($row->ck_state ?? 0)) ?></td>
<td class="py-2.5 px-2 text-gray-500 text-[12px]"><?= esc($row->ck_regdate ?? '') ?></td>
<?php if ($showKindActions): ?>
<td class="text-center text-sm" onclick="event.stopPropagation()">
<td class="py-2.5 px-2 text-center text-xs" onclick="event.stopPropagation()">
<a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
<form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" onsubmit="return confirm('이 코드 종류를 삭제하시겠습니까?');">
<?= csrf_field() ?>
@@ -65,42 +73,43 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="<?= (string) $colCount ?>" class="text-center text-gray-400 py-4">등록된 코드 종류가 없습니다.</td></tr>
<tr><td colspan="<?= (string) $colCount ?>" class="text-center text-gray-400 py-6">등록된 코드 종류가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2 border-b pb-1">
<h3 class="text-base font-bold text-gray-700">
세부코드
<!-- 세부코드 -->
<section class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<h2 class="text-sm font-bold text-gray-900">
<i class="fa-solid fa-list-ul text-emerald-600 mr-1"></i>세부코드
<?php if ($selectedKind !== null): ?>
— <?= esc($selectedKind->ck_name) ?> (<?= esc($selectedKind->ck_code) ?>)
<span class="font-medium text-gray-400">— <?= esc($selectedKind->ck_name) ?> (<?= esc($selectedKind->ck_code) ?>)</span>
<?php endif; ?>
</h3>
</h2>
<?php if ($canManageDetails && $selectedKind !== null): ?>
<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>
<a href="<?= base_url('admin/code-details/' . (int) $selectedKind->ck_idx . '/create') ?>" class="inline-flex items-center rounded-lg bg-[#243a5e] px-3 py-1.5 text-white text-xs font-semibold shadow-sm hover:opacity-90">세부코드 등록</a>
<?php endif; ?>
</div>
<?php if ($selectedKind === null): ?>
<div class="border border-gray-300 rounded p-6 text-center text-gray-500">왼쪽에서 코드 종류를 선택해 주세요.</div>
<div class="py-10 text-center text-sm text-gray-400">왼쪽에서 코드 종류를 선택해 주세요.</div>
<?php else: ?>
<div class="border border-gray-300 overflow-auto">
<table class="data-table w-full">
<div class="overflow-auto">
<table class="w-full text-[13px]">
<thead>
<tr>
<th class="w-16">번호</th>
<th class="w-24">코드</th>
<th>코드명</th>
<th class="w-24">범위</th>
<th class="w-20">정렬</th>
<th class="w-20">상태</th>
<th class="w-40">등록일</th>
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="py-2.5 px-2 w-12 text-center">번호</th>
<th class="py-2.5 px-2 w-20">코드</th>
<th class="py-2.5 px-2">코드명</th>
<th class="py-2.5 px-2 w-16 text-center">범위</th>
<th class="py-2.5 px-2 w-14 text-center">정렬</th>
<th class="py-2.5 px-2 w-16 text-center">상태</th>
<th class="py-2.5 px-2 w-32">등록일</th>
<?php if ($canManageDetails): ?>
<th class="w-28">작업</th>
<th class="py-2.5 px-2 w-24 text-center">작업</th>
<?php endif; ?>
</tr>
</thead>
@@ -111,16 +120,18 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
$isPlatform = (($row->cd_source ?? 'platform') === 'platform' && (int) ($row->cd_lg_idx ?? 0) === 0);
$scopeLabel = $isPlatform ? '공통' : '지자체';
?>
<tr>
<td class="text-center"><?= (string) $dNo ?></td>
<td class="text-center font-mono"><?= esc($row->cd_code) ?></td>
<td><?= esc($row->cd_name) ?></td>
<td class="text-center text-xs"><?= esc($scopeLabel) ?></td>
<td class="text-center"><?= (int) ($row->cd_sort ?? 0) ?></td>
<td class="text-center"><?= (int) ($row->cd_state ?? 0) === 1 ? '사용' : '미사용' ?></td>
<td class="text-left"><?= esc($row->cd_regdate ?? '') ?></td>
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="py-2.5 px-2 text-center text-gray-500"><?= (string) $dNo ?></td>
<td class="py-2.5 px-2 text-center font-mono text-gray-700"><?= esc($row->cd_code) ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->cd_name) ?></td>
<td class="py-2.5 px-2 text-center">
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $isPlatform ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700' ?>"><?= esc($scopeLabel) ?></span>
</td>
<td class="py-2.5 px-2 text-center text-gray-600"><?= (int) ($row->cd_sort ?? 0) ?></td>
<td class="py-2.5 px-2 text-center"><?= $stateBadge((int) ($row->cd_state ?? 0)) ?></td>
<td class="py-2.5 px-2 text-gray-500 text-[12px]"><?= esc($row->cd_regdate ?? '') ?></td>
<?php if ($canManageDetails): ?>
<td class="text-center text-sm">
<td class="py-2.5 px-2 text-center text-xs">
<?php if (! empty($rowCanEdit[$row->cd_idx])): ?>
<a href="<?= base_url('admin/code-details/edit/' . (int) $row->cd_idx) ?>" class="text-blue-600 hover:underline">수정</a>
<form action="<?= base_url('admin/code-details/delete/' . (int) $row->cd_idx) ?>" method="POST" class="ml-1 inline" onsubmit="return confirm('이 세부코드를 삭제하시겠습니까?');">
@@ -135,7 +146,7 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="<?= (string) $detailColCount ?>" class="text-center text-gray-400 py-4">등록된 세부코드가 없습니다.</td></tr>
<tr><td colspan="<?= (string) $detailColCount ?>" class="text-center text-gray-400 py-6">등록된 세부코드가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>

View File

@@ -14,7 +14,7 @@ $userNav = session_user_nav_display();
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>종량제 시스템</title>
<title>GBLS</title>
<!-- Tailwind CSS v3 with Plugins -->
<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>

View File

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

View File

@@ -10,7 +10,7 @@
<section>
<h3 class="font-bold text-gray-700 mb-1">시스템 개요</h3>
<p>종량제 시스템은 지자체 종량제 쓰레기봉투의 발주, 입고, 재고, 판매, 불출 전체 물류 프로세스를 관리합니다.</p>
<p><strong>GBLS</strong>(Garbage Bag Logistics System) 지자체 종량제 쓰레기봉투의 발주, 입고, 재고, 판매, 불출 전체 물류 프로세스를 관리합니다.</p>
</section>
<section>

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
/**
* 임베드(탭/iframe) 전용 레이아웃 — 헤더·사이드바 없이 본문만.
* /workspace 탭 iframe 안에서 업무 페이지를 중첩 크롬 없이 표시한다.
*
* @var string $title
* @var string $content
* @var bool $bare true면 본문을 카드 래퍼 없이 그대로 출력(대시보드용)
*/
$bare = ! empty($bare);
helper('admin');
$helpUrl = function_exists('manual_help_url_for_path') ? manual_help_url_for_path() : '';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? 'GBLS') ?></title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Pretendard', '"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff', 'title-bar': '#1a2b4b', 'control-panel': '#f8f9fa',
'btn-search': '#243a5e', 'btn-excel-border': '#28a745', 'btn-excel-text': '#28a745',
'btn-print-border': '#ced4da', 'btn-exit': '#d9534f',
},
fontSize: { 'xxs': '0.65rem' }
}
}
}
</script>
<style>
body { margin: 0; background: #f0f4f8; font-family: 'Pretendard', 'Malgun Gothic', 'Noto Sans KR', sans-serif; letter-spacing: -0.01em; -webkit-font-smoothing: antialiased; }
.embed-titlebar { display: flex; align-items: center; gap: .5rem; font-size: 1.05rem; font-weight: 800; color: #1a2b4b; letter-spacing: -0.03em; margin: 0 0 0.75rem; }
.embed-flash { margin-bottom: .75rem; padding: .6rem .9rem; border-radius: 8px; font-size: .8125rem; }
.embed-flash.ok { background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; }
.embed-flash.err { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; }
.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 { .no-print { display: none !important; } .embed-titlebar { display: none; } }
</style>
</head>
<body>
<div style="padding: 0.875rem 1rem 1.25rem;">
<?php if (! empty($title) || $helpUrl !== ''): ?>
<div style="display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin:0 0 .75rem;">
<h1 class="embed-titlebar" style="margin:0;"><?php if (! empty($title)): ?><i class="fa-solid fa-folder-open" style="color:#007bff;"></i><?= esc($title) ?><?php endif; ?></h1>
<?php if ($helpUrl !== ''): ?>
<a href="<?= esc($helpUrl, 'attr') ?>" class="embed-help no-print" style="display:inline-flex;align-items:center;gap:.3rem;padding:.3rem .6rem;border-radius:6px;background:#eef2f7;border:1px solid #dde4ec;color:#1a2b4b;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
<i class="fa-regular fa-circle-question"></i> 이 화면 설명
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="embed-flash ok"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="embed-flash err"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if ($bare): ?>
<?= $content ?>
<?php else: ?>
<div class="bg-white border border-[#dde4ec] rounded-xl shadow-sm p-4">
<?= $content ?>
</div>
<?php endif; ?>
</div>
<script>
(function () {
// 세션 만료 등으로 iframe 안에서 로그인 페이지가 열리면 상위 프레임을 로그인으로 보낸다.
if (window.top !== window.self && /\/login(\/|$)/.test(location.pathname)) {
try { window.top.location.href = location.href; } catch (e) {}
}
// 방문한 업무 메뉴 경로 기록 (대시보드 "최근 방문 메뉴"용). localStorage 는 동일 출처 탭과 공유된다.
try {
var rp = (location.pathname || '').replace(/\/+$/, '') || '/';
if (rp !== '/' && !/\/login|\/logout|\/register|\/manual/.test(rp)) {
var RK = 'jrj_recent_menus';
var ra = JSON.parse(localStorage.getItem(RK) || '[]');
if (!Array.isArray(ra)) ra = [];
ra = ra.filter(function (x) { return x && x.p && x.p !== rp; });
ra.unshift({ p: rp, t: Date.now() });
localStorage.setItem(RK, JSON.stringify(ra.slice(0, 12)));
}
} catch (e) {}
// 탭(iframe) 안에서의 링크·폼 이동은 항상 embed 유지 → 중첩 헤더/사이드바 방지
function withEmbed(href) {
try {
var u = new URL(href, location.href);
if (u.origin !== location.origin) return null;
if (u.searchParams.get('embed') === '1') return null;
u.searchParams.set('embed', '1');
return u.href;
} catch (e) { return null; }
}
// "이 화면 설명" → 워크스페이스 새 탭으로 매뉴얼 열기(없으면 새 창)
document.addEventListener('click', function (e) {
var h = e.target.closest ? e.target.closest('a.embed-help') : null;
if (!h) return;
e.preventDefault(); e.stopPropagation();
var url = h.getAttribute('href');
try {
if (window.parent && window.parent !== window && typeof window.parent.wsOpenTab === 'function') {
window.parent.wsOpenTab(url, '도움말'); return;
}
} catch (err) {}
window.open(url, '_blank');
}, true);
document.addEventListener('click', function (e) {
var a = e.target.closest ? e.target.closest('a[href]') : null;
if (!a || a.classList.contains('embed-help')) return;
var t = (a.getAttribute('target') || '').toLowerCase();
if (t && t !== '_self') return;
if (a.hasAttribute('download')) return;
var href = a.getAttribute('href') || '';
if (!href || href.charAt(0) === '#' || /^(javascript:|mailto:|tel:|data:)/i.test(href)) return;
var ne = withEmbed(href);
if (ne) a.setAttribute('href', ne);
}, true);
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f || f.tagName !== 'FORM') return;
var ne = withEmbed(f.getAttribute('action') || location.href);
if (ne) f.setAttribute('action', ne);
}, true);
// 표 '번호' 컬럼 역순 채번 (사이트 레이아웃과 동일)
var run = function () {
document.querySelectorAll('table').forEach(function (table) {
var head = table.querySelector('thead tr'); if (!head) return;
var ths = Array.prototype.slice.call(head.querySelectorAll('th'));
var col = ths.findIndex(function (th) { return (th.textContent || '').replace(/\s+/g, '').trim() === '번호'; });
if (col < 0) return;
var body = table.querySelector('tbody'); if (!body) return;
var rows = Array.prototype.slice.call(body.querySelectorAll(':scope > tr')).filter(function (tr) {
var c = tr.querySelectorAll('td'); if (!c.length) return false;
if (c.length === 1 && Number(c[0].getAttribute('colspan') || '1') > 1) return false; return true;
});
var n = rows.length;
rows.forEach(function (tr) { var c = tr.querySelectorAll('td'); if (c[col]) c[col].textContent = String(n--); });
});
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run();
})();
</script>
</body>
</html>

View File

@@ -19,7 +19,7 @@ $userNav = session_user_nav_display();
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '종량제 시스템') ?></title>
<title><?= esc($title ?? 'GBLS') ?></title>
<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>
@@ -139,7 +139,7 @@ body { overflow: hidden; }
<?= $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>GBLS</span>
<span><?= date('Y.m.d (D) g:i:sA') ?></span>
</footer>
<script>
@@ -181,5 +181,18 @@ body { overflow: hidden; }
}
})();
</script>
<script>
(function () {
// bfcache 복원 시 열린 채 남은 모달/팝업으로 회색 레이어가 클릭을 막는 문제 방지
function closeStuckOverlays() {
document.querySelectorAll('.fixed.inset-0[id$="-modal"], .fixed.inset-0[id$="-popup"]').forEach(function (el) {
el.classList.add('hidden'); el.setAttribute('aria-hidden', 'true');
});
document.body.style.overflow = '';
}
window.addEventListener('pageshow', function (e) { if (e.persisted) closeStuckOverlays(); });
window.addEventListener('pagehide', closeStuckOverlays);
})();
</script>
</body>
</html>

View File

@@ -35,13 +35,14 @@ if ($effectiveLgIdx) {
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx);
$lgLabel = $lgRow ? (string) $lgRow->lg_name : '';
}
$helpUrl = function_exists('manual_help_url_for_path') ? manual_help_url_for_path() : '';
?>
<!DOCTYPE html>
<html lang="ko" class="gov-portal-html">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '종량제 시스템') ?></title>
<title><?= esc($title ?? 'GBLS') ?></title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
@@ -93,6 +94,10 @@ tailwind.config = {
<?php if ($lgLabel !== ''): ?><strong><?= esc($lgLabel) ?></strong> · <?php endif; ?>
<?= esc($levelName) ?> · <?= esc($mbName) ?>님
</span>
<a href="<?= base_url('workspace') ?>" title="워크스페이스(탭으로 여러 화면 열기)"
style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.3);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
<i class="fa-regular fa-window-restore"></i> 워크스페이스
</a>
<?php if ($isAdmin): ?>
<a href="<?= base_url('admin') ?>" title="관리자 페이지"
style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.3);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
@@ -112,7 +117,14 @@ tailwind.config = {
<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>
<div style="display:flex;align-items:center;justify-content:space-between;gap:.5rem;">
<h1 class="work-titlebar" style="margin-bottom:0;"><i class="fa-solid fa-folder-open tb-ico"></i><?= esc($title) ?></h1>
<?php if ($helpUrl !== ''): ?>
<a href="<?= esc($helpUrl, 'attr') ?>" target="_blank" rel="noopener" class="no-print" style="display:inline-flex;align-items:center;gap:.3rem;padding:.3rem .6rem;border-radius:6px;background:#fff;border:1px solid var(--border);color:#1a2b4b;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
<i class="fa-regular fa-circle-question"></i> 이 화면 설명
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="work-flash ok"><?= esc(session()->getFlashdata('success')) ?></div>
@@ -131,12 +143,42 @@ tailwind.config = {
</div>
<footer class="portal-footer">
<span>종량제 시스템</span>
<span>GBLS</span>
<span><?= date('Y.m.d (D) H:i') ?></span>
</footer>
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<script>
(function () {
// 방문한 업무 메뉴 경로 기록 (메인 메뉴검색의 "최근 방문 메뉴"용)
try {
var p = (location.pathname || '').replace(/\/+$/, '') || '/';
if (p === '/' || /\/login|\/logout|\/register/.test(p)) return;
var KEY = 'jrj_recent_menus';
var arr = JSON.parse(localStorage.getItem(KEY) || '[]');
if (!Array.isArray(arr)) arr = [];
arr = arr.filter(function (x) { return x && x.p && x.p !== p; });
arr.unshift({ p: p, t: Date.now() });
localStorage.setItem(KEY, JSON.stringify(arr.slice(0, 12)));
} catch (e) {}
})();
</script>
<script>
(function () {
// bfcache(뒤로가기/탭 복귀) 복원 시 열린 채 남은 전체화면 모달·팝업으로 인해
// 회색 레이어가 화면을 덮고 클릭이 막히는 문제 방지 — 복원 시 강제로 닫는다.
function closeStuckOverlays() {
document.querySelectorAll('.fixed.inset-0[id$="-modal"], .fixed.inset-0[id$="-popup"]').forEach(function (el) {
el.classList.add('hidden');
el.setAttribute('aria-hidden', 'true');
});
document.body.style.overflow = '';
}
window.addEventListener('pageshow', function (e) { if (e.persisted) closeStuckOverlays(); });
window.addEventListener('pagehide', closeStuckOverlays);
})();
</script>
<script>
(() => {
// 표의 '번호' 컬럼 역순 자동 채번 (기존 사이트 레이아웃 계승)
const normalize = (s) => String(s || '').replace(/\s+/g, '').trim();

View File

@@ -0,0 +1,479 @@
<?php
declare(strict_types=1);
/**
* 워크스페이스(탭) 셸 — 크롬처럼 메뉴를 탭(iframe)으로 열어두고 작업 상태 유지.
* 헤더 + 대메뉴(클릭) + 좌측 사이드바(소메뉴) + 탭바 + iframe 패널.
* 탭은 세션(이 셸이 열려 있는 동안)만 유지된다.
*/
helper('admin');
$gov = gov_portal_nav_context(false);
$navPartial = [
'govNavItems' => $gov['navItems'],
'govNavJson' => $gov['navJson'],
'govActiveParentIdx' => $gov['activeParentIdx'],
'govCurrentPath' => gov_portal_nav_match_path($gov['currentPath']),
'govDashboardAliases' => $gov['dashboardAliases'],
'govActiveChildHref' => '',
];
$mbLevel = (int) session()->get('mb_level');
$mbName = (string) (session()->get('mb_name') ?? '담당자');
$levelName = config(\Config\Roles::class)->getLevelName($mbLevel);
$isAdmin = ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN || $mbLevel === \Config\Roles::LEVEL_LOCAL_ADMIN);
$effectiveLgIdx = admin_effective_lg_idx();
$lgLabel = '';
if ($effectiveLgIdx) {
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx);
$lgLabel = $lgRow ? (string) $lgRow->lg_name : '';
}
?>
<!DOCTYPE html>
<html lang="ko" class="gov-portal-html">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>워크스페이스 · GBLS</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>
<?php include __DIR__ . '/../../home/_dashboard_gov_portal_brand_css.php'; ?>
<?php include __DIR__ . '/../../home/_dashboard_gov_portal_topnav_css.php'; ?>
<?php include __DIR__ . '/../../home/_dashboard_gov_portal_chrome_css.php'; ?>
html, body { height: 100%; }
body.gov-portal-shell { height: 100vh; overflow: hidden; }
.ws-main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.ws-topbar { display: flex; align-items: stretch; background: #e9eef5; border-bottom: 1px solid var(--border); }
.ws-tabbar { display: flex; align-items: stretch; gap: 2px; padding: 4px 6px 0; overflow-x: auto; min-height: 36px; flex: 1; min-width: 0; }
.ws-tab { display: inline-flex; align-items: center; gap: .4rem; max-width: 200px; padding: .35rem .6rem; background: #f5f7fa; border: 1px solid var(--border); border-bottom: none; border-radius: 7px 7px 0 0; font-size: .78rem; color: #555; cursor: pointer; white-space: nowrap; }
.ws-tab .t-name { overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
.ws-tab.active { background: #fff; color: var(--navy); font-weight: 700; box-shadow: 0 -2px 0 #007bff inset; }
.ws-tab.focused-tab { box-shadow: 0 -2px 0 #243a5e inset; }
.ws-tab .t-refresh, .ws-tab .t-close { width: 16px; height: 16px; line-height: 14px; text-align: center; border-radius: 50%; color: #999; font-size: 12px; }
.ws-tab .t-refresh:hover { background: #dbeafe; color: #1d4ed8; }
.ws-tab .t-close:hover { background: #e2e8f0; color: #333; }
/* 분할 레이아웃 컨트롤 */
.ws-layout { display: flex; align-items: center; gap: 3px; padding: 4px 8px; flex-shrink: 0; border-left: 1px solid var(--border); }
.ws-layout button { width: 30px; height: 26px; border: 1px solid var(--border); background: #f5f7fa; border-radius: 6px; color: #64748b; cursor: pointer; font-size: 12px; display: inline-flex; align-items: center; justify-content: center; }
.ws-layout button:hover { background: #e2e8f0; color: #334155; }
.ws-layout button.active { background: #243a5e; color: #fff; border-color: #243a5e; }
.ws-panels { flex: 1; position: relative; min-height: 0; background: #cbd5e1; }
.ws-frame { position: absolute; left: 0; top: 0; width: 100%; height: 100%; border: 0; display: none; background: #fff; box-sizing: border-box; }
/* 분할 칸 헤더 */
.ws-slot-head { position: absolute; display: none; align-items: center; gap: .3rem; height: 28px; padding: 0 .3rem 0 .6rem; background: #eef2f7; border-bottom: 1px solid var(--border); font-size: .72rem; color: #475569; box-sizing: border-box; cursor: pointer; z-index: 3; }
.ws-slot-head.focused { background: #dbeafe; color: var(--navy); box-shadow: inset 0 2px 0 #007bff; font-weight: 700; }
.ws-slot-head .sh-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-slot-head .sh-btn { width: 18px; height: 18px; line-height: 16px; text-align: center; border-radius: 50%; color: #94a3b8; flex-shrink: 0; font-size: 12px; }
.ws-slot-head .sh-btn:hover { background: #fff; color: #334155; }
.ws-slot-empty { position: absolute; display: none; align-items: center; justify-content: center; background: #f8fafc; color: #94a3b8; font-size: .8rem; box-sizing: border-box; cursor: pointer; z-index: 1; text-align: center; padding: 1rem; }
/* 분할 구분선 드래그 핸들 */
.ws-gutter { position: absolute; display: none; z-index: 6; background: transparent; }
.ws-gutter.v { width: 9px; height: 100%; top: 0; cursor: col-resize; }
.ws-gutter.h { width: 100%; height: 9px; left: 0; cursor: row-resize; }
.ws-gutter:hover { background: rgba(37, 99, 235, .25); }
/* 드래그 중 iframe 위에서도 마우스 이벤트를 받기 위한 오버레이 */
.ws-drag-overlay { position: absolute; inset: 0; z-index: 50; display: none; }
.ws-drag-overlay.v { cursor: col-resize; }
.ws-drag-overlay.h { cursor: row-resize; }
.ws-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; font-size: .9rem; gap: .5rem; z-index: 2; background: #f0f4f8; }
</style>
</head>
<body class="gov-portal-shell">
<header class="portal-header">
<div class="portal-header-inner">
<?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('workspace')]) ?>
<?= view('home/_dashboard_gov_portal_topnav_click', $navPartial) ?>
<div class="portal-header-utils" style="display:flex;align-items:center;gap:.5rem;">
<span class="user-line">
<?php if ($lgLabel !== ''): ?><strong><?= esc($lgLabel) ?></strong> · <?php endif; ?>
<?= esc($levelName) ?> · <?= esc($mbName) ?>님
</span>
<?php if ($isAdmin): ?>
<a href="<?= base_url('admin') ?>" title="관리자 페이지" style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.3);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;"><i class="fa-solid fa-gear"></i> 관리자</a>
<?php endif; ?>
<a href="<?= base_url('logout') ?>" title="로그아웃" style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.22);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;"><i class="fa-solid fa-right-from-bracket"></i> 로그아웃</a>
</div>
</div>
</header>
<div class="layout">
<?= view('home/_dashboard_gov_portal_sidebar', $navPartial) ?>
<main class="ws-main">
<div class="ws-topbar">
<div class="ws-tabbar" id="wsTabBar" role="tablist"></div>
<div class="ws-layout" id="wsLayout">
<button type="button" data-mode="single" title="1분할 (한 화면)"><i class="fa-regular fa-square"></i></button>
<button type="button" data-mode="lr" title="2분할 (좌우)"><i class="fa-solid fa-table-columns"></i></button>
<button type="button" data-mode="tb" title="2분할 (상하)"><i class="fa-solid fa-table-columns fa-rotate-90"></i></button>
<button type="button" data-mode="quad" title="4분할"><i class="fa-solid fa-table-cells-large"></i></button>
</div>
</div>
<div class="ws-panels" id="wsPanels">
<div class="ws-empty" id="wsEmpty">
<i class="fa-regular fa-window-restore" style="font-size:1.6rem;opacity:.5;"></i>
<div>왼쪽 메뉴를 클릭하면 탭으로 열립니다. 여러 화면을 열어두고 전환해도 작업 내용이 유지됩니다.</div>
</div>
</div>
</main>
</div>
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<script>
(function () {
var bar = document.getElementById('wsTabBar');
var panels = document.getElementById('wsPanels');
var empty = document.getElementById('wsEmpty');
var tabs = {}; // id -> {url,title,frame,btn}
var order = [];
var MAX = 12;
// 분할 레이아웃 상태
var layout = 'single'; // single | lr | tb | quad
var slots = [null]; // 칸별로 배치된 tab id (길이 = 칸 수)
var focused = 0; // 포커스된 칸 인덱스
function norm(u) { try { var a = new URL(u, location.origin); return a.pathname + a.search; } catch (e) { return u; } }
function withEmbed(u) {
try { var a = new URL(u, location.origin); a.searchParams.set('embed', '1'); return a.pathname + a.search; }
catch (e) { return u + (u.indexOf('?') >= 0 ? '&' : '?') + 'embed=1'; }
}
// 분할 비율 (좌 컬럼 폭 / 위 행 높이), 드래그로 조절
var vRatio = 0.5, hRatio = 0.5;
var RATIO_MIN = 0.12, RATIO_MAX = 0.88;
function pct(r) { return (r * 100).toFixed(3) + '%'; }
// 레이아웃별 칸 사각형 (2px 간격, 비율 반영) — left/top/width/height CSS 문자열
function rectsFor(mode) {
var Lw = 'calc(' + pct(vRatio) + ' - 1px)'; // 좌 컬럼 폭
var Rl = 'calc(' + pct(vRatio) + ' + 1px)'; // 우 컬럼 시작
var Rw = 'calc(' + pct(1 - vRatio) + ' - 1px)'; // 우 컬럼 폭
var Th = 'calc(' + pct(hRatio) + ' - 1px)'; // 위 행 높이
var Bt = 'calc(' + pct(hRatio) + ' + 1px)'; // 아래 행 시작
var Bh = 'calc(' + pct(1 - hRatio) + ' - 1px)'; // 아래 행 높이
if (mode === 'lr') return [
{ l: '0', t: '0', w: Lw, h: '100%' },
{ l: Rl, t: '0', w: Rw, h: '100%' }
];
if (mode === 'tb') return [
{ l: '0', t: '0', w: '100%', h: Th },
{ l: '0', t: Bt, w: '100%', h: Bh }
];
if (mode === 'quad') return [
{ l: '0', t: '0', w: Lw, h: Th },
{ l: Rl, t: '0', w: Rw, h: Th },
{ l: '0', t: Bt, w: Lw, h: Bh },
{ l: Rl, t: Bt, w: Rw, h: Bh }
];
return [{ l: '0', t: '0', w: '100%', h: '100%' }]; // single
}
var STORE_KEY = 'jrj_ws_tabs';
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
function persist() {
try {
sessionStorage.setItem(STORE_KEY, JSON.stringify({
owner: WS_OWNER,
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
layout: layout,
focused: focused,
vRatio: vRatio,
hRatio: hRatio,
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
}));
} catch (e) {}
}
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
var slotHeads = [], slotEmpties = [];
for (var si = 0; si < 4; si++) {
(function (idx) {
var h = document.createElement('div');
h.className = 'ws-slot-head';
var nm = document.createElement('span'); nm.className = 'sh-name';
var rl = document.createElement('span'); rl.className = 'sh-btn sh-reload'; rl.textContent = '↻'; rl.title = '이 칸 새로고침';
var cl = document.createElement('span'); cl.className = 'sh-btn sh-clear'; cl.textContent = '×'; cl.title = '이 칸 비우기';
h.appendChild(nm); h.appendChild(rl); h.appendChild(cl);
h.addEventListener('click', function (e) {
if (e.target === rl) { var sid = slots[idx]; if (sid) reloadTab(sid); return; }
if (e.target === cl) { slots[idx] = null; render(); return; }
focusSlot(idx);
});
panels.appendChild(h);
var emp = document.createElement('div');
emp.className = 'ws-slot-empty';
emp.textContent = ' 위 탭에서 이 칸에 표시할 화면을 선택하세요';
emp.addEventListener('click', function () { focusSlot(idx); });
panels.appendChild(emp);
slotHeads.push(h); slotEmpties.push(emp);
})(si);
}
// 분할 구분선(드래그 핸들) + 드래그용 투명 오버레이
var vGutter = document.createElement('div'); vGutter.className = 'ws-gutter v';
var hGutter = document.createElement('div'); hGutter.className = 'ws-gutter h';
var dragOverlay = document.createElement('div'); dragOverlay.className = 'ws-drag-overlay';
panels.appendChild(vGutter); panels.appendChild(hGutter); panels.appendChild(dragOverlay);
function clampRatio(r) { return Math.min(RATIO_MAX, Math.max(RATIO_MIN, r)); }
function startDrag(axis) {
dragOverlay.className = 'ws-drag-overlay ' + axis;
dragOverlay.style.display = 'block';
function move(ev) {
var rect = panels.getBoundingClientRect();
if (axis === 'v') vRatio = clampRatio((ev.clientX - rect.left) / rect.width);
else hRatio = clampRatio((ev.clientY - rect.top) / rect.height);
render();
}
function up() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
dragOverlay.style.display = 'none';
persist();
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
}
vGutter.addEventListener('mousedown', function (e) { e.preventDefault(); startDrag('v'); });
hGutter.addEventListener('mousedown', function (e) { e.preventDefault(); startDrag('h'); });
// 더블클릭 시 50%로 초기화
vGutter.addEventListener('dblclick', function () { vRatio = 0.5; render(); persist(); });
hGutter.addEventListener('dblclick', function () { hRatio = 0.5; render(); persist(); });
var layoutBtns = Array.prototype.slice.call(document.querySelectorAll('#wsLayout button'));
// CSS 길이 가감 (calc(0 + 28px) 같은 무효 표현 방지)
function addPx(val, px) { return val === '0' ? (px + 'px') : ('calc(' + val + ' + ' + px + 'px)'); }
function subPx(val, px) { return 'calc(' + val + ' - ' + px + 'px)'; }
function render() {
var rects = rectsFor(layout);
var n = rects.length;
var split = layout !== 'single';
// single 모드에서 포커스 칸이 비면 가장 최근 탭으로 자동 채움
if (!split && !(slots[0] && tabs[slots[0]]) && order.length) {
slots[0] = order[order.length - 1];
}
if (focused >= n) focused = n - 1;
var shown = {};
for (var i = 0; i < 4; i++) {
var head = slotHeads[i], emp = slotEmpties[i];
if (i >= n) { head.style.display = 'none'; emp.style.display = 'none'; continue; }
var r = rects[i];
var bodyTop = split ? addPx(r.t, 28) : r.t;
var bodyH = split ? subPx(r.h, 28) : r.h;
if (split) {
head.style.display = 'flex';
head.style.left = r.l; head.style.top = r.t; head.style.width = r.w;
head.classList.toggle('focused', i === focused);
var sid = slots[i];
head.querySelector('.sh-name').textContent = (sid && tabs[sid]) ? tabs[sid].title : '비어 있음';
} else {
head.style.display = 'none';
}
var id = slots[i];
if (id && tabs[id]) {
shown[id] = true;
var f = tabs[id].frame;
f.style.left = r.l; f.style.top = bodyTop; f.style.width = r.w; f.style.height = bodyH;
f.style.display = 'block';
emp.style.display = 'none';
} else if (split) {
emp.style.display = 'flex';
emp.style.left = r.l; emp.style.top = bodyTop; emp.style.width = r.w; emp.style.height = bodyH;
} else {
emp.style.display = 'none';
}
}
// 어느 칸에도 없는 iframe 은 숨김 / 포커스 칸 프레임에 active 표시
Object.keys(tabs).forEach(function (k) {
if (!shown[k]) tabs[k].frame.style.display = 'none';
tabs[k].frame.classList.toggle('active', k === slots[focused]);
});
// 탭 버튼 강조 (표시 중 = active, 포커스 칸 = focused-tab)
Object.keys(tabs).forEach(function (k) {
var pos = slots.indexOf(k);
tabs[k].btn.classList.toggle('active', pos >= 0 && pos < n);
tabs[k].btn.classList.toggle('focused-tab', split && slots[focused] === k);
});
// 분할 구분선 위치/표시
if (layout === 'lr' || layout === 'quad') { vGutter.style.display = 'block'; vGutter.style.left = 'calc(' + pct(vRatio) + ' - 4px)'; }
else { vGutter.style.display = 'none'; }
if (layout === 'tb' || layout === 'quad') { hGutter.style.display = 'block'; hGutter.style.top = 'calc(' + pct(hRatio) + ' - 4px)'; }
else { hGutter.style.display = 'none'; }
// 레이아웃 버튼 강조
layoutBtns.forEach(function (b) { b.classList.toggle('active', b.getAttribute('data-mode') === layout); });
// 전체 빈 상태
if (empty) empty.style.display = order.length === 0 ? 'flex' : 'none';
// 포커스 칸 화면에 맞춰 좌측 사이드바 동기화
var fid = slots[focused];
try { if (window.govPortalNav) window.govPortalNav.syncByUrl(fid && tabs[fid] ? tabs[fid].url : ''); } catch (e) {}
persist();
}
function focusSlot(i) { focused = i; render(); }
// 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
function placeInFocused(id) {
if (!tabs[id]) return;
if (layout === 'single') { slots = [id]; }
else {
var j = slots.indexOf(id);
if (j >= 0 && j !== focused) { var tmp = slots[focused]; slots[focused] = id; slots[j] = tmp; }
else { slots[focused] = id; }
}
render();
}
function setLayout(mode) {
var n = rectsFor(mode).length;
var keep = [], seen = {};
slots.forEach(function (id) { if (id && tabs[id] && !seen[id]) { seen[id] = true; keep.push(id); } });
var ns = keep.slice(0, n);
for (var i = 0; i < order.length && ns.length < n; i++) { if (ns.indexOf(order[i]) < 0) ns.push(order[i]); }
while (ns.length < n) ns.push(null);
slots = ns;
layout = mode;
if (focused >= n) focused = n - 1;
render();
}
layoutBtns.forEach(function (b) {
b.addEventListener('click', function () { setLayout(b.getAttribute('data-mode')); });
});
function reloadTab(id) {
var t = tabs[id];
if (!t) return;
try { t.frame.contentWindow.location.reload(); }
catch (e) { t.frame.src = t.frame.src; }
}
function closeTab(id) {
if (!tabs[id]) return;
tabs[id].frame.remove();
tabs[id].btn.remove();
delete tabs[id];
order = order.filter(function (x) { return x !== id; });
for (var i = 0; i < slots.length; i++) { if (slots[i] === id) slots[i] = null; }
render();
}
function openTab(url, title, opts) {
var id = norm(url);
if (tabs[id]) { if (!(opts && opts.noFocus)) placeInFocused(id); return; }
if (order.length >= MAX) { closeTab(order[0]); } // 오래된 탭 정리
var frame = document.createElement('iframe');
frame.className = 'ws-frame';
frame.src = withEmbed(url);
frame.setAttribute('title', title || '탭');
// iframe 내부 포커스에서도 단축키 동작 + 칸 클릭 시 그 칸 포커스
frame.addEventListener('load', function () {
try {
var d = frame.contentDocument;
d.addEventListener('keydown', handleShortcut);
d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true);
} catch (e) {}
});
panels.appendChild(frame);
var btn = document.createElement('div');
btn.className = 'ws-tab';
btn.setAttribute('role', 'tab');
btn.title = title || '탭';
var nameSpan = document.createElement('span');
nameSpan.className = 't-name';
nameSpan.textContent = title || '탭';
var refresh = document.createElement('span');
refresh.className = 't-refresh';
refresh.textContent = '↻';
refresh.title = '이 탭 새로고침';
var close = document.createElement('span');
close.className = 't-close';
close.textContent = '×';
close.title = '탭 닫기';
btn.appendChild(nameSpan); btn.appendChild(refresh); btn.appendChild(close);
btn.addEventListener('click', function (e) {
if (e.target === close) { closeTab(id); }
else if (e.target === refresh) { placeInFocused(id); reloadTab(id); }
else { placeInFocused(id); }
});
btn.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
btn.addEventListener('auxclick', function (e) { if (e.button === 1) { e.preventDefault(); closeTab(id); } });
bar.appendChild(btn);
tabs[id] = { url: url, title: title, frame: frame, btn: btn };
order.push(id);
if (!(opts && opts.noFocus)) placeInFocused(id);
}
window.wsOpenTab = openTab;
// 키보드 단축키 (Alt 기반, 포커스 칸 기준)
// Alt+1~9: 번호 탭을 포커스 칸에 · Alt+W: 포커스 칸 탭 닫기 · Alt+[ / Alt+]: 포커스 칸 탭 전환
function handleShortcut(e) {
if (!e.altKey || e.ctrlKey || e.metaKey) return;
var k = e.key;
if (k >= '1' && k <= '9') {
var i = parseInt(k, 10) - 1;
if (order[i]) { e.preventDefault(); placeInFocused(order[i]); }
} else if (k === 'w' || k === 'W' || k === 'ㅈ') {
var fid = slots[focused];
if (fid) { e.preventDefault(); closeTab(fid); }
} else if (k === '[' || k === ']') {
var cur = order.indexOf(slots[focused]);
if (order.length < 2) return;
e.preventDefault();
if (cur < 0) cur = 0;
var nx = k === ']' ? (cur + 1) % order.length : (cur - 1 + order.length) % order.length;
placeInFocused(order[nx]);
}
}
document.addEventListener('keydown', handleShortcut);
// 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
document.querySelector('.sidebar').addEventListener('click', function (e) {
var a = e.target.closest('a[href]');
if (!a) return;
var href = a.getAttribute('href') || '';
if (a.closest('.sb-gray') || a.closest('.sb-teal')) return;
if (/\/logout|\/login/.test(href) || href.charAt(0) === '#' || href === '') return;
e.preventDefault();
openTab(href, (a.textContent || '').trim());
});
// 상단 대메뉴 직접 링크도 포커스 칸에 열기
document.querySelectorAll('.portal-nav-link[href]').forEach(function (lnk) {
lnk.addEventListener('click', function (e) {
var href = lnk.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') return;
e.preventDefault();
openTab(href, (lnk.textContent || '').trim());
});
});
// 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할
(function restore() {
var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
if (saved && saved.tabs && saved.tabs.length) {
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';
if (typeof saved.vRatio === 'number') vRatio = clampRatio(saved.vRatio);
if (typeof saved.hRatio === 'number') hRatio = clampRatio(saved.hRatio);
var n = rectsFor(layout).length;
slots = [];
for (var i = 0; i < n; i++) {
var u = saved.slots && saved.slots[i] ? norm(saved.slots[i]) : null;
slots.push(u && tabs[u] ? u : null);
}
focused = Math.min(Math.max(parseInt(saved.focused, 10) || 0, 0), n - 1);
render();
return;
}
openTab('<?= base_url('/') ?>', '업무 현황');
})();
})();
</script>
</body>
</html>

View File

@@ -13,7 +13,7 @@ $mbName = session()->get('mb_name') ?? '담당자';
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 업무 현황</title>
<title>GBLS — 업무 현황</title>
<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://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>

View File

@@ -18,7 +18,7 @@ $dashBlend = base_url('dashboard/blend');
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 통계·그래프 현황</title>
<title>GBLS — 통계·그래프 현황</title>
<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://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>

View File

@@ -73,7 +73,7 @@ $notices = [
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 종합 현황 (정보집약)</title>
<title>GBLS — 종합 현황 (정보집약)</title>
<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://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>

View File

@@ -18,7 +18,7 @@ $dashBlend = base_url('dashboard/blend');
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 업무 현황 (모던)</title>
<title>GBLS — 업무 현황 (모던)</title>
<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://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>

View File

@@ -178,7 +178,7 @@ $lowStock = [
<i class="fa-solid fa-building-columns"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">종량제 시스템 · 포털</p>
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">GBLS · 포털</p>
<p class="text-[11px] text-gray-500 truncate">기본 · 변형(strip) 시안</p>
</div>
</a>

View File

@@ -17,6 +17,7 @@ $slugs = array_keys($pages);
$pos = array_search($current, $slugs, true);
$prevSlug = ($pos !== false && $pos > 0) ? $slugs[$pos - 1] : null;
$nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : null;
$searchQ = (string) (service('request')->getGet('q') ?? '');
?>
<style>
/* 매뉴얼 본문 스코프 스타일 (사이트 레이아웃 Tailwind 에 typography 플러그인 없음) */
@@ -54,6 +55,16 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
<!-- 좌측 목차 -->
<nav class="manual-toc no-print w-56 shrink-0 sticky top-0 self-start">
<div class="bg-title-bar text-white text-sm font-bold px-3 py-2 rounded-t">사용자 매뉴얼</div>
<!-- 매뉴얼 전체 검색 -->
<div class="manual-search" style="position:relative;padding:6px;border:1px solid #d1d5db;border-top:0;background:#fff;">
<div style="position:relative;">
<input id="manualSearchInput" type="search" autocomplete="off" placeholder="매뉴얼 검색 (예: LOT, 불출)"
value="<?= esc($searchQ, 'attr') ?>"
class="w-full border border-gray-300 rounded px-2 py-1.5 pr-7 text-xs focus:outline-none focus:ring-2 focus:ring-[#243a5e]/30"/>
<i class="fa-solid fa-magnifying-glass" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:11px;"></i>
</div>
<ul id="manualSearchResults" class="hidden" style="position:absolute;left:6px;right:6px;top:100%;z-index:40;max-height:300px;overflow-y:auto;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.12);margin:2px 0 0;padding:0;list-style:none;"></ul>
</div>
<ul class="border border-t-0 border-gray-300 rounded-b divide-y divide-gray-100 bg-white text-sm">
<?php foreach ($pages as $slug => $p): ?>
<li>
@@ -90,3 +101,75 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
</div>
</div>
</div>
<script>
(function () {
// 현재 출처 기준 경로(앱 baseURL 호스트와 접속 호스트가 달라도 동일 출처로 요청)
var SEARCH_URL = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/search'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var BASE = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var Q0 = <?= json_encode($searchQ, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
var input = document.getElementById('manualSearchInput');
var box = document.getElementById('manualSearchResults');
function esc(s) { return String(s || '').replace(/[&<>]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]; }); }
function doSearch(q) {
q = (q || '').trim();
if (!q) { box.classList.add('hidden'); box.innerHTML = ''; return; }
fetch(SEARCH_URL + '?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (d) {
var res = (d && d.results) || [];
if (!res.length) {
box.innerHTML = '<li style="padding:7px 10px;color:#9ca3af;font-size:12px;">검색 결과가 없습니다.</li>';
box.classList.remove('hidden'); return;
}
box.innerHTML = res.map(function (m) {
return '<li><a href="' + BASE + encodeURIComponent(m.slug) + '?q=' + encodeURIComponent(q) +
'" style="display:block;padding:7px 10px;border-bottom:1px solid #f1f5f9;text-decoration:none;">' +
'<span style="font-size:12px;font-weight:700;color:#1a2b4b;">' + esc(m.title) +
' <span style="color:#94a3b8;font-weight:400;">(' + (m.hits || 1) + ')</span></span>' +
'<span style="display:block;font-size:11px;color:#64748b;margin-top:2px;line-height:1.4;">' + esc(m.snippet) + '</span></a></li>';
}).join('');
box.classList.remove('hidden');
})
.catch(function () {});
}
if (input) {
var timer = null;
input.addEventListener('input', function () { clearTimeout(timer); var v = input.value; timer = setTimeout(function () { doSearch(v); }, 250); });
input.addEventListener('focus', function () { if (input.value.trim()) doSearch(input.value); });
document.addEventListener('click', function (e) { if (!e.target.closest('.manual-search')) box.classList.add('hidden'); });
}
// 현재 페이지 본문에서 검색어 하이라이트
if (Q0 && Q0.trim()) {
var prose = document.querySelector('.manual-prose');
if (prose) {
var needle = Q0.trim().toLowerCase();
var walker = document.createTreeWalker(prose, NodeFilter.SHOW_TEXT, null);
var nodes = [], first = null;
while (walker.nextNode()) { nodes.push(walker.currentNode); }
nodes.forEach(function (node) {
var t = node.nodeValue, lt = t.toLowerCase(), idx = lt.indexOf(needle);
if (idx < 0) return;
var frag = document.createDocumentFragment(), last = 0;
while (idx >= 0) {
frag.appendChild(document.createTextNode(t.slice(last, idx)));
var mark = document.createElement('mark');
mark.textContent = t.slice(idx, idx + needle.length);
mark.style.background = '#fde68a';
frag.appendChild(mark);
if (!first) first = mark;
last = idx + needle.length;
idx = lt.indexOf(needle, last);
}
frag.appendChild(document.createTextNode(t.slice(last)));
node.parentNode.replaceChild(frag, node);
});
if (first) { try { first.scrollIntoView({ block: 'center' }); } catch (e) {} }
}
}
})();
</script>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>종량제 시스템 봉투 수불 현황</title>
<title>GBLS 봉투 수불 현황</title>
<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://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">

View File

@@ -6,9 +6,12 @@ $href = $href ?? base_url();
/** @var string $linkClass Anchor + inner flex typography */
$linkClass = $linkClass ?? 'app-brand flex items-center gap-2 shrink-0 text-base font-semibold text-gray-800 tracking-tight hover:text-blue-600';
?>
<a href="<?= esc($href) ?>" class="<?= esc($linkClass, 'attr') ?>" title="종량제 시스템">
<a href="<?= esc($href) ?>" class="<?= esc($linkClass, 'attr') ?>" title="GBLS (Garbage Bag Logistics System)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-blue-900 translate-y-[1px] 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>
<span class="leading-none flex flex-col">
<strong class="font-extrabold tracking-wide">GBLS</strong>
<span class="text-[0.56rem] font-medium text-gray-400 tracking-tight whitespace-nowrap">Garbage Bag Logistics System</span>
</span>
</a>

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
/**
* 종량제 시스템 — 미니멀 에코 마크 (링 + 잎)
* GBLS — 미니멀 에코 마크 (링 + 잎)
*
* @var string $svgClass Tailwind classes for the SVG root
*/

View File

@@ -11,5 +11,8 @@ $brandHref = $brandHref ?? base_url('dashboard/gov-portal');
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" 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>종량제 시스템</span>
<span style="display:inline-flex;flex-direction:column;line-height:1.02;">
<strong style="font-size:1.02rem;font-weight:800;letter-spacing:.5px;">GBLS</strong>
<span style="font-size:.56rem;font-weight:500;opacity:.72;letter-spacing:.1px;white-space:nowrap;">Garbage Bag Logistics System</span>
</span>
</a>

View File

@@ -1,3 +1,3 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템</title>
<title>GBLS</title>

View File

@@ -7,7 +7,7 @@
var titleEl = document.getElementById('portalSidebarTitle');
if (listEl && navData.length) {
function renderSidebar(idx) {
function renderSidebar(idx, overrideHref) {
var parent = navData[idx];
if (!parent) return;
if (titleEl) titleEl.textContent = parent.name || 'MY MENU';
@@ -22,15 +22,17 @@
listEl.appendChild(empty);
return;
}
// overrideHref 가 빈 문자열이면 "이 그룹에 일치 항목 없음" → 첫 항목 강조하지 않음
var hasOverride = (typeof overrideHref === 'string');
var activeHref = hasOverride ? overrideHref : activeChildHref;
items.forEach(function (child, ci) {
var li = document.createElement('li');
var chHref = (child.href || '').toLowerCase().replace(/^\//, '');
var on = activeChildHref ? (chHref === activeChildHref) : (ci === 0);
var on = activeHref ? (chHref === activeHref) : (hasOverride ? false : ci === 0);
if (child.href) {
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' +
'<span class="menu-ico">' + (on ? '×' : '+') + '</span>' + child.name + '</a>';
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + child.name + '</a>';
} else {
li.innerHTML = '<span class="menu-sub" style="opacity:.65;"><span class="menu-ico">+</span>' + child.name + '</span>';
li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + child.name + '</span>';
}
listEl.appendChild(li);
});
@@ -58,6 +60,51 @@
setActiveTrigger(activeIdx);
renderSidebar(activeIdx);
// 워크스페이스 등 외부에서 "현재 보는 화면(URL)"에 맞춰 사이드바를 동기화하기 위한 공개 API
function pathOf(u) {
try { var a = new URL(u, location.origin); return (a.pathname + a.search).toLowerCase(); }
catch (e) { return (u || '').toLowerCase(); }
}
function pathOnly(u) {
try { return new URL(u, location.origin).pathname.toLowerCase().replace(/\/$/, ''); }
catch (e) { return (u || '').toLowerCase(); }
}
// 현재 사이드바의 모든 소메뉴에서 강조(active)를 해제 — 메뉴에 없는 화면(대시보드 등)에서 사용
function clearSidebarActive() {
listEl.querySelectorAll('a').forEach(function (a) {
a.classList.remove('active');
});
}
window.govPortalNav = {
// URL 로 소속 대메뉴/소메뉴를 찾아 사이드바 강조를 갱신. 일치 없으면(대시보드 등) 강조 해제.
syncByUrl: function (url) {
var target = pathOf(url), targetPath = pathOnly(url), fb = null;
for (var p = 0; p < navData.length; p++) {
var par = navData[p];
var kids = (par.children && par.children.length) ? par.children : (par.href ? [par] : []);
for (var i = 0; i < kids.length; i++) {
if (!kids[i].url) continue;
if (pathOf(kids[i].url) === target) { // 정확 일치(경로+쿼리)
setActiveTrigger(p);
renderSidebar(p, (kids[i].href || '').toLowerCase().replace(/^\//, ''));
return true;
}
if (!fb && pathOnly(kids[i].url) === targetPath) { // 경로만 일치(쿼리 무시) 폴백
fb = { p: p, href: (kids[i].href || '').toLowerCase().replace(/^\//, '') };
}
}
}
if (fb) {
setActiveTrigger(fb.p);
renderSidebar(fb.p, fb.href);
return true;
}
// 어느 메뉴와도 일치하지 않으면(예: 업무 현황 대시보드) 화살표 강조 해제
clearSidebarActive();
return false;
}
};
}
var searchInput = document.getElementById('menuSearch') || document.getElementById('menuSearchStrip');

View File

@@ -22,12 +22,11 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
<li>
<?php if ($child['href'] !== ''): ?>
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
<span class="menu-ico"><?= $isChildActive ? '×' : '+' ?></span>
<?= esc($child['name']) ?>
</a>
<?php else: ?>
<span class="menu-sub" style="opacity:.65;cursor:default;">
<span class="menu-ico">+</span><?= esc($child['name']) ?>
<?= esc($child['name']) ?>
</span>
<?php endif; ?>
</li>
@@ -35,7 +34,7 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
<?php elseif ($activeParent['href'] !== ''): ?>
<li>
<a href="<?= esc($activeParent['url']) ?>" class="active">
<span class="menu-ico">×</span><?= esc($activeParent['name']) ?>
<?= esc($activeParent['name']) ?>
</a>
</li>
<?php endif; ?>
@@ -51,9 +50,7 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
<a href="<?= base_url('admin/select-local-government') ?>" style="color:#fff;text-decoration:underline;">지자체 선택</a>
</div>
<div class="sb-links">
<a href="<?= base_url('bag/help') ?>">나의 할일</a>
<a href="<?= base_url('dashboard') ?>">종합·그래프</a>
<a href="<?= base_url('bag/help') ?>">FAQ</a>
<a href="<?= base_url('bag/manual') ?>">사용자 매뉴얼</a>
</div>
</div>
</aside>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title> - 종량제 시스템</title>
<title> - GBLS</title>
<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"/>
@@ -45,6 +45,6 @@ tailwind.config = {
<a href="<?= base_url('logout') ?>" class="inline-block bg-btn-exit text-white px-4 py-2 rounded-sm text-sm shadow hover:bg-red-700 transition">로그아웃</a>
</section>
</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-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">GBLS</footer>
</body>
</html>

View File

@@ -26,11 +26,14 @@ tailwind.config = {
</head>
<body class="bg-portal-bg text-gray-700 flex flex-col 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 text-base font-bold tracking-tight hover:opacity-90">
<a href="<?= base_url() ?>" class="flex items-center gap-2 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
<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>
<span class="leading-none flex flex-col">
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
<span class="text-[0.56rem] font-medium text-white/65 tracking-tight whitespace-nowrap">Garbage Bag Logistics System</span>
</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>

View File

@@ -33,6 +33,35 @@ test.describe('사용자 매뉴얼', () => {
await expect(page.locator('.manual-prose')).toContainText('바코드');
});
test('로그인·회원가입 페이지 렌더 + 승인/2차인증 안내', async ({ page }) => {
await login(page, 'user');
await page.goto('/bag/manual/account');
await expect(page.locator('.manual-prose h1')).toContainText('회원가입');
await expect(page.locator('.manual-prose')).toContainText('승인');
await expect(page.locator('.manual-prose')).toContainText('2차 인증');
await expect(page.locator('.manual-toc a', { hasText: '로그인·회원가입' })).toBeVisible();
});
test('워크스페이스·단축키 페이지 렌더 + 단축키 표 노출', async ({ page }) => {
await login(page, 'user');
await page.goto('/bag/manual/workspace');
await expect(page.locator('.manual-prose h1')).toContainText('워크스페이스');
// 단축키 표 내용 확인
await expect(page.locator('.manual-prose')).toContainText('Alt + W');
await expect(page.locator('.manual-prose')).toContainText('다음 탭');
// 목차에도 새 항목 노출
await expect(page.locator('.manual-toc a', { hasText: '워크스페이스' })).toBeVisible();
});
test('매뉴얼 검색이 단축키 내용을 찾음', async ({ page }) => {
await login(page, 'user');
await page.goto('/bag/manual/overview');
await page.locator('#manualSearchInput').fill('단축키');
await page.waitForTimeout(700);
await expect(page.locator('#manualSearchResults')).toBeVisible();
await expect(page.locator('#manualSearchResults')).toContainText('워크스페이스');
});
test('미등록 slug 는 404', async ({ page }) => {
await login(page, 'user');
const res = await page.goto('/bag/manual/does-not-exist');

95
e2e/qa_sweep.spec.js Normal file
View File

@@ -0,0 +1,95 @@
const { test, expect } = require('@playwright/test');
const { login } = require('./helpers/auth');
/**
* 전체 점검 스윕 — 주요 화면 콘솔에러·차단 오버레이 점검 + 매뉴얼/도움말/워크스페이스 통합 검증.
*/
async function selectDaegu(page) {
await page.goto('/admin/select-local-government');
await page.evaluate(() => {
const r = document.querySelector('input[name="lg_idx"][value="1"]');
if (r) { r.checked = true; r.form.submit(); }
});
await page.waitForTimeout(700);
}
// kakao 외부 SDK 관련(도메인 미등록 환경) 잡음은 제외
function appError(msg) {
return !/kakao|dapi\.kakao|sdk\.js|OPEN_MAP_AND_LOCAL|appkey/i.test(msg);
}
const PAGES = [
'/', '/bag/inventory', '/bag/order/create', '/bag/bag-orders',
'/bag/receiving/scanner', '/bag/receiving/batch', '/bag/sale/designated',
'/bag/issue/create', '/bag/issue', '/bag/flow', '/bag/sales',
'/bag/reports/daily-summary', '/bag/reports/lot-flow', '/bag/reports/returns',
'/bag/bag-prices', '/bag/packaging-units', '/bag/code-kinds',
'/bag/designated-shops', '/bag/designated-shops/browse', '/bag/number-lookup',
'/bag/manual', '/admin', '/admin/menus', '/admin/users', '/admin/access/login-history',
];
test.describe('QA 스윕', () => {
test('주요 화면 콘솔 에러·차단 오버레이 점검', async ({ page }) => {
await login(page, 'admin');
await selectDaegu(page);
const problems = [];
for (const url of PAGES) {
const errs = [];
page.removeAllListeners('pageerror');
page.on('pageerror', (e) => { if (appError(String(e))) errs.push(String(e)); });
const res = await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(700);
const status = res ? res.status() : 0;
const cover = await page.evaluate(() => {
const cx = Math.floor(innerWidth / 2), cy = Math.floor(innerHeight / 2);
let n = 0;
document.querySelectorAll('*').forEach((el) => {
const s = getComputedStyle(el);
if ((s.position === 'fixed' || s.position === 'absolute') && s.display !== 'none' &&
s.visibility !== 'hidden' && parseFloat(s.opacity || '1') > 0.1 && s.pointerEvents !== 'none') {
const r = el.getBoundingClientRect();
if (r.width >= innerWidth * 0.85 && r.height >= innerHeight * 0.7 &&
!/portal-header|sidebar|ws-/.test(el.className || '')) n++;
}
});
return n;
});
if (status >= 400) problems.push(`${url} → HTTP ${status}`);
if (errs.length) problems.push(`${url} → JS오류: ${errs[0]}`);
if (cover > 0) problems.push(`${url} → 화면 덮는 오버레이 ${cover}`);
}
console.log('>>> SWEEP problems=' + JSON.stringify(problems, null, 0));
expect(problems, problems.join('\n')).toEqual([]);
});
test('매뉴얼 전체 페이지 렌더', async ({ page }) => {
await login(page, 'user');
const slugs = ['overview', 'flow', 'order', 'inventory', 'sales', 'reports', 'basic', 'codes', 'faq'];
for (const s of slugs) {
const res = await page.goto('/bag/manual/' + s, { waitUntil: 'domcontentloaded' });
expect(res.status(), s).toBe(200);
await expect(page.locator('.manual-prose')).not.toBeEmpty();
}
// 미등록 slug → 404
const bad = await page.goto('/bag/manual/zzz-none', { waitUntil: 'domcontentloaded' });
expect(bad.status()).toBe(404);
});
test('이 화면 설명 매핑 정확', async ({ page }) => {
await login(page, 'admin');
await selectDaegu(page);
const cases = [
['/bag/inventory', 'inventory'],
['/bag/order/create', 'order'],
['/bag/sale/designated', 'sales'],
['/bag/flow', 'reports'],
['/bag/bag-prices', 'basic'],
['/bag/number-lookup', 'codes'],
];
for (const [url, slug] of cases) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
const href = await page.locator('a.no-print', { hasText: '이 화면 설명' }).first().getAttribute('href');
expect(href, url).toContain('/bag/manual/' + slug);
}
});
});

View File

@@ -8,14 +8,15 @@ const { login } = require('./helpers/auth');
* - 대메뉴 클릭 → 좌측 사이드바에 소메뉴 표시
*/
test.describe('gov-portal 전면 적용', () => {
test('메인(/)이 포털 대시보드로 렌더', async ({ page }) => {
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.locator('.ws-tabbar')).toBeVisible();
// 첫 탭(대시보드) iframe 안에 실데이터 KPI
await expect(page.frameLocator('.ws-frame.active').getByText('봉투 재고 총량')).toBeVisible({ timeout: 10000 });
// 목업 흔적 없음
await expect(page.getByText('서비스 데스크')).toHaveCount(0);
});

178
e2e/workspace.spec.js Normal file
View File

@@ -0,0 +1,178 @@
const { test, expect } = require('@playwright/test');
const { login } = require('./helpers/auth');
/**
* 워크스페이스(탭) — 메뉴를 탭(iframe)으로 열고 전환해도 작업 상태 유지
*/
test.describe('워크스페이스 탭', () => {
test('탭 열기·전환·상태 유지·닫기', async ({ page }) => {
await login(page, 'admin');
await page.goto('/admin/select-local-government');
await page.evaluate(() => {
const r = document.querySelector('input[name="lg_idx"][value="1"]');
if (r) { r.checked = true; r.form.submit(); }
});
await page.waitForTimeout(700);
await page.goto('/workspace');
await page.waitForTimeout(2500);
// 대시보드 탭이 자동으로 열림
await expect(page.locator('.ws-tab')).toHaveCount(1);
await expect(page.locator('.ws-frame.active')).toBeVisible();
// 대시보드 탭에 입력
await page.frameLocator('.ws-frame.active').locator('#mainMenuSearch').fill('WS_STATE', { timeout: 8000 });
// 사이드바 메뉴 클릭 → 새 탭
await page.locator('.sidebar .my-menu-list a').first().click();
await page.waitForTimeout(1500);
await expect(page.locator('.ws-tab')).toHaveCount(2);
// 첫 탭으로 복귀 → 입력값 유지 확인
await page.locator('.ws-tab').first().click();
await page.waitForTimeout(400);
await expect(page.frameLocator('.ws-frame.active').locator('#mainMenuSearch')).toHaveValue('WS_STATE');
// 탭 닫기
await page.locator('.ws-tab').nth(1).locator('.t-close').click();
await page.waitForTimeout(300);
await expect(page.locator('.ws-tab')).toHaveCount(1);
});
test('관리자 페이지로 이동 후 복귀해도 열어둔 탭 유지', async ({ page }) => {
await login(page, 'admin');
await page.goto('/admin/select-local-government');
await page.evaluate(() => {
const r = document.querySelector('input[name="lg_idx"][value="1"]');
if (r) { r.checked = true; r.form.submit(); }
});
await page.waitForTimeout(700);
await page.goto('/workspace');
await page.waitForTimeout(2500);
// 소메뉴를 탭으로 추가 → 탭 2개
await page.locator('.sidebar .my-menu-list a').first().click();
await page.waitForTimeout(1500);
await expect(page.locator('.ws-tab')).toHaveCount(2);
// 관리자 페이지로 전체 이동(워크스페이스를 떠남)
await page.goto('/admin');
await page.waitForTimeout(800);
// 다시 워크스페이스로 복귀 → 세션에서 탭 복원
await page.goto('/workspace');
await page.waitForTimeout(2500);
await expect(page.locator('.ws-tab')).toHaveCount(2);
});
test('다른 아이디로 로그인하면 이전 탭이 복원되지 않음', async ({ page }) => {
// admin 으로 워크스페이스에서 소메뉴 탭을 추가
await login(page, 'admin');
await page.goto('/admin/select-local-government');
await page.evaluate(() => {
const r = document.querySelector('input[name="lg_idx"][value="1"]');
if (r) { r.checked = true; r.form.submit(); }
});
await page.waitForTimeout(700);
await page.goto('/workspace');
await page.waitForTimeout(2500);
await page.locator('.sidebar .my-menu-list a').first().click();
await page.waitForTimeout(1500);
await expect(page.locator('.ws-tab')).toHaveCount(2);
// 같은 브라우저 탭에서 로그아웃 → 다른 아이디(local)로 로그인
await page.goto('/logout');
await page.waitForTimeout(500);
await login(page, 'local');
await page.goto('/workspace');
await page.waitForTimeout(2500);
// 이전 사용자의 탭들은 사라지고 기본 대시보드 탭만 남아야 함
await expect(page.locator('.ws-tab')).toHaveCount(1);
});
test('분할 보기: 2분할(좌우)·4분할에서 여러 화면 동시 표시', async ({ page }) => {
await login(page, 'admin');
await page.goto('/admin/select-local-government');
await page.evaluate(() => {
const r = document.querySelector('input[name="lg_idx"][value="1"]');
if (r) { r.checked = true; r.form.submit(); }
});
await page.waitForTimeout(700);
await page.goto('/workspace');
await page.waitForTimeout(2500);
// 분할 컨트롤 버튼 4개 존재
await expect(page.locator('#wsLayout button')).toHaveCount(4);
// 소메뉴 2개를 탭으로 열기
await page.locator('.sidebar .my-menu-list a').nth(0).click();
await page.waitForTimeout(1200);
await page.locator('.sidebar .my-menu-list a').nth(1).click();
await page.waitForTimeout(1200);
// 단일 모드: 화면에 보이는 프레임은 1개
await expect(page.locator('.ws-frame:visible')).toHaveCount(1);
// 2분할(좌우) → 보이는 프레임 2개 + 칸 헤더 2개
await page.locator('#wsLayout button[data-mode="lr"]').click();
await page.waitForTimeout(600);
await expect(page.locator('.ws-frame:visible')).toHaveCount(2);
await expect(page.locator('.ws-slot-head:visible')).toHaveCount(2);
// 4분할 → 칸 헤더 4개
await page.locator('#wsLayout button[data-mode="quad"]').click();
await page.waitForTimeout(600);
await expect(page.locator('.ws-slot-head:visible')).toHaveCount(4);
// 다시 1분할 → 보이는 프레임 1개
await page.locator('#wsLayout button[data-mode="single"]').click();
await page.waitForTimeout(600);
await expect(page.locator('.ws-frame:visible')).toHaveCount(1);
await expect(page.locator('.ws-slot-head:visible')).toHaveCount(0);
});
test('편의: 가운데클릭 닫기·사이드바 동기화', async ({ page }) => {
await login(page, 'admin');
await page.goto('/admin/select-local-government');
await page.evaluate(() => {
const r = document.querySelector('input[name="lg_idx"][value="1"]');
if (r) { r.checked = true; r.form.submit(); }
});
await page.waitForTimeout(700);
await page.goto('/workspace');
await page.waitForTimeout(2500);
await expect(page.locator('.ws-tab')).toHaveCount(1);
// 대시보드(업무 현황) 탭은 메뉴에 없으므로 사이드바에 active(▸) 가 없어야 함
await expect(page.locator('.sidebar .my-menu-list a.active')).toHaveCount(0);
// 소메뉴를 탭으로 열기 → 그 메뉴가 사이드바에서 active(▸) 로 강조됨
const firstMenu = page.locator('.sidebar .my-menu-list a').first();
const menuText = (await firstMenu.textContent() || '').trim();
await firstMenu.click();
await page.waitForTimeout(1500);
await expect(page.locator('.ws-tab')).toHaveCount(2);
const activeLink = page.locator('.sidebar .my-menu-list a.active');
await expect(activeLink).toHaveCount(1);
await expect(activeLink).toContainText(menuText.replace(/^[▸·]\s*/, ''));
// 대시보드 탭으로 전환 → active(▸) 강조가 해제되어야 함 (탭↔사이드바 동기화)
await page.locator('.ws-tab').nth(0).click();
await page.waitForTimeout(500);
await expect(page.locator('.sidebar .my-menu-list a.active')).toHaveCount(0);
// 메뉴 탭으로 다시 전환 → active(▸) 가 그 메뉴로 복귀
await page.locator('.ws-tab').nth(1).click();
await page.waitForTimeout(500);
await expect(page.locator('.sidebar .my-menu-list a.active')).toHaveCount(1);
// 두 번째 탭 가운데(휠) 클릭으로 닫기
await page.locator('.ws-tab').nth(1).click({ button: 'middle' });
await page.waitForTimeout(400);
await expect(page.locator('.ws-tab')).toHaveCount(1);
});
});